fix conflict

This commit is contained in:
yaschalew
2026-07-02 09:28:02 +03:00
183 changed files with 10335 additions and 5286 deletions

View File

@@ -32,88 +32,78 @@ export class CreateInvoices1821000000002 implements MigrationInterface {
`);
}
const invoicesExists = await queryRunner.query(
`SELECT 1 FROM information_schema.tables WHERE table_schema = 'freight' AND table_name = 'invoices';`,
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.invoices (
id uuid NOT NULL DEFAULT uuid_generate_v4(),
invoice_number varchar(64) NOT NULL,
company_id uuid NOT NULL,
company_profile_id uuid NOT NULL,
total_amount numeric(14, 2) NOT NULL,
currency varchar(8) NOT NULL DEFAULT 'ETB',
status freight.invoices_status_enum NOT NULL DEFAULT 'DRAFT',
source varchar(255) NOT NULL,
source_id varchar(255) NOT NULL,
type varchar(255) NOT NULL,
issued_at timestamptz,
payment_id uuid,
due_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT pk_invoices PRIMARY KEY (id),
CONSTRAINT uq_invoices_invoice_number UNIQUE (invoice_number),
CONSTRAINT fk_invoices_company FOREIGN KEY (company_id)
REFERENCES freight.companies (id) ON DELETE RESTRICT,
CONSTRAINT fk_invoices_company_profile FOREIGN KEY (company_profile_id)
REFERENCES freight.company_profiles (id) ON DELETE RESTRICT,
CONSTRAINT fk_invoices_payment FOREIGN KEY (payment_id)
REFERENCES freight.payments (id) ON DELETE SET NULL
);
`);
await queryRunner.query(
`CREATE INDEX idx_invoices_company ON freight.invoices (company_id);`,
);
await queryRunner.query(
`CREATE INDEX idx_invoices_company_profile ON freight.invoices (company_profile_id);`,
);
await queryRunner.query(
`CREATE INDEX idx_invoices_source ON freight.invoices (source, source_id);`,
);
await queryRunner.query(
`CREATE INDEX idx_invoices_status ON freight.invoices (status);`,
);
if (!invoicesExists.length) {
await queryRunner.query(`
CREATE TABLE freight.invoices (
id uuid NOT NULL DEFAULT uuid_generate_v4(),
invoice_number varchar(64) NOT NULL,
company_id uuid NOT NULL,
company_profile_id uuid NOT NULL,
total_amount numeric(14, 2) NOT NULL,
currency varchar(8) NOT NULL DEFAULT 'ETB',
status freight.invoices_status_enum NOT NULL DEFAULT 'DRAFT',
source varchar(255) NOT NULL,
source_id varchar(255) NOT NULL,
type varchar(255) NOT NULL,
issued_at timestamptz,
payment_id uuid,
due_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT pk_invoices PRIMARY KEY (id),
CONSTRAINT uq_invoices_invoice_number UNIQUE (invoice_number),
CONSTRAINT fk_invoices_company FOREIGN KEY (company_id)
REFERENCES freight.companies (id) ON DELETE RESTRICT,
CONSTRAINT fk_invoices_company_profile FOREIGN KEY (company_profile_id)
REFERENCES freight.company_profiles (id) ON DELETE RESTRICT,
CONSTRAINT fk_invoices_payment FOREIGN KEY (payment_id)
REFERENCES freight.payments (id) ON DELETE SET NULL
);
`);
await queryRunner.query(`
CREATE TABLE freight.invoice_lines (
id uuid NOT NULL DEFAULT uuid_generate_v4(),
invoice_id uuid NOT NULL,
charge_type varchar NOT NULL,
description varchar(255),
quantity numeric(12, 2) NOT NULL DEFAULT 1,
unit_rate numeric(14, 2) NOT NULL DEFAULT 0,
amount numeric(14, 2) NOT NULL,
currency varchar(8) NOT NULL DEFAULT 'ETB',
metadata jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT pk_invoice_lines PRIMARY KEY (id),
CONSTRAINT fk_invoice_lines_invoice FOREIGN KEY (invoice_id)
REFERENCES freight.invoices (id) ON DELETE CASCADE
);
`);
await queryRunner.query(
`CREATE INDEX idx_invoices_company ON freight.invoices (company_id);`,
);
await queryRunner.query(
`CREATE INDEX idx_invoices_company_profile ON freight.invoices (company_profile_id);`,
);
await queryRunner.query(
`CREATE INDEX idx_invoices_source ON freight.invoices (source, source_id);`,
);
await queryRunner.query(
`CREATE INDEX idx_invoices_status ON freight.invoices (status);`,
);
}
const invoiceLinesExists = await queryRunner.query(
`SELECT 1 FROM information_schema.tables WHERE table_schema = 'freight' AND table_name = 'invoice_lines';`,
await queryRunner.query(
`CREATE INDEX idx_invoice_lines_invoice ON freight.invoice_lines (invoice_id);`,
);
if (!invoiceLinesExists.length) {
await queryRunner.query(`
CREATE TABLE freight.invoice_lines (
id uuid NOT NULL DEFAULT uuid_generate_v4(),
invoice_id uuid NOT NULL,
charge_type varchar NOT NULL,
description varchar(255),
quantity numeric(12, 2) NOT NULL DEFAULT 1,
unit_rate numeric(14, 2) NOT NULL DEFAULT 0,
amount numeric(14, 2) NOT NULL,
currency varchar(8) NOT NULL DEFAULT 'ETB',
metadata jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT pk_invoice_lines PRIMARY KEY (id),
CONSTRAINT fk_invoice_lines_invoice FOREIGN KEY (invoice_id)
REFERENCES freight.invoices (id) ON DELETE CASCADE
);
`);
await queryRunner.query(
`CREATE INDEX idx_invoice_lines_invoice ON freight.invoice_lines (invoice_id);`,
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.invoice_lines;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.invoices;`);
await queryRunner.query(`DROP TYPE IF EXISTS freight.invoices_status_enum;`);
await queryRunner.query(
`DROP TYPE IF EXISTS freight.invoices_status_enum;`,
);
}
}

View File

@@ -0,0 +1,34 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddGrnNumberToWarehouseInventory1828000000000 implements MigrationInterface {
name = 'AddGrnNumberToWarehouseInventory1828000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.warehouse_inventory
ADD COLUMN IF NOT EXISTS grn_number VARCHAR(100) NULL
`);
await queryRunner.query(`
UPDATE freight.warehouse_inventory
SET grn_number = substring(notes FROM 'GRN Number: ([^\\n\\r]+)')
WHERE grn_number IS NULL
AND notes IS NOT NULL
AND notes ~ 'GRN Number: '
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_grn_number
ON freight.warehouse_inventory(grn_number)
WHERE grn_number IS NOT NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_warehouse_inventory_grn_number`);
await queryRunner.query(`
ALTER TABLE freight.warehouse_inventory
DROP COLUMN IF EXISTS grn_number
`);
}
}

