Skip to content

Procurement & Supplier Management Module Plan (v2)

This document is a comprehensive revision of the procurement module plan, incorporating research into Medusa v2 module architecture, inventory integration, entity lifecycle management, COA storage/backup, and admin UI patterns.


Changes from v1

Area v1 v2
Supplier status is_active: boolean status enum: active, inactive, suspended, blocked
COA status verified: boolean only status enum: draft, verified, expired, superseded, rejected (retains verified fields for backward compat)
Inventory integration "Inventory quantity updated" (hand-wave) Full Medusa InventoryLevel integration with incoming_quantitystocked_quantity workflow
InboundShipment No location Added location_id for stock location routing
Lot quantity quantity_remaining (dual source of truth) received_quantity is audit-only; Medusa stocked_quantity is authoritative
Entity lifecycle Not addressed Full state machines, transition rules, cascading deactivation
Deletion policy Not addressed Hard-delete vs soft-delete rules per entity
Payment tracking Flat fields on PO (payment_method, payment_reference) Dedicated PurchaseOrderPayment model with full crypto conversion tracking (rate, timestamp, tx hash, network, fees)
Audit trail Not addressed AuditLog model with INSERT-only semantics
COA immutability Not addressed Verified COAs are immutable; corrections require new revision
COA storage Assumed pre-uploaded Full R2 upload pipeline with SHA-256 hashing, versioning, retention policy
Backup Not addressed 3-tier backup (R2 primary → R2 backup → off-provider), integrity checks
Admin UI Page structure only Procurement dashboard with KPIs, guided wizards, activity logs
Price list activation No disable mechanism Added is_active boolean for manual deactivation

1. Overview & Goals

Research Relay sources RUO peptides and research chemicals from third-party suppliers, receives them, performs or verifies quality control, assigns lot numbers, labels vials, and lists them for sale. This module manages the entire inbound supply chain — from supplier relationship through to inventory release.

What This Module Covers

Supplier ──→ Pricelist ──→ Purchase Order ──→ Inbound Shipment ──→
  Reception ──→ QC / Lab Testing ──→ Lot Assignment ──→ COA Attached ──→
    Inventory Released ──→ Vial Labels Printed ──→ Ready for Sale

Key Design Decisions

Decision Choice Rationale
Module name procurement Clear, standard terminology
Lot number generation Auto-generated from PO + product + date Consistent, traceable, no manual errors
COA ↔ Lot relationship Many-to-many (via junction) One supplier COA often covers an entire manufacturing batch that arrives as multiple lots/shipments
Supplier ↔ Product Link at Product level (not variant) You buy "BPC-157" from a supplier; variants are your own sizing/packaging
QC testing Tracked as QcInspection records Separate from supplier COA — your own incoming inspection
Lot status flow pendingquarantinedactiveexpired/recalled Goods arrive in pending, move to quarantined during QC, released to active
Inventory source of truth Medusa InventoryLevel.stocked_quantity Lot received_quantity is audit-only; Medusa inventory is authoritative for availability
Supplier lifecycle 4-state enum (not boolean) Distinguishes business decisions from compliance issues
COA immutability Immutable after verification Corrections require new revision; aligns with 21 CFR Part 11 principles
Soft-delete Medusa built-in (deleted_at) Every model gets soft-delete automatically via model.define()

2. Data Model

2.1 Entity Relationship Diagram

┌──────────────────┐       ┌───────────────────┐       ┌──────────────────┐
│     Supplier     │──1:N──│  SupplierPriceList │──N:1──│     Product      │
│                  │       │                   │       │   (Medusa core)  │
│  name            │       │  supplier_sku     │       │                  │
│  code            │       │  unit_cost        │       └──────────────────┘
│  status          │       │  currency         │
│  contact_*       │       │  effective_date   │
│  lead_time       │       │  is_active        │
│  payment_*       │       │  moq              │
└──────┬───────────┘       └───────────────────┘
       │ 1:N
┌──────────────────┐       ┌──────────────────────┐
│  PurchaseOrder   │──1:N──│  PurchaseOrderLine   │
│                  │       │                      │
│  po_number       │       │  product_id          │
│  supplier_id     │       │  variant_id          │
│  status          │       │  quantity_ordered    │
│  payment_status  │       │  unit_cost           │
│  status_changed_*│       │  quantity_received   │
└──────┬───────────┘       └──────────────────────┘
       │ 1:N
┌──────────────────────────┐
│  PurchaseOrderPayment    │
│                          │
│  amount_usd              │
│  payment_method          │──── crypto / wire / ach / check / etc.
│  crypto_currency         │──── BTC, ETH, USDT, USDC, SOL...
│  crypto_amount           │──── "0.04217391"
│  crypto_rate_usd         │──── USD per 1 coin at conversion
│  crypto_rate_timestamp   │──── exact timestamp of rate quote
│  crypto_tx_hash          │──── on-chain tx proof
│  crypto_network          │──── bitcoin, ethereum, lightning...
│  status                  │──── pending → confirmed / failed
└──────────────────────────┘
       │ 1:N
┌──────────────────────┐
│  InboundShipment     │
│                      │
│  purchase_order_id   │
│  location_id         │──── NEW: target stock location
│  carrier             │
│  tracking_number     │
│  status              │
│  received_at         │──────────────┐
│  received_by         │              │
│  package_condition   │              │ on reception
│  notes               │              │ creates lots
└──────────────────────┘              │
                              ┌───────────────┐
                              │     Lot       │  (existing model, extended)
                              │               │
                              │  lot_number   │──── auto-generated
                              │  status       │──── pending → quarantined → active
                              │  shipment_id  │──── links to inbound shipment
                              │  po_line_id   │──── links to PO line
                              │  supplier_lot │──── manufacturer's lot number
                              └───────┬───────┘
                          ┌───────────┴───────────┐
                          ▼                       ▼
                  ┌───────────────┐      ┌────────────────┐
                  │ QcInspection  │      │   LotCoa       │  (junction)
                  │               │      │                │
                  │  lot_id       │      │  lot_id        │
                  │  inspector    │      │  coa_id        │
                  │  inspected_at │      └────────┬───────┘
                  │  result       │               │
                  │  items[]      │               ▼
                  └───────────────┘       ┌──────────────┐
                                          │     Coa      │  (existing, extended)
                                          │              │
                                          │  status      │──── NEW: draft/verified/expired/...
                                          │  file_url    │
                                          │  file_sha256 │──── NEW: tamper detection
                                          │  revision    │──── NEW: version tracking
                                          │  lab_name    │
                                          │  purity_*    │
                                          └──────────────┘

┌───────────────────┐
│    AuditLog       │  (NEW — INSERT-only)
│                   │
│  entity_type      │
│  entity_id        │
│  action           │
│  field_name       │
│  old_value        │
│  new_value        │
│  actor_id         │
│  context          │
└───────────────────┘

