189 lines
10 KiB
JavaScript
189 lines
10 KiB
JavaScript
/**
|
|
* 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');
|
|
};
|