View File

@@ -0,0 +1,71 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Extend `freight.invoices` into the billing record of record for every source
* (booking, demurrage, warehouse fees, …) so warehouse fee invoices can be
* centralized onto it instead of the parallel `warehouse_fee_invoices` table.
*
* Adds money tracking that supports partial payment (`subtotal/tax/paid/balance`),
* a `paid_at` stamp, a `payments` jsonb ledger, and the `ISSUED` / `PARTIALLY_PAID`
* statuses the warehouse flow uses.
*
* Matches billing/entities/invoice.entity.ts. All columns are additive with
* defaults, so existing booking/demurrage rows are unaffected.
*/
export class ExtendInvoicesForPartialPayment1828000000000
implements MigrationInterface
{
name = "ExtendInvoicesForPartialPayment1828000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
// New statuses. ADD VALUE is non-transactional-value-safe on PG 12+ as long
// as the value is not referenced in the same transaction (it is not here).
await queryRunner.query(
`ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'ISSUED' BEFORE 'PENDING';`,
);
await queryRunner.query(
`ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'PARTIALLY_PAID' BEFORE 'PAID';`,
);
await queryRunner.query(`
ALTER TABLE freight.invoices
ADD COLUMN IF NOT EXISTS subtotal_amount numeric(14, 2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS tax_amount numeric(14, 2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS paid_amount numeric(14, 2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS balance_amount numeric(14, 2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS paid_at timestamptz,
ADD COLUMN IF NOT EXISTS payments jsonb NOT NULL DEFAULT '[]';
`);
// Backfill existing rows: subtotal mirrors the total (no tax was modeled),
// the outstanding balance is the full total for unpaid invoices.
await queryRunner.query(`
UPDATE freight.invoices
SET subtotal_amount = total_amount,
balance_amount = total_amount;
`);
// Already-settled invoices: fully paid, zero balance, stamped from updated_at.
await queryRunner.query(`
UPDATE freight.invoices
SET paid_amount = total_amount,
balance_amount = 0,
paid_at = updated_at
WHERE status = 'PAID';
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.invoices
DROP COLUMN IF EXISTS payments,
DROP COLUMN IF EXISTS paid_at,
DROP COLUMN IF EXISTS balance_amount,
DROP COLUMN IF EXISTS paid_amount,
DROP COLUMN IF EXISTS tax_amount,
DROP COLUMN IF EXISTS subtotal_amount;
`);
// Postgres cannot drop individual enum values; ISSUED / PARTIALLY_PAID are
// left on freight.invoices_status_enum (harmless, unused after down).
}
}