2.2 Supplier

The central entity for vendor management. Uses a 4-state status enum instead of a boolean is_active.

// src/modules/procurement/models/supplier.ts
const Supplier = model.define("supplier", {
  id: model.id().primaryKey(),
  name: model.text(),                            // "PurePeptides Inc."
  code: model.text().unique(),                   // "PP" — short code for lot numbers
  contact_name: model.text().nullable(),
  contact_email: model.text().nullable(),
  contact_phone: model.text().nullable(),
  website: model.text().nullable(),
  address_line1: model.text().nullable(),
  address_line2: model.text().nullable(),
  city: model.text().nullable(),
  state: model.text().nullable(),
  postal_code: model.text().nullable(),
  country: model.text().default("US"),
  payment_terms: model.text().nullable(),        // "Net 30", "Prepaid", "COD"
  default_lead_time_days: model.number().default(14),
  default_currency: model.text().default("usd"),
  account_number: model.text().nullable(),       // Your account # with supplier
  tax_id: model.text().nullable(),               // Supplier's EIN/TIN
  notes: model.text().nullable(),
  status: model.enum([
    "active",       // Normal operations, can place POs
    "inactive",     // Voluntarily paused, no new POs allowed
    "suspended",    // Quality/compliance issue, under review
    "blocked",      // Permanently disqualified
  ]).default("active"),
  status_changed_at: model.dateTime().nullable(),
  status_changed_by: model.text().nullable(),    // Admin user ID
  metadata: model.json().nullable(),
  // Relations
  price_lists: model.hasMany(() => SupplierPriceList),
  purchase_orders: model.hasMany(() => PurchaseOrder),
})

Why an enum instead of boolean:

Status Meaning Can place POs? Can receive shipments?
active Normal operations Yes Yes
inactive Business decision to pause No Yes (existing POs)
suspended Compliance/quality issue under review No Blocked
blocked Permanently disqualified No No

Supplier code field: A short (2-4 character) unique code used in auto-generated lot numbers:

Supplier Name Code
PurePeptides Inc. PP
Biosynth AG BS
Cayman Chemical CC

2.3 Supplier Price List

Tracks what each supplier charges for each product over time. Now includes is_active for manual deactivation.

// src/modules/procurement/models/supplier-price-list.ts
const SupplierPriceList = model.define("supplier_price_list", {
  id: model.id().primaryKey(),
  supplier: model.belongsTo(() => Supplier),
  supplier_sku: model.text().nullable(),         // Supplier's catalog number
  unit_cost: model.bigNumber(),                  // Cost in smallest currency unit (cents)
  currency: model.text().default("usd"),
  unit_quantity: model.text(),                   // "1g", "5mg", "100mg"
  minimum_order_quantity: model.number().default(1),
  effective_date: model.dateTime(),
  expires_at: model.dateTime().nullable(),
  is_active: model.boolean().default(true),      // NEW: manual deactivation
  notes: model.text().nullable(),
  metadata: model.json().nullable(),
})

Effective price query: WHERE is_active = true AND effective_date <= NOW() AND (expires_at IS NULL OR expires_at > NOW())

Link to Medusa Product:

// src/links/supplier-pricelist-product.ts
defineLink(
  ProductModule.linkable.product,
  {
    linkable: ProcurementModule.linkable.supplierPriceList,
    isList: true,
  }
)

2.4 Purchase Order

// src/modules/procurement/models/purchase-order.ts
const PurchaseOrder = model.define("purchase_order", {
  id: model.id().primaryKey(),
  po_number: model.text().unique(),              // Auto-generated: "PO-2026-0042"
  supplier: model.belongsTo(() => Supplier),
  status: model.enum([
    "draft",
    "submitted",
    "confirmed",
    "partially_shipped",
    "shipped",
    "partially_received",
    "received",
    "cancelled",
    "closed",
  ]).default("draft"),
  status_changed_at: model.dateTime().nullable(),
  status_changed_by: model.text().nullable(),    // Admin user ID
  ordered_at: model.dateTime().nullable(),
  expected_at: model.dateTime().nullable(),
  subtotal: model.bigNumber().nullable(),
  shipping_cost: model.bigNumber().nullable(),
  tax_amount: model.bigNumber().nullable(),
  total: model.bigNumber().nullable(),
  currency: model.text().default("usd"),
  payment_status: model.enum([
    "unpaid",
    "partially_paid",
    "paid",
  ]).default("unpaid"),
  notes: model.text().nullable(),
  supplier_reference: model.text().nullable(),
  metadata: model.json().nullable(),
  // Relations
  lines: model.hasMany(() => PurchaseOrderLine),
  shipments: model.hasMany(() => InboundShipment),
  payments: model.hasMany(() => PurchaseOrderPayment),
})

PO Number Format: PO-{YYYY}-{NNNN} — auto-incrementing per year, generated when PO transitions from draft to submitted.

Closing constraint: A PO should not move to closed until payment_status is paid.

2.5 Purchase Order Line

// src/modules/procurement/models/purchase-order-line.ts
const PurchaseOrderLine = model.define("purchase_order_line", {
  id: model.id().primaryKey(),
  purchase_order: model.belongsTo(() => PurchaseOrder),
  quantity_ordered: model.number(),
  unit_cost: model.bigNumber(),
  currency: model.text().default("usd"),
  quantity_received: model.number().default(0),
  supplier_sku: model.text().nullable(),
  notes: model.text().nullable(),
  metadata: model.json().nullable(),
  lots: model.hasMany(() => Lot, { mappedBy: "po_line" }),
})

Links to Medusa Product and Variant:

// src/links/po-line-product.ts
defineLink(ProductModule.linkable.product, ProcurementModule.linkable.purchaseOrderLine)

// src/links/po-line-variant.ts
defineLink(ProductModule.linkable.productVariant, ProcurementModule.linkable.purchaseOrderLine)

2.6 Purchase Order Payment

Tracks individual payments against a PO. Supports multiple partial payments, mixed payment methods (crypto + fiat), and full crypto conversion tracking per IRS Notice 2014-21 (FMV at time of receipt/payment).

