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_quantity → stocked_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 | pending → quarantined → active → expired/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:
- Create the
lot_coajunction table - Populate from existing COA records (each COA.lot_id → row in lot_coa)
- Remove the
lotFK from the COA model - Update models to use
manyToManyviaLotCoa - 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:
- INSERT-only — the service exposes only
createAuditLogsandlistAuditLogs/retrieveAuditLog. No update or delete methods. - Event-driven — populated via Medusa subscribers listening for entity events.
- 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¶
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:
- Query the lot's linked
ProductVariantvia the existing link - Query that variant's
InventoryItemvia Medusa's auto-link - Use the shipment's
location_idto target the correctInventoryLevel
5.5 Source of Truth¶
- Medusa
InventoryLevel.stocked_quantityis authoritative for product availability and storefront display Lot.received_quantityis audit-only — records what was physically counted at reception- Do not maintain
quantity_remainingon Lot — this creates a dual source of truth. If you need "how much of this lot is left", query theInventoryLevelfor 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:
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¶
| 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¶
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
procurementmodule with Supplier, SupplierPriceList, AuditLog models - Register module in
medusa-config.tswith 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_quantityon inventory) - Cancel PO workflow (reverts
incoming_quantity) - PO
payment_statuscomputed 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_coajunction 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_quantityonly) - 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/coasendpoint - 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-s3below 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