diff --git a/prisma/migrations/20260802235729_add_purchase_order/migration.sql b/prisma/migrations/20260802235729_add_purchase_order/migration.sql new file mode 100644 index 0000000..35aba7e --- /dev/null +++ b/prisma/migrations/20260802235729_add_purchase_order/migration.sql @@ -0,0 +1,18 @@ +-- CreateTable +CREATE TABLE "purchase_orders" ( + "id" UUID NOT NULL, + "vendor_id" UUID NOT NULL, + "order_date" TIMESTAMPTZ(6) NOT NULL, + "expected_date" TIMESTAMPTZ(6) NOT NULL, + "received_at" TIMESTAMPTZ(6), + "received_by" UUID, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "purchase_orders_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "purchase_orders" ADD CONSTRAINT "purchase_orders_vendor_id_fkey" FOREIGN KEY ("vendor_id") REFERENCES "vendors"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "purchase_orders" ADD CONSTRAINT "purchase_orders_received_by_fkey" FOREIGN KEY ("received_by") REFERENCES "employees"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7a3919c..b6850fe 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -51,14 +51,17 @@ model Request { // possible future normalization, not built yet. `managerId` is a nullable // self-relation: top-level employees (no manager) have `managerId = null`. model Employee { - id String @id @default(uuid()) @db.Uuid - name String - department String - managerId String? @map("manager_id") @db.Uuid - manager Employee? @relation("EmployeeManager", fields: [managerId], references: [id]) - reports Employee[] @relation("EmployeeManager") - requests Request[] - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + id String @id @default(uuid()) @db.Uuid + name String + department String + managerId String? @map("manager_id") @db.Uuid + manager Employee? @relation("EmployeeManager", fields: [managerId], references: [id]) + reports Employee[] @relation("EmployeeManager") + requests Request[] + // Back-relation for PurchaseOrder.receivedById — an Employee can be the + // receiver on multiple POs over time. + purchaseOrdersReceived PurchaseOrder[] + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) @@map("employees") } @@ -130,24 +133,56 @@ model License { // (OEM, e.g. "Dell") — Vendor is the reseller/distributor actually bought // from (e.g. "FPT Trading"), a different concept that isn't always the same // company. `licenses` is the back-relation from the License.vendor -> -// License.vendorId retrofit (same doc section) — PurchaseOrder is a future -// FK from this entity, not built yet. taxCode/notes are nullable — not -// every vendor record has a known VN tax code or extra notes at creation -// time. +// License.vendorId retrofit (same doc section). `purchaseOrders` is the +// back-relation from PurchaseOrder.vendorId (see "## PurchaseOrder (gộp +// Receipt) (chốt 2026-08-02)"). taxCode/notes are nullable — not every +// vendor record has a known VN tax code or extra notes at creation time. model Vendor { - id String @id @default(uuid()) @db.Uuid - name String - contactEmail String @map("contact_email") - contactPhone String @map("contact_phone") - address String - taxCode String? @map("tax_code") - notes String? - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) - licenses License[] + id String @id @default(uuid()) @db.Uuid + name String + contactEmail String @map("contact_email") + contactPhone String @map("contact_phone") + address String + taxCode String? @map("tax_code") + notes String? + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + licenses License[] + purchaseOrders PurchaseOrder[] @@map("vendors") } +// Purchasing document — see docs/asset-license-management.md's "## +// PurchaseOrder (gộp Receipt) (chốt 2026-08-02)". Deliberately absorbs what +// would otherwise be a separate `Receipt` entity: receiving is just part of +// this row's own state (`receivedAt`/`receivedBy`), not a separate table to +// reconcile against. No `status` field — status is inferred from linked +// `Request`s (Ordered -> Received -> Delivered), not stored here, to avoid +// two sources of truth. No cost/price fields — stakeholder said explicitly +// PurchaseOrder doesn't need to track money, only "which asset/license +// belongs to which PO". Receiving is all-or-nothing per PO (no partial +// receipt tracking) — a deliberate simplification, see doc for the +// stakeholder-accepted tradeoff. `receivedAt`/`receivedBy` are both +// nullable and must be co-null/co-set together (both-null = not yet +// received) — enforced at the Zod layer (src/lib/purchase-orders.ts), not a +// DB constraint, same approach as other cross-field invariants in this +// codebase (e.g. Assignment's assetItemId/licenseId XOR, not built yet). +// purchaseOrderId back-references on AssetItem/License/Request (traceability, +// same doc section) are NOT part of this change — out of scope for this task. +model PurchaseOrder { + id String @id @default(uuid()) @db.Uuid + vendorId String @map("vendor_id") @db.Uuid + vendor Vendor @relation(fields: [vendorId], references: [id]) + orderDate DateTime @map("order_date") @db.Timestamptz(6) + expectedDate DateTime @map("expected_date") @db.Timestamptz(6) + receivedAt DateTime? @map("received_at") @db.Timestamptz(6) + receivedById String? @map("received_by") @db.Uuid + receivedBy Employee? @relation(fields: [receivedById], references: [id]) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + + @@map("purchase_orders") +} + // Minimal internal auth accounts — NOT company SSO (deliberately ruled out, // see CLAUDE.md / auth decision notes). A handful of trusted IT staff // accounts, created by hand via scripts/create-user.ts, no self-registration.