// src/modules/procurement/models/purchase-order-payment.ts
const PurchaseOrderPayment = model.define("purchase_order_payment", {
  id: model.id().primaryKey(),
  purchase_order: model.belongsTo(() => PurchaseOrder),

  // USD amount
  amount_usd: model.bigNumber(),                 // Amount in USD cents (what this payment covers)

  // Payment method
  payment_method: model.enum([
    "crypto",          // Any cryptocurrency
    "wire",            // Bank wire transfer
    "ach",             // ACH transfer
    "check",           // Physical check
    "credit_card",     // Credit/debit card
    "other",
  ]),

  // Crypto-specific fields (null for fiat payments)
  crypto_currency: model.text().nullable(),      // "BTC", "ETH", "USDT", "USDC", "SOL", etc.
  crypto_amount: model.text().nullable(),        // Amount in crypto (string for precision: "0.01234567")
  crypto_rate_usd: model.text().nullable(),      // USD price of 1 unit of crypto at conversion time
  crypto_rate_source: model.text().nullable(),   // Where rate was sourced: "coinbase", "kraken", "coingecko", "btcpay"
  crypto_rate_timestamp: model.dateTime().nullable(), // Exact timestamp of the rate quote
  crypto_tx_hash: model.text().nullable(),       // On-chain transaction hash
  crypto_network: model.text().nullable(),       // "bitcoin", "ethereum", "lightning", "solana", "tron"
  crypto_from_address: model.text().nullable(),  // Sending wallet address
  crypto_to_address: model.text().nullable(),    // Receiving wallet address (supplier's)
  crypto_confirmations: model.number().nullable(), // Block confirmations at time of recording
  crypto_fee: model.text().nullable(),           // Network fee paid (in crypto units)
  crypto_fee_usd: model.bigNumber().nullable(),  // Network fee in USD cents

  // Fiat-specific fields (null for crypto payments)
  fiat_reference: model.text().nullable(),       // Wire reference, check #, ACH trace #
  fiat_bank: model.text().nullable(),            // Originating bank or processor

  // Common fields
  paid_at: model.dateTime(),                     // When payment was sent/executed
  confirmed_at: model.dateTime().nullable(),     // When payment was confirmed (crypto: N confirmations; fiat: cleared)
  status: model.enum([
    "pending",         // Payment initiated but not confirmed
    "confirmed",       // Payment confirmed/cleared
    "failed",          // Payment failed or reversed
  ]).default("pending"),
  notes: model.text().nullable(),
  recorded_by: model.text().nullable(),          // Admin user ID who recorded this payment
  metadata: model.json().nullable(),
})

Why a separate model (not flat fields on PO):

Scenario Flat fields (v1) Payment records (v2)
Pay $5,000 PO with 0.05 BTC + $1,200 wire Cannot represent 2 payment records
Pay 50% now, 50% on delivery Cannot represent 2 payment records
BTC payment fails, retry with different coin Cannot represent Failed record + new record
Audit: what was BTC price when we paid? Lost crypto_rate_usd + crypto_rate_timestamp
Tax reporting: FIFO cost basis per payment Impossible Each record has rate + timestamp

Crypto payment example:

PO-2026-0042: $3,800.00 total (supplier: PurePeptides)

