Domain Model
Sale tables live in
salePostgreSQL schema; allocation tables inallocationschema. All schemas defined in@nx/core/src/models/schemas/{sale,allocation}/. Numeric columns usedecimal(15, 4).
1. Full ERD
2. Common Columns
| Column | Type | Notes |
|---|---|---|
id | text | PK, Snowflake |
createdAt / modifiedAt | timestamptz | - |
createdBy / modifiedBy | text | User audit |
deletedAt | timestamptz | Soft-delete |
metadata | jsonb | Extension bag |
3. Entities
3.1 SaleOrder
| Property | Value |
|---|---|
| Table | SaleOrder |
| Source | core/src/models/schemas/sale/sale-order/schema.ts |
| Soft-delete | yes |
| Field | Type | Required | Description |
|---|---|---|---|
orderNumber | text | ✓ | Unique partial per merchant |
name / slug | text | Slug unique partial | |
validity | jsonb | { from, to } for time-limited orders | |
status | text | ✓ | See §4.1; default DRAFT |
draftAt / processingAt / partialAt / completedAt / cancelledAt | timestamptz | Per-status timestamps | |
cancellationReason | text | - | |
customerId | text | FK | |
merchantId | text | ✓ | Owner |
saleChannelId | text | ✓ | FK |
shiftId | text | FK to Shift | |
openedInShiftEnrollmentId / closedInShiftEnrollmentId | text | FK to ShiftEnrollment | |
currency | text | ✓ | Default VND |
exchangeRate | decimal(12,6) | Default 1 | |
subtotal / tax / discount / total | decimal(15,4) | ✓ | Default 0; maintained by updateSummaryFromItems |
originOrderId | text | For order-split parent tracking | |
checkSplitAt / orderSplitAt / mergedAt | timestamptz | Operation timestamps | |
counter | jsonb | { paid, paidItemIds[], total } - payment progress |
3.2 SaleOrderItem
| Property | Value |
|---|---|
| Table | SaleOrderItem |
| Polymorphic | (itemType, itemId) via generatePrincipalColumnDefs({ discriminator: 'item', defaultPolymorphic: 'ProductVariant' }) |
| Field | Type | Required | Description |
|---|---|---|---|
saleOrderId | text | ✓ | FK |
itemType | text | ✓ | PRODUCT_VARIANT (default) / other |
itemId | text | ✓ | FK target |
mode | text | ✓ | PRODUCT (default - auto-merge duplicates) / CUSTOM (always new line) |
leadItemId | text | Group lead for combo items | |
currency | text | ✓ | Default VND |
basePrice / unitPrice | decimal(15,4) | ✓ | Pre-discount / post-discount per-unit |
discount / tax | decimal(15,4) | ✓ | Default 0 |
quantity | decimal(15,4) | ✓ | Default 1 |
total | decimal(15,4) | ✓ | Computed |
fareId / fareProvider | text | Pricing source ref | |
priceMetadata | jsonb | Pricing snapshot (v2 detail) | |
transferHistory | jsonb | Array<TTransferHistoryEntry> - merge/split tracking | |
recipeId | text | Linked active MaterialRecipe.id (snapshot) |
3.3 SaleCheck / SaleCheckItem
SaleCheck:
| Field | Type | Required | Description |
|---|---|---|---|
saleOrderId | text | ✓ | Parent order |
status | text | ✓ | PROCESSING (default) / COMPLETED / CANCELLED |
subtotal / tax / discount / total | decimal(15,4) | ✓ | Recalculated by recalculateTotals |
customerId | text | Per-check customer (different from order's) |
SaleCheckItem: saleCheckId, saleOrderItemId, quantity, subtotal/tax/discount/total.
3.4 KitchenStation / KitchenTicket / KitchenTicketItem
KitchenStation: merchantId, name (i18n), status (default ACTIVATED).
KitchenTicket:
| Field | Type | Required | Description |
|---|---|---|---|
ticketNumber | text | ✓ | Unique partial; sequential per station |
saleOrderId | text | ✓ | FK |
merchantId | text | ✓ | Owner |
kitchenStationId | text | Routing target (optional) | |
status | text | ✓ | See §4.3; default PENDING |
priority | int | ✓ | Default 0 (rush flag bumps priority) |
sequence | int | ✓ | Default 1 (ordering hint) |
pendingAt / processingAt / readyAt / completedAt / voidedAt | timestamptz | Status timestamps |
KitchenTicketItem:
| Field | Type | Required | Description |
|---|---|---|---|
kitchenTicketId | text | ✓ | FK |
saleOrderItemId | text | ✓ | FK |
quantity | decimal(15,4) | ✓ | Default 1 |
status | text | ✓ | See §4.4; default PENDING |
startedAt / readyAt / servedAt / voidedAt | timestamptz | Status timestamps |
Each status change emits Kafka
KITCHEN_TICKET_ITEM_STATUS_CHANGEDand triggers ticket auto-progression evaluation.
3.5 AllocationUsage / AllocationUnit / AllocationZone / AllocationLayout
Schema lives in
allocationschema (separate fromsale).
AllocationUsage - polymorphic usage of an allocation unit:
| Field | Type | Required | Description |
|---|---|---|---|
usageType | text | ✓ | SALE_ORDER / RESERVATION (via discriminator: 'usage') |
usageId | text | ✓ | FK target id |
unitId | text | ✓ | FK to AllocationUnit |
merchantId | text | ✓ | Owner |
assigneeId | text | Person/staff assigned | |
status | text | ✓ | ACTIVE (default) / SUCCESS / CANCELLED / EXPIRED |
type | text | ✓ | GENERAL (default) / DINE_IN / TAKEAWAY / DELIVERY |
reservedFrom / reservedTo / reservedAt / startedAt / completedAt | timestamptz | Lifecycle timestamps |
AllocationUnit - physical unit (table, seat, locker):
name (i18n),zoneId(notNull),placement(jsonb position),style(jsonb),capacity(int),status.
AllocationZone - section / floor / area:
name (i18n),layoutId(notNull),style(jsonb),parentId(self-ref hierarchy),status.
AllocationLayout - top-level floor plan container.
3.6 Reservation
| Field | Type | Required | Description |
|---|---|---|---|
merchantId | text | ✓ | Owner |
guestName | text | ✓ | - |
guestPhone | text | ✓ | - |
guestEmail | text | - | |
partySize | int | ✓ | - |
reservedFrom | timestamptz | ✓ | - |
reservedTo | timestamptz | - | |
notes | text | - | |
source | text | ✓ | Default PHONE; WEB / WALK_IN / APP |
occasion | text | Birthday / anniversary / etc. | |
status | text | ✓ | PENDING (default) / CONFIRMED / CHECKED_IN / CANCELLED |
confirmedAt / checkedInAt / cancelledAt | timestamptz | - | |
cancellationReason | text | - | |
saleOrderId | text | FK after check-in |
3.7 Shift / ShiftDrawer / ShiftEnrollment / ShiftEnrollmentEvent / ShiftReport
Shift v2 model. Source:
core/src/models/schemas/sale/shift*. All entities soft-deletable.
Shift - top-level shift aggregate, scoped to (merchantId, saleChannelId):
| Field | Type | Required | Description |
|---|---|---|---|
merchantId | text | ✓ | Owner |
saleChannelId | text | ✓ | Channel scope |
code | text | ✓ | Identifier (prefix SHIFT) |
name | jsonb | i18n { default, en?, vi? } | |
notes | jsonb | i18n { default, en?, vi? } | |
status | text | ✓ | See §4.7; default 100_OPENING |
openedById | text | ✓ | User who opened |
closedById | text | User who closed | |
openedAt / closedAt | timestamptz | openedAt defaults to now | |
startAt / endAt | timestamptz | Planned window (optional) | |
metadata | jsonb | Free-form (legacy openNotes/closeNotes removed - notes live in the notes column) |
A channel may have multiple
100_OPENINGshifts at once (per-device single-open is enforced via enrollment);(merchantId, saleChannelId)is a plain composite index.
ShiftDrawer - per-device cash drawer, 1:1 with a ShiftEnrollment:
| Field | Type | Required | Description |
|---|---|---|---|
shiftEnrollmentId | text | ✓ | 1:1 owner (partial unique) |
shiftId / merchantId / deviceId | text | ✓ | Scope |
status | text | ✓ | Same set as Shift; default 100_OPENING |
openingFloat | decimal(15,4) | ✓ | Cash float at open |
expectedCash / actualCash / cashDiscrepancy | decimal(15,4) | Reconciliation | |
expectedNonCash / actualNonCash | jsonb | TShiftNonCashBreakdown (per non-cash method) | |
closeRecountCount | int | ✓ | Default 0; recount attempts |
openedAt / closedAt | timestamptz | openedAt defaults to now |
ShiftEnrollment - a principal (device or user) joined to a Shift:
| Field | Type | Required | Description |
|---|---|---|---|
shiftId / merchantId | text | ✓ | Scope |
principalType | text | ✓ | Device / User |
principalId | text | ✓ | Device or user id |
status | text | ✓ | ACTIVATED (default) / DEACTIVATED |
role | text | Enrollment role | |
drawerId | text | Attached shared-drawer session | |
enrolledById | text | - | |
enrolledAt / leftAt | timestamptz | enrolledAt defaults to now | |
notes | jsonb | i18n { default, en?, vi? } - note supplied at join/create |
At most one
ACTIVATEDenrollment per principal - partial unique index.
ShiftEnrollmentEvent - append-only ledger of enrollment + cash-movement events:
| Field | Type | Required | Description |
|---|---|---|---|
shiftEnrollmentId / shiftId / merchantId | text | ✓ | Scope |
type | text | ✓ | See §4.8 |
amount | decimal(15,4) | Set only for cash movements (PAY_IN / PAY_OUT / SAFE_DROP) | |
occurredAt | timestamptz | ✓ | Defaults to now |
note | text | - |
ShiftReport - X/Z report rows; per-enrollment when shiftEnrollmentId is set, shift-wide when NULL:
| Field | Type | Required | Description |
|---|---|---|---|
shiftId / merchantId / saleChannelId | text | ✓ | Scope |
shiftEnrollmentId | text | NULL = shift-wide aggregate; set = per-enrollment | |
reportType | text | ✓ | X (live snapshot) / Z (final) |
reportNo | int | ✓ | Sequential per scope |
openedById / closedById / generatedById | text | Audit | |
openedAt / closedAt / generatedAt | timestamptz | generatedAt defaults to now | |
openingFloat, cashSales, cashRefunds, payIns, payOuts, safeDrops, expectedCash, actualCash, cashDiscrepancy | decimal(15,4) | ✓ | Cash summary |
grossSales, discounts, taxAmount, netSales, voidAmount | decimal(15,4) | ✓ | Sales summary |
orderCount / itemCount / voidCount | int | ✓ | Default 0 |
paymentMethods / sections / categoryBreakdown / actualNonCash | jsonb | Breakdowns | |
notes | text | - |
3.8 Customer
| Field | Type | Required | Description |
|---|---|---|---|
name | text | ✓ | - |
phone | text | - | |
email | text | - | |
userId | text | Linked user account (optional) | |
merchantId | text | ✓ | Owner |
pointBalance | decimal(15,4) | ✓ | Default 0 |
3.9 PointTransaction
| Field | Type | Required | Description |
|---|---|---|---|
customerId | text | ✓ | FK |
merchantId | text | ✓ | Owner |
saleOrderId | text | ✓ | Source order |
type | text | ✓ | AWARD / REDEEM / ADJUST (per PointTransactionTypes) |
points | decimal(15,4) | ✓ | Signed delta |
conversionRate | decimal(15,4) | ✓ | Snapshot of points-per-currency at award time |
Idempotency:
PointTransactionRepository.existsBySaleOrderIdblocks duplicate awards.
4. Status Enums
4.1 SaleOrderStatuses
| Value | Stage |
|---|---|
DRAFT | Cart / mutable items |
PROCESSING | Checkout complete, awaiting payment |
PARTIAL | Some payment received |
COMPLETED | Fully paid |
CANCELLED | Terminal |
4.2 SaleCheckStatuses
| Value | Stage |
|---|---|
PROCESSING | Default - accepting payments |
PARTIAL | Some payment received |
COMPLETED | Fully paid |
CANCELLED | Terminal |
4.3 KitchenTicketStatuses
Source:
core/src/models/schemas/sale/kitchen-ticket/constants.ts. The ticket is at a different layer than the items - they have different status sets.
| Value | Code | Stage |
|---|---|---|
PENDING | 103_PENDING | Just sent to kitchen, no item COOKING yet |
PROCESSING | 203_PROCESSING | At least one item is COOKING (auto-progress from PENDING) |
READY | 302_SUCCESS | All items READY-or-beyond (auto) |
COMPLETED | 303_COMPLETED | All items terminal, ≥1 SERVED (auto) |
VOIDED | 505_CANCELLED | Manually voided |
Helper guards: canVoid (any active), canProgress (PENDING only), canMarkReady (PROCESSING only), canComplete (READY only).
4.4 KitchenTicketItemStatuses
Source:
core/src/models/schemas/sale/kitchen-ticket-item/constants.ts. Different set from the ticket-level enum.
| Value | Code | Trigger |
|---|---|---|
PENDING | 103_PENDING | Initial |
COOKING | 203_PROCESSING | startCookingItem |
READY | 302_SUCCESS | markItemReady - emits Kafka KITCHEN_TICKET_ITEM_STATUS_CHANGED |
SERVED | 303_COMPLETED | markItemServed |
VOIDED | 505_CANCELLED | voidTicketItem |
4.5 AllocationUsageStatuses
| Value | Stage |
|---|---|
ACTIVE | Reserved/occupied |
SUCCESS | Order paid → usage closed |
CANCELLED | Order/reservation cancelled |
EXPIRED | Reservation timeout |
4.6 ReservationStatuses
| Value | Stage |
|---|---|
PENDING | Created, awaiting confirmation |
CONFIRMED | Confirmed by host |
CHECKED_IN | Guest arrived; spawns SaleOrder |
CANCELLED | Terminal |
4.7 ShiftStatuses
Source:
core/src/models/schemas/sale/shift/constants.ts.ShiftDrawerStatusesuses the identical set;isOpen=100_OPENING.
| Value | Stage |
|---|---|
100_OPENING | Active shift / drawer |
200_CLOSED | Closed, awaiting reconciliation |
300_RECONCILED | Cash reconciled - terminal |
4.8 ShiftEnrollment Enums
ShiftEnrollmentStatuses: ACTIVATED (default) ↔ DEACTIVATED.
ShiftPrincipalType: Device / User.
ShiftEnrollmentEventTypes - append-only ledger; amount is set only for cash movements:
| Value | Cash movement |
|---|---|
DROP_IN / DROP_OUT | no |
CHECK_IN / CHECK_OUT | no |
DRAWER_OPEN / RECONCILE | no |
PAY_IN / PAY_OUT / SAFE_DROP | yes (amount required) |
4.9 ShiftReportTypes
| Value | Stage |
|---|---|
X | Live snapshot (re-runnable) |
Z | Final report - one per scope |
5. Cross-entity Invariants
| Invariant | Enforcement |
|---|---|
SaleOrder.subtotal/tax/discount/total = Σ(items) | Service updateSummaryFromItems after every item mutation |
At most one 100_OPENING Shift per (merchantId, saleChannelId) | Partial unique index + ShiftService |
At most one ACTIVATED ShiftEnrollment per principal | Partial unique index |
ShiftDrawer is 1:1 with a ShiftEnrollment | Partial unique index on shiftEnrollmentId |
ShiftEnrollmentEvent.amount set only for PAY_IN / PAY_OUT / SAFE_DROP | ShiftEnrollmentEventTypes.isCashMovement |
SaleCheck totals = Σ(SaleCheckItem) for its lines | SaleCheckRepository.recalculateTotals |
KitchenTicket auto-progresses through PENDING→COOKING→READY→SERVED based on item statuses | KitchenTicketRepository.evaluateTicketAutoProgression after every item status change |
KitchenTicket.ticketNumber unique partial per kitchen station (sequence reset on station change) | Schema partial unique + getNextSequence |
PointTransaction idempotent per (customerId, saleOrderId) | existsBySaleOrderId lookup before write |
AllocationUsage follows order/reservation lifecycle (cancel cascades) | Service-level on cancelOrder / cancellation |
Order merge / split operations preserve item totals | Service-level transaction; transferHistory audit |
6. Soft-delete Behavior
| Entity | Soft-delete | Notes |
|---|---|---|
| All sale entities | ✓ | deletedAt marker; archive for SaleOrder = soft-delete |
KitchenTicket voidedAt | logical | voidedAt is not a soft-delete; ticket remains queryable |