Stage M5 and Stage M6

This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:23 +07:00
parent 7688657d3c
commit 624a6402bf
77 changed files with 4879 additions and 176 deletions
@@ -0,0 +1,88 @@
-- CreateEnum
CREATE TYPE "OrderStatus" AS ENUM ('PENDING', 'CONFIRMED', 'FULFILLED', 'COMPLETED', 'CANCELLED');
-- CreateEnum
CREATE TYPE "PaymentStatus" AS ENUM ('UNPAID', 'PAID', 'PARTIALLY_REFUNDED', 'REFUNDED');
-- CreateEnum
CREATE TYPE "FulfillmentStatus" AS ENUM ('UNFULFILLED', 'PARTIALLY_FULFILLED', 'FULFILLED');
-- CreateTable
CREATE TABLE "orders" (
"id" UUID NOT NULL,
"number" SERIAL NOT NULL,
"customer_id" UUID,
"email" VARCHAR(255) NOT NULL,
"phone" VARCHAR(20) NOT NULL,
"status" "OrderStatus" NOT NULL DEFAULT 'PENDING',
"payment_status" "PaymentStatus" NOT NULL DEFAULT 'UNPAID',
"fulfillment_status" "FulfillmentStatus" NOT NULL DEFAULT 'UNFULFILLED',
"currency" "Currency" NOT NULL DEFAULT 'VND',
"subtotal_amount" INTEGER NOT NULL,
"discount_amount" INTEGER NOT NULL DEFAULT 0,
"shipping_amount" INTEGER NOT NULL DEFAULT 0,
"tax_amount" INTEGER NOT NULL DEFAULT 0,
"total_amount" INTEGER NOT NULL,
"ship_full_name" VARCHAR(160) NOT NULL,
"ship_phone" VARCHAR(20) NOT NULL,
"ship_line1" VARCHAR(255) NOT NULL,
"ship_line2" VARCHAR(255),
"ship_ward" VARCHAR(120),
"ship_district" VARCHAR(120),
"ship_province" VARCHAR(120) NOT NULL,
"ship_country_code" CHAR(2) NOT NULL DEFAULT 'VN',
"ship_postal_code" VARCHAR(20),
"customer_note" VARCHAR(1000),
"placed_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"confirmed_at" TIMESTAMPTZ(3),
"cancelled_at" TIMESTAMPTZ(3),
"cancel_reason" VARCHAR(500),
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "orders_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "order_lines" (
"id" UUID NOT NULL,
"order_id" UUID NOT NULL,
"variant_id" UUID,
"product_name" VARCHAR(255) NOT NULL,
"variant_title" VARCHAR(255) NOT NULL,
"sku" VARCHAR(64) NOT NULL,
"image_url" VARCHAR(500),
"unit_amount" INTEGER NOT NULL,
"quantity" INTEGER NOT NULL,
"line_amount" INTEGER NOT NULL,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "order_lines_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "orders_number_key" ON "orders"("number");
-- CreateIndex
CREATE INDEX "orders_customer_id_idx" ON "orders"("customer_id");
-- CreateIndex
CREATE INDEX "orders_email_idx" ON "orders"("email");
-- CreateIndex
CREATE INDEX "orders_status_placed_at_idx" ON "orders"("status", "placed_at");
-- CreateIndex
CREATE INDEX "order_lines_order_id_idx" ON "order_lines"("order_id");
-- CreateIndex
CREATE INDEX "order_lines_variant_id_idx" ON "order_lines"("variant_id");
-- AddForeignKey
ALTER TABLE "orders" ADD CONSTRAINT "orders_customer_id_fkey" FOREIGN KEY ("customer_id") REFERENCES "customers"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "order_lines" ADD CONSTRAINT "order_lines_order_id_fkey" FOREIGN KEY ("order_id") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "order_lines" ADD CONSTRAINT "order_lines_variant_id_fkey" FOREIGN KEY ("variant_id") REFERENCES "product_variants"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,64 @@
-- Full-text search over the catalog, in PostgreSQL (ADR-0012).
--
-- Two extensions do the work that a dedicated search engine would otherwise be
-- brought in for:
-- unaccent — so "ao chay bo" finds "Áo Chạy Bộ". Vietnamese shoppers type
-- without diacritics constantly; without this, most of them find
-- nothing.
-- pg_trgm — trigram similarity, which gives typo tolerance and partial-word
-- matching that a tsquery alone cannot ("nocturn", "jacket run").
CREATE EXTENSION IF NOT EXISTS unaccent;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- `unaccent()` is STABLE, not IMMUTABLE, because it depends on a dictionary that
-- could in principle be changed. Postgres therefore refuses it in a generated
-- column or an index. Pinning the dictionary by name makes the result genuinely
-- immutable, which is the standard way around this.
CREATE OR REPLACE FUNCTION immutable_unaccent(text)
RETURNS text
LANGUAGE sql
IMMUTABLE
STRICT
PARALLEL SAFE
AS $$
SELECT public.unaccent('public.unaccent'::regdictionary, $1)
$$;
-- CreateTable
CREATE TABLE "search_documents" (
"product_id" UUID NOT NULL,
"locale" "Locale" NOT NULL,
"title" VARCHAR(255) NOT NULL,
"keywords" TEXT NOT NULL,
"body" TEXT NOT NULL,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "search_documents_pkey" PRIMARY KEY ("product_id","locale")
);
-- AddForeignKey
ALTER TABLE "search_documents" ADD CONSTRAINT "search_documents_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- The searchable vector is GENERATED, not written by the application.
--
-- That is the whole point: an index maintained by hand drifts from the text it
-- indexes the first time someone updates one and forgets the other. Here it
-- cannot — the database recomputes it on every write to the source columns.
--
-- `simple` rather than `english`: the catalog is bilingual, and English
-- stemming applied to Vietnamese produces nonsense. Weighting carries the
-- relevance instead of stemming.
ALTER TABLE "search_documents"
ADD COLUMN "document" tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('simple', immutable_unaccent(coalesce("title", ''))), 'A') ||
setweight(to_tsvector('simple', immutable_unaccent(coalesce("keywords", ''))), 'B') ||
setweight(to_tsvector('simple', immutable_unaccent(coalesce("body", ''))), 'C')
) STORED;
CREATE INDEX "search_documents_document_idx" ON "search_documents" USING GIN ("document");
-- Trigram index over the high-signal text only. Including `body` would bloat it
-- for matches nobody wants ranked by similarity anyway.
CREATE INDEX "search_documents_trgm_idx" ON "search_documents"
USING GIN ((immutable_unaccent("title") || ' ' || immutable_unaccent("keywords")) gin_trgm_ops);
+167
View File
@@ -208,6 +208,7 @@ model Customer {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
addresses Address[]
orders Order[]
@@map("customers")
}
@@ -504,6 +505,7 @@ model Product {
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
searchDocuments SearchDocument[]
brand Brand? @relation(fields: [brandId], references: [id], onDelete: SetNull)
primaryCategory Category? @relation(fields: [primaryCategoryId], references: [id], onDelete: SetNull)
options ProductOption[]
@@ -601,6 +603,7 @@ model ProductVariant {
optionValues ProductVariantOptionValue[]
stockLevels StockLevel[]
stockMovements StockMovement[]
orderLines OrderLine[]
@@index([productId, position])
@@index([status])
@@ -864,3 +867,167 @@ model ProductAttributeTranslation {
@@id([attributeId, locale])
@@map("product_attribute_translations")
}
// ---------------------------------------------------------------------------
// Commerce — orders (M5)
//
// Carts are deliberately absent: a guest cart lives in Redis and holds only
// variant ids and quantities (ADR-0010). Prices are recomputed from the catalog
// on every read, so a cart can never carry a stale or tampered price into an
// order.
// ---------------------------------------------------------------------------
enum OrderStatus {
/// Placed, awaiting payment. Stock is reserved from this moment.
PENDING
CONFIRMED
FULFILLED
COMPLETED
CANCELLED
}
enum PaymentStatus {
UNPAID
PAID
PARTIALLY_REFUNDED
REFUNDED
}
enum FulfillmentStatus {
UNFULFILLED
PARTIALLY_FULFILLED
FULFILLED
}
/// A placed order.
///
/// Every customer-facing and catalog-facing value is SNAPSHOT here rather than
/// joined at read time. An order is a record of what was agreed, and it has to
/// stay readable after the product is renamed, repriced, archived or the
/// customer edits their address book. The `variantId` FK exists for reporting
/// and returns, never for rendering the order.
model Order {
id String @id @default(uuid(7)) @db.Uuid
/// Human-facing reference, formatted for display as "SP-000123". Kept as an
/// integer so it is monotonic and cheap to look up; the prefix is
/// presentation and lives in the mapper.
number Int @unique @default(autoincrement())
/// Null for a guest checkout. Guests are identified by email + order number.
customerId String? @map("customer_id") @db.Uuid
email String @db.VarChar(255)
phone String @db.VarChar(20)
status OrderStatus @default(PENDING)
paymentStatus PaymentStatus @default(UNPAID) @map("payment_status")
fulfillmentStatus FulfillmentStatus @default(UNFULFILLED) @map("fulfillment_status")
/// ---- Money, integer minor units throughout (ADR-0011) ------------------
currency Currency @default(VND)
subtotalAmount Int @map("subtotal_amount")
/// Zero until promotions land (M7); the column exists so the total is always
/// the sum of named parts rather than an unexplained number.
discountAmount Int @default(0) @map("discount_amount")
shippingAmount Int @default(0) @map("shipping_amount")
taxAmount Int @default(0) @map("tax_amount")
totalAmount Int @map("total_amount")
/// ---- Shipping address, snapshot ----------------------------------------
shipFullName String @map("ship_full_name") @db.VarChar(160)
shipPhone String @map("ship_phone") @db.VarChar(20)
shipLine1 String @map("ship_line1") @db.VarChar(255)
shipLine2 String? @map("ship_line2") @db.VarChar(255)
shipWard String? @map("ship_ward") @db.VarChar(120)
shipDistrict String? @map("ship_district") @db.VarChar(120)
shipProvince String @map("ship_province") @db.VarChar(120)
shipCountryCode String @default("VN") @map("ship_country_code") @db.Char(2)
shipPostalCode String? @map("ship_postal_code") @db.VarChar(20)
customerNote String? @map("customer_note") @db.VarChar(1000)
placedAt DateTime @default(now()) @map("placed_at") @db.Timestamptz(3)
confirmedAt DateTime? @map("confirmed_at") @db.Timestamptz(3)
cancelledAt DateTime? @map("cancelled_at") @db.Timestamptz(3)
cancelReason String? @map("cancel_reason") @db.VarChar(500)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
lines OrderLine[]
@@index([customerId])
@@index([email])
@@index([status, placedAt])
@@map("orders")
}
/// One purchased variant, frozen at the moment of purchase.
model OrderLine {
id String @id @default(uuid(7)) @db.Uuid
orderId String @map("order_id") @db.Uuid
/// Nulled rather than cascading if a variant is ever hard-deleted — losing
/// the reporting link is survivable, losing the order line is not.
variantId String? @map("variant_id") @db.Uuid
/// ---- Snapshot ----------------------------------------------------------
productName String @map("product_name") @db.VarChar(255)
variantTitle String @map("variant_title") @db.VarChar(255)
sku String @db.VarChar(64)
imageUrl String? @map("image_url") @db.VarChar(500)
unitAmount Int @map("unit_amount")
quantity Int
lineAmount Int @map("line_amount")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
variant ProductVariant? @relation(fields: [variantId], references: [id], onDelete: SetNull)
@@index([orderId])
@@index([variantId])
@@map("order_lines")
}
// ---------------------------------------------------------------------------
// Search (M6)
//
// Owned exclusively by SearchModule. It is a *projection*: every row is
// derivable from the catalog and can be rebuilt from scratch at any time, which
// is what lets the module be extracted later without taking catalog tables with
// it (ADR-0012).
// ---------------------------------------------------------------------------
/// One searchable document per product per locale.
///
/// The text is split by weight rather than concatenated, because a match on a
/// product's name should outrank a match buried in its description. The
/// `document` column is GENERATED from these three by the database — see the
/// migration — so an index can never drift from the text it indexes.
model SearchDocument {
productId String @map("product_id") @db.Uuid
locale Locale
/// Weight A — the product name.
title String @db.VarChar(255)
/// Weight B — brand, category, colourways, sizes, SKUs. Short, high-signal.
keywords String
/// Weight C — descriptions. Long, low-signal, still worth matching.
body String
/// Maintained by Postgres from the columns above. Declared here only so
/// Prisma knows it exists and leaves it alone; it is read and written with
/// raw SQL in the repository.
document Unsupported("tsvector")?
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@id([productId, locale])
@@map("search_documents")
}
+5
View File
@@ -312,6 +312,10 @@ async function seedProduct(
primaryCategoryId: refs.categoryId ?? null,
genderTargets: seed.genders,
sportTypes: seed.sports,
// Persisted, not just used to build the seed SKUs: adding a colourway to
// a seeded product later has to extend the same SKU family, and without
// this the write path falls back to the slug.
skuPrefix: seed.skuPrefix,
},
create: {
slug: seed.key,
@@ -324,6 +328,7 @@ async function seedProduct(
primaryCategoryId: refs.categoryId ?? null,
genderTargets: seed.genders,
sportTypes: seed.sports,
skuPrefix: seed.skuPrefix,
},
});