Payment 1:
  amount_usd: 380000           (cents)
  payment_method: "crypto"
  crypto_currency: "BTC"
  crypto_amount: "0.04217391"
  crypto_rate_usd: "90100.00"  (BTC/USD at conversion time)
  crypto_rate_source: "kraken"
  crypto_rate_timestamp: "2026-03-05T14:30:00Z"
  crypto_tx_hash: "a1b2c3d4e5..."
  crypto_network: "bitcoin"
  crypto_from_address: "bc1q..."
  crypto_to_address: "bc1q..."  (supplier's address)
  crypto_confirmations: 3
  crypto_fee: "0.00001200"
  crypto_fee_usd: 108           (cents)
  paid_at: "2026-03-05T14:30:00Z"
  confirmed_at: "2026-03-05T15:05:00Z"
  status: "confirmed"

Stablecoin payment example:

Payment 1:
  amount_usd: 380000
  payment_method: "crypto"
  crypto_currency: "USDT"
  crypto_amount: "3800.00"
  crypto_rate_usd: "1.0002"    (USDT/USD — track even for stablecoins)
  crypto_rate_source: "coingecko"
  crypto_network: "tron"       (common for USDT transfers)
  crypto_tx_hash: "..."
  crypto_fee: "1.00"
  crypto_fee_usd: 100

Payment status flow:

pending → confirmed   (normal: crypto gets confirmations, wire clears)
pending → failed      (tx dropped, reversed, or rejected)

PO payment_status is computed from payments:

Condition PO payment_status
No confirmed payments unpaid
Sum of confirmed amount_usd < PO total partially_paid
Sum of confirmed amount_usd >= PO total paid

Accounting integration:

Each confirmed crypto payment record provides exactly what Koinly and Zoho Books need: - crypto_currency + crypto_amount → disposition amount for FIFO cost basis - crypto_rate_usd + crypto_rate_timestamp → FMV at disposal (IRS Notice 2014-21) - crypto_fee + crypto_fee_usd → deductible transaction costs - crypto_tx_hash → on-chain proof for audit

2.7 Inbound Shipment

Now includes location_id to route inventory adjustments to the correct stock location.

// src/modules/procurement/models/inbound-shipment.ts
const InboundShipment = model.define("inbound_shipment", {
  id: model.id().primaryKey(),
  purchase_order: model.belongsTo(() => PurchaseOrder),
  location_id: model.text().nullable(),          // NEW: target Medusa StockLocation ID
  status: model.enum([
    "pending",
    "in_transit",
    "delivered",
    "received",
  ]).default("pending"),
  carrier: model.text().nullable(),
  tracking_number: model.text().nullable(),
  tracking_url: model.text().nullable(),
  shipped_at: model.dateTime().nullable(),
  expected_arrival: model.dateTime().nullable(),
  received_at: model.dateTime().nullable(),
  received_by: model.text().nullable(),
  package_condition: model.enum([
    "good",
    "damaged",
    "tampered",
  ]).nullable(),
  temperature_on_arrival: model.text().nullable(),
  notes: model.text().nullable(),
  metadata: model.json().nullable(),
})

Tracking URL auto-generation:

Carrier URL Template
FedEx https://www.fedex.com/fedextrack/?trknbr={tracking_number}
UPS https://www.ups.com/track?tracknum={tracking_number}
USPS https://tools.usps.com/go/TrackConfirmAction?tLabels={tracking_number}
DHL https://www.dhl.com/us-en/home/tracking.html?tracking-id={tracking_number}

2.8 Lot Model Extensions

The existing Lot model gets new procurement fields. received_quantity is audit-only — Medusa's InventoryLevel.stocked_quantity is the authoritative source for sellable inventory.

// Extended fields on existing Lot model
{
  // ... existing fields (lot_number, manufacture_date, expiration_date, etc.) ...

  // UPDATED statuses (add "pending" and "rejected")
  status: model.enum([
    "pending",       // Just received, not yet inspected
    "quarantined",   // Under QC inspection/testing
    "active",        // QC passed, released for sale
    "rejected",      // QC failed, flagged for return/disposal
    "expired",       // Past expiration date
    "recalled",      // Recalled due to quality/safety issue
  ]),

  // NEW procurement fields
  supplier_lot_number: model.text().nullable(),
  po_line: model.belongsTo(() => PurchaseOrderLine).nullable(),
  shipment: model.belongsTo(() => InboundShipment).nullable(),
  received_at: model.dateTime().nullable(),
  received_quantity: model.number().nullable(),  // AUDIT-ONLY — not used for availability
  status_changed_at: model.dateTime().nullable(),
  status_changed_by: model.text().nullable(),
}

Alternative (decoupled) approach: If modifying the existing compliance module is undesirable, create a LotProcurementData model in the procurement module linked via Module Links:

// src/modules/procurement/models/lot-procurement-data.ts
const LotProcurementData = model.define("lot_procurement_data", {
  id: model.id().primaryKey(),
  supplier_lot_number: model.text().nullable(),
  received_at: model.dateTime().nullable(),
  received_quantity: model.number().nullable(),
  metadata: model.json().nullable(),
})

// src/links/lot-procurement.ts
defineLink(ProductComplianceModule.linkable.lot, ProcurementModule.linkable.lotProcurementData)
defineLink(ProcurementModule.linkable.purchaseOrderLine, { linkable: ProductComplianceModule.linkable.lot, isList: true })
defineLink(ProcurementModule.linkable.inboundShipment, { linkable: ProductComplianceModule.linkable.lot, isList: true })

2.9 COA Model Extensions

The COA model gets a status enum, tamper-detection hash, and version tracking. The existing verified/verified_by/verified_at fields are retained for backward compatibility.

// Extended fields on existing Coa model
{
  // ... existing fields (file_url, file_key, purity_percentage, etc.) ...

  // NEW fields
  status: model.enum([
    "draft",         // Uploaded but not reviewed
    "verified",      // Reviewed and confirmed authentic
    "expired",       // Lot expired or COA past retention
    "superseded",    // Replaced by updated analysis
    "rejected",      // Found to be invalid/fraudulent
  ]).default("draft"),
  file_sha256: model.text().nullable(),          // SHA-256 hash for tamper detection
  revision: model.number().default(1),           // Version number, incremented on re-upload
  superseded_by: model.text().nullable(),         // ID of the replacement COA
  retention_until: model.dateTime().nullable(),   // Calculated retention date

  // EXISTING fields retained
  verified: model.boolean().default(false),       // true when status = "verified"
  verified_by: model.text().nullable(),
  verified_at: model.dateTime().nullable(),
}

Immutability rule: Once status = "verified", the API rejects modifications to file_url, file_key, purity_percentage, analysis_date, lab_name, lab_reference, and purity_records. If a correction is needed, a new COA revision must be created and the old one marked superseded.

2.10 COA ↔ Lot: Many-to-Many Relationship

Current state: Each COA belongsTo exactly one Lot.

Problem: A supplier manufactures a batch and splits it across multiple shipments. Each shipment becomes its own lot, but they share a single supplier COA.

Solution: Junction table.

// src/modules/product-compliance/models/lot-coa.ts
const LotCoa = model.define("lot_coa", {
  id: model.id().primaryKey(),
  lot: model.belongsTo(() => Lot),
  coa: model.belongsTo(() => Coa),
})

Migration path:

  1. Create the lot_coa junction table
  2. Populate from existing COA records (each COA.lot_id → row in lot_coa)
  3. Remove the lot FK from the COA model
  4. Update models to use manyToMany via LotCoa
  5. Update all API endpoints and admin UI

2.11 QC Inspection

// src/modules/procurement/models/qc-inspection.ts
const QcInspection = model.define("qc_inspection", {
  id: model.id().primaryKey(),
  inspection_number: model.text().unique(),      // "QC-2026-0001"
  status: model.enum([
    "pending",
    "in_progress",
    "passed",
    "failed",
    "conditional",
  ]).default("pending"),
  inspector: model.text().nullable(),
  inspected_at: model.dateTime().nullable(),
  notes: model.text().nullable(),
  result_summary: model.text().nullable(),
  metadata: model.json().nullable(),
  items: model.hasMany(() => QcInspectionItem),
})

// src/modules/procurement/models/qc-inspection-item.ts
const QcInspectionItem = model.define("qc_inspection_item", {
  id: model.id().primaryKey(),
  qc_inspection: model.belongsTo(() => QcInspection),
  parameter: model.text(),                       // "Appearance", "Purity", "Weight"
  test_method: model.text().nullable(),          // "Visual", "HPLC", "MS"
  expected_value: model.text().nullable(),
  observed_value: model.text(),
  unit: model.text().nullable(),
  passes: model.boolean().default(true),
  notes: model.text().nullable(),
  metadata: model.json().nullable(),
})

Link to Lot:

// src/links/lot-qc-inspection.ts
defineLink(
  ProductComplianceModule.linkable.lot,
  { linkable: ProcurementModule.linkable.qcInspection, isList: true }
)

2.12 Audit Log

A dedicated, INSERT-only audit trail for compliance-critical status changes.

// src/modules/procurement/models/audit-log.ts
const AuditLog = model.define("audit_log", {
  id: model.id().primaryKey(),
  entity_type: model.text(),          // "supplier", "purchase_order", "lot", "coa"
  entity_id: model.text(),
  action: model.text(),               // "status_changed", "created", "soft_deleted", "verified"
  field_name: model.text().nullable(), // "status", "payment_status", etc.
  old_value: model.text().nullable(),
  new_value: model.text().nullable(),
  actor_id: model.text().nullable(),   // Admin user ID (from req.auth_context.actor_id)
  actor_type: model.text().default("admin"), // "admin", "system", "scheduled_job"
  context: model.json().nullable(),    // Additional context (reason, IP, etc.)
  metadata: model.json().nullable(),
})

Implementation rules:

  1. INSERT-only — the service exposes only createAuditLogs and listAuditLogs/retrieveAuditLog. No update or delete methods.
  2. Event-driven — populated via Medusa subscribers listening for entity events.
  3. Admin UI — displayed as an "Activity" section on entity detail pages.

Events to track:

Event Priority Fields
Supplier status change Critical old_status, new_status, reason
PO status change Critical old_status, new_status
PO payment recorded Critical amount, method, reference
Lot status change Critical old_status, new_status, reason
COA verified/rejected Critical verified_by, result
QC inspection completed Critical result, inspector
Price list created/deactivated Medium supplier, effective_date
Shipment received Medium received_by, condition
Entity soft-deleted Medium entity_type, entity_id, actor

3. Entity Lifecycle Management

3.1 Supplier State Machine

          ┌────────────────────────────────┐
          │                                │
          ▼                                │
    ┌──────────┐     business      ┌──────────┐
    │  active   │◄────decision─────│ inactive  │
    │          │─────decision─────▶│           │
    └────┬─────┘                   └───────────┘
         │ compliance/quality issue
   ┌─────────────┐
   │  suspended  │
   └──────┬──────┘
     ┌────┴────┐
     │         │
     ▼         ▼
┌────────┐  ┌─────────┐
│ active │  │ blocked │  (terminal — no return)
└────────┘  └─────────┘

3.2 Cascading Effects on Supplier Status Change

Supplier → inactive

Downstream Entity Action Rationale
Draft POs Auto-cancel No point keeping uncommitted orders
Submitted/Confirmed POs Flag for review, do NOT auto-cancel May have financial obligations
Active Price Lists Set is_active = false Prevent new POs at stale pricing
Existing Lots No change Already received physical goods
COAs No change Historical lab documents

Supplier → suspended

Downstream Entity Action Rationale
Draft POs Auto-cancel Cannot order from suspended supplier
Submitted/Confirmed POs Block from progressing; flag for review May need to cancel or find alternative
Active Price Lists Keep but flag in metadata May reactivate if suspension lifted
Active Lots from this supplier Flag for review Depends on reason for suspension

Supplier → blocked

Downstream Entity Action Rationale
All open POs Cancel Permanent disqualification
Active Price Lists Set is_active = false Permanent
Active Lots Depends on block reason Quality issue → quarantine; business issue → lots are fine

Implementation: A Medusa workflow triggered by a supplier.status_changed event subscriber (same pattern as existing lot-status-changed.ts subscriber).

3.3 Purchase Order State Machine

draft → submitted → confirmed → partially_shipped → shipped →
  partially_received → received → closed
                    \→ cancelled (from draft, submitted, or confirmed only)

Closing constraint: PO cannot move to closed until payment_status = "paid".

3.4 Lot Status Lifecycle

            ┌──────────┐
            │ pending  │  ← Created at reception, awaiting QC
            └────┬─────┘
                 │ QC inspection started
          ┌─────────────┐
          │ quarantined │  ← Under QC review / testing
          └──────┬──────┘
        ┌────────┴────────┐
        │                 │
        ▼                 ▼
  ┌──────────┐     ┌──────────┐
  │  active  │     │ rejected │  ← QC failed
  └────┬─────┘     └──────────┘
  ┌────┴────────┐
  │             │
  ▼             ▼
┌─────────┐  ┌──────────┐
│ expired │  │ recalled │
└─────────┘  └──────────┘
Status Inventory Available?
pending No
quarantined No
active Yes
rejected No
expired No
recalled No

3.5 COA Status Lifecycle

draft → verified → expired
                 → superseded (by a newer COA revision)
      → rejected (invalid/fraudulent)

Once verified, the COA is immutable. Corrections create a new revision.


4. Deletion Policy

4.1 Soft-Delete (Built-in)

Every model defined with model.define() automatically gets deleted_at, created_at, and updated_at columns. The MedusaService base class auto-generates softDeleteX() and restoreX() methods. Unique indexes filter WHERE deleted_at IS NULL, so soft-deleted records don't conflict.

4.2 Hard-Delete Rules

Entity Can hard-delete? Condition
Supplier Only if zero POs, zero lots, zero price lists No historical records whatsoever
Price List Only if never referenced in a PO line No dependent records
Purchase Order Only if status = draft AND no lines Never sent to supplier
PO Line Only if PO is draft AND no lots created No downstream records
Inbound Shipment Only if status = pending AND no lots Nothing received
Lot Never Physical goods were received; audit trail required
COA Never Lab documents are regulatory records
QC Inspection Never Quality records are regulatory documents
Audit Log Never Immutable audit trail

Implementation: Guard checks in DELETE API route handlers. Default to soft-delete. For Lots, COAs, QC Inspections, and Audit Logs, reject hard-delete entirely at the API level.


5. Inventory Integration

5.1 Medusa Inventory Architecture

ProductVariant (manage_inventory: true)
  → InventoryItem (auto-created by Medusa)
    → InventoryLevel (per StockLocation)
        - stocked_quantity    ← physically available
        - reserved_quantity   ← committed to orders, still physical
        - incoming_quantity   ← expected inbound (POs)

Available for sale = stocked_quantity - reserved_quantity

5.2 Integration Points

Procurement Event Inventory Action Medusa API
PO submitted Increment incoming_quantity updateInventoryLevelsWorkflow
PO cancelled Decrement incoming_quantity updateInventoryLevelsWorkflow
Shipment received (lot pending) No change
QC passes (lot → active) Add to stocked_quantity, decrement incoming_quantity adjustInventoryLevelsStep + updateInventoryLevelsWorkflow
QC fails (lot → rejected) Decrement incoming_quantity only updateInventoryLevelsWorkflow
Lot recalled Subtract from stocked_quantity adjustInventoryLevelsStep (negative)
Lot expired Subtract from stocked_quantity adjustInventoryLevelsStep (negative)

5.3 Release Lot Inventory Workflow

// src/workflows/release-lot-inventory.ts
import { createWorkflow, WorkflowResponse } from "@medusajs/framework/workflows-sdk"
import { adjustInventoryLevelsStep } from "@medusajs/medusa/core-flows"

type ReleaseLotInput = {
  inventory_item_id: string
  location_id: string
  quantity: number
}

export const releaseLotInventoryWorkflow = createWorkflow(
  "release-lot-inventory",
  (input: ReleaseLotInput) => {
    // Step 1: Add to stocked_quantity
    adjustInventoryLevelsStep([{
      inventory_item_id: input.inventory_item_id,
      location_id: input.location_id,
      adjustment: input.quantity,
    }])

    // Step 2: Decrement incoming_quantity
    // (via updateInventoryLevelsWorkflow or a custom step)

    // Step 3: Update lot status to "active"
    // Step 4: Create audit log entry

    return new WorkflowResponse({ success: true })
  }
)

5.4 Resolving Lot → InventoryItem

The link chain is: ProductVariant → Lot (existing link) and ProductVariant → InventoryItem (auto by Medusa). To find which inventory item to adjust when releasing a lot:

  1. Query the lot's linked ProductVariant via the existing link
  2. Query that variant's InventoryItem via Medusa's auto-link
  3. Use the shipment's location_id to target the correct InventoryLevel

5.5 Source of Truth

  • Medusa InventoryLevel.stocked_quantity is authoritative for product availability and storefront display
  • Lot.received_quantity is audit-only — records what was physically counted at reception
  • Do not maintain quantity_remaining on Lot — this creates a dual source of truth. If you need "how much of this lot is left", query the InventoryLevel for the linked variant at the relevant location

5.6 FIFO/FEFO Fulfillment

When fulfilling orders, prefer lots with the earliest expiration date (First Expired, First Out). This is a future enhancement — Medusa's default fulfillment doesn't consider lot expiry. Implementation would involve a custom fulfillment workflow step that selects the lot with the nearest expiration_date when allocating inventory.


6. COA Storage, Backup & Compliance

6.1 Storage Backend: Cloudflare R2

See the COA Storage Plan for full R2 setup details. Key additions for v2:

Pin @aws-sdk/client-s3 to avoid R2 checksum bug:

// package.json
"resolutions": {
  "@aws-sdk/client-s3": "3.728.0"
}

6.2 File Naming & Versioning

R2 does not support native object versioning. Use versioned file keys:

coa/{lot_number}/{lot_number}-coa-v1.pdf    ← original
coa/{lot_number}/{lot_number}-coa-v2.pdf    ← revision (if correction needed)

Rules: - Never overwrite an existing file key - Increment revision field on the COA record - Old files remain in R2 and in backup snapshots - The COA record's file_key always points to the current version

6.3 Tamper Detection

Compute SHA-256 hash at upload time and store in file_sha256:

import { createHash } from "crypto"

const hash = createHash("sha256").update(file.buffer).digest("hex")
// Store as coa.file_sha256

6.4 Immutability After Verification

Once status = "verified": - API rejects modifications to: file_url, file_key, purity_percentage, analysis_date, lab_name, lab_reference, purity records - If correction needed: create new COA revision, mark old as superseded - Aligns with 21 CFR Part 11 electronic records principles

6.5 Regulatory Retention

21 CFR Part 111 (Dietary Supplements), Subpart P: - Records must be kept 1 year past shelf life date OR 2 years past last batch distribution

Recommendation for Research Relay: - Retain COA records and files indefinitely — R2 storage costs are negligible (~$0.015/GB/month) - Set retention_until to 3 years past last batch distribution as a minimum floor - Never auto-delete COA files or database records

6.6 Backup Strategy (3-Tier)

Tier Location Frequency Retention Purpose
Primary R2 rr-bizops-files Real-time Indefinite Production access
Backup R2 rr-backups/files/ Weekly sync 4 snapshots Disaster recovery
Off-provider Backblaze B2 (or similar) Monthly sync of coa/ prefix 5 years minimum Regulatory compliance, account-level protection

Gap addressed: v1 backup plan has both primary and backup in the same Cloudflare account. If the account is compromised or suspended, both are at risk. The third tier to a different provider addresses this.

Estimated cost: Under $1/month for the off-provider tier.

6.7 Integrity Verification

Weekly scheduled job that: 1. Queries all COA records with file_url set 2. Issues HEAD requests to verify files exist and are accessible 3. Compares file ETag/size against stored metadata 4. Alerts on missing or corrupted files

Runs alongside the existing pg-backup-verify job.

6.8 CDN Configuration

For cdn.research-relay.com/coa/*:

Cache-Control: public, max-age=31536000, immutable
Content-Type: application/pdf
Content-Disposition: inline; filename="{lot_number}-coa.pdf"

COAs are publicly accessible — transparency is a selling point for research chemical customers.


7. Lot Number Generation

7.1 Format

{PRODUCT_CODE}-{SUPPLIER_CODE}{YYMMDD}-{SEQ}
Component Source Length Example
PRODUCT_CODE Product's short code 3-10 chars BPC157
SUPPLIER_CODE Supplier's code field 2-4 chars PP
YYMMDD Reception date 6 chars 260315
SEQ Auto-incrementing per product+date 2-3 chars 01

Examples:

Product Supplier Received Lot Number
BPC-157 PurePeptides (PP) 2026-03-15 BPC157-PP260315-01
BPC-157 PurePeptides (PP) 2026-03-15 (2nd) BPC157-PP260315-02
TB-500 Biosynth (BS) 2026-04-01 TB500-BS260401-01

7.2 Generation Logic

async function generateLotNumber(
  productCode: string,
  supplierCode: string,
  receptionDate: Date
): Promise<string> {
  const dateStr = format(receptionDate, "yyMMdd")
  const prefix = `${productCode}-${supplierCode}${dateStr}`

  const existingLots = await lotService.list({
    filters: { lot_number: { $like: `${prefix}-%` } },
    order: { lot_number: "DESC" },
    take: 1,
  })

  let seq = 1
  if (existingLots.length > 0) {
    const lastSeq = parseInt(existingLots[0].lot_number.split("-").pop(), 10)
    seq = lastSeq + 1
  }

  return `${prefix}-${String(seq).padStart(2, "0")}`
}

7.3 When Lots Are Created

Lots are created at reception — when an inbound shipment is physically received:

1. Shipment arrives → Admin marks shipment as "received"
2. For each PO line being received:
   a. Enter quantity received
   b. Enter supplier's lot number
   c. Enter manufacture date and expiration date
   d. Lot number auto-generated
   e. Lot created with status "pending"
3. Optionally attach supplier COA to one or more lots
4. QC inspection performed
5. QC passes → lot "active", inventory released
6. QC fails → lot "rejected"

8. Admin UI

8.1 Navigation Structure

Procurement (new top-level nav, Package icon)
├── Dashboard             — KPIs, recent activity, quick nav
├── Suppliers              — List/create/edit suppliers
├── Purchase Orders        — List/create/manage POs
├── Inbound Shipments      — Track shipments, mark received
└── QC Inspections         — View/create inspection records

Compliance (existing, updated)
├── Lots                   — Now shows procurement origin data
├── Attestations           — Unchanged
└── Settings               — Unchanged

8.2 Procurement Dashboard (/app/procurement)

Mirrors the existing compliance dashboard pattern (KPI cards + quick nav + recent activity):

KPI Cards (responsive grid):

Card Value Color Logic
Open POs Count of non-closed/cancelled POs Orange if any overdue
Pending Shipments Count of in-transit shipments Red if any overdue
Lots Awaiting QC Count of lots in pending or quarantined Orange if > 0
Low Stock Alerts Count of products below reorder point Red if any critical

Quick Nav Cards: Links to Suppliers, Purchase Orders, Shipments, QC pages.

Recent Activity: Latest 10 audit log entries for procurement entities.

8.3 Suppliers Page (/app/procurement/suppliers)

List view columns: Name, Code, Status (badge), Contact, Lead Time, Active POs count

Detail view (/app/procurement/suppliers/[id]): - Supplier info card (editable) - Status management with reason field (triggers cascading workflow) - Price list table - Purchase order history - Activity log (from AuditLog)

8.4 Purchase Orders Page (/app/procurement/purchase-orders)

List view columns: PO #, Supplier, Status (badge), Items, Total, Ordered, Expected, Payment (badge)

Create PO flow (guided): 1. Select supplier → auto-populates payment terms, currency, lead time 2. Add line items: search product → auto-fills supplier SKU and cost from price list → enter quantity 3. Add notes, expected delivery date 4. Save as draft or submit

Detail view: PO header, line items table, shipments section, payments section, lots section, activity log.

Payments section on PO detail: - Payment summary bar: total due, total paid, remaining balance - Payment records table:

Column Source
Date payment.paid_at
Method Badge: crypto (with coin icon) / wire / ach / check
Amount (USD) payment.amount_usd formatted
Crypto Details {crypto_amount} {crypto_currency} @ ${crypto_rate_usd} (crypto only)
Tx Hash Truncated, linked to block explorer (crypto only)
Status Badge: pending / confirmed / failed
  • "Record Payment" button opens form:
  • Payment method selector (crypto / wire / ach / check / other)
  • Amount in USD
  • If crypto: coin selector, amount in crypto, rate at conversion, rate source, rate timestamp, tx hash, network, addresses
  • If fiat: reference number, bank name
  • Date paid, notes

8.5 Inbound Shipments (/app/procurement/shipments)

List view columns: PO #, Supplier, Carrier, Tracking (linked), Status, Shipped, ETA, Received

Receive Shipment flow (guided wizard): 1. Received by (auto-fill current admin), received at (auto-fill now) 2. Package condition, temperature on arrival 3. For each PO line: quantity received, supplier lot number, mfg/exp dates → lot auto-generated 4. Optionally attach COA PDF 5. Submit → shipment "received", PO status updated, lots created as "pending"

8.6 QC Inspections (/app/procurement/qc)

List view columns: QC #, Lot, Status (badge), Inspector, Date

Create QC flow: 1. Select lot (from pending or quarantined lots) → lot moves to quarantined 2. Add inspection items (parameter, method, expected, observed, pass/fail) 3. Set result: Passed → lot "active" + inventory released; Failed → lot "rejected"; Conditional → lot stays "quarantined"

8.7 Enhanced Lot Detail Page

Existing lot detail page extended with: - Origin card: Supplier, PO number (linked), Shipment tracking (linked), Supplier lot number - QC Inspections card: List of inspections with results - Activity card: Audit log entries for this lot

8.8 Widgets

Widget Injection Zone Content
Supplier pricing product.details.after Active supplier prices and recent POs for this product
Lot origin order.details.after Lot provenance and procurement data for ordered items

8.9 UX Principles

  • Dashboard-first — KPI cards with quick nav, replicate compliance dashboard pattern
  • Status-driven color coding — green=active/passed, blue=submitted/in-transit, orange=pending/partial, red=cancelled/failed/blocked, grey=draft/closed
  • Auto-fill from prior data — supplier selection populates terms; product selection populates from price list
  • Lot numbers auto-generated — operators never type lot numbers manually
  • Guided multi-step flows — receive shipment and QC inspection use step-by-step wizards
  • Activity log on every detail page — who changed what, when, why

9. Storefront Integration

9.1 COA Display on Product Pages

Product → ProductVariant → Lot (via link) → COA (via lot_coa junction)

Traverse: get all variants → get all active lots → get all verified COAs → deduplicate.

9.2 Store API Endpoint

// GET /store/compliance/products/:id/coas
{
  coas: [
    {
      id: "coa_01H...",
      file_url: "https://cdn.research-relay.com/coa/BPC157-PP260315-01/...",
      purity_percentage: 98.7,
      analysis_date: "2026-01-18",
      lab_name: "Analytical Testing Services Inc.",
      lab_reference: "ATS-2026-00142",
      status: "verified",
      lots: [
        { lot_number: "BPC157-PP260315-01", status: "active" },
        { lot_number: "BPC157-PP260315-02", status: "active" },
      ],
      purity_records: [...]
    }
  ]
}

9.3 Caching

Increase COA API revalidation from 60s to 300s (COA data changes infrequently).

9.4 PDF Preview

Add inline PDF preview on lot detail page using browser native rendering (<iframe> or <object> tag) so customers can preview without downloading.


10. Vial Label Data

10.1 Required Label Fields

Field Source Required By
Product name product.title GHS, RUO
Catalog number variant.sku Industry practice
CAS Number product.metadata.cas_number Industry practice
Lot Number lot.lot_number GMP traceability
Quantity / Size variant.title Industry practice
Form product.metadata.form Industry practice
Purity coa.purity_percentage Industry practice
Storage conditions product.metadata.storage_class Industry practice
Expiration date lot.expiration_date GMP traceability
FOR RESEARCH USE ONLY Static text FDA 21 CFR 809.10
Company name & contact Static GHS, RUO
GHS pictograms product.metadata (if hazmat) OSHA 29 CFR 1910.1200
Barcode Generated from catalog # + lot # Industry practice

10.2 Label Formats

Format Size Use Case
Small vial 1" x 0.5" 2mL vials — minimal info
Standard vial 2" x 1" 5-10mL vials — full info
Bottle 3" x 2" Solvents, larger containers — full GHS

11. Module Structure

app/src/modules/procurement/
├── index.ts                           # Module registration
├── service.ts                         # ProcurementModuleService
├── models/
│   ├── supplier.ts
│   ├── supplier-price-list.ts
│   ├── purchase-order.ts
│   ├── purchase-order-line.ts
│   ├── purchase-order-payment.ts       # NEW: crypto + fiat payment tracking
│   ├── inbound-shipment.ts
│   ├── qc-inspection.ts
│   ├── qc-inspection-item.ts
│   └── audit-log.ts                   # NEW
├── migrations/
│   └── Migration_YYYYMMDDHHMMSS.ts
└── loaders/

app/src/modules/product-compliance/
├── models/
│   ├── lot-coa.ts                     # NEW junction table
│   ├── lot.ts                         # Modified: new statuses + procurement fields
│   └── coa.ts                         # Modified: status enum + integrity fields
└── migrations/
    └── Migration_YYYYMMDDHHMMSS.ts

app/src/links/
├── supplier-pricelist-product.ts      # Product ↔ SupplierPriceList
├── po-line-product.ts                 # Product ↔ PurchaseOrderLine
├── po-line-variant.ts                 # ProductVariant ↔ PurchaseOrderLine
├── lot-procurement.ts                 # Lot ↔ PurchaseOrderLine, Lot ↔ InboundShipment
└── lot-qc-inspection.ts              # Lot ↔ QcInspection

app/src/workflows/
├── release-lot-inventory.ts           # NEW: QC pass → inventory release
├── reject-lot.ts                      # NEW: QC fail → decrement incoming
├── submit-purchase-order.ts           # NEW: PO submission + incoming_quantity
├── cancel-purchase-order.ts           # NEW: PO cancel + revert incoming
└── cascade-supplier-status.ts         # NEW: supplier status change cascading

app/src/subscribers/
├── supplier-status-changed.ts         # NEW: triggers cascade workflow
├── lot-status-changed.ts              # EXISTING: extended for inventory
└── coa-verified.ts                    # EXISTING

app/src/api/admin/procurement/
├── suppliers/
│   ├── route.ts                       # GET (list), POST (create)
│   ├── [id]/route.ts                  # GET, POST (update), DELETE
│   └── validators.ts
├── purchase-orders/
│   ├── route.ts                       # GET (list), POST (create)
│   ├── [id]/route.ts                  # GET, POST (update)
│   ├── [id]/submit/route.ts           # POST (submit → generates PO#)
│   ├── [id]/lines/route.ts            # POST (add line)
│   ├── [id]/payments/route.ts         # GET (list), POST (record payment)
│   ├── [id]/payments/[paymentId]/route.ts  # GET, POST (update status)
│   └── validators.ts
├── shipments/
│   ├── route.ts                       # GET (list), POST (create)
│   ├── [id]/route.ts                  # GET, POST (update)
│   ├── [id]/receive/route.ts          # POST (receive → create lots)
│   └── validators.ts
├── qc/
│   ├── route.ts                       # GET (list), POST (create)
│   ├── [id]/route.ts                  # GET, POST (update)
│   └── validators.ts
└── audit-log/
    └── route.ts                       # GET (list, filtered by entity)

app/src/api/store/compliance/
├── products/[id]/coas/route.ts        # GET (public COAs for product)
└── (existing routes unchanged)

app/src/admin/routes/procurement/
├── page.tsx                           # Procurement dashboard
├── suppliers/
│   ├── page.tsx                       # Supplier list
│   └── [id]/page.tsx                  # Supplier detail
├── purchase-orders/
│   ├── page.tsx                       # PO list
│   └── [id]/page.tsx                  # PO detail + create
├── shipments/
│   ├── page.tsx                       # Shipment list
│   └── [id]/page.tsx                  # Shipment detail + receive wizard
└── qc/
    ├── page.tsx                       # QC list
    └── [id]/page.tsx                  # QC detail

app/src/admin/widgets/
├── product-supplier-widget.tsx        # NEW: supplier pricing on product page
└── order-lot-origin-widget.tsx        # NEW: lot origin on order page

12. Implementation Phases

Phase 1: Foundation (Supplier + Price Lists)

  • Create procurement module with Supplier, SupplierPriceList, AuditLog models
  • Register module in medusa-config.ts with EventBus dependency
  • Create module links (Supplier ↔ Product)
  • Admin API routes for suppliers (CRUD + status changes)
  • Admin API routes for price lists (CRUD)
  • Supplier status cascade workflow
  • Supplier list and detail admin pages (with activity log)
  • Generate and run migrations
  • Seed script with sample suppliers

Phase 2: Purchase Orders

  • Add PurchaseOrder, PurchaseOrderLine, and PurchaseOrderPayment models
  • Create module links (PO Line ↔ Product, PO Line ↔ Variant)
  • Admin API routes for POs (CRUD + submit + status transitions)
  • Admin API routes for PO payments (list, record, update status)
  • PO number auto-generation
  • Submit PO workflow (sets incoming_quantity on inventory)
  • Cancel PO workflow (reverts incoming_quantity)
  • PO payment_status computed from payment records (unpaid/partially_paid/paid)
  • PO list and detail admin pages with create flow and payment section
  • "Record Payment" form with crypto conversion fields
  • Generate and run migrations

Phase 3: Inbound Shipping & Reception

  • Add InboundShipment model (with location_id)
  • Admin API routes for shipments
  • Shipment list page with tracking links
  • "Receive Shipment" wizard (create lots from PO lines)
  • Lot number auto-generation
  • Add procurement fields to Lot model (supplier_lot_number, status_changed_*)
  • Extend lot detail page with procurement origin section
  • Generate and run migrations

Phase 4: COA Enhancements

  • Add COA model extensions (status enum, file_sha256, revision, superseded_by, retention_until)
  • Create lot_coa junction model
  • Write data migration to move existing COA→Lot FKs to junction table
  • Implement COA immutability guard (reject updates to verified COAs)
  • Implement SHA-256 hash computation at upload time
  • Implement versioned file keys (-v{N}.pdf)
  • Update all admin and store API endpoints
  • Update admin lot detail page COA section
  • Update storefront COA card
  • Test migration on copy of production data

Phase 5: QC Inspections + Inventory Release

  • Add QcInspection and QcInspectionItem models
  • Create module links (Lot ↔ QcInspection)
  • Admin API routes for QC inspections
  • Release lot inventory workflow (QC pass → stocked_quantity + incoming_quantity)
  • Reject lot workflow (QC fail → incoming_quantity only)
  • QC list and detail admin pages
  • Lot status transition logic (pending → quarantined → active/rejected)
  • Generate and run migrations

Phase 6: Dashboard & Widgets

  • Procurement dashboard page with KPI cards
  • Product supplier pricing widget (product.details.after)
  • Order lot origin widget (order.details.after)
  • Activity log section on all entity detail pages

Phase 7: Storefront COA Integration

  • GET /store/compliance/products/:id/coas endpoint
  • COA section on storefront product detail page
  • Increase COA API revalidation to 300s
  • Inline PDF preview on lot detail page
  • Test with real uploaded COA PDFs

Phase 8: Backup & Compliance Enhancements

  • Add third-tier COA backup to non-Cloudflare provider
  • Weekly COA integrity verification job
  • Set CDN cache headers for verified COAs (immutable)
  • Pin @aws-sdk/client-s3 below 3.729.0

Phase 9: Label Data (Future)

  • Define label templates (small, standard, bottle)
  • Admin endpoint for label data/PDF generation
  • Label preview in admin lot detail page
  • Printer integration research

13. Implementation Checklist

  • Module setup — 9 new models (Supplier, SupplierPriceList, PurchaseOrder, PurchaseOrderLine, PurchaseOrderPayment, InboundShipment, QcInspection, QcInspectionItem, AuditLog) + 2 model extensions (Lot, Coa) + 1 junction (LotCoa)
  • Module links — 5 new links
  • Workflows — 5 new workflows (release lot, reject lot, submit PO, cancel PO, cascade supplier status)
  • Subscribers — 1 new (supplier status changed) + 1 extended (lot status changed)
  • Admin API — 4 resource groups (~18 endpoints) + audit log
  • Admin UI — 9 new pages + 1 dashboard + 2 widgets + activity logs
  • Store API — 1 new endpoint (product COAs)
  • Storefront — 1 new section (product COA display) + PDF preview
  • Backup — 3rd-tier off-provider backup + integrity checks
  • Testing — seed script, full flow test, COA migration test, inventory integration test