/** * 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'); };