feat(F-144): completed feature
This commit is contained in:
67
project/migrations/048_reporting_store_shipping_snapshots.js
Normal file
67
project/migrations/048_reporting_store_shipping_snapshots.js
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* F-144 — Reporting snapshots: store_id, shipping_cents, cost & VAT line snapshots
|
||||
*
|
||||
* Adds the snapshot columns needed to report multi-store sales without
|
||||
* rewriting history (REPORTING_ARCHITECTURE.md §5 "store/VAT/cost/shipping"):
|
||||
* - orders_orders.store_id -> uuid NOT NULL, FK -> pos_stores, default store.
|
||||
* - orders_orders.shipping_cents -> integer NOT NULL DEFAULT 0 (shipping split out).
|
||||
* - orders_items.cost_at_sale_cents -> bigint (nullable; NULL until backfilled).
|
||||
* - orders_items.vat_rate -> text (nullable snapshot of the VAT key).
|
||||
*
|
||||
* store_id gets a column DEFAULT of the well-known default store (seeded by
|
||||
* migration 043_pos_basics), so both pre-existing rows and new inserts that
|
||||
* don't specify a store inherit the default — no full table rewrite, no
|
||||
* app-layer change to the order INSERT path. The FK is created with a `DO $$`
|
||||
* guard (same idiom as 047/046) because PG16 does not support
|
||||
* `ADD CONSTRAINT IF NOT EXISTS`; the guard makes `up` safely re-runnable.
|
||||
*
|
||||
* Idempotent: columns/index use `IF NOT EXISTS`; the FK is guarded by
|
||||
* `DO $$ IF NOT EXISTS ... $$`. Reversible: down() drops everything.
|
||||
*
|
||||
* @param {import('node-pg-migrate').MigrationBuilder} pgm
|
||||
*/
|
||||
|
||||
// Must match the default-store UUID seeded in 043_pos_basics and exported as
|
||||
// DEFAULT_STORE_ID by src/modules/inventory/index.ts.
|
||||
const DEFAULT_STORE_ID = '00000000-0000-0000-0000-000000000001';
|
||||
|
||||
export const up = (pgm) => {
|
||||
// ── orders_orders.store_id (NOT NULL + default store; no table rewrite) ──
|
||||
// `ADD COLUMN IF NOT EXISTS` is supported (PG>=9.6); existing rows, if any,
|
||||
// are backfilled by the DEFAULT, satisfying NOT NULL.
|
||||
pgm.sql(`ALTER TABLE orders_orders ADD COLUMN IF NOT EXISTS store_id uuid NOT NULL DEFAULT '${DEFAULT_STORE_ID}'::uuid`);
|
||||
|
||||
// ── orders_orders.shipping_cents (shipping split out of totals) ─────────
|
||||
pgm.sql(`ALTER TABLE orders_orders ADD COLUMN IF NOT EXISTS shipping_cents integer NOT NULL DEFAULT 0`);
|
||||
|
||||
// ── orders_items line snapshots (nullable — no history rewrite) ──────────
|
||||
pgm.sql(`ALTER TABLE orders_items ADD COLUMN IF NOT EXISTS cost_at_sale_cents bigint`);
|
||||
pgm.sql(`ALTER TABLE orders_items ADD COLUMN IF NOT EXISTS vat_rate text`);
|
||||
|
||||
// ── FK to pos_stores (guarded, idempotent; note the ';' after REFERENCES) ──
|
||||
pgm.sql(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'orders_orders_store_id_fkey'
|
||||
) THEN
|
||||
ALTER TABLE orders_orders
|
||||
ADD CONSTRAINT orders_orders_store_id_fkey
|
||||
FOREIGN KEY (store_id) REFERENCES pos_stores(id);
|
||||
END IF;
|
||||
END $$
|
||||
`);
|
||||
|
||||
// ── supporting index for multi-store + date-range reporting queries ──────
|
||||
pgm.sql(`CREATE INDEX IF NOT EXISTS orders_orders_store_id_created_at_idx ON orders_orders (store_id, created_at)`);
|
||||
};
|
||||
|
||||
export const down = (pgm) => {
|
||||
pgm.sql(`DROP INDEX IF EXISTS orders_orders_store_id_created_at_idx`);
|
||||
pgm.sql(`ALTER TABLE orders_orders DROP CONSTRAINT IF EXISTS orders_orders_store_id_fkey`);
|
||||
pgm.sql(`ALTER TABLE orders_items DROP COLUMN IF EXISTS vat_rate`);
|
||||
pgm.sql(`ALTER TABLE orders_items DROP COLUMN IF EXISTS cost_at_sale_cents`);
|
||||
pgm.sql(`ALTER TABLE orders_orders DROP COLUMN IF EXISTS shipping_cents`);
|
||||
pgm.sql(`ALTER TABLE orders_orders DROP COLUMN IF EXISTS store_id`);
|
||||
};
|
||||
81
project/src/app/tests/reporting-snapshots.itest.ts
Normal file
81
project/src/app/tests/reporting-snapshots.itest.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type pg from 'pg';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { createPool } from '../../infrastructure/db/pool.js';
|
||||
import {
|
||||
getTestDbUrl,
|
||||
recreateDatabase,
|
||||
runMigrations,
|
||||
} from '../../infrastructure/db/tests/db-test-support.js';
|
||||
import { DEFAULT_STORE_ID } from '../../modules/inventory/index.js';
|
||||
|
||||
const hasDb = Boolean(process.env.TEST_DATABASE_URL);
|
||||
|
||||
describe.skipIf(!hasDb)('F-144 reporting store/VAT/cost/shipping snapshots (real PostgreSQL)', () => {
|
||||
const url = hasDb ? getTestDbUrl() : '';
|
||||
let pool: pg.Pool;
|
||||
|
||||
beforeAll(async () => {
|
||||
await recreateDatabase(url);
|
||||
await runMigrations(url, 'up');
|
||||
pool = createPool(url);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
async function columnInfo(table: string, column: string) {
|
||||
return pool.query(
|
||||
'SELECT column_name, is_nullable, column_default FROM information_schema.columns WHERE table_name = $1 AND column_name = $2',
|
||||
[table, column],
|
||||
);
|
||||
}
|
||||
|
||||
it('adds store_id to orders_orders as NOT NULL w/ default store + FK + index (AC1)', async () => {
|
||||
const col = (await columnInfo('orders_orders', 'store_id')).rows[0];
|
||||
expect(col).toBeDefined();
|
||||
expect(col.is_nullable).toBe('NO');
|
||||
expect(col.column_default).toMatch(DEFAULT_STORE_ID);
|
||||
|
||||
const fk = (
|
||||
await pool.query(
|
||||
"SELECT conname, contype FROM pg_constraint WHERE conrelid = 'orders_orders'::regclass AND conname = 'orders_orders_store_id_fkey'",
|
||||
)
|
||||
).rows[0];
|
||||
expect(fk).toBeDefined();
|
||||
expect(fk.contype).toBe('f'); // foreign key
|
||||
|
||||
const idx = (
|
||||
await pool.query(
|
||||
"SELECT indexname FROM pg_indexes WHERE tablename = 'orders_orders' AND indexname = 'orders_orders_store_id_created_at_idx'",
|
||||
)
|
||||
).rows[0];
|
||||
expect(idx).toBeDefined();
|
||||
|
||||
// New inserts inherit the default store without app-layer changes.
|
||||
// `source = 'pos'` is provided to satisfy the 047 CHECK
|
||||
// (source = 'pos' OR user_id IS NOT NULL) without touching store_id.
|
||||
const inserted = (
|
||||
await pool.query("INSERT INTO orders_orders (source) VALUES ('pos') RETURNING store_id, shipping_cents")
|
||||
).rows[0];
|
||||
expect(inserted.store_id).toBe(DEFAULT_STORE_ID);
|
||||
expect(inserted.shipping_cents).toBe(0);
|
||||
});
|
||||
|
||||
it('adds shipping_cents to orders_orders as NOT NULL DEFAULT 0 (AC2)', async () => {
|
||||
const col = (await columnInfo('orders_orders', 'shipping_cents')).rows[0];
|
||||
expect(col).toBeDefined();
|
||||
expect(col.is_nullable).toBe('NO');
|
||||
expect(col.column_default).toContain('0');
|
||||
});
|
||||
|
||||
it('adds cost_at_sale_cents and vat_rate snapshots to orders_items as nullable — no history rewrite (AC3)', async () => {
|
||||
const cost = (await columnInfo('orders_items', 'cost_at_sale_cents')).rows[0];
|
||||
expect(cost).toBeDefined();
|
||||
expect(cost.is_nullable).toBe('YES');
|
||||
|
||||
const vat = (await columnInfo('orders_items', 'vat_rate')).rows[0];
|
||||
expect(vat).toBeDefined();
|
||||
expect(vat.is_nullable).toBe('YES');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user