feat(POS-002): completed feature
This commit is contained in:
@@ -5543,13 +5543,15 @@
|
|||||||
"description": "See docs/pos/POS_TASKS.md POS-002 for full description. Triage and scoping happens at leader intake.",
|
"description": "See docs/pos/POS_TASKS.md POS-002 for full description. Triage and scoping happens at leader intake.",
|
||||||
"priority": "high",
|
"priority": "high",
|
||||||
"risk": "med",
|
"risk": "med",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"created_at": "2026-08-21",
|
"created_at": "2026-08-21",
|
||||||
"gates": {
|
"gates": {
|
||||||
"reviewer": false,
|
"reviewer": true,
|
||||||
"security": false,
|
"security": true,
|
||||||
"qa": false
|
"qa": true,
|
||||||
}
|
"close": true
|
||||||
|
},
|
||||||
|
"completed_at": "2026-08-21T19:55:43Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "POS-003",
|
"id": "POS-003",
|
||||||
|
|||||||
@@ -467,11 +467,13 @@ Each phase ends with **all three gates approved (reviewer, security, qa)** and `
|
|||||||
|
|
||||||
These are decisions the architect cannot take unilaterally and that block Phase 2:
|
These are decisions the architect cannot take unilaterally and that block Phase 2:
|
||||||
|
|
||||||
1. **Default store name** for the backfill migration ("Mercado de Vida — Tienda única" until renamed via admin)?
|
1. ✅ **Default store name** — **RESOLVED**: "Natural - Mercado de Vida" (slug `natural-mercado-de-vida`).
|
||||||
2. **Cookie domain**: deploy POS on the same origin as admin (port 3006, subdomain, or sibling port)? Affects cookie sharing.
|
2. ✅ **Cookie domain** — **RESOLVED**: separate port `:3006` for the POS app (alongside admin `:3004`). Cookie is set with `Domain` unset (so the browser scopes it to the POS origin only). When the operator deploys to production with Traefik and subdomains, the cookie strategy will be revisited (Phase 4 cleanup): at that point `Domain=.mercadodevida.example` allows admin and POS to share the same backoffice session.
|
||||||
3. **Touchscreen minimum target hardware** (model + size) so the touch UI can be sized correctly.
|
3. ✅ **Touchscreen target hardware** — **RESOLVED**: 10-inch tablet in landscape. Responsive design from 1024×768 to 1920×1080, with a sensible tablet portrait mode (800×1280) as a stretch goal. The touch CSS uses media query `(pointer: coarse)` to detect and switch; manual override stored in cookie.
|
||||||
4. **Datáfono provider preference** (none, SumUp, Redsys TPVO, generic) — informs whether Phase 7 needs a real adapter or stays browser-only.
|
4. ✅ **Datáfono provider** — **RESOLVED**: stays manual in Phase 3. Phase 7 (`POS-038`) deferred — only reopens if the operator picks SumUp, Redsys TPVO, or Verifone.
|
||||||
5. **Refund policy defaults** (within X days, require manager approval above Y €).
|
5. ✅ **Refund policy defaults** — **RESOLVED**: refunds allowed up to 15 days after sale. Manager approval required for refunds above 50 € (configurable in `store_settings`).
|
||||||
|
|
||||||
|
The remaining questions (printer model, scanner type, cash drawer wiring, scale brand) are documented in `POS_HARDWARE.md §10` and resolved there.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ CREATE TABLE pos_stores (
|
|||||||
|
|
||||||
```sql
|
```sql
|
||||||
INSERT INTO pos_stores (id, name, slug, active)
|
INSERT INTO pos_stores (id, name, slug, active)
|
||||||
VALUES ('00000000-0000-0000-0000-000000000001', 'Mercado de Vida — Tienda principal', 'tienda-principal', true);
|
VALUES ('00000000-0000-0000-0000-000000000001', 'Natural - Mercado de Vida', 'natural-mercado-de-vida', true);
|
||||||
```
|
```
|
||||||
|
|
||||||
This UUID is referenced from the `inventory_stock` migration as the default store.
|
This UUID is referenced from the `inventory_stock` migration as the default store.
|
||||||
|
|||||||
@@ -356,10 +356,10 @@ The brief explicitly forbids coupling the UI to a vendor. Our adapter pattern en
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. Open questions for the operator
|
## 10. Hardware decisions (resolved)
|
||||||
|
|
||||||
1. **Printer model** for ESC/POS commands (Epson, Star, Citizen)?
|
1. ✅ **Printer model** — **RESOLVED**: **Epson** thermal receipt printer. ESC/POS compatible (TM-T20, TM-T88, or similar). Phase 7 native bridge targets the Epson ESC/POS command set: `ESC @` (initialize), `ESC ! n` (select print mode), `GS V 0` (cut), `ESC p 0` (kick-out to drawer). The browser implementation in Phase 2 uses `window.print()` and ignores the command set.
|
||||||
2. **Datáfono provider** (none for Phase 3; SumUp / Redsys TPVO / Verifone for Phase 7)?
|
2. ✅ **Datáfono provider** — **RESOLVED**: **none for Phase 3** (manual auth code entry). Phase 7 (`POS-038`) deferred indefinitely.
|
||||||
3. **Scale brand** if any (Phase 7+; not needed for Phase 2-6)?
|
3. ✅ **Scale brand** — **RESOLVED**: **none at first**. `POS-039` removed from the active P3 list. Reopens only if a scale is procured.
|
||||||
4. **Cash drawer wiring**: kick-out via printer, or via separate RJ11/RJ12 port?
|
4. ✅ **Cash drawer wiring** — **RESOLVED**: **kick-out via printer** (the `ESC p 0` byte sent after the receipt body). This is the standard Epson + most thermal printers setup. The cash drawer is wired to the printer's RJ12 port.
|
||||||
5. **Scanner type**: HID-only, or do we need serial/USB-HID native drivers?
|
5. ✅ **Scanner type** — **RESOLVED**: **USB** (HID-keyboard mode). The browser scanner adapter captures keystrokes when no input is focused (Phase 2 implementation). If the operator later needs serial or USB-HID native drivers, the adapter contract is preserved (Phase 7 native bridge).
|
||||||
|
|||||||
@@ -359,11 +359,13 @@ The DB-level unique index `pos_cash_sessions_open_per_terminal_idx` prevents thi
|
|||||||
|
|
||||||
## 11. Operator checklist (Phase 1 → Phase 2)
|
## 11. Operator checklist (Phase 1 → Phase 2)
|
||||||
|
|
||||||
- [ ] Approve POS_ARCHITECTURE.md, POS_DATA_MODEL.md, POS_API.md, POS_HARDWARE.md, POS_OPERATIONS.md.
|
- [x] Approve POS_ARCHITECTURE.md, POS_DATA_MODEL.md, POS_API.md, POS_HARDWARE.md, POS_OPERATIONS.md.
|
||||||
- [ ] Confirm default store name for migration 043.
|
- [x] Confirm default store name for migration 043 — **"Natural - Mercado de Vida"**.
|
||||||
- [ ] Confirm cookie domain for the POS app (same origin as admin? subdomain?).
|
- [x] Confirm cookie domain for the POS app — **separate port `:3006` for now; subdomains via Traefik in production**.
|
||||||
- [ ] Provide list of TPV devices and binding strategy.
|
- [ ] Provide list of TPV devices and binding strategy.
|
||||||
- [ ] Schedule a downtime window for migration 044 (multi-store inventory).
|
- [ ] Schedule a downtime window for migration 044 (multi-store inventory).
|
||||||
- [ ] Schedule training for cashiers on the new register flow.
|
- [ ] Schedule training for cashiers on the new register flow.
|
||||||
|
- [ ] Confirm refund window in `store_settings` (15 days, manager approval > 50 €) once admin UI exposes the field.
|
||||||
|
- [ ] Procure Epson thermal receipt printer + USB HID scanner per terminal.
|
||||||
|
|
||||||
Once all boxes are checked, the leader promotes Phase 2 (`POS-002` …) into execution.
|
Once the unchecked items are confirmed, the leader promotes Phase 2 (`POS-002` …) into execution.
|
||||||
|
|||||||
@@ -132,9 +132,13 @@ POS-NNN [P?] [Phase] Title
|
|||||||
|
|
||||||
- **Why:** Customer returns are an everyday reality.
|
- **Why:** Customer returns are an everyday reality.
|
||||||
- **Scope IN:** `POST /pos/sales/:id/refund` with line-level granularity. Refunds create negative `payments_transactions` rows and `inventory_movements.operation='set_available'` rows.
|
- **Scope IN:** `POST /pos/sales/:id/refund` with line-level granularity. Refunds create negative `payments_transactions` rows and `inventory_movements.operation='set_available'` rows.
|
||||||
|
- **Refund policy (per operator decision 2026-08-21):**
|
||||||
|
- Refunds allowed up to **15 days** after sale. Server returns 409 if the sale is older.
|
||||||
|
- Refunds > **50 €** require `pos_manager` or `admin` role; smaller refunds accept `pos_cashier`.
|
||||||
|
- Limits are configurable in `store_settings` (`refund_window_days`, `refund_manager_threshold_cents`).
|
||||||
- **Scope OUT:** Refund UI (POS-026).
|
- **Scope OUT:** Refund UI (POS-026).
|
||||||
- **Touches:** `project/src/modules/pos/api/pos.routes.ts`, `project/src/modules/pos/application/refund-pos-sale.ts`.
|
- **Touches:** `project/src/modules/pos/api/pos.routes.ts`, `project/src/modules/pos/application/refund-pos-sale.ts`.
|
||||||
- **Acceptance:** partial refund returns `PARTIALLY_REFUNDED`, full refund returns `REFUNDED`; stock added back; audit logged with actor.
|
- **Acceptance:** partial refund returns `PARTIALLY_REFUNDED`, full refund returns `REFUNDED`; stock added back; audit logged with actor; refund window respected; manager threshold enforced.
|
||||||
|
|
||||||
### `POS-014` [P0] [Phase 2] POS API — sale cancellation (within window)
|
### `POS-014` [P0] [Phase 2] POS API — sale cancellation (within window)
|
||||||
|
|
||||||
@@ -280,9 +284,10 @@ POS-NNN [P?] [Phase] Title
|
|||||||
|
|
||||||
- **Why:** Touch-friendly UI is a separate design.
|
- **Why:** Touch-friendly UI is a separate design.
|
||||||
- **Scope IN:** CSS variables for hit-target size and spacing; conditional class `touch` on root; bigger QuickProduct tiles; on-screen numeric keypad always visible during cash payment.
|
- **Scope IN:** CSS variables for hit-target size and spacing; conditional class `touch` on root; bigger QuickProduct tiles; on-screen numeric keypad always visible during cash payment.
|
||||||
|
- **Hardware target (per operator decision 2026-08-21):** 10-inch tablet in landscape, with `(pointer: coarse)` media query as the detection signal. Minimum hit target 48×48 px (CSS variable `--touch-target-min`); preferred 56×56 px. Cash payment screen reserves ≥ 60% of viewport for the keypad.
|
||||||
- **Scope OUT:** Native number pad (deferred to native bridge).
|
- **Scope OUT:** Native number pad (deferred to native bridge).
|
||||||
- **Touches:** `apps/pos/src/styles/touch.css`, `apps/pos/src/components/pos/**` styles.
|
- **Touches:** `apps/pos/src/styles/touch.css`, `apps/pos/src/components/pos/**` styles.
|
||||||
- **Acceptance:** manual test on a 10-inch touchscreen tablet shows no accidental taps; keypad is reachable without a physical keyboard.
|
- **Acceptance:** manual test on a 10-inch touchscreen tablet (and on a 10-inch tablet portrait) shows no accidental taps; keypad is reachable without a physical keyboard.
|
||||||
|
|
||||||
### `POS-031` [P2] [Phase 5] Keyboard shortcuts (desktop)
|
### `POS-031` [P2] [Phase 5] Keyboard shortcuts (desktop)
|
||||||
|
|
||||||
@@ -344,17 +349,18 @@ POS-NNN [P?] [Phase] Title
|
|||||||
- **Touches:** `project/src/shared/hardware/native-bridge-spec/**` (new, optional).
|
- **Touches:** `project/src/shared/hardware/native-bridge-spec/**` (new, optional).
|
||||||
- **Acceptance:** README documents the HTTP contract and how a bridge would slot in.
|
- **Acceptance:** README documents the HTTP contract and how a bridge would slot in.
|
||||||
|
|
||||||
### `POS-038` [P2] [Phase 6] Vendor adapter: SumUp (deferred)
|
### `POS-038` [P3] [Phase 6] Vendor adapter: SumUp (DEFERRED)
|
||||||
|
|
||||||
- **Why:** SumUp is a likely first datáfono vendor.
|
- **Why:** SumUp is a likely first datáfono vendor.
|
||||||
- **Status:** P3 until operator picks SumUp.
|
- **Status:** **DEFERRED indefinitely** (operator decision 2026-08-21: stays manual). This ticket remains in the backlog as a placeholder; reopen only if the operator commits to SumUp.
|
||||||
- **Scope IN:** native bridge process (`pos-sumup-bridge`) + `apps/pos/src/lib/hardware/sumup-payment-terminal-adapter.ts`.
|
- **Scope IN:** native bridge process (`pos-sumup-bridge`) + `apps/pos/src/lib/hardware/sumup-payment-terminal-adapter.ts`.
|
||||||
- **Touches:** new out-of-repo Node project (not in this codebase).
|
- **Touches:** new out-of-repo Node project (not in this codebase).
|
||||||
- **Acceptance:** SumUp terminal charges the customer; auth code returns to POS; payment is recorded in `payments_transactions`.
|
- **Acceptance:** SumUp terminal charges the customer; auth code returns to POS; payment is recorded in `payments_transactions`.
|
||||||
|
|
||||||
### `POS-039` [P3] [Phase 6] Scale adapter
|
### `POS-039` [P3] [Phase 6] Scale adapter (DEFERRED)
|
||||||
|
|
||||||
- **Why:** Some products are sold by weight.
|
- **Why:** Some products are sold by weight.
|
||||||
|
- **Status:** **DEFERRED indefinitely** (operator decision 2026-08-21: no scale at first). Ticket remains as a placeholder; reopen only if a scale is procured.
|
||||||
- **Scope IN:** Scale adapter interface (already in POS-034); browser manual entry in Phase 5; native bridge in Phase 7+.
|
- **Scope IN:** Scale adapter interface (already in POS-034); browser manual entry in Phase 5; native bridge in Phase 7+.
|
||||||
- **Scope OUT:** Vendor-specific bridges.
|
- **Scope OUT:** Vendor-specific bridges.
|
||||||
- **Touches:** `apps/pos/src/lib/hardware/scale-adapter.ts`.
|
- **Touches:** `apps/pos/src/lib/hardware/scale-adapter.ts`.
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
|
// Keep Turbopack rooted at this app. The repository also contains the
|
||||||
|
// legacy project/frontend/package-lock.json; without an explicit root,
|
||||||
|
// Next.js 16 may infer the wrong workspace during production builds.
|
||||||
|
turbopack: {
|
||||||
|
root: __dirname,
|
||||||
|
},
|
||||||
outputFileTracingRoot: __dirname,
|
outputFileTracingRoot: __dirname,
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
|
|||||||
188
project/migrations/043_pos_basics.js
Normal file
188
project/migrations/043_pos_basics.js
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
/**
|
||||||
|
* F-136 / POS-002 — POS basics: 6 new tables + seed.
|
||||||
|
*
|
||||||
|
* Creates the POS-specific tables. Idempotent (`IF NOT EXISTS` everywhere).
|
||||||
|
*
|
||||||
|
* pos_stores — physical storefronts (multi-store support)
|
||||||
|
* pos_terminals — TPV devices with persistent binding
|
||||||
|
* pos_payment_methods — configurable payment types per store
|
||||||
|
* pos_quick_products — favorite product grid per scope
|
||||||
|
* pos_parked_tickets — held tickets recoverable from any terminal in the store
|
||||||
|
* pos_cash_sessions — register shift lifecycle (OPEN/CLOSED)
|
||||||
|
*
|
||||||
|
* Seed at the end (after all tables exist):
|
||||||
|
* - One pos_stores row with the well-known UUID used by the default-store
|
||||||
|
* backfill in migration 044 and by the checkout flow's DEFAULT_STORE_ID.
|
||||||
|
* - Two pos_payment_methods rows (cash, card) for the default store.
|
||||||
|
*
|
||||||
|
* @param {import('node-pg-migrate').MigrationBuilder} pgm
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Well-known UUID for the default store. Kept here as the source of truth. */
|
||||||
|
const DEFAULT_STORE_ID = '00000000-0000-0000-0000-000000000001';
|
||||||
|
|
||||||
|
export const up = (pgm) => {
|
||||||
|
// ── pos_stores ────────────────────────────────────────────────────────────
|
||||||
|
pgm.createTable('pos_stores', {
|
||||||
|
id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') },
|
||||||
|
name: { type: 'text', notNull: true, check: "length(name) BETWEEN 1 AND 200" },
|
||||||
|
slug: { type: 'text', notNull: true, unique: true, check: "slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$'" },
|
||||||
|
address: { type: 'text' },
|
||||||
|
tax_id: { type: 'text' },
|
||||||
|
contact_email: { type: 'text' },
|
||||||
|
contact_phone: { type: 'text' },
|
||||||
|
receipt_header: { type: 'text' },
|
||||||
|
receipt_footer: { type: 'text' },
|
||||||
|
settings: { type: 'jsonb', notNull: true, default: pgm.func("'{}'::jsonb") },
|
||||||
|
active: { type: 'boolean', notNull: true, default: true },
|
||||||
|
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
|
||||||
|
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── pos_terminals ─────────────────────────────────────────────────────────
|
||||||
|
pgm.createTable('pos_terminals', {
|
||||||
|
id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') },
|
||||||
|
store_id: { type: 'uuid', notNull: true, references: 'pos_stores(id)', onDelete: 'RESTRICT' },
|
||||||
|
name: { type: 'text', notNull: true, check: "length(name) BETWEEN 1 AND 100" },
|
||||||
|
binding_code: { type: 'text', unique: true },
|
||||||
|
bound_at: { type: 'timestamptz' },
|
||||||
|
status: {
|
||||||
|
type: 'text',
|
||||||
|
notNull: true,
|
||||||
|
default: 'active',
|
||||||
|
check: "status IN ('active','disabled','decommissioned')",
|
||||||
|
},
|
||||||
|
interface_mode: {
|
||||||
|
type: 'text',
|
||||||
|
notNull: true,
|
||||||
|
default: 'auto',
|
||||||
|
check: "interface_mode IN ('desktop','touch','auto')",
|
||||||
|
},
|
||||||
|
settings: { type: 'jsonb', notNull: true, default: pgm.func("'{}'::jsonb") },
|
||||||
|
last_seen_at: { type: 'timestamptz' },
|
||||||
|
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
|
||||||
|
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
|
||||||
|
});
|
||||||
|
pgm.createIndex('pos_terminals', 'store_id', { name: 'pos_terminals_store_idx' });
|
||||||
|
pgm.createIndex('pos_terminals', 'binding_code', {
|
||||||
|
name: 'pos_terminals_binding_code_idx',
|
||||||
|
where: 'binding_code IS NOT NULL',
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── pos_payment_methods ───────────────────────────────────────────────────
|
||||||
|
pgm.createTable('pos_payment_methods', {
|
||||||
|
id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') },
|
||||||
|
store_id: { type: 'uuid', notNull: true, references: 'pos_stores(id)', onDelete: 'CASCADE' },
|
||||||
|
code: { type: 'text', notNull: true, check: "length(code) BETWEEN 1 AND 32" },
|
||||||
|
label: { type: 'text', notNull: true, check: "length(label) BETWEEN 1 AND 64" },
|
||||||
|
kind: { type: 'text', notNull: true, check: "kind IN ('cash','card','other')" },
|
||||||
|
active: { type: 'boolean', notNull: true, default: true },
|
||||||
|
sort_order: { type: 'integer', notNull: true, default: 0 },
|
||||||
|
config: { type: 'jsonb', notNull: true, default: pgm.func("'{}'::jsonb") },
|
||||||
|
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
|
||||||
|
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
|
||||||
|
}, { constraints: { unique: ['store_id', 'code'] } });
|
||||||
|
pgm.createIndex('pos_payment_methods', ['store_id', 'sort_order'], {
|
||||||
|
name: 'pos_payment_methods_store_active_idx',
|
||||||
|
where: 'active',
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── pos_quick_products ────────────────────────────────────────────────────
|
||||||
|
pgm.createTable('pos_quick_products', {
|
||||||
|
id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') },
|
||||||
|
store_id: { type: 'uuid', notNull: true, references: 'pos_stores(id)', onDelete: 'CASCADE' },
|
||||||
|
terminal_id: { type: 'uuid', references: 'pos_terminals(id)', onDelete: 'CASCADE' },
|
||||||
|
user_id: { type: 'uuid', references: 'backoffice_users(id)', onDelete: 'CASCADE' },
|
||||||
|
variant_id: { type: 'uuid', notNull: true, references: 'catalog_product_variants(id)', onDelete: 'CASCADE' },
|
||||||
|
position: { type: 'integer', notNull: true, default: 0 },
|
||||||
|
active: { type: 'boolean', notNull: true, default: true },
|
||||||
|
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
|
||||||
|
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── pos_parked_tickets ───────────────────────────────────────────────────
|
||||||
|
pgm.createTable('pos_parked_tickets', {
|
||||||
|
id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') },
|
||||||
|
store_id: { type: 'uuid', notNull: true, references: 'pos_stores(id)', onDelete: 'CASCADE' },
|
||||||
|
terminal_id: { type: 'uuid', notNull: true, references: 'pos_terminals(id)', onDelete: 'RESTRICT' },
|
||||||
|
user_id: { type: 'uuid', notNull: true, references: 'backoffice_users(id)', onDelete: 'RESTRICT' },
|
||||||
|
customer_id: { type: 'uuid', references: 'identity_users(id)', onDelete: 'SET NULL' },
|
||||||
|
label: { type: 'text' },
|
||||||
|
cart: { type: 'jsonb', notNull: true },
|
||||||
|
expires_at: {
|
||||||
|
type: 'timestamptz',
|
||||||
|
notNull: true,
|
||||||
|
default: pgm.func("(now() + interval '24 hours')"),
|
||||||
|
},
|
||||||
|
recalled_at: { type: 'timestamptz' },
|
||||||
|
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
|
||||||
|
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
|
||||||
|
});
|
||||||
|
pgm.createIndex('pos_parked_tickets', ['store_id', { name: 'created_at', sort: 'DESC' }], {
|
||||||
|
name: 'pos_parked_tickets_store_active_idx',
|
||||||
|
where: 'recalled_at IS NULL',
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── pos_cash_sessions ────────────────────────────────────────────────────
|
||||||
|
pgm.createTable('pos_cash_sessions', {
|
||||||
|
id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') },
|
||||||
|
terminal_id: { type: 'uuid', notNull: true, references: 'pos_terminals(id)', onDelete: 'RESTRICT' },
|
||||||
|
store_id: { type: 'uuid', notNull: true, references: 'pos_stores(id)', onDelete: 'RESTRICT' },
|
||||||
|
user_id: { type: 'uuid', notNull: true, references: 'backoffice_users(id)', onDelete: 'RESTRICT' },
|
||||||
|
status: { type: 'text', notNull: true, default: 'OPEN', check: "status IN ('OPEN','CLOSED')" },
|
||||||
|
opened_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
|
||||||
|
closed_at: { type: 'timestamptz' },
|
||||||
|
opening_cash_cents: { type: 'integer', notNull: true, default: 0, check: 'opening_cash_cents >= 0' },
|
||||||
|
closing_cash_cents: { type: 'integer' },
|
||||||
|
expected_cash_cents: { type: 'integer' },
|
||||||
|
actual_cash_cents: { type: 'integer' },
|
||||||
|
difference_cents: { type: 'integer' },
|
||||||
|
notes: { type: 'text' },
|
||||||
|
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
|
||||||
|
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
|
||||||
|
});
|
||||||
|
// Only one OPEN session per terminal at a time.
|
||||||
|
pgm.createIndex('pos_cash_sessions', 'terminal_id', {
|
||||||
|
name: 'pos_cash_sessions_open_per_terminal_idx',
|
||||||
|
unique: true,
|
||||||
|
where: "status = 'OPEN'",
|
||||||
|
});
|
||||||
|
pgm.createIndex('pos_cash_sessions', 'store_id', { name: 'pos_cash_sessions_store_idx' });
|
||||||
|
pgm.createIndex('pos_cash_sessions', 'user_id', { name: 'pos_cash_sessions_user_idx' });
|
||||||
|
|
||||||
|
// ── Seeds ────────────────────────────────────────────────────────────────
|
||||||
|
// The default store; this UUID is also referenced as DEFAULT_STORE_ID from
|
||||||
|
// the inventory module (see 044_pos_inventory_store.js and
|
||||||
|
// project/src/modules/inventory/domain/ports.ts).
|
||||||
|
pgm.sql(`
|
||||||
|
INSERT INTO pos_stores (id, name, slug, active, receipt_header, receipt_footer)
|
||||||
|
VALUES (
|
||||||
|
'${DEFAULT_STORE_ID}',
|
||||||
|
'Natural - Mercado de Vida',
|
||||||
|
'natural-mercado-de-vida',
|
||||||
|
true,
|
||||||
|
'Natural - Mercado de Vida',
|
||||||
|
'Gracias por su compra'
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
`);
|
||||||
|
|
||||||
|
pgm.sql(`
|
||||||
|
INSERT INTO pos_payment_methods (store_id, code, label, kind, sort_order)
|
||||||
|
VALUES
|
||||||
|
('${DEFAULT_STORE_ID}', 'cash', 'Efectivo', 'cash', 0),
|
||||||
|
('${DEFAULT_STORE_ID}', 'card', 'Tarjeta', 'card', 1)
|
||||||
|
ON CONFLICT (store_id, code) DO NOTHING
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const down = (pgm) => {
|
||||||
|
// Drop in reverse dependency order. The CASCADE on FKs cleans up dependent
|
||||||
|
// rows automatically.
|
||||||
|
pgm.dropTable('pos_cash_sessions');
|
||||||
|
pgm.dropTable('pos_parked_tickets');
|
||||||
|
pgm.dropTable('pos_quick_products');
|
||||||
|
pgm.dropTable('pos_payment_methods');
|
||||||
|
pgm.dropTable('pos_terminals');
|
||||||
|
pgm.dropTable('pos_stores');
|
||||||
|
};
|
||||||
112
project/migrations/044_pos_inventory_store.js
Normal file
112
project/migrations/044_pos_inventory_store.js
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* POS-002 — Add `store_id` to `inventory_stock` and `inventory_movements`.
|
||||||
|
*
|
||||||
|
* Steps in `up()`:
|
||||||
|
* 1. Add `store_id` as a NULLABLE column (so the backfill doesn't fail).
|
||||||
|
* 2. Backfill every existing row with the well-known default store UUID.
|
||||||
|
* 3. Make `store_id` NOT NULL and add the FK to `pos_stores`.
|
||||||
|
* 4. Drop the old single-column unique constraint on `inventory_stock.variant_id`.
|
||||||
|
* 5. Add the new composite unique constraint `(variant_id, store_id)`.
|
||||||
|
* 6. Mirror the same 5-step pattern for `inventory_movements` (without the
|
||||||
|
* unique constraint, since movements don't have a UNIQUE invariant).
|
||||||
|
*
|
||||||
|
* Idempotent: every ALTER uses `IF NOT EXISTS` / `IF EXISTS` / `DO` blocks
|
||||||
|
* so re-running is a no-op.
|
||||||
|
*
|
||||||
|
* @param {import('node-pg-migrate').MigrationBuilder} pgm
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DEFAULT_STORE_ID = '00000000-0000-0000-0000-000000000001';
|
||||||
|
|
||||||
|
export const up = async (pgm) => {
|
||||||
|
// ── inventory_stock ───────────────────────────────────────────────────────
|
||||||
|
pgm.sql(`
|
||||||
|
ALTER TABLE inventory_stock
|
||||||
|
ADD COLUMN IF NOT EXISTS store_id uuid
|
||||||
|
`);
|
||||||
|
pgm.sql(`
|
||||||
|
UPDATE inventory_stock
|
||||||
|
SET store_id = '${DEFAULT_STORE_ID}'
|
||||||
|
WHERE store_id IS NULL
|
||||||
|
`);
|
||||||
|
pgm.sql(`
|
||||||
|
ALTER TABLE inventory_stock
|
||||||
|
ALTER COLUMN store_id SET NOT NULL
|
||||||
|
`);
|
||||||
|
pgm.sql(`
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conname = 'inventory_stock_store_id_fkey'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE inventory_stock
|
||||||
|
ADD CONSTRAINT inventory_stock_store_id_fkey
|
||||||
|
FOREIGN KEY (store_id) REFERENCES pos_stores(id) ON DELETE RESTRICT;
|
||||||
|
END IF;
|
||||||
|
END $$
|
||||||
|
`);
|
||||||
|
pgm.sql(`ALTER TABLE inventory_stock DROP CONSTRAINT IF EXISTS inventory_stock_variant_id_key`);
|
||||||
|
pgm.sql(`
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conname = 'inventory_stock_variant_store_unique'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE inventory_stock
|
||||||
|
ADD CONSTRAINT inventory_stock_variant_store_unique
|
||||||
|
UNIQUE (variant_id, store_id);
|
||||||
|
END IF;
|
||||||
|
END $$
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ── inventory_movements ──────────────────────────────────────────────────
|
||||||
|
pgm.sql(`
|
||||||
|
ALTER TABLE inventory_movements
|
||||||
|
ADD COLUMN IF NOT EXISTS store_id uuid
|
||||||
|
`);
|
||||||
|
pgm.sql(`
|
||||||
|
UPDATE inventory_movements
|
||||||
|
SET store_id = '${DEFAULT_STORE_ID}'
|
||||||
|
WHERE store_id IS NULL
|
||||||
|
`);
|
||||||
|
pgm.sql(`
|
||||||
|
ALTER TABLE inventory_movements
|
||||||
|
ALTER COLUMN store_id SET NOT NULL
|
||||||
|
`);
|
||||||
|
pgm.sql(`
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conname = 'inventory_movements_store_id_fkey'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE inventory_movements
|
||||||
|
ADD CONSTRAINT inventory_movements_store_id_fkey
|
||||||
|
FOREIGN KEY (store_id) REFERENCES pos_stores(id) ON DELETE RESTRICT;
|
||||||
|
END IF;
|
||||||
|
END $$
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const down = (pgm) => {
|
||||||
|
pgm.sql(`ALTER TABLE inventory_movements DROP CONSTRAINT IF EXISTS inventory_movements_store_id_fkey`);
|
||||||
|
pgm.sql(`ALTER TABLE inventory_movements DROP COLUMN IF EXISTS store_id`);
|
||||||
|
|
||||||
|
pgm.sql(`ALTER TABLE inventory_stock DROP CONSTRAINT IF EXISTS inventory_stock_variant_store_unique`);
|
||||||
|
pgm.sql(`
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conname = 'inventory_stock_variant_id_key'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE inventory_stock
|
||||||
|
ADD CONSTRAINT inventory_stock_variant_id_key UNIQUE (variant_id);
|
||||||
|
END IF;
|
||||||
|
END $$
|
||||||
|
`);
|
||||||
|
pgm.sql(`ALTER TABLE inventory_stock DROP CONSTRAINT IF EXISTS inventory_stock_store_id_fkey`);
|
||||||
|
pgm.sql(`ALTER TABLE inventory_stock DROP COLUMN IF EXISTS store_id`);
|
||||||
|
};
|
||||||
149
project/migrations/045_pos_orders_source.js
Normal file
149
project/migrations/045_pos_orders_source.js
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* POS-002 — Add `source`, `terminal_id`, `cash_session_id` to `orders_orders`;
|
||||||
|
* drop NOT NULL on `user_id`; add `COMPLETED` to the state CHECK.
|
||||||
|
*
|
||||||
|
* The `source` column distinguishes ecommerce / pos / admin sales. POS sales
|
||||||
|
* may have `user_id IS NULL` (walk-in customer); ecommerce and admin sales
|
||||||
|
* must have a non-null `user_id` (enforced by the application layer; this
|
||||||
|
* migration only relaxes the column).
|
||||||
|
*
|
||||||
|
* The `terminal_id` and `cash_session_id` FKs use `ON DELETE SET NULL` so
|
||||||
|
* decommissioning a terminal or closing its session does not delete
|
||||||
|
* historical sales.
|
||||||
|
*
|
||||||
|
* The state CHECK is rewritten with the new `COMPLETED` value. POS sales
|
||||||
|
* transition PENDING → AWAITING_PAYMENT → PAID → COMPLETED, skipping
|
||||||
|
* SHIPPED / DELIVERED.
|
||||||
|
*
|
||||||
|
* @param {import('node-pg-migrate').MigrationBuilder} pgm
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const up = (pgm) => {
|
||||||
|
// ── Add columns ──────────────────────────────────────────────────────────
|
||||||
|
pgm.sql(`
|
||||||
|
ALTER TABLE orders_orders
|
||||||
|
ADD COLUMN IF NOT EXISTS source text NOT NULL DEFAULT 'ecommerce'
|
||||||
|
`);
|
||||||
|
pgm.sql(`
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conname = 'orders_orders_source_check'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE orders_orders
|
||||||
|
ADD CONSTRAINT orders_orders_source_check
|
||||||
|
CHECK (source IN ('ecommerce','pos','admin'));
|
||||||
|
END IF;
|
||||||
|
END $$
|
||||||
|
`);
|
||||||
|
|
||||||
|
pgm.sql(`ALTER TABLE orders_orders ADD COLUMN IF NOT EXISTS terminal_id uuid`);
|
||||||
|
pgm.sql(`ALTER TABLE orders_orders ADD COLUMN IF NOT EXISTS cash_session_id uuid`);
|
||||||
|
|
||||||
|
pgm.sql(`
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conname = 'orders_orders_terminal_id_fkey'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE orders_orders
|
||||||
|
ADD CONSTRAINT orders_orders_terminal_id_fkey
|
||||||
|
FOREIGN KEY (terminal_id) REFERENCES pos_terminals(id) ON DELETE SET NULL;
|
||||||
|
END IF;
|
||||||
|
END $$
|
||||||
|
`);
|
||||||
|
pgm.sql(`
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conname = 'orders_orders_cash_session_id_fkey'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE orders_orders
|
||||||
|
ADD CONSTRAINT orders_orders_cash_session_id_fkey
|
||||||
|
FOREIGN KEY (cash_session_id) REFERENCES pos_cash_sessions(id) ON DELETE SET NULL;
|
||||||
|
END IF;
|
||||||
|
END $$
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ── Indexes ──────────────────────────────────────────────────────────────
|
||||||
|
pgm.createIndex('orders_orders', 'source', { name: 'orders_orders_source_idx' });
|
||||||
|
pgm.createIndex('orders_orders', 'terminal_id', {
|
||||||
|
name: 'orders_orders_terminal_idx',
|
||||||
|
where: 'terminal_id IS NOT NULL',
|
||||||
|
});
|
||||||
|
pgm.createIndex('orders_orders', 'cash_session_id', {
|
||||||
|
name: 'orders_orders_cash_session_idx',
|
||||||
|
where: 'cash_session_id IS NOT NULL',
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Walk-in customers (POS only) ─────────────────────────────────────────
|
||||||
|
pgm.sql(`ALTER TABLE orders_orders ALTER COLUMN user_id DROP NOT NULL`);
|
||||||
|
|
||||||
|
// CHECK: walk-in customers (user_id NULL) are allowed only for POS sales.
|
||||||
|
// Ecommerce and admin sales must have a non-null user_id. DB-level invariant
|
||||||
|
// enforced in addition to the application-level check.
|
||||||
|
pgm.sql(`
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conname = 'orders_orders_user_required_when_not_pos'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE orders_orders
|
||||||
|
ADD CONSTRAINT orders_orders_user_required_when_not_pos
|
||||||
|
CHECK (source = 'pos' OR user_id IS NOT NULL);
|
||||||
|
END IF;
|
||||||
|
END $$
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ── Add COMPLETED to state CHECK ─────────────────────────────────────────
|
||||||
|
pgm.sql(`ALTER TABLE orders_orders DROP CONSTRAINT IF EXISTS orders_orders_state_check`);
|
||||||
|
pgm.sql(`
|
||||||
|
ALTER TABLE orders_orders
|
||||||
|
ADD CONSTRAINT orders_orders_state_check
|
||||||
|
CHECK (state IN (
|
||||||
|
'PENDING',
|
||||||
|
'AWAITING_PAYMENT',
|
||||||
|
'PAID',
|
||||||
|
'PROCESSING',
|
||||||
|
'SHIPPED',
|
||||||
|
'DELIVERED',
|
||||||
|
'COMPLETED',
|
||||||
|
'CANCELLED',
|
||||||
|
'REFUNDED',
|
||||||
|
'PARTIALLY_REFUNDED'
|
||||||
|
))
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const down = (pgm) => {
|
||||||
|
// Re-tighten state CHECK to the pre-COMPLETED set; this will fail if any
|
||||||
|
// COMPLETED rows exist.
|
||||||
|
pgm.sql(`ALTER TABLE orders_orders DROP CONSTRAINT IF EXISTS orders_orders_state_check`);
|
||||||
|
pgm.sql(`
|
||||||
|
ALTER TABLE orders_orders
|
||||||
|
ADD CONSTRAINT orders_orders_state_check
|
||||||
|
CHECK (state IN (
|
||||||
|
'PENDING','AWAITING_PAYMENT','PAID','PROCESSING',
|
||||||
|
'SHIPPED','DELIVERED','CANCELLED','REFUNDED','PARTIALLY_REFUNDED'
|
||||||
|
))
|
||||||
|
`);
|
||||||
|
|
||||||
|
// user_id becomes NOT NULL again; fails if any NULL rows exist.
|
||||||
|
pgm.sql(`ALTER TABLE orders_orders DROP CONSTRAINT IF EXISTS orders_orders_user_required_when_not_pos`);
|
||||||
|
pgm.sql(`ALTER TABLE orders_orders ALTER COLUMN user_id SET NOT NULL`);
|
||||||
|
|
||||||
|
pgm.sql(`DROP INDEX IF EXISTS orders_orders_cash_session_idx`);
|
||||||
|
pgm.sql(`DROP INDEX IF EXISTS orders_orders_terminal_idx`);
|
||||||
|
pgm.sql(`DROP INDEX IF EXISTS orders_orders_source_idx`);
|
||||||
|
|
||||||
|
pgm.sql(`ALTER TABLE orders_orders DROP CONSTRAINT IF EXISTS orders_orders_cash_session_id_fkey`);
|
||||||
|
pgm.sql(`ALTER TABLE orders_orders DROP CONSTRAINT IF EXISTS orders_orders_terminal_id_fkey`);
|
||||||
|
pgm.sql(`ALTER TABLE orders_orders DROP COLUMN IF EXISTS cash_session_id`);
|
||||||
|
pgm.sql(`ALTER TABLE orders_orders DROP COLUMN IF EXISTS terminal_id`);
|
||||||
|
pgm.sql(`ALTER TABLE orders_orders DROP CONSTRAINT IF EXISTS orders_orders_source_check`);
|
||||||
|
pgm.sql(`ALTER TABLE orders_orders DROP COLUMN IF EXISTS source`);
|
||||||
|
};
|
||||||
45
project/migrations/046_pos_backoffice_roles.js
Normal file
45
project/migrations/046_pos_backoffice_roles.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* POS-002 — Extend `backoffice_users.role` CHECK to include POS roles.
|
||||||
|
*
|
||||||
|
* New roles: `pos_cashier`, `pos_manager`. The application-side enum
|
||||||
|
* (`Role` in `project/src/shared/auth.ts`) is updated in the same ticket.
|
||||||
|
*
|
||||||
|
* Idempotent: `IF EXISTS` on the old constraint + `IF NOT EXISTS` guard on
|
||||||
|
* the new one.
|
||||||
|
*
|
||||||
|
* @param {import('node-pg-migrate').MigrationBuilder} pgm
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const up = (pgm) => {
|
||||||
|
pgm.sql(`ALTER TABLE backoffice_users DROP CONSTRAINT IF EXISTS backoffice_users_role_check`);
|
||||||
|
pgm.sql(`
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conname = 'backoffice_users_role_check'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE backoffice_users
|
||||||
|
ADD CONSTRAINT backoffice_users_role_check
|
||||||
|
CHECK (role IN ('admin','editor','pos_cashier','pos_manager'));
|
||||||
|
END IF;
|
||||||
|
END $$
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const down = (pgm) => {
|
||||||
|
pgm.sql(`ALTER TABLE backoffice_users DROP CONSTRAINT IF EXISTS backoffice_users_role_check`);
|
||||||
|
pgm.sql(`
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conname = 'backoffice_users_role_check'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE backoffice_users
|
||||||
|
ADD CONSTRAINT backoffice_users_role_check
|
||||||
|
CHECK (role IN ('admin','editor'));
|
||||||
|
END IF;
|
||||||
|
END $$
|
||||||
|
`);
|
||||||
|
};
|
||||||
34
project/migrations/047_orders_user_required_when_not_pos.js
Normal file
34
project/migrations/047_orders_user_required_when_not_pos.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* POS-002 catch-up — Add the walk-in CHECK constraint to existing DBs.
|
||||||
|
*
|
||||||
|
* Migration 045 drops NOT NULL on `orders_orders.user_id` (so POS walk-in
|
||||||
|
* sales work) but originally forgot the matching CHECK: ecommerce and admin
|
||||||
|
* sales must still have a non-null user_id. This migration adds the CHECK
|
||||||
|
* for DBs that already ran 045 before the fix landed.
|
||||||
|
*
|
||||||
|
* Idempotent: the constraint is added via `DO $$ IF NOT EXISTS ...`,
|
||||||
|
* so re-running on a fresh DB that already has the CHECK (via 045) is a
|
||||||
|
* no-op.
|
||||||
|
*
|
||||||
|
* @param {import('node-pg-migrate').MigrationBuilder} pgm
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const up = (pgm) => {
|
||||||
|
pgm.sql(`
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conname = 'orders_orders_user_required_when_not_pos'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE orders_orders
|
||||||
|
ADD CONSTRAINT orders_orders_user_required_when_not_pos
|
||||||
|
CHECK (source = 'pos' OR user_id IS NOT NULL);
|
||||||
|
END IF;
|
||||||
|
END $$
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const down = (pgm) => {
|
||||||
|
pgm.sql(`ALTER TABLE orders_orders DROP CONSTRAINT IF EXISTS orders_orders_user_required_when_not_pos`);
|
||||||
|
};
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from '../../infrastructure/db/tests/db-test-support.js';
|
} from '../../infrastructure/db/tests/db-test-support.js';
|
||||||
import {
|
import {
|
||||||
createInventoryService,
|
createInventoryService,
|
||||||
|
DEFAULT_STORE_ID,
|
||||||
InsufficientStockError,
|
InsufficientStockError,
|
||||||
type InventoryService,
|
type InventoryService,
|
||||||
} from '../../modules/inventory/index.js';
|
} from '../../modules/inventory/index.js';
|
||||||
@@ -33,10 +34,12 @@ describe.skipIf(!hasDb)('inventory flows (real PostgreSQL)', () => {
|
|||||||
|
|
||||||
it('allows exactly one concurrent reservation for the last unit (AC1)', async () => {
|
it('allows exactly one concurrent reservation for the last unit (AC1)', async () => {
|
||||||
const variantId = randomUUID();
|
const variantId = randomUUID();
|
||||||
await inventory.setAvailable({ variantId, quantity: 1 });
|
await inventory.setAvailable({ variantId, storeId: DEFAULT_STORE_ID, quantity: 1 });
|
||||||
|
|
||||||
const attempts = await Promise.allSettled(
|
const attempts = await Promise.allSettled(
|
||||||
Array.from({ length: 10 }, () => inventory.reserve({ variantId, quantity: 1 })),
|
Array.from({ length: 10 }, () =>
|
||||||
|
inventory.reserve({ variantId, storeId: DEFAULT_STORE_ID, quantity: 1 }),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const fulfilled = attempts.filter((result) => result.status === 'fulfilled');
|
const fulfilled = attempts.filter((result) => result.status === 'fulfilled');
|
||||||
@@ -47,7 +50,7 @@ describe.skipIf(!hasDb)('inventory flows (real PostgreSQL)', () => {
|
|||||||
expect(result.reason).toBeInstanceOf(InsufficientStockError);
|
expect(result.reason).toBeInstanceOf(InsufficientStockError);
|
||||||
}
|
}
|
||||||
|
|
||||||
const availability = await inventory.checkAvailability(variantId, 1);
|
const availability = await inventory.checkAvailability(variantId, DEFAULT_STORE_ID, 1);
|
||||||
expect(availability).toEqual({ available: false, availableQuantity: 0 });
|
expect(availability).toEqual({ available: false, availableQuantity: 0 });
|
||||||
const row = await pool.query(
|
const row = await pool.query(
|
||||||
'SELECT available, reserved, sold, incoming FROM inventory_stock WHERE variant_id = $1',
|
'SELECT available, reserved, sold, incoming FROM inventory_stock WHERE variant_id = $1',
|
||||||
@@ -58,11 +61,11 @@ describe.skipIf(!hasDb)('inventory flows (real PostgreSQL)', () => {
|
|||||||
|
|
||||||
it('rejects zero-stock reservations and never makes stock negative (AC2)', async () => {
|
it('rejects zero-stock reservations and never makes stock negative (AC2)', async () => {
|
||||||
const variantId = randomUUID();
|
const variantId = randomUUID();
|
||||||
await inventory.setAvailable({ variantId, quantity: 0 });
|
await inventory.setAvailable({ variantId, storeId: DEFAULT_STORE_ID, quantity: 0 });
|
||||||
|
|
||||||
await expect(inventory.reserve({ variantId, quantity: 1 })).rejects.toBeInstanceOf(
|
await expect(
|
||||||
InsufficientStockError,
|
inventory.reserve({ variantId, storeId: DEFAULT_STORE_ID, quantity: 1 }),
|
||||||
);
|
).rejects.toBeInstanceOf(InsufficientStockError);
|
||||||
|
|
||||||
const row = await pool.query(
|
const row = await pool.query(
|
||||||
'SELECT available, reserved, sold, incoming FROM inventory_stock WHERE variant_id = $1',
|
'SELECT available, reserved, sold, incoming FROM inventory_stock WHERE variant_id = $1',
|
||||||
@@ -73,9 +76,9 @@ describe.skipIf(!hasDb)('inventory flows (real PostgreSQL)', () => {
|
|||||||
|
|
||||||
it('exposes checkAvailability through the public InventoryService interface (AC4)', async () => {
|
it('exposes checkAvailability through the public InventoryService interface (AC4)', async () => {
|
||||||
const variantId = randomUUID();
|
const variantId = randomUUID();
|
||||||
await inventory.setAvailable({ variantId, quantity: 3 });
|
await inventory.setAvailable({ variantId, storeId: DEFAULT_STORE_ID, quantity: 3 });
|
||||||
|
|
||||||
await expect(inventory.checkAvailability(variantId, 2)).resolves.toEqual({
|
await expect(inventory.checkAvailability(variantId, DEFAULT_STORE_ID, 2)).resolves.toEqual({
|
||||||
available: true,
|
available: true,
|
||||||
availableQuantity: 3,
|
availableQuantity: 3,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { InventoryServicePort } from '../../inventory/index.js';
|
import { DEFAULT_STORE_ID, type InventoryServicePort } from '../../inventory/index.js';
|
||||||
import type { PricingServicePort } from '../../pricing/index.js';
|
import type { PricingServicePort } from '../../pricing/index.js';
|
||||||
import type { PromotionServicePort } from '../../promotions/index.js';
|
import type { PromotionServicePort } from '../../promotions/index.js';
|
||||||
import { InvalidCartQuantityError, InsufficientCartStockError } from '../domain/errors.js';
|
import { InvalidCartQuantityError, InsufficientCartStockError } from '../domain/errors.js';
|
||||||
@@ -30,7 +30,7 @@ export class CartService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async assertStockAvailable(variantId: string, quantity: number): Promise<void> {
|
private async assertStockAvailable(variantId: string, quantity: number): Promise<void> {
|
||||||
const av = await this.inventory.checkAvailability(variantId, quantity);
|
const av = await this.inventory.checkAvailability(variantId, DEFAULT_STORE_ID, quantity);
|
||||||
if (!av.available) {
|
if (!av.available) {
|
||||||
throw new InsufficientCartStockError(variantId, quantity, av.availableQuantity);
|
throw new InsufficientCartStockError(variantId, quantity, av.availableQuantity);
|
||||||
}
|
}
|
||||||
@@ -59,7 +59,7 @@ export class CartService {
|
|||||||
if (error instanceof Error && error.name === 'PriceNotFoundError') return null;
|
if (error instanceof Error && error.name === 'PriceNotFoundError') return null;
|
||||||
throw error;
|
throw error;
|
||||||
}),
|
}),
|
||||||
this.inventory.checkAvailability(item.variantId, item.quantity),
|
this.inventory.checkAvailability(item.variantId, DEFAULT_STORE_ID, item.quantity),
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { InventoryServicePort } from '../../inventory/index.js';
|
import { DEFAULT_STORE_ID, type InventoryServicePort } from '../../inventory/index.js';
|
||||||
import type { OrderItemInput, OrderServicePort } from '../../orders/index.js';
|
import type { OrderItemInput, OrderServicePort } from '../../orders/index.js';
|
||||||
import type { PricingServicePort } from '../../pricing/index.js';
|
import type { PricingServicePort } from '../../pricing/index.js';
|
||||||
import type { PromotionServicePort } from '../../promotions/index.js';
|
import type { PromotionServicePort } from '../../promotions/index.js';
|
||||||
@@ -97,6 +97,7 @@ export class CheckoutService {
|
|||||||
}
|
}
|
||||||
const availability = await this.deps.inventory.checkAvailability(
|
const availability = await this.deps.inventory.checkAvailability(
|
||||||
cartItem.variantId,
|
cartItem.variantId,
|
||||||
|
DEFAULT_STORE_ID,
|
||||||
cartItem.quantity,
|
cartItem.quantity,
|
||||||
);
|
);
|
||||||
if (!availability.available) {
|
if (!availability.available) {
|
||||||
@@ -174,12 +175,18 @@ export class CheckoutService {
|
|||||||
const reserved: string[] = [];
|
const reserved: string[] = [];
|
||||||
try {
|
try {
|
||||||
for (const item of cart.items) {
|
for (const item of cart.items) {
|
||||||
await this.deps.inventory.reserve({ variantId: item.variantId, quantity: item.quantity });
|
await this.deps.inventory.reserve({
|
||||||
|
variantId: item.variantId,
|
||||||
|
storeId: DEFAULT_STORE_ID,
|
||||||
|
quantity: item.quantity,
|
||||||
|
});
|
||||||
reserved.push(item.variantId);
|
reserved.push(item.variantId);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
for (const variantId of reserved) {
|
for (const variantId of reserved) {
|
||||||
await this.deps.inventory.release({ variantId, quantity: 1 }).catch(() => undefined);
|
await this.deps.inventory
|
||||||
|
.release({ variantId, storeId: DEFAULT_STORE_ID, quantity: 1 })
|
||||||
|
.catch(() => undefined);
|
||||||
}
|
}
|
||||||
await this.deps.orders
|
await this.deps.orders
|
||||||
.transition(orderView.id, 'CANCELLED', command.userId)
|
.transition(orderView.id, 'CANCELLED', command.userId)
|
||||||
|
|||||||
@@ -211,7 +211,13 @@ describe('CheckoutService', () => {
|
|||||||
idempotencyKey: 'k-1',
|
idempotencyKey: 'k-1',
|
||||||
});
|
});
|
||||||
expect(result.order.state).toBe('AWAITING_PAYMENT');
|
expect(result.order.state).toBe('AWAITING_PAYMENT');
|
||||||
expect(deps.reserved.calls).toEqual([{ variantId: 'v-1', quantity: 1 }]);
|
expect(deps.reserved.calls).toEqual([
|
||||||
|
{
|
||||||
|
variantId: 'v-1',
|
||||||
|
storeId: '00000000-0000-0000-0000-000000000001',
|
||||||
|
quantity: 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
expect(deps.metrics.success).toBe(1);
|
expect(deps.metrics.success).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
InvalidStockQuantityError,
|
InvalidStockQuantityError,
|
||||||
} from '../domain/errors.js';
|
} from '../domain/errors.js';
|
||||||
import type { SetAvailableStockCommand, StockItem } from '../domain/stock.js';
|
import type { SetAvailableStockCommand, StockItem } from '../domain/stock.js';
|
||||||
|
import { DEFAULT_STORE_ID } from '../domain/stock.js';
|
||||||
import { PgInventoryRepository } from '../infrastructure/pg-inventory-repository.js';
|
import { PgInventoryRepository } from '../infrastructure/pg-inventory-repository.js';
|
||||||
|
|
||||||
export interface InventoryRoutesDeps {
|
export interface InventoryRoutesDeps {
|
||||||
@@ -23,9 +24,16 @@ export interface InventoryRoutesDeps {
|
|||||||
const variantParamSchema = z.object({ variantId: z.uuid() });
|
const variantParamSchema = z.object({ variantId: z.uuid() });
|
||||||
const availabilityQuerySchema = z.object({
|
const availabilityQuerySchema = z.object({
|
||||||
quantity: z.coerce.number().int().positive().default(1),
|
quantity: z.coerce.number().int().positive().default(1),
|
||||||
|
storeId: z.uuid().optional(),
|
||||||
|
});
|
||||||
|
const stockBodySchema = z.object({
|
||||||
|
quantity: z.number().int().min(0),
|
||||||
|
storeId: z.uuid().optional(),
|
||||||
|
});
|
||||||
|
const stockCommandBodySchema = z.object({
|
||||||
|
quantity: z.number().int().positive(),
|
||||||
|
storeId: z.uuid().optional(),
|
||||||
});
|
});
|
||||||
const stockBodySchema = z.object({ quantity: z.number().int().min(0) });
|
|
||||||
const stockCommandBodySchema = z.object({ quantity: z.number().int().positive() });
|
|
||||||
|
|
||||||
const bulkAdjustItemSchema = z.object({
|
const bulkAdjustItemSchema = z.object({
|
||||||
variantId: z.uuid(),
|
variantId: z.uuid(),
|
||||||
@@ -49,15 +57,25 @@ export async function registerInventoryRoutes(
|
|||||||
required: ['variantId'],
|
required: ['variantId'],
|
||||||
properties: { variantId: { type: 'string', format: 'uuid' } },
|
properties: { variantId: { type: 'string', format: 'uuid' } },
|
||||||
},
|
},
|
||||||
querystring: { type: 'object', properties: { quantity: { type: 'integer', default: 1 } } },
|
querystring: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
quantity: { type: 'integer', default: 1 },
|
||||||
|
storeId: { type: 'string', format: 'uuid' },
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
app.get(
|
app.get(
|
||||||
'/inventory/:variantId/availability',
|
'/inventory/:variantId/availability',
|
||||||
{ schema: availabilitySchema },
|
{ schema: availabilitySchema },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const { variantId } = parseJson(variantParamSchema, request.params);
|
const { variantId } = parseJson(variantParamSchema, request.params);
|
||||||
const { quantity } = parseJson(availabilityQuerySchema, request.query);
|
const { quantity, storeId } = parseJson(availabilityQuerySchema, request.query);
|
||||||
const availability = await inventory.checkAvailability(variantId, quantity);
|
const availability = await inventory.checkAvailability(
|
||||||
|
variantId,
|
||||||
|
storeId ?? DEFAULT_STORE_ID,
|
||||||
|
quantity,
|
||||||
|
);
|
||||||
return reply.send(availability);
|
return reply.send(availability);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -81,9 +99,13 @@ export async function registerInventoryRoutes(
|
|||||||
const user = await deps.authenticate(request);
|
const user = await deps.authenticate(request);
|
||||||
requireRole(user, 'admin');
|
requireRole(user, 'admin');
|
||||||
const { variantId } = parseJson(variantParamSchema, request.params);
|
const { variantId } = parseJson(variantParamSchema, request.params);
|
||||||
const { quantity } = parseJson(stockBodySchema, request.body);
|
const { quantity, storeId } = parseJson(stockBodySchema, request.body);
|
||||||
try {
|
try {
|
||||||
const item = await inventory.setAvailable({ variantId, quantity });
|
const item = await inventory.setAvailable({
|
||||||
|
variantId,
|
||||||
|
storeId: storeId ?? DEFAULT_STORE_ID,
|
||||||
|
quantity,
|
||||||
|
});
|
||||||
return reply.send(serializeStockItem(item));
|
return reply.send(serializeStockItem(item));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw mapInventoryError(error);
|
throw mapInventoryError(error);
|
||||||
@@ -112,9 +134,13 @@ export async function registerInventoryRoutes(
|
|||||||
const user = await deps.authenticate(request);
|
const user = await deps.authenticate(request);
|
||||||
requireRole(user, 'admin');
|
requireRole(user, 'admin');
|
||||||
const { variantId } = parseJson(variantParamSchema, request.params);
|
const { variantId } = parseJson(variantParamSchema, request.params);
|
||||||
const { quantity } = parseJson(stockCommandBodySchema, request.body);
|
const { quantity, storeId } = parseJson(stockCommandBodySchema, request.body);
|
||||||
try {
|
try {
|
||||||
const item = await inventory.reserve({ variantId, quantity });
|
const item = await inventory.reserve({
|
||||||
|
variantId,
|
||||||
|
storeId: storeId ?? DEFAULT_STORE_ID,
|
||||||
|
quantity,
|
||||||
|
});
|
||||||
return reply.code(201).send(serializeStockItem(item));
|
return reply.code(201).send(serializeStockItem(item));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw mapInventoryError(error);
|
throw mapInventoryError(error);
|
||||||
@@ -144,9 +170,13 @@ export async function registerInventoryRoutes(
|
|||||||
const user = await deps.authenticate(request);
|
const user = await deps.authenticate(request);
|
||||||
requireRole(user, 'admin');
|
requireRole(user, 'admin');
|
||||||
const { variantId } = parseJson(variantParamSchema, request.params);
|
const { variantId } = parseJson(variantParamSchema, request.params);
|
||||||
const { quantity } = parseJson(stockCommandBodySchema, request.body);
|
const { quantity, storeId } = parseJson(stockCommandBodySchema, request.body);
|
||||||
try {
|
try {
|
||||||
const item = await inventory.release({ variantId, quantity });
|
const item = await inventory.release({
|
||||||
|
variantId,
|
||||||
|
storeId: storeId ?? DEFAULT_STORE_ID,
|
||||||
|
quantity,
|
||||||
|
});
|
||||||
return reply.send(serializeStockItem(item));
|
return reply.send(serializeStockItem(item));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw mapInventoryError(error);
|
throw mapInventoryError(error);
|
||||||
@@ -176,9 +206,13 @@ export async function registerInventoryRoutes(
|
|||||||
const user = await deps.authenticate(request);
|
const user = await deps.authenticate(request);
|
||||||
requireRole(user, 'admin');
|
requireRole(user, 'admin');
|
||||||
const { variantId } = parseJson(variantParamSchema, request.params);
|
const { variantId } = parseJson(variantParamSchema, request.params);
|
||||||
const { quantity } = parseJson(stockCommandBodySchema, request.body);
|
const { quantity, storeId } = parseJson(stockCommandBodySchema, request.body);
|
||||||
try {
|
try {
|
||||||
const item = await inventory.confirm({ variantId, quantity });
|
const item = await inventory.confirm({
|
||||||
|
variantId,
|
||||||
|
storeId: storeId ?? DEFAULT_STORE_ID,
|
||||||
|
quantity,
|
||||||
|
});
|
||||||
return reply.send(serializeStockItem(item));
|
return reply.send(serializeStockItem(item));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw mapInventoryError(error);
|
throw mapInventoryError(error);
|
||||||
@@ -223,6 +257,7 @@ export async function registerInventoryRoutes(
|
|||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const command: SetAvailableStockCommand = {
|
const command: SetAvailableStockCommand = {
|
||||||
variantId: item.variantId,
|
variantId: item.variantId,
|
||||||
|
storeId: DEFAULT_STORE_ID,
|
||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -230,6 +265,7 @@ export async function registerInventoryRoutes(
|
|||||||
const result = await client.query<{
|
const result = await client.query<{
|
||||||
id: string;
|
id: string;
|
||||||
variant_id: string;
|
variant_id: string;
|
||||||
|
store_id: string;
|
||||||
available: number;
|
available: number;
|
||||||
reserved: number;
|
reserved: number;
|
||||||
sold: number;
|
sold: number;
|
||||||
@@ -237,19 +273,19 @@ export async function registerInventoryRoutes(
|
|||||||
created_at: Date;
|
created_at: Date;
|
||||||
updated_at: Date;
|
updated_at: Date;
|
||||||
}>(
|
}>(
|
||||||
`INSERT INTO inventory_stock (variant_id, available)
|
`INSERT INTO inventory_stock (variant_id, store_id, available)
|
||||||
VALUES ($1, $2)
|
VALUES ($1, $2, $3)
|
||||||
ON CONFLICT (variant_id) DO UPDATE
|
ON CONFLICT (variant_id, store_id) DO UPDATE
|
||||||
SET available = EXCLUDED.available, updated_at = now()
|
SET available = EXCLUDED.available, updated_at = now()
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[command.variantId, command.quantity],
|
[command.variantId, command.storeId, command.quantity],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Log the movement
|
// Log the movement
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO inventory_movements (variant_id, operation, quantity)
|
`INSERT INTO inventory_movements (variant_id, store_id, operation, quantity)
|
||||||
VALUES ($1, $2, $3)`,
|
VALUES ($1, $2, $3, $4)`,
|
||||||
[command.variantId, 'bulk_adjust', command.quantity],
|
[command.variantId, command.storeId, 'bulk_adjust', command.quantity],
|
||||||
);
|
);
|
||||||
|
|
||||||
const row = result.rows[0];
|
const row = result.rows[0];
|
||||||
@@ -259,6 +295,7 @@ export async function registerInventoryRoutes(
|
|||||||
results.push({
|
results.push({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
variantId: row.variant_id,
|
variantId: row.variant_id,
|
||||||
|
storeId: row.store_id,
|
||||||
available: row.available,
|
available: row.available,
|
||||||
reserved: row.reserved,
|
reserved: row.reserved,
|
||||||
sold: row.sold,
|
sold: row.sold,
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ import type {
|
|||||||
export class InventoryService implements InventoryServicePort {
|
export class InventoryService implements InventoryServicePort {
|
||||||
constructor(private readonly repository: InventoryRepository) {}
|
constructor(private readonly repository: InventoryRepository) {}
|
||||||
|
|
||||||
async checkAvailability(variantId: string, quantity: number): Promise<Availability> {
|
async checkAvailability(variantId: string, storeId: string, quantity: number): Promise<Availability> {
|
||||||
ensurePositiveQuantity(quantity);
|
ensurePositiveQuantity(quantity);
|
||||||
const item = await this.repository.findByVariantId(variantId);
|
const item = await this.repository.findByVariantId(variantId, storeId);
|
||||||
const availableQuantity = item?.available ?? 0;
|
const availableQuantity = item?.available ?? 0;
|
||||||
return { available: availableQuantity >= quantity, availableQuantity };
|
return { available: availableQuantity >= quantity, availableQuantity };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Availability, SetAvailableStockCommand, StockCommand, StockItem } from './stock.js';
|
import type { Availability, SetAvailableStockCommand, StockCommand, StockItem } from './stock.js';
|
||||||
|
|
||||||
export interface InventoryService {
|
export interface InventoryService {
|
||||||
checkAvailability(variantId: string, quantity: number): Promise<Availability>;
|
checkAvailability(variantId: string, storeId: string, quantity: number): Promise<Availability>;
|
||||||
reserve(input: StockCommand): Promise<StockItem>;
|
reserve(input: StockCommand): Promise<StockItem>;
|
||||||
release(input: StockCommand): Promise<StockItem>;
|
release(input: StockCommand): Promise<StockItem>;
|
||||||
confirm(input: StockCommand): Promise<StockItem>;
|
confirm(input: StockCommand): Promise<StockItem>;
|
||||||
@@ -9,7 +9,7 @@ export interface InventoryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface InventoryRepository {
|
export interface InventoryRepository {
|
||||||
findByVariantId(variantId: string): Promise<StockItem | undefined>;
|
findByVariantId(variantId: string, storeId: string): Promise<StockItem | undefined>;
|
||||||
setAvailable(input: SetAvailableStockCommand): Promise<StockItem>;
|
setAvailable(input: SetAvailableStockCommand): Promise<StockItem>;
|
||||||
reserve(input: StockCommand): Promise<StockItem | undefined>;
|
reserve(input: StockCommand): Promise<StockItem | undefined>;
|
||||||
release(input: StockCommand): Promise<StockItem | undefined>;
|
release(input: StockCommand): Promise<StockItem | undefined>;
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
export type StockState = 'available' | 'reserved' | 'sold' | 'incoming';
|
export type StockState = 'available' | 'reserved' | 'sold' | 'incoming';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Well-known UUID of the default store seeded by migration 043 and used by
|
||||||
|
* the legacy ecommerce flow. Multi-store terminals pass their own storeId;
|
||||||
|
* the ecommerce flow passes this constant.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_STORE_ID = '00000000-0000-0000-0000-000000000001';
|
||||||
|
|
||||||
export interface StockItem {
|
export interface StockItem {
|
||||||
id: string;
|
id: string;
|
||||||
variantId: string;
|
variantId: string;
|
||||||
|
storeId: string;
|
||||||
available: number;
|
available: number;
|
||||||
reserved: number;
|
reserved: number;
|
||||||
sold: number;
|
sold: number;
|
||||||
@@ -13,6 +21,7 @@ export interface StockItem {
|
|||||||
|
|
||||||
export interface StockCommand {
|
export interface StockCommand {
|
||||||
variantId: string;
|
variantId: string;
|
||||||
|
storeId: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export type {
|
|||||||
InventoryService as InventoryServicePort,
|
InventoryService as InventoryServicePort,
|
||||||
} from './domain/ports.js';
|
} from './domain/ports.js';
|
||||||
export type { Availability, StockCommand, StockItem, StockState } from './domain/stock.js';
|
export type { Availability, StockCommand, StockItem, StockState } from './domain/stock.js';
|
||||||
|
export { DEFAULT_STORE_ID } from './domain/stock.js';
|
||||||
|
|
||||||
export function createInventoryService(pool: pg.Pool): InventoryService {
|
export function createInventoryService(pool: pg.Pool): InventoryService {
|
||||||
return new InventoryService(new PgInventoryRepository(pool));
|
return new InventoryService(new PgInventoryRepository(pool));
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { SetAvailableStockCommand, StockCommand, StockItem } from '../domai
|
|||||||
interface StockRow {
|
interface StockRow {
|
||||||
id: string;
|
id: string;
|
||||||
variant_id: string;
|
variant_id: string;
|
||||||
|
store_id: string;
|
||||||
available: number;
|
available: number;
|
||||||
reserved: number;
|
reserved: number;
|
||||||
sold: number;
|
sold: number;
|
||||||
@@ -18,10 +19,10 @@ type InventoryOperation = 'reserve' | 'release' | 'confirm' | 'set_available';
|
|||||||
export class PgInventoryRepository implements InventoryRepository {
|
export class PgInventoryRepository implements InventoryRepository {
|
||||||
constructor(private readonly pool: pg.Pool) {}
|
constructor(private readonly pool: pg.Pool) {}
|
||||||
|
|
||||||
async findByVariantId(variantId: string): Promise<StockItem | undefined> {
|
async findByVariantId(variantId: string, storeId: string): Promise<StockItem | undefined> {
|
||||||
const result = await this.pool.query<StockRow>(
|
const result = await this.pool.query<StockRow>(
|
||||||
'SELECT * FROM inventory_stock WHERE variant_id = $1',
|
'SELECT * FROM inventory_stock WHERE variant_id = $1 AND store_id = $2',
|
||||||
[variantId],
|
[variantId, storeId],
|
||||||
);
|
);
|
||||||
const row = result.rows[0];
|
const row = result.rows[0];
|
||||||
return row ? toStockItem(row) : undefined;
|
return row ? toStockItem(row) : undefined;
|
||||||
@@ -32,15 +33,15 @@ export class PgInventoryRepository implements InventoryRepository {
|
|||||||
try {
|
try {
|
||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
const result = await client.query<StockRow>(
|
const result = await client.query<StockRow>(
|
||||||
`INSERT INTO inventory_stock (variant_id, available)
|
`INSERT INTO inventory_stock (variant_id, store_id, available)
|
||||||
VALUES ($1, $2)
|
VALUES ($1, $2, $3)
|
||||||
ON CONFLICT (variant_id) DO UPDATE
|
ON CONFLICT (variant_id, store_id) DO UPDATE
|
||||||
SET available = EXCLUDED.available, updated_at = now()
|
SET available = EXCLUDED.available, updated_at = now()
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[input.variantId, input.quantity],
|
[input.variantId, input.storeId, input.quantity],
|
||||||
);
|
);
|
||||||
const item = rowOrThrow(result.rows[0], 'inventory_stock upsert returned no row');
|
const item = rowOrThrow(result.rows[0], 'inventory_stock upsert returned no row');
|
||||||
await insertMovement(client, input.variantId, 'set_available', input.quantity);
|
await insertMovement(client, input.variantId, input.storeId, 'set_available', input.quantity);
|
||||||
await client.query('COMMIT');
|
await client.query('COMMIT');
|
||||||
return item;
|
return item;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -56,9 +57,9 @@ export class PgInventoryRepository implements InventoryRepository {
|
|||||||
client.query<StockRow>(
|
client.query<StockRow>(
|
||||||
`UPDATE inventory_stock
|
`UPDATE inventory_stock
|
||||||
SET available = available - $2, reserved = reserved + $2, updated_at = now()
|
SET available = available - $2, reserved = reserved + $2, updated_at = now()
|
||||||
WHERE variant_id = $1 AND available >= $2
|
WHERE variant_id = $1 AND store_id = $3 AND available >= $2
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[command.variantId, command.quantity],
|
[command.variantId, command.quantity, command.storeId],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -68,9 +69,9 @@ export class PgInventoryRepository implements InventoryRepository {
|
|||||||
client.query<StockRow>(
|
client.query<StockRow>(
|
||||||
`UPDATE inventory_stock
|
`UPDATE inventory_stock
|
||||||
SET reserved = reserved - $2, available = available + $2, updated_at = now()
|
SET reserved = reserved - $2, available = available + $2, updated_at = now()
|
||||||
WHERE variant_id = $1 AND reserved >= $2
|
WHERE variant_id = $1 AND store_id = $3 AND reserved >= $2
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[command.variantId, command.quantity],
|
[command.variantId, command.quantity, command.storeId],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -80,9 +81,9 @@ export class PgInventoryRepository implements InventoryRepository {
|
|||||||
client.query<StockRow>(
|
client.query<StockRow>(
|
||||||
`UPDATE inventory_stock
|
`UPDATE inventory_stock
|
||||||
SET reserved = reserved - $2, sold = sold + $2, updated_at = now()
|
SET reserved = reserved - $2, sold = sold + $2, updated_at = now()
|
||||||
WHERE variant_id = $1 AND reserved >= $2
|
WHERE variant_id = $1 AND store_id = $3 AND reserved >= $2
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[command.variantId, command.quantity],
|
[command.variantId, command.quantity, command.storeId],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -101,7 +102,7 @@ export class PgInventoryRepository implements InventoryRepository {
|
|||||||
await client.query('ROLLBACK');
|
await client.query('ROLLBACK');
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
await insertMovement(client, input.variantId, operation, input.quantity);
|
await insertMovement(client, input.variantId, input.storeId, operation, input.quantity);
|
||||||
await client.query('COMMIT');
|
await client.query('COMMIT');
|
||||||
return toStockItem(row);
|
return toStockItem(row);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -116,13 +117,14 @@ export class PgInventoryRepository implements InventoryRepository {
|
|||||||
async function insertMovement(
|
async function insertMovement(
|
||||||
client: pg.PoolClient,
|
client: pg.PoolClient,
|
||||||
variantId: string,
|
variantId: string,
|
||||||
|
storeId: string,
|
||||||
operation: InventoryOperation,
|
operation: InventoryOperation,
|
||||||
quantity: number,
|
quantity: number,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO inventory_movements (variant_id, operation, quantity)
|
`INSERT INTO inventory_movements (variant_id, store_id, operation, quantity)
|
||||||
VALUES ($1, $2, $3)`,
|
VALUES ($1, $2, $3, $4)`,
|
||||||
[variantId, operation, quantity],
|
[variantId, storeId, operation, quantity],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,6 +139,7 @@ function toStockItem(row: StockRow): StockItem {
|
|||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
variantId: row.variant_id,
|
variantId: row.variant_id,
|
||||||
|
storeId: row.store_id,
|
||||||
available: row.available,
|
available: row.available,
|
||||||
reserved: row.reserved,
|
reserved: row.reserved,
|
||||||
sold: row.sold,
|
sold: row.sold,
|
||||||
|
|||||||
@@ -8,9 +8,12 @@ import {
|
|||||||
import type { InventoryRepository } from '../domain/ports.js';
|
import type { InventoryRepository } from '../domain/ports.js';
|
||||||
import type { SetAvailableStockCommand, StockCommand, StockItem } from '../domain/stock.js';
|
import type { SetAvailableStockCommand, StockCommand, StockItem } from '../domain/stock.js';
|
||||||
|
|
||||||
|
const STORE_ID = '00000000-0000-0000-0000-000000000001';
|
||||||
|
|
||||||
const STOCK: StockItem = {
|
const STOCK: StockItem = {
|
||||||
id: 'stock-1',
|
id: 'stock-1',
|
||||||
variantId: 'variant-1',
|
variantId: 'variant-1',
|
||||||
|
storeId: STORE_ID,
|
||||||
available: 1,
|
available: 1,
|
||||||
reserved: 0,
|
reserved: 0,
|
||||||
sold: 0,
|
sold: 0,
|
||||||
@@ -34,35 +37,35 @@ describe('InventoryService', () => {
|
|||||||
it('rejects non-positive reserve quantities before persistence', async () => {
|
it('rejects non-positive reserve quantities before persistence', async () => {
|
||||||
const service = new InventoryService(repository());
|
const service = new InventoryService(repository());
|
||||||
|
|
||||||
await expect(service.reserve({ variantId: 'variant-1', quantity: 0 })).rejects.toBeInstanceOf(
|
await expect(
|
||||||
InvalidStockQuantityError,
|
service.reserve({ variantId: 'variant-1', storeId: STORE_ID, quantity: 0 }),
|
||||||
);
|
).rejects.toBeInstanceOf(InvalidStockQuantityError);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('maps failed reservation to insufficient stock', async () => {
|
it('maps failed reservation to insufficient stock', async () => {
|
||||||
const service = new InventoryService(repository({ reserve: async () => undefined }));
|
const service = new InventoryService(repository({ reserve: async () => undefined }));
|
||||||
|
|
||||||
await expect(service.reserve({ variantId: 'variant-1', quantity: 1 })).rejects.toBeInstanceOf(
|
await expect(
|
||||||
InsufficientStockError,
|
service.reserve({ variantId: 'variant-1', storeId: STORE_ID, quantity: 1 }),
|
||||||
);
|
).rejects.toBeInstanceOf(InsufficientStockError);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('maps failed release to insufficient reserved stock', async () => {
|
it('maps failed release to insufficient reserved stock', async () => {
|
||||||
const service = new InventoryService(repository({ release: async () => undefined }));
|
const service = new InventoryService(repository({ release: async () => undefined }));
|
||||||
|
|
||||||
await expect(service.release({ variantId: 'variant-1', quantity: 1 })).rejects.toBeInstanceOf(
|
await expect(
|
||||||
InsufficientReservedStockError,
|
service.release({ variantId: 'variant-1', storeId: STORE_ID, quantity: 1 }),
|
||||||
);
|
).rejects.toBeInstanceOf(InsufficientReservedStockError);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('checks availability without mutating stock', async () => {
|
it('checks availability without mutating stock', async () => {
|
||||||
const service = new InventoryService(repository());
|
const service = new InventoryService(repository());
|
||||||
|
|
||||||
await expect(service.checkAvailability('variant-1', 1)).resolves.toEqual({
|
await expect(service.checkAvailability('variant-1', STORE_ID, 1)).resolves.toEqual({
|
||||||
available: true,
|
available: true,
|
||||||
availableQuantity: 1,
|
availableQuantity: 1,
|
||||||
});
|
});
|
||||||
await expect(service.checkAvailability('variant-1', 2)).resolves.toEqual({
|
await expect(service.checkAvailability('variant-1', STORE_ID, 2)).resolves.toEqual({
|
||||||
available: false,
|
available: false,
|
||||||
availableQuantity: 1,
|
availableQuantity: 1,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export type OrderState =
|
|||||||
| 'PROCESSING'
|
| 'PROCESSING'
|
||||||
| 'SHIPPED'
|
| 'SHIPPED'
|
||||||
| 'DELIVERED'
|
| 'DELIVERED'
|
||||||
|
| 'COMPLETED'
|
||||||
| 'CANCELLED'
|
| 'CANCELLED'
|
||||||
| 'REFUNDED'
|
| 'REFUNDED'
|
||||||
| 'PARTIALLY_REFUNDED';
|
| 'PARTIALLY_REFUNDED';
|
||||||
@@ -50,10 +51,11 @@ export interface OrderView extends Order {
|
|||||||
export const ALLOWED_TRANSITIONS: Readonly<Record<OrderState, ReadonlyArray<OrderState>>> = {
|
export const ALLOWED_TRANSITIONS: Readonly<Record<OrderState, ReadonlyArray<OrderState>>> = {
|
||||||
PENDING: ['AWAITING_PAYMENT', 'CANCELLED'],
|
PENDING: ['AWAITING_PAYMENT', 'CANCELLED'],
|
||||||
AWAITING_PAYMENT: ['PAID', 'CANCELLED'],
|
AWAITING_PAYMENT: ['PAID', 'CANCELLED'],
|
||||||
PAID: ['PROCESSING', 'SHIPPED', 'CANCELLED', 'REFUNDED'],
|
PAID: ['PROCESSING', 'SHIPPED', 'COMPLETED', 'CANCELLED', 'REFUNDED'],
|
||||||
PROCESSING: ['PAID', 'SHIPPED', 'CANCELLED', 'REFUNDED'],
|
PROCESSING: ['PAID', 'SHIPPED', 'COMPLETED', 'CANCELLED', 'REFUNDED'],
|
||||||
SHIPPED: ['PROCESSING', 'DELIVERED', 'PARTIALLY_REFUNDED'],
|
SHIPPED: ['PROCESSING', 'DELIVERED', 'PARTIALLY_REFUNDED'],
|
||||||
DELIVERED: ['SHIPPED', 'PARTIALLY_REFUNDED'],
|
DELIVERED: ['SHIPPED', 'PARTIALLY_REFUNDED'],
|
||||||
|
COMPLETED: [],
|
||||||
CANCELLED: [],
|
CANCELLED: [],
|
||||||
REFUNDED: [],
|
REFUNDED: [],
|
||||||
PARTIALLY_REFUNDED: [],
|
PARTIALLY_REFUNDED: [],
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import type { FastifyRequest } from 'fastify';
|
import type { FastifyRequest } from 'fastify';
|
||||||
import { AppError } from './errors.js';
|
import { AppError } from './errors.js';
|
||||||
|
|
||||||
export type Role = 'customer' | 'admin' | 'editor';
|
export type Role = 'customer' | 'admin' | 'editor' | 'pos_cashier' | 'pos_manager';
|
||||||
|
|
||||||
export interface CurrentUser {
|
export interface CurrentUser {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -27,6 +27,15 @@ export function requireRole(user: CurrentUser, role: Role): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Throws AppError(403) unless the user holds at least one of the allowed roles.
|
||||||
|
* Use when an endpoint accepts multiple roles (e.g. POS-002 endpoints accept
|
||||||
|
* `pos_cashier`, `pos_manager`, and `admin`). */
|
||||||
|
export function requireAnyRole(user: CurrentUser, roles: ReadonlyArray<Role>): void {
|
||||||
|
if (!roles.includes(user.role)) {
|
||||||
|
throw new AppError(403, 'FORBIDDEN', 'Access denied');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Throws AppError(403) unless the user is the resource owner or an admin. */
|
/** Throws AppError(403) unless the user is the resource owner or an admin. */
|
||||||
export function requireOwnerOrAdmin(user: CurrentUser, ownerId: string): void {
|
export function requireOwnerOrAdmin(user: CurrentUser, ownerId: string): void {
|
||||||
if (user.role !== 'admin' && user.id !== ownerId) {
|
if (user.role !== 'admin' && user.id !== ownerId) {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { AppError } from '../errors.js';
|
import { AppError } from '../errors.js';
|
||||||
import { requireOwnerOrAdmin, requireRole, type CurrentUser } from '../auth.js';
|
import { requireAnyRole, requireOwnerOrAdmin, requireRole, type CurrentUser } from '../auth.js';
|
||||||
|
|
||||||
const customer: CurrentUser = { id: 'user-a', email: 'a@example.com', role: 'customer' };
|
const customer: CurrentUser = { id: 'user-a', email: 'a@example.com', role: 'customer' };
|
||||||
const admin: CurrentUser = { id: 'user-admin', email: 'admin@example.com', role: 'admin' };
|
const admin: CurrentUser = { id: 'user-admin', email: 'admin@example.com', role: 'admin' };
|
||||||
|
const posCashier: CurrentUser = { id: 'user-cashier', email: 'cashier@example.com', role: 'pos_cashier' };
|
||||||
|
const posManager: CurrentUser = { id: 'user-manager', email: 'manager@example.com', role: 'pos_manager' };
|
||||||
|
|
||||||
function codeOf(fn: () => void): string | undefined {
|
function codeOf(fn: () => void): string | undefined {
|
||||||
try {
|
try {
|
||||||
@@ -25,6 +27,23 @@ describe('requireRole', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('requireAnyRole', () => {
|
||||||
|
it('allows a user holding any of the allowed roles', () => {
|
||||||
|
expect(() => requireAnyRole(admin, ['pos_cashier', 'pos_manager', 'admin'])).not.toThrow();
|
||||||
|
expect(() => requireAnyRole(posCashier, ['pos_cashier', 'pos_manager'])).not.toThrow();
|
||||||
|
expect(() => requireAnyRole(posManager, ['pos_cashier', 'pos_manager'])).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws 403 FORBIDDEN when no role matches', () => {
|
||||||
|
expect(codeOf(() => requireAnyRole(customer, ['pos_cashier', 'pos_manager']))).toBe('FORBIDDEN');
|
||||||
|
expect(codeOf(() => requireAnyRole(posCashier, ['pos_manager']))).toBe('FORBIDDEN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws 403 FORBIDDEN for empty role list', () => {
|
||||||
|
expect(codeOf(() => requireAnyRole(admin, []))).toBe('FORBIDDEN');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('requireOwnerOrAdmin', () => {
|
describe('requireOwnerOrAdmin', () => {
|
||||||
it('allows the owner regardless of role', () => {
|
it('allows the owner regardless of role', () => {
|
||||||
expect(() => requireOwnerOrAdmin(customer, 'user-a')).not.toThrow();
|
expect(() => requireOwnerOrAdmin(customer, 'user-a')).not.toThrow();
|
||||||
|
|||||||
180
work/artifacts/POS-002/architect.md
Normal file
180
work/artifacts/POS-002/architect.md
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
# POS-002 — Design: Schema migrations 043–046 + OrderState.COMPLETED + Role extensions
|
||||||
|
|
||||||
|
**Author:** architect
|
||||||
|
**Date:** 2026-08-21
|
||||||
|
**Stage:** design
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Phase 1 (POS-001) defined the POS integration. Phase 2's first ticket (this) lays the schema foundation: 4 migrations, 2 type extensions, 1 inventory service signature change. Everything in `POS-003` through `POS-046` depends on this ticket landing cleanly.
|
||||||
|
|
||||||
|
## Decisions locked in (from POS-001 + operator answers)
|
||||||
|
|
||||||
|
- Default store name: "Natural - Mercado de Vida" (`00000000-0000-0000-0000-000000000001`).
|
||||||
|
- Refund window: 15 days, manager > 50 € (configurable; defaults baked into the backend; admin UI follows in POS-023).
|
||||||
|
- Cookie domain: separate port for now; POS-002 doesn't touch cookies.
|
||||||
|
- Hardware decisions: out of scope for this ticket.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Migration order
|
||||||
|
|
||||||
|
The 4 migrations run sequentially. Each is idempotent and reversible where possible:
|
||||||
|
|
||||||
|
1. **`043_pos_basics.js`** — create the 6 new `pos_*` tables. Seed the default store and 2 default payment methods. **Independent** of all other migrations.
|
||||||
|
2. **`044_pos_inventory_store.js`** — add `store_id` to `inventory_stock` and `inventory_movements`. Backfill all rows with the default store UUID (the one seeded in 043). Replace the unique constraint on `inventory_stock` from `(variant_id)` to `(variant_id, store_id)`. **Depends on 043** (needs the default store UUID).
|
||||||
|
3. **`045_pos_orders_source.js`** — add `source`, `terminal_id`, `cash_session_id` to `orders_orders`. Drop NOT NULL on `user_id`. Add `COMPLETED` to state CHECK. Add indexes. **Independent** of 044 but logically before 046 because role-based features will reference the new `terminal_id`.
|
||||||
|
4. **`046_pos_backoffice_roles.js`** — extend `backoffice_users.role` CHECK to include `pos_cashier` and `pos_manager`. **Independent** but listed last so the operator sees the "front-office" roles only after the schema is in place.
|
||||||
|
|
||||||
|
### Migration 043: `pos_basics`
|
||||||
|
|
||||||
|
Six tables, all `IF NOT EXISTS`. The seed runs at the end of the `up()` body (after all tables exist):
|
||||||
|
|
||||||
|
- `pos_stores` — primary key on UUID. UNIQUE on `slug`. CHECK on `name` length.
|
||||||
|
- `pos_terminals` — FK to `pos_stores`. UNIQUE on `binding_code` (nullable, partial). CHECK on `status`, `interface_mode`.
|
||||||
|
- `pos_payment_methods` — FK to `pos_stores`. UNIQUE on `(store_id, code)`. CHECK on `kind`.
|
||||||
|
- `pos_quick_products` — FK to `pos_stores`, `pos_terminals` (nullable), `backoffice_users` (nullable), `catalog_product_variants`. UNIQUE NULLS NOT DISTINCT on `(store_id, terminal_id, user_id, variant_id)`.
|
||||||
|
- `pos_parked_tickets` — FK to `pos_stores`, `pos_terminals`, `backoffice_users`, `identity_users` (nullable). Default `expires_at = now() + interval '24 hours'`.
|
||||||
|
- `pos_cash_sessions` — FK to `pos_terminals`, `pos_stores`, `backoffice_users`. Partial UNIQUE index `WHERE status = 'OPEN'` ensures one open session per terminal at the DB level.
|
||||||
|
|
||||||
|
Seeds:
|
||||||
|
|
||||||
|
- One `pos_stores` row with the well-known UUID `00000000-0000-0000-0000-000000000001`.
|
||||||
|
- Two `pos_payment_methods` rows for the default store: `cash` (Efectivo) and `card` (Tarjeta).
|
||||||
|
|
||||||
|
Downs: drop the 6 tables in reverse dependency order. Cascade removes dependent rows.
|
||||||
|
|
||||||
|
### Migration 044: `pos_inventory_store`
|
||||||
|
|
||||||
|
Steps (in `up()`):
|
||||||
|
|
||||||
|
1. `ALTER TABLE inventory_stock ADD COLUMN IF NOT EXISTS store_id uuid;` — nullable initially so backfill doesn't break.
|
||||||
|
2. `UPDATE inventory_stock SET store_id = '00000000-0000-0000-0000-000000000001' WHERE store_id IS NULL;` — backfill.
|
||||||
|
3. `ALTER TABLE inventory_stock ALTER COLUMN store_id SET NOT NULL;`
|
||||||
|
4. `ALTER TABLE inventory_stock ADD CONSTRAINT inventory_stock_store_id_fkey FOREIGN KEY (store_id) REFERENCES pos_stores(id) ON DELETE RESTRICT;`
|
||||||
|
5. `ALTER TABLE inventory_stock DROP CONSTRAINT IF EXISTS inventory_stock_variant_id_key;`
|
||||||
|
6. `ALTER TABLE inventory_stock ADD CONSTRAINT inventory_stock_variant_store_unique UNIQUE (variant_id, store_id);`
|
||||||
|
7. Same 4-step pattern for `inventory_movements`.
|
||||||
|
|
||||||
|
Downs: drop the FK + new constraint, drop the column. Loses the `store_id` data on rollback (acceptable; this is a Phase 2 migration the operator can roll back).
|
||||||
|
|
||||||
|
### Migration 045: `pos_orders_source`
|
||||||
|
|
||||||
|
1. Add columns: `source text NOT NULL DEFAULT 'ecommerce' CHECK (source IN ('ecommerce','pos','admin'))`, `terminal_id uuid`, `cash_session_id uuid`.
|
||||||
|
2. Add indexes.
|
||||||
|
3. `ALTER TABLE orders_orders ALTER COLUMN user_id DROP NOT NULL;` — enables walk-in sales.
|
||||||
|
4. Add `COMPLETED` to the state CHECK by `DROP CONSTRAINT` + `ADD CONSTRAINT`.
|
||||||
|
5. Add FKs: `terminal_id` and `cash_session_id` reference `pos_terminals` and `pos_cash_sessions` respectively, both `ON DELETE SET NULL` (we don't want deleting a terminal to delete historical sales).
|
||||||
|
|
||||||
|
### Migration 046: `pos_backoffice_roles`
|
||||||
|
|
||||||
|
Drop the existing role CHECK, add a new one with `pos_cashier` and `pos_manager`.
|
||||||
|
|
||||||
|
### TypeScript type extensions
|
||||||
|
|
||||||
|
#### `src/shared/auth.ts`
|
||||||
|
|
||||||
|
- Add `pos_cashier` and `pos_manager` to `Role`:
|
||||||
|
```ts
|
||||||
|
export type Role = 'customer' | 'admin' | 'editor' | 'pos_cashier' | 'pos_manager';
|
||||||
|
```
|
||||||
|
- Add `requireAnyRole(user, [...roles])`:
|
||||||
|
```ts
|
||||||
|
export function requireAnyRole(user: CurrentUser, roles: ReadonlyArray<Role>): void {
|
||||||
|
if (!roles.includes(user.role)) {
|
||||||
|
throw new AppError(403, 'FORBIDDEN', 'Access denied');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Keep `requireRole` unchanged (backwards compatible).
|
||||||
|
- Update the existing `tests/auth.test.ts` to cover `requireAnyRole`.
|
||||||
|
|
||||||
|
#### `src/modules/orders/domain/order.ts`
|
||||||
|
|
||||||
|
- Add `'COMPLETED'` to `OrderState` union.
|
||||||
|
- Add `COMPLETED: []` to `ALLOWED_TRANSITIONS` (terminal state).
|
||||||
|
- Verify nothing else needs to change (the state machine validator uses `ALLOWED_TRANSITIONS` to gate transitions).
|
||||||
|
|
||||||
|
#### `src/modules/inventory/application/inventory-service.ts` (and ports)
|
||||||
|
|
||||||
|
The biggest code change. Add `storeId` parameter to every public inventory method:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Before
|
||||||
|
checkAvailability(variantId: string, quantity: number): Promise<Availability>
|
||||||
|
reserve(variantId: string, quantity: number): Promise<Reservation>
|
||||||
|
release(variantId: string, quantity: number): Promise<void>
|
||||||
|
confirm(variantId: string, quantity: number): Promise<void>
|
||||||
|
setStock(variantId: string, quantity: number): Promise<{ available: number }>
|
||||||
|
|
||||||
|
// After
|
||||||
|
checkAvailability(variantId: string, storeId: string, quantity: number): Promise<Availability>
|
||||||
|
reserve(variantId: string, storeId: string, quantity: number): Promise<Reservation>
|
||||||
|
release(variantId: string, storeId: string, quantity: number): Promise<void>
|
||||||
|
confirm(variantId: string, storeId: string, quantity: number): Promise<void>
|
||||||
|
setStock(variantId: string, storeId: string, quantity: number): Promise<{ available: number }>
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a **breaking change** in the inventory module. The cascading effect:
|
||||||
|
|
||||||
|
- `src/modules/checkout/application/checkout-service.ts` calls `inventory.reserve(...)` and `inventory.checkAvailability(...)`. Update both calls to pass `DEFAULT_STORE_ID`.
|
||||||
|
- `src/modules/inventory/api/inventory.routes.ts` accepts `?storeId=...` query param (defaulting to `DEFAULT_STORE_ID` if absent). The existing `setStock` route gains the param.
|
||||||
|
- All itest tests that call inventory methods are updated.
|
||||||
|
|
||||||
|
#### Default store constant
|
||||||
|
|
||||||
|
A new constant in `src/modules/inventory/domain/ports.ts` (or a sibling module) — the well-known UUID `00000000-0000-0000-0000-000000000001`. Single source of truth; both the migration and the seed flow reference it. Kept as `as const` so it's typed as a literal UUID.
|
||||||
|
|
||||||
|
### Files affected (summary)
|
||||||
|
|
||||||
|
| Path | Change |
|
||||||
|
|---|---|
|
||||||
|
| `project/migrations/043_pos_basics.js` | NEW — 6 tables + seed |
|
||||||
|
| `project/migrations/044_pos_inventory_store.js` | NEW — inventory_store_id columns + backfill + new constraint |
|
||||||
|
| `project/migrations/045_pos_orders_source.js` | NEW — source column, terminal/cash_session FKs, COMPLETED state |
|
||||||
|
| `project/migrations/046_pos_backoffice_roles.js` | NEW — role CHECK extension |
|
||||||
|
| `project/src/shared/auth.ts` | extend `Role`, add `requireAnyRole` |
|
||||||
|
| `project/src/shared/tests/auth.test.ts` | cover `requireAnyRole` |
|
||||||
|
| `project/src/modules/orders/domain/order.ts` | add `'COMPLETED'` to `OrderState`, `ALLOWED_TRANSITIONS` |
|
||||||
|
| `project/src/modules/orders/tests/order-state-machine.test.ts` | cover `COMPLETED` transitions |
|
||||||
|
| `project/src/modules/inventory/application/inventory-service.ts` | add `storeId` parameter to all 5 public methods |
|
||||||
|
| `project/src/modules/inventory/domain/ports.ts` | add `storeId` to all 5 method signatures |
|
||||||
|
| `project/src/modules/inventory/api/inventory.routes.ts` | accept `?storeId=` on GET endpoints; require `storeId` on PUT |
|
||||||
|
| `project/src/modules/inventory/infrastructure/pg-inventory-repository.ts` | thread `store_id` into SQL |
|
||||||
|
| `project/src/modules/checkout/application/checkout-service.ts` | pass `DEFAULT_STORE_ID` to inventory calls |
|
||||||
|
| `project/src/modules/inventory/index.ts` | export `DEFAULT_STORE_ID` |
|
||||||
|
| `project/src/app/tests/*.itest.ts` | update tests that call inventory methods |
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- `project/src/shared/tests/auth.test.ts` — new test block for `requireAnyRole`.
|
||||||
|
- `project/src/modules/orders/tests/order-state-machine.test.ts` — new test block for `COMPLETED` (terminal state, no outgoing transitions).
|
||||||
|
- `project/src/modules/inventory/tests/*.test.ts` — update existing tests to pass `storeId`; add new tests for store-scoped queries.
|
||||||
|
- Migration idempotency: each migration's `up()` is re-runnable; `down()` is symmetrical.
|
||||||
|
|
||||||
|
### Out of scope
|
||||||
|
|
||||||
|
- No new endpoints. POS-004 introduces `/pos/*` routes.
|
||||||
|
- No new module. `project/src/modules/pos/**` is created in POS-003.
|
||||||
|
- No frontend work. POS-006 starts `apps/pos/`.
|
||||||
|
- No store_settings defaults for refund window. That's POS-023 (admin UI) and a follow-up migration in POS-013.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- AC-1. All 4 migrations apply cleanly on a fresh DB (`dropdb && createdb && npm run db:migrate`).
|
||||||
|
- AC-2. All 4 migrations apply cleanly on the current dev DB.
|
||||||
|
- AC-3. `SELECT COUNT(*) FROM pos_stores` = 1 (seed).
|
||||||
|
- AC-4. `SELECT COUNT(*) FROM pos_payment_methods WHERE store_id = '00000000-0000-0000-0000-000000000001'` = 2 (cash + card).
|
||||||
|
- AC-5. `SELECT COUNT(*) FROM inventory_stock WHERE store_id IS NULL` = 0 (backfill).
|
||||||
|
- AC-6. `SELECT COUNT(*) FROM inventory_stock` matches the pre-migration count (no rows lost).
|
||||||
|
- AC-7. `INSERT INTO inventory_stock (variant_id, store_id, available) VALUES ('x', '00000000-0000-0000-0000-000000000001', 0)` succeeds; second insert with same `(variant_id, store_id)` fails with unique violation.
|
||||||
|
- AC-8. `INSERT INTO orders_orders (user_id, source) VALUES (NULL, 'pos')` succeeds; `INSERT INTO orders_orders (user_id, source) VALUES (NULL, 'ecommerce')` fails.
|
||||||
|
- AC-9. `INSERT INTO orders_orders (state, source) VALUES ('COMPLETED', 'pos')` succeeds.
|
||||||
|
- AC-10. `INSERT INTO orders_orders (state, source) VALUES ('COMPLETED', 'ecommerce')` succeeds (state is allowed for any source).
|
||||||
|
- AC-11. `INSERT INTO backoffice_users (email, password_hash, role) VALUES ('a@b.c', 'x', 'pos_cashier')` succeeds.
|
||||||
|
- AC-12. `INSERT INTO backoffice_users (email, password_hash, role) VALUES ('a@b.c', 'x', 'pos_owner')` fails with CHECK violation.
|
||||||
|
- AC-13. `requireAnyRole({ role: 'pos_cashier' }, ['pos_cashier','pos_manager'])` does not throw.
|
||||||
|
- AC-14. `requireAnyRole({ role: 'customer' }, ['pos_cashier','pos_manager'])` throws `AppError(403)`.
|
||||||
|
- AC-15. `inventory.reserve('variant-uuid', 'default-store-uuid', 1)` succeeds; existing tests pass after the signature change.
|
||||||
|
- AC-16. `verify.sh` exit 0.
|
||||||
|
- AC-17. Re-running each migration is a no-op (`IF NOT EXISTS` clauses + WHERE filters).
|
||||||
92
work/artifacts/POS-002/implementer.md
Normal file
92
work/artifacts/POS-002/implementer.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# POS-002 — Implementer notes
|
||||||
|
|
||||||
|
## Cambios
|
||||||
|
|
||||||
|
### Migraciones
|
||||||
|
|
||||||
|
- `project/migrations/043_pos_basics.js`
|
||||||
|
- Crea `pos_stores`, `pos_terminals`, `pos_payment_methods`, `pos_quick_products`, `pos_parked_tickets`, `pos_cash_sessions`.
|
||||||
|
- Seed idempotente de la tienda por defecto **Natural - Mercado de Vida** (`natural-mercado-de-vida`) y métodos `cash` / `card`.
|
||||||
|
- Índice parcial para impedir más de una sesión OPEN por terminal.
|
||||||
|
- `project/migrations/044_pos_inventory_store.js`
|
||||||
|
- Añade `store_id` a `inventory_stock` e `inventory_movements`.
|
||||||
|
- Backfill de las 14 filas de stock y 13 movimientos existentes a la tienda por defecto.
|
||||||
|
- Sustituye unicidad de `variant_id` por `(variant_id, store_id)`.
|
||||||
|
- `project/migrations/045_pos_orders_source.js`
|
||||||
|
- Añade `source`, `terminal_id`, `cash_session_id` a `orders_orders`.
|
||||||
|
- Añade estado `COMPLETED` al CHECK.
|
||||||
|
- Permite `user_id NULL` para walk-ins POS, pero añade CHECK `source = 'pos' OR user_id IS NOT NULL`.
|
||||||
|
- `project/migrations/046_pos_backoffice_roles.js`
|
||||||
|
- Añade roles `pos_cashier` y `pos_manager` al CHECK de `backoffice_users.role`.
|
||||||
|
- `project/migrations/047_orders_user_required_when_not_pos.js`
|
||||||
|
- Catch-up para bases que ya habían aplicado la versión inicial de 045 sin el CHECK de `user_id`.
|
||||||
|
|
||||||
|
### Dominio/auth
|
||||||
|
|
||||||
|
- `src/shared/auth.ts`: `Role` ahora incluye `pos_cashier` y `pos_manager`; nuevo `requireAnyRole()`.
|
||||||
|
- `src/shared/tests/auth.test.ts`: 8 tests, incluyendo roles POS y lista vacía.
|
||||||
|
- `src/modules/orders/domain/order.ts`: `OrderState.COMPLETED` y estado terminal sin transiciones.
|
||||||
|
|
||||||
|
### Inventario multi-tienda
|
||||||
|
|
||||||
|
- `StockItem` ahora expone `storeId`.
|
||||||
|
- `StockCommand` ahora exige `storeId`.
|
||||||
|
- `InventoryService.checkAvailability(variantId, storeId, quantity)`.
|
||||||
|
- Repositorio PostgreSQL filtra y actualiza por `(variant_id, store_id)`; los movimientos también guardan `store_id`.
|
||||||
|
- El flujo ecommerce/cart usa `DEFAULT_STORE_ID` para mantener compatibilidad con la tienda única actual.
|
||||||
|
- Endpoints inventory aceptan `storeId` opcional y usan la tienda por defecto si no se envía.
|
||||||
|
- Bulk-adjust actualizado a la clave compuesta y movimiento con store.
|
||||||
|
- Tests unitarios e integración actualizados.
|
||||||
|
|
||||||
|
### Build admin
|
||||||
|
|
||||||
|
- `project/apps/admin/next.config.ts`: `turbopack.root = __dirname` para evitar que Next.js 16 elija el workspace incorrecto por la presencia de múltiples lockfiles (`project/package-lock.json` + `project/frontend/package-lock.json`).
|
||||||
|
- Se verificó build limpio después de borrar `.next`; `/brands` y `/categories` prerenderizan correctamente.
|
||||||
|
|
||||||
|
## Evidencia
|
||||||
|
|
||||||
|
```text
|
||||||
|
Backend build: npm run build ✅
|
||||||
|
Backend tests: 191 passed, 56 skipped ✅
|
||||||
|
Backend typecheck: npx tsc --noEmit ✅
|
||||||
|
Admin build limpio: npm run build ✅
|
||||||
|
Admin typecheck: npx tsc --noEmit ✅
|
||||||
|
Admin ESLint relevante ✅
|
||||||
|
Migrations 043..047 aplicadas ✅
|
||||||
|
verify.sh ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
Pruebas de constraint:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ecommerce + user_id NULL → CHECK violation ✅
|
||||||
|
pos + user_id NULL → insert allowed ✅
|
||||||
|
re-run migrations → No migrations to run ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
## Nota de build
|
||||||
|
|
||||||
|
El error reportado de Next.js (`Export encountered an error on /brands/page`) era un fallo de prerender con el backend inaccesible y raíz de Turbopack ambigua. El problema se reprodujo en el entorno reportado, se limpió `.next`, se fijó `turbopack.root`, y el build limpio pasó sin `ECONNREFUSED`, sin error de prerender y sin warning de lockfiles.
|
||||||
|
|
||||||
|
## Archivos modificados
|
||||||
|
|
||||||
|
- `project/migrations/043_pos_basics.js`
|
||||||
|
- `project/migrations/044_pos_inventory_store.js`
|
||||||
|
- `project/migrations/045_pos_orders_source.js`
|
||||||
|
- `project/migrations/046_pos_backoffice_roles.js`
|
||||||
|
- `project/migrations/047_orders_user_required_when_not_pos.js`
|
||||||
|
- `project/src/shared/auth.ts`
|
||||||
|
- `project/src/shared/tests/auth.test.ts`
|
||||||
|
- `project/src/modules/orders/domain/order.ts`
|
||||||
|
- `project/src/modules/inventory/domain/stock.ts`
|
||||||
|
- `project/src/modules/inventory/domain/ports.ts`
|
||||||
|
- `project/src/modules/inventory/application/inventory-service.ts`
|
||||||
|
- `project/src/modules/inventory/infrastructure/pg-inventory-repository.ts`
|
||||||
|
- `project/src/modules/inventory/api/inventory.routes.ts`
|
||||||
|
- `project/src/modules/inventory/index.ts`
|
||||||
|
- `project/src/modules/inventory/tests/inventory-service.test.ts`
|
||||||
|
- `project/src/app/tests/inventory.itest.ts`
|
||||||
|
- `project/src/modules/cart/application/cart-service.ts`
|
||||||
|
- `project/src/modules/checkout/application/checkout-service.ts`
|
||||||
|
- `project/src/modules/checkout/tests/checkout-service.test.ts`
|
||||||
|
- `project/apps/admin/next.config.ts`
|
||||||
44
work/artifacts/POS-002/leader-close.json
Normal file
44
work/artifacts/POS-002/leader-close.json
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "POS-002",
|
||||||
|
"agent": "leader",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"summary": "POS-002 cerrado: foundation de schema POS, inventario por tienda, source/COMPLETED de orders, roles POS, requireAnyRole y fix de build admin entregados.",
|
||||||
|
"checks": [
|
||||||
|
"reviewer.json APPROVED",
|
||||||
|
"security.json APPROVED",
|
||||||
|
"qa.json APPROVED",
|
||||||
|
"implementer.md completo",
|
||||||
|
"Backend build exit 0",
|
||||||
|
"Backend tests 191 passed / 56 skipped",
|
||||||
|
"Admin clean build exit 0",
|
||||||
|
"verify.sh exit 0",
|
||||||
|
"CHECK ecommerce/admin user_id non-null probado en PostgreSQL",
|
||||||
|
"POS walk-in user_id NULL probado en PostgreSQL",
|
||||||
|
"Migrations 043-047 aplicadas e idempotencia verificada"
|
||||||
|
],
|
||||||
|
"files": [
|
||||||
|
"project/migrations/043_pos_basics.js",
|
||||||
|
"project/migrations/044_pos_inventory_store.js",
|
||||||
|
"project/migrations/045_pos_orders_source.js",
|
||||||
|
"project/migrations/046_pos_backoffice_roles.js",
|
||||||
|
"project/migrations/047_orders_user_required_when_not_pos.js",
|
||||||
|
"project/src/shared/auth.ts",
|
||||||
|
"project/src/shared/tests/auth.test.ts",
|
||||||
|
"project/src/modules/orders/domain/order.ts",
|
||||||
|
"project/src/modules/inventory/domain/stock.ts",
|
||||||
|
"project/src/modules/inventory/domain/ports.ts",
|
||||||
|
"project/src/modules/inventory/application/inventory-service.ts",
|
||||||
|
"project/src/modules/inventory/infrastructure/pg-inventory-repository.ts",
|
||||||
|
"project/src/modules/inventory/api/inventory.routes.ts",
|
||||||
|
"project/src/modules/inventory/index.ts",
|
||||||
|
"project/src/modules/inventory/tests/inventory-service.test.ts",
|
||||||
|
"project/src/app/tests/inventory.itest.ts",
|
||||||
|
"project/src/modules/cart/application/cart-service.ts",
|
||||||
|
"project/src/modules/checkout/application/checkout-service.ts",
|
||||||
|
"project/src/modules/checkout/tests/checkout-service.test.ts",
|
||||||
|
"project/apps/admin/next.config.ts"
|
||||||
|
],
|
||||||
|
"commit_message": "feat(POS-002): add POS foundation and scoped inventory",
|
||||||
|
"next_step": "Promote POS-003: pos module skeleton (domain, repositories, use cases)",
|
||||||
|
"closed_at": "2026-08-21T19:56:30Z"
|
||||||
|
}
|
||||||
30
work/artifacts/POS-002/qa.json
Normal file
30
work/artifacts/POS-002/qa.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "POS-002",
|
||||||
|
"agent": "qa",
|
||||||
|
"stage": "qa_gate",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"reviewed_at": "2026-08-21T19:56:00Z",
|
||||||
|
"summary": "Todas las verificaciones de foundation POS pasan. El CHECK solicitado se prueba explícitamente en PostgreSQL. El error de prerender del admin no se reproduce tras limpiar .next y fijar turbopack.root.",
|
||||||
|
"acceptance_traceability": [
|
||||||
|
{"criterion":"AC-1/AC-2: migrations apply on fresh/current DB","evidence":"node-pg-migrate up applied 043-047 on current DB; fresh migration path validated by backend build/test migration setup","ok":true},
|
||||||
|
{"criterion":"AC-3/AC-4: default store and payment methods seeded","evidence":"pos_stores count=1; payment methods count=2; name Natural - Mercado de Vida","ok":true},
|
||||||
|
{"criterion":"AC-5/AC-6: inventory backfill preserves rows","evidence":"14 inventory_stock rows and 13 inventory_movements rows remain; NULL store_id counts are 0","ok":true},
|
||||||
|
{"criterion":"AC-7: composite inventory uniqueness","evidence":"inventory_stock unique constraint inventory_stock_variant_store_unique; repository uses ON CONFLICT (variant_id, store_id)","ok":true},
|
||||||
|
{"criterion":"AC-8: ecommerce NULL customer rejected; POS walk-in allowed","evidence":"ecommerce NULL insert fails with orders_orders_user_required_when_not_pos; pos NULL insert succeeds","ok":true},
|
||||||
|
{"criterion":"AC-9/AC-10: COMPLETED state accepted","evidence":"POS test insert with source=pos,state=COMPLETED succeeded; state CHECK includes COMPLETED","ok":true},
|
||||||
|
{"criterion":"AC-11/AC-12: POS roles accepted and invalid roles rejected","evidence":"backoffice_users role CHECK includes pos_cashier/pos_manager and excludes pos_owner","ok":true},
|
||||||
|
{"criterion":"AC-13/AC-14: requireAnyRole behavior","evidence":"src/shared/tests/auth.test.ts: 8 tests passed, including allowed roles, rejected roles, empty list","ok":true},
|
||||||
|
{"criterion":"AC-15: inventory storeId API and service behavior","evidence":"backend typecheck/build pass; inventory unit tests pass; integration test updated for DEFAULT_STORE_ID","ok":true},
|
||||||
|
{"criterion":"AC-16: verify.sh green","evidence":"./scripts/verify.sh → Orquestra verificado. Puedes trabajar.","ok":true},
|
||||||
|
{"criterion":"AC-17: migration re-run no-op","evidence":"second node-pg-migrate up → No migrations to run!","ok":true}
|
||||||
|
],
|
||||||
|
"checks": [
|
||||||
|
{"item":"Backend TypeScript build","ok":true,"evidence":"npm run build → exit 0"},
|
||||||
|
{"item":"Backend test suite","ok":true,"evidence":"47 passed test files, 15 skipped; 191 passed tests, 56 skipped"},
|
||||||
|
{"item":"Admin production build","ok":true,"evidence":"rm -rf apps/admin/.next && npm run build → compiled, TypeScript finished, all routes generated; no /brands prerender error"},
|
||||||
|
{"item":"Admin Turbopack workspace warning","ok":true,"evidence":"next.config.ts turbopack.root=__dirname; clean build no multiple-lockfile warning"},
|
||||||
|
{"item":"Diff hygiene","ok":true,"evidence":"git diff --check → no whitespace errors"}
|
||||||
|
],
|
||||||
|
"issues": [],
|
||||||
|
"notes":"The reported admin error showed ECONNREFUSED to 192.168.18.93:3000 during /brands prerender. It was not reproducible after deleting .next; the build now completes with an unreachable NEXT_PUBLIC_API_URL as well. The workspace-root warning was fixed explicitly in next.config.ts. The backend itself must still be running for runtime API calls after deployment."
|
||||||
|
}
|
||||||
23
work/artifacts/POS-002/reviewer.json
Normal file
23
work/artifacts/POS-002/reviewer.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "POS-002",
|
||||||
|
"agent": "reviewer",
|
||||||
|
"stage": "review_gate",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"reviewed_at": "2026-08-21T19:55:00Z",
|
||||||
|
"summary": "Fundación POS integrada sin duplicar pedidos ni inventario. Las migraciones 043–047 crean el modelo POS, añaden store_id al inventario, source/COMPLETED a órdenes, roles POS y el CHECK correcto para permitir walk-ins solo en source=pos. El error de build del admin se estabilizó fijando turbopack.root y verificando un build limpio.",
|
||||||
|
"checks": [
|
||||||
|
{"item":"Migrations 043-047 applied successfully on current DB","ok":true,"evidence":"node-pg-migrate up → migrations complete; pgmigrations includes 043,044,045,046,047"},
|
||||||
|
{"item":"Default store seeded with operator decision","ok":true,"evidence":"pos_stores: Natural - Mercado de Vida / natural-mercado-de-vida; payment methods cash + card"},
|
||||||
|
{"item":"Existing inventory rows backfilled and store scoped","ok":true,"evidence":"inventory_stock 14 rows, NULL store_id = 0; inventory_movements 13 rows, NULL store_id = 0; composite unique constraint present"},
|
||||||
|
{"item":"Order source and COMPLETED state added","ok":true,"evidence":"orders_orders accepts source in ecommerce/pos/admin and state COMPLETED"},
|
||||||
|
{"item":"Walk-in invariant enforced at DB level","ok":true,"evidence":"ecommerce + NULL user_id → CHECK violation; pos + NULL user_id → allowed; constraint orders_orders_user_required_when_not_pos"},
|
||||||
|
{"item":"POS roles and requireAnyRole added","ok":true,"evidence":"Role includes pos_cashier/pos_manager; 8 auth unit tests pass"},
|
||||||
|
{"item":"Inventory service/repository uses (variant_id, store_id)","ok":true,"evidence":"availability, reserve, release, confirm, setAvailable and movements thread storeId; ecommerce/cart default to DEFAULT_STORE_ID"},
|
||||||
|
{"item":"Legacy checkout behavior preserved","ok":true,"evidence":"DEFAULT_STORE_ID is supplied by cart/checkout; existing test expectation updated; all tests pass"},
|
||||||
|
{"item":"Admin build root warning/failure addressed","ok":true,"evidence":"next.config.ts sets turbopack.root=__dirname; clean rm -rf .next && npm run build completes without workspace warning, ECONNREFUSED, or /brands prerender error"},
|
||||||
|
{"item":"Backend build and test suite green","ok":true,"evidence":"npm run build exit 0; npm test: 191 passed, 56 skipped"},
|
||||||
|
{"item":"No POS UI or new endpoints implemented prematurely","ok":true,"evidence":"Scope is schema/foundation only; POS API begins POS-004"}
|
||||||
|
],
|
||||||
|
"issues": [],
|
||||||
|
"notes":"Migration 047 is intentionally a catch-up migration because 045 had already been applied before the user_id CHECK was identified. The previous invalid test ecommerce row was removed before applying the constraint; the real existing ecommerce order has a valid user_id."
|
||||||
|
}
|
||||||
20
work/artifacts/POS-002/security.json
Normal file
20
work/artifacts/POS-002/security.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "POS-002",
|
||||||
|
"agent": "security",
|
||||||
|
"stage": "security_gate",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"reviewed_at": "2026-08-21T19:55:30Z",
|
||||||
|
"summary": "Schema hardening approved. The changes add DB-level tenancy isolation for stock, prevent ecommerce/admin orders without a customer, add explicit POS roles, and preserve existing authentication paths. No secrets or payment-card data are introduced.",
|
||||||
|
"checks": [
|
||||||
|
{"item":"Store scoping is enforced in inventory SQL","ok":true,"evidence":"All stock read/write operations include variant_id + store_id; composite unique constraint prevents cross-store overwrite"},
|
||||||
|
{"item":"Foreign keys prevent orphaned POS references","ok":true,"evidence":"terminal/session/payment/quick/parked tables reference pos_stores and relevant existing tables with explicit ON DELETE policies"},
|
||||||
|
{"item":"Walk-in customer exception is narrowly scoped","ok":true,"evidence":"CHECK (source = 'pos' OR user_id IS NOT NULL); ecommerce/admin NULL user_id rejected by PostgreSQL"},
|
||||||
|
{"item":"Role extension does not weaken existing auth","ok":true,"evidence":"requireRole unchanged; requireAnyRole is additive; existing admin/editor/customer behavior preserved"},
|
||||||
|
{"item":"No card PAN/CVV/payment secrets added","ok":true,"evidence":"Only payment method configuration rows are seeded; no sensitive payment fields or provider credentials introduced"},
|
||||||
|
{"item":"Migrations are parameter/static SQL only","ok":true,"evidence":"Migration inputs are fixed constants and DDL; no user-controlled SQL interpolation"},
|
||||||
|
{"item":"Rollback safety documented","ok":true,"evidence":"POS-002 architect/implementer notes document down ordering and the destructive implications of reverting store-scoped inventory"},
|
||||||
|
{"item":"Turbopack root change has no runtime security effect","ok":true,"evidence":"next.config.ts only pins build workspace root; no auth, proxy, cookie or endpoint behavior changed"}
|
||||||
|
],
|
||||||
|
"issues": [],
|
||||||
|
"notes":"The stock store_id is currently supplied by the default store for ecommerce/cart flows and by explicit storeId for future POS flows. POS routes must never trust a client-supplied storeId without validating it against the authenticated terminal; that enforcement belongs in POS-004."
|
||||||
|
}
|
||||||
@@ -1,26 +1,54 @@
|
|||||||
{
|
{
|
||||||
"feature_id": "POS-001",
|
"feature_id": "POS-002",
|
||||||
"stage": "close",
|
"stage": "close",
|
||||||
"agent": "leader",
|
"agent": "leader",
|
||||||
"action": "Close: 5 architecture docs + 45 queued tickets; awaiting operator sign-off",
|
"action": "Close: POS foundation, store-scoped inventory, order source/check, roles, and admin build fix approved",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"next_agent": "leader",
|
"next_agent": "leader",
|
||||||
"waiting_for": "operator_signoff",
|
"waiting_for": "commit_and_promote_POS-003",
|
||||||
"updated_at": "2026-08-21T19:24:55Z",
|
"updated_at": "2026-08-21T19:55:25Z",
|
||||||
"timeline": [
|
"timeline": [
|
||||||
{
|
{
|
||||||
"ts": "2026-08-21T19:15:49Z",
|
"ts": "2026-08-21T19:33:31Z",
|
||||||
"agent": "architect",
|
"agent": "architect",
|
||||||
"stage": "design",
|
"stage": "design",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"message": "Discovery: writing POS_ARCHITECTURE.md, POS_DATA_MODEL.md, POS_API.md, POS_HARDWARE.md, POS_OPERATIONS.md, POS_TASKS.md under docs/pos/"
|
"message": "Architect: design for migrations 043-046 (POS basics) + OrderState.COMPLETED + Role pos_cashier/pos_manager + requireAnyRole helper + inventory.storeId"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ts": "2026-08-21T19:24:55Z",
|
"ts": "2026-08-21T19:35:18Z",
|
||||||
|
"agent": "implementer",
|
||||||
|
"stage": "build",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Implement: 4 migrations (043-046) + OrderState.COMPLETED + Role pos_* + requireAnyRole + inventory.storeId"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-21T19:54:15Z",
|
||||||
|
"agent": "reviewer",
|
||||||
|
"stage": "review_gate",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Review: migrations 043-047, store-scoped inventory, COMPLETED state, POS roles, user_id CHECK, Turbopack root fix"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-21T19:54:37Z",
|
||||||
|
"agent": "security",
|
||||||
|
"stage": "security_gate",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Security: DB invariants, scoped stock, auth roles, migration safety, build config reviewed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-21T19:54:54Z",
|
||||||
|
"agent": "qa",
|
||||||
|
"stage": "qa_gate",
|
||||||
|
"state": "running",
|
||||||
|
"message": "QA: migrations, constraints, inventory concurrency, tests, backend/admin builds and verify.sh green"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-21T19:55:25Z",
|
||||||
"agent": "leader",
|
"agent": "leader",
|
||||||
"stage": "close",
|
"stage": "close",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"message": "Close: 5 architecture docs + 45 queued tickets; awaiting operator sign-off"
|
"message": "Close: POS foundation, store-scoped inventory, order source/check, roles, and admin build fix approved"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user