Stage M5 and Stage M6
This commit is contained in:
@@ -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);
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user