View File

@@ -0,0 +1,222 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Fold warehouse fee invoices into the central billing system.
*
* Warehouse fee invoices are no longer a standalone aggregate: each becomes a
* global `freight.invoices` row (`source = 'warehouse'`, `source_id =
* inventory_id`) with its items as `freight.invoice_lines`. The warehouse
* service is now a thin layer over `BillingService`. This migration backfills the
* existing rows (preserving ids, numbers, status, amounts and payment history),
* then drops the two legacy tables.
*
* Rows that cannot be billed centrally — no company to bill (`company_id` /
* `company_profile_id` underivable from the customer or the booking) — are not
* migrated; they could never have been charged through the gateway and are
* dropped with the table.
*/
export class CentralizeWarehouseInvoices1829000000000 implements MigrationInterface {
name = 'CentralizeWarehouseInvoices1829000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// 1. Invoice headers. Keep the same id so items still link, and so any
// external reference to the invoice id stays valid.
await queryRunner.query(`
INSERT INTO freight.invoices (
id, invoice_number, company_id, company_profile_id,
subtotal_amount, tax_amount, total_amount, paid_amount, balance_amount,
currency, status, source, source_id, type,
issued_at, paid_at, payments, payment_id, due_at,
created_at, updated_at, deleted_at
)
SELECT
fee.id,
fee.invoice_number,
COALESCE(fee.customer_id, b.company_id),
COALESCE(
b.company_profile_id,
(SELECT cp.id
FROM freight.company_profiles cp
WHERE cp.company_id = COALESCE(fee.customer_id, b.company_id)
AND cp.deleted_at IS NULL
ORDER BY cp.created_at ASC
LIMIT 1)
),
fee.subtotal_amount, fee.tax_amount, fee.total_amount, fee.paid_amount, fee.balance_amount,
fee.currency,
fee.status::freight.invoices_status_enum,
'warehouse',
fee.inventory_id,
fee.invoice_type,
fee.issued_at,
fee.paid_at,
COALESCE(fee.payments, '[]'::jsonb),
NULL,
COALESCE(fee.due_date, fee.issued_at, fee.created_at),
fee.created_at, fee.updated_at, fee.deleted_at
FROM freight.warehouse_fee_invoices fee
LEFT JOIN freight.bookings b ON b.id = fee.booking_id
WHERE COALESCE(fee.customer_id, b.company_id) IS NOT NULL
AND COALESCE(
b.company_profile_id,
(SELECT cp.id
FROM freight.company_profiles cp
WHERE cp.company_id = COALESCE(fee.customer_id, b.company_id)
AND cp.deleted_at IS NULL
ORDER BY cp.created_at ASC
LIMIT 1)
) IS NOT NULL
ON CONFLICT (id) DO NOTHING;
`);
// 2. Invoice lines — only for items whose parent invoice migrated. Warehouse
// fee fields (fee_rule_id / chargeable_days / free_days) move into the
// line's jsonb metadata.
await queryRunner.query(`
INSERT INTO freight.invoice_lines (
id, invoice_id, charge_type, description, quantity, unit_rate, amount,
currency, metadata, created_at, updated_at, deleted_at
)
SELECT
item.id,
item.invoice_id,
item.fee_type,
item.description,
item.quantity,
item.unit_rate,
item.amount,
item.currency,
jsonb_build_object(
'feeRuleId', item.fee_rule_id,
'chargeableDays', item.chargeable_days,
'freeDays', item.free_days
),
item.created_at, item.updated_at, item.deleted_at
FROM freight.warehouse_fee_invoice_items item
JOIN freight.invoices i ON i.id = item.invoice_id AND i.source = 'warehouse'
ON CONFLICT (id) DO NOTHING;
`);
// 3. Drop the legacy tables (items first — FK to invoices).
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_fee_invoice_items;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_fee_invoices;`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Recreate the legacy tables …
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.warehouse_fee_invoices (
id uuid NOT NULL DEFAULT uuid_generate_v4(),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
invoice_number varchar(40) NOT NULL,
booking_id uuid,
customer_id uuid,
inventory_id uuid NOT NULL,
facility_id uuid,
warehouse_id uuid,
yard_id uuid,
zone_id uuid,
invoice_type varchar(32) NOT NULL DEFAULT 'MIXED_WAREHOUSE_FEES',
status varchar(20) NOT NULL DEFAULT 'DRAFT',
subtotal_amount numeric(14,2) NOT NULL DEFAULT 0,
tax_amount numeric(14,2) NOT NULL DEFAULT 0,
total_amount numeric(14,2) NOT NULL DEFAULT 0,
paid_amount numeric(14,2) NOT NULL DEFAULT 0,
balance_amount numeric(14,2) NOT NULL DEFAULT 0,
currency varchar(8) NOT NULL DEFAULT 'USD',
period_start timestamptz,
period_end timestamptz,
issued_at timestamptz,
due_date timestamptz,
paid_at timestamptz,
cancelled_at timestamptz,
payments jsonb NOT NULL DEFAULT '[]',
notes text,
CONSTRAINT "PK_warehouse_fee_invoices" PRIMARY KEY (id),
CONSTRAINT "UQ_warehouse_fee_invoices_invoice_number" UNIQUE (invoice_number)
);
`);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoices_booking_id" ON freight.warehouse_fee_invoices (booking_id);`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoices_inventory_id" ON freight.warehouse_fee_invoices (inventory_id);`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoices_status" ON freight.warehouse_fee_invoices (status);`,
);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.warehouse_fee_invoice_items (
id uuid NOT NULL DEFAULT uuid_generate_v4(),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
invoice_id uuid NOT NULL,
fee_rule_id uuid,
fee_type varchar(32) NOT NULL,
description varchar(255) NOT NULL,
quantity numeric(12,2) NOT NULL DEFAULT 1,
unit_rate numeric(14,2) NOT NULL DEFAULT 0,
amount numeric(14,2) NOT NULL DEFAULT 0,
currency varchar(8) NOT NULL DEFAULT 'USD',
chargeable_days int,
free_days int,
CONSTRAINT "PK_warehouse_fee_invoice_items" PRIMARY KEY (id),
CONSTRAINT "FK_warehouse_fee_invoice_items_invoice"
FOREIGN KEY (invoice_id) REFERENCES freight.warehouse_fee_invoices (id) ON DELETE CASCADE
);
`);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoice_items_invoice_id" ON freight.warehouse_fee_invoice_items (invoice_id);`,
);
// … then copy the warehouse-source invoices back, deriving the typed FKs and
// period from the linked inventory item.
await queryRunner.query(`
INSERT INTO freight.warehouse_fee_invoices (
id, created_at, updated_at, deleted_at, invoice_number,
booking_id, customer_id, inventory_id, facility_id, warehouse_id, yard_id, zone_id,
invoice_type, status, subtotal_amount, tax_amount, total_amount, paid_amount, balance_amount,
currency, period_start, period_end, issued_at, due_date, paid_at, cancelled_at, payments, notes
)
SELECT
i.id, i.created_at, i.updated_at, i.deleted_at, i.invoice_number,
inv.booking_id, i.company_id, i.source_id, w.facility_id, inv.warehouse_id, inv.yard_id, inv.zone_id,
i.type, i.status::text, i.subtotal_amount, i.tax_amount, i.total_amount, i.paid_amount, i.balance_amount,
i.currency, inv.arrived_at, i.issued_at, i.issued_at, i.due_at, i.paid_at,
CASE WHEN i.status::text = 'CANCELLED' THEN i.updated_at ELSE NULL END,
i.payments, NULL
FROM freight.invoices i
LEFT JOIN freight.warehouse_inventory inv ON inv.id = i.source_id
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
WHERE i.source = 'warehouse'
ON CONFLICT (id) DO NOTHING;
`);
await queryRunner.query(`
INSERT INTO freight.warehouse_fee_invoice_items (
id, created_at, updated_at, deleted_at, invoice_id, fee_rule_id, fee_type,
description, quantity, unit_rate, amount, currency, chargeable_days, free_days
)
SELECT
l.id, l.created_at, l.updated_at, l.deleted_at, l.invoice_id,
NULLIF(l.metadata->>'feeRuleId', '')::uuid,
l.charge_type,
COALESCE(l.description, ''),
l.quantity, l.unit_rate, l.amount, l.currency,
NULLIF(l.metadata->>'chargeableDays', '')::int,
NULLIF(l.metadata->>'freeDays', '')::int
FROM freight.invoice_lines l
JOIN freight.invoices i ON i.id = l.invoice_id AND i.source = 'warehouse'
ON CONFLICT (id) DO NOTHING;
`);
// Remove the migrated rows from the central tables.
await queryRunner.query(`
DELETE FROM freight.invoice_lines
WHERE invoice_id IN (SELECT id FROM freight.invoices WHERE source = 'warehouse');
`);
await queryRunner.query(`DELETE FROM freight.invoices WHERE source = 'warehouse';`);
}
}

View File

@@ -0,0 +1,27 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Add the `EXPIRED` invoice status. An invoice expires when its source's pay
* window closes before settlement (e.g. a booking whose `paymentDeadline`
* lapses) — driven event-style from the domain via `BillingService.expirePayable`,
* which emits `${source}.invoice.expired`. Terminal and not settle-able (kept out
* of `OPEN_STATUSES`), so it is distinct from `CANCELLED` (manual void) and
* `OVERDUE` (still payable).
*
* Matches Freight.InvoiceStatus in packages/types. ADD VALUE only — additive and
* not referenced in this same transaction, so it is PG 12+ safe.
*/
export class AddExpiredInvoiceStatus1830000000000 implements MigrationInterface {
name = "AddExpiredInvoiceStatus1830000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'EXPIRED' AFTER 'REFUNDED';`,
);
}
public async down(): Promise<void> {
// Postgres cannot drop individual enum values; EXPIRED is left on
// freight.invoices_status_enum (harmless, unused after down).
}
}