mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
BIN
4_5767239985799371288.xlsx
Normal file
BIN
4_5767239985799371288.xlsx
Normal file
Binary file not shown.
BIN
EDR-Freight-Priority-Flows-Portal.pdf
Normal file
BIN
EDR-Freight-Priority-Flows-Portal.pdf
Normal file
Binary file not shown.
BIN
EDR-Freight-Priority-Flows.pdf
Normal file
BIN
EDR-Freight-Priority-Flows.pdf
Normal file
Binary file not shown.
BIN
INV-20260812-00005-QR.png
Normal file
BIN
INV-20260812-00005-QR.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
BIN
INV-20260812-00005.pdf
Normal file
BIN
INV-20260812-00005.pdf
Normal file
Binary file not shown.
@@ -202,14 +202,13 @@ EIMS_NATURE_OF_SUPPLIES=service
|
|||||||
EIMS_PAYMENT_MODE=CASH
|
EIMS_PAYMENT_MODE=CASH
|
||||||
EIMS_PAYMENT_TERM=IMMIDIATE
|
EIMS_PAYMENT_TERM=IMMIDIATE
|
||||||
EIMS_UNIT_DEFAULT=PCS
|
EIMS_UNIT_DEFAULT=PCS
|
||||||
# MoR numeric country code for the buyer; our companies store the country name.
|
# Buyer Country/Region/City/Wereda are NOT configured here any more. They are resolved from the
|
||||||
EIMS_BUYER_COUNTRY_CODE=
|
# Ministry's own location master (EIMS_COUNTRY_REGION_VW), committed as
|
||||||
# Buyer region name -> MoR numeric code. companies.region holds names; MoR wants ^[0-9]{1,3}$.
|
# src/config/mor-locations.data.ts and regenerated with:
|
||||||
# An unmapped region fails locally rather than being filed with a guess.
|
# pnpm --filter @edr/freight-api eims:import-locations <workbook.xlsx>
|
||||||
EIMS_BUYER_REGION_CODES=Addis Ababa=13
|
# The removed EIMS_BUYER_COUNTRY_CODE / _COUNTRY_CODES / _REGION_CODES / _CITY_CODES /
|
||||||
# Same mechanism for Wereda. MoR has never named a Wereda regex in an error (only Region's is
|
# _WEREDA_CODES maps are ignored if still set — MoR reference data is the only source, and an env
|
||||||
# confirmed), so this is precautionary — but an unmapped name still fails locally, not filed as a guess.
|
# var must not be able to override an official code. Delete them from your deployment config.
|
||||||
EIMS_BUYER_WEREDA_CODES=
|
|
||||||
EIMS_CASHIER_NAME=
|
EIMS_CASHIER_NAME=
|
||||||
EIMS_SALESPERSON_NAME=
|
EIMS_SALESPERSON_NAME=
|
||||||
# Automatic filing of issued invoices (@Cron sweep, one invoice per tick).
|
# Automatic filing of issued invoices (@Cron sweep, one invoice per tick).
|
||||||
|
|||||||
@@ -38,7 +38,8 @@
|
|||||||
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show",
|
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show",
|
||||||
"migration:run": "nest build && node dist/scripts/migrate.js",
|
"migration:run": "nest build && node dist/scripts/migrate.js",
|
||||||
"script": "ts-node -r tsconfig-paths/register src/scripts/main.ts",
|
"script": "ts-node -r tsconfig-paths/register src/scripts/main.ts",
|
||||||
"eims:login": "ts-node -r tsconfig-paths/register src/scripts/eims-login.ts"
|
"eims:login": "ts-node -r tsconfig-paths/register src/scripts/eims-login.ts",
|
||||||
|
"eims:import-locations": "ts-node -r tsconfig-paths/register src/scripts/import-mor-locations.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@edr/api-common": "workspace:*",
|
"@edr/api-common": "workspace:*",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { applyDecorators, UseGuards } from '@nestjs/common';
|
import { applyDecorators, UseGuards } from '@nestjs/common';
|
||||||
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
import { FreightJwtGuard } from './freight-jwt.guard';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
FreightPermissionGuard,
|
FreightPermissionGuard,
|
||||||
@@ -11,7 +11,7 @@ import { FREIGHT_PERMS } from '../seed/freight-permissions.registry';
|
|||||||
export const BookingStaff = (permission: string | string[]) =>
|
export const BookingStaff = (permission: string | string[]) =>
|
||||||
applyDecorators(
|
applyDecorators(
|
||||||
UseGuards(
|
UseGuards(
|
||||||
JwtGuard,
|
FreightJwtGuard,
|
||||||
FreightPermissionGuard(
|
FreightPermissionGuard(
|
||||||
Array.isArray(permission) ? permission : [permission],
|
Array.isArray(permission) ? permission : [permission],
|
||||||
),
|
),
|
||||||
@@ -26,11 +26,11 @@ export const BookingStaff = (permission: string | string[]) =>
|
|||||||
* BookingStaff(<view key>) or MixedAudience(); kept for routes not yet swept.
|
* BookingStaff(<view key>) or MixedAudience(); kept for routes not yet swept.
|
||||||
*/
|
*/
|
||||||
export const StaffReference = () =>
|
export const StaffReference = () =>
|
||||||
applyDecorators(UseGuards(JwtGuard, FreightPermissionGuard([])));
|
applyDecorators(UseGuards(FreightJwtGuard, FreightPermissionGuard([])));
|
||||||
|
|
||||||
/** Portal routes: customer accounts only; ownership scoping stays in services. */
|
/** Portal routes: customer accounts only; ownership scoping stays in services. */
|
||||||
export const PortalCustomer = () =>
|
export const PortalCustomer = () =>
|
||||||
applyDecorators(UseGuards(JwtGuard, PortalCustomerGuard));
|
applyDecorators(UseGuards(FreightJwtGuard, PortalCustomerGuard));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Routes both audiences call (sign, shared document reads, handover): staff
|
* Routes both audiences call (sign, shared document reads, handover): staff
|
||||||
@@ -40,7 +40,7 @@ export const PortalCustomer = () =>
|
|||||||
export const MixedAudience = (permission: string | string[]) =>
|
export const MixedAudience = (permission: string | string[]) =>
|
||||||
applyDecorators(
|
applyDecorators(
|
||||||
UseGuards(
|
UseGuards(
|
||||||
JwtGuard,
|
FreightJwtGuard,
|
||||||
MixedAudienceGuard(
|
MixedAudienceGuard(
|
||||||
Array.isArray(permission) ? permission : [permission],
|
Array.isArray(permission) ? permission : [permission],
|
||||||
),
|
),
|
||||||
@@ -95,6 +95,24 @@ export const TrainSchedulingLoad = () =>
|
|||||||
export const TrainSchedulingUnload = () =>
|
export const TrainSchedulingUnload = () =>
|
||||||
BookingStaff(FREIGHT_PERMS.trainScheduling.unload);
|
BookingStaff(FREIGHT_PERMS.trainScheduling.unload);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-station loading/unloading time windows — the four buttons are four
|
||||||
|
* permissions so start and end can be granted to different people. The same
|
||||||
|
* endpoint that records a click also edits it (explicit `at`), so each
|
||||||
|
* permission covers editing its own timestamp too.
|
||||||
|
*/
|
||||||
|
export const TrainSchedulingLoadingStart = () =>
|
||||||
|
BookingStaff(FREIGHT_PERMS.trainScheduling.loadingStart);
|
||||||
|
|
||||||
|
export const TrainSchedulingLoadingEnd = () =>
|
||||||
|
BookingStaff(FREIGHT_PERMS.trainScheduling.loadingEnd);
|
||||||
|
|
||||||
|
export const TrainSchedulingUnloadingStart = () =>
|
||||||
|
BookingStaff(FREIGHT_PERMS.trainScheduling.unloadingStart);
|
||||||
|
|
||||||
|
export const TrainSchedulingUnloadingEnd = () =>
|
||||||
|
BookingStaff(FREIGHT_PERMS.trainScheduling.unloadingEnd);
|
||||||
|
|
||||||
export const TrainSchedulingCancel = () =>
|
export const TrainSchedulingCancel = () =>
|
||||||
BookingStaff(FREIGHT_PERMS.trainScheduling.cancel);
|
BookingStaff(FREIGHT_PERMS.trainScheduling.cancel);
|
||||||
|
|
||||||
|
|||||||
101
apps/edr-freight-api/src/common/freight-jwt.guard.ts
Normal file
101
apps/edr-freight-api/src/common/freight-jwt.guard.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||||
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
/** One position as the login snapshot stores it (`iam.sessions.userInfo`). */
|
||||||
|
type SnapshotPosition = { id?: string; [key: string]: unknown };
|
||||||
|
|
||||||
|
type SessionUserInfo = {
|
||||||
|
employee?: { id?: string; positions?: SnapshotPosition[] }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like the IAM JwtGuard, but keeps the caller's SECONDARY positions.
|
||||||
|
*
|
||||||
|
* IAM models an employee as holding many positions, and the login snapshot in
|
||||||
|
* `iam.sessions.userInfo` carries all of them. `JwtGuard.parseToken` then
|
||||||
|
* collapses that to a single `employee.position` — whichever the request
|
||||||
|
* headers select, else `positions[0]` — and drops the rest. Non-delegate
|
||||||
|
* secondary positions vanish entirely, so staff holding two posts resolve to
|
||||||
|
* only one post's permissions and every check on the other one rejects them.
|
||||||
|
*
|
||||||
|
* This re-attaches the full list as `employee.positions`. `employee.position`
|
||||||
|
* is left exactly as the parent set it, so everything reading the single
|
||||||
|
* position today (audit log, delegation deadline) is unaffected; only the
|
||||||
|
* permission utils, which prefer the array, see the difference.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class FreightJwtGuard extends IamJwtGuard implements CanActivate {
|
||||||
|
// ponytail: unbounded-until-TTL map, cleared wholesale when it gets big.
|
||||||
|
// Sessions are few and the value is small; swap for an LRU if that changes.
|
||||||
|
private static readonly CACHE_TTL_MS = 30_000;
|
||||||
|
private static readonly CACHE_MAX_ENTRIES = 5_000;
|
||||||
|
private readonly cache = new Map<
|
||||||
|
string,
|
||||||
|
{ positions: SnapshotPosition[]; expiresAt: number }
|
||||||
|
>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
reflector: Reflector,
|
||||||
|
@InjectDataSource() private readonly ds: DataSource,
|
||||||
|
) {
|
||||||
|
super(reflector, ds);
|
||||||
|
}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
if (!(await super.canActivate(context))) return false;
|
||||||
|
|
||||||
|
const user = context.switchToHttp().getRequest().user as
|
||||||
|
| TCurrentUser
|
||||||
|
| undefined;
|
||||||
|
const employee = user?.employee;
|
||||||
|
if (!employee || !user?.sessionId) return true;
|
||||||
|
|
||||||
|
const positions = await this.positionsForSession(
|
||||||
|
user.sessionId,
|
||||||
|
employee.id,
|
||||||
|
);
|
||||||
|
// Never blank out what the parent resolved: an unreadable session or a
|
||||||
|
// snapshot without positions must degrade to the single-position
|
||||||
|
// behaviour, not to no positions at all.
|
||||||
|
if (positions.length) {
|
||||||
|
(employee as { positions?: SnapshotPosition[] }).positions = positions;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every position the login snapshot holds for this employee. */
|
||||||
|
private async positionsForSession(
|
||||||
|
sessionId: string,
|
||||||
|
employeeId: string | undefined,
|
||||||
|
): Promise<SnapshotPosition[]> {
|
||||||
|
const now = Date.now();
|
||||||
|
const hit = this.cache.get(sessionId);
|
||||||
|
if (hit && hit.expiresAt > now) return hit.positions;
|
||||||
|
|
||||||
|
let positions: SnapshotPosition[] = [];
|
||||||
|
try {
|
||||||
|
const rows: { userInfo: SessionUserInfo | null }[] = await this.ds.query(
|
||||||
|
`SELECT "userInfo" FROM iam.sessions WHERE id = $1`,
|
||||||
|
[sessionId],
|
||||||
|
);
|
||||||
|
const employees = rows[0]?.userInfo?.employee ?? [];
|
||||||
|
const match =
|
||||||
|
employees.find((e) => e?.id && e.id === employeeId) ?? employees[0];
|
||||||
|
positions = match?.positions ?? [];
|
||||||
|
} catch {
|
||||||
|
return []; // iam unreachable — caller keeps the parent's single position
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.cache.size >= FreightJwtGuard.CACHE_MAX_ENTRIES)
|
||||||
|
this.cache.clear();
|
||||||
|
this.cache.set(sessionId, {
|
||||||
|
positions,
|
||||||
|
expiresAt: now + FreightJwtGuard.CACHE_TTL_MS,
|
||||||
|
});
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
assertCanApproveContractStep,
|
assertCanApproveContractStep,
|
||||||
canEditContractStep,
|
canEditContractStep,
|
||||||
collectPermissionKeys,
|
collectPermissionKeys,
|
||||||
|
collectPositionTypeKeys,
|
||||||
hasFreightPermission,
|
hasFreightPermission,
|
||||||
setPositionTypePermissionResolver,
|
setPositionTypePermissionResolver,
|
||||||
} from './freight-permission.util';
|
} from './freight-permission.util';
|
||||||
@@ -121,3 +122,66 @@ describe('collectPermissionKeys — position-type grants', () => {
|
|||||||
expect(hasFreightPermission(direct, CLEARANCE)).toBe(true);
|
expect(hasFreightPermission(direct, CLEARANCE)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IAM lets an employee hold several positions, but the vendored `JwtGuard`
|
||||||
|
* collapses `employee.positions[]` down to a single `employee.position` and
|
||||||
|
* drops the rest — so staff on two posts resolved to one post's permissions
|
||||||
|
* and every check on the other rejected them. `FreightJwtGuard` restores the
|
||||||
|
* full list as `employee.positions`; these cover the union that depends on it.
|
||||||
|
*/
|
||||||
|
describe('multiple positions', () => {
|
||||||
|
// Shaped like the real two-post employee: GL chief AND GL director.
|
||||||
|
const twoPost = {
|
||||||
|
employee: {
|
||||||
|
// What the vendored guard leaves behind — one of the two, arbitrarily.
|
||||||
|
position: {
|
||||||
|
positionType: { key: 'djibouti-gl-chief' },
|
||||||
|
permissions: [{ key: FREIGHT_PERMS.contracts.view }],
|
||||||
|
},
|
||||||
|
// What FreightJwtGuard puts back.
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
positionType: { key: 'djibouti-gl-chief' },
|
||||||
|
permissions: [{ key: FREIGHT_PERMS.contracts.view }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
positionType: { key: 'djibouti-gl-director' },
|
||||||
|
permissions: [{ key: FREIGHT_PERMS.bookings.view }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it('unions permissions across every position', () => {
|
||||||
|
const keys = collectPermissionKeys(twoPost);
|
||||||
|
expect(keys).toContain(FREIGHT_PERMS.contracts.view);
|
||||||
|
expect(keys).toContain(FREIGHT_PERMS.bookings.view);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('grants the secondary position’s permission, not just the first', () => {
|
||||||
|
expect(hasFreightPermission(twoPost, FREIGHT_PERMS.bookings.view)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers to both position types', () => {
|
||||||
|
expect(collectPositionTypeKeys(twoPost)).toEqual(
|
||||||
|
expect.arrayContaining(['djibouti-gl-chief', 'djibouti-gl-director']),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not double-count the position the guard also left singular', () => {
|
||||||
|
const keys = collectPermissionKeys(twoPost);
|
||||||
|
expect(keys.filter((k) => k === FREIGHT_PERMS.contracts.view)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still resolves the single position when the array is absent', () => {
|
||||||
|
// A request that skipped FreightJwtGuard must degrade to the old behaviour,
|
||||||
|
// not to no permissions at all.
|
||||||
|
const onePost = {
|
||||||
|
employee: {
|
||||||
|
position: { permissions: [{ key: FREIGHT_PERMS.contracts.view }] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(hasFreightPermission(onePost, FREIGHT_PERMS.contracts.view)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -17,6 +17,15 @@ type MeLikeUser = {
|
|||||||
permissions?: PermissionLike[];
|
permissions?: PermissionLike[];
|
||||||
positionType?: PositionTypeLike | null;
|
positionType?: PositionTypeLike | null;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* Every position the employee holds, restored by `FreightJwtGuard`
|
||||||
|
* from the login snapshot. The IAM guard only ever sets the singular
|
||||||
|
* `position` above; without this, a second post's grants are invisible.
|
||||||
|
*/
|
||||||
|
positions?: {
|
||||||
|
permissions?: PermissionLike[];
|
||||||
|
positionType?: PositionTypeLike | null;
|
||||||
|
}[];
|
||||||
delegatedPositions?: { permissions?: PermissionLike[] }[];
|
delegatedPositions?: { permissions?: PermissionLike[] }[];
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
@@ -98,10 +107,15 @@ export function collectPermissionKeys(user: MeLikeUser | null | undefined): stri
|
|||||||
return [...keys];
|
return [...keys];
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const p of employee.position?.permissions ?? []) {
|
// `position` is whichever single post the IAM guard selected; `positions` is
|
||||||
if (p.key) keys.add(p.key);
|
// the full set FreightJwtGuard restores. Walk both — the array is absent on
|
||||||
|
// a session the guard could not re-read, and the two overlap harmlessly.
|
||||||
|
for (const pos of [employee.position, ...(employee.positions ?? [])]) {
|
||||||
|
for (const p of pos?.permissions ?? []) {
|
||||||
|
if (p.key) keys.add(p.key);
|
||||||
|
}
|
||||||
|
addTypePermissions(pos?.positionType);
|
||||||
}
|
}
|
||||||
addTypePermissions(employee.position?.positionType);
|
|
||||||
for (const delegated of employee.delegatedPositions ?? []) {
|
for (const delegated of employee.delegatedPositions ?? []) {
|
||||||
for (const p of delegated.permissions ?? []) {
|
for (const p of delegated.permissions ?? []) {
|
||||||
if (p.key) keys.add(p.key);
|
if (p.key) keys.add(p.key);
|
||||||
@@ -158,8 +172,10 @@ export function collectPositionTypeKeys(
|
|||||||
return [...keys];
|
return [...keys];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (employee.position?.positionType?.key) {
|
// Both shapes, same reason as collectPermissionKeys: an employee holding two
|
||||||
keys.add(employee.position.positionType.key);
|
// posts answers to both their position types.
|
||||||
|
for (const pos of [employee.position, ...(employee.positions ?? [])]) {
|
||||||
|
if (pos?.positionType?.key) keys.add(pos.positionType.key);
|
||||||
}
|
}
|
||||||
return [...keys];
|
return [...keys];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { applyDecorators, UseGuards } from '@nestjs/common';
|
import { applyDecorators, UseGuards } from '@nestjs/common';
|
||||||
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
import { FreightJwtGuard } from './freight-jwt.guard';
|
||||||
|
|
||||||
import { FreightPermissionGuard } from './freight-permission.guard';
|
import { FreightPermissionGuard } from './freight-permission.guard';
|
||||||
import {
|
import {
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
|
|
||||||
export const RuleEngineView = (slug: RuleEngineResourceSlug) =>
|
export const RuleEngineView = (slug: RuleEngineResourceSlug) =>
|
||||||
applyDecorators(
|
applyDecorators(
|
||||||
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.view(slug)])),
|
UseGuards(FreightJwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.view(slug)])),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Granular CRUD replaces the retired coarse RuleEngineManage. Each write
|
// Granular CRUD replaces the retired coarse RuleEngineManage. Each write
|
||||||
@@ -18,17 +18,17 @@ export const RuleEngineView = (slug: RuleEngineResourceSlug) =>
|
|||||||
// update on PATCH / reorder / move-order, delete on DELETE.
|
// update on PATCH / reorder / move-order, delete on DELETE.
|
||||||
export const RuleEngineCreate = (slug: RuleEngineResourceSlug) =>
|
export const RuleEngineCreate = (slug: RuleEngineResourceSlug) =>
|
||||||
applyDecorators(
|
applyDecorators(
|
||||||
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.create(slug)])),
|
UseGuards(FreightJwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.create(slug)])),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const RuleEngineUpdate = (slug: RuleEngineResourceSlug) =>
|
export const RuleEngineUpdate = (slug: RuleEngineResourceSlug) =>
|
||||||
applyDecorators(
|
applyDecorators(
|
||||||
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.update(slug)])),
|
UseGuards(FreightJwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.update(slug)])),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const RuleEngineDelete = (slug: RuleEngineResourceSlug) =>
|
export const RuleEngineDelete = (slug: RuleEngineResourceSlug) =>
|
||||||
applyDecorators(
|
applyDecorators(
|
||||||
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.delete(slug)])),
|
UseGuards(FreightJwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.delete(slug)])),
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -38,5 +38,5 @@ export const RuleEngineDelete = (slug: RuleEngineResourceSlug) =>
|
|||||||
*/
|
*/
|
||||||
export const RuleEngineApprove = (slug: RuleEngineApprovableSlug) =>
|
export const RuleEngineApprove = (slug: RuleEngineApprovableSlug) =>
|
||||||
applyDecorators(
|
applyDecorators(
|
||||||
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.approve(slug)])),
|
UseGuards(FreightJwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.approve(slug)])),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -70,62 +70,3 @@ describe("eims.config — private key / certificate resolution", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("eims.config — baked-in Ethiopia region/zone/woreda codes", () => {
|
|
||||||
it("resolves a known region/wereda/zone with no env var set at all", () => {
|
|
||||||
withEnv(
|
|
||||||
{ ...REQUIRED, EIMS_PRIVATE_KEY: "x", EIMS_CERTIFICATE_PATH: "/dev/null" },
|
|
||||||
() => {
|
|
||||||
const cfg = eimsConfigFactory();
|
|
||||||
expect(cfg.invoice.buyerRegionCodes.Somali).toBe("05");
|
|
||||||
expect(cfg.invoice.buyerWeredaCodes["Jijiga Town"]).toBe("02");
|
|
||||||
expect(cfg.invoice.buyerCityCodes.Fafan).toBe("01");
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("an env var entry overrides the baked-in code for the same name", () => {
|
|
||||||
withEnv(
|
|
||||||
{
|
|
||||||
...REQUIRED,
|
|
||||||
EIMS_PRIVATE_KEY: "x",
|
|
||||||
EIMS_CERTIFICATE_PATH: "/dev/null",
|
|
||||||
EIMS_BUYER_REGION_CODES: "Somali=99",
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
expect(eimsConfigFactory().invoice.buyerRegionCodes.Somali).toBe("99");
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("an env var still adds a name the baked-in table doesn't have (a spelling variant)", () => {
|
|
||||||
withEnv(
|
|
||||||
{
|
|
||||||
...REQUIRED,
|
|
||||||
EIMS_PRIVATE_KEY: "x",
|
|
||||||
EIMS_CERTIFICATE_PATH: "/dev/null",
|
|
||||||
EIMS_BUYER_CITY_CODES: "Fafen=01",
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
const codes = eimsConfigFactory().invoice.buyerCityCodes;
|
|
||||||
expect(codes.Fafen).toBe("01");
|
|
||||||
expect(codes.Fafan).toBe("01"); // baked-in entry still present alongside it
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("resolves the bare Addis Ababa sub-city name a buyer profile actually stores, not the CSV's example-woreda name", () => {
|
|
||||||
withEnv(
|
|
||||||
{ ...REQUIRED, EIMS_PRIVATE_KEY: "x", EIMS_CERTIFICATE_PATH: "/dev/null" },
|
|
||||||
() => {
|
|
||||||
const codes = eimsConfigFactory().invoice.buyerWeredaCodes;
|
|
||||||
expect(codes.Bole).toBe("01");
|
|
||||||
expect(codes.Arada).toBe("01");
|
|
||||||
expect(codes.Kirkos).toBe("01");
|
|
||||||
expect(codes.Yeka).toBe("01");
|
|
||||||
expect(codes["Nifas Silk Lafto"]).toBe("13");
|
|
||||||
expect(codes["Nefas Silk-Lafto"]).toBe("13");
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { registerAs } from "@nestjs/config";
|
import { registerAs } from "@nestjs/config";
|
||||||
|
|
||||||
import { ETHIOPIA_REGION_CODES, ETHIOPIA_WOREDA_CODES, ETHIOPIA_ZONE_CODES } from "./ethiopia-geo-codes";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ethiopian MoR EIMS e-invoicing gateway.
|
* Ethiopian MoR EIMS e-invoicing gateway.
|
||||||
@@ -99,35 +98,6 @@ export interface EimsInvoiceConfig {
|
|||||||
paymentMode: string;
|
paymentMode: string;
|
||||||
paymentTerm: string;
|
paymentTerm: string;
|
||||||
unitDefault: string;
|
unitDefault: string;
|
||||||
/**
|
|
||||||
* Domestic fallback only — used when the buyer's `Company.country` is empty or "Ethiopia" (the
|
|
||||||
* column's own default) and not already listed in `buyerCountryCodes`. A genuinely foreign
|
|
||||||
* buyer must be in `buyerCountryCodes` by name or the mapping fails locally; this value is never
|
|
||||||
* applied to them, so an unconfigured foreign country can't silently be filed as Ethiopia.
|
|
||||||
*/
|
|
||||||
buyerCountryCode: string | null;
|
|
||||||
/**
|
|
||||||
* Country name → MoR code, from `EIMS_BUYER_COUNTRY_CODES` ("Ethiopia=231,Djibouti=071"). Format
|
|
||||||
* unconfirmed (unlike Region/Wereda, MoR has never named a Country regex), so — unlike them —
|
|
||||||
* this is not validated against a fixed digit pattern, only looked up by name.
|
|
||||||
*/
|
|
||||||
buyerCountryCodes: Record<string, string>;
|
|
||||||
/**
|
|
||||||
* Buyer region name → MoR numeric code, from `EIMS_BUYER_REGION_CODES`
|
|
||||||
* ("Addis Ababa=13,Oromia=4"). A buyer whose region is neither a code nor in this map fails
|
|
||||||
* locally rather than being filed with a guessed one.
|
|
||||||
*/
|
|
||||||
buyerRegionCodes: Record<string, string>;
|
|
||||||
/** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */
|
|
||||||
buyerWeredaCodes: Record<string, string>;
|
|
||||||
/**
|
|
||||||
* Buyer *zone* name → MoR City code, from `EIMS_BUYER_CITY_CODES` ("Kirkos=101"). `Company` has
|
|
||||||
* no dedicated city column — Zone is the closest match in EDR's own data. Optional, unlike
|
|
||||||
* Region/Wereda: MoR has never required City on a live buyer (confirmed — filing already
|
|
||||||
* succeeds with it null), so an unmapped zone falls back to null rather than failing the
|
|
||||||
* mapping.
|
|
||||||
*/
|
|
||||||
buyerCityCodes: Record<string, string>;
|
|
||||||
/**
|
/**
|
||||||
* Per-`chargeType` tax treatment, e.g. `EIMS_TAX_CODE_BY_CHARGE_TYPE=RAIL_FREIGHT=VAT0` +
|
* Per-`chargeType` tax treatment, e.g. `EIMS_TAX_CODE_BY_CHARGE_TYPE=RAIL_FREIGHT=VAT0` +
|
||||||
* `EIMS_TAX_RATE_BY_CHARGE_TYPE=RAIL_FREIGHT=0`. A charge type not listed here falls back to
|
* `EIMS_TAX_RATE_BY_CHARGE_TYPE=RAIL_FREIGHT=0`. A charge type not listed here falls back to
|
||||||
@@ -258,13 +228,6 @@ export default registerAs("eims", (): EimsConfig => {
|
|||||||
paymentMode: process.env.EIMS_PAYMENT_MODE ?? "",
|
paymentMode: process.env.EIMS_PAYMENT_MODE ?? "",
|
||||||
paymentTerm: process.env.EIMS_PAYMENT_TERM ?? "",
|
paymentTerm: process.env.EIMS_PAYMENT_TERM ?? "",
|
||||||
unitDefault: process.env.EIMS_UNIT_DEFAULT ?? "",
|
unitDefault: process.env.EIMS_UNIT_DEFAULT ?? "",
|
||||||
buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null,
|
|
||||||
buyerCountryCodes: parseCodeMap(process.env.EIMS_BUYER_COUNTRY_CODES),
|
|
||||||
// Baked-in Ethiopia reference table first, env var entries win on a name collision — lets a
|
|
||||||
// deployment override or add to it without a redeploy. See ethiopia-geo-codes.ts.
|
|
||||||
buyerRegionCodes: { ...ETHIOPIA_REGION_CODES, ...parseCodeMap(process.env.EIMS_BUYER_REGION_CODES) },
|
|
||||||
buyerWeredaCodes: { ...ETHIOPIA_WOREDA_CODES, ...parseCodeMap(process.env.EIMS_BUYER_WEREDA_CODES) },
|
|
||||||
buyerCityCodes: { ...ETHIOPIA_ZONE_CODES, ...parseCodeMap(process.env.EIMS_BUYER_CITY_CODES) },
|
|
||||||
taxCodeByChargeType: parseCodeMap(process.env.EIMS_TAX_CODE_BY_CHARGE_TYPE),
|
taxCodeByChargeType: parseCodeMap(process.env.EIMS_TAX_CODE_BY_CHARGE_TYPE),
|
||||||
taxRateByChargeType: parseCodeMap(process.env.EIMS_TAX_RATE_BY_CHARGE_TYPE),
|
taxRateByChargeType: parseCodeMap(process.env.EIMS_TAX_RATE_BY_CHARGE_TYPE),
|
||||||
exciseByChargeType: parseCodeMap(process.env.EIMS_EXCISE_BY_CHARGE_TYPE),
|
exciseByChargeType: parseCodeMap(process.env.EIMS_EXCISE_BY_CHARGE_TYPE),
|
||||||
|
|||||||
@@ -1,160 +0,0 @@
|
|||||||
/**
|
|
||||||
* MoR EIMS region/zone/woreda codes, by name — the baked-in fallback under
|
|
||||||
* `EIMS_BUYER_REGION_CODES`/`EIMS_BUYER_WEREDA_CODES`/`EIMS_BUYER_CITY_CODES` (zone is the closest
|
|
||||||
* match to EIMS's "City", per `eims-invoice.mapper.ts`).
|
|
||||||
*
|
|
||||||
* Before this existed, every buyer from a not-yet-seen region/zone/woreda crashed EIMS filing until
|
|
||||||
* someone hunted down the code and added it to an env var by hand — happened three times in one
|
|
||||||
* afternoon (2026-08-17: Somali region, Fafan zone, Jigjiga woreda, even the Ethiopia country code
|
|
||||||
* itself were all unset). Ethiopia's administrative divisions are fixed, known, reference data, not
|
|
||||||
* something that should be maintained reactively per buyer. Source: `ethiopia_administrative_
|
|
||||||
* hierarchy_master.csv`, supplied 2026-08-17 — NOT exhaustive (a representative sample per region,
|
|
||||||
* not all ~1000 real woredas), extend as new gaps surface.
|
|
||||||
*
|
|
||||||
* The env vars stay wired in ahead of this table (see `eims.config.ts`) — for a quick correction
|
|
||||||
* without a redeploy, or a name spelled differently in a buyer's profile than in this table (already
|
|
||||||
* hit live: DB has zone "Fafen", this table's official spelling is "Fafan" — same zone, matching is
|
|
||||||
* case/space-insensitive but not spelling-tolerant, so the env var override is still how that buyer
|
|
||||||
* actually resolves; this table mainly helps the *next* buyer whose profile spelling matches).
|
|
||||||
*
|
|
||||||
* ponytail: region names are unique nationwide (only ~15), safe as a flat map. Zone and woreda names
|
|
||||||
* are not always unique across different regions (e.g. "North Shewa" is both an Amhara zone and an
|
|
||||||
* Oromia zone, different codes) — `Company` stores region/zone/woreda as three independent strings,
|
|
||||||
* no parent linkage, so a flat name lookup can't disambiguate. First occurrence in the source data
|
|
||||||
* wins on a collision. Only affects the optional `City` field (zone) — never blocks filing, unlike
|
|
||||||
* Region/Wereda. A correct fix needs `Company` to store a linked hierarchy, not just three strings;
|
|
||||||
* out of scope here. Upgrade path: key this by `${region}/${zone}` once that linkage exists.
|
|
||||||
*/
|
|
||||||
const ROWS: Array<[region: string, zone: string, woreda: string, regionCode: string, zoneCode: string, woredaCode: string]> = [
|
|
||||||
["Tigray", "Western Tigray", "Humera", "01", "01", "01"],
|
|
||||||
["Tigray", "Western Tigray", "Kafta Humera", "01", "01", "02"],
|
|
||||||
["Tigray", "Western Tigray", "Tsegede", "01", "01", "03"],
|
|
||||||
["Tigray", "North Western Tigray", "Shire Endaselassie", "01", "02", "01"],
|
|
||||||
["Tigray", "North Western Tigray", "Sheraro", "01", "02", "02"],
|
|
||||||
["Tigray", "Central Tigray", "Axum", "01", "03", "01"],
|
|
||||||
["Tigray", "Central Tigray", "Adwa", "01", "03", "02"],
|
|
||||||
["Tigray", "Eastern Tigray", "Adigrat", "01", "04", "01"],
|
|
||||||
["Tigray", "Southern Tigray", "Maychew", "01", "05", "01"],
|
|
||||||
["Tigray", "Mekelle Special Zone", "Mekelle City", "01", "06", "01"],
|
|
||||||
["Afar", "Awusi Rasu (Zone 1)", "Asayita", "02", "01", "01"],
|
|
||||||
["Afar", "Awusi Rasu (Zone 1)", "Semera-Logiya", "02", "01", "02"],
|
|
||||||
["Afar", "Kilbet Rasu (Zone 2)", "Abala", "02", "02", "01"],
|
|
||||||
["Afar", "Gabi Rasu (Zone 3)", "Awash Fentale", "02", "03", "01"],
|
|
||||||
["Afar", "Fantena Rasu (Zone 4)", "Yalo", "02", "04", "01"],
|
|
||||||
["Afar", "Hari Rasu (Zone 5)", "Telalak", "02", "05", "01"],
|
|
||||||
["Amhara", "North Gondar", "Debark", "03", "01", "01"],
|
|
||||||
["Amhara", "South Gondar", "Debre Tabor", "03", "02", "01"],
|
|
||||||
["Amhara", "North Wollo", "Woldiya", "03", "03", "01"],
|
|
||||||
["Amhara", "South Wollo", "Dessie Town", "03", "04", "01"],
|
|
||||||
["Amhara", "North Shewa", "Debre Berhan", "03", "05", "01"],
|
|
||||||
["Amhara", "East Gojjam", "Debre Markos", "03", "06", "01"],
|
|
||||||
["Amhara", "West Gojjam", "Finote Selam", "03", "07", "01"],
|
|
||||||
["Amhara", "Wag Hemra", "Sekota", "03", "08", "01"],
|
|
||||||
["Amhara", "Awi", "Injibara", "03", "09", "01"],
|
|
||||||
["Amhara", "Oromia Special Zone", "Kemise", "03", "10", "01"],
|
|
||||||
["Amhara", "Bahir Dar Special Zone", "Bahir Dar City", "03", "11", "01"],
|
|
||||||
["Amhara", "Gondar Special Zone", "Gondar City", "03", "12", "01"],
|
|
||||||
["Oromia", "North Shewa", "Fiche", "04", "01", "01"],
|
|
||||||
["Oromia", "South West Shewa", "Waliso", "04", "02", "01"],
|
|
||||||
["Oromia", "East Shewa", "Adama Town", "04", "03", "01"],
|
|
||||||
["Oromia", "East Shewa", "Bishoftu Town", "04", "03", "02"],
|
|
||||||
["Oromia", "West Shewa", "Ambo", "04", "04", "01"],
|
|
||||||
["Oromia", "Arsi", "Asella", "04", "05", "01"],
|
|
||||||
["Oromia", "West Arsi", "Shashemene", "04", "06", "01"],
|
|
||||||
["Oromia", "Bale", "Robe", "04", "07", "01"],
|
|
||||||
["Oromia", "East Hararghe", "Harar Outskirts", "04", "08", "01"],
|
|
||||||
["Oromia", "West Hararghe", "Chiro", "04", "09", "01"],
|
|
||||||
["Oromia", "Jimma", "Jimma Town", "04", "10", "01"],
|
|
||||||
["Oromia", "Illubabor", "Mettu", "04", "11", "01"],
|
|
||||||
["Oromia", "Buno Bedele", "Bedele", "04", "12", "01"],
|
|
||||||
["Oromia", "Welega (West)", "Gimbi", "04", "13", "01"],
|
|
||||||
["Oromia", "Welega (East)", "Nekemte", "04", "14", "01"],
|
|
||||||
["Oromia", "Horo Guduru Welega", "Shambu", "04", "15", "01"],
|
|
||||||
["Oromia", "Kelam Welega", "Dembidolo", "04", "16", "01"],
|
|
||||||
["Oromia", "Borena", "Yabelo", "04", "17", "01"],
|
|
||||||
["Oromia", "Guji", "Negele Borana", "04", "18", "01"],
|
|
||||||
["Oromia", "West Guji", "Bule Hora", "04", "19", "01"],
|
|
||||||
["Oromia", "East Bale", "Ginir", "04", "20", "01"],
|
|
||||||
["Oromia", "Sheger City", "Sululta", "04", "21", "01"],
|
|
||||||
["Somali", "Fafan", "Jijiga Woreda", "05", "01", "01"],
|
|
||||||
["Somali", "Fafan", "Jijiga Town", "05", "01", "02"],
|
|
||||||
["Somali", "Fafan", "Awbare", "05", "01", "03"],
|
|
||||||
["Somali", "Sitti", "Shinile", "05", "02", "01"],
|
|
||||||
["Somali", "Erer", "Fiq", "05", "03", "01"],
|
|
||||||
["Somali", "Jarar", "Degehabur", "05", "04", "01"],
|
|
||||||
["Somali", "Nogob", "Segeg", "05", "05", "01"],
|
|
||||||
["Somali", "Korahe", "Kebridehar", "05", "06", "01"],
|
|
||||||
["Somali", "Shabelle", "Gode", "05", "07", "01"],
|
|
||||||
["Somali", "Afder", "Afder Woreda", "05", "08", "01"],
|
|
||||||
["Somali", "Liben", "Filtu", "05", "09", "01"],
|
|
||||||
["Somali", "Dhawa", "Mubarak", "05", "10", "01"],
|
|
||||||
["Somali", "Dollo", "Warder", "05", "11", "01"],
|
|
||||||
["Benishangul-Gumuz", "Asosa", "Asosa Woreda", "06", "01", "01"],
|
|
||||||
["Benishangul-Gumuz", "Kamasashi", "Kamasashi Woreda", "06", "02", "01"],
|
|
||||||
["Benishangul-Gumuz", "Metekel", "Gilgel Beles", "06", "03", "01"],
|
|
||||||
["Southern Ethiopia", "Wolayta", "Sodo Zuria", "07", "01", "01"],
|
|
||||||
["Southern Ethiopia", "Wolayta", "Sodo Town", "07", "01", "02"],
|
|
||||||
["Southern Ethiopia", "Gamo", "Arba Minch Town", "07", "02", "01"],
|
|
||||||
["Southern Ethiopia", "Gofa", "Sawla", "07", "03", "01"],
|
|
||||||
["Southern Ethiopia", "Konso", "Konso Woreda", "07", "04", "01"],
|
|
||||||
["Southern Ethiopia", "South Omo", "Jinka", "07", "05", "01"],
|
|
||||||
["Gambela", "Anywaa", "Gambela Zuria", "08", "01", "01"],
|
|
||||||
["Gambela", "Nuer", "Lare", "08", "02", "01"],
|
|
||||||
["Gambela", "Majang", "Metu Zuria part", "08", "03", "01"],
|
|
||||||
["Harari", "Harar Hundanee", "Amir Nur Woreda", "09", "01", "01"],
|
|
||||||
["Harari", "Harar Hundanee", "Abadir Woreda", "09", "01", "02"],
|
|
||||||
["Addis Ababa", "Bole Sub-City", "Bole Woreda 01", "10", "01", "01"],
|
|
||||||
["Addis Ababa", "Kirkos Sub-City", "Kirkos Woreda 01", "10", "02", "01"],
|
|
||||||
["Addis Ababa", "Nifas Silk Lafto", "NSL Woreda 13", "10", "03", "13"],
|
|
||||||
["Addis Ababa", "Yeka Sub-City", "Yeka Woreda 01", "10", "04", "01"],
|
|
||||||
["Addis Ababa", "Arada Sub-City", "Arada Woreda 01", "10", "05", "01"],
|
|
||||||
["Dire Dawa", "Dire Dawa Urban", "Melka Jebdu", "11", "01", "01"],
|
|
||||||
["Dire Dawa", "Dire Dawa Rural", "Gurgura", "11", "02", "01"],
|
|
||||||
["Sidama", "Hawassa City Admin", "Hayek Chereka", "12", "01", "01"],
|
|
||||||
["Sidama", "Sidama Zuria", "Yirgalem Town", "12", "02", "01"],
|
|
||||||
["Sidama", "Sidama Zuria", "Aleta Wendo", "12", "02", "02"],
|
|
||||||
["Southwest Ethiopia", "Keffa", "Bonga Town", "13", "01", "01"],
|
|
||||||
["Southwest Ethiopia", "Sheka", "Mappi Zuria", "13", "02", "01"],
|
|
||||||
["Southwest Ethiopia", "Bench Sheko", "Mizan Aman", "13", "03", "01"],
|
|
||||||
["Central Ethiopia", "Gurage", "Wolkite", "14", "01", "01"],
|
|
||||||
["Central Ethiopia", "Hadiya", "Hosaina", "14", "02", "01"],
|
|
||||||
["Central Ethiopia", "Silte", "Worabe", "14", "03", "01"],
|
|
||||||
["Gedeo State", "Gedeo Zone", "Dilla Zuria", "15", "01", "01"],
|
|
||||||
["Gedeo State", "Gedeo Zone", "Yirgacheffe", "15", "01", "02"],
|
|
||||||
];
|
|
||||||
|
|
||||||
/** First occurrence wins on a name collision — see the class comment. */
|
|
||||||
const buildMap = (pick: (row: (typeof ROWS)[number]) => [string, string]): Record<string, string> => {
|
|
||||||
const map: Record<string, string> = {};
|
|
||||||
for (const row of ROWS) {
|
|
||||||
const [name, code] = pick(row);
|
|
||||||
if (!(name in map)) map[name] = code;
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ETHIOPIA_REGION_CODES: Record<string, string> = buildMap((r) => [r[0], r[3]]);
|
|
||||||
/** Zone name → code. Fed into `buyerCityCodes` — EIMS's "City" is really the buyer's zone. */
|
|
||||||
export const ETHIOPIA_ZONE_CODES: Record<string, string> = buildMap((r) => [r[1], r[4]]);
|
|
||||||
export const ETHIOPIA_WOREDA_CODES: Record<string, string> = buildMap((r) => [r[2], r[5]]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Buyer records commonly store just the bare Addis Ababa sub-city name ("Bole", "Arada") as their
|
|
||||||
* woreda, not the source CSV's specific example-woreda name ("Bole Woreda 01") — confirmed live
|
|
||||||
* 2026-08-17 across three different buyers before any of them actually got past this check. Since
|
|
||||||
* the CSV lists exactly one representative woreda per Addis sub-city, alias the bare name to that
|
|
||||||
* same code rather than wait on a fuller table.
|
|
||||||
*/
|
|
||||||
const ADDIS_SUBCITY_ALIASES: Array<[bareName: string, csvZoneName: string]> = [
|
|
||||||
["Bole", "Bole Sub-City"],
|
|
||||||
["Kirkos", "Kirkos Sub-City"],
|
|
||||||
["Nifas Silk Lafto", "Nifas Silk Lafto"],
|
|
||||||
// Matches EIMS_BUYER_WEREDA_CODES' own existing spelling in .env — same zone, different hyphenation.
|
|
||||||
["Nefas Silk-Lafto", "Nifas Silk Lafto"],
|
|
||||||
["Yeka", "Yeka Sub-City"],
|
|
||||||
["Arada", "Arada Sub-City"],
|
|
||||||
];
|
|
||||||
for (const [bareName, csvZoneName] of ADDIS_SUBCITY_ALIASES) {
|
|
||||||
const row = ROWS.find((r) => r[1] === csvZoneName);
|
|
||||||
if (row && !(bareName in ETHIOPIA_WOREDA_CODES)) ETHIOPIA_WOREDA_CODES[bareName] = row[5];
|
|
||||||
}
|
|
||||||
275
apps/edr-freight-api/src/config/mor-location.resolver.spec.ts
Normal file
275
apps/edr-freight-api/src/config/mor-location.resolver.spec.ts
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
import { MorLocationTuple } from "./mor-locations.data";
|
||||||
|
import {
|
||||||
|
MorGeoMappingError,
|
||||||
|
normalizeName,
|
||||||
|
resolveMorGeo,
|
||||||
|
tryResolveMorGeo,
|
||||||
|
} from "./mor-location.resolver";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rows copied verbatim out of the Ministry sheet (`EIMS_COUNTRY_REGION_VW`), chosen for the traps
|
||||||
|
* the real data contains rather than for tidiness:
|
||||||
|
*
|
||||||
|
* - BABILE and KERSA each exist in two different zones with different LOCALITY_NOs — the reason a
|
||||||
|
* global name lookup is unsafe and the hierarchy is mandatory.
|
||||||
|
* - ILLUBABOR has BURE twice under the same zone with different LOCALITY_NOs (691 and 890, the
|
||||||
|
* second with the Ministry's own trailing space) — a genuine ambiguity that must never be
|
||||||
|
* silently resolved to the first row.
|
||||||
|
* - "Wal-Mera" and "Akaki woreda" carry the sheet's mixed casing and punctuation.
|
||||||
|
*/
|
||||||
|
const FIXTURE: MorLocationTuple[] = [
|
||||||
|
[70, "Ethiopia", 6, "SOMALI", 31, "FAAFAN ZONE", 190, "JIJIGA"],
|
||||||
|
[70, "Ethiopia", 6, "SOMALI", 31, "FAAFAN ZONE", 194, "BABILE"],
|
||||||
|
[70, "Ethiopia", 6, "SOMALI", 30, "SITI ZONE", 197, "DENBEL"],
|
||||||
|
[70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 495, "BABILE"],
|
||||||
|
[70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 482, "KERSA"],
|
||||||
|
[70, "Ethiopia", 2, "OROMIA", 72, "JIMMA ZONE", 503, "KERSA"],
|
||||||
|
[70, "Ethiopia", 2, "OROMIA", 73, "ILLUBABOR", 691, "BURE"],
|
||||||
|
[70, "Ethiopia", 2, "OROMIA", 73, "ILLUBABOR", 890, "BURE "],
|
||||||
|
[70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 976, "Wal-Mera"],
|
||||||
|
[70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 909, "Akaki woreda"],
|
||||||
|
[70, "Ethiopia", 13, "ADDIS ABABA", 78, "BOLE", 1100, "WOREDA 1"],
|
||||||
|
[253, "Djibouti", 1, "DJIBOUTI", 1, "DJIBOUTI VILLE", 1, "BALBALA"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const JIJIGA = {
|
||||||
|
country: "Ethiopia",
|
||||||
|
region: "SOMALI",
|
||||||
|
zone: "FAAFAN ZONE",
|
||||||
|
woreda: "JIJIGA",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("normalizeName", () => {
|
||||||
|
it("collapses whitespace, trims, and compares case-insensitively", () => {
|
||||||
|
expect(normalizeName(" FAAFAN ZONE ")).toBe("FAAFAN ZONE");
|
||||||
|
expect(normalizeName("faafan zone")).toBe("FAAFAN ZONE");
|
||||||
|
expect(normalizeName(" FAAFAN ZONE ")).toBe(normalizeName("faafan zone"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes harmless punctuation and hyphen/space differences", () => {
|
||||||
|
expect(normalizeName("Wal-Mera")).toBe("WAL MERA");
|
||||||
|
expect(normalizeName("Wal Mera")).toBe("WAL MERA");
|
||||||
|
expect(normalizeName("ZONE 1 (AYSSAITA)")).toBe("ZONE 1 AYSSAITA");
|
||||||
|
expect(normalizeName("Ber'ano")).toBe("BERANO");
|
||||||
|
expect(normalizeName("KEAHORE/HADAT/")).toBe("KEAHORE HADAT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps digits, which several MoR locality names depend on", () => {
|
||||||
|
expect(normalizeName(" woreda 10 ")).toBe("WOREDA 10");
|
||||||
|
expect(normalizeName("WOREDA 1")).not.toBe(normalizeName("WOREDA 10"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveMorGeo", () => {
|
||||||
|
it("resolves the exact MoR spelling to the Ministry's own codes", () => {
|
||||||
|
expect(resolveMorGeo(JIJIGA, FIXTURE)).toEqual({
|
||||||
|
Country: "70",
|
||||||
|
Region: "6",
|
||||||
|
City: "31",
|
||||||
|
Wereda: "190",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves the EDR/e-Trade spellings through the alias layer", () => {
|
||||||
|
expect(
|
||||||
|
resolveMorGeo(
|
||||||
|
{
|
||||||
|
country: "Ethiopia",
|
||||||
|
region: "Somali",
|
||||||
|
zone: "Fafen",
|
||||||
|
woreda: "Jigjiga",
|
||||||
|
},
|
||||||
|
FIXTURE,
|
||||||
|
),
|
||||||
|
).toEqual({ Country: "70", Region: "6", City: "31", Wereda: "190" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is case-insensitive", () => {
|
||||||
|
expect(
|
||||||
|
resolveMorGeo(
|
||||||
|
{
|
||||||
|
country: "ethiopia",
|
||||||
|
region: "somali",
|
||||||
|
zone: "faafan zone",
|
||||||
|
woreda: "jijiga",
|
||||||
|
},
|
||||||
|
FIXTURE,
|
||||||
|
),
|
||||||
|
).toEqual({ Country: "70", Region: "6", City: "31", Wereda: "190" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores leading, trailing and repeated whitespace on every level", () => {
|
||||||
|
expect(
|
||||||
|
resolveMorGeo(
|
||||||
|
{
|
||||||
|
country: " Ethiopia ",
|
||||||
|
region: " SOMALI ",
|
||||||
|
zone: " FAAFAN ZONE ",
|
||||||
|
woreda: "\tJIJIGA ",
|
||||||
|
},
|
||||||
|
FIXTURE,
|
||||||
|
),
|
||||||
|
).toEqual({ Country: "70", Region: "6", City: "31", Wereda: "190" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a hyphen as a space, in either direction", () => {
|
||||||
|
const expected = { Country: "70", Region: "2", City: "86", Wereda: "976" };
|
||||||
|
const base = {
|
||||||
|
country: "Ethiopia",
|
||||||
|
region: "Oromia",
|
||||||
|
zone: "Finfine Vic Spec",
|
||||||
|
};
|
||||||
|
expect(resolveMorGeo({ ...base, woreda: "Wal-Mera" }, FIXTURE)).toEqual(expected);
|
||||||
|
expect(resolveMorGeo({ ...base, woreda: "wal mera" }, FIXTURE)).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches a zone whose MoR label carries the ' ZONE' suffix EDR does not store", () => {
|
||||||
|
expect(resolveMorGeo({ ...JIJIGA, zone: "Faafan" }, FIXTURE).City).toBe("31");
|
||||||
|
expect(
|
||||||
|
resolveMorGeo(
|
||||||
|
{
|
||||||
|
country: "Ethiopia",
|
||||||
|
region: "Somali",
|
||||||
|
zone: "Siti",
|
||||||
|
woreda: "Denbel",
|
||||||
|
},
|
||||||
|
FIXTURE,
|
||||||
|
),
|
||||||
|
).toEqual({ Country: "70", Region: "6", City: "30", Wereda: "197" });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a locality name that exists in more than one zone", () => {
|
||||||
|
it("picks BABILE by its full hierarchy, never by name alone", () => {
|
||||||
|
expect(resolveMorGeo({ ...JIJIGA, woreda: "BABILE" }, FIXTURE).Wereda).toBe("194");
|
||||||
|
expect(
|
||||||
|
resolveMorGeo(
|
||||||
|
{
|
||||||
|
country: "Ethiopia",
|
||||||
|
region: "OROMIA",
|
||||||
|
zone: "MISRAK HARARGE",
|
||||||
|
woreda: "BABILE",
|
||||||
|
},
|
||||||
|
FIXTURE,
|
||||||
|
).Wereda,
|
||||||
|
).toBe("495");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("picks KERSA by its full hierarchy", () => {
|
||||||
|
const oromia = { country: "Ethiopia", region: "OROMIA" };
|
||||||
|
expect(
|
||||||
|
resolveMorGeo({ ...oromia, zone: "MISRAK HARARGE", woreda: "KERSA" }, FIXTURE).Wereda,
|
||||||
|
).toBe("482");
|
||||||
|
expect(
|
||||||
|
resolveMorGeo({ ...oromia, zone: "JIMMA ZONE", woreda: "KERSA" }, FIXTURE).Wereda,
|
||||||
|
).toBe("503");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let a locality leak across regions", () => {
|
||||||
|
// DENBEL exists under SOMALI/SITI ZONE only — asking for it under OROMIA must fail, not
|
||||||
|
// fall back to the nationwide match the old flat maps would have found.
|
||||||
|
expect(() =>
|
||||||
|
resolveMorGeo(
|
||||||
|
{
|
||||||
|
country: "Ethiopia",
|
||||||
|
region: "OROMIA",
|
||||||
|
zone: "MISRAK HARARGE",
|
||||||
|
woreda: "DENBEL",
|
||||||
|
},
|
||||||
|
FIXTURE,
|
||||||
|
),
|
||||||
|
).toThrow(/no MoR LOCALITY_DESC match/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("failures happen locally, before anything is filed", () => {
|
||||||
|
const cases: Array<[string, Record<string, string>, RegExp]> = [
|
||||||
|
["unknown country", { ...JIJIGA, country: "Wakanda" }, /no MoR COUNTRY_NAME match/],
|
||||||
|
["unknown region", { ...JIJIGA, region: "Atlantis" }, /no MoR PARISH_NAME match/],
|
||||||
|
["unknown zone", { ...JIJIGA, zone: "Nowhere Zone" }, /no MoR CITY_NAME match/],
|
||||||
|
["unknown woreda", { ...JIJIGA, woreda: "Example" }, /no MoR LOCALITY_DESC match/],
|
||||||
|
];
|
||||||
|
|
||||||
|
it.each(cases)("%s fails with an actionable validation error", (_label, input, pattern) => {
|
||||||
|
expect(() => resolveMorGeo(input, FIXTURE)).toThrow(MorGeoMappingError);
|
||||||
|
expect(() => resolveMorGeo(input, FIXTURE)).toThrow(pattern);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the offending address in the message so the company record can be corrected", () => {
|
||||||
|
expect(() => resolveMorGeo({ ...JIJIGA, woreda: "Example" }, FIXTURE)).toThrow(
|
||||||
|
/country="Ethiopia", region="SOMALI", zone="FAAFAN ZONE", woreda="Example"/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an ambiguous locality instead of taking the first row", () => {
|
||||||
|
const input = {
|
||||||
|
country: "Ethiopia",
|
||||||
|
region: "OROMIA",
|
||||||
|
zone: "ILLUBABOR",
|
||||||
|
woreda: "BURE",
|
||||||
|
};
|
||||||
|
expect(() => resolveMorGeo(input, FIXTURE)).toThrow(MorGeoMappingError);
|
||||||
|
expect(() => resolveMorGeo(input, FIXTURE)).toThrow(/ambiguous/);
|
||||||
|
// Both colliding codes are named, and neither is silently selected.
|
||||||
|
expect(() => resolveMorGeo(input, FIXTURE)).toThrow(/691, 890/);
|
||||||
|
expect(tryResolveMorGeo(input, FIXTURE)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails loudly when the MoR master has not been generated yet", () => {
|
||||||
|
expect(() => resolveMorGeo(JIJIGA, [])).toThrow(/MoR location master is empty/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reproduces MoR's numeric values unchanged, as strings", () => {
|
||||||
|
const codes = resolveMorGeo(JIJIGA, FIXTURE);
|
||||||
|
expect(codes).toEqual({
|
||||||
|
Country: "70",
|
||||||
|
Region: "6",
|
||||||
|
City: "31",
|
||||||
|
Wereda: "190",
|
||||||
|
});
|
||||||
|
for (const value of Object.values(codes)) {
|
||||||
|
expect(typeof value).toBe("string");
|
||||||
|
expect(value).toMatch(/^[0-9]+$/);
|
||||||
|
}
|
||||||
|
// The source row is the only origin of every code — no renumbering, no derivation.
|
||||||
|
const [countryNo, , parishNo, , cityNo, , localityNo] = FIXTURE[0];
|
||||||
|
expect(codes).toEqual({
|
||||||
|
Country: String(countryNo),
|
||||||
|
Region: String(parishNo),
|
||||||
|
City: String(cityNo),
|
||||||
|
Wereda: String(localityNo),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never emits an Open Admin Data ETxx identifier", () => {
|
||||||
|
for (const value of Object.values(resolveMorGeo(JIJIGA, FIXTURE))) {
|
||||||
|
expect(value).not.toMatch(/^ET/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a blank country as domestic, matching the column default", () => {
|
||||||
|
expect(resolveMorGeo({ ...JIJIGA, country: "" }, FIXTURE).Country).toBe("70");
|
||||||
|
expect(resolveMorGeo({ ...JIJIGA, country: null }, FIXTURE).Country).toBe("70");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves a named foreign country rather than defaulting it to Ethiopia", () => {
|
||||||
|
expect(
|
||||||
|
resolveMorGeo(
|
||||||
|
{
|
||||||
|
country: "Djibouti",
|
||||||
|
region: "DJIBOUTI",
|
||||||
|
zone: "DJIBOUTI VILLE",
|
||||||
|
woreda: "BALBALA",
|
||||||
|
},
|
||||||
|
FIXTURE,
|
||||||
|
),
|
||||||
|
).toEqual({ Country: "253", Region: "1", City: "1", Wereda: "1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a company record that already holds a MoR code, but only a real one", () => {
|
||||||
|
expect(resolveMorGeo({ ...JIJIGA, region: "6" }, FIXTURE).Region).toBe("6");
|
||||||
|
expect(() => resolveMorGeo({ ...JIJIGA, region: "999" }, FIXTURE)).toThrow(
|
||||||
|
/no MoR PARISH_NAME match/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
255
apps/edr-freight-api/src/config/mor-location.resolver.ts
Normal file
255
apps/edr-freight-api/src/config/mor-location.resolver.ts
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
import { BadRequestException } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { MOR_LOCATIONS, MorLocationTuple } from "./mor-locations.data";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves an EDR company address to the Ministry of Revenues' own EIMS location codes, using the
|
||||||
|
* MoR location master (`EIMS_COUNTRY_REGION_VW`) shipped in `mor-locations.data.ts`.
|
||||||
|
*
|
||||||
|
* MoR's field names do not line up with either EDR's or generic Ethiopian administrative datasets,
|
||||||
|
* so the mapping is fixed by the Ministry sheet, not by interpretation:
|
||||||
|
*
|
||||||
|
* Company.country -> COUNTRY_NAME -> COUNTRY_NO -> BuyerDetails.Country
|
||||||
|
* Company.region -> PARISH_NAME -> PARISH_NO -> BuyerDetails.Region
|
||||||
|
* Company.zone -> CITY_NAME -> CITY_NO -> BuyerDetails.City
|
||||||
|
* Company.woreda -> LOCALITY_DESC -> LOCALITY_NO -> BuyerDetails.Wereda
|
||||||
|
*
|
||||||
|
* This replaces the previous `EIMS_BUYER_*_CODES` environment maps and the `ethiopia-geo-codes.ts`
|
||||||
|
* table they layered over. Both invented their codes (sequential "01".."15" per region, from a
|
||||||
|
* generic administrative CSV) and both looked names up **globally**, which cannot be correct:
|
||||||
|
* KERSA, GORO, BABILE and BURE each occur in several different zones with different LOCALITY_NOs.
|
||||||
|
* A global name lookup silently picked the first, i.e. filed a real invoice against whichever tax
|
||||||
|
* jurisdiction happened to sort first. Resolution here is strictly hierarchical — each level is
|
||||||
|
* searched only within the rows its parent already selected.
|
||||||
|
*
|
||||||
|
* Open Admin Data identifiers (`ET14`, `ET0407`, …) are unrelated to this code system and must
|
||||||
|
* never appear in an EIMS payload; nothing in this module can emit one, since every returned value
|
||||||
|
* comes from a numeric column of the Ministry sheet.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface MorGeoCodes {
|
||||||
|
/** COUNTRY_NO as a string — `BuyerDetails.Country`. */
|
||||||
|
Country: string;
|
||||||
|
/** PARISH_NO as a string — `BuyerDetails.Region`. */
|
||||||
|
Region: string;
|
||||||
|
/** CITY_NO as a string — `BuyerDetails.City`. MoR calls the zone level "City". */
|
||||||
|
City: string;
|
||||||
|
/** LOCALITY_NO as a string — `BuyerDetails.Wereda`. */
|
||||||
|
Wereda: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MorAddressInput {
|
||||||
|
country?: string | null;
|
||||||
|
region?: string | null;
|
||||||
|
zone?: string | null;
|
||||||
|
woreda?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Level = "country" | "region" | "zone" | "woreda";
|
||||||
|
|
||||||
|
/** Which tuple slots hold the name and the code at each level. */
|
||||||
|
const SLOTS: Record<Level, { name: 1 | 3 | 5 | 7; no: 0 | 2 | 4 | 6; column: string }> = {
|
||||||
|
country: { name: 1, no: 0, column: "COUNTRY_NAME" },
|
||||||
|
region: { name: 3, no: 2, column: "PARISH_NAME" },
|
||||||
|
zone: { name: 5, no: 4, column: "CITY_NAME" },
|
||||||
|
woreda: { name: 7, no: 6, column: "LOCALITY_DESC" },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One normalized form for both sides of every comparison. Deliberately conservative: it removes
|
||||||
|
* differences that cannot change which jurisdiction is meant (case, stray and repeated whitespace,
|
||||||
|
* hyphen/slash/parenthesis/apostrophe punctuation, combining accents) and nothing else. There is
|
||||||
|
* no fuzzy or edit-distance matching anywhere in this module — a near-miss must fail loudly rather
|
||||||
|
* than file an invoice against a neighbouring woreda.
|
||||||
|
*
|
||||||
|
* " FAAFAN ZONE " -> "FAAFAN ZONE"
|
||||||
|
* "Wal-Mera" -> "WAL MERA"
|
||||||
|
* "Ber'ano" -> "BERANO"
|
||||||
|
* "ZONE 1 (AYSSAITA)"-> "ZONE 1 AYSSAITA"
|
||||||
|
*/
|
||||||
|
const normalizeCache = new Map<string, string>();
|
||||||
|
export function normalizeName(value: string | null | undefined): string {
|
||||||
|
const raw = value ?? "";
|
||||||
|
const hit = normalizeCache.get(raw);
|
||||||
|
if (hit !== undefined) return hit;
|
||||||
|
const normalized = raw
|
||||||
|
.normalize("NFKD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.toUpperCase()
|
||||||
|
.replace(/['\u2018\u2019`]/g, "")
|
||||||
|
.replace(/[^A-Z0-9]+/g, " ")
|
||||||
|
.trim();
|
||||||
|
normalizeCache.set(raw, normalized);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reviewed spelling differences between what EDR/e-Trade store and what the Ministry sheet calls
|
||||||
|
* the same place. Every entry is scoped to the administrative level it applies to, and to its
|
||||||
|
* parent where the name is not unique nationwide — so an alias can never reach across into another
|
||||||
|
* region's jurisdiction. `from`/`to` are compared normalized, so casing and spacing here are
|
||||||
|
* cosmetic.
|
||||||
|
*
|
||||||
|
* Add an entry only after confirming the two names are the same place in the Ministry sheet. This
|
||||||
|
* is the only sanctioned place for spelling compatibility; `mor-locations.data.ts` stays verbatim.
|
||||||
|
*/
|
||||||
|
interface MorAlias {
|
||||||
|
level: Exclude<Level, "country">;
|
||||||
|
/** Parent scope, normalized-compared. Omit a level to leave the alias unscoped at that level. */
|
||||||
|
region?: string;
|
||||||
|
zone?: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALIASES: MorAlias[] = [
|
||||||
|
// e-Trade and the customer portal both spell the Somali zone "Fafen"; MoR spells it "FAAFAN
|
||||||
|
// ZONE". Confirmed same zone (CITY_NO 31) — this is the buyer that first exposed the whole
|
||||||
|
// fabricated-code problem.
|
||||||
|
{ level: "zone", region: "SOMALI", from: "Fafen", to: "FAAFAN ZONE" },
|
||||||
|
// MoR's own capital of that zone is "JIJIGA"; every other source spells it "Jigjiga".
|
||||||
|
{
|
||||||
|
level: "woreda",
|
||||||
|
region: "SOMALI",
|
||||||
|
zone: "FAAFAN ZONE",
|
||||||
|
from: "Jigjiga",
|
||||||
|
to: "JIJIGA",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MoR suffixes many zone labels with " ZONE" ("JIMMA ZONE", "FAAFAN ZONE", "SITI ZONE") while EDR
|
||||||
|
* stores the bare name. Retrying the suffixed spelling is an exact match against a second candidate
|
||||||
|
* string, scoped to the already-resolved region — not fuzzy matching — and it removes a long tail
|
||||||
|
* of otherwise hand-maintained aliases. Applied to the zone level only: locality suffixes
|
||||||
|
* ("WOREDA", "TOWN ADMINISTRATION") are not mechanical and could select a different place.
|
||||||
|
*/
|
||||||
|
const zoneSuffixCandidates = (normalized: string): string[] =>
|
||||||
|
normalized.endsWith(" ZONE") ? [] : [`${normalized} ZONE`];
|
||||||
|
|
||||||
|
export class MorGeoMappingError extends BadRequestException {
|
||||||
|
constructor(code: "EIMS_GEO_MAPPING_FAILED" | "EIMS_GEO_AMBIGUOUS", message: string) {
|
||||||
|
super({ code, message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renders the address being resolved for an error message. No customer-identifying data. */
|
||||||
|
const describe = (input: MorAddressInput): string =>
|
||||||
|
`country="${input.country ?? ""}", region="${input.region ?? ""}", ` +
|
||||||
|
`zone="${input.zone ?? ""}", woreda="${input.woreda ?? ""}"`;
|
||||||
|
|
||||||
|
function matchLevel(
|
||||||
|
rows: MorLocationTuple[],
|
||||||
|
level: Level,
|
||||||
|
raw: string | null | undefined,
|
||||||
|
parents: { region?: string; zone?: string },
|
||||||
|
input: MorAddressInput,
|
||||||
|
): { no: number; rows: MorLocationTuple[] } {
|
||||||
|
const { name: nameSlot, no: noSlot, column } = SLOTS[level];
|
||||||
|
const wanted = normalizeName(raw);
|
||||||
|
|
||||||
|
const candidates: string[] = [];
|
||||||
|
if (wanted) {
|
||||||
|
candidates.push(wanted);
|
||||||
|
for (const alias of ALIASES) {
|
||||||
|
if (alias.level !== level) continue;
|
||||||
|
if (alias.region && normalizeName(alias.region) !== parents.region) continue;
|
||||||
|
if (alias.zone && normalizeName(alias.zone) !== parents.zone) continue;
|
||||||
|
if (normalizeName(alias.from) === wanted) candidates.push(normalizeName(alias.to));
|
||||||
|
}
|
||||||
|
if (level === "zone") candidates.push(...zoneSuffixCandidates(wanted));
|
||||||
|
}
|
||||||
|
|
||||||
|
let matched: MorLocationTuple[] = [];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
matched = rows.filter((row) => normalizeName(row[nameSlot] as string) === candidate);
|
||||||
|
if (matched.length > 0) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A company record that already holds the MoR code itself resolves too — but only when that code
|
||||||
|
// genuinely exists at this level under this parent. An unvalidated numeric pass-through is how a
|
||||||
|
// wrong code reaches MoR without anything noticing.
|
||||||
|
if (matched.length === 0 && /^[0-9]{1,6}$/.test((raw ?? "").trim())) {
|
||||||
|
const asCode = Number((raw ?? "").trim());
|
||||||
|
matched = rows.filter((row) => row[noSlot] === asCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matched.length === 0) {
|
||||||
|
throw new MorGeoMappingError(
|
||||||
|
"EIMS_GEO_MAPPING_FAILED",
|
||||||
|
`EIMS geographic mapping failed: no MoR ${column} match for ${describe(input)}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const distinct = [...new Set(matched.map((row) => row[noSlot] as number))];
|
||||||
|
if (distinct.length > 1) {
|
||||||
|
throw new MorGeoMappingError(
|
||||||
|
"EIMS_GEO_AMBIGUOUS",
|
||||||
|
`EIMS geographic mapping is ambiguous: MoR ${column} "${(raw ?? "").trim()}" matches ` +
|
||||||
|
`${distinct.length} different codes (${distinct.sort((a, b) => a - b).join(", ")}) for ` +
|
||||||
|
`${describe(input)}. Correct the company address or the MoR reference data; an ambiguous ` +
|
||||||
|
"location is never filed.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { no: distinct[0], rows: matched };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the full hierarchy, or throws a `BadRequestException` naming the level that failed.
|
||||||
|
*
|
||||||
|
* Never guesses and never returns a partial result: an unknown or ambiguous location must stop the
|
||||||
|
* filing here, locally, before any MoR request and before an EIMS counter is consumed.
|
||||||
|
*/
|
||||||
|
export function resolveMorGeo(
|
||||||
|
input: MorAddressInput,
|
||||||
|
rows: MorLocationTuple[] = MOR_LOCATIONS,
|
||||||
|
): MorGeoCodes {
|
||||||
|
if (rows.length === 0) {
|
||||||
|
throw new MorGeoMappingError(
|
||||||
|
"EIMS_GEO_MAPPING_FAILED",
|
||||||
|
"EIMS geographic mapping failed: the MoR location master is empty. Generate it with " +
|
||||||
|
"`pnpm --filter @edr/freight-api eims:import-locations <workbook.xlsx>`.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// `companies.country` defaults to 'Ethiopia' and is often left blank on older rows; blank means
|
||||||
|
// domestic here, exactly as the column default says. A *named* foreign country is resolved like
|
||||||
|
// any other and fails if MoR does not list it — it is never quietly filed as Ethiopia.
|
||||||
|
const country = (input.country ?? "").trim() || "Ethiopia";
|
||||||
|
|
||||||
|
const inCountry = matchLevel(rows, "country", country, {}, input);
|
||||||
|
const inRegion = matchLevel(inCountry.rows, "region", input.region, {}, input);
|
||||||
|
const regionScope = normalizeName(inRegion.rows[0][SLOTS.region.name] as string);
|
||||||
|
const inZone = matchLevel(inRegion.rows, "zone", input.zone, { region: regionScope }, input);
|
||||||
|
const zoneScope = normalizeName(inZone.rows[0][SLOTS.zone.name] as string);
|
||||||
|
const inWoreda = matchLevel(
|
||||||
|
inZone.rows,
|
||||||
|
"woreda",
|
||||||
|
input.woreda,
|
||||||
|
{
|
||||||
|
region: regionScope,
|
||||||
|
zone: zoneScope,
|
||||||
|
},
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
Country: String(inCountry.no),
|
||||||
|
Region: String(inRegion.no),
|
||||||
|
City: String(inZone.no),
|
||||||
|
Wereda: String(inWoreda.no),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Non-throwing variant for callers that already have a working fallback (the seller identity). */
|
||||||
|
export function tryResolveMorGeo(
|
||||||
|
input: MorAddressInput,
|
||||||
|
rows: MorLocationTuple[] = MOR_LOCATIONS,
|
||||||
|
): MorGeoCodes | null {
|
||||||
|
try {
|
||||||
|
return resolveMorGeo(input, rows);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
1181
apps/edr-freight-api/src/config/mor-locations.data.ts
Normal file
1181
apps/edr-freight-api/src/config/mor-locations.data.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -122,6 +122,8 @@ export class ContractDocumentViewModelBuilder {
|
|||||||
contract.customsClearingEnabled,
|
contract.customsClearingEnabled,
|
||||||
// Bulk templates are keyed by the contract's cargo type.
|
// Bulk templates are keyed by the contract's cargo type.
|
||||||
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId,
|
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId,
|
||||||
|
// Ethiopian-customs-only service types resolve to the Ethiopian variant.
|
||||||
|
contract.serviceType?.includesEthiopianCustomsOnly,
|
||||||
);
|
);
|
||||||
dynamicTemplate = dynamicSource
|
dynamicTemplate = dynamicSource
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds `reference` to freight.audit_logs — the human identifier of the entity
|
||||||
|
* the action touched (booking reference, schedule number, train number, …),
|
||||||
|
* resolved at write time by the audit interceptor. `resource_id` stays the
|
||||||
|
* machine id; this column is what staff actually type into the search box.
|
||||||
|
*
|
||||||
|
* Production safety:
|
||||||
|
* - `ADD COLUMN ... NOT NULL DEFAULT ''` is metadata-only on Postgres 11+:
|
||||||
|
* no table rewrite, no long lock, existing rows read '' without being
|
||||||
|
* touched. Rows written before this migration keep '' permanently —
|
||||||
|
* capture starts from deploy, by design (no backfill).
|
||||||
|
* - Everything is IF NOT EXISTS so a hand-patched database converges
|
||||||
|
* instead of failing the deploy.
|
||||||
|
* - No existing column is altered and nothing is dropped: zero data-loss
|
||||||
|
* surface.
|
||||||
|
*
|
||||||
|
* The index is an expression index on upper(reference) with
|
||||||
|
* text_pattern_ops so the search endpoint's case-insensitive prefix match
|
||||||
|
* (`upper(reference) LIKE upper($1) || '%'`) is indexed. '' rows are
|
||||||
|
* excluded to keep it small — they are never searched for.
|
||||||
|
*/
|
||||||
|
export class AuditLogReference3690000000000 implements MigrationInterface {
|
||||||
|
name = 'AuditLogReference3690000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.audit_logs
|
||||||
|
ADD COLUMN IF NOT EXISTS reference varchar(64) NOT NULL DEFAULT ''
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_logs_reference_upper
|
||||||
|
ON freight.audit_logs (upper(reference) text_pattern_ops)
|
||||||
|
WHERE reference <> ''
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// Down discards every captured reference — acceptable only because down
|
||||||
|
// migrations are never run against production here.
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_audit_logs_reference_upper`);
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.audit_logs DROP COLUMN IF EXISTS reference`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loading and unloading times, on the stop that already records the train
|
||||||
|
* standing at a station.
|
||||||
|
*
|
||||||
|
* The OCC report publishes, per train, "total loading and unloading time" and
|
||||||
|
* the "other activity" left over from the station stay. Nothing recorded when
|
||||||
|
* handling started or ended — the July 2026 seed had to write the figure into
|
||||||
|
* a checkpoint's note — so the staying-time report could only ever publish the
|
||||||
|
* whole stay.
|
||||||
|
*
|
||||||
|
* These four go on `train_checkpoint_events` rather than a table of their own:
|
||||||
|
* a stop is already one row there, keyed (schedule, sequence_no), and the
|
||||||
|
* arrival row is the one the staying-time report builds a stay from. All four
|
||||||
|
* are nullable — a stop where nobody logged the handling still reports its
|
||||||
|
* staying time, with the handling columns empty rather than zero.
|
||||||
|
*/
|
||||||
|
export class CheckpointHandlingTimes3690000000000 implements MigrationInterface {
|
||||||
|
name = "CheckpointHandlingTimes3690000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.train_checkpoint_events
|
||||||
|
ADD COLUMN IF NOT EXISTS unloading_started_at timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS unloading_completed_at timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS loading_started_at timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS loading_completed_at timestamptz;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.train_checkpoint_events
|
||||||
|
DROP COLUMN IF EXISTS unloading_started_at,
|
||||||
|
DROP COLUMN IF EXISTS unloading_completed_at,
|
||||||
|
DROP COLUMN IF EXISTS loading_started_at,
|
||||||
|
DROP COLUMN IF EXISTS loading_completed_at;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard loading-and-unloading time, so the handling figure can be reported
|
||||||
|
* the way the OCC scorecard reports it — hours against a target, with a rate.
|
||||||
|
*
|
||||||
|
* Nullable with NO default, unlike every other column in this table. The
|
||||||
|
* reporting spec publishes standards for a station stay (10h / 13h) and for a
|
||||||
|
* turn-around cycle (65 / 88 / 96) but none for handling, so there is no
|
||||||
|
* honest figure to seed. Until a planner enters one in Operating standards the
|
||||||
|
* rate reads empty rather than judging trains against an invented number.
|
||||||
|
*/
|
||||||
|
export class HandlingStandards3700000000000 implements MigrationInterface {
|
||||||
|
name = "HandlingStandards3700000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.operations_standards
|
||||||
|
ADD COLUMN IF NOT EXISTS handling_standard_hours_container numeric(6,2),
|
||||||
|
ADD COLUMN IF NOT EXISTS handling_standard_hours_bulk numeric(6,2);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.operations_standards
|
||||||
|
DROP COLUMN IF EXISTS handling_standard_hours_container,
|
||||||
|
DROP COLUMN IF EXISTS handling_standard_hours_bulk;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the customs clearing agent's contact details to freight.bookings.
|
||||||
|
*
|
||||||
|
* The agent moved from the contract to the booking: on a without-customs
|
||||||
|
* service the customer now names their agent (name, email, phone) when
|
||||||
|
* completing each booking, instead of once at contract creation. The existing
|
||||||
|
* `customs_clearing_agent` column keeps the name; these two columns add the
|
||||||
|
* contact info. Nullable — customs-bundled and legacy bookings have none.
|
||||||
|
*/
|
||||||
|
export class BookingClearingAgentContact3710000000000 implements MigrationInterface {
|
||||||
|
name = 'BookingClearingAgentContact3710000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings
|
||||||
|
ADD COLUMN IF NOT EXISTS customs_clearing_agent_email varchar(200),
|
||||||
|
ADD COLUMN IF NOT EXISTS customs_clearing_agent_phone varchar(50)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings
|
||||||
|
DROP COLUMN IF EXISTS customs_clearing_agent_email,
|
||||||
|
DROP COLUMN IF EXISTS customs_clearing_agent_phone
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-station loading/unloading time windows on a schedule, operator-clicked:
|
||||||
|
* { [yardId]: { loading?: { startedAt, endedAt, startedByUserId, endedByUserId },
|
||||||
|
* unloading?: { same } } }
|
||||||
|
* Booking load/unload is gated on the matching window having been started.
|
||||||
|
*/
|
||||||
|
export class ScheduleStationWorkLogs3720000000000 implements MigrationInterface {
|
||||||
|
name = 'ScheduleStationWorkLogs3720000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.train_schedules
|
||||||
|
ADD COLUMN IF NOT EXISTS station_work_logs jsonb
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS station_work_logs
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approval gate for detaching a wagon (or sending it to maintenance) from a
|
||||||
|
* train whose run is already SCHEDULED.
|
||||||
|
*
|
||||||
|
* Before scheduling, the consist is the builder's to edit. After scheduling,
|
||||||
|
* pulling a wagon changes a departure customers booked against, so it becomes
|
||||||
|
* a two-person action: one staffer files a request with a reason, another
|
||||||
|
* staffer (with trains:approve_wagon_detach) approves it — approval executes
|
||||||
|
* the detach on the spot. Rows are never deleted; decided rows are the audit
|
||||||
|
* trail of who asked, who decided, and why.
|
||||||
|
*
|
||||||
|
* One PENDING row per (train, wagon) at a time — a second request while one is
|
||||||
|
* undecided is a coordination failure, not a workflow (partial unique index).
|
||||||
|
*/
|
||||||
|
export class WagonDetachRequests3730000000000 implements MigrationInterface {
|
||||||
|
name = 'WagonDetachRequests3730000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE freight.wagon_detach_requests_status_enum
|
||||||
|
AS ENUM ('PENDING', 'APPROVED', 'REJECTED');
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.wagon_detach_requests (
|
||||||
|
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
train_id uuid NOT NULL REFERENCES freight.trains (id),
|
||||||
|
wagon_id uuid NOT NULL REFERENCES freight.wagons (id),
|
||||||
|
-- Snapshot: the audit trail must still read correctly after the wagon
|
||||||
|
-- is renumbered or deleted.
|
||||||
|
wagon_number varchar(50) NOT NULL,
|
||||||
|
action varchar(20) NOT NULL,
|
||||||
|
reason varchar(500) NOT NULL,
|
||||||
|
status freight.wagon_detach_requests_status_enum NOT NULL DEFAULT 'PENDING',
|
||||||
|
-- Who asked and who decided. Both recorded: the point of the gate is
|
||||||
|
-- that they are different people.
|
||||||
|
requested_by uuid,
|
||||||
|
decided_by uuid,
|
||||||
|
decided_at timestamptz,
|
||||||
|
decision_note varchar(500),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wagon_detach_requests_train
|
||||||
|
ON freight.wagon_detach_requests (train_id)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wagon_detach_requests_train_status
|
||||||
|
ON freight.wagon_detach_requests (train_id, status)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// The workflow invariant, enforced where it cannot race: at most one
|
||||||
|
// undecided request per wagon per train.
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_wagon_detach_requests_one_pending
|
||||||
|
ON freight.wagon_detach_requests (train_id, wagon_id)
|
||||||
|
WHERE status = 'PENDING' AND deleted_at IS NULL
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_detach_requests`);
|
||||||
|
await queryRunner.query(`DROP TYPE IF EXISTS freight.wagon_detach_requests_status_enum`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NUMBER_OF_WAGONS cargo unit: the customer books a wagon COUNT alongside the
|
||||||
|
* bulk weight. `bulk_requested_wagons` drives allocation and PER_WAGON pricing;
|
||||||
|
* `bulk_item_count` is the optional informational item count entered with it.
|
||||||
|
* Nullable — every other cargo unit leaves both empty.
|
||||||
|
*/
|
||||||
|
export class BookingBulkRequestedWagons3740000000000 implements MigrationInterface {
|
||||||
|
name = 'BookingBulkRequestedWagons3740000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings
|
||||||
|
ADD COLUMN IF NOT EXISTS bulk_requested_wagons int,
|
||||||
|
ADD COLUMN IF NOT EXISTS bulk_item_count int
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings
|
||||||
|
DROP COLUMN IF EXISTS bulk_requested_wagons,
|
||||||
|
DROP COLUMN IF EXISTS bulk_item_count
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
import { CONTRACT_TEMPLATE_DEFAULTS } from '../seed/data/contract-template-defaults';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Third customs-clearing option on contract templates: Ethiopian-customs-only
|
||||||
|
* (the Service Provider clears the Ethiopian side only, Djibouti stays with
|
||||||
|
* the Client), matching service types with includes_ethiopian_customs_only.
|
||||||
|
*
|
||||||
|
* - ethiopian_customs_only column on contract_templates (bulk variant flag;
|
||||||
|
* the seeded container variants carry it in the code suffix instead, like
|
||||||
|
* the existing _CUSTOMS/_NO_CUSTOMS pair).
|
||||||
|
* - The bulk unique index and intercity check widen to the new flag.
|
||||||
|
* - Seeds the two new system container templates from the defaults pack.
|
||||||
|
*/
|
||||||
|
const SEEDED_CODES = [
|
||||||
|
'IMPORT_CONTAINER_ETHIOPIAN_CUSTOMS',
|
||||||
|
'EXPORT_CONTAINER_ETHIOPIAN_CUSTOMS',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export class EthiopianCustomsContractTemplates3750000000000 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.contract_templates
|
||||||
|
ADD COLUMN IF NOT EXISTS ethiopian_customs_only boolean
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP INDEX IF EXISTS freight.uq_contract_templates_cargo_dir_customs`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_templates_cargo_dir_customs
|
||||||
|
ON freight.contract_templates
|
||||||
|
(cargo_type_id, trade_direction,
|
||||||
|
COALESCE(with_customs, false), COALESCE(ethiopian_customs_only, false))
|
||||||
|
WHERE deleted_at IS NULL AND cargo_type_id IS NOT NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.contract_templates
|
||||||
|
DROP CONSTRAINT IF EXISTS ck_bulk_intercity_no_customs
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.contract_templates
|
||||||
|
ADD CONSTRAINT ck_bulk_intercity_no_customs CHECK (
|
||||||
|
cargo_type_id IS NULL
|
||||||
|
OR (
|
||||||
|
trade_direction IN ('IMPORT', 'EXPORT', 'INTERCITY')
|
||||||
|
AND (trade_direction = 'INTERCITY') = (with_customs IS NULL)
|
||||||
|
AND (ethiopian_customs_only IS NOT TRUE OR with_customs IS TRUE)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
for (const code of SEEDED_CODES) {
|
||||||
|
const seed = CONTRACT_TEMPLATE_DEFAULTS.find((t) => t.code === code);
|
||||||
|
if (!seed) throw new Error(`Missing contract template default for ${code}`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO freight.contract_templates
|
||||||
|
(id, code, name, description, document_title, whereas_clauses, articles,
|
||||||
|
is_active, is_system, created_at, updated_at)
|
||||||
|
SELECT gen_random_uuid(), $1::varchar, $2, $3, $4, $5::jsonb, $6::jsonb,
|
||||||
|
true, true, now(), now()
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM freight.contract_templates
|
||||||
|
WHERE code = $1::varchar AND deleted_at IS NULL
|
||||||
|
)`,
|
||||||
|
[
|
||||||
|
seed.code,
|
||||||
|
seed.name,
|
||||||
|
seed.description,
|
||||||
|
seed.documentTitle,
|
||||||
|
JSON.stringify(seed.whereasClauses),
|
||||||
|
JSON.stringify(
|
||||||
|
seed.articles.map((article, index) => ({ ...article, order: index + 1 })),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`DELETE FROM freight.contract_templates WHERE code = ANY($1) AND is_system = true`,
|
||||||
|
[[...SEEDED_CODES]],
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.contract_templates
|
||||||
|
DROP CONSTRAINT IF EXISTS ck_bulk_intercity_no_customs
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.contract_templates
|
||||||
|
ADD CONSTRAINT ck_bulk_intercity_no_customs CHECK (
|
||||||
|
cargo_type_id IS NULL
|
||||||
|
OR (
|
||||||
|
trade_direction IN ('IMPORT', 'EXPORT', 'INTERCITY')
|
||||||
|
AND (trade_direction = 'INTERCITY') = (with_customs IS NULL)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP INDEX IF EXISTS freight.uq_contract_templates_cargo_dir_customs`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_templates_cargo_dir_customs
|
||||||
|
ON freight.contract_templates
|
||||||
|
(cargo_type_id, trade_direction, COALESCE(with_customs, false))
|
||||||
|
WHERE deleted_at IS NULL AND cargo_type_id IS NOT NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.contract_templates
|
||||||
|
DROP COLUMN IF EXISTS ethiopian_customs_only
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
* humanized handler name where a route has none.
|
* humanized handler name where a route has none.
|
||||||
*
|
*
|
||||||
* Excludes the AI Assist and Account entities.
|
* Excludes the AI Assist and Account entities.
|
||||||
* Generated from the controllers under src/ — 517 endpoints.
|
* Generated from the controllers under src/ — 528 endpoints.
|
||||||
*/
|
*/
|
||||||
/** [title, method, entity] for one auditable route. */
|
/** [title, method, entity] for one auditable route. */
|
||||||
export type AuditEndpointMeta = readonly [title: string, method: string, entity: string];
|
export type AuditEndpointMeta = readonly [title: string, method: string, entity: string];
|
||||||
@@ -38,6 +38,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
|||||||
"POST /api/bookings/:id/clearance/draft-declaration": ["GL ET sends a draft customs declaration (multi-file) with an estimated price for the customer to review", "POST", "Booking"],
|
"POST /api/bookings/:id/clearance/draft-declaration": ["GL ET sends a draft customs declaration (multi-file) with an estimated price for the customer to review", "POST", "Booking"],
|
||||||
"POST /api/bookings/:id/clearance/draft-declaration/accept": ["Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia", "POST", "Booking"],
|
"POST /api/bookings/:id/clearance/draft-declaration/accept": ["Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia", "POST", "Booking"],
|
||||||
"POST /api/bookings/:id/clearance/draft-declaration/change": ["Customer requests a change to the draft customs declaration with a reason — GL Ethiopia sends a corrected draft (repeatable)", "POST", "Booking"],
|
"POST /api/bookings/:id/clearance/draft-declaration/change": ["Customer requests a change to the draft customs declaration with a reason — GL Ethiopia sends a corrected draft (repeatable)", "POST", "Booking"],
|
||||||
|
"POST /api/bookings/:id/clearance/draft-declaration/skip": ["GL ET skips the draft-declaration round: no estimate is sent to the customer, the real declaration is filed directly and duty & tax passes by default", "POST", "Booking"],
|
||||||
"POST /api/bookings/:id/clearance/duty": ["GL ET sets duty/tax on booking with notice attachment", "POST", "Booking"],
|
"POST /api/bookings/:id/clearance/duty": ["GL ET sets duty/tax on booking with notice attachment", "POST", "Booking"],
|
||||||
"POST /api/bookings/:id/clearance/duty-slip": ["Customer uploads duty/tax payment slip on booking", "POST", "Booking"],
|
"POST /api/bookings/:id/clearance/duty-slip": ["Customer uploads duty/tax payment slip on booking", "POST", "Booking"],
|
||||||
"POST /api/bookings/:id/clearance/export-release": ["Confirm Booking Export Release", "POST", "Booking"],
|
"POST /api/bookings/:id/clearance/export-release": ["Confirm Booking Export Release", "POST", "Booking"],
|
||||||
@@ -51,7 +52,12 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
|||||||
"POST /api/bookings/:id/clearance/charges/port-document": ["GL Djibouti uploads the port-charges document", "POST", "Booking"],
|
"POST /api/bookings/:id/clearance/charges/port-document": ["GL Djibouti uploads the port-charges document", "POST", "Booking"],
|
||||||
"PATCH /api/bookings/:id/clearance/charges/:chargeId/bill": ["GL Ethiopia sets or revises a clearance charge's amount + currency", "PATCH", "Booking"],
|
"PATCH /api/bookings/:id/clearance/charges/:chargeId/bill": ["GL Ethiopia sets or revises a clearance charge's amount + currency", "PATCH", "Booking"],
|
||||||
"POST /api/bookings/:id/clearance/charges/:chargeId/send": ["GL Ethiopia issues the clearance charge invoice to the customer", "POST", "Booking"],
|
"POST /api/bookings/:id/clearance/charges/:chargeId/send": ["GL Ethiopia issues the clearance charge invoice to the customer", "POST", "Booking"],
|
||||||
|
"POST /api/bookings/:id/clearance/charges/:chargeId/accept": ["Customer accepts a proposed clearance charge — issues the payable invoice and locks the charge", "POST", "Booking"],
|
||||||
|
"POST /api/bookings/:id/clearance/charges/:chargeId/reject": ["Customer rejects a proposed clearance charge with a reason — GL Ethiopia revises and re-sends", "POST", "Booking"],
|
||||||
"POST /api/bookings/:id/clearance/charges/miscellaneous": ["GL Ethiopia creates the miscellaneous clearance charge", "POST", "Booking"],
|
"POST /api/bookings/:id/clearance/charges/miscellaneous": ["GL Ethiopia creates the miscellaneous clearance charge", "POST", "Booking"],
|
||||||
|
"POST /api/bookings/:id/additional-charges": ["Finance raises a new additional charge — draft, or send to the customer immediately", "POST", "Booking"],
|
||||||
|
"POST /api/bookings/:id/additional-charges/:chargeId/send": ["Issue the draft charge's payable invoice and notify the customer", "POST", "Booking"],
|
||||||
|
"POST /api/bookings/:id/additional-charges/:chargeId/cancel": ["Withdraw a draft or unpaid additional charge", "POST", "Booking"],
|
||||||
"POST /api/bookings/:id/clearance/ro-amendment": ["Request Booking RO Amendment", "POST", "Booking"],
|
"POST /api/bookings/:id/clearance/ro-amendment": ["Request Booking RO Amendment", "POST", "Booking"],
|
||||||
"POST /api/bookings/:id/clearance/transit-assignee/assign": ["GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns", "POST", "Booking"],
|
"POST /api/bookings/:id/clearance/transit-assignee/assign": ["GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns", "POST", "Booking"],
|
||||||
"POST /api/bookings/:id/clearance/transit-assignee/request": ["GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration", "POST", "Booking"],
|
"POST /api/bookings/:id/clearance/transit-assignee/request": ["GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration", "POST", "Booking"],
|
||||||
@@ -73,7 +79,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
|||||||
"POST /api/bookings/:id/documents": ["Upload documents for a booking (DRAFT only)", "POST", "Booking"],
|
"POST /api/bookings/:id/documents": ["Upload documents for a booking (DRAFT only)", "POST", "Booking"],
|
||||||
"PATCH /api/bookings/:id/export-handover-mode": ["Export only: choose direct truck-to-train (no warehouse, no GRN) or warehouse first", "PATCH", "Booking"],
|
"PATCH /api/bookings/:id/export-handover-mode": ["Export only: choose direct truck-to-train (no warehouse, no GRN) or warehouse first", "PATCH", "Booking"],
|
||||||
"POST /api/bookings/:id/generate-grn": ["Generate a GRN over the received containers (all received, or a subset) — one GRN per batch", "POST", "Booking"],
|
"POST /api/bookings/:id/generate-grn": ["Generate a GRN over the received containers (all received, or a subset) — one GRN per batch", "POST", "Booking"],
|
||||||
"POST /api/bookings/:id/generate-price": ["Generate price preview (DRAFT or CHANGES_REQUESTED)", "POST", "Booking"],
|
// "POST /api/bookings/:id/generate-price": ["Generate price preview (DRAFT or CHANGES_REQUESTED)", "POST", "Booking"],
|
||||||
"POST /api/bookings/:id/government-expedite": ["Expedite government booking to PAID / ELIGIBLE for scheduling", "POST", "Booking"],
|
"POST /api/bookings/:id/government-expedite": ["Expedite government booking to PAID / ELIGIBLE for scheduling", "POST", "Booking"],
|
||||||
"POST /api/bookings/:id/marketing/approve": ["Staff contract signature and fully execute (use contract/sign STAFF preferred)", "POST", "Booking"],
|
"POST /api/bookings/:id/marketing/approve": ["Staff contract signature and fully execute (use contract/sign STAFF preferred)", "POST", "Booking"],
|
||||||
"POST /api/bookings/:id/operation/review": ["Operations reviews an operation request: ACCEPT (→ batch pool),", "POST", "Booking"],
|
"POST /api/bookings/:id/operation/review": ["Operations reviews an operation request: ACCEPT (→ batch pool),", "POST", "Booking"],
|
||||||
@@ -85,7 +91,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
|||||||
"POST /api/bookings/:id/staff/request-changes": ["Staff return booking for customer updates", "POST", "Booking"],
|
"POST /api/bookings/:id/staff/request-changes": ["Staff return booking for customer updates", "POST", "Booking"],
|
||||||
"POST /api/bookings/:id/submit": ["Customer submit booking", "POST", "Booking"],
|
"POST /api/bookings/:id/submit": ["Customer submit booking", "POST", "Booking"],
|
||||||
"POST /api/bookings/:id/wagon-cancellations": ["Request a partial wagon cancellation on a PAID booking — opens the cancellation-fee invoice; wagons are released only once the fee settles", "POST", "Booking"],
|
"POST /api/bookings/:id/wagon-cancellations": ["Request a partial wagon cancellation on a PAID booking — opens the cancellation-fee invoice; wagons are released only once the fee settles", "POST", "Booking"],
|
||||||
"POST /api/bookings/:id/wagon-cancellations/preview": ["Preview the fee/credit of a partial wagon cancellation (no writes)", "POST", "Booking"],
|
// "POST /api/bookings/:id/wagon-cancellations/preview": ["Preview the fee/credit of a partial wagon cancellation (no writes)", "POST", "Booking"],
|
||||||
"POST /api/bookings/wagon-cancellations/:cancellationId/rebook": ["Rebook a wagon-cancellation credit: pick a shipment day only — the new booking is created under the contract and marked PAID (freight already paid; contract must still be valid)", "POST", "Booking"],
|
"POST /api/bookings/wagon-cancellations/:cancellationId/rebook": ["Rebook a wagon-cancellation credit: pick a shipment day only — the new booking is created under the contract and marked PAID (freight already paid; contract must still be valid)", "POST", "Booking"],
|
||||||
"POST /api/bookings/wagon-cancellations/:cancellationId/withdraw": ["Withdraw a fee-pending wagon cancellation (owner, or staff with the void permission)", "POST", "Booking"],
|
"POST /api/bookings/wagon-cancellations/:cancellationId/withdraw": ["Withdraw a fee-pending wagon cancellation (owner, or staff with the void permission)", "POST", "Booking"],
|
||||||
"POST /api/bookings/consolidation-approvals/:approvalId/approve": ["Approve a shared wagon: both bookings leave the gate and continue to Operations together.", "POST", "Booking"],
|
"POST /api/bookings/consolidation-approvals/:approvalId/approve": ["Approve a shared wagon: both bookings leave the gate and continue to Operations together.", "POST", "Booking"],
|
||||||
@@ -207,7 +213,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
|||||||
"POST /api/contracts/:id/staff/request-changes": ["Staff return contract for customer updates", "POST", "Contract"],
|
"POST /api/contracts/:id/staff/request-changes": ["Staff return contract for customer updates", "POST", "Contract"],
|
||||||
"POST /api/contracts/:id/submit": ["Customer submit contract (freezes contract_rate_snapshots)", "POST", "Contract"],
|
"POST /api/contracts/:id/submit": ["Customer submit contract (freezes contract_rate_snapshots)", "POST", "Contract"],
|
||||||
"POST /api/contracts/:id/suspend": ["Staff freeze a signed contract (reversible, any post-signature step)", "POST", "Contract"],
|
"POST /api/contracts/:id/suspend": ["Staff freeze a signed contract (reversible, any post-signature step)", "POST", "Contract"],
|
||||||
"POST /api/contracts/:id/validate-shipment": ["Pre-create validation + authoritative price preview: full booking price breakdown (rail, first/last mile, surcharges), overweight lines and 20ft weight-pairing errors for a shipment payload (no booking created)", "POST", "Contract"],
|
// "POST /api/contracts/:id/validate-shipment": ["Pre-create validation + authoritative price preview: full booking price breakdown (rail, first/last mile, surcharges), overweight lines and 20ft weight-pairing errors for a shipment payload (no booking created)", "POST", "Contract"],
|
||||||
"POST /api/contracts/booking-requests/:reqId/accept": ["GL marks a shipment request accepted + links the created booking", "POST", "Contract"],
|
"POST /api/contracts/booking-requests/:reqId/accept": ["GL marks a shipment request accepted + links the created booking", "POST", "Contract"],
|
||||||
"POST /api/contracts/booking-requests/:reqId/cancel": ["Customer cancels their own pending shipment request", "POST", "Contract"],
|
"POST /api/contracts/booking-requests/:reqId/cancel": ["Customer cancels their own pending shipment request", "POST", "Contract"],
|
||||||
"POST /api/contracts/booking-requests/:reqId/reject": ["GL rejects a shipment request", "POST", "Contract"],
|
"POST /api/contracts/booking-requests/:reqId/reject": ["GL rejects a shipment request", "POST", "Contract"],
|
||||||
@@ -240,7 +246,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
|||||||
"PUT /api/contract-templates/:code/articles": ["Replace the full ordered article list (used for reorder)", "PUT", "Contract Template"],
|
"PUT /api/contract-templates/:code/articles": ["Replace the full ordered article list (used for reorder)", "PUT", "Contract Template"],
|
||||||
"PATCH /api/contract-templates/:code/articles/:articleId": ["Update an article's title or body", "PATCH", "Contract Template"],
|
"PATCH /api/contract-templates/:code/articles/:articleId": ["Update an article's title or body", "PATCH", "Contract Template"],
|
||||||
"DELETE /api/contract-templates/:code/articles/:articleId": ["Remove an article from the template", "DELETE", "Contract Template"],
|
"DELETE /api/contract-templates/:code/articles/:articleId": ["Remove an article from the template", "DELETE", "Contract Template"],
|
||||||
"POST /api/contract-templates/:code/preview": ["Render an HTML preview of the template against mock contract data", "POST", "Contract Template"],
|
// "POST /api/contract-templates/:code/preview": ["Render an HTML preview of the template against mock contract data", "POST", "Contract Template"],
|
||||||
|
|
||||||
// Driver
|
// Driver
|
||||||
"POST /api/drivers": ["Create a new driver", "POST", "Driver"],
|
"POST /api/drivers": ["Create a new driver", "POST", "Driver"],
|
||||||
@@ -266,6 +272,8 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
|||||||
"POST /api/invoices/:id/eims/receipt/sales": ["Register a sales receipt with MoR EIMS against a registered invoice", "POST", "EIMS Invoice"],
|
"POST /api/invoices/:id/eims/receipt/sales": ["Register a sales receipt with MoR EIMS against a registered invoice", "POST", "EIMS Invoice"],
|
||||||
"POST /api/invoices/:id/eims/receipt/withholding": ["Register a withholding receipt with MoR EIMS against a registered invoice", "POST", "EIMS Invoice"],
|
"POST /api/invoices/:id/eims/receipt/withholding": ["Register a withholding receipt with MoR EIMS against a registered invoice", "POST", "EIMS Invoice"],
|
||||||
"POST /api/invoices/eims/bulk-cancel": ["Cancel multiple invoices", "POST", "EIMS Invoice"],
|
"POST /api/invoices/eims/bulk-cancel": ["Cancel multiple invoices", "POST", "EIMS Invoice"],
|
||||||
|
"POST /api/invoices/eims/bulk-register": ["Submit multiple invoices to MoR EIMS in one call. Asynchronous — this only confirms MoR", "POST", "EIMS Invoice"],
|
||||||
|
"POST /api/eims/webhook/bulk-register": ["EIMS bulk-register webhook callback (MoR reports per-invoice results)", "POST", "EIMS Invoice"],
|
||||||
|
|
||||||
// Exchange Setting
|
// Exchange Setting
|
||||||
"PATCH /api/exchange-settings": ["Set the USD→ETB fallback by hand (used only while CBE is unreachable)", "PATCH", "Exchange Setting"],
|
"PATCH /api/exchange-settings": ["Set the USD→ETB fallback by hand (used only while CBE is unreachable)", "PATCH", "Exchange Setting"],
|
||||||
@@ -373,6 +381,14 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
|||||||
"PATCH /api/notifications/:id/read": ["Mark one of my notifications as read", "PATCH", "Notification Inbox"],
|
"PATCH /api/notifications/:id/read": ["Mark one of my notifications as read", "PATCH", "Notification Inbox"],
|
||||||
"POST /api/notifications/read-all": ["Mark all my notifications as read", "POST", "Notification Inbox"],
|
"POST /api/notifications/read-all": ["Mark all my notifications as read", "POST", "Notification Inbox"],
|
||||||
|
|
||||||
|
// Operations Standard
|
||||||
|
"PATCH /api/operations-standards": ["Change one or more operating standards", "PATCH", "Operations Standard"],
|
||||||
|
|
||||||
|
// Operations Target
|
||||||
|
"POST /api/operations-targets": ["Create a planned target", "POST", "Operations Target"],
|
||||||
|
"PATCH /api/operations-targets/:id": ["Update a planned target", "PATCH", "Operations Target"],
|
||||||
|
"DELETE /api/operations-targets/:id": ["Soft-delete a planned target", "DELETE", "Operations Target"],
|
||||||
|
|
||||||
// Organization User
|
// Organization User
|
||||||
"PUT /api/backoffice/organizations/:orgId/employee-users/:userId/roles": ["Replace org-scoped roles assigned to an employee user", "PUT", "Organization User"],
|
"PUT /api/backoffice/organizations/:orgId/employee-users/:userId/roles": ["Replace org-scoped roles assigned to an employee user", "PUT", "Organization User"],
|
||||||
"POST /api/backoffice/organizations/:orgId/users": ["Create an organization user without assigning positions", "POST", "Organization User"],
|
"POST /api/backoffice/organizations/:orgId/users": ["Create an organization user without assigning positions", "POST", "Organization User"],
|
||||||
@@ -445,7 +461,8 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
|||||||
// Two controllers register this same path; Nest serves whichever module loads first.
|
// Two controllers register this same path; Nest serves whichever module loads first.
|
||||||
"POST /api/train-scheduling/schedules/:id/maintenance": ["Reschedule train for maintenance (new departure + rebalance)", "POST", "Schedule"],
|
"POST /api/train-scheduling/schedules/:id/maintenance": ["Reschedule train for maintenance (new departure + rebalance)", "POST", "Schedule"],
|
||||||
"POST /api/train-scheduling/schedules/:id/reschedule/execute": ["Execute a confirmed reschedule plan", "POST", "Schedule"],
|
"POST /api/train-scheduling/schedules/:id/reschedule/execute": ["Execute a confirmed reschedule plan", "POST", "Schedule"],
|
||||||
"POST /api/train-scheduling/schedules/:id/reschedule/preview": ["Preview reschedule / government preempt plan", "POST", "Schedule"],
|
"POST /api/train-scheduling/schedules/:id/reschedule/maintenance": ["Reschedule train for maintenance (new departure + rebalance)", "POST", "Schedule"],
|
||||||
|
// "POST /api/train-scheduling/schedules/:id/reschedule/preview": ["Preview reschedule / government preempt plan", "POST", "Schedule"],
|
||||||
|
|
||||||
// Service Type
|
// Service Type
|
||||||
"POST /api/service-types": ["Create a service type", "POST", "Service Type"],
|
"POST /api/service-types": ["Create a service type", "POST", "Service Type"],
|
||||||
@@ -463,7 +480,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
|||||||
// Shipping Line Booking
|
// Shipping Line Booking
|
||||||
"POST /api/shipping-line-bookings/initiate": ["Initiate a bare booking (no contract). Starts at AWAITING_DOCUMENTS so the shipping line can upload its documents for Operations to approve.", "POST", "Shipping Line Booking"],
|
"POST /api/shipping-line-bookings/initiate": ["Initiate a bare booking (no contract). Starts at AWAITING_DOCUMENTS so the shipping line can upload its documents for Operations to approve.", "POST", "Shipping Line Booking"],
|
||||||
"POST /api/shipping-line-bookings/:id/cancel": ["Cancel one of the signed-in shipping line's own bookings. Allowed only before the booking is priced.", "POST", "Shipping Line Booking"],
|
"POST /api/shipping-line-bookings/:id/cancel": ["Cancel one of the signed-in shipping line's own bookings. Allowed only before the booking is priced.", "POST", "Shipping Line Booking"],
|
||||||
"POST /api/shipping-line-bookings/:id/price-preview": ["Authoritative price quote for the completion payload — same compute as /complete, saved as the booking's breakdown + rate snapshots (refreshed on every re-preview). Persists nothing else.", "POST", "Shipping Line Booking"],
|
// "POST /api/shipping-line-bookings/:id/price-preview": ["Authoritative price quote for the completion payload — same compute as /complete, saved as the booking's breakdown + rate snapshots (refreshed on every re-preview). Persists nothing else.", "POST", "Shipping Line Booking"],
|
||||||
"POST /api/shipping-line-bookings/:id/complete": ["Complete an approved (CLEARANCE_READY) booking: cargo + binding shipment day.", "POST", "Shipping Line Booking"],
|
"POST /api/shipping-line-bookings/:id/complete": ["Complete an approved (CLEARANCE_READY) booking: cargo + binding shipment day.", "POST", "Shipping Line Booking"],
|
||||||
|
|
||||||
// Shipping Line Credit
|
// Shipping Line Credit
|
||||||
@@ -510,6 +527,8 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
|||||||
"PATCH /api/train-builder/:id/details": ["Edit the train's name and fixed import/export run numbers", "PATCH", "Train Build"],
|
"PATCH /api/train-builder/:id/details": ["Edit the train's name and fixed import/export run numbers", "PATCH", "Train Build"],
|
||||||
"PUT /api/train-builder/:id/locomotives": ["Replace the locomotive set (minimum 1, same yard)", "PUT", "Train Build"],
|
"PUT /api/train-builder/:id/locomotives": ["Replace the locomotive set (minimum 1, same yard)", "PUT", "Train Build"],
|
||||||
"POST /api/train-builder/:id/reorder-wagons": ["Persist a drag-reorder of the full consist", "POST", "Train Build"],
|
"POST /api/train-builder/:id/reorder-wagons": ["Persist a drag-reorder of the full consist", "POST", "Train Build"],
|
||||||
|
"PATCH /api/train-builder/:id/wagons/:wagonId/yard": ["Move one coupled wagon to another yard — refused while any live schedule has the wagon allocated", "PATCH", "Train Build"],
|
||||||
|
"PATCH /api/train-builder/:id/wagons/yard": ["Move several coupled wagons to another yard in one transaction — refused outright if any is allocated to a live schedule", "PATCH", "Train Build"],
|
||||||
"POST /api/train-builder/:id/wagons": ["Append AVAILABLE wagons from the train's yard to the consist", "POST", "Train Build"],
|
"POST /api/train-builder/:id/wagons": ["Append AVAILABLE wagons from the train's yard to the consist", "POST", "Train Build"],
|
||||||
"DELETE /api/train-builder/:id/wagons/:wagonId": ["Detach one wagon from the consist", "DELETE", "Train Build"],
|
"DELETE /api/train-builder/:id/wagons/:wagonId": ["Detach one wagon from the consist", "DELETE", "Train Build"],
|
||||||
"POST /api/train-builder/:id/wagons/:wagonId/maintenance": ["Detach one wagon and move it to MAINTENANCE status", "POST", "Train Build"],
|
"POST /api/train-builder/:id/wagons/:wagonId/maintenance": ["Detach one wagon and move it to MAINTENANCE status", "POST", "Train Build"],
|
||||||
@@ -520,16 +539,16 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
|||||||
"POST /api/train-scheduling/bookings/:bookingId/expire": ["Staff: expire a reservation and free its capacity", "POST", "Train Schedule"],
|
"POST /api/train-scheduling/bookings/:bookingId/expire": ["Staff: expire a reservation and free its capacity", "POST", "Train Schedule"],
|
||||||
"POST /api/train-scheduling/bookings/:bookingId/mark-paid": ["Staff: mark a reserved booking paid and allocate it now", "POST", "Train Schedule"],
|
"POST /api/train-scheduling/bookings/:bookingId/mark-paid": ["Staff: mark a reserved booking paid and allocate it now", "POST", "Train Schedule"],
|
||||||
"POST /api/train-scheduling/bookings/:bookingId/move-schedule": ["Re-point a booking to another OPEN same-route schedule", "POST", "Train Schedule"],
|
"POST /api/train-scheduling/bookings/:bookingId/move-schedule": ["Re-point a booking to another OPEN same-route schedule", "POST", "Train Schedule"],
|
||||||
"POST /api/train-scheduling/bulk/preview": ["Preview a bulk train schedule", "POST", "Train Schedule"],
|
// "POST /api/train-scheduling/bulk/preview": ["Preview a bulk train schedule", "POST", "Train Schedule"],
|
||||||
"POST /api/train-scheduling/bulk/schedules": ["Create a bulk train schedule", "POST", "Train Schedule"],
|
"POST /api/train-scheduling/bulk/schedules": ["Create a bulk train schedule", "POST", "Train Schedule"],
|
||||||
"POST /api/train-scheduling/bulk/schedules/:id/assign-bookings": ["Assign bulk bookings to a train schedule", "POST", "Train Schedule"],
|
"POST /api/train-scheduling/bulk/schedules/:id/assign-bookings": ["Assign bulk bookings to a train schedule", "POST", "Train Schedule"],
|
||||||
"POST /api/train-scheduling/bulk/schedules/:id/cancel": ["Cancel bulk train schedule", "POST", "Train Schedule"],
|
"POST /api/train-scheduling/bulk/schedules/:id/cancel": ["Cancel bulk train schedule", "POST", "Train Schedule"],
|
||||||
"POST /api/train-scheduling/container/preview": ["Preview a container train schedule", "POST", "Train Schedule"],
|
// "POST /api/train-scheduling/container/preview": ["Preview a container train schedule", "POST", "Train Schedule"],
|
||||||
"POST /api/train-scheduling/container/schedules": ["Create a container train schedule", "POST", "Train Schedule"],
|
"POST /api/train-scheduling/container/schedules": ["Create a container train schedule", "POST", "Train Schedule"],
|
||||||
"POST /api/train-scheduling/container/schedules/:id/assign-bookings": ["Assign container bookings to a train schedule", "POST", "Train Schedule"],
|
"POST /api/train-scheduling/container/schedules/:id/assign-bookings": ["Assign container bookings to a train schedule", "POST", "Train Schedule"],
|
||||||
"POST /api/train-scheduling/container/schedules/:id/cancel": ["Cancel container train schedule", "POST", "Train Schedule"],
|
"POST /api/train-scheduling/container/schedules/:id/cancel": ["Cancel container train schedule", "POST", "Train Schedule"],
|
||||||
"PATCH /api/train-scheduling/global-rules": ["Update global train scheduling rules (singleton)", "PATCH", "Train Schedule"],
|
"PATCH /api/train-scheduling/global-rules": ["Update global train scheduling rules (singleton)", "PATCH", "Train Schedule"],
|
||||||
"POST /api/train-scheduling/preview": ["Preview a mixed-capable train schedule", "POST", "Train Schedule"],
|
// "POST /api/train-scheduling/preview": ["Preview a mixed-capable train schedule", "POST", "Train Schedule"],
|
||||||
"POST /api/train-scheduling/schedules/:id/adjust-consist": ["Permanently trim free wagons off / couple yard wagons onto the schedule's built train (weight & length limits incl. tolerance enforced, every change logged)", "POST", "Train Schedule"],
|
"POST /api/train-scheduling/schedules/:id/adjust-consist": ["Permanently trim free wagons off / couple yard wagons onto the schedule's built train (weight & length limits incl. tolerance enforced, every change logged)", "POST", "Train Schedule"],
|
||||||
"POST /api/train-scheduling/schedules/:id/arrive": ["Mark a dispatched train arrived (move assets to destination yard, free assets)", "POST", "Train Schedule"],
|
"POST /api/train-scheduling/schedules/:id/arrive": ["Mark a dispatched train arrived (move assets to destination yard, free assets)", "POST", "Train Schedule"],
|
||||||
"POST /api/train-scheduling/schedules/:id/assign-bookings": ["Assign bookings to a train schedule (mixed-capable)", "POST", "Train Schedule"],
|
"POST /api/train-scheduling/schedules/:id/assign-bookings": ["Assign bookings to a train schedule (mixed-capable)", "POST", "Train Schedule"],
|
||||||
@@ -564,6 +583,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
|||||||
"DELETE /api/train-scheduling/schedules/:id/wagons/:trainSetWagonId": ["Remove an empty wagon slot from a train", "DELETE", "Train Schedule"],
|
"DELETE /api/train-scheduling/schedules/:id/wagons/:trainSetWagonId": ["Remove an empty wagon slot from a train", "DELETE", "Train Schedule"],
|
||||||
"POST /api/train-scheduling/schedules/:id/wagons/:wagonId/move-load": ["Move a wagon's whole load to another wagon (empty → move/repin, loaded → swap loads)", "POST", "Train Schedule"],
|
"POST /api/train-scheduling/schedules/:id/wagons/:wagonId/move-load": ["Move a wagon's whole load to another wagon (empty → move/repin, loaded → swap loads)", "POST", "Train Schedule"],
|
||||||
"PATCH /api/train-scheduling/schedules/:id/window-rule": ["Override the booking-window rule for one schedule (open/close hour, duration, doc-review, payment, lead days) — only before the window opens", "PATCH", "Train Schedule"],
|
"PATCH /api/train-scheduling/schedules/:id/window-rule": ["Override the booking-window rule for one schedule (open/close hour, duration, doc-review, payment, lead days) — only before the window opens", "PATCH", "Train Schedule"],
|
||||||
|
"PATCH /api/train-scheduling/schedules/:id/wagon-yards": ["Re-plan the yard this departure boards wagons from and/or cuts them at (schedule-only; physical yards untouched, dispatch requires alignment)", "PATCH", "Train Schedule"],
|
||||||
"POST /api/train-scheduling/schedules/:id/merge": ["Merge another train into this schedule: its wagons join this consist, a same-day schedule on it is absorbed, and the emptied train is deactivated", "POST", "Train Schedule"],
|
"POST /api/train-scheduling/schedules/:id/merge": ["Merge another train into this schedule: its wagons join this consist, a same-day schedule on it is absorbed, and the emptied train is deactivated", "POST", "Train Schedule"],
|
||||||
"PATCH /api/train-scheduling/schedules/:id/checkpoints/:sequenceNo": ["Edit a logged leg", "PATCH", "Train Schedule"],
|
"PATCH /api/train-scheduling/schedules/:id/checkpoints/:sequenceNo": ["Edit a logged leg", "PATCH", "Train Schedule"],
|
||||||
|
|
||||||
@@ -611,7 +631,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
|||||||
"POST /api/warehouse-allocation-rules": ["Create a warehouse allocation rule", "POST", "Warehouse"],
|
"POST /api/warehouse-allocation-rules": ["Create a warehouse allocation rule", "POST", "Warehouse"],
|
||||||
"PATCH /api/warehouse-allocation-rules/:id": ["Update a warehouse allocation rule", "PATCH", "Warehouse"],
|
"PATCH /api/warehouse-allocation-rules/:id": ["Update a warehouse allocation rule", "PATCH", "Warehouse"],
|
||||||
"DELETE /api/warehouse-allocation-rules/:id": ["Delete a warehouse allocation rule", "DELETE", "Warehouse"],
|
"DELETE /api/warehouse-allocation-rules/:id": ["Delete a warehouse allocation rule", "DELETE", "Warehouse"],
|
||||||
"POST /api/warehouse-allocation/preview": ["Preview the yard/warehouse/zone a booking would be allocated to", "POST", "Warehouse"],
|
// "POST /api/warehouse-allocation/preview": ["Preview the yard/warehouse/zone a booking would be allocated to", "POST", "Warehouse"],
|
||||||
"POST /api/warehouse-fee-rules": ["Create a storage / demurrage fee rule", "POST", "Warehouse"],
|
"POST /api/warehouse-fee-rules": ["Create a storage / demurrage fee rule", "POST", "Warehouse"],
|
||||||
"PATCH /api/warehouse-fee-rules/:id": ["Update a fee rule", "PATCH", "Warehouse"],
|
"PATCH /api/warehouse-fee-rules/:id": ["Update a fee rule", "PATCH", "Warehouse"],
|
||||||
"DELETE /api/warehouse-fee-rules/:id": ["Delete a fee rule", "DELETE", "Warehouse"],
|
"DELETE /api/warehouse-fee-rules/:id": ["Delete a fee rule", "DELETE", "Warehouse"],
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { BaseRepository } from '@edr/api-common';
|
import { BaseRepository } from '@edr/api-common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Between, FindOptionsWhere, LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
|
import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
|
||||||
|
|
||||||
import { AuditLog } from './entities/audit-log.entity';
|
import { AuditLog } from './entities/audit-log.entity';
|
||||||
|
import type { AuditReferenceSource } from './audit-reference.registry';
|
||||||
|
|
||||||
export interface AuditLogQuery {
|
export interface AuditLogQuery {
|
||||||
type?: string;
|
type?: string;
|
||||||
@@ -12,6 +13,10 @@ export interface AuditLogQuery {
|
|||||||
method?: string;
|
method?: string;
|
||||||
isSuccess?: boolean;
|
isSuccess?: boolean;
|
||||||
resourceId?: string;
|
resourceId?: string;
|
||||||
|
reference?: string;
|
||||||
|
userName?: string;
|
||||||
|
title?: string;
|
||||||
|
q?: string;
|
||||||
from?: Date;
|
from?: Date;
|
||||||
to?: Date;
|
to?: Date;
|
||||||
skip: number;
|
skip: number;
|
||||||
@@ -40,30 +45,91 @@ export class AuditLogRepository extends BaseRepository<AuditLog> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the human identifier for one entity row (`WHERE id = $1`).
|
||||||
|
*
|
||||||
|
* `source` comes from the static `AUDIT_REFERENCE_SOURCES` registry — never
|
||||||
|
* from user input — so interpolating its table/column is safe; the id is
|
||||||
|
* bound as a parameter. Returns null when the row doesn't exist or the
|
||||||
|
* identifier column is empty.
|
||||||
|
*/
|
||||||
|
async lookupReference(
|
||||||
|
source: AuditReferenceSource,
|
||||||
|
id: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const rows = await this.auditLogRepository.manager.query<
|
||||||
|
{ reference: string | null }[]
|
||||||
|
>(
|
||||||
|
`SELECT ${source.column}::varchar AS reference FROM ${source.table} WHERE id = $1::uuid`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
return rows[0]?.reference || null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Paginated, filtered read. Newest first — every index on this table is
|
* Paginated, filtered read. Newest first — every index on this table is
|
||||||
* ordered `created_at DESC` to match.
|
* ordered `created_at DESC` to match.
|
||||||
|
*
|
||||||
|
* Query builder rather than `findAndCount`: `q` needs an OR across four
|
||||||
|
* columns, and `reference` needs the `upper(...) LIKE` shape that matches
|
||||||
|
* the expression index — neither fits `FindOptionsWhere`.
|
||||||
*/
|
*/
|
||||||
async search(query: AuditLogQuery): Promise<[AuditLog[], number]> {
|
async search(query: AuditLogQuery): Promise<[AuditLog[], number]> {
|
||||||
const where: FindOptionsWhere<AuditLog> = {};
|
const qb = this.auditLogRepository.createQueryBuilder('audit_log');
|
||||||
|
|
||||||
if (query.type) where.type = query.type;
|
if (query.type) qb.andWhere('audit_log.type = :type', { type: query.type });
|
||||||
if (query.userId) where.userId = query.userId;
|
if (query.userId) qb.andWhere('audit_log.user_id = :userId', { userId: query.userId });
|
||||||
if (query.method) where.method = query.method;
|
if (query.method) qb.andWhere('audit_log.method = :method', { method: query.method });
|
||||||
if (query.resourceId) where.resourceId = query.resourceId;
|
if (query.resourceId) {
|
||||||
if (query.isSuccess !== undefined) where.isSuccess = query.isSuccess;
|
qb.andWhere('audit_log.resource_id = :resourceId', { resourceId: query.resourceId });
|
||||||
|
}
|
||||||
|
if (query.isSuccess !== undefined) {
|
||||||
|
qb.andWhere('audit_log.is_success = :isSuccess', { isSuccess: query.isSuccess });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case-insensitive prefix match, shaped to hit idx_audit_logs_reference_upper.
|
||||||
|
// The explicit <> '' repeats the index's partial predicate — without it the
|
||||||
|
// planner cannot prove the partial index applies and falls back to a scan.
|
||||||
|
if (query.reference) {
|
||||||
|
qb.andWhere("audit_log.reference <> ''").andWhere(
|
||||||
|
"upper(audit_log.reference) LIKE upper(:reference) || '%'",
|
||||||
|
{ reference: escapeLike(query.reference) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (query.userName) {
|
||||||
|
qb.andWhere('audit_log.user_name ILIKE :userName', {
|
||||||
|
userName: `%${escapeLike(query.userName)}%`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (query.title) {
|
||||||
|
qb.andWhere('audit_log.title ILIKE :title', {
|
||||||
|
title: `%${escapeLike(query.title)}%`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// One search box across the columns staff actually search by.
|
||||||
|
// ponytail: ILIKE %…% scans the time-bounded window; add pg_trgm GIN
|
||||||
|
// indexes if the table grows past a few million rows.
|
||||||
|
if (query.q) {
|
||||||
|
const q = `%${escapeLike(query.q)}%`;
|
||||||
|
qb.andWhere(
|
||||||
|
`(audit_log.reference ILIKE :q
|
||||||
|
OR audit_log.resource_id ILIKE :q
|
||||||
|
OR audit_log.user_name ILIKE :q
|
||||||
|
OR audit_log.title ILIKE :q)`,
|
||||||
|
{ q },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Date range: either bound may be supplied alone.
|
// Date range: either bound may be supplied alone.
|
||||||
if (query.from && query.to) where.createdAt = Between(query.from, query.to);
|
if (query.from) qb.andWhere('audit_log.created_at >= :from', { from: query.from });
|
||||||
else if (query.from) where.createdAt = MoreThanOrEqual(query.from);
|
if (query.to) qb.andWhere('audit_log.created_at <= :to', { to: query.to });
|
||||||
else if (query.to) where.createdAt = LessThanOrEqual(query.to);
|
|
||||||
|
|
||||||
return this.auditLogRepository.findAndCount({
|
return qb
|
||||||
where,
|
.orderBy('audit_log.created_at', 'DESC')
|
||||||
order: { createdAt: 'DESC' },
|
.skip(query.skip)
|
||||||
skip: query.skip,
|
.take(query.take)
|
||||||
take: query.take,
|
.getManyAndCount();
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Distinct entity types present, for populating a filter dropdown. */
|
/** Distinct entity types present, for populating a filter dropdown. */
|
||||||
@@ -76,4 +142,20 @@ export class AuditLogRepository extends BaseRepository<AuditLog> {
|
|||||||
|
|
||||||
return rows.map((row) => row.type);
|
return rows.map((row) => row.type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Distinct action titles present, for the action filter dropdown. */
|
||||||
|
async distinctTitles(): Promise<string[]> {
|
||||||
|
const rows = await this.auditLogRepository
|
||||||
|
.createQueryBuilder('audit_log')
|
||||||
|
.select('DISTINCT audit_log.title', 'title')
|
||||||
|
.orderBy('audit_log.title', 'ASC')
|
||||||
|
.getRawMany<{ title: string }>();
|
||||||
|
|
||||||
|
return rows.map((row) => row.title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Escape LIKE wildcards so a literal `%`/`_` in the search text stays literal. */
|
||||||
|
function escapeLike(value: string): string {
|
||||||
|
return value.replace(/[\\%_]/g, (ch) => `\\${ch}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Where each audited entity type keeps its human identifier — the value staff
|
||||||
|
* search by (booking reference, train number, invoice number).
|
||||||
|
*
|
||||||
|
* Used by `AuditService.record` for a single indexed primary-key lookup at
|
||||||
|
* write time. Types not listed simply get `reference = ''`; the lookup is
|
||||||
|
* best-effort and an audit row is never lost over it.
|
||||||
|
*
|
||||||
|
* Table and column names are static values from this file — never user input —
|
||||||
|
* so interpolating them into SQL is safe. Ids are always bound as parameters.
|
||||||
|
*/
|
||||||
|
export interface AuditReferenceSource {
|
||||||
|
/** Schema-qualified table holding the entity. */
|
||||||
|
readonly table: string;
|
||||||
|
/** Column with the human identifier. */
|
||||||
|
readonly column: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AUDIT_REFERENCE_SOURCES: Readonly<Record<string, AuditReferenceSource>> = {
|
||||||
|
Booking: { table: 'freight.bookings', column: 'reference' },
|
||||||
|
Contract: { table: 'freight.contracts', column: 'reference' },
|
||||||
|
// "Schedule" (reschedule module) and "Train Schedule" are the same table.
|
||||||
|
Schedule: { table: 'freight.train_schedules', column: 'reference' },
|
||||||
|
'Train Schedule': { table: 'freight.train_schedules', column: 'reference' },
|
||||||
|
Train: { table: 'freight.trains', column: 'train_number' },
|
||||||
|
// Train Build routes carry the train id in :id.
|
||||||
|
'Train Build': { table: 'freight.trains', column: 'train_number' },
|
||||||
|
Wagon: { table: 'freight.wagons', column: 'wagon_number' },
|
||||||
|
Locomotive: { table: 'freight.locomotives', column: 'code' },
|
||||||
|
'EIMS Invoice': { table: 'freight.invoices', column: 'invoice_number' },
|
||||||
|
// Payment paths mostly carry an invoice id; the ones that don't (e.g.
|
||||||
|
// redirect-success/:bookingId) miss the lookup and fall back to ''.
|
||||||
|
Payment: { table: 'freight.invoices', column: 'invoice_number' },
|
||||||
|
Vehicle: { table: 'freight.vehicles', column: 'plate_number' },
|
||||||
|
Company: { table: 'freight.companies', column: 'name' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Lookups run `WHERE id = $1::uuid` — guard non-uuid ids (template codes…). */
|
||||||
|
export const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
@@ -43,4 +43,13 @@ export class AuditController {
|
|||||||
types(): Promise<string[]> {
|
types(): Promise<string[]> {
|
||||||
return this.auditService.listTypes();
|
return this.auditService.listTypes();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('actions')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.auditLog.view)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Distinct action titles present in the audit log (filter dropdown)',
|
||||||
|
})
|
||||||
|
actions(): Promise<string[]> {
|
||||||
|
return this.auditService.listActions();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { PaginatedResponse } from '@edr/types';
|
|||||||
import { AuditLog } from './entities/audit-log.entity';
|
import { AuditLog } from './entities/audit-log.entity';
|
||||||
import { AuditLogRepository } from './audit-log.repository';
|
import { AuditLogRepository } from './audit-log.repository';
|
||||||
import { AuditLogQueryDto } from './dto/audit-log-query.dto';
|
import { AuditLogQueryDto } from './dto/audit-log-query.dto';
|
||||||
|
import {
|
||||||
|
AUDIT_REFERENCE_SOURCES,
|
||||||
|
UUID_PATTERN,
|
||||||
|
} from './audit-reference.registry';
|
||||||
import {
|
import {
|
||||||
buildPaginationMeta,
|
buildPaginationMeta,
|
||||||
normalizePagination,
|
normalizePagination,
|
||||||
@@ -25,6 +29,7 @@ export class AuditService {
|
|||||||
*/
|
*/
|
||||||
async record(entry: Partial<AuditLog>): Promise<void> {
|
async record(entry: Partial<AuditLog>): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
entry.reference = await this.resolveReference(entry.type, entry.resourceId);
|
||||||
await this.auditLogRepository.record(entry);
|
await this.auditLogRepository.record(entry);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
@@ -35,6 +40,34 @@ export class AuditService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort human identifier (booking reference, train number, …) for the
|
||||||
|
* entity the action touched — one primary-key lookup against the table
|
||||||
|
* registered for the type. Always returns a string: '' when the type has no
|
||||||
|
* registered source, the id isn't a uuid (template codes), the row is gone,
|
||||||
|
* or the lookup itself fails. A missing reference must never cost the audit
|
||||||
|
* row, so failures degrade to '' rather than throwing.
|
||||||
|
*/
|
||||||
|
private async resolveReference(
|
||||||
|
type: string | undefined,
|
||||||
|
resourceId: string | null | undefined,
|
||||||
|
): Promise<string> {
|
||||||
|
const source = type ? AUDIT_REFERENCE_SOURCES[type] : undefined;
|
||||||
|
if (!source || !resourceId || !UUID_PATTERN.test(resourceId)) return '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const reference = await this.auditLogRepository.lookupReference(source, resourceId);
|
||||||
|
return reference?.slice(0, 64) ?? '';
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Reference lookup failed for ${type} ${resourceId}: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Paginated, filtered audit history, newest first. */
|
/** Paginated, filtered audit history, newest first. */
|
||||||
async search(query: AuditLogQueryDto): Promise<PaginatedResponse<AuditLog>> {
|
async search(query: AuditLogQueryDto): Promise<PaginatedResponse<AuditLog>> {
|
||||||
const { page, pageSize, skip, take } = normalizePagination(query);
|
const { page, pageSize, skip, take } = normalizePagination(query);
|
||||||
@@ -53,6 +86,10 @@ export class AuditService {
|
|||||||
userId: query.userId,
|
userId: query.userId,
|
||||||
method: query.method,
|
method: query.method,
|
||||||
resourceId: query.resourceId,
|
resourceId: query.resourceId,
|
||||||
|
reference: query.reference,
|
||||||
|
userName: query.userName,
|
||||||
|
title: query.title,
|
||||||
|
q: query.q,
|
||||||
isSuccess:
|
isSuccess:
|
||||||
query.isSuccess === undefined ? undefined : query.isSuccess === 'true',
|
query.isSuccess === undefined ? undefined : query.isSuccess === 'true',
|
||||||
from,
|
from,
|
||||||
@@ -68,4 +105,9 @@ export class AuditService {
|
|||||||
async listTypes(): Promise<string[]> {
|
async listTypes(): Promise<string[]> {
|
||||||
return this.auditLogRepository.distinctTypes();
|
return this.auditLogRepository.distinctTypes();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Distinct action titles, for the action filter dropdown. */
|
||||||
|
async listActions(): Promise<string[]> {
|
||||||
|
return this.auditLogRepository.distinctTitles();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,44 @@ export class AuditLogQueryDto extends PaginationQueryDto {
|
|||||||
@MaxLength(64)
|
@MaxLength(64)
|
||||||
resourceId?: string;
|
resourceId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'Human identifier of the affected record — booking reference, schedule number, train number. Case-insensitive prefix match.',
|
||||||
|
example: 'S-2026-00045',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(64)
|
||||||
|
reference?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Staff name, case-insensitive substring match.',
|
||||||
|
example: 'Mulu',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(150)
|
||||||
|
userName?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Action title, case-insensitive substring match.',
|
||||||
|
example: 'Cancel booking',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'Free-text search across reference, resource id, staff name and action title.',
|
||||||
|
example: 'B-2026-00120',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
q?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'Filter by outcome: true = succeeded, false = failed.',
|
description: 'Filter by outcome: true = succeeded, false = failed.',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -86,6 +86,20 @@ export class AuditLog {
|
|||||||
@Column({ name: 'resource_id', type: 'varchar', length: 64, nullable: true })
|
@Column({ name: 'resource_id', type: 'varchar', length: 64, nullable: true })
|
||||||
resourceId?: string | null;
|
resourceId?: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Human identifier of the affected record — booking reference, schedule
|
||||||
|
* number, train number — resolved at write time from
|
||||||
|
* `AUDIT_REFERENCE_SOURCES`. This is what staff type into the search box;
|
||||||
|
* `resourceId` stays the machine id.
|
||||||
|
*
|
||||||
|
* `''` (never NULL) when the entity type has no registered source, the
|
||||||
|
* lookup found nothing, or the row predates the column. Empty string keeps
|
||||||
|
* search SQL to one shape and matches how pre-existing rows read after the
|
||||||
|
* metadata-only migration.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'reference', type: 'varchar', length: 64, default: '' })
|
||||||
|
reference!: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sanitized request body. Secrets are replaced with `[REDACTED]` and uploads
|
* Sanitized request body. Secrets are replaced with `[REDACTED]` and uploads
|
||||||
* are reduced to `{ __file, originalName, mimeType, size }` descriptors —
|
* are reduced to `{ __file, originalName, mimeType, size }` descriptors —
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Body, Controller, Patch, Post, UseGuards } from "@nestjs/common";
|
import { Body, Controller, Patch, Post, UseGuards } from "@nestjs/common";
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
import { CurrentUser } from "@tria-plc/api-common/modules/auth/decorators/current-user.decorator";
|
import { CurrentUser } from "@tria-plc/api-common/modules/auth/decorators/current-user.decorator";
|
||||||
import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard";
|
import { FreightJwtGuard } from "../../common/freight-jwt.guard";
|
||||||
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||||
|
|
||||||
import { AccountService } from "./account.service";
|
import { AccountService } from "./account.service";
|
||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
@ApiTags("auth")
|
@ApiTags("auth")
|
||||||
@Controller("me")
|
@Controller("me")
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtGuard)
|
@UseGuards(FreightJwtGuard)
|
||||||
export class AccountController {
|
export class AccountController {
|
||||||
constructor(private readonly accountService: AccountService) {}
|
constructor(private readonly accountService: AccountService) {}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { CurrentUser } from '@tria-plc/api-common/modules/auth/decorators/current-user.decorator';
|
import { CurrentUser } from '@tria-plc/api-common/modules/auth/decorators/current-user.decorator';
|
||||||
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
import { FreightJwtGuard } from '../../common/freight-jwt.guard';
|
||||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
|
|
||||||
import { FreightMeService } from './freight-me.service';
|
import { FreightMeService } from './freight-me.service';
|
||||||
@@ -13,7 +13,7 @@ export class FreightMeController {
|
|||||||
constructor(private readonly freightMeService: FreightMeService) {}
|
constructor(private readonly freightMeService: FreightMeService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(JwtGuard)
|
@UseGuards(FreightJwtGuard)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Current user with flat permissionKeys for backoffice gating',
|
summary: 'Current user with flat permissionKeys for backoffice gating',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import {
|
|||||||
} from '../../common/freight-permission.util';
|
} from '../../common/freight-permission.util';
|
||||||
import { PERMISSIONS_CATALOG } from '../../seed/freight-permissions.registry';
|
import { PERMISSIONS_CATALOG } from '../../seed/freight-permissions.registry';
|
||||||
|
|
||||||
|
/** One position as the session snapshot carries it. */
|
||||||
|
type TokenPosition = NonNullable<TCurrentUser['employee']>['position'];
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FreightMeService {
|
export class FreightMeService {
|
||||||
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
|
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
|
||||||
@@ -65,49 +68,63 @@ export class FreightMeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getEnrichedProfile(user: TCurrentUser) {
|
async getEnrichedProfile(user: TCurrentUser) {
|
||||||
const positionId = user.employee?.position?.id;
|
const employeeRecord = user.employee as
|
||||||
const [positionType, positionTypePermissionKeys] = await Promise.all([
|
| (typeof user.employee & { positions?: TokenPosition[] })
|
||||||
this.lookupPositionType(positionId),
|
| undefined;
|
||||||
this.lookupPositionTypePermissions(positionId),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Merge the type-level grants into the position's own permission list so
|
// `FreightJwtGuard` restores every position the login snapshot holds; the
|
||||||
// BOTH consumers see them: `collectPermissionKeys` below, and the
|
// stock IAM guard only ever leaves the single `position`. Fall back to it
|
||||||
// backoffice's `getPermissionKeys`, which walks this same nested array.
|
// so a request that somehow skipped our guard still resolves one post
|
||||||
const positionPermissions = [
|
// rather than none.
|
||||||
...(user.employee?.position?.permissions ?? []),
|
const rawPositions: TokenPosition[] = employeeRecord?.positions?.length
|
||||||
];
|
? employeeRecord.positions
|
||||||
const seenPermissionKeys = new Set(
|
: employeeRecord?.position
|
||||||
positionPermissions.map((p) => p?.key).filter(Boolean),
|
? [employeeRecord.position]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const enrichedPositions = await Promise.all(
|
||||||
|
rawPositions.map(async (position) => {
|
||||||
|
const [positionType, positionTypePermissionKeys] = await Promise.all([
|
||||||
|
this.lookupPositionType(position.id),
|
||||||
|
this.lookupPositionTypePermissions(position.id),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Merge the type-level grants into this position's own permission list
|
||||||
|
// so BOTH consumers see them: `collectPermissionKeys` below, and the
|
||||||
|
// backoffice's `getPermissionKeys`, which walks this nested array.
|
||||||
|
const permissions = [...(position.permissions ?? [])];
|
||||||
|
const seen = new Set(permissions.map((p) => p?.key).filter(Boolean));
|
||||||
|
for (const key of positionTypePermissionKeys) {
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key);
|
||||||
|
permissions.push({ key } as (typeof permissions)[number]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
positionTypePermissionKeys,
|
||||||
|
position: {
|
||||||
|
id: position.id,
|
||||||
|
key: position.key,
|
||||||
|
employeePositionId: position.employeePositionId,
|
||||||
|
name: position.name,
|
||||||
|
isDelegate: position.isDelegate,
|
||||||
|
parentPositionId: position.parentPositionId,
|
||||||
|
permissions,
|
||||||
|
positionType,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
for (const key of positionTypePermissionKeys) {
|
|
||||||
if (!seenPermissionKeys.has(key)) {
|
|
||||||
seenPermissionKeys.add(key);
|
|
||||||
positionPermissions.push({ key } as (typeof positionPermissions)[number]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const employee = user.employee
|
const employee = employeeRecord
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
id: user.employee.id,
|
id: employeeRecord.id,
|
||||||
organizationId: user.employee.organizationId,
|
organizationId: employeeRecord.organizationId,
|
||||||
unitId: user.employee.unitId,
|
unitId: employeeRecord.unitId,
|
||||||
name: user.employee.name,
|
name: employeeRecord.name,
|
||||||
positions: user.employee.position
|
positions: enrichedPositions.map((p) => p.position),
|
||||||
? [
|
|
||||||
{
|
|
||||||
id: user.employee.position.id,
|
|
||||||
key: user.employee.position.key,
|
|
||||||
employeePositionId: user.employee.position.employeePositionId,
|
|
||||||
name: user.employee.position.name,
|
|
||||||
isDelegate: user.employee.position.isDelegate,
|
|
||||||
parentPositionId: user.employee.position.parentPositionId,
|
|
||||||
permissions: positionPermissions,
|
|
||||||
positionType,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [],
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: [];
|
: [];
|
||||||
@@ -118,7 +135,7 @@ export class FreightMeService {
|
|||||||
const permissionKeys = [
|
const permissionKeys = [
|
||||||
...new Set([
|
...new Set([
|
||||||
...collectPermissionKeys(user),
|
...collectPermissionKeys(user),
|
||||||
...positionTypePermissionKeys,
|
...enrichedPositions.flatMap((p) => p.positionTypePermissionKeys),
|
||||||
]),
|
]),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,11 @@ import { InvoiceLine } from "./entities/invoice-line.entity";
|
|||||||
import { Invoice, InvoicePayment } from "./entities/invoice.entity";
|
import { Invoice, InvoicePayment } from "./entities/invoice.entity";
|
||||||
import { InvoiceLineRepository } from "./invoice-line.repository";
|
import { InvoiceLineRepository } from "./invoice-line.repository";
|
||||||
import { nextDailyInvoiceNumber } from "./invoice-numbering.util";
|
import { nextDailyInvoiceNumber } from "./invoice-numbering.util";
|
||||||
import { applySettlement, round2 } from "./invoice-settlement.util";
|
import {
|
||||||
|
applySettlement,
|
||||||
|
invoicePaymentMethodExpr,
|
||||||
|
round2,
|
||||||
|
} from "./invoice-settlement.util";
|
||||||
import { InvoiceRepository } from "./invoice.repository";
|
import { InvoiceRepository } from "./invoice.repository";
|
||||||
|
|
||||||
/** Options forwarded to the payment gateway when settling an invoice. */
|
/** Options forwarded to the payment gateway when settling an invoice. */
|
||||||
@@ -111,6 +115,8 @@ export interface InvoiceListFilters {
|
|||||||
statuses?: Freight.InvoiceStatus[];
|
statuses?: Freight.InvoiceStatus[];
|
||||||
sources?: string[];
|
sources?: string[];
|
||||||
eimsStatuses?: string[];
|
eimsStatuses?: string[];
|
||||||
|
/** Settled payment method, normalised UPPER_SNAKE — see `invoicePaymentMethodExpr`. */
|
||||||
|
paymentMethods?: string[];
|
||||||
currency?: string;
|
currency?: string;
|
||||||
search?: string;
|
search?: string;
|
||||||
issuedFrom?: string;
|
issuedFrom?: string;
|
||||||
@@ -125,6 +131,13 @@ export interface InvoiceListFilters {
|
|||||||
tradeDirections?: string[];
|
tradeDirections?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The list/summary query builders both alias the invoice as `invoice` and the
|
||||||
|
* joined gateway payment as `payment`; TypeORM rewrites those alias.property
|
||||||
|
* references into real quoted columns.
|
||||||
|
*/
|
||||||
|
const PAYMENT_METHOD_EXPR = invoicePaymentMethodExpr("invoice", "payment");
|
||||||
|
|
||||||
const DEFAULT_DUE_DAYS = 14;
|
const DEFAULT_DUE_DAYS = 14;
|
||||||
|
|
||||||
/** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */
|
/** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */
|
||||||
@@ -294,6 +307,13 @@ export class BillingService {
|
|||||||
eimsStatuses: filter.eimsStatuses,
|
eimsStatuses: filter.eimsStatuses,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (filter.paymentMethods?.length) {
|
||||||
|
// Requires the `payment` alias to be joined by the caller — both call
|
||||||
|
// sites do, unconditionally, so this can never reference a missing alias.
|
||||||
|
qb.andWhere(`${PAYMENT_METHOD_EXPR} IN (:...paymentMethods)`, {
|
||||||
|
paymentMethods: filter.paymentMethods,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (filter.currency) {
|
if (filter.currency) {
|
||||||
// Stored casing has drifted ("usd" rows exist) — compare normalised.
|
// Stored casing has drifted ("usd" rows exist) — compare normalised.
|
||||||
qb.andWhere("UPPER(invoice.currency) = :currency", {
|
qb.andWhere("UPPER(invoice.currency) = :currency", {
|
||||||
@@ -333,20 +353,24 @@ export class BillingService {
|
|||||||
qb.andWhere("invoice.balanceAmount > 0 AND invoice.dueAt < now()");
|
qb.andWhere("invoice.balanceAmount > 0 AND invoice.dueAt < now()");
|
||||||
}
|
}
|
||||||
if (filter.search) {
|
if (filter.search) {
|
||||||
// Searches what the row actually shows: its number, who it bills, and
|
// Searches what the row actually shows: its number, who it bills, the
|
||||||
// the source record behind it (booking reference, GRN, shipping line).
|
// source record behind it (booking reference, PNR, GRN, shipping line)
|
||||||
|
// and the payment references a customer or a provider support desk would
|
||||||
|
// quote back — the gateway transaction id and our merchant order id.
|
||||||
// The raw `sourceId` stays matchable so a pasted UUID still resolves.
|
// The raw `sourceId` stays matchable so a pasted UUID still resolves.
|
||||||
// Requires the `company` alias — every caller of this joins it.
|
// Requires the `company` and `payment` aliases — every caller joins both.
|
||||||
qb.andWhere(
|
qb.andWhere(
|
||||||
`(invoice.invoiceNumber ILIKE :search
|
`(invoice.invoiceNumber ILIKE :search
|
||||||
OR invoice.sourceId ILIKE :search
|
OR invoice.sourceId ILIKE :search
|
||||||
OR company.name ILIKE :search
|
OR company.name ILIKE :search
|
||||||
|
OR payment.transactionId ILIKE :search
|
||||||
|
OR payment.merchantOrderId ILIKE :search
|
||||||
OR EXISTS (
|
OR EXISTS (
|
||||||
SELECT 1 FROM freight.bookings b
|
SELECT 1 FROM freight.bookings b
|
||||||
LEFT JOIN freight.warehouse_inventory wi ON wi.booking_id = b.id
|
LEFT JOIN freight.warehouse_inventory wi ON wi.booking_id = b.id
|
||||||
LEFT JOIN freight.first_mile fm ON fm.booking_id = b.id
|
LEFT JOIN freight.first_mile fm ON fm.booking_id = b.id
|
||||||
LEFT JOIN freight.last_mile lm ON lm.booking_id = b.id
|
LEFT JOIN freight.last_mile lm ON lm.booking_id = b.id
|
||||||
WHERE b.reference ILIKE :search
|
WHERE (b.reference ILIKE :search OR b.pnr_code ILIKE :search)
|
||||||
AND (b.id::text = invoice.source_id
|
AND (b.id::text = invoice.source_id
|
||||||
OR wi.id::text = invoice.source_id
|
OR wi.id::text = invoice.source_id
|
||||||
OR fm.id::text = invoice.source_id
|
OR fm.id::text = invoice.source_id
|
||||||
@@ -388,6 +412,9 @@ export class BillingService {
|
|||||||
.getRepository(Invoice)
|
.getRepository(Invoice)
|
||||||
.createQueryBuilder("invoice")
|
.createQueryBuilder("invoice")
|
||||||
.leftJoinAndSelect("invoice.company", "company")
|
.leftJoinAndSelect("invoice.company", "company")
|
||||||
|
// The gateway payment behind the invoice: the settled method and the
|
||||||
|
// provider's transaction reference both live on it, and nowhere else.
|
||||||
|
.leftJoinAndSelect("invoice.payment", "payment")
|
||||||
// sortBy is whitelisted through INVOICE_SORT_COLUMNS, never interpolated
|
// sortBy is whitelisted through INVOICE_SORT_COLUMNS, never interpolated
|
||||||
// raw. The id tiebreaker keeps paging stable when the sort column ties
|
// raw. The id tiebreaker keeps paging stable when the sort column ties
|
||||||
// (issuedAt is null on every DRAFT row).
|
// (issuedAt is null on every DRAFT row).
|
||||||
@@ -544,6 +571,7 @@ export class BillingService {
|
|||||||
// Joined, not selected: `applyInvoiceFilters` searches the customer name,
|
// Joined, not selected: `applyInvoiceFilters` searches the customer name,
|
||||||
// so the alias has to exist even though the summary only sums money.
|
// so the alias has to exist even though the summary only sums money.
|
||||||
.leftJoin("invoice.company", "company")
|
.leftJoin("invoice.company", "company")
|
||||||
|
.leftJoin("invoice.payment", "payment")
|
||||||
.select("invoice.currency", "currency")
|
.select("invoice.currency", "currency")
|
||||||
.addSelect("SUM(invoice.paidAmount)", "collected")
|
.addSelect("SUM(invoice.paidAmount)", "collected")
|
||||||
.groupBy("invoice.currency");
|
.groupBy("invoice.currency");
|
||||||
@@ -757,7 +785,7 @@ export class BillingService {
|
|||||||
/** Invoice header plus its line items. */
|
/** Invoice header plus its line items. */
|
||||||
async findById(id: string): Promise<Invoice & { lines: InvoiceLine[] }> {
|
async findById(id: string): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||||
const invoice = await this.invoices.findById(id, {
|
const invoice = await this.invoices.findById(id, {
|
||||||
relations: { company: true, companyProfile: true },
|
relations: { company: true, companyProfile: true, payment: true },
|
||||||
});
|
});
|
||||||
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
|
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
|
||||||
const [hydrated] = await this.attachShippingLineCompanies([invoice]);
|
const [hydrated] = await this.attachShippingLineCompanies([invoice]);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
|
|
||||||
import { EimsInvoiceStatus } from "../../eims/eims-registration.types";
|
import { EimsInvoiceStatus } from "../../eims/eims-registration.types";
|
||||||
|
import { INVOICE_PAYMENT_METHODS } from "../invoice-settlement.util";
|
||||||
|
|
||||||
/** Columns the invoice list may be ordered by -> their query-builder expression. */
|
/** Columns the invoice list may be ordered by -> their query-builder expression. */
|
||||||
export const INVOICE_SORT_COLUMNS: Record<string, string> = {
|
export const INVOICE_SORT_COLUMNS: Record<string, string> = {
|
||||||
@@ -96,6 +97,19 @@ export class FilterInvoiceDto {
|
|||||||
@IsIn(Object.values(EimsInvoiceStatus), { each: true })
|
@IsIn(Object.values(EimsInvoiceStatus), { each: true })
|
||||||
eimsStatuses?: EimsInvoiceStatus[];
|
eimsStatuses?: EimsInvoiceStatus[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settled payment method (`?paymentMethods=CBE_BILL,BANK_TRANSFER`). Values are
|
||||||
|
* the normalised UPPER_SNAKE vocabulary of `invoicePaymentMethodExpr`. Not
|
||||||
|
* validated against a fixed list — the manual pay endpoint takes a free-form
|
||||||
|
* method, so an `IsIn` here would silently drop a real value.
|
||||||
|
*/
|
||||||
|
@ApiPropertyOptional({ isArray: true, enum: INVOICE_PAYMENT_METHODS })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(csv)
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
paymentMethods?: string[];
|
||||||
|
|
||||||
/** Manual-payments worklist and the invoice list: restrict to one currency. */
|
/** Manual-payments worklist and the invoice list: restrict to one currency. */
|
||||||
@ApiPropertyOptional({ enum: ["USD", "ETB"] })
|
@ApiPropertyOptional({ enum: ["USD", "ETB"] })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -60,11 +60,7 @@ const context = (over: Partial<EimsMapperContext> = {}): EimsMapperContext => ({
|
|||||||
unitDefault: "PCS",
|
unitDefault: "PCS",
|
||||||
incomeWithholdValue: 0,
|
incomeWithholdValue: 0,
|
||||||
transactionWithholdValue: 0,
|
transactionWithholdValue: 0,
|
||||||
buyerCountryCode: "231", // test-only, not a confirmed real MoR code
|
buyerGeo: { Country: "70", Region: "6", City: "31", Wereda: "190" },
|
||||||
buyerCountryCodes: {},
|
|
||||||
buyerRegionCodes: { "Addis Ababa": "13" },
|
|
||||||
buyerWeredaCodes: {},
|
|
||||||
buyerCityCodes: {},
|
|
||||||
...over,
|
...over,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -90,26 +86,24 @@ describe("toEimsInvoice", () => {
|
|||||||
expect(doc.SellerDetails).toBe(seller);
|
expect(doc.SellerDetails).toBe(seller);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps the buyer from the company row and leaves unmodelled fields null", () => {
|
it("maps the buyer from the company row and omits Id fields for a TIN-identified buyer", () => {
|
||||||
const doc = toEimsInvoice(invoice(), seller, context());
|
const doc = toEimsInvoice(invoice(), seller, context());
|
||||||
|
|
||||||
|
// MoR rule 7004 rejects an explicit IdType/IdNumber null — the keys must be absent.
|
||||||
expect(doc.BuyerDetails).toEqual({
|
expect(doc.BuyerDetails).toEqual({
|
||||||
City: null,
|
// Resolved by the registration service before the counter was reserved; the mapper copies.
|
||||||
// company.country is "Ethiopia" (the domestic default) — resolves to context's flat
|
City: "31",
|
||||||
// buyerCountryCode fallback, not null, per resolveCountryCode.
|
Country: "70",
|
||||||
Country: "231",
|
|
||||||
Email: "buyer@abc.et",
|
Email: "buyer@abc.et",
|
||||||
HouseNumber: "NEW",
|
HouseNumber: "NEW",
|
||||||
IdNumber: null,
|
|
||||||
IdType: null,
|
|
||||||
Tin: "0999930000",
|
Tin: "0999930000",
|
||||||
LegalName: "ABC Trading PLC",
|
LegalName: "ABC Trading PLC",
|
||||||
Phone: "0912345678",
|
Phone: "0912345678",
|
||||||
Region: "13",
|
Region: "6",
|
||||||
Zone: "SHA",
|
Zone: "SHA",
|
||||||
Kebele: "03",
|
Kebele: "03",
|
||||||
VatNumber: "123475885858",
|
VatNumber: "123475885858",
|
||||||
Wereda: "574",
|
Wereda: "190",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -284,108 +278,39 @@ describe("toEimsInvoice", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("toEimsInvoice — MoR field constraints", () => {
|
describe("toEimsInvoice — MoR field constraints", () => {
|
||||||
it("passes a buyer region through when it is already a MoR code", () => {
|
/**
|
||||||
|
* Geography is no longer resolved here. `resolveMorGeo` runs in the registration service, ahead
|
||||||
|
* of the counter reservation, and hands the mapper finished MoR codes — so what these cover is
|
||||||
|
* that the resolved values reach the right `BuyerDetails` fields untouched. The lookup rules
|
||||||
|
* themselves (hierarchy, aliases, ambiguity) are covered in `mor-location.resolver.spec.ts`.
|
||||||
|
*/
|
||||||
|
it("puts the resolved MoR codes on BuyerDetails, unmodified and as strings", () => {
|
||||||
const doc = toEimsInvoice(invoice(), seller, context());
|
const doc = toEimsInvoice(invoice(), seller, context());
|
||||||
expect(doc.BuyerDetails.Region).toBe("13");
|
|
||||||
|
expect(doc.BuyerDetails.Country).toBe("70");
|
||||||
|
expect(doc.BuyerDetails.Region).toBe("6");
|
||||||
|
expect(doc.BuyerDetails.City).toBe("31");
|
||||||
|
expect(doc.BuyerDetails.Wereda).toBe("190");
|
||||||
|
for (const field of ["Country", "Region", "City", "Wereda"] as const) {
|
||||||
|
expect(typeof doc.BuyerDetails[field]).toBe("string");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps a region name to its code, ignoring case and spacing", () => {
|
it("never emits an Open Admin Data ETxx identifier as a location", () => {
|
||||||
const doc = toEimsInvoice(
|
|
||||||
invoice({ company: { ...invoice().company!, region: " addis ababa " } }),
|
|
||||||
seller,
|
|
||||||
context({ buyerRegionCodes: { "Addis Ababa": "13" } }),
|
|
||||||
);
|
|
||||||
expect(doc.BuyerDetails.Region).toBe("13");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refuses to file a buyer whose region has no mapping", () => {
|
|
||||||
expect(() =>
|
|
||||||
toEimsInvoice(
|
|
||||||
invoice({ company: { ...invoice().company!, region: "Somewhere Else" } }),
|
|
||||||
seller,
|
|
||||||
context(),
|
|
||||||
),
|
|
||||||
).toThrow(/not a MoR Region code and has no mapping/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refuses a buyer with no region at all rather than guessing one", () => {
|
|
||||||
expect(() =>
|
|
||||||
toEimsInvoice(
|
|
||||||
invoice({ company: { ...invoice().company!, region: null } }),
|
|
||||||
seller,
|
|
||||||
context(),
|
|
||||||
),
|
|
||||||
).toThrow(/buyer Region \(unset\)/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("passes a buyer wereda through when it is already a MoR code", () => {
|
|
||||||
const doc = toEimsInvoice(invoice(), seller, context());
|
const doc = toEimsInvoice(invoice(), seller, context());
|
||||||
expect(doc.BuyerDetails.Wereda).toBe("574");
|
for (const field of ["Country", "Region", "City", "Wereda"] as const) {
|
||||||
|
expect(doc.BuyerDetails[field]).toMatch(/^[0-9]+$/);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps a wereda name to its code", () => {
|
it("keeps BuyerDetails.Zone as the buyer's own zone name — MoR takes that one as prose", () => {
|
||||||
const doc = toEimsInvoice(
|
const doc = toEimsInvoice(
|
||||||
invoice({ company: { ...invoice().company!, woreda: "Yeka" } }),
|
invoice({ company: { ...invoice().company!, zone: "Fafen" } }),
|
||||||
seller,
|
seller,
|
||||||
context({ buyerWeredaCodes: { Yeka: "99" } }),
|
context(),
|
||||||
);
|
);
|
||||||
expect(doc.BuyerDetails.Wereda).toBe("99");
|
expect(doc.BuyerDetails.Zone).toBe("Fafen");
|
||||||
});
|
expect(doc.BuyerDetails.City).toBe("31");
|
||||||
|
|
||||||
it("refuses to file a buyer whose wereda has no mapping", () => {
|
|
||||||
expect(() =>
|
|
||||||
toEimsInvoice(
|
|
||||||
invoice({ company: { ...invoice().company!, woreda: "Yeka" } }),
|
|
||||||
seller,
|
|
||||||
context({ buyerWeredaCodes: {} }),
|
|
||||||
),
|
|
||||||
).toThrow(/buyer Wereda "Yeka".*EIMS_BUYER_WEREDA_CODES/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("derives City from the buyer's zone via the city code map", () => {
|
|
||||||
const doc = toEimsInvoice(
|
|
||||||
invoice({ company: { ...invoice().company!, zone: "Kirkos" } }),
|
|
||||||
seller,
|
|
||||||
context({ buyerCityCodes: { Kirkos: "101" } }),
|
|
||||||
);
|
|
||||||
expect(doc.BuyerDetails.City).toBe("101");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves City null (not a throw) when the buyer's zone has no city mapping — City is optional", () => {
|
|
||||||
const doc = toEimsInvoice(
|
|
||||||
invoice({ company: { ...invoice().company!, zone: "Somewhere Else" } }),
|
|
||||||
seller,
|
|
||||||
context({ buyerCityCodes: {} }),
|
|
||||||
);
|
|
||||||
expect(doc.BuyerDetails.City).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("maps a buyer country name to its code via the country code map", () => {
|
|
||||||
const doc = toEimsInvoice(
|
|
||||||
invoice({ company: { ...invoice().company!, country: "Djibouti" } }),
|
|
||||||
seller,
|
|
||||||
context({ buyerCountryCodes: { Djibouti: "071" } }),
|
|
||||||
);
|
|
||||||
expect(doc.BuyerDetails.Country).toBe("071");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to the flat domestic country code only for Ethiopia, not any unmapped country", () => {
|
|
||||||
const doc = toEimsInvoice(
|
|
||||||
invoice({ company: { ...invoice().company!, country: "Ethiopia" } }),
|
|
||||||
seller,
|
|
||||||
context({ buyerCountryCode: "231", buyerCountryCodes: {} }),
|
|
||||||
);
|
|
||||||
expect(doc.BuyerDetails.Country).toBe("231");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refuses a genuinely foreign buyer country with no mapping — never silently files it as Ethiopia", () => {
|
|
||||||
expect(() =>
|
|
||||||
toEimsInvoice(
|
|
||||||
invoice({ company: { ...invoice().company!, country: "Kenya" } }),
|
|
||||||
seller,
|
|
||||||
context({ buyerCountryCode: "231", buyerCountryCodes: {} }),
|
|
||||||
),
|
|
||||||
).toThrow(/buyer Country "Kenya".*EIMS_BUYER_COUNTRY_CODES/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emits NatureOfSupplies lowercase, whatever case it was configured in", () => {
|
it("emits NatureOfSupplies lowercase, whatever case it was configured in", () => {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
* authoritative: they are passed through or overridable rather than validated against a fixed set.
|
* authoritative: they are passed through or overridable rather than validated against a fixed set.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { MorGeoCodes } from "../../config/mor-location.resolver";
|
||||||
import { round2 } from "./invoice-settlement.util";
|
import { round2 } from "./invoice-settlement.util";
|
||||||
|
|
||||||
/** Only proven-required constant: the 400 SCHEMA ERROR sample rejects a payload without it. */
|
/** Only proven-required constant: the 400 SCHEMA ERROR sample rejects a payload without it. */
|
||||||
@@ -34,8 +35,9 @@ export interface EimsBuyerDetails {
|
|||||||
City: string | null;
|
City: string | null;
|
||||||
Email: string | null;
|
Email: string | null;
|
||||||
HouseNumber: string | null;
|
HouseNumber: string | null;
|
||||||
IdNumber: string | null;
|
/** Omitted entirely for a TIN-identified buyer — MoR rule 7004 rejects an explicit null. */
|
||||||
IdType: string | null;
|
IdNumber?: string;
|
||||||
|
IdType?: string;
|
||||||
Tin: string;
|
Tin: string;
|
||||||
LegalName: string;
|
LegalName: string;
|
||||||
Phone: string | null;
|
Phone: string | null;
|
||||||
@@ -235,35 +237,15 @@ export interface EimsMapperContext {
|
|||||||
*/
|
*/
|
||||||
relatedDocument?: string | null;
|
relatedDocument?: string | null;
|
||||||
/**
|
/**
|
||||||
* Domestic fallback only, applied when `company.country` is empty or "Ethiopia" and not already
|
* The buyer's MoR location codes — `Country`/`Region`/`City`/`Wereda`, already resolved from the
|
||||||
* in `buyerCountryCodes` — see that field. Never applied to a genuinely foreign buyer.
|
* Ministry's location master by `resolveMorGeo`.
|
||||||
*/
|
|
||||||
buyerCountryCode?: string | null;
|
|
||||||
/** Country name → MoR code. Format unconfirmed, so looked up by name only, not digit-validated. */
|
|
||||||
buyerCountryCodes: Record<string, string>;
|
|
||||||
/**
|
|
||||||
* Region name → MoR numeric code, for buyers whose stored region is free text.
|
|
||||||
*
|
*
|
||||||
* `companies.region` holds names ("Addis Ababa") while MoR validates `BuyerDetails.Region`
|
* Resolved by the caller, not here, and deliberately so: geographic resolution can fail (unknown
|
||||||
* against `^[0-9]{1,3}$`. A stored value that is already a code passes through; anything else
|
* or ambiguous address) and that failure must happen **before** an EIMS counter is reserved, so a
|
||||||
* must be in this map or the mapping **fails locally** — sending a guessed region code onto a
|
* bad company address never burns a sequence number. See `mor-location.resolver.ts` for why the
|
||||||
* tax document is worse than refusing to file.
|
* lookup has to be hierarchical, and `EimsInvoiceRegistrationService` for where it runs.
|
||||||
*/
|
*/
|
||||||
buyerRegionCodes: Record<string, string>;
|
buyerGeo: MorGeoCodes;
|
||||||
/**
|
|
||||||
* Wereda name → MoR code, same shape as `buyerRegionCodes`. `companies.woreda` holds names
|
|
||||||
* ("Yeka") or codes inconsistently; unlike Region, MoR has never named a Wereda regex in an
|
|
||||||
* error, so this is precautionary rather than confirmed — but the fix is identical either way:
|
|
||||||
* fail locally on an unmapped name rather than file a guess.
|
|
||||||
*/
|
|
||||||
buyerWeredaCodes: Record<string, string>;
|
|
||||||
/**
|
|
||||||
* Buyer *zone* name → MoR City code. `Company` has no dedicated city column; Zone is the
|
|
||||||
* closest match in EDR's own data. Unlike Region/Wereda, City is optional — MoR has already
|
|
||||||
* accepted a live filing with it null — so an unmapped zone resolves to null, it does not fail
|
|
||||||
* the mapping.
|
|
||||||
*/
|
|
||||||
buyerCityCodes: Record<string, string>;
|
|
||||||
buyerIdType?: string | null;
|
buyerIdType?: string | null;
|
||||||
buyerIdNumber?: string | null;
|
buyerIdNumber?: string | null;
|
||||||
/** Required when the invoice currency is not ETB. */
|
/** Required when the invoice currency is not ETB. */
|
||||||
@@ -273,14 +255,6 @@ export interface EimsMapperContext {
|
|||||||
formatDate?: (issuedAt: Date) => string;
|
formatDate?: (issuedAt: Date) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* MoR's own constraint on `Region`: one to three digits, confirmed by its 400 SCHEMA ERROR. Reused
|
|
||||||
* as the pass-through test for `Wereda` too — every Wereda value MoR has actually shown us (seller
|
|
||||||
* "12"/"13", the collection's "574") fits the same shape, though MoR has not named a Wereda regex
|
|
||||||
* the way it named Region's.
|
|
||||||
*/
|
|
||||||
const LOCATION_CODE = /^[0-9]{1,3}$/;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The only two values MoR accepts for `NatureOfSupplies`, lowercase.
|
* The only two values MoR accepts for `NatureOfSupplies`, lowercase.
|
||||||
*
|
*
|
||||||
@@ -309,87 +283,6 @@ export const formatEimsDate = (issuedAt: Date): string =>
|
|||||||
* an unissued invoice, unresolved line tax, a line/total mismatch, or a non-ETB invoice with no
|
* an unissued invoice, unresolved line tax, a line/total mismatch, or a non-ETB invoice with no
|
||||||
* exchange rate.
|
* exchange rate.
|
||||||
*/
|
*/
|
||||||
/**
|
|
||||||
* A buyer's location value (Region, Wereda or City) as a MoR code: passed through when already
|
|
||||||
* numeric, otherwise looked up by name (case- and space-insensitive).
|
|
||||||
*
|
|
||||||
* Region/Wereda are required: an unmapped value throws — sending a guessed code onto a tax
|
|
||||||
* document is worse than refusing to file. City is optional (`required: false`, City's own
|
|
||||||
* caller) — MoR has already accepted a live filing with it null, so an unmapped zone resolves to
|
|
||||||
* null instead of blocking the invoice.
|
|
||||||
*/
|
|
||||||
function resolveLocationCode(
|
|
||||||
field: "Region" | "Wereda" | "City",
|
|
||||||
value: string | null | undefined,
|
|
||||||
codes: Record<string, string>,
|
|
||||||
envVar: string,
|
|
||||||
invoiceNumber: string,
|
|
||||||
opts: { required?: boolean } = {},
|
|
||||||
): string | null {
|
|
||||||
const raw = (value ?? "").trim();
|
|
||||||
if (LOCATION_CODE.test(raw)) return raw;
|
|
||||||
|
|
||||||
const key = raw.toLowerCase().replace(/\s+/g, " ");
|
|
||||||
const mapped = Object.entries(codes).find(
|
|
||||||
([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key,
|
|
||||||
)?.[1];
|
|
||||||
if (mapped && LOCATION_CODE.test(mapped)) return mapped;
|
|
||||||
|
|
||||||
if (opts.required === false) return null;
|
|
||||||
|
|
||||||
throw new Error(
|
|
||||||
`EIMS mapping: invoice ${invoiceNumber} has buyer ${field} ${raw ? `"${raw}"` : "(unset)"}, ` +
|
|
||||||
`which is not a MoR ${field} code and has no mapping. Add it to ${envVar}.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A buyer's `Country` as a MoR code: looked up by name in `codes` first; when unmapped, applies
|
|
||||||
* `domesticFallback` only if the stored country is empty or "Ethiopia" (the DB column's default).
|
|
||||||
* A genuinely foreign, unmapped country throws rather than silently filing as Ethiopia — same
|
|
||||||
* "fail locally, don't guess" rule as `resolveLocationCode`, but never digit-validated: MoR's
|
|
||||||
* Country code format is unconfirmed, unlike Region/Wereda's proven `^[0-9]{1,3}$`.
|
|
||||||
*/
|
|
||||||
function resolveCountryCode(
|
|
||||||
country: string | null | undefined,
|
|
||||||
codes: Record<string, string>,
|
|
||||||
domesticFallback: string | null,
|
|
||||||
invoiceNumber: string,
|
|
||||||
): string | null {
|
|
||||||
const raw = (country ?? "").trim();
|
|
||||||
const key = raw.toLowerCase().replace(/\s+/g, " ");
|
|
||||||
const mapped = Object.entries(codes).find(
|
|
||||||
([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key,
|
|
||||||
)?.[1];
|
|
||||||
if (mapped) return mapped;
|
|
||||||
|
|
||||||
if ((!raw || key === "ethiopia") && domesticFallback) return domesticFallback;
|
|
||||||
|
|
||||||
throw new Error(
|
|
||||||
`EIMS mapping: invoice ${invoiceNumber} has buyer Country "${raw || "(unset)"}", which has no ` +
|
|
||||||
"MoR country code mapping. Add it to EIMS_BUYER_COUNTRY_CODES.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Same name-or-code resolution as `resolveLocationCode`, for a caller with no invoice to attach an
|
|
||||||
* error to and that must never throw — currently only `EimsSellerCacheService`, resolving
|
|
||||||
* e-Trade's region/zone/woreda *names* for EDR's own seller identity. Pass-through numeric code,
|
|
||||||
* name lookup, `undefined` on no match — the caller falls back to static config either way.
|
|
||||||
*/
|
|
||||||
export function resolveOptionalCode(
|
|
||||||
value: string | null | undefined,
|
|
||||||
codes: Record<string, string>,
|
|
||||||
): string | undefined {
|
|
||||||
const raw = (value ?? "").trim();
|
|
||||||
if (LOCATION_CODE.test(raw)) return raw;
|
|
||||||
const key = raw.toLowerCase().replace(/\s+/g, " ");
|
|
||||||
const mapped = Object.entries(codes).find(
|
|
||||||
([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key,
|
|
||||||
)?.[1];
|
|
||||||
return mapped && LOCATION_CODE.test(mapped) ? mapped : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toEimsInvoice(
|
export function toEimsInvoice(
|
||||||
invoice: EimsMapperInvoice,
|
invoice: EimsMapperInvoice,
|
||||||
seller: EimsSellerDetails,
|
seller: EimsSellerDetails,
|
||||||
@@ -511,46 +404,24 @@ export function toEimsInvoice(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
BuyerDetails: {
|
BuyerDetails: {
|
||||||
// No dedicated city column on Company — Zone is the closest match; optional (see
|
// Country/Region/City/Wereda are MoR location codes resolved from the Ministry's own
|
||||||
// resolveLocationCode's City comment).
|
// location master *before* this mapper ran, and before an EIMS counter was reserved — see
|
||||||
City: resolveLocationCode(
|
// EimsMapperContext.buyerGeo. `Zone` alongside them is the buyer's free-text zone name,
|
||||||
"City",
|
// which MoR takes as prose, not a code.
|
||||||
company.zone,
|
City: context.buyerGeo.City,
|
||||||
context.buyerCityCodes,
|
|
||||||
"EIMS_BUYER_CITY_CODES",
|
|
||||||
invoice.invoiceNumber,
|
|
||||||
{ required: false },
|
|
||||||
),
|
|
||||||
Email: company.email ?? null,
|
Email: company.email ?? null,
|
||||||
HouseNumber: company.houseNo ?? null,
|
HouseNumber: company.houseNo ?? null,
|
||||||
IdNumber: context.buyerIdNumber ?? null,
|
...(context.buyerIdNumber != null ? { IdNumber: context.buyerIdNumber } : {}),
|
||||||
IdType: context.buyerIdType ?? null,
|
...(context.buyerIdType != null ? { IdType: context.buyerIdType } : {}),
|
||||||
Tin: company.tin,
|
Tin: company.tin,
|
||||||
LegalName: company.name,
|
LegalName: company.name,
|
||||||
Phone: company.phone ?? null,
|
Phone: company.phone ?? null,
|
||||||
Region: resolveLocationCode(
|
Region: context.buyerGeo.Region,
|
||||||
"Region",
|
Country: context.buyerGeo.Country,
|
||||||
company.region,
|
|
||||||
context.buyerRegionCodes,
|
|
||||||
"EIMS_BUYER_REGION_CODES",
|
|
||||||
invoice.invoiceNumber,
|
|
||||||
),
|
|
||||||
Country: resolveCountryCode(
|
|
||||||
company.country,
|
|
||||||
context.buyerCountryCodes,
|
|
||||||
context.buyerCountryCode ?? null,
|
|
||||||
invoice.invoiceNumber,
|
|
||||||
),
|
|
||||||
Zone: company.zone ?? null,
|
Zone: company.zone ?? null,
|
||||||
Kebele: company.kebele ?? null,
|
Kebele: company.kebele ?? null,
|
||||||
VatNumber: company.vatNumber ?? null,
|
VatNumber: company.vatNumber ?? null,
|
||||||
Wereda: resolveLocationCode(
|
Wereda: context.buyerGeo.Wereda,
|
||||||
"Wereda",
|
|
||||||
company.woreda,
|
|
||||||
context.buyerWeredaCodes,
|
|
||||||
"EIMS_BUYER_WEREDA_CODES",
|
|
||||||
invoice.invoiceNumber,
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
DocumentDetails: {
|
DocumentDetails: {
|
||||||
DocumentNumber: context.documentNumber,
|
DocumentNumber: context.documentNumber,
|
||||||
|
|||||||
@@ -34,3 +34,43 @@ export function applySettlement(
|
|||||||
const balanceAmount = Math.max(0, round2(total - paidAmount));
|
const balanceAmount = Math.max(0, round2(total - paidAmount));
|
||||||
return { paidAmount, balanceAmount, fullyPaid: paidAmount >= total };
|
return { paidAmount, balanceAmount, fullyPaid: paidAmount >= total };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SQL for an invoice's settled payment method, normalised to one vocabulary.
|
||||||
|
*
|
||||||
|
* Two sources have to be merged: gateway settlements carry the real provider on
|
||||||
|
* the linked `freight.payments` row (`cbe-bill`, `telebirr`, …) while the
|
||||||
|
* invoice's own `payments` ledger only records a flat `"GATEWAY"`; manual
|
||||||
|
* settlements have no payments row at all and the ledger is the ONLY source
|
||||||
|
* (`BANK_TRANSFER`, `OFFLINE`, or whatever `PayInvoiceDto.method` carried).
|
||||||
|
* So: provider first, newest ledger entry as the fallback.
|
||||||
|
*
|
||||||
|
* `-> -1` is the last ledger element — the ledger is appended newest-last.
|
||||||
|
* `::text` is not cosmetic: `payments.method` is a real Postgres enum, and
|
||||||
|
* COALESCE against a text fallback fails without the cast.
|
||||||
|
*
|
||||||
|
* Normalised UPPER_SNAKE so `cbe-bill` and a hand-typed `CBE_BILL` are one
|
||||||
|
* value on screen, in the filter and in the export.
|
||||||
|
*/
|
||||||
|
export const invoicePaymentMethodExpr = (invoice: string, payment: string): string =>
|
||||||
|
`UPPER(REPLACE(COALESCE(${payment}.method::text, ${invoice}.payments -> -1 ->> 'method'), '-', '_'))`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The methods the filter offers. Not exhaustive by construction — the manual
|
||||||
|
* pay endpoint takes a free-form `method` string — so nothing validates against
|
||||||
|
* this list; it is the pick-list, not a constraint.
|
||||||
|
*/
|
||||||
|
export const INVOICE_PAYMENT_METHODS = [
|
||||||
|
"TELEBIRR",
|
||||||
|
"CBE_BIRR",
|
||||||
|
"CBE_BILL",
|
||||||
|
"EBIRR",
|
||||||
|
"WAAFI",
|
||||||
|
"CARD",
|
||||||
|
"DMONEY",
|
||||||
|
"CAC_BANK",
|
||||||
|
"BANK_TRANSFER",
|
||||||
|
"OFFLINE",
|
||||||
|
/** Settled at a gateway whose provider row is no longer linked. */
|
||||||
|
"GATEWAY",
|
||||||
|
] as const;
|
||||||
|
|||||||
@@ -26,9 +26,18 @@ import { Booking } from './entities/booking.entity';
|
|||||||
import { assertBookingStatus } from './booking-status.util';
|
import { assertBookingStatus } from './booking-status.util';
|
||||||
import { ContainerValidationService } from './container-validation.service';
|
import { ContainerValidationService } from './container-validation.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One physical container over its VGM limit. Weight limits are per container,
|
||||||
|
* so an overloaded box is reported (and billed) on its own tons above the
|
||||||
|
* limit — a lighter box on the same line never absorbs them.
|
||||||
|
*/
|
||||||
export interface OverweightLine {
|
export interface OverweightLine {
|
||||||
containerTypeCode: string;
|
containerTypeCode: string;
|
||||||
|
/** Container number when known, else "<code> #2" — identifies the box. */
|
||||||
|
containerLabel: string;
|
||||||
|
/** This container's VGM, not the line total. */
|
||||||
totalVgmTons: number;
|
totalVgmTons: number;
|
||||||
|
/** The per-container limit. */
|
||||||
maxAllowedTons: number;
|
maxAllowedTons: number;
|
||||||
excessTons: number;
|
excessTons: number;
|
||||||
}
|
}
|
||||||
@@ -250,9 +259,10 @@ export class BookingPricingService {
|
|||||||
clearanceBlocked.push(...clearance.blocked);
|
clearanceBlocked.push(...clearance.blocked);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Overweight detail for the customer: map the engine's per-line results back
|
// Overweight detail for the customer: one row per over-limit CONTAINER,
|
||||||
// to the booking's container lines (same order) for code + weights. maxAllowed
|
// mapped back to the booking's container lines (same order) for the code and
|
||||||
// is derived from the line total minus the excess the engine computed.
|
// the physical container numbers. maxAllowed is the per-container limit,
|
||||||
|
// recovered from that container's weight minus its own excess.
|
||||||
const overweightLines: OverweightLine[] = [];
|
const overweightLines: OverweightLine[] = [];
|
||||||
const containerLines = (booking.bookingContainers ?? []).filter(
|
const containerLines = (booking.bookingContainers ?? []).filter(
|
||||||
(bc) => bc.containerTypeId != null,
|
(bc) => bc.containerTypeId != null,
|
||||||
@@ -261,8 +271,6 @@ export class BookingPricingService {
|
|||||||
const wr = ruleResult.containerWeightResults[i];
|
const wr = ruleResult.containerWeightResults[i];
|
||||||
if (!wr?.isOverweight) continue;
|
if (!wr?.isOverweight) continue;
|
||||||
const line = containerLines[i];
|
const line = containerLines[i];
|
||||||
const totalVgmTons = Number(line?.totalVgmTons ?? 0);
|
|
||||||
const excessTons = Number(wr.overweightExcessTons ?? 0);
|
|
||||||
let code = line?.containerSize ?? '';
|
let code = line?.containerSize ?? '';
|
||||||
if (line?.containerTypeId) {
|
if (line?.containerTypeId) {
|
||||||
try {
|
try {
|
||||||
@@ -271,12 +279,32 @@ export class BookingPricingService {
|
|||||||
// fall back to the container size label
|
// fall back to the container size label
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
overweightLines.push({
|
const numbers = (line?.units ?? [])
|
||||||
containerTypeCode: code,
|
.slice()
|
||||||
totalVgmTons,
|
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
|
||||||
maxAllowedTons: Math.max(0, totalVgmTons - excessTons),
|
.map((u) => u.containerNumber);
|
||||||
excessTons,
|
// Legacy weight results carry no per-unit detail (a line total only) —
|
||||||
});
|
// report the line as a single row, as before.
|
||||||
|
const units = wr.overweightUnits?.length
|
||||||
|
? wr.overweightUnits
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
unitIndex: 0,
|
||||||
|
vgmTons: Number(line?.totalVgmTons ?? 0),
|
||||||
|
excessTons: Number(wr.overweightExcessTons ?? 0),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for (const u of units) {
|
||||||
|
overweightLines.push({
|
||||||
|
containerTypeCode: code,
|
||||||
|
containerLabel:
|
||||||
|
(u.unitIndex > 0 ? numbers[u.unitIndex - 1] : null) ||
|
||||||
|
(u.unitIndex > 0 ? `${code} #${u.unitIndex}` : code),
|
||||||
|
totalVgmTons: u.vgmTons,
|
||||||
|
maxAllowedTons: Math.max(0, u.vgmTons - u.excessTons),
|
||||||
|
excessTons: u.excessTons,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -346,6 +374,13 @@ export class BookingPricingService {
|
|||||||
quantity: qty,
|
quantity: qty,
|
||||||
vgmPerUnitTons: vgm,
|
vgmPerUnitTons: vgm,
|
||||||
totalVgmTons: qty * vgm,
|
totalVgmTons: qty * vgm,
|
||||||
|
// Real per-box weights when the booking recorded them: weight
|
||||||
|
// limits are per container, so 22/18/20t is 2t over on the first
|
||||||
|
// box even though the line total fits a 3x20t allowance.
|
||||||
|
unitVgmTons: (bc.units ?? [])
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
|
||||||
|
.map((u) => Number(u.vgmTons ?? 0)),
|
||||||
isReefer: ct.isReefer,
|
isReefer: ct.isReefer,
|
||||||
// Per-container opt-ins — PER_CONTAINER surcharges bill these.
|
// Per-container opt-ins — PER_CONTAINER surcharges bill these.
|
||||||
hazardousQuantity: Number(bc.hazardousQuantity ?? 0),
|
hazardousQuantity: Number(bc.hazardousQuantity ?? 0),
|
||||||
|
|||||||
@@ -440,7 +440,9 @@ export class BookingTransitionService {
|
|||||||
assertBookingStatus(booking, ["SELECTED_FOR_BATCH"]);
|
assertBookingStatus(booking, ["SELECTED_FOR_BATCH"]);
|
||||||
// Consolidated pair: the shared wagon dies with this hold. An unpaid
|
// Consolidated pair: the shared wagon dies with this hold. An unpaid
|
||||||
// partner's hold is released with it (both cancel, no fee); a PAID partner
|
// partner's hold is released with it (both cancel, no fee); a PAID partner
|
||||||
// keeps the whole wagon and this canceller owes the cancellation fee.
|
// cannot board alone, so the partnerLapsed listener cancels it too, with
|
||||||
|
// the cancellation fee — this unpaid canceller owes nothing (fees only
|
||||||
|
// apply to paid bookings).
|
||||||
const partnerId = booking.consolidationPartnerId;
|
const partnerId = booking.consolidationPartnerId;
|
||||||
if (partnerId) {
|
if (partnerId) {
|
||||||
const partner = await this.bookingsService.findById(partnerId);
|
const partner = await this.bookingsService.findById(partnerId);
|
||||||
@@ -452,7 +454,7 @@ export class BookingTransitionService {
|
|||||||
);
|
);
|
||||||
if (partnerPaid) {
|
if (partnerPaid) {
|
||||||
this.events.emit("booking.consolidation.partnerLapsed", {
|
this.events.emit("booking.consolidation.partnerLapsed", {
|
||||||
expiredBookingId: booking.id,
|
paidBookingId: partnerId,
|
||||||
});
|
});
|
||||||
} else if (!["CANCELLED", "EXPIRED"].includes(partner.status)) {
|
} else if (!["CANCELLED", "EXPIRED"].includes(partner.status)) {
|
||||||
const partnerReason = "Cancelled with its consolidation partner";
|
const partnerReason = "Cancelled with its consolidation partner";
|
||||||
@@ -592,10 +594,11 @@ export class BookingTransitionService {
|
|||||||
|
|
||||||
// Consolidated pair: a shared wagon never ships half-full, so cancelling
|
// Consolidated pair: a shared wagon never ships half-full, so cancelling
|
||||||
// one half settles the other too. Neither paid → both cancel, no fee. A
|
// one half settles the other too. Neither paid → both cancel, no fee. A
|
||||||
// PAID partner instead keeps the whole wagon and the unpaid canceller
|
// PAID partner cannot board alone, so the partnerLapsed listener cancels
|
||||||
// owes the cancellation fee (opened by the partnerLapsed listener). A
|
// it too, with the cancellation fee — the unpaid canceller owes nothing
|
||||||
// PAID booking itself never comes through here (status gate above) — it
|
// (fees only apply to paid bookings). A PAID booking itself never comes
|
||||||
// cancels via wagon cancellation, where the fee machinery lives.
|
// through here (status gate above) — it cancels via wagon cancellation,
|
||||||
|
// where the fee machinery lives.
|
||||||
const partnerId = booking.consolidationPartnerId;
|
const partnerId = booking.consolidationPartnerId;
|
||||||
if (partnerId) {
|
if (partnerId) {
|
||||||
const partner = await this.bookingsService.findById(partnerId);
|
const partner = await this.bookingsService.findById(partnerId);
|
||||||
@@ -607,7 +610,7 @@ export class BookingTransitionService {
|
|||||||
);
|
);
|
||||||
if (partnerPaid) {
|
if (partnerPaid) {
|
||||||
this.events.emit("booking.consolidation.partnerLapsed", {
|
this.events.emit("booking.consolidation.partnerLapsed", {
|
||||||
expiredBookingId: booking.id,
|
paidBookingId: partnerId,
|
||||||
});
|
});
|
||||||
} else if (!["CANCELLED", "EXPIRED"].includes(partner.status)) {
|
} else if (!["CANCELLED", "EXPIRED"].includes(partner.status)) {
|
||||||
const partnerReason = "Cancelled with its consolidation partner";
|
const partnerReason = "Cancelled with its consolidation partner";
|
||||||
|
|||||||
@@ -40,3 +40,70 @@ describe('BookingWagonCancellationService.resolveRequestedCut (bulk)', () => {
|
|||||||
expect(cut.weightTons).toBeCloseTo(62.625, 3);
|
expect(cut.weightTons).toBeCloseTo(62.625, 3);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Odd-20ft credit rebook: the rebooked booking shares a wagon again, so GL
|
||||||
|
* must pick the consolidation partner — no partner, no rebook; a partner
|
||||||
|
* already paired elsewhere is refused.
|
||||||
|
*/
|
||||||
|
describe('BookingWagonCancellationService.rebook (odd-20ft consolidation)', () => {
|
||||||
|
const units = Array.from({ length: 3 }, (_, i) => ({
|
||||||
|
containerSize: '20ft',
|
||||||
|
containerNumber: `CONT${i}`,
|
||||||
|
sealNumber: null,
|
||||||
|
vgmTons: 10,
|
||||||
|
isHazardous: false,
|
||||||
|
isReefer: false,
|
||||||
|
}));
|
||||||
|
const row = {
|
||||||
|
id: 'wc1',
|
||||||
|
bookingId: 'b1',
|
||||||
|
status: 'CREDIT_AVAILABLE',
|
||||||
|
creditAmount: 100,
|
||||||
|
cancelledQuantities: { bySize: { '20ft': 3 }, units },
|
||||||
|
};
|
||||||
|
const source = {
|
||||||
|
id: 'b1',
|
||||||
|
contractId: 'c1',
|
||||||
|
paymentCurrency: 'USD',
|
||||||
|
originYardId: 'y1',
|
||||||
|
destinationYardId: 'y2',
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeSvc = (partner?: unknown) => {
|
||||||
|
const svc = Object.create(BookingWagonCancellationService.prototype) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
> & {
|
||||||
|
rebook(id: string, dto: unknown): Promise<unknown>;
|
||||||
|
};
|
||||||
|
svc.repo = { findById: async () => row };
|
||||||
|
svc.bookingsRepository = {
|
||||||
|
findById: async () => source,
|
||||||
|
findByIdWithFiles: async () => partner ?? null,
|
||||||
|
};
|
||||||
|
return svc;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('refuses an odd-20ft rebook without a GL-picked partner', async () => {
|
||||||
|
await expect(
|
||||||
|
makeSvc().rebook('wc1', { scheduledDate: '2026-09-01' }),
|
||||||
|
).rejects.toThrow(/pick a consolidation partner/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a partner that already shares a wagon', async () => {
|
||||||
|
const paired = {
|
||||||
|
id: 'p1',
|
||||||
|
reference: 'BK-1',
|
||||||
|
status: 'SUBMITTED',
|
||||||
|
consolidationPartnerId: 'someone-else',
|
||||||
|
};
|
||||||
|
await expect(
|
||||||
|
makeSvc(paired).rebook('wc1', {
|
||||||
|
scheduledDate: '2026-09-01',
|
||||||
|
partnerBookingId: 'p1',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/already shares a wagon/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { OnEvent } from '@nestjs/event-emitter';
|
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
|
||||||
import { ExchangeService } from '@edr/api-common';
|
import { ExchangeService } from '@edr/api-common';
|
||||||
import { Freight, NotificationAudience, NotificationType } from '@edr/types';
|
import { Freight, NotificationAudience, NotificationType } from '@edr/types';
|
||||||
import { DataSource, EntityManager, In, IsNull } from 'typeorm';
|
import { DataSource, EntityManager, In, IsNull } from 'typeorm';
|
||||||
@@ -126,6 +126,7 @@ export class BookingWagonCancellationService {
|
|||||||
@Inject(forwardRef(() => FirstMileService))
|
@Inject(forwardRef(() => FirstMileService))
|
||||||
private readonly firstMile: FirstMileService,
|
private readonly firstMile: FirstMileService,
|
||||||
private readonly inbox: NotificationInboxService,
|
private readonly inbox: NotificationInboxService,
|
||||||
|
private readonly events: EventEmitter2,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ── T1: request ────────────────────────────────────────────────────────────
|
// ── T1: request ────────────────────────────────────────────────────────────
|
||||||
@@ -383,6 +384,9 @@ export class BookingWagonCancellationService {
|
|||||||
status: 'CANCELLED',
|
status: 'CANCELLED',
|
||||||
trainScheduleId: null,
|
trainScheduleId: null,
|
||||||
requestedTrainScheduleId: null,
|
requestedTrainScheduleId: null,
|
||||||
|
// A dead booking holds no shipment day — leaving it set lets the
|
||||||
|
// stranded-PAID day sweep pick the booking up and resurrect it.
|
||||||
|
scheduledDate: null,
|
||||||
});
|
});
|
||||||
await this.detachFromSchedule(b);
|
await this.detachFromSchedule(b);
|
||||||
}
|
}
|
||||||
@@ -538,38 +542,73 @@ export class BookingWagonCancellationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The batch engine expired an UNPAID booking whose consolidation partner had
|
* A consolidation pair broke with only one side PAID: the unpaid half
|
||||||
* already PAID: the paid partner keeps the whole wagon at no extra cost; the
|
* expired/cancelled fee-free (cancellation fees only ever apply to a paid
|
||||||
* lapsed side owes the cancellation fee on its own wagons — shared wagon
|
* booking), and the PAID half cannot board either — its odd 20ft has no
|
||||||
* included (ceil). Credit is 0 (nothing was paid); once the fee settles GL
|
* partner for the shared wagon. So the PAID booking is cancelled too, owing
|
||||||
* rebooks the customer through a normal new booking.
|
* the cancellation fee on ceil of its own fractional wagons (shared wagon
|
||||||
|
* included); its paid freight is kept as rebooking credit. Once the fee
|
||||||
|
* settles, GL staff rebook it through a normal new booking, where its odd
|
||||||
|
* 20ft goes through consolidation pairing again.
|
||||||
*/
|
*/
|
||||||
@OnEvent('booking.consolidation.partnerLapsed')
|
@OnEvent('booking.consolidation.partnerLapsed')
|
||||||
async onConsolidationPartnerLapsed(payload: {
|
async onConsolidationPartnerLapsed(payload: {
|
||||||
expiredBookingId: string;
|
paidBookingId: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const booking = await this.bookingsRepository.findById(
|
const booking = await this.bookingsRepository.findById(
|
||||||
payload.expiredBookingId,
|
payload.paidBookingId,
|
||||||
);
|
);
|
||||||
if (!booking) return;
|
if (!booking) return;
|
||||||
|
if (['CANCELLED', 'EXPIRED', 'COMPLETED'].includes(booking.status)) return;
|
||||||
if (await this.repo.findOpenForBooking(booking.id)) return; // already charged
|
if (await this.repo.findOpenForBooking(booking.id)) return; // already charged
|
||||||
const row = await this.openConsolidationBreak(
|
const row = await this.openConsolidationBreak(
|
||||||
booking,
|
booking,
|
||||||
'ceil',
|
'ceil',
|
||||||
0,
|
this.creditFor(booking, Number(booking.wagonsRequired ?? 0)),
|
||||||
'Expired while its consolidation partner had paid — cancellation fee applies',
|
'Consolidation partner lapsed unpaid — paired booking cancelled, cancellation fee applies',
|
||||||
);
|
);
|
||||||
if (row.status !== 'FEE_PENDING') return; // nothing owed
|
await this.dataSource.getRepository(Booking).update(booking.id, {
|
||||||
|
status: 'CANCELLED',
|
||||||
|
trainScheduleId: null,
|
||||||
|
requestedTrainScheduleId: null,
|
||||||
|
// A dead booking holds no shipment day — leaving it set lets the
|
||||||
|
// stranded-PAID day sweep pick the booking up and resurrect it.
|
||||||
|
scheduledDate: null,
|
||||||
|
});
|
||||||
|
await this.detachFromSchedule(booking);
|
||||||
this.notifyCustomer(
|
this.notifyCustomer(
|
||||||
booking,
|
booking,
|
||||||
'Cancellation fee due',
|
'Consolidated booking cancelled',
|
||||||
`${booking.reference} expired unpaid while sharing a wagon with a paid booking. A cancellation fee for ${Math.ceil(Number(row.wagonsCancelled))} wagon(s) has been invoiced — settle it before booking again.`,
|
`${booking.reference} shared a wagon with a booking that was never paid, so it cannot board and is cancelled. A cancellation fee for ${Math.ceil(Number(row.wagonsCancelled))} wagon(s) has been invoiced; your paid freight is kept as credit — settle the fee and EDR staff will rebook you.`,
|
||||||
|
);
|
||||||
|
this.notifyStaff(
|
||||||
|
booking,
|
||||||
|
'Consolidation partner lapsed — paid booking cancelled',
|
||||||
|
`${booking.reference}: its consolidation partner lapsed unpaid, so the paid booking is cancelled with a cancellation fee invoice. Rebook it from its credit once the fee settles (it must pair up again).`,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Consolidation-lapse fee failed for booking ${payload.expiredBookingId}: ${err instanceof Error ? err.message : String(err)}`,
|
`Consolidation-lapse cancellation failed for paid booking ${payload.paidBookingId}: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
);
|
);
|
||||||
|
// A silent failure here leaves a PAID half-wagon booking boarding alone
|
||||||
|
// (BK-2026-000201: no LIVE IMPORT 20ft CANCELLATION_FEE rate — the fee
|
||||||
|
// pricing threw and the booking stayed PAID). Scream to staff so it is
|
||||||
|
// fixed and the booking cancelled by hand instead of shipping.
|
||||||
|
try {
|
||||||
|
const failed = await this.bookingsRepository.findById(
|
||||||
|
payload.paidBookingId,
|
||||||
|
);
|
||||||
|
if (failed) {
|
||||||
|
this.notifyStaff(
|
||||||
|
failed,
|
||||||
|
'Consolidation-lapse cancellation FAILED — action needed',
|
||||||
|
`${failed.reference}: its consolidation partner lapsed unpaid, but the automatic cancellation failed: ${err instanceof Error ? err.message : String(err)}. Fix the cause (usually a missing LIVE per-wagon CANCELLATION_FEE rate for this trade direction + container size), then cancel the whole booking manually so the fee is invoiced and its wagons are freed.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Notification is best-effort — the error log above already fired.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -762,6 +801,26 @@ export class BookingWagonCancellationService {
|
|||||||
const createDto = this.buildRebookDto(row, dto.scheduledDate, dto.containers);
|
const createDto = this.buildRebookDto(row, dto.scheduledDate, dto.containers);
|
||||||
// Same currency as the source booking — the credit is in it.
|
// Same currency as the source booking — the credit is in it.
|
||||||
createDto.paymentCurrency = source.paymentCurrency ?? undefined;
|
createDto.paymentCurrency = source.paymentCurrency ?? undefined;
|
||||||
|
|
||||||
|
// An odd-20ft credit shares a wagon again on rebook. GL picks who — never
|
||||||
|
// the auto-matcher (it could claim a partner behind GL's back), so the
|
||||||
|
// create below runs with auto-consolidation off and the chosen partner is
|
||||||
|
// linked once the booking exists and is PAID.
|
||||||
|
const oddFt20 = this.creditFt20(row) % 2 === 1;
|
||||||
|
let partner: Booking | null = null;
|
||||||
|
if (oddFt20) {
|
||||||
|
createDto.skipAutoConsolidation = true;
|
||||||
|
if (!dto.partnerBookingId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'This credit carries an odd 20ft container — pick a consolidation partner booking to share its wagon (see the rebook-partners list).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
partner = await this.loadRebookPartner(
|
||||||
|
source,
|
||||||
|
dto.partnerBookingId,
|
||||||
|
dto.scheduledDate,
|
||||||
|
);
|
||||||
|
}
|
||||||
const created = await this.contractBooking.createUnderContract(
|
const created = await this.contractBooking.createUnderContract(
|
||||||
source.contractId,
|
source.contractId,
|
||||||
createDto,
|
createDto,
|
||||||
@@ -794,12 +853,19 @@ export class BookingWagonCancellationService {
|
|||||||
`First-mile accept failed for rebooked ${newBookingId}: ${err instanceof Error ? err.message : String(err)}`,
|
`First-mile accept failed for rebooked ${newBookingId}: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
try {
|
if (partner) {
|
||||||
await this.bookingBatch.ensurePaidBookingAllocated(newBookingId);
|
// Consolidated rebook: never allocate the half-wagon booking alone. It
|
||||||
} catch (err) {
|
// rides PAID and the batch engine settles the pair atomically once the
|
||||||
this.logger.error(
|
// partner's own invoice is paid.
|
||||||
`Allocation failed for rebooked ${newBookingId}: ${err instanceof Error ? err.message : String(err)}`,
|
await this.pairRebookedBooking(newBookingId, partner);
|
||||||
);
|
} else {
|
||||||
|
try {
|
||||||
|
await this.bookingBatch.ensurePaidBookingAllocated(newBookingId);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`Allocation failed for rebooked ${newBookingId}: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = (await this.repo.update(row.id, {
|
const updated = (await this.repo.update(row.id, {
|
||||||
@@ -817,6 +883,142 @@ export class BookingWagonCancellationService {
|
|||||||
return { cancellation: updated, bookingId: newBookingId };
|
return { cancellation: updated, bookingId: newBookingId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Total 20ft units the credit carries (odd ⇒ the rebook shares a wagon again). */
|
||||||
|
private creditFt20(row: BookingWagonCancellation): number {
|
||||||
|
return Object.entries(row.cancelledQuantities?.bySize ?? {})
|
||||||
|
.filter(([size]) => sizeFtOf(size) === 20)
|
||||||
|
.reduce((sum, [, qty]) => sum + Number(qty || 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Partner candidates for rebooking an odd-20ft credit — what the GL rebook
|
||||||
|
* form lists. Empty when the credit is even (no shared wagon) or spent.
|
||||||
|
*/
|
||||||
|
async rebookPartnerCandidates(
|
||||||
|
cancellationId: string,
|
||||||
|
scheduledDate: string,
|
||||||
|
): Promise<
|
||||||
|
Array<{
|
||||||
|
id: string;
|
||||||
|
reference: string;
|
||||||
|
companyName: string | null;
|
||||||
|
status: string;
|
||||||
|
scheduledDate: string | null;
|
||||||
|
ft20Quantity: number;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
const row = await this.mustFind(cancellationId);
|
||||||
|
if (row.status !== 'CREDIT_AVAILABLE') return [];
|
||||||
|
if (this.creditFt20(row) % 2 === 0) return [];
|
||||||
|
const source = await this.bookingsRepository.findById(row.bookingId);
|
||||||
|
if (!source) return [];
|
||||||
|
const rows = await this.bookingsRepository.findRebookConsolidationCandidates(
|
||||||
|
source,
|
||||||
|
new Date(scheduledDate),
|
||||||
|
);
|
||||||
|
return rows.map((b) => ({
|
||||||
|
id: b.id,
|
||||||
|
reference: b.reference,
|
||||||
|
companyName: b.company?.name ?? null,
|
||||||
|
status: b.status,
|
||||||
|
scheduledDate: b.scheduledDate ? b.scheduledDate.toISOString() : null,
|
||||||
|
ft20Quantity: (b.bookingContainers ?? [])
|
||||||
|
.filter((line) => Number(line.containerType?.sizeFt) === 20)
|
||||||
|
.reduce((sum, line) => sum + Number(line.quantity || 0), 0),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The GL-picked partner, validated to actually fit the rebooked shared wagon. */
|
||||||
|
private async loadRebookPartner(
|
||||||
|
source: Booking,
|
||||||
|
partnerId: string,
|
||||||
|
scheduledDate: string,
|
||||||
|
): Promise<Booking> {
|
||||||
|
const partner = await this.bookingsRepository.findByIdWithFiles(partnerId);
|
||||||
|
if (!partner) {
|
||||||
|
throw new NotFoundException(`Partner booking ${partnerId} not found.`);
|
||||||
|
}
|
||||||
|
if (partner.consolidationPartnerId) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Booking ${partner.reference} already shares a wagon with another booking.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!['SUBMITTED', 'PENDING_CONSOLIDATION'].includes(partner.status)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Booking ${partner.reference} cannot be consolidated (status ${partner.status}).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
partner.originYardId !== source.originYardId ||
|
||||||
|
partner.destinationYardId !== source.destinationYardId ||
|
||||||
|
partner.tradeDirection !== source.tradeDirection
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Booking ${partner.reference} rides a different route/direction — it cannot share a wagon with this rebooking.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const eatDay = (d: Date | string) =>
|
||||||
|
new Date(d).toLocaleDateString('en-CA', { timeZone: 'Africa/Addis_Ababa' });
|
||||||
|
if (!partner.scheduledDate || eatDay(partner.scheduledDate) !== eatDay(scheduledDate)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Booking ${partner.reference} is not booked for ${eatDay(scheduledDate)} — a shared wagon must board one train.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const ft20 = (partner.bookingContainers ?? [])
|
||||||
|
.filter((line) => Number(line.containerType?.sizeFt) === 20)
|
||||||
|
.reduce((sum, line) => sum + Number(line.quantity || 0), 0);
|
||||||
|
if (ft20 % 2 !== 1) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Booking ${partner.reference} has no odd 20ft container — nothing to consolidate.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return partner;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link the rebooked (already PAID) booking with the GL-picked partner. A
|
||||||
|
* parked partner is resumed the way pairConsolidation would resume it —
|
||||||
|
* but only the partner: the rebooked side's PAID status must survive, so
|
||||||
|
* the link is written directly. The paired event then runs the partner's
|
||||||
|
* deferred contract finalize (invoice → pay window); the shared wagon
|
||||||
|
* boards once that invoice is paid.
|
||||||
|
*/
|
||||||
|
private async pairRebookedBooking(
|
||||||
|
newBookingId: string,
|
||||||
|
partner: Booking,
|
||||||
|
): Promise<void> {
|
||||||
|
// ponytail: validate-then-link without a row lock — a concurrent claim in
|
||||||
|
// this window loses silently; move to pairConsolidationIfUnpaired-style
|
||||||
|
// locking if it ever bites.
|
||||||
|
const fresh = await this.dataSource.getRepository(Booking).findOne({
|
||||||
|
where: { id: partner.id },
|
||||||
|
select: { id: true, consolidationPartnerId: true, status: true },
|
||||||
|
});
|
||||||
|
if (!fresh || fresh.consolidationPartnerId) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Booking ${partner.reference} was claimed by another consolidation while rebooking — pick another partner.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (fresh.status === 'PENDING_CONSOLIDATION') {
|
||||||
|
await this.dataSource.getRepository(Booking).update(partner.id, {
|
||||||
|
status: partner.consolidationResumeStatus ?? 'SUBMITTED',
|
||||||
|
consolidationResumeStatus: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await this.bookingsRepository.linkConsolidationPartners(
|
||||||
|
newBookingId,
|
||||||
|
partner.id,
|
||||||
|
);
|
||||||
|
this.events.emit('booking.consolidation.paired', {
|
||||||
|
bookingIds: [partner.id],
|
||||||
|
});
|
||||||
|
this.notifyCustomer(
|
||||||
|
partner,
|
||||||
|
'Consolidation partner found',
|
||||||
|
`${partner.reference} now shares a wagon with a rebooked shipment. Pay your booking to board — the shared wagon ships once both halves are paid.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── History ────────────────────────────────────────────────────────────────
|
// ── History ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
list(filter: WagonCancellationListFilter) {
|
list(filter: WagonCancellationListFilter) {
|
||||||
|
|||||||
@@ -725,6 +725,30 @@ export class BookingsController {
|
|||||||
return this.wagonCancellationService.withdraw(cancellationId);
|
return this.wagonCancellationService.withdraw(cancellationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get("wagon-cancellations/:cancellationId/rebook-partners")
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Consolidation partner candidates for rebooking an odd-20ft credit on the given day (GL picks who shares the rebooked wagon)",
|
||||||
|
})
|
||||||
|
async listRebookPartners(
|
||||||
|
@Param("cancellationId", ParseUUIDPipe) cancellationId: string,
|
||||||
|
@Query("scheduledDate") scheduledDate: string,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
await this.assertWagonCancellationActor(
|
||||||
|
cancellationId,
|
||||||
|
user,
|
||||||
|
FREIGHT_PERMS.bookings.wagonCancellationRebook,
|
||||||
|
);
|
||||||
|
if (!scheduledDate) {
|
||||||
|
throw new BadRequestException("scheduledDate is required.");
|
||||||
|
}
|
||||||
|
return this.wagonCancellationService.rebookPartnerCandidates(
|
||||||
|
cancellationId,
|
||||||
|
scheduledDate,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Post("wagon-cancellations/:cancellationId/rebook")
|
@Post("wagon-cancellations/:cancellationId/rebook")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
@@ -1633,6 +1657,23 @@ export class BookingsController {
|
|||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(":id/clearance/draft-declaration/skip")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"GL ET skips the draft-declaration round: no estimate is sent to the customer, the real declaration is filed directly and duty & tax passes by default",
|
||||||
|
})
|
||||||
|
async skipBookingDraftDeclaration(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
const booking = await this.bookingClearanceService.skipDraftDeclaration(
|
||||||
|
id,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
|
}
|
||||||
|
|
||||||
@Post(":id/clearance/draft-declaration/accept")
|
@Post(":id/clearance/draft-declaration/accept")
|
||||||
@PortalCustomer()
|
@PortalCustomer()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
|
|||||||
@@ -377,6 +377,58 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Candidate partners for rebooking an odd-20ft cancellation credit: unpaired
|
||||||
|
* odd-20ft bookings on the same route/direction riding the requested day —
|
||||||
|
* SUBMITTED (committed direct booking) or parked PENDING_CONSOLIDATION.
|
||||||
|
* Unlike {@link findManualConsolidationCandidates} this is not customs-only:
|
||||||
|
* GL picks who shares the rebooked wagon whatever the contract kind.
|
||||||
|
*/
|
||||||
|
async findRebookConsolidationCandidates(
|
||||||
|
booking: Booking,
|
||||||
|
scheduledDate: Date,
|
||||||
|
limit = 50,
|
||||||
|
): Promise<Booking[]> {
|
||||||
|
const rows = await this.repository
|
||||||
|
.createQueryBuilder('b')
|
||||||
|
.leftJoinAndSelect('b.bookingContainers', 'bc')
|
||||||
|
.leftJoinAndSelect('bc.containerType', 'ct')
|
||||||
|
.leftJoinAndSelect('b.company', 'company')
|
||||||
|
.where('b.id != :bookingId', { bookingId: booking.id })
|
||||||
|
.andWhere('b.consolidationPartnerId IS NULL')
|
||||||
|
.andWhere('b.originYardId = :originYardId', {
|
||||||
|
originYardId: booking.originYardId,
|
||||||
|
})
|
||||||
|
.andWhere('b.destinationYardId = :destinationYardId', {
|
||||||
|
destinationYardId: booking.destinationYardId,
|
||||||
|
})
|
||||||
|
.andWhere('b.tradeDirection = :tradeDirection', {
|
||||||
|
tradeDirection: booking.tradeDirection,
|
||||||
|
})
|
||||||
|
.andWhere('b.status IN (:...statuses)', {
|
||||||
|
statuses: ['SUBMITTED', 'PENDING_CONSOLIDATION'],
|
||||||
|
})
|
||||||
|
// Same EAT booking day as the rebook — the pair shares one physical
|
||||||
|
// wagon, so it must board one train.
|
||||||
|
.andWhere(
|
||||||
|
`DATE(b.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = DATE(:bookingDate AT TIME ZONE 'Africa/Addis_Ababa')`,
|
||||||
|
{ bookingDate: scheduledDate },
|
||||||
|
)
|
||||||
|
.orderBy('b.createdAt', 'ASC')
|
||||||
|
.take(limit)
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
// Odd-20ft test in memory (two 20ft per wagon: odd + odd = whole wagons).
|
||||||
|
return rows.filter((row) => {
|
||||||
|
const lines = row.bookingContainers ?? [];
|
||||||
|
if (lines.length === 0) return false;
|
||||||
|
const ft20 = lines
|
||||||
|
.filter((line) => Number(line.containerType?.sizeFt) === 20)
|
||||||
|
.reduce((sum, line) => sum + Number(line.quantity || 0), 0);
|
||||||
|
return ft20 % 2 === 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find another booking whose container quantity complements this one to fill whole wagon(s)
|
* Find another booking whose container quantity complements this one to fill whole wagon(s)
|
||||||
* (same route, same container type, partial wagon on both sides). Only 20ft lines ever
|
* (same route, same container type, partial wagon on both sides). Only 20ft lines ever
|
||||||
|
|||||||
@@ -27,11 +27,15 @@ export class PriceLineItemDto {
|
|||||||
currency!: string;
|
currency!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One physical container over its per-container VGM limit. */
|
||||||
export class OverweightLineDto {
|
export class OverweightLineDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
containerTypeCode!: string;
|
containerTypeCode!: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty({ description: 'Container number, or "<code> #2" when unnumbered' })
|
||||||
|
containerLabel!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "This container's VGM in tons" })
|
||||||
totalVgmTons!: number;
|
totalVgmTons!: number;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
|
|||||||
@@ -121,6 +121,15 @@ export class RebookCancelledWagonsDto {
|
|||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
@Type(() => RebookContainerLineDto)
|
@Type(() => RebookContainerLineDto)
|
||||||
containers?: RebookContainerLineDto[];
|
containers?: RebookContainerLineDto[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'Required when the credit carries an odd 20ft count: the odd-20ft booking ' +
|
||||||
|
'GL picked to share the rebooked wagon (see the rebook-partners endpoint).',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
partnerBookingId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class FilterWagonCancellationsDto {
|
export class FilterWagonCancellationsDto {
|
||||||
|
|||||||
@@ -359,6 +359,12 @@ export class Booking extends BaseEntity {
|
|||||||
@Column({ name: 'customs_clearing_agent', type: 'varchar', length: 200, nullable: true })
|
@Column({ name: 'customs_clearing_agent', type: 'varchar', length: 200, nullable: true })
|
||||||
customsClearingAgent?: string | null;
|
customsClearingAgent?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'customs_clearing_agent_email', type: 'varchar', length: 200, nullable: true })
|
||||||
|
customsClearingAgentEmail?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'customs_clearing_agent_phone', type: 'varchar', length: 50, nullable: true })
|
||||||
|
customsClearingAgentPhone?: string | null;
|
||||||
|
|
||||||
@Column({ name: 'equipment_return', type: 'varchar', length: 20 })
|
@Column({ name: 'equipment_return', type: 'varchar', length: 20 })
|
||||||
equipmentReturn!: string;
|
equipmentReturn!: string;
|
||||||
|
|
||||||
@@ -411,6 +417,23 @@ export class Booking extends BaseEntity {
|
|||||||
@Column({ name: 'bulk_total_weight_tons', type: 'numeric', precision: 12, scale: 3, nullable: true })
|
@Column({ name: 'bulk_total_weight_tons', type: 'numeric', precision: 12, scale: 3, nullable: true })
|
||||||
bulkTotalWeightTons?: number | null;
|
bulkTotalWeightTons?: number | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NUMBER_OF_WAGONS bulk only: the wagon count the customer asked for at
|
||||||
|
* booking. Allocation and PER_WAGON pricing use this count verbatim, and the
|
||||||
|
* cargo weight spreads evenly across it (weight ÷ count per wagon — validated
|
||||||
|
* against wagon capacity at creation). Null for every other cargo unit.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'bulk_requested_wagons', type: 'int', nullable: true })
|
||||||
|
bulkRequestedWagons?: number | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NUMBER_OF_WAGONS bulk only: optional informational item count entered with
|
||||||
|
* the weight. Never prices or sizes anything (unlike PER_ITEM, where the
|
||||||
|
* count lives in cargoTotalWeightVgm).
|
||||||
|
*/
|
||||||
|
@Column({ name: 'bulk_item_count', type: 'int', nullable: true })
|
||||||
|
bulkItemCount?: number | null;
|
||||||
|
|
||||||
@Column({ name: 'is_hazardous', type: 'boolean', default: false })
|
@Column({ name: 'is_hazardous', type: 'boolean', default: false })
|
||||||
isHazardous!: boolean;
|
isHazardous!: boolean;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Controller, Get, Post, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { CurrentUser } from '@tria-plc/api-common/modules/auth/decorators/current-user.decorator';
|
import { CurrentUser } from '@tria-plc/api-common/modules/auth/decorators/current-user.decorator';
|
||||||
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
import { FreightJwtGuard } from '../../common/freight-jwt.guard';
|
||||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
|
|
||||||
import { ChatSync } from '../../common/booking-guards';
|
import { ChatSync } from '../../common/booking-guards';
|
||||||
@@ -18,7 +18,7 @@ export class ChatController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('sso')
|
@Get('sso')
|
||||||
@UseGuards(JwtGuard)
|
@UseGuards(FreightJwtGuard)
|
||||||
@ApiOperation({ summary: 'One-click sign-in link into EDR internal chat' })
|
@ApiOperation({ summary: 'One-click sign-in link into EDR internal chat' })
|
||||||
getSso(@CurrentUser() user: TCurrentUser) {
|
getSso(@CurrentUser() user: TCurrentUser) {
|
||||||
return this.sso.getSsoUrl(user);
|
return this.sso.getSsoUrl(user);
|
||||||
|
|||||||
@@ -34,15 +34,27 @@ describe('bulkTemplateCode', () => {
|
|||||||
expect(bulkTemplateCode('STEEL', 'INTERCITY', null)).toBe('BULK_INTERCITY_STEEL');
|
expect(bulkTemplateCode('STEEL', 'INTERCITY', null)).toBe('BULK_INTERCITY_STEEL');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('produces 5 distinct codes per cargo type', () => {
|
it('gives the Ethiopian-customs-only variant its own suffix', () => {
|
||||||
|
expect(bulkTemplateCode('STEEL', 'IMPORT', true, true)).toBe(
|
||||||
|
'BULK_IMPORT_STEEL_ETHIOPIAN_CUSTOMS',
|
||||||
|
);
|
||||||
|
// The flag is meaningless without customs clearing.
|
||||||
|
expect(bulkTemplateCode('STEEL', 'IMPORT', false, true)).toBe(
|
||||||
|
'BULK_IMPORT_STEEL_NO_CUSTOMS',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces 7 distinct codes per cargo type', () => {
|
||||||
const codes = [
|
const codes = [
|
||||||
bulkTemplateCode('STEEL', 'IMPORT', true),
|
bulkTemplateCode('STEEL', 'IMPORT', true),
|
||||||
|
bulkTemplateCode('STEEL', 'IMPORT', true, true),
|
||||||
bulkTemplateCode('STEEL', 'IMPORT', false),
|
bulkTemplateCode('STEEL', 'IMPORT', false),
|
||||||
bulkTemplateCode('STEEL', 'EXPORT', true),
|
bulkTemplateCode('STEEL', 'EXPORT', true),
|
||||||
|
bulkTemplateCode('STEEL', 'EXPORT', true, true),
|
||||||
bulkTemplateCode('STEEL', 'EXPORT', false),
|
bulkTemplateCode('STEEL', 'EXPORT', false),
|
||||||
bulkTemplateCode('STEEL', 'INTERCITY', null),
|
bulkTemplateCode('STEEL', 'INTERCITY', null),
|
||||||
];
|
];
|
||||||
expect(new Set(codes).size).toBe(5);
|
expect(new Set(codes).size).toBe(7);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -112,6 +124,33 @@ describe('ContractTemplatesService bulk create/resolve', () => {
|
|||||||
).rejects.toBeInstanceOf(BadRequestException);
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('creates the Ethiopian-customs-only variant alongside the full-customs one', async () => {
|
||||||
|
const { service } = build();
|
||||||
|
const created = await service.create({
|
||||||
|
cargoTypeId: 'cargo-1',
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
withCustoms: true,
|
||||||
|
ethiopianCustomsOnly: true,
|
||||||
|
});
|
||||||
|
expect(created.code).toBe('BULK_IMPORT_STEEL_ETHIOPIAN_CUSTOMS');
|
||||||
|
expect(created.ethiopianCustomsOnly).toBe(true);
|
||||||
|
expect(created.documentTitle).toBe(
|
||||||
|
'Steel Transportation and Ethiopian Customs Clearance Services',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects Ethiopian-customs-only without customs clearing', async () => {
|
||||||
|
const { service } = build();
|
||||||
|
await expect(
|
||||||
|
service.create({
|
||||||
|
cargoTypeId: 'cargo-1',
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
withCustoms: false,
|
||||||
|
ethiopianCustomsOnly: true,
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
it('resolves a domestic bulk contract to the intercity template, ignoring its customs flag', async () => {
|
it('resolves a domestic bulk contract to the intercity template, ignoring its customs flag', async () => {
|
||||||
const { repository, service } = build();
|
const { repository, service } = build();
|
||||||
await service.findActiveForContract('DOMESTIC', 'BULK', true, 'cargo-1');
|
await service.findActiveForContract('DOMESTIC', 'BULK', true, 'cargo-1');
|
||||||
@@ -119,6 +158,7 @@ describe('ContractTemplatesService bulk create/resolve', () => {
|
|||||||
'cargo-1',
|
'cargo-1',
|
||||||
'INTERCITY',
|
'INTERCITY',
|
||||||
null,
|
null,
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -129,6 +169,18 @@ describe('ContractTemplatesService bulk create/resolve', () => {
|
|||||||
'cargo-1',
|
'cargo-1',
|
||||||
'IMPORT',
|
'IMPORT',
|
||||||
false,
|
false,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves an Ethiopian-customs-only contract to the Ethiopian variant', async () => {
|
||||||
|
const { repository, service } = build();
|
||||||
|
await service.findActiveForContract('IMPORT', 'BULK', true, 'cargo-1', true);
|
||||||
|
expect(repository.findActiveBulkTemplate).toHaveBeenCalledWith(
|
||||||
|
'cargo-1',
|
||||||
|
'IMPORT',
|
||||||
|
true,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,22 @@ describe('contractTemplateCodeFor', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('resolves the Ethiopian variant only when customs clearing is enabled', () => {
|
||||||
|
expect(contractTemplateCodeFor('IMPORT', 'CONTAINER', true, true)).toBe(
|
||||||
|
'IMPORT_CONTAINER_ETHIOPIAN_CUSTOMS',
|
||||||
|
);
|
||||||
|
expect(contractTemplateCodeFor('EXPORT', 'BULK', true, true)).toBe(
|
||||||
|
'EXPORT_BULK_ETHIOPIAN_CUSTOMS',
|
||||||
|
);
|
||||||
|
// Without customs clearing the Ethiopian flag is meaningless.
|
||||||
|
expect(contractTemplateCodeFor('IMPORT', 'CONTAINER', false, true)).toBe(
|
||||||
|
'IMPORT_CONTAINER_NO_CUSTOMS',
|
||||||
|
);
|
||||||
|
expect(contractTemplateCodeFor('DOMESTIC', 'CONTAINER', true, true)).toBe(
|
||||||
|
'INTERCITY_CONTAINER',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('never gives intercity a customs variant — it crosses no border', () => {
|
it('never gives intercity a customs variant — it crosses no border', () => {
|
||||||
for (const flag of [true, false, null, undefined]) {
|
for (const flag of [true, false, null, undefined]) {
|
||||||
expect(contractTemplateCodeFor('DOMESTIC', 'BULK', flag)).toBe('INTERCITY_BULK');
|
expect(contractTemplateCodeFor('DOMESTIC', 'BULK', flag)).toBe('INTERCITY_BULK');
|
||||||
@@ -40,7 +56,11 @@ describe('contractTemplateCodeFor', () => {
|
|||||||
for (const d of directions) {
|
for (const d of directions) {
|
||||||
for (const f of freights) {
|
for (const f of freights) {
|
||||||
for (const c of [true, false]) {
|
for (const c of [true, false]) {
|
||||||
expect(CONTRACT_TEMPLATE_CODES).toContain(contractTemplateCodeFor(d, f, c));
|
for (const e of [true, false, undefined]) {
|
||||||
|
expect(CONTRACT_TEMPLATE_CODES).toContain(
|
||||||
|
contractTemplateCodeFor(d, f, c, e),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,9 +68,9 @@ describe('contractTemplateCodeFor', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('CONTRACT_TEMPLATE_DEFAULTS', () => {
|
describe('CONTRACT_TEMPLATE_DEFAULTS', () => {
|
||||||
it('seeds exactly the ten declared codes, once each', () => {
|
it('seeds exactly the fourteen declared codes, once each', () => {
|
||||||
const seeded = CONTRACT_TEMPLATE_DEFAULTS.map((t) => t.code).sort();
|
const seeded = CONTRACT_TEMPLATE_DEFAULTS.map((t) => t.code).sort();
|
||||||
expect(seeded).toHaveLength(10);
|
expect(seeded).toHaveLength(14);
|
||||||
expect(seeded).toEqual([...CONTRACT_TEMPLATE_CODES].sort());
|
expect(seeded).toEqual([...CONTRACT_TEMPLATE_CODES].sort());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { BaseRepository } from "@edr/api-common";
|
import { BaseRepository } from "@edr/api-common";
|
||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { InjectRepository } from "@nestjs/typeorm";
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
import { IsNull, Repository } from "typeorm";
|
import { Repository } from "typeorm";
|
||||||
|
|
||||||
import { CargoType } from "../rule-engine/entities/cargo-type.entity";
|
import { CargoType } from "../rule-engine/entities/cargo-type.entity";
|
||||||
import {
|
import {
|
||||||
@@ -33,10 +33,22 @@ export class ContractTemplatesRepository extends BaseRepository<ContractTemplate
|
|||||||
cargoTypeId: string,
|
cargoTypeId: string,
|
||||||
tradeDirection: BulkTemplateDirection,
|
tradeDirection: BulkTemplateDirection,
|
||||||
withCustoms: boolean | null,
|
withCustoms: boolean | null,
|
||||||
|
ethiopianCustomsOnly = false,
|
||||||
): Promise<ContractTemplate | null> {
|
): Promise<ContractTemplate | null> {
|
||||||
return this.repository.findOne({
|
return this.repository
|
||||||
where: { cargoTypeId, tradeDirection, withCustoms: withCustoms ?? IsNull() },
|
.createQueryBuilder("t")
|
||||||
});
|
.where("t.cargo_type_id = :cargoTypeId", { cargoTypeId })
|
||||||
|
.andWhere("t.trade_direction = :tradeDirection", { tradeDirection })
|
||||||
|
.andWhere(
|
||||||
|
withCustoms === null
|
||||||
|
? "t.with_customs IS NULL"
|
||||||
|
: "t.with_customs = :withCustoms",
|
||||||
|
withCustoms === null ? {} : { withCustoms },
|
||||||
|
)
|
||||||
|
.andWhere("COALESCE(t.ethiopian_customs_only, false) = :ethiopianCustomsOnly", {
|
||||||
|
ethiopianCustomsOnly,
|
||||||
|
})
|
||||||
|
.getOne();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,6 +61,7 @@ export class ContractTemplatesRepository extends BaseRepository<ContractTemplate
|
|||||||
cargoTypeId: string,
|
cargoTypeId: string,
|
||||||
tradeDirection: BulkTemplateDirection,
|
tradeDirection: BulkTemplateDirection,
|
||||||
withCustoms: boolean | null,
|
withCustoms: boolean | null,
|
||||||
|
ethiopianCustomsOnly = false,
|
||||||
): Promise<ContractTemplate | null> {
|
): Promise<ContractTemplate | null> {
|
||||||
return this.repository
|
return this.repository
|
||||||
.createQueryBuilder("t")
|
.createQueryBuilder("t")
|
||||||
@@ -60,6 +73,9 @@ export class ContractTemplatesRepository extends BaseRepository<ContractTemplate
|
|||||||
: "t.with_customs = :withCustoms",
|
: "t.with_customs = :withCustoms",
|
||||||
withCustoms === null ? {} : { withCustoms },
|
withCustoms === null ? {} : { withCustoms },
|
||||||
)
|
)
|
||||||
|
.andWhere("COALESCE(t.ethiopian_customs_only, false) = :ethiopianCustomsOnly", {
|
||||||
|
ethiopianCustomsOnly,
|
||||||
|
})
|
||||||
.andWhere(
|
.andWhere(
|
||||||
`(t.cargo_type_id = :cargoTypeId OR t.cargo_type_id = (
|
`(t.cargo_type_id = :cargoTypeId OR t.cargo_type_id = (
|
||||||
SELECT c.parent_group_id FROM freight.cargo_types c
|
SELECT c.parent_group_id FROM freight.cargo_types c
|
||||||
|
|||||||
@@ -41,13 +41,17 @@ import {
|
|||||||
*/
|
*/
|
||||||
const PREVIEW_TEMPLATE_KEYS: Record<ContractTemplateCode, string> = {
|
const PREVIEW_TEMPLATE_KEYS: Record<ContractTemplateCode, string> = {
|
||||||
IMPORT_BULK_CUSTOMS: "IMP_BULK_USD_FORWARDING",
|
IMPORT_BULK_CUSTOMS: "IMP_BULK_USD_FORWARDING",
|
||||||
|
IMPORT_BULK_ETHIOPIAN_CUSTOMS: "IMP_BULK_USD_FORWARDING",
|
||||||
IMPORT_BULK_NO_CUSTOMS: "IMP_BULK_USD_TRANSPORT_ONLY",
|
IMPORT_BULK_NO_CUSTOMS: "IMP_BULK_USD_TRANSPORT_ONLY",
|
||||||
EXPORT_BULK_CUSTOMS: "EXP_BULK_USD_FORWARDING",
|
EXPORT_BULK_CUSTOMS: "EXP_BULK_USD_FORWARDING",
|
||||||
|
EXPORT_BULK_ETHIOPIAN_CUSTOMS: "EXP_BULK_USD_FORWARDING",
|
||||||
EXPORT_BULK_NO_CUSTOMS: "EXP_BULK_USD_TRANSPORT_ONLY",
|
EXPORT_BULK_NO_CUSTOMS: "EXP_BULK_USD_TRANSPORT_ONLY",
|
||||||
INTERCITY_BULK: "DOM_BULK_USD_TRANSPORT_ONLY",
|
INTERCITY_BULK: "DOM_BULK_USD_TRANSPORT_ONLY",
|
||||||
IMPORT_CONTAINER_CUSTOMS: "IMP_CON_USD_FORWARDING",
|
IMPORT_CONTAINER_CUSTOMS: "IMP_CON_USD_FORWARDING",
|
||||||
|
IMPORT_CONTAINER_ETHIOPIAN_CUSTOMS: "IMP_CON_USD_FORWARDING",
|
||||||
IMPORT_CONTAINER_NO_CUSTOMS: "IMP_CON_USD_TRANSPORT_ONLY",
|
IMPORT_CONTAINER_NO_CUSTOMS: "IMP_CON_USD_TRANSPORT_ONLY",
|
||||||
EXPORT_CONTAINER_CUSTOMS: "EXP_CON_USD_FORWARDING",
|
EXPORT_CONTAINER_CUSTOMS: "EXP_CON_USD_FORWARDING",
|
||||||
|
EXPORT_CONTAINER_ETHIOPIAN_CUSTOMS: "EXP_CON_USD_FORWARDING",
|
||||||
EXPORT_CONTAINER_NO_CUSTOMS: "EXP_CON_USD_TRANSPORT_ONLY",
|
EXPORT_CONTAINER_NO_CUSTOMS: "EXP_CON_USD_TRANSPORT_ONLY",
|
||||||
INTERCITY_CONTAINER: "DOM_CON_USD_TRANSPORT_ONLY",
|
INTERCITY_CONTAINER: "DOM_CON_USD_TRANSPORT_ONLY",
|
||||||
};
|
};
|
||||||
@@ -101,7 +105,7 @@ export class ContractTemplatesService {
|
|||||||
|
|
||||||
const direction = dto.tradeDirection;
|
const direction = dto.tradeDirection;
|
||||||
const intercity = direction === "INTERCITY";
|
const intercity = direction === "INTERCITY";
|
||||||
if (intercity && dto.withCustoms !== undefined) {
|
if (intercity && (dto.withCustoms !== undefined || dto.ethiopianCustomsOnly)) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Intercity contracts are domestic and cross no border — they have no customs clearing variant",
|
"Intercity contracts are domestic and cross no border — they have no customs clearing variant",
|
||||||
);
|
);
|
||||||
@@ -112,12 +116,24 @@ export class ContractTemplatesService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const withCustoms = intercity ? null : Boolean(dto.withCustoms);
|
const withCustoms = intercity ? null : Boolean(dto.withCustoms);
|
||||||
|
const ethiopianOnly = Boolean(dto.ethiopianCustomsOnly) && !intercity;
|
||||||
|
if (ethiopianOnly && !withCustoms) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Ethiopian-customs-only is a customs clearing variant — it requires withCustoms to be true",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const label = this.comboLabel(cargoType.cargoTypeName, direction, withCustoms);
|
const label = this.comboLabel(
|
||||||
|
cargoType.cargoTypeName,
|
||||||
|
direction,
|
||||||
|
withCustoms,
|
||||||
|
ethiopianOnly,
|
||||||
|
);
|
||||||
const existing = await this.repository.findByCargoCombo(
|
const existing = await this.repository.findByCargoCombo(
|
||||||
dto.cargoTypeId,
|
dto.cargoTypeId,
|
||||||
direction,
|
direction,
|
||||||
withCustoms,
|
withCustoms,
|
||||||
|
ethiopianOnly,
|
||||||
);
|
);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
throw new ConflictException(
|
throw new ConflictException(
|
||||||
@@ -126,11 +142,13 @@ export class ContractTemplatesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const template = new ContractTemplate();
|
const template = new ContractTemplate();
|
||||||
template.code = bulkTemplateCode(cargoType.code, direction, withCustoms);
|
template.code = bulkTemplateCode(cargoType.code, direction, withCustoms, ethiopianOnly);
|
||||||
template.name = dto.name ?? label;
|
template.name = dto.name ?? label;
|
||||||
template.description = dto.description ?? null;
|
template.description = dto.description ?? null;
|
||||||
template.documentTitle = withCustoms
|
template.documentTitle = withCustoms
|
||||||
? `${cargoType.cargoTypeName} Transportation and Customs Clearance Services`
|
? ethiopianOnly
|
||||||
|
? `${cargoType.cargoTypeName} Transportation and Ethiopian Customs Clearance Services`
|
||||||
|
: `${cargoType.cargoTypeName} Transportation and Customs Clearance Services`
|
||||||
: `${cargoType.cargoTypeName} Transportation Services`;
|
: `${cargoType.cargoTypeName} Transportation Services`;
|
||||||
template.whereasClauses = [];
|
template.whereasClauses = [];
|
||||||
template.articles = [];
|
template.articles = [];
|
||||||
@@ -138,6 +156,7 @@ export class ContractTemplatesService {
|
|||||||
template.cargoTypeId = cargoType.id;
|
template.cargoTypeId = cargoType.id;
|
||||||
template.tradeDirection = direction;
|
template.tradeDirection = direction;
|
||||||
template.withCustoms = withCustoms;
|
template.withCustoms = withCustoms;
|
||||||
|
template.ethiopianCustomsOnly = intercity ? null : ethiopianOnly;
|
||||||
template.isSystem = false;
|
template.isSystem = false;
|
||||||
try {
|
try {
|
||||||
return await this.repository.saveTemplate(template);
|
return await this.repository.saveTemplate(template);
|
||||||
@@ -157,18 +176,21 @@ export class ContractTemplatesService {
|
|||||||
cargoTypeName: string,
|
cargoTypeName: string,
|
||||||
direction: BulkTemplateDirection,
|
direction: BulkTemplateDirection,
|
||||||
withCustoms: boolean | null,
|
withCustoms: boolean | null,
|
||||||
|
ethiopianCustomsOnly = false,
|
||||||
): string {
|
): string {
|
||||||
const dir = direction.charAt(0) + direction.slice(1).toLowerCase();
|
const dir = direction.charAt(0) + direction.slice(1).toLowerCase();
|
||||||
const customs =
|
const customs =
|
||||||
withCustoms === null
|
withCustoms === null
|
||||||
? ""
|
? ""
|
||||||
: withCustoms
|
: withCustoms
|
||||||
? ", with customs clearing"
|
? ethiopianCustomsOnly
|
||||||
|
? ", with Ethiopian customs clearing only"
|
||||||
|
: ", with customs clearing"
|
||||||
: ", without customs clearing";
|
: ", without customs clearing";
|
||||||
return `${cargoTypeName} Bulk Contract (${dir}${customs})`;
|
return `${cargoTypeName} Bulk Contract (${dir}${customs})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bulk templates only — the five seeded container templates are permanent. */
|
/** Bulk templates only — the seeded container templates are permanent. */
|
||||||
async remove(code: string): Promise<void> {
|
async remove(code: string): Promise<void> {
|
||||||
const template = await this.getByCode(code);
|
const template = await this.getByCode(code);
|
||||||
if (template.isSystem) {
|
if (template.isSystem) {
|
||||||
@@ -193,6 +215,7 @@ export class ContractTemplatesService {
|
|||||||
freightType?: string | null,
|
freightType?: string | null,
|
||||||
customsClearingEnabled?: boolean | null,
|
customsClearingEnabled?: boolean | null,
|
||||||
cargoTypeId?: string | null,
|
cargoTypeId?: string | null,
|
||||||
|
ethiopianCustomsOnly?: boolean | null,
|
||||||
): Promise<ContractTemplate | null> {
|
): Promise<ContractTemplate | null> {
|
||||||
const isBulk = (freightType ?? "").toUpperCase().includes("BULK");
|
const isBulk = (freightType ?? "").toUpperCase().includes("BULK");
|
||||||
if (isBulk) {
|
if (isBulk) {
|
||||||
@@ -202,12 +225,16 @@ export class ContractTemplatesService {
|
|||||||
cargoTypeId,
|
cargoTypeId,
|
||||||
direction,
|
direction,
|
||||||
direction === "INTERCITY" ? null : Boolean(customsClearingEnabled),
|
direction === "INTERCITY" ? null : Boolean(customsClearingEnabled),
|
||||||
|
direction === "INTERCITY"
|
||||||
|
? false
|
||||||
|
: Boolean(customsClearingEnabled && ethiopianCustomsOnly),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const code = contractTemplateCodeFor(
|
const code = contractTemplateCodeFor(
|
||||||
tradeDirection,
|
tradeDirection,
|
||||||
freightType,
|
freightType,
|
||||||
customsClearingEnabled,
|
customsClearingEnabled,
|
||||||
|
ethiopianCustomsOnly,
|
||||||
);
|
);
|
||||||
const template = await this.repository.findByCode(code);
|
const template = await this.repository.findByCode(code);
|
||||||
return template?.isActive ? template : null;
|
return template?.isActive ? template : null;
|
||||||
|
|||||||
@@ -43,6 +43,14 @@ export class CreateContractTemplateDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
withCustoms?: boolean;
|
withCustoms?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
"Restricts the with-customs variant to Ethiopian-side clearing only (Djibouti stays with the Client). Requires withCustoms=true; rejected for INTERCITY",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
ethiopianCustomsOnly?: boolean;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: "Display name (derived from the cargo type when omitted)" })
|
@ApiPropertyOptional({ description: "Display name (derived from the cargo type when omitted)" })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
|||||||
import { CargoType } from "../../rule-engine/entities/cargo-type.entity";
|
import { CargoType } from "../../rule-engine/entities/cargo-type.entity";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The five seeded container templates (import/export split by customs
|
* The seeded container templates (import/export split by customs-clearing
|
||||||
* clearing; intercity is domestic, crosses no border, so it has a single
|
* option — full, Ethiopian-only, none; intercity is domestic, crosses no
|
||||||
* template). These are system rows: always present, never deletable.
|
* border, so it has a single template). These are system rows: always
|
||||||
|
* present, never deletable.
|
||||||
*
|
*
|
||||||
* Bulk templates are NOT seeded — staff create them per bulk cargo type
|
* Bulk templates are NOT seeded — staff create them per bulk cargo type
|
||||||
* (`cargoTypeId`), trade direction (`tradeDirection`) and customs option
|
* (`cargoTypeId`), trade direction (`tradeDirection`) and customs option
|
||||||
@@ -20,18 +21,24 @@ import { CargoType } from "../../rule-engine/entities/cargo-type.entity";
|
|||||||
*
|
*
|
||||||
* The `_CUSTOMS` variant is issued when the contract has customs clearing
|
* The `_CUSTOMS` variant is issued when the contract has customs clearing
|
||||||
* enabled (the Service Provider clears in Djibouti/Ethiopia on the Client's
|
* enabled (the Service Provider clears in Djibouti/Ethiopia on the Client's
|
||||||
* behalf); `_NO_CUSTOMS` is the transport-only paper, where the Client handles
|
* behalf); `_ETHIOPIAN_CUSTOMS` when the service type is Ethiopian-customs-only
|
||||||
|
* (the Service Provider clears the Ethiopian side only, Djibouti stays with the
|
||||||
|
* Client); `_NO_CUSTOMS` is the transport-only paper, where the Client handles
|
||||||
* its own declarations.
|
* its own declarations.
|
||||||
*/
|
*/
|
||||||
export const CONTRACT_TEMPLATE_CODES = [
|
export const CONTRACT_TEMPLATE_CODES = [
|
||||||
"IMPORT_BULK_CUSTOMS",
|
"IMPORT_BULK_CUSTOMS",
|
||||||
|
"IMPORT_BULK_ETHIOPIAN_CUSTOMS",
|
||||||
"IMPORT_BULK_NO_CUSTOMS",
|
"IMPORT_BULK_NO_CUSTOMS",
|
||||||
"EXPORT_BULK_CUSTOMS",
|
"EXPORT_BULK_CUSTOMS",
|
||||||
|
"EXPORT_BULK_ETHIOPIAN_CUSTOMS",
|
||||||
"EXPORT_BULK_NO_CUSTOMS",
|
"EXPORT_BULK_NO_CUSTOMS",
|
||||||
"INTERCITY_BULK",
|
"INTERCITY_BULK",
|
||||||
"IMPORT_CONTAINER_CUSTOMS",
|
"IMPORT_CONTAINER_CUSTOMS",
|
||||||
|
"IMPORT_CONTAINER_ETHIOPIAN_CUSTOMS",
|
||||||
"IMPORT_CONTAINER_NO_CUSTOMS",
|
"IMPORT_CONTAINER_NO_CUSTOMS",
|
||||||
"EXPORT_CONTAINER_CUSTOMS",
|
"EXPORT_CONTAINER_CUSTOMS",
|
||||||
|
"EXPORT_CONTAINER_ETHIOPIAN_CUSTOMS",
|
||||||
"EXPORT_CONTAINER_NO_CUSTOMS",
|
"EXPORT_CONTAINER_NO_CUSTOMS",
|
||||||
"INTERCITY_CONTAINER",
|
"INTERCITY_CONTAINER",
|
||||||
] as const;
|
] as const;
|
||||||
@@ -66,6 +73,7 @@ export function contractTemplateCodeFor(
|
|||||||
tradeDirection?: string | null,
|
tradeDirection?: string | null,
|
||||||
freightType?: string | null,
|
freightType?: string | null,
|
||||||
customsClearingEnabled?: boolean | null,
|
customsClearingEnabled?: boolean | null,
|
||||||
|
ethiopianCustomsOnly?: boolean | null,
|
||||||
): ContractTemplateCode {
|
): ContractTemplateCode {
|
||||||
const direction =
|
const direction =
|
||||||
tradeDirection === "IMPORT"
|
tradeDirection === "IMPORT"
|
||||||
@@ -78,7 +86,11 @@ export function contractTemplateCodeFor(
|
|||||||
if (direction === "INTERCITY") {
|
if (direction === "INTERCITY") {
|
||||||
return `INTERCITY_${freight}` as ContractTemplateCode;
|
return `INTERCITY_${freight}` as ContractTemplateCode;
|
||||||
}
|
}
|
||||||
const customs = customsClearingEnabled ? "CUSTOMS" : "NO_CUSTOMS";
|
const customs = customsClearingEnabled
|
||||||
|
? ethiopianCustomsOnly
|
||||||
|
? "ETHIOPIAN_CUSTOMS"
|
||||||
|
: "CUSTOMS"
|
||||||
|
: "NO_CUSTOMS";
|
||||||
return `${direction}_${freight}_${customs}` as ContractTemplateCode;
|
return `${direction}_${freight}_${customs}` as ContractTemplateCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,9 +119,16 @@ export function bulkTemplateCode(
|
|||||||
cargoCode: string,
|
cargoCode: string,
|
||||||
direction: BulkTemplateDirection,
|
direction: BulkTemplateDirection,
|
||||||
withCustoms: boolean | null,
|
withCustoms: boolean | null,
|
||||||
|
ethiopianCustomsOnly = false,
|
||||||
): string {
|
): string {
|
||||||
const suffix =
|
const suffix =
|
||||||
direction === "INTERCITY" ? "" : withCustoms ? "_CUSTOMS" : "_NO_CUSTOMS";
|
direction === "INTERCITY"
|
||||||
|
? ""
|
||||||
|
: withCustoms
|
||||||
|
? ethiopianCustomsOnly
|
||||||
|
? "_ETHIOPIAN_CUSTOMS"
|
||||||
|
: "_CUSTOMS"
|
||||||
|
: "_NO_CUSTOMS";
|
||||||
return `BULK_${direction}_${cargoCode}${suffix}`.toUpperCase();
|
return `BULK_${direction}_${cargoCode}${suffix}`.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,7 +181,15 @@ export class ContractTemplate extends BaseEntity {
|
|||||||
@Column({ name: "with_customs", type: "boolean", nullable: true })
|
@Column({ name: "with_customs", type: "boolean", nullable: true })
|
||||||
withCustoms?: boolean | null;
|
withCustoms?: boolean | null;
|
||||||
|
|
||||||
/** The five seeded container templates — cannot be deleted. */
|
/**
|
||||||
|
* Bulk templates only: the with-customs variant restricted to Ethiopian-side
|
||||||
|
* clearing (Djibouti stays with the Client). Only meaningful when
|
||||||
|
* `withCustoms` is true; null/false otherwise.
|
||||||
|
*/
|
||||||
|
@Column({ name: "ethiopian_customs_only", type: "boolean", nullable: true })
|
||||||
|
ethiopianCustomsOnly?: boolean | null;
|
||||||
|
|
||||||
|
/** The seeded container templates — cannot be deleted. */
|
||||||
@Column({ name: "is_system", type: "boolean", default: false })
|
@Column({ name: "is_system", type: "boolean", default: false })
|
||||||
isSystem!: boolean;
|
isSystem!: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -785,6 +785,50 @@ export class BookingClearanceService {
|
|||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GL Ethiopia skips the draft-declaration round entirely: the customer is
|
||||||
|
* not sent an estimate, staff file the real customs declaration directly.
|
||||||
|
* Duty & tax passes with it by default — there is no draft price to advise
|
||||||
|
* from. Advising duty later still works and overrides the skip (a skipped
|
||||||
|
* milestone is completed normally by adviseDuty).
|
||||||
|
*/
|
||||||
|
async skipDraftDeclaration(bookingId: string, userId?: string): Promise<Booking> {
|
||||||
|
const booking = await this.loadBooking(bookingId);
|
||||||
|
if (booking.tradeDirection !== 'IMPORT') {
|
||||||
|
throw new BadRequestException('Draft declaration applies only to import bookings.');
|
||||||
|
}
|
||||||
|
await this.milestoneService.ensureBookingMilestones(bookingId, 'IMPORT');
|
||||||
|
const milestones = await this.workflowService.listMilestonesForBooking(bookingId);
|
||||||
|
const uploaded = milestones.find((m) => m.milestoneCode === 'DRAFT_DECLARATION_UPLOADED');
|
||||||
|
if (uploaded?.status === 'COMPLETED') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'A draft declaration was already sent to the customer — it can no longer be skipped.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.workflowService.assertPriorCompleteForBooking(
|
||||||
|
bookingId,
|
||||||
|
'IMPORT',
|
||||||
|
'DRAFT_DECLARATION_UPLOADED',
|
||||||
|
);
|
||||||
|
await this.workflowService.skipMilestonesForBooking(bookingId, [
|
||||||
|
'DRAFT_DECLARATION_UPLOADED',
|
||||||
|
'DRAFT_DECLARATION_ACCEPTED',
|
||||||
|
]);
|
||||||
|
await this.workflowService.onDutySkippedForBooking(bookingId);
|
||||||
|
await this.bookingsRepository.update(bookingId, {
|
||||||
|
dutyRequired: false,
|
||||||
|
clearanceCurrentPhase: ContractDocPhase.GlEtOutput,
|
||||||
|
} as never);
|
||||||
|
await this.clearanceEvents.record({
|
||||||
|
bookingId,
|
||||||
|
action: 'DRAFT_DECLARATION_SKIPPED',
|
||||||
|
label:
|
||||||
|
'Skipped the draft declaration — filing the customs declaration directly (duty & tax passed by default)',
|
||||||
|
actorId: userId ?? null,
|
||||||
|
});
|
||||||
|
return this.bookingsService.findById(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The customer accepts the draft declaration — GL Ethiopia may now file the
|
* The customer accepts the draft declaration — GL Ethiopia may now file the
|
||||||
* real customs declaration.
|
* real customs declaration.
|
||||||
|
|||||||
@@ -227,3 +227,85 @@ describe('ContractBookingService — quantity-cap completion', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The customer's shipment request is the order: GL may not change its container
|
||||||
|
* sizes/quantities or billing currency at completion — only per-unit details.
|
||||||
|
*/
|
||||||
|
describe('ContractBookingService — shipment-request lock at completion', () => {
|
||||||
|
type WithAssert = {
|
||||||
|
assertMatchesShipmentRequest(
|
||||||
|
bookingId: string,
|
||||||
|
dto: {
|
||||||
|
paymentCurrency?: string;
|
||||||
|
containers?: Array<{ containerSize: string; quantity: number }>;
|
||||||
|
bulkLines?: Array<{ cargoWeightTons?: number }>;
|
||||||
|
},
|
||||||
|
): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const serviceWithRequest = (request: unknown): WithAssert => {
|
||||||
|
const svc = Object.create(ContractBookingService.prototype) as WithAssert & {
|
||||||
|
dataSource: unknown;
|
||||||
|
};
|
||||||
|
svc.dataSource = {
|
||||||
|
getRepository: () => ({ findOne: async () => request }),
|
||||||
|
};
|
||||||
|
return svc;
|
||||||
|
};
|
||||||
|
|
||||||
|
const request = {
|
||||||
|
paymentCurrency: 'USD',
|
||||||
|
requestedLines: {
|
||||||
|
containers: [
|
||||||
|
{ containerSize: '20ft', quantity: 2 },
|
||||||
|
{ containerSize: '40ft', quantity: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it('accepts the exact requested quantities and currency', async () => {
|
||||||
|
await expect(
|
||||||
|
serviceWithRequest(request).assertMatchesShipmentRequest('b1', {
|
||||||
|
paymentCurrency: 'USD',
|
||||||
|
containers: [
|
||||||
|
{ containerSize: '40ft', quantity: 1 },
|
||||||
|
{ containerSize: '20ft', quantity: 2 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects changed quantities', async () => {
|
||||||
|
await expect(
|
||||||
|
serviceWithRequest(request).assertMatchesShipmentRequest('b1', {
|
||||||
|
paymentCurrency: 'USD',
|
||||||
|
containers: [
|
||||||
|
{ containerSize: '20ft', quantity: 4 },
|
||||||
|
{ containerSize: '40ft', quantity: 1 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a changed billing currency', async () => {
|
||||||
|
await expect(
|
||||||
|
serviceWithRequest(request).assertMatchesShipmentRequest('b1', {
|
||||||
|
paymentCurrency: 'ETB',
|
||||||
|
containers: [
|
||||||
|
{ containerSize: '20ft', quantity: 2 },
|
||||||
|
{ containerSize: '40ft', quantity: 1 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op without a linked request', async () => {
|
||||||
|
await expect(
|
||||||
|
serviceWithRequest(null).assertMatchesShipmentRequest('b1', {
|
||||||
|
paymentCurrency: 'ETB',
|
||||||
|
containers: [{ containerSize: '20ft', quantity: 9 }],
|
||||||
|
}),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { OnEvent } from '@nestjs/event-emitter';
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
import { insertWithGeneratedReference } from '@edr/api-common';
|
import { insertWithGeneratedReference } from '@edr/api-common';
|
||||||
|
import { CargoUnitOfMeasure } from '@edr/types';
|
||||||
|
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
||||||
@@ -28,6 +29,7 @@ import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-s
|
|||||||
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
|
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
|
||||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||||
|
import { bulkTonsPerWagon } from '../train-scheduling/train-capacity.util';
|
||||||
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
||||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||||
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
||||||
@@ -36,6 +38,7 @@ import { CargoType } from '../rule-engine/entities/cargo-type.entity';
|
|||||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
import { hasFreightPermission } from '../../common/freight-permission.util';
|
import { hasFreightPermission } from '../../common/freight-permission.util';
|
||||||
|
|
||||||
|
import { BookingRequest } from './entities/booking-request.entity';
|
||||||
import { Contract } from './entities/contract.entity';
|
import { Contract } from './entities/contract.entity';
|
||||||
import { ContractRoute } from './entities/contract-route.entity';
|
import { ContractRoute } from './entities/contract-route.entity';
|
||||||
import {
|
import {
|
||||||
@@ -272,6 +275,8 @@ export class ContractBookingService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bulkFields = await this.resolveBulkCargoFields(contract, dto);
|
||||||
|
|
||||||
// Denormalize route/direction/freight onto the booking for the scheduling engine.
|
// Denormalize route/direction/freight onto the booking for the scheduling engine.
|
||||||
// Retry past a concurrent insert that grabbed the same BK sequence number.
|
// Retry past a concurrent insert that grabbed the same BK sequence number.
|
||||||
const booking = await insertWithGeneratedReference(
|
const booking = await insertWithGeneratedReference(
|
||||||
@@ -305,8 +310,7 @@ export class ContractBookingService {
|
|||||||
cargoFreeText: dto.cargoFreeText?.trim() || null,
|
cargoFreeText: dto.cargoFreeText?.trim() || null,
|
||||||
isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'),
|
isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'),
|
||||||
isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'),
|
isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'),
|
||||||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
...bulkFields,
|
||||||
bulkTotalWeightTons: this.resolveBulkWeightTons(dto),
|
|
||||||
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
|
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
|
||||||
firstMilePickupLat: contract.firstMilePickupLat ?? null,
|
firstMilePickupLat: contract.firstMilePickupLat ?? null,
|
||||||
firstMilePickupLng: contract.firstMilePickupLng ?? null,
|
firstMilePickupLng: contract.firstMilePickupLng ?? null,
|
||||||
@@ -395,6 +399,10 @@ export class ContractBookingService {
|
|||||||
if (
|
if (
|
||||||
withContainers &&
|
withContainers &&
|
||||||
freightType === 'CONTAINER' &&
|
freightType === 'CONTAINER' &&
|
||||||
|
// A rebooked cancellation credit carries `skipAutoConsolidation`: its
|
||||||
|
// shared-wagon partner is picked by GL in the rebook flow, so nothing may
|
||||||
|
// auto-claim (or park) it here behind GL's back.
|
||||||
|
!dto.skipAutoConsolidation &&
|
||||||
(await this.consolidationService.needsConsolidationFromBooking(
|
(await this.consolidationService.needsConsolidationFromBooking(
|
||||||
withContainers,
|
withContainers,
|
||||||
))
|
))
|
||||||
@@ -848,6 +856,35 @@ export class ContractBookingService {
|
|||||||
if (!dto.scheduledDate) {
|
if (!dto.scheduledDate) {
|
||||||
throw new BadRequestException('A binding shipment day is required');
|
throw new BadRequestException('A binding shipment day is required');
|
||||||
}
|
}
|
||||||
|
// Without-customs import/export: the customer's own clearing agent (name,
|
||||||
|
// email, phone) is captured per booking at completion. A resubmit may omit
|
||||||
|
// the fields and keep what the booking already stored. Customs contracts
|
||||||
|
// (GL clears) and intercity (no border) never collect an agent.
|
||||||
|
if (
|
||||||
|
!contract.customsClearingEnabled &&
|
||||||
|
contract.tradeDirection !== 'DOMESTIC'
|
||||||
|
) {
|
||||||
|
const agentName =
|
||||||
|
dto.customsClearingAgent?.trim() || booking.customsClearingAgent || null;
|
||||||
|
const agentEmail =
|
||||||
|
dto.customsClearingAgentEmail?.trim() ||
|
||||||
|
booking.customsClearingAgentEmail ||
|
||||||
|
null;
|
||||||
|
const agentPhone =
|
||||||
|
dto.customsClearingAgentPhone?.trim() ||
|
||||||
|
booking.customsClearingAgentPhone ||
|
||||||
|
null;
|
||||||
|
if (!agentName || !agentEmail || !agentPhone) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Customs clearing agent name, email and phone are required to complete this booking.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.bookingsRepository.update(booking.id, {
|
||||||
|
customsClearingAgent: agentName,
|
||||||
|
customsClearingAgentEmail: agentEmail,
|
||||||
|
customsClearingAgentPhone: agentPhone,
|
||||||
|
} as never);
|
||||||
|
}
|
||||||
// No expiry gate here on purpose: this booking was already initiated
|
// No expiry gate here on purpose: this booking was already initiated
|
||||||
// before the contract lapsed (createUnderContract/initiateUnderContract
|
// before the contract lapsed (createUnderContract/initiateUnderContract
|
||||||
// already checked expiry at start). Finishing an in-flight booking must
|
// already checked expiry at start). Finishing an in-flight booking must
|
||||||
@@ -863,6 +900,12 @@ export class ContractBookingService {
|
|||||||
direction: contract.tradeDirection ?? null,
|
direction: contract.tradeDirection ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The customer's shipment request is the order: sizes, quantities and
|
||||||
|
// billing currency are theirs — GL enters everything else. Both halves of a
|
||||||
|
// consolidated pair pass through here, so each is checked against its OWN
|
||||||
|
// request.
|
||||||
|
await this.assertMatchesShipmentRequest(booking.id, dto);
|
||||||
|
|
||||||
const freightType = contract.freightType;
|
const freightType = contract.freightType;
|
||||||
let hasCargo =
|
let hasCargo =
|
||||||
(booking.bookingContainers?.length ?? 0) > 0 ||
|
(booking.bookingContainers?.length ?? 0) > 0 ||
|
||||||
@@ -944,8 +987,7 @@ export class ContractBookingService {
|
|||||||
await this.bookingsRepository.update(booking.id, {
|
await this.bookingsRepository.update(booking.id, {
|
||||||
cargoTypeId: this.resolveCargoTypeId(contract, dto),
|
cargoTypeId: this.resolveCargoTypeId(contract, dto),
|
||||||
cargoFreeText: dto.cargoFreeText?.trim() || null,
|
cargoFreeText: dto.cargoFreeText?.trim() || null,
|
||||||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
...(await this.resolveBulkCargoFields(contract, dto)),
|
||||||
bulkTotalWeightTons: this.resolveBulkWeightTons(dto),
|
|
||||||
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
|
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
|
||||||
// Completion is where the cargo — and therefore the price — is fixed, so
|
// Completion is where the cargo — and therefore the price — is fixed, so
|
||||||
// it is also where the billing currency is chosen. A bare instance was
|
// it is also where the billing currency is chosen. A bare instance was
|
||||||
@@ -1052,6 +1094,72 @@ export class ContractBookingService {
|
|||||||
return { booking: completed, warnings };
|
return { booking: completed, warnings };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The linked shipment request (customs Path B) is the customer's order:
|
||||||
|
* container sizes + quantities and the billing currency are the customer's
|
||||||
|
* choices, and GL may not change them at completion — only per-unit details
|
||||||
|
* (numbers, seals, VGM, handling) are GL's to enter. No linked request, or a
|
||||||
|
* legacy request without lines/currency ⇒ nothing to enforce. Container lines
|
||||||
|
* are checked only when the payload restates cargo (a day-only resubmit keeps
|
||||||
|
* the already-validated persisted cargo).
|
||||||
|
*/
|
||||||
|
private async assertMatchesShipmentRequest(
|
||||||
|
bookingId: string,
|
||||||
|
dto: CreateBookingUnderContractDto,
|
||||||
|
): Promise<void> {
|
||||||
|
const request = await this.dataSource.getRepository(BookingRequest).findOne({
|
||||||
|
where: { createdBookingId: bookingId },
|
||||||
|
});
|
||||||
|
if (!request) return;
|
||||||
|
const lines = request.requestedLines ?? {};
|
||||||
|
|
||||||
|
if (request.paymentCurrency) {
|
||||||
|
if (dto.paymentCurrency && dto.paymentCurrency !== request.paymentCurrency) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`The customer chose ${request.paymentCurrency} on the shipment request — the billing currency cannot be changed.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
dto.paymentCurrency = request.paymentCurrency;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.containers?.length && lines.containers?.length) {
|
||||||
|
// Compare per size in ft ("20ft" vs "20FT"/"20" spellings must not differ).
|
||||||
|
const byFt = (rows: Array<{ containerSize: string; quantity: number }>) => {
|
||||||
|
const map = new Map<number, number>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const ft = parseInt(String(row.containerSize), 10);
|
||||||
|
map.set(ft, (map.get(ft) ?? 0) + Number(row.quantity || 0));
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
};
|
||||||
|
const requested = byFt(lines.containers);
|
||||||
|
const given = byFt(dto.containers);
|
||||||
|
const same =
|
||||||
|
requested.size === given.size &&
|
||||||
|
[...requested].every(([ft, qty]) => given.get(ft) === qty);
|
||||||
|
if (!same) {
|
||||||
|
const summary = [...requested]
|
||||||
|
.map(([ft, qty]) => `${qty} × ${ft}ft`)
|
||||||
|
.join(', ');
|
||||||
|
throw new BadRequestException(
|
||||||
|
`The customer requested exactly ${summary} — container sizes and quantities cannot be changed at completion.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.bulkLines?.length && lines.bulk?.cargoWeightTons != null) {
|
||||||
|
const givenTons = dto.bulkLines.reduce(
|
||||||
|
(sum, l) => sum + Number(l.cargoWeightTons || 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
if (givenTons !== Number(lines.bulk.cargoWeightTons)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`The customer requested ${lines.bulk.cargoWeightTons} tons on the shipment request — the bulk quantity cannot be changed at completion.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Search for a complementary partner for a parked-eligible drawdown, pair it or
|
* Search for a complementary partner for a parked-eligible drawdown, pair it or
|
||||||
* park it in PENDING_CONSOLIDATION with the resume status it should return to.
|
* park it in PENDING_CONSOLIDATION with the resume status it should return to.
|
||||||
@@ -1456,8 +1564,10 @@ export class ContractBookingService {
|
|||||||
return probe;
|
return probe;
|
||||||
}
|
}
|
||||||
|
|
||||||
probe.cargoTotalWeightVgm = this.resolveBulkTons(dto);
|
const bulkFields = await this.resolveBulkCargoFields(contract, dto);
|
||||||
probe.bulkTotalWeightTons = this.resolveBulkWeightTons(dto);
|
probe.cargoTotalWeightVgm = bulkFields.cargoTotalWeightVgm;
|
||||||
|
probe.bulkTotalWeightTons = bulkFields.bulkTotalWeightTons;
|
||||||
|
probe.bulkRequestedWagons = bulkFields.bulkRequestedWagons;
|
||||||
const cargoTypeId = this.resolveCargoTypeId(contract, dto);
|
const cargoTypeId = this.resolveCargoTypeId(contract, dto);
|
||||||
probe.cargoTypeId = cargoTypeId;
|
probe.cargoTypeId = cargoTypeId;
|
||||||
if (cargoTypeId) {
|
if (cargoTypeId) {
|
||||||
@@ -1861,6 +1971,107 @@ export class ContractBookingService {
|
|||||||
return tons > 0 ? tons : null;
|
return tons > 0 ? tons : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk cargo columns for the booking row, resolved against the commodity's
|
||||||
|
* unit of measure:
|
||||||
|
*
|
||||||
|
* - PER_TON: `cargoTotalWeightVgm` = tons (legacy behaviour).
|
||||||
|
* - PER_ITEM: `cargoTotalWeightVgm` = item count, real tonnage in
|
||||||
|
* `bulkTotalWeightTons` (legacy behaviour).
|
||||||
|
* - NUMBER_OF_WAGONS: `cargoTotalWeightVgm` = tons, and the payload must fix
|
||||||
|
* the wagon count (customer on the portal, GL in the backoffice). The
|
||||||
|
* count is validated so each wagon's even share (tons ÷ wagons) fits what
|
||||||
|
* one wagon of this cargo may carry; the optional item count is stored as
|
||||||
|
* information only and never prices or sizes anything.
|
||||||
|
*
|
||||||
|
* Container contracts (and payloads without bulk lines) pass through with
|
||||||
|
* the legacy zero/null values.
|
||||||
|
*/
|
||||||
|
private async resolveBulkCargoFields(
|
||||||
|
contract: Contract,
|
||||||
|
dto: CreateBookingUnderContractDto,
|
||||||
|
): Promise<{
|
||||||
|
cargoTotalWeightVgm: number;
|
||||||
|
bulkTotalWeightTons: number | null;
|
||||||
|
bulkRequestedWagons: number | null;
|
||||||
|
bulkItemCount: number | null;
|
||||||
|
}> {
|
||||||
|
const legacy = {
|
||||||
|
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||||||
|
bulkTotalWeightTons: this.resolveBulkWeightTons(dto),
|
||||||
|
bulkRequestedWagons: null as number | null,
|
||||||
|
bulkItemCount: null as number | null,
|
||||||
|
};
|
||||||
|
if (contract.freightType === 'CONTAINER' || !dto.bulkLines?.length) {
|
||||||
|
return legacy;
|
||||||
|
}
|
||||||
|
const cargoTypeId = this.resolveCargoTypeId(contract, dto);
|
||||||
|
if (!cargoTypeId) return legacy;
|
||||||
|
const cargoType = await this.dataSource.getRepository(CargoType).findOne({
|
||||||
|
where: { id: cargoTypeId },
|
||||||
|
relations: { wagonTypes: true },
|
||||||
|
});
|
||||||
|
if (cargoType?.unitOfMeasure !== CargoUnitOfMeasure.NumberOfWagons) {
|
||||||
|
return legacy;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tons = dto.bulkLines.reduce(
|
||||||
|
(sum, l) => sum + Number(l.cargoWeightTons ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const items = dto.bulkLines.reduce(
|
||||||
|
(sum, l) => sum + Number(l.itemCount ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const wagons = Math.floor(Number(dto.requestedWagons ?? 0));
|
||||||
|
if (!(wagons >= 1)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`${cargoType.cargoTypeName} is booked by wagons — enter the number of wagons needed.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!(tons > 0)) {
|
||||||
|
throw new BadRequestException('Cargo weight in tons is required.');
|
||||||
|
}
|
||||||
|
this.assertWagonShareFits(cargoType, tons, wagons);
|
||||||
|
return {
|
||||||
|
cargoTotalWeightVgm: tons,
|
||||||
|
bulkTotalWeightTons: null,
|
||||||
|
bulkRequestedWagons: wagons,
|
||||||
|
bulkItemCount: items > 0 ? Math.floor(items) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NUMBER_OF_WAGONS: block the booking outright when the even per-wagon share
|
||||||
|
* (tons ÷ requested wagons) is heavier than what ANY of the cargo's allowed
|
||||||
|
* wagon types may carry — 100T on 2 wagons is 50T each and fine on a 60T
|
||||||
|
* wagon, but 100T on 1 wagon can never ride. Cargo types with no wagon types
|
||||||
|
* configured skip the check (allocation falls back to the default rating).
|
||||||
|
*/
|
||||||
|
private assertWagonShareFits(
|
||||||
|
cargoType: CargoType,
|
||||||
|
tons: number,
|
||||||
|
wagons: number,
|
||||||
|
): void {
|
||||||
|
const allowed = (cargoType.wagonTypes ?? []).filter(
|
||||||
|
(wt) => Number(wt.capacityTons) > 0,
|
||||||
|
);
|
||||||
|
if (!allowed.length) return;
|
||||||
|
const maxPerWagon = Math.max(
|
||||||
|
...allowed.map((wt) =>
|
||||||
|
bulkTonsPerWagon(cargoType, wt.id, Number(wt.capacityTons)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const share = tons / wagons;
|
||||||
|
if (share > maxPerWagon) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`${tons} tons across ${wagons} wagon(s) loads ${round3(share)}T per wagon, ` +
|
||||||
|
`but a wagon of this cargo carries at most ${round3(maxPerWagon)}T — ` +
|
||||||
|
`request at least ${Math.ceil(tons / maxPerWagon)} wagons.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-line handling counts. Each physical container carries its own hazardous
|
* Per-line handling counts. Each physical container carries its own hazardous
|
||||||
* / reefer / return switch (entered next to its VGM), so the count is however
|
* / reefer / return switch (entered next to its VGM), so the count is however
|
||||||
@@ -2091,6 +2302,7 @@ export class ContractBookingService {
|
|||||||
): Promise<{
|
): Promise<{
|
||||||
overweightLines: Array<{
|
overweightLines: Array<{
|
||||||
containerTypeCode: string;
|
containerTypeCode: string;
|
||||||
|
containerLabel: string;
|
||||||
totalVgmTons: number;
|
totalVgmTons: number;
|
||||||
maxAllowedTons: number;
|
maxAllowedTons: number;
|
||||||
excessTons: number;
|
excessTons: number;
|
||||||
@@ -2183,8 +2395,7 @@ export class ContractBookingService {
|
|||||||
contractRouteId: route?.id ?? null,
|
contractRouteId: route?.id ?? null,
|
||||||
originYardId: route?.originYardId ?? null,
|
originYardId: route?.originYardId ?? null,
|
||||||
destinationYardId: route?.destinationYardId ?? null,
|
destinationYardId: route?.destinationYardId ?? null,
|
||||||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
...(await this.resolveBulkCargoFields(contract, dto)),
|
||||||
bulkTotalWeightTons: this.resolveBulkWeightTons(dto),
|
|
||||||
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
|
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
|
||||||
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
|
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
|
||||||
bookingContainers: resolved.map(({ line, ct, totalVgmTons }) =>
|
bookingContainers: resolved.map(({ line, ct, totalVgmTons }) =>
|
||||||
@@ -2200,6 +2411,12 @@ export class ContractBookingService {
|
|||||||
: 0,
|
: 0,
|
||||||
vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0,
|
vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0,
|
||||||
totalVgmTons,
|
totalVgmTons,
|
||||||
|
// Per-box weights drive the overweight check — the limit is per
|
||||||
|
// container, so a heavy box is billed even when the line total fits.
|
||||||
|
units: (line.units ?? []).map((u, idx) => ({
|
||||||
|
vgmTons: Number(u.vgmTons ?? 0),
|
||||||
|
sortOrder: idx,
|
||||||
|
})) as BookingContainer['units'],
|
||||||
wagonsRequired: Math.ceil(line.quantity * wagonsPerUnitForSize(ct.sizeFt)),
|
wagonsRequired: Math.ceil(line.quantity * wagonsPerUnitForSize(ct.sizeFt)),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -2233,6 +2450,7 @@ export class ContractBookingService {
|
|||||||
containerTypeId: ct.id,
|
containerTypeId: ct.id,
|
||||||
quantity: line.quantity,
|
quantity: line.quantity,
|
||||||
totalVgmTons,
|
totalVgmTons,
|
||||||
|
unitVgmTons: (line.units ?? []).map((u) => Number(u.vgmTons ?? 0)),
|
||||||
})),
|
})),
|
||||||
contract.tradeDirection,
|
contract.tradeDirection,
|
||||||
);
|
);
|
||||||
@@ -2312,7 +2530,12 @@ export class ContractBookingService {
|
|||||||
(s, u) => s + Number(u.vgmTons ?? 0),
|
(s, u) => s + Number(u.vgmTons ?? 0),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
return { containerTypeId: ct.id, quantity: line.quantity, totalVgmTons };
|
return {
|
||||||
|
containerTypeId: ct.id,
|
||||||
|
quantity: line.quantity,
|
||||||
|
totalVgmTons,
|
||||||
|
unitVgmTons: (line.units ?? []).map((u) => Number(u.vgmTons ?? 0)),
|
||||||
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
type ClearanceTrainState,
|
type ClearanceTrainState,
|
||||||
} from '@edr/types';
|
} from '@edr/types';
|
||||||
|
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
|
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
|
||||||
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
|
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
|
||||||
import { FilesService } from '../files/files.service';
|
import { FilesService } from '../files/files.service';
|
||||||
@@ -117,6 +119,18 @@ export interface ContractClearanceView {
|
|||||||
linkedBookingReviewNote?: string | null;
|
linkedBookingReviewNote?: string | null;
|
||||||
/** Shipment day the booking currently holds — the default when GL resubmits. */
|
/** Shipment day the booking currently holds — the default when GL resubmits. */
|
||||||
linkedBookingScheduledDate?: string | null;
|
linkedBookingScheduledDate?: string | null;
|
||||||
|
/**
|
||||||
|
* Open wagon-cancellation on a CANCELLED linked booking (consolidation
|
||||||
|
* partner lapsed, staff cut): FEE_PENDING = customer must pay the
|
||||||
|
* cancellation fee; CREDIT_AVAILABLE = fee settled, GL rebooks the credit.
|
||||||
|
*/
|
||||||
|
linkedBookingCancellation?: {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
wagonsCancelled: number;
|
||||||
|
creditAmount: number;
|
||||||
|
creditCurrency: string;
|
||||||
|
} | null;
|
||||||
dutyAdvice?: {
|
dutyAdvice?: {
|
||||||
amount: number;
|
amount: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
@@ -171,6 +185,7 @@ export class ContractClearanceService {
|
|||||||
private readonly glOperationsService: GlOperationsService,
|
private readonly glOperationsService: GlOperationsService,
|
||||||
private readonly notifier: ContractNotifierService,
|
private readonly notifier: ContractNotifierService,
|
||||||
private readonly transitAgentsService: TransitAgentsService,
|
private readonly transitAgentsService: TransitAgentsService,
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private isPhasedCustoms(contract: Contract): boolean {
|
private isPhasedCustoms(contract: Contract): boolean {
|
||||||
@@ -369,9 +384,27 @@ export class ContractClearanceService {
|
|||||||
// without a cycle row), so fall back to the contract's own live booking —
|
// without a cycle row), so fall back to the contract's own live booking —
|
||||||
// otherwise the clearance page sees no linked booking at all and cannot show
|
// otherwise the clearance page sees no linked booking at all and cannot show
|
||||||
// its status or the actions that depend on it.
|
// its status or the actions that depend on it.
|
||||||
const booking = cycle?.bookingId
|
let booking = cycle?.bookingId
|
||||||
? await this.bookingsService.findById(cycle.bookingId)
|
? await this.bookingsService.findById(cycle.bookingId)
|
||||||
: await this.contractsRepository.findLatestBookingForContract(contractId);
|
: await this.contractsRepository.findLatestBookingForContract(contractId);
|
||||||
|
// The fallback skips terminal bookings, but a CANCELLED one with an open
|
||||||
|
// wagon-cancellation still belongs on this page: the fee gate and the
|
||||||
|
// rebook-from-credit action live here. Surface the newest such booking.
|
||||||
|
if (!booking) {
|
||||||
|
const [open] = await this.dataSource.query<{ booking_id: string }[]>(
|
||||||
|
`SELECT c.booking_id
|
||||||
|
FROM freight.booking_wagon_cancellations c
|
||||||
|
JOIN freight.bookings b ON b.id = c.booking_id
|
||||||
|
WHERE b.contract_id = $1
|
||||||
|
AND b.status = 'CANCELLED'
|
||||||
|
AND c.status IN ('FEE_PENDING', 'CREDIT_AVAILABLE')
|
||||||
|
AND c.deleted_at IS NULL
|
||||||
|
ORDER BY c.created_at DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
[contractId],
|
||||||
|
);
|
||||||
|
if (open) booking = await this.bookingsService.findById(open.booking_id);
|
||||||
|
}
|
||||||
if (booking) {
|
if (booking) {
|
||||||
linkedBookingId = booking.id ?? null;
|
linkedBookingId = booking.id ?? null;
|
||||||
linkedBookingReference = booking.reference ?? null;
|
linkedBookingReference = booking.reference ?? null;
|
||||||
@@ -395,6 +428,40 @@ export class ContractClearanceService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A CANCELLED booking may carry an open wagon-cancellation (consolidation
|
||||||
|
// partner lapsed, staff cut): FEE_PENDING gates on the customer paying the
|
||||||
|
// cancellation fee; CREDIT_AVAILABLE lets GL rebook from the credit here.
|
||||||
|
let linkedBookingCancellation: {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
wagonsCancelled: number;
|
||||||
|
creditAmount: number;
|
||||||
|
creditCurrency: string;
|
||||||
|
} | null = null;
|
||||||
|
if (booking && linkedBookingStatus === 'CANCELLED') {
|
||||||
|
const [row] = await this.dataSource.query<
|
||||||
|
{ id: string; status: string; wagons_cancelled: string; credit_amount: string }[]
|
||||||
|
>(
|
||||||
|
`SELECT id, status, wagons_cancelled, credit_amount
|
||||||
|
FROM freight.booking_wagon_cancellations
|
||||||
|
WHERE booking_id = $1
|
||||||
|
AND status IN ('FEE_PENDING', 'CREDIT_AVAILABLE')
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
[booking.id],
|
||||||
|
);
|
||||||
|
if (row) {
|
||||||
|
linkedBookingCancellation = {
|
||||||
|
id: row.id,
|
||||||
|
status: row.status,
|
||||||
|
wagonsCancelled: Number(row.wagons_cancelled),
|
||||||
|
creditAmount: Number(row.credit_amount),
|
||||||
|
creditCurrency: booking.paymentCurrency ?? 'ETB',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
contractId,
|
contractId,
|
||||||
status: contract.status,
|
status: contract.status,
|
||||||
@@ -433,6 +500,7 @@ export class ContractClearanceService {
|
|||||||
linkedBookingStatus,
|
linkedBookingStatus,
|
||||||
linkedBookingReviewNote,
|
linkedBookingReviewNote,
|
||||||
linkedBookingScheduledDate,
|
linkedBookingScheduledDate,
|
||||||
|
linkedBookingCancellation,
|
||||||
dutyAdvice,
|
dutyAdvice,
|
||||||
dutyDispute,
|
dutyDispute,
|
||||||
transitAssignee,
|
transitAssignee,
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ describe('ContractClearanceService — duty dispute', () => {
|
|||||||
{} as never, // glOperationsService
|
{} as never, // glOperationsService
|
||||||
notifier as never,
|
notifier as never,
|
||||||
{} as never, // transitAgentsService
|
{} as never, // transitAgentsService
|
||||||
|
{} as never, // dataSource
|
||||||
);
|
);
|
||||||
build([
|
build([
|
||||||
milestone('DUTY_TAXES_ADVISED', 'COMPLETED'),
|
milestone('DUTY_TAXES_ADVISED', 'COMPLETED'),
|
||||||
|
|||||||
@@ -428,6 +428,8 @@ export class ContractTransitionService {
|
|||||||
contract.customsClearingEnabled,
|
contract.customsClearingEnabled,
|
||||||
// Bulk templates are keyed by the contract's cargo type.
|
// Bulk templates are keyed by the contract's cargo type.
|
||||||
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId,
|
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId,
|
||||||
|
// Ethiopian-customs-only service types resolve to the Ethiopian variant.
|
||||||
|
contract.serviceType?.includesEthiopianCustomsOnly,
|
||||||
);
|
);
|
||||||
if (!active) return null;
|
if (!active) return null;
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
IsArray,
|
IsArray,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsDateString,
|
IsDateString,
|
||||||
|
IsEmail,
|
||||||
IsIn,
|
IsIn,
|
||||||
IsInt,
|
IsInt,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
IsString,
|
IsString,
|
||||||
IsUUID,
|
IsUUID,
|
||||||
Matches,
|
Matches,
|
||||||
|
MaxLength,
|
||||||
Min,
|
Min,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
@@ -208,6 +210,19 @@ export class CreateBookingUnderContractDto {
|
|||||||
@Type(() => CreateBulkLineDto)
|
@Type(() => CreateBulkLineDto)
|
||||||
bulkLines?: CreateBulkLineDto[];
|
bulkLines?: CreateBulkLineDto[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
minimum: 1,
|
||||||
|
description:
|
||||||
|
'NUMBER_OF_WAGONS bulk cargo only: how many wagons the shipment needs. ' +
|
||||||
|
'The weight spreads evenly across them; a PER_WAGON rate bills this count. ' +
|
||||||
|
'Required when the cargo type is measured by wagons, ignored otherwise.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Transform(({ value }) => (value == null || value === '' ? undefined : Number(value)))
|
||||||
|
requestedWagons?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'What the containers carry — captured per booking (container freight).',
|
description: 'What the containers carry — captured per booking (container freight).',
|
||||||
})
|
})
|
||||||
@@ -215,6 +230,29 @@ export class CreateBookingUnderContractDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
cargoFreeText?: string;
|
cargoFreeText?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
maxLength: 200,
|
||||||
|
description:
|
||||||
|
'Customs clearing agent name. Required at completion of a without-customs ' +
|
||||||
|
'import/export booking (the service enforces it); ignored on customs contracts.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
customsClearingAgent?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ maxLength: 200, description: 'Customs clearing agent email.' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEmail()
|
||||||
|
@MaxLength(200)
|
||||||
|
customsClearingAgentEmail?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ maxLength: 50, description: 'Customs clearing agent phone number.' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
customsClearingAgentPhone?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -63,6 +63,12 @@ describe('shipment preview / created booking parity', () => {
|
|||||||
resolveShipmentEquipmentReturn: () => c.equipmentReturn,
|
resolveShipmentEquipmentReturn: () => c.equipmentReturn,
|
||||||
resolveBulkTons: () => 0,
|
resolveBulkTons: () => 0,
|
||||||
resolveBulkWeightTons: () => 0,
|
resolveBulkWeightTons: () => 0,
|
||||||
|
resolveBulkCargoFields: async () => ({
|
||||||
|
cargoTotalWeightVgm: 0,
|
||||||
|
bulkTotalWeightTons: null,
|
||||||
|
bulkRequestedWagons: null,
|
||||||
|
bulkItemCount: null,
|
||||||
|
}),
|
||||||
resolveContainerTypeForSize: async () => ({ id: 'ct40', sizeFt: 40 }),
|
resolveContainerTypeForSize: async () => ({ id: 'ct40', sizeFt: 40 }),
|
||||||
handlingCounts: () => ({
|
handlingCounts: () => ({
|
||||||
hazardousQuantity: 0,
|
hazardousQuantity: 0,
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ describe('ContractClearanceService — transit assignee', () => {
|
|||||||
{} as never,
|
{} as never,
|
||||||
notifier as never,
|
notifier as never,
|
||||||
transitAgentsService as never,
|
transitAgentsService as never,
|
||||||
|
{} as never, // dataSource
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
import { IsIn, IsNumber, IsOptional, IsString, Length } from "class-validator";
|
import { IsIn, IsNumber, IsOptional, IsString, Length } from "class-validator";
|
||||||
|
|
||||||
import { EIMS_MODE_OF_PAYMENT, EimsModeOfPayment } from "../eims-receipt.types";
|
import { EIMS_MODE_OF_PAYMENT, EimsModeOfPayment } from "../eims-receipt.types";
|
||||||
@@ -9,9 +9,15 @@ import { EIMS_MODE_OF_PAYMENT, EimsModeOfPayment } from "../eims-receipt.types";
|
|||||||
* guessed (payment method, collector, provider references — none of it is modelled on `Invoice`).
|
* guessed (payment method, collector, provider references — none of it is modelled on `Invoice`).
|
||||||
*/
|
*/
|
||||||
export class RegisterSalesReceiptDto {
|
export class RegisterSalesReceiptDto {
|
||||||
@ApiProperty({ enum: EIMS_MODE_OF_PAYMENT, description: "MoR's confirmed ModeOfPayment enum." })
|
@ApiPropertyOptional({
|
||||||
|
enum: EIMS_MODE_OF_PAYMENT,
|
||||||
|
description:
|
||||||
|
"MoR's confirmed ModeOfPayment enum. Optional when the invoice's recorded payment method " +
|
||||||
|
"maps unambiguously (CASH, CHEQUE, CPO, CARD, BANK_TRANSFER); otherwise required.",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
@IsIn(EIMS_MODE_OF_PAYMENT)
|
@IsIn(EIMS_MODE_OF_PAYMENT)
|
||||||
modeOfPayment!: EimsModeOfPayment;
|
modeOfPayment?: EimsModeOfPayment;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Defaults to "Payment received".' })
|
@ApiPropertyOptional({ description: 'Defaults to "Payment received".' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -36,9 +36,12 @@ const invoiceRow = (over: Partial<Invoice> = {}): Invoice =>
|
|||||||
tin: "0999930000",
|
tin: "0999930000",
|
||||||
vatNumber: "123475885858",
|
vatNumber: "123475885858",
|
||||||
phone: "0912345678",
|
phone: "0912345678",
|
||||||
region: "13",
|
// A real MoR address — Ethiopia / SOMALI / FAAFAN ZONE / JIJIGA, which the Ministry master
|
||||||
zone: "SHA",
|
// codes as 70 / 6 / 31 / 190. Spelled the way EDR and e-Trade actually store it, so the
|
||||||
woreda: "574",
|
// alias layer is exercised end to end rather than only in the resolver's own spec.
|
||||||
|
region: "Somali",
|
||||||
|
zone: "Fafen",
|
||||||
|
woreda: "Jigjiga",
|
||||||
kebele: "03",
|
kebele: "03",
|
||||||
houseNo: "NEW",
|
houseNo: "NEW",
|
||||||
country: "Ethiopia",
|
country: "Ethiopia",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialE
|
|||||||
|
|
||||||
import { EimsConfig } from "../../config/eims.config";
|
import { EimsConfig } from "../../config/eims.config";
|
||||||
import { Invoice } from "../billing/entities/invoice.entity";
|
import { Invoice } from "../billing/entities/invoice.entity";
|
||||||
|
import { MorGeoCodes, resolveMorGeo } from "../../config/mor-location.resolver";
|
||||||
import { EimsDocumentType, EimsMapperLine, toEimsInvoice } from "../billing/eims-invoice.mapper";
|
import { EimsDocumentType, EimsMapperLine, toEimsInvoice } from "../billing/eims-invoice.mapper";
|
||||||
import { sendCompanyChannels } from "../notifications/notify-company.util";
|
import { sendCompanyChannels } from "../notifications/notify-company.util";
|
||||||
import { NotificationsService } from "../notifications/notifications.service";
|
import { NotificationsService } from "../notifications/notifications.service";
|
||||||
@@ -32,6 +33,8 @@ interface BulkReservation {
|
|||||||
invoice: Invoice & { lines: EimsMapperLine[] };
|
invoice: Invoice & { lines: EimsMapperLine[] };
|
||||||
documentType: EimsDocumentType;
|
documentType: EimsDocumentType;
|
||||||
relatedDocument: string | null;
|
relatedDocument: string | null;
|
||||||
|
/** Resolved before this reservation existed — see the `prepared` pass in `bulkRegister`. */
|
||||||
|
buyerGeo: MorGeoCodes;
|
||||||
invoiceCounter: number;
|
invoiceCounter: number;
|
||||||
documentNumber: string;
|
documentNumber: string;
|
||||||
previousIrn: string;
|
previousIrn: string;
|
||||||
@@ -123,7 +126,16 @@ export class EimsBulkRegistrationService {
|
|||||||
}
|
}
|
||||||
relatedDocument = invoice.relatedInvoice.eimsIrn;
|
relatedDocument = invoice.relatedInvoice.eimsIrn;
|
||||||
}
|
}
|
||||||
return { invoice, documentType, relatedDocument };
|
// Same rule as the single-invoice path: buyer geography is resolved from the MoR location
|
||||||
|
// master before reserveBulk touches a counter, so one bad company address fails the whole
|
||||||
|
// batch locally instead of burning a block of EIMS sequence numbers.
|
||||||
|
const buyerGeo = resolveMorGeo({
|
||||||
|
country: invoice.company?.country,
|
||||||
|
region: invoice.company?.region,
|
||||||
|
zone: invoice.company?.zone,
|
||||||
|
woreda: invoice.company?.woreda,
|
||||||
|
});
|
||||||
|
return { invoice, documentType, relatedDocument, buyerGeo };
|
||||||
});
|
});
|
||||||
|
|
||||||
if (prepared.length === 0) {
|
if (prepared.length === 0) {
|
||||||
@@ -141,6 +153,7 @@ export class EimsBulkRegistrationService {
|
|||||||
r.invoice,
|
r.invoice,
|
||||||
this.sellerCache.getSellerDetails(cfg),
|
this.sellerCache.getSellerDetails(cfg),
|
||||||
buildEimsContext(cfg, {
|
buildEimsContext(cfg, {
|
||||||
|
buyerGeo: r.buyerGeo,
|
||||||
documentNumber: r.documentNumber,
|
documentNumber: r.documentNumber,
|
||||||
invoiceCounter: r.invoiceCounter,
|
invoiceCounter: r.invoiceCounter,
|
||||||
previousIrn: r.previousIrn,
|
previousIrn: r.previousIrn,
|
||||||
@@ -269,7 +282,12 @@ export class EimsBulkRegistrationService {
|
|||||||
|
|
||||||
/** TX1. Reserve a contiguous block of N counters, one per invoice, in the given order. */
|
/** TX1. Reserve a contiguous block of N counters, one per invoice, in the given order. */
|
||||||
private async reserveBulk(
|
private async reserveBulk(
|
||||||
prepared: Array<{ invoice: Invoice & { lines: EimsMapperLine[] }; documentType: EimsDocumentType; relatedDocument: string | null }>,
|
prepared: Array<{
|
||||||
|
invoice: Invoice & { lines: EimsMapperLine[] };
|
||||||
|
documentType: EimsDocumentType;
|
||||||
|
relatedDocument: string | null;
|
||||||
|
buyerGeo: MorGeoCodes;
|
||||||
|
}>,
|
||||||
systemNumber: string,
|
systemNumber: string,
|
||||||
placeholder: string,
|
placeholder: string,
|
||||||
): Promise<BulkReservation[]> {
|
): Promise<BulkReservation[]> {
|
||||||
@@ -302,7 +320,7 @@ export class EimsBulkRegistrationService {
|
|||||||
|
|
||||||
// Locked in the caller's given order — stable, avoids two concurrent bulk calls deadlocking
|
// Locked in the caller's given order — stable, avoids two concurrent bulk calls deadlocking
|
||||||
// on the opposite lock order.
|
// on the opposite lock order.
|
||||||
for (const { invoice, documentType, relatedDocument } of prepared) {
|
for (const { invoice, documentType, relatedDocument, buyerGeo } of prepared) {
|
||||||
const locked = await this.lockInvoice(manager, invoice.id);
|
const locked = await this.lockInvoice(manager, invoice.id);
|
||||||
const thisCounter = counter++;
|
const thisCounter = counter++;
|
||||||
const thisDocNumber = String(docNumber++);
|
const thisDocNumber = String(docNumber++);
|
||||||
@@ -322,6 +340,7 @@ export class EimsBulkRegistrationService {
|
|||||||
invoice: Object.assign(locked, { lines: invoice.lines }),
|
invoice: Object.assign(locked, { lines: invoice.lines }),
|
||||||
documentType,
|
documentType,
|
||||||
relatedDocument,
|
relatedDocument,
|
||||||
|
buyerGeo,
|
||||||
invoiceCounter: thisCounter,
|
invoiceCounter: thisCounter,
|
||||||
documentNumber: thisDocNumber,
|
documentNumber: thisDocNumber,
|
||||||
previousIrn: thisPreviousIrn,
|
previousIrn: thisPreviousIrn,
|
||||||
|
|||||||
@@ -66,7 +66,13 @@ describe("assertEimsInvoiceConfig — charge-type overrides", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("buildEimsContext — taxForLine", () => {
|
describe("buildEimsContext — taxForLine", () => {
|
||||||
const input = { documentNumber: "24", invoiceCounter: 7, previousIrn: "", session: SESSION };
|
const input = {
|
||||||
|
buyerGeo: { Country: "70", Region: "6", City: "31", Wereda: "190" },
|
||||||
|
documentNumber: "24",
|
||||||
|
invoiceCounter: 7,
|
||||||
|
previousIrn: "",
|
||||||
|
session: SESSION,
|
||||||
|
};
|
||||||
const line = (chargeType: string) => ({ chargeType, quantity: 1, unitRate: 100, amount: 100 });
|
const line = (chargeType: string) => ({ chargeType, quantity: 1, unitRate: 100, amount: 100 });
|
||||||
|
|
||||||
it("uses the per-chargeType override when one is configured", () => {
|
it("uses the per-chargeType override when one is configured", () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { BadRequestException } from "@nestjs/common";
|
import { BadRequestException } from "@nestjs/common";
|
||||||
import { EimsConfig } from "../../config/eims.config";
|
import { EimsConfig } from "../../config/eims.config";
|
||||||
|
import { MorGeoCodes } from "../../config/mor-location.resolver";
|
||||||
import { EimsSessionContext } from "./eims-auth.service";
|
import { EimsSessionContext } from "./eims-auth.service";
|
||||||
import {
|
import {
|
||||||
EimsMapperContext,
|
EimsMapperContext,
|
||||||
@@ -149,6 +150,12 @@ export function buildEimsSeller(config: EimsConfig): EimsSellerDetails {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface EimsContextInput {
|
export interface EimsContextInput {
|
||||||
|
/**
|
||||||
|
* The buyer's MoR location codes, resolved from the Ministry location master by
|
||||||
|
* `resolveMorGeo` **before** the caller reserved an EIMS counter — see
|
||||||
|
* `EimsMapperContext.buyerGeo`.
|
||||||
|
*/
|
||||||
|
buyerGeo: MorGeoCodes;
|
||||||
/** `DocumentDetails.DocumentNumber`. The caller decides its source. */
|
/** `DocumentDetails.DocumentNumber`. The caller decides its source. */
|
||||||
documentNumber: string;
|
documentNumber: string;
|
||||||
invoiceCounter: number;
|
invoiceCounter: number;
|
||||||
@@ -205,11 +212,7 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E
|
|||||||
unitDefault: invoice.unitDefault,
|
unitDefault: invoice.unitDefault,
|
||||||
incomeWithholdValue: invoice.incomeWithholdValue!,
|
incomeWithholdValue: invoice.incomeWithholdValue!,
|
||||||
transactionWithholdValue: invoice.transactionWithholdValue!,
|
transactionWithholdValue: invoice.transactionWithholdValue!,
|
||||||
buyerCountryCode: invoice.buyerCountryCode,
|
buyerGeo: input.buyerGeo,
|
||||||
buyerCountryCodes: invoice.buyerCountryCodes,
|
|
||||||
buyerRegionCodes: invoice.buyerRegionCodes,
|
|
||||||
buyerWeredaCodes: invoice.buyerWeredaCodes,
|
|
||||||
buyerCityCodes: invoice.buyerCityCodes,
|
|
||||||
// TEMPORARY — see EimsInvoiceConfig.buyerIdType.
|
// TEMPORARY — see EimsInvoiceConfig.buyerIdType.
|
||||||
buyerIdType: invoice.buyerIdType,
|
buyerIdType: invoice.buyerIdType,
|
||||||
buyerIdNumber: invoice.buyerIdNumber,
|
buyerIdNumber: invoice.buyerIdNumber,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { EimsClientService } from "./eims-client.service";
|
|||||||
import { EimsApiException, EimsConfigException } from "./eims.errors";
|
import { EimsApiException, EimsConfigException } from "./eims.errors";
|
||||||
import { buildEimsSeller } from "./eims-invoice-context";
|
import { buildEimsSeller } from "./eims-invoice-context";
|
||||||
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
|
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
|
||||||
|
import { ETradeService } from "../companies/services/etrade.service";
|
||||||
import { EimsSellerCacheService } from "./eims-seller-cache.service";
|
import { EimsSellerCacheService } from "./eims-seller-cache.service";
|
||||||
import { EimsSystemState } from "./entities/eims-system-state.entity";
|
import { EimsSystemState } from "./entities/eims-system-state.entity";
|
||||||
import { EimsInvoiceStatus } from "./eims-registration.types";
|
import { EimsInvoiceStatus } from "./eims-registration.types";
|
||||||
@@ -58,9 +59,12 @@ const invoiceRow = (over: Partial<Invoice> = {}): Invoice =>
|
|||||||
vatNumber: "123475885858",
|
vatNumber: "123475885858",
|
||||||
phone: "0912345678",
|
phone: "0912345678",
|
||||||
email: "buyer@abc.et",
|
email: "buyer@abc.et",
|
||||||
region: "13",
|
// A real MoR address — Ethiopia / SOMALI / FAAFAN ZONE / JIJIGA, which the Ministry master
|
||||||
zone: "SHA",
|
// codes as 70 / 6 / 31 / 190. Spelled the way EDR and e-Trade actually store it, so the
|
||||||
woreda: "574",
|
// alias layer is exercised end to end rather than only in the resolver's own spec.
|
||||||
|
region: "Somali",
|
||||||
|
zone: "Fafen",
|
||||||
|
woreda: "Jigjiga",
|
||||||
kebele: "03",
|
kebele: "03",
|
||||||
houseNo: "NEW",
|
houseNo: "NEW",
|
||||||
country: "Ethiopia",
|
country: "Ethiopia",
|
||||||
@@ -502,21 +506,23 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a mapper failure after reservation (e.g. unmapped buyer country) also releases the reservation", async () => {
|
it("a mapper failure after reservation still releases the reservation", async () => {
|
||||||
// Regression: toEimsInvoice/buildEimsContext used to sit outside the try/catch that calls
|
// Regression: toEimsInvoice/buildEimsContext used to sit outside the try/catch that calls
|
||||||
// settleFailure — a throw here left the reservation permanently orphaned (a real live incident:
|
// settleFailure — a throw here left the reservation permanently orphaned (a real live incident:
|
||||||
// 500 on register, then every subsequent attempt 409'd "already in flight" until manually
|
// 500 on register, then every subsequent attempt 409'd "already in flight" until manually
|
||||||
// resolved). This never reaches postSigned at all — the mapper throws before submit() is called.
|
// resolved). This never reaches postSigned at all — the mapper throws before submit() is called.
|
||||||
const db = new FakeDb([
|
//
|
||||||
invoiceRow({ company: { ...invoiceRow().company, country: "France" } as never }),
|
// The trigger used to be an unmapped buyer country. That can no longer get this far: geography
|
||||||
]);
|
// is resolved before the reservation now (see the test below). A line/total mismatch is a
|
||||||
|
// mapper-only failure that still reaches this point.
|
||||||
|
const db = new FakeDb([invoiceRow({ totalAmount: 999999 })]);
|
||||||
const postSigned = jest.fn();
|
const postSigned = jest.fn();
|
||||||
|
|
||||||
// The mapper throws a plain Error (it's a pure function, not a NestJS layer) — that's the
|
// The mapper throws a plain Error (it's a pure function, not a NestJS layer) — that's the
|
||||||
// point: settleFailure must treat *any* non-EimsApiException as pre-wire, not just its own
|
// point: settleFailure must treat *any* non-EimsApiException as pre-wire, not just its own
|
||||||
// known exception types.
|
// known exception types.
|
||||||
await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toThrow(
|
await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toThrow(
|
||||||
/no MoR country code mapping/,
|
/lines sum to/,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(postSigned).not.toHaveBeenCalled();
|
expect(postSigned).not.toHaveBeenCalled();
|
||||||
@@ -532,6 +538,69 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("an unmappable buyer address fails before a counter is ever reserved", async () => {
|
||||||
|
// The whole point of resolving geography ahead of reserve(): a company-record problem is a
|
||||||
|
// local data problem, and it must not cost an EIMS sequence number. Nothing about the invoice
|
||||||
|
// or the system state may change.
|
||||||
|
const db = new FakeDb([
|
||||||
|
invoiceRow({ company: { ...invoiceRow().company, woreda: "Nowhere" } as never }),
|
||||||
|
]);
|
||||||
|
const before = { ...db.state };
|
||||||
|
const postSigned = jest.fn();
|
||||||
|
|
||||||
|
await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toThrow(
|
||||||
|
/no MoR LOCALITY_DESC match/,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(postSigned).not.toHaveBeenCalled();
|
||||||
|
expect(db.state).toMatchObject({
|
||||||
|
nextInvoiceCounter: before.nextInvoiceCounter,
|
||||||
|
nextDocumentNumber: before.nextDocumentNumber,
|
||||||
|
inFlightInvoiceId: null,
|
||||||
|
});
|
||||||
|
expect(db.invoices.get(INVOICE_ID)).toMatchObject({
|
||||||
|
eimsStatus: EimsInvoiceStatus.NotSubmitted,
|
||||||
|
eimsInvoiceCounter: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("files the buyer's MoR codes, resolved from the location master with no e-Trade call", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
const postSigned = jest.fn().mockResolvedValue({ statusCode: 200, body: { irn: "IRN-1" } });
|
||||||
|
|
||||||
|
// The real seller cache, wired to an e-Trade mock that must never be reached: registration
|
||||||
|
// reads the company row EDR already stored, so filing stays deterministic and independent of
|
||||||
|
// e-Trade's availability. `refresh()` is deliberately not called — the cache stays empty and
|
||||||
|
// the seller falls back to static config, exactly as it does on a cold process.
|
||||||
|
const cfg = config();
|
||||||
|
const resolveCompanyData = jest.fn();
|
||||||
|
const sellerCache = new EimsSellerCacheService(
|
||||||
|
{ resolveCompanyData, extractRegistrationData: jest.fn() } as unknown as ETradeService,
|
||||||
|
{ get: () => cfg } as unknown as ConfigService,
|
||||||
|
);
|
||||||
|
|
||||||
|
const service = new EimsInvoiceRegistrationService(
|
||||||
|
db.asDataSource(),
|
||||||
|
{ get: () => cfg } as unknown as ConfigService,
|
||||||
|
{ postSigned, postBearer: jest.fn() } as unknown as EimsClientService,
|
||||||
|
{ getSessionContext: jest.fn().mockResolvedValue(SESSION) } as unknown as EimsAuthService,
|
||||||
|
{ notify: jest.fn().mockResolvedValue(undefined) } as unknown as NotificationInboxService,
|
||||||
|
{ directSend: jest.fn().mockResolvedValue(undefined) } as unknown as NotificationsService,
|
||||||
|
sellerCache,
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.registerInvoiceWithEims(INVOICE_ID);
|
||||||
|
|
||||||
|
const [, body] = postSigned.mock.calls[0];
|
||||||
|
expect(body.BuyerDetails).toMatchObject({
|
||||||
|
Country: "70",
|
||||||
|
Region: "6",
|
||||||
|
City: "31",
|
||||||
|
Wereda: "190",
|
||||||
|
});
|
||||||
|
expect(resolveCompanyData).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("treats a success response with no IRN as a failed registration", async () => {
|
it("treats a success response with no IRN as a failed registration", async () => {
|
||||||
const db = new FakeDb([invoiceRow()]);
|
const db = new FakeDb([invoiceRow()]);
|
||||||
const postSigned = jest.fn().mockResolvedValue({ statusCode: 200, body: { irn: "" } });
|
const postSigned = jest.fn().mockResolvedValue({ statusCode: 200, body: { irn: "" } });
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { EimsClientService } from "./eims-client.service";
|
|||||||
import { EimsApiException, EimsConfigException } from "./eims.errors";
|
import { EimsApiException, EimsConfigException } from "./eims.errors";
|
||||||
import { EimsSellerCacheService } from "./eims-seller-cache.service";
|
import { EimsSellerCacheService } from "./eims-seller-cache.service";
|
||||||
import { EimsSystemState } from "./entities/eims-system-state.entity";
|
import { EimsSystemState } from "./entities/eims-system-state.entity";
|
||||||
|
import { resolveMorGeo } from "../../config/mor-location.resolver";
|
||||||
import { assertEimsInvoiceConfig, buildEimsContext } from "./eims-invoice-context";
|
import { assertEimsInvoiceConfig, buildEimsContext } from "./eims-invoice-context";
|
||||||
import {
|
import {
|
||||||
EimsInvoiceError,
|
EimsInvoiceError,
|
||||||
@@ -116,6 +117,17 @@ export class EimsInvoiceRegistrationService {
|
|||||||
relatedDocument = invoice.relatedInvoice.eimsIrn;
|
relatedDocument = invoice.relatedInvoice.eimsIrn;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Buyer geography is resolved from the MoR location master *here*, ahead of the reservation:
|
||||||
|
// an unknown or ambiguous company address is a local data problem, and failing it after
|
||||||
|
// reserving would consume an EIMS sequence number for an invoice that was never filable. It
|
||||||
|
// needs no network access, so there is no reason for it to sit behind the login either.
|
||||||
|
const buyerGeo = resolveMorGeo({
|
||||||
|
country: invoice.company?.country,
|
||||||
|
region: invoice.company?.region,
|
||||||
|
zone: invoice.company?.zone,
|
||||||
|
woreda: invoice.company?.woreda,
|
||||||
|
});
|
||||||
|
|
||||||
// Authenticate before reserving: the source system comes from the token, and the state row is
|
// Authenticate before reserving: the source system comes from the token, and the state row is
|
||||||
// keyed by it. A login failure here costs nothing — no counter has been consumed yet.
|
// keyed by it. A login failure here costs nothing — no counter has been consumed yet.
|
||||||
const session = await this.auth.getSessionContext();
|
const session = await this.auth.getSessionContext();
|
||||||
@@ -135,6 +147,7 @@ export class EimsInvoiceRegistrationService {
|
|||||||
invoice,
|
invoice,
|
||||||
this.sellerCache.getSellerDetails(cfg),
|
this.sellerCache.getSellerDetails(cfg),
|
||||||
buildEimsContext(cfg, {
|
buildEimsContext(cfg, {
|
||||||
|
buyerGeo,
|
||||||
// Allocated from the system state, not our invoiceNumber: MoR validates DocumentNumber
|
// Allocated from the system state, not our invoiceNumber: MoR validates DocumentNumber
|
||||||
// against ^(0|[1-9][0-9]{0,8})$, which "INV-20260807-00006" can never satisfy.
|
// against ^(0|[1-9][0-9]{0,8})$, which "INV-20260807-00006" can never satisfy.
|
||||||
documentNumber: reservation.documentNumber,
|
documentNumber: reservation.documentNumber,
|
||||||
|
|||||||
@@ -116,6 +116,47 @@ describe("EimsReceiptService.registerSalesReceipt", () => {
|
|||||||
expect(receipt.qr).toBe("iVBORw0KGgo...");
|
expect(receipt.qr).toBe("iVBORw0KGgo...");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("derives mode/date/voucher/amount from the recorded manual payment", async () => {
|
||||||
|
const db = new FakeDb([
|
||||||
|
invoiceRow({
|
||||||
|
payments: [
|
||||||
|
{ amount: 4000, method: "CASH", reference: "CRV-000123", paidAt: "2026-08-20T09:00:00.000Z", metadata: null },
|
||||||
|
],
|
||||||
|
} as never),
|
||||||
|
]);
|
||||||
|
const postBearer = jest.fn().mockResolvedValue(okResponse());
|
||||||
|
|
||||||
|
await build(db, postBearer).registerSalesReceipt(INVOICE_ID, {} as never);
|
||||||
|
|
||||||
|
const request = postBearer.mock.calls[0][1];
|
||||||
|
expect(request.TransactionDetails.ModeOfPayment).toBe("CASH");
|
||||||
|
expect(request.ManualReceiptNumber).toBe("CRV-000123");
|
||||||
|
expect(request.ReceiptDate).toBe("2026-08-20T09:00:00.000Z");
|
||||||
|
expect(request.CollectedAmount).toBe(4000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts a gateway reference in TransactionNumber, never ManualReceiptNumber, and demands an explicit mode", async () => {
|
||||||
|
const db = new FakeDb([
|
||||||
|
invoiceRow({
|
||||||
|
payments: [
|
||||||
|
{ amount: 10000, method: "GATEWAY", reference: "txn-9f8e7d", paidAt: "2026-08-21T10:00:00.000Z", metadata: null },
|
||||||
|
],
|
||||||
|
} as never),
|
||||||
|
]);
|
||||||
|
const postBearer = jest.fn().mockResolvedValue(okResponse());
|
||||||
|
const service = build(db, postBearer);
|
||||||
|
|
||||||
|
// GATEWAY says nothing about the channel — deriving would guess a tax field.
|
||||||
|
await expect(service.registerSalesReceipt(INVOICE_ID, {} as never)).rejects.toThrow(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.registerSalesReceipt(INVOICE_ID, { modeOfPayment: "Card" } as never);
|
||||||
|
const request = postBearer.mock.calls[0][1];
|
||||||
|
expect(request.TransactionDetails.TransactionNumber).toBe("txn-9f8e7d");
|
||||||
|
expect(request.ManualReceiptNumber).not.toBe("txn-9f8e7d");
|
||||||
|
});
|
||||||
|
|
||||||
it("defaults PaymentCoverage to FULL when the invoice balance is 0, PARTIAL otherwise", async () => {
|
it("defaults PaymentCoverage to FULL when the invoice balance is 0, PARTIAL otherwise", async () => {
|
||||||
const db = new FakeDb([invoiceRow({ balanceAmount: 500 })]);
|
const db = new FakeDb([invoiceRow({ balanceAmount: 500 })]);
|
||||||
const postBearer = jest.fn().mockResolvedValue(okResponse());
|
const postBearer = jest.fn().mockResolvedValue(okResponse());
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import { EimsReceipt, EimsReceiptKind, EimsReceiptStatus } from "./entities/eims
|
|||||||
import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto";
|
import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto";
|
||||||
import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto";
|
import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto";
|
||||||
import {
|
import {
|
||||||
|
EIMS_MODE_OF_PAYMENT,
|
||||||
|
EimsModeOfPayment,
|
||||||
EimsReceiptResponse,
|
EimsReceiptResponse,
|
||||||
EimsSalesReceiptRequest,
|
EimsSalesReceiptRequest,
|
||||||
EimsWithholdReceiptRequest,
|
EimsWithholdReceiptRequest,
|
||||||
@@ -41,8 +43,11 @@ const DETERMINISTIC_KINDS = new Set(["SCHEMA_VALIDATION", "RULE_VALIDATION", "AU
|
|||||||
* double-submission guard the way `/v1/cancel` does ("IRN already Canceled."), so the same caution
|
* double-submission guard the way `/v1/cancel` does ("IRN already Canceled."), so the same caution
|
||||||
* applies as an unacknowledged registration: a human must check the MoR portal first.
|
* applies as an unacknowledged registration: a human must check the MoR portal first.
|
||||||
*
|
*
|
||||||
* Several request fields have no confirmed source in this codebase (payment method, collector,
|
* Sales receipts derive what the invoice's payment ledger actually records — amount, date,
|
||||||
* withholding rate/amount) and are never guessed — see the two DTOs.
|
* finance's voucher number (ManualReceiptNumber), gateway transaction id, and the payment mode
|
||||||
|
* where the ledger method maps unambiguously to MoR's enum. Fields with no recorded source
|
||||||
|
* (collector, withholding rate/amount, mobile-money modes) are still asked of the caller, never
|
||||||
|
* guessed — see the two DTOs.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EimsReceiptService {
|
export class EimsReceiptService {
|
||||||
@@ -68,7 +73,26 @@ export class EimsReceiptService {
|
|||||||
|
|
||||||
const session = await this.auth.getSessionContext();
|
const session = await this.auth.getSessionContext();
|
||||||
const receiptNumber = this.generateReceiptNumber(invoice);
|
const receiptNumber = this.generateReceiptNumber(invoice);
|
||||||
const collectedAmount = dto.collectedAmount ?? Number(invoice.paidAmount);
|
|
||||||
|
// The newest ledger entry is the payment this receipt vouches for. A gateway settlement's
|
||||||
|
// `reference` is the provider transaction id; a manual settlement's `reference` is finance's
|
||||||
|
// own voucher number (CRV) — that one belongs in ManualReceiptNumber so the registered
|
||||||
|
// receipt matches finance's books.
|
||||||
|
const lastPayment = invoice.payments?.length
|
||||||
|
? invoice.payments[invoice.payments.length - 1]
|
||||||
|
: null;
|
||||||
|
const isGateway = (lastPayment?.method ?? "").toUpperCase() === "GATEWAY";
|
||||||
|
const modeOfPayment = dto.modeOfPayment ?? deriveModeOfPayment(lastPayment?.method);
|
||||||
|
if (!modeOfPayment) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: "EIMS_MODE_OF_PAYMENT_REQUIRED",
|
||||||
|
message:
|
||||||
|
`Recorded payment method "${lastPayment?.method ?? "none"}" has no unambiguous MoR ` +
|
||||||
|
`ModeOfPayment — pass modeOfPayment (one of ${EIMS_MODE_OF_PAYMENT.join(", ")}).`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const collectedAmount =
|
||||||
|
dto.collectedAmount ?? (lastPayment ? lastPayment.amount : Number(invoice.paidAmount));
|
||||||
const balance = Number(invoice.balanceAmount);
|
const balance = Number(invoice.balanceAmount);
|
||||||
|
|
||||||
const request: EimsSalesReceiptRequest = {
|
const request: EimsSalesReceiptRequest = {
|
||||||
@@ -77,9 +101,9 @@ export class EimsReceiptService {
|
|||||||
Reason: dto.reason ?? "Payment received",
|
Reason: dto.reason ?? "Payment received",
|
||||||
// ISO-8601 UTC — the collection's saved example uses a "+03:00" offset instead; no schema
|
// ISO-8601 UTC — the collection's saved example uses a "+03:00" offset instead; no schema
|
||||||
// error for this field was ever observed to confirm which form MoR actually requires.
|
// error for this field was ever observed to confirm which form MoR actually requires.
|
||||||
ReceiptDate: new Date().toISOString(),
|
ReceiptDate: lastPayment?.paidAt ?? new Date().toISOString(),
|
||||||
ReceiptCounter: String(Date.now()),
|
ReceiptCounter: String(Date.now()),
|
||||||
ManualReceiptNumber: receiptNumber,
|
ManualReceiptNumber: (!isGateway && lastPayment?.reference) || receiptNumber,
|
||||||
SourceSystemType: session.systemType,
|
SourceSystemType: session.systemType,
|
||||||
SourceSystemNumber: session.systemNumber,
|
SourceSystemNumber: session.systemNumber,
|
||||||
ReceiptCurrency: currency,
|
ReceiptCurrency: currency,
|
||||||
@@ -97,7 +121,7 @@ export class EimsReceiptService {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
TransactionDetails: {
|
TransactionDetails: {
|
||||||
ModeOfPayment: dto.modeOfPayment,
|
ModeOfPayment: modeOfPayment,
|
||||||
ChequeNumber: dto.chequeNumber ?? null,
|
ChequeNumber: dto.chequeNumber ?? null,
|
||||||
CPONumber: dto.cpoNumber ?? null,
|
CPONumber: dto.cpoNumber ?? null,
|
||||||
DocumentNumber: dto.documentNumber ?? null,
|
DocumentNumber: dto.documentNumber ?? null,
|
||||||
@@ -105,7 +129,7 @@ export class EimsReceiptService {
|
|||||||
PaymentServiceProvider: dto.paymentServiceProvider ?? null,
|
PaymentServiceProvider: dto.paymentServiceProvider ?? null,
|
||||||
OtherPaymentServiceProviderName: dto.otherPaymentServiceProviderName ?? null,
|
OtherPaymentServiceProviderName: dto.otherPaymentServiceProviderName ?? null,
|
||||||
AccountNumber: dto.accountNumber ?? null,
|
AccountNumber: dto.accountNumber ?? null,
|
||||||
TransactionNumber: dto.transactionNumber ?? null,
|
TransactionNumber: dto.transactionNumber ?? (isGateway ? (lastPayment?.reference ?? null) : null),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -286,3 +310,17 @@ export class EimsReceiptService {
|
|||||||
return `REC-${invoice.invoiceNumber}-${Date.now()}`;
|
return `REC-${invoice.invoiceNumber}-${Date.now()}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recorded ledger method → MoR ModeOfPayment, only where the mapping is unambiguous. Mobile-money
|
||||||
|
* methods (TELEBIRR, EBIRR, …) have no MoR enum slot, and "GATEWAY" says nothing about the real
|
||||||
|
* channel — those return undefined and the caller must supply modeOfPayment explicitly. Guessing
|
||||||
|
* a tax field is worse than asking.
|
||||||
|
*/
|
||||||
|
function deriveModeOfPayment(method: string | null | undefined): EimsModeOfPayment | undefined {
|
||||||
|
if (!method) return undefined;
|
||||||
|
const normalized = method.toUpperCase().replace(/-/g, "_");
|
||||||
|
const direct = EIMS_MODE_OF_PAYMENT.find((m) => m.toUpperCase().replace(/ /g, "_") === normalized);
|
||||||
|
if (direct) return direct;
|
||||||
|
return normalized === "BANK_TRANSFER" ? "Local Bank Transfer" : undefined;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import { ETradeService } from "../companies/services/etrade.service";
|
|||||||
import { eimsConfig, eimsInvoiceConfig } from "./eims-test-fixtures";
|
import { eimsConfig, eimsInvoiceConfig } from "./eims-test-fixtures";
|
||||||
import { EimsSellerCacheService } from "./eims-seller-cache.service";
|
import { EimsSellerCacheService } from "./eims-seller-cache.service";
|
||||||
|
|
||||||
|
// A real MoR address (PARISH_NO 13 / CITY_NO 78 / LOCALITY_NO 1100) — the resolver now works off
|
||||||
|
// the Ministry's own hierarchy, so a made-up address would simply not resolve.
|
||||||
const registrationData = (over: Record<string, unknown> = {}) => ({
|
const registrationData = (over: Record<string, unknown> = {}) => ({
|
||||||
companyName: "Ethio-Djibouti Railway PLC (eTrade)",
|
companyName: "Ethio-Djibouti Railway PLC (eTrade)",
|
||||||
region: "Addis Ababa",
|
region: "Addis Ababa",
|
||||||
zone: "Bole",
|
zone: "Bole",
|
||||||
woreda: "Yeka",
|
woreda: "Woreda 1",
|
||||||
mobilePhone: "0911000000",
|
mobilePhone: "0911000000",
|
||||||
regularPhone: "",
|
regularPhone: "",
|
||||||
...over,
|
...over,
|
||||||
@@ -24,16 +26,10 @@ const build = (cfg: EimsConfig = eimsConfig()) => {
|
|||||||
return { service, resolveCompanyData, extractRegistrationData, cfg };
|
return { service, resolveCompanyData, extractRegistrationData, cfg };
|
||||||
};
|
};
|
||||||
|
|
||||||
const CODES = {
|
|
||||||
buyerRegionCodes: { "Addis Ababa": "13" },
|
|
||||||
buyerWeredaCodes: { Yeka: "99" },
|
|
||||||
buyerCityCodes: { Bole: "101" },
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("EimsSellerCacheService.getSellerDetails", () => {
|
describe("EimsSellerCacheService.getSellerDetails", () => {
|
||||||
it("static config wins over a conflicting e-Trade value", async () => {
|
it("static config wins over a conflicting e-Trade value", async () => {
|
||||||
const cfg = eimsConfig({
|
const cfg = eimsConfig({
|
||||||
invoice: eimsInvoiceConfig({ sellerLegalName: "Ethio-Djibouti Railway S.C.", ...CODES }),
|
invoice: eimsInvoiceConfig({ sellerLegalName: "Ethio-Djibouti Railway S.C." }),
|
||||||
});
|
});
|
||||||
const { service, resolveCompanyData } = build(cfg);
|
const { service, resolveCompanyData } = build(cfg);
|
||||||
resolveCompanyData.mockResolvedValue({
|
resolveCompanyData.mockResolvedValue({
|
||||||
@@ -56,7 +52,6 @@ describe("EimsSellerCacheService.getSellerDetails", () => {
|
|||||||
sellerRegion: "",
|
sellerRegion: "",
|
||||||
sellerWereda: "",
|
sellerWereda: "",
|
||||||
sellerCity: null,
|
sellerCity: null,
|
||||||
...CODES,
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const { service, resolveCompanyData } = build(cfg);
|
const { service, resolveCompanyData } = build(cfg);
|
||||||
@@ -67,13 +62,32 @@ describe("EimsSellerCacheService.getSellerDetails", () => {
|
|||||||
|
|
||||||
expect(seller.LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)");
|
expect(seller.LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)");
|
||||||
expect(seller.Region).toBe("13");
|
expect(seller.Region).toBe("13");
|
||||||
expect(seller.Wereda).toBe("99");
|
expect(seller.City).toBe("78");
|
||||||
|
expect(seller.Wereda).toBe("1100");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the static seller values alone when MoR does not list the e-Trade address", async () => {
|
||||||
|
// e-Trade's free text does not always correspond to a MoR row (here "Yeka" is a MoR *City*
|
||||||
|
// under ADDIS ABABA, not a locality under BOLE). That must degrade to the static config, which
|
||||||
|
// MoR has already cleared under rule 7017 — never throw, and never file a guessed code.
|
||||||
|
const cfg = eimsConfig({
|
||||||
|
invoice: eimsInvoiceConfig({ sellerRegion: "1", sellerWereda: "13", sellerCity: "101" }),
|
||||||
|
});
|
||||||
|
const { service, resolveCompanyData, extractRegistrationData } = build(cfg);
|
||||||
|
extractRegistrationData.mockReturnValue(registrationData({ woreda: "Yeka" }));
|
||||||
|
resolveCompanyData.mockResolvedValue({ companyInfo: {}, businessInfo: {} });
|
||||||
|
|
||||||
|
await expect(service.refresh()).resolves.toBeUndefined();
|
||||||
|
const seller = service.getSellerDetails(cfg);
|
||||||
|
|
||||||
|
expect(seller.Region).toBe("1");
|
||||||
|
expect(seller.Wereda).toBe("13");
|
||||||
expect(seller.City).toBe("101");
|
expect(seller.City).toBe("101");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("VatNumber and Email are always the static value, never touched by e-Trade", async () => {
|
it("VatNumber and Email are always the static value, never touched by e-Trade", async () => {
|
||||||
const cfg = eimsConfig({
|
const cfg = eimsConfig({
|
||||||
invoice: eimsInvoiceConfig({ sellerVatNumber: "0000000000", sellerEmail: "finance@example.et", ...CODES }),
|
invoice: eimsInvoiceConfig({ sellerVatNumber: "0000000000", sellerEmail: "finance@example.et" }),
|
||||||
});
|
});
|
||||||
const { service, resolveCompanyData } = build(cfg);
|
const { service, resolveCompanyData } = build(cfg);
|
||||||
resolveCompanyData.mockResolvedValue({ companyInfo: {}, businessInfo: {} });
|
resolveCompanyData.mockResolvedValue({ companyInfo: {}, businessInfo: {} });
|
||||||
@@ -108,7 +122,7 @@ describe("EimsSellerCacheService.getSellerDetails", () => {
|
|||||||
|
|
||||||
describe("EimsSellerCacheService.refresh", () => {
|
describe("EimsSellerCacheService.refresh", () => {
|
||||||
it("keeps the previous snapshot when a refresh fails", async () => {
|
it("keeps the previous snapshot when a refresh fails", async () => {
|
||||||
const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "", ...CODES }) });
|
const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "" }) });
|
||||||
const { service, resolveCompanyData } = build(cfg);
|
const { service, resolveCompanyData } = build(cfg);
|
||||||
resolveCompanyData.mockResolvedValueOnce({ companyInfo: {}, businessInfo: {} });
|
resolveCompanyData.mockResolvedValueOnce({ companyInfo: {}, businessInfo: {} });
|
||||||
await service.refresh();
|
await service.refresh();
|
||||||
@@ -123,7 +137,7 @@ describe("EimsSellerCacheService.refresh", () => {
|
|||||||
it("keeps the previous snapshot on timeout, without waiting for the slow request", async () => {
|
it("keeps the previous snapshot on timeout, without waiting for the slow request", async () => {
|
||||||
jest.useFakeTimers();
|
jest.useFakeTimers();
|
||||||
try {
|
try {
|
||||||
const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "", ...CODES }) });
|
const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "" }) });
|
||||||
const { service, resolveCompanyData } = build(cfg);
|
const { service, resolveCompanyData } = build(cfg);
|
||||||
resolveCompanyData.mockResolvedValueOnce({ companyInfo: {}, businessInfo: {} });
|
resolveCompanyData.mockResolvedValueOnce({ companyInfo: {}, businessInfo: {} });
|
||||||
await service.refresh();
|
await service.refresh();
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { ConfigService } from "@nestjs/config";
|
|||||||
|
|
||||||
import { EimsConfig } from "../../config/eims.config";
|
import { EimsConfig } from "../../config/eims.config";
|
||||||
import { ETradeService } from "../companies/services/etrade.service";
|
import { ETradeService } from "../companies/services/etrade.service";
|
||||||
import { EimsSellerDetails, resolveOptionalCode } from "../billing/eims-invoice.mapper";
|
import { tryResolveMorGeo } from "../../config/mor-location.resolver";
|
||||||
|
import { EimsSellerDetails } from "../billing/eims-invoice.mapper";
|
||||||
import { buildEimsSeller } from "./eims-invoice-context";
|
import { buildEimsSeller } from "./eims-invoice-context";
|
||||||
|
|
||||||
const has = (value: string | null | undefined): value is string => Boolean(value && value.trim());
|
const has = (value: string | null | undefined): value is string => Boolean(value && value.trim());
|
||||||
@@ -104,17 +105,24 @@ export class EimsSellerCacheService implements OnModuleInit {
|
|||||||
);
|
);
|
||||||
if (!businessInfo) return; // no licence on file yet — keep the previous snapshot
|
if (!businessInfo) return; // no licence on file yet — keep the previous snapshot
|
||||||
const data = this.etrade.extractRegistrationData(businessInfo, companyInfo);
|
const data = this.etrade.extractRegistrationData(businessInfo, companyInfo);
|
||||||
const codes = cfg.invoice;
|
// e-Trade returns region/zone/woreda as names ("Addis Ababa", "Bole") — resolved through the
|
||||||
|
// same MoR location master the buyer side uses, since the geography is objective, not
|
||||||
|
// buyer-specific. `tryResolveMorGeo` never throws: an address MoR does not list simply
|
||||||
|
// leaves these fields to getSellerDetails' static-config fallback, which is authoritative
|
||||||
|
// anyway (see the class comment — MoR has already cleared the static seller values under
|
||||||
|
// rule 7017, so nothing here may override one). e-Trade carries no country field; the
|
||||||
|
// resolver reads a blank country as domestic, which is correct for EDR's own registration.
|
||||||
|
const geo = tryResolveMorGeo({
|
||||||
|
region: data.region,
|
||||||
|
zone: data.zone,
|
||||||
|
woreda: data.woreda,
|
||||||
|
});
|
||||||
this.cached = {
|
this.cached = {
|
||||||
LegalName: data.companyName || undefined,
|
LegalName: data.companyName || undefined,
|
||||||
Phone: data.mobilePhone || data.regularPhone || undefined,
|
Phone: data.mobilePhone || data.regularPhone || undefined,
|
||||||
// e-Trade returns region/zone/woreda as names ("Addis Ababa", "Bole") — resolved via the
|
Region: geo?.Region,
|
||||||
// same buyer code maps, since the geography is objective, not buyer-specific, despite the
|
Wereda: geo?.Wereda,
|
||||||
// env var's "BUYER_" prefix. Never throws: an unmapped name just leaves that field to
|
City: geo?.City,
|
||||||
// getSellerDetails' static-config fallback.
|
|
||||||
Region: resolveOptionalCode(data.region, codes.buyerRegionCodes),
|
|
||||||
Wereda: resolveOptionalCode(data.woreda, codes.buyerWeredaCodes),
|
|
||||||
City: resolveOptionalCode(data.zone, codes.buyerCityCodes),
|
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
|
|||||||
@@ -32,11 +32,6 @@ export const eimsInvoiceConfig = (over: Partial<EimsInvoiceConfig> = {}): EimsIn
|
|||||||
paymentMode: "CASH",
|
paymentMode: "CASH",
|
||||||
paymentTerm: "IMMIDIATE",
|
paymentTerm: "IMMIDIATE",
|
||||||
unitDefault: "PCS",
|
unitDefault: "PCS",
|
||||||
buyerCountryCode: null,
|
|
||||||
buyerCountryCodes: { Ethiopia: "231" }, // test-only, not a confirmed real MoR code
|
|
||||||
buyerRegionCodes: { "Addis Ababa": "13" },
|
|
||||||
buyerWeredaCodes: { Yeka: "99" }, // test-only, not a real MoR code
|
|
||||||
buyerCityCodes: { Kirkos: "101" }, // test-only, not a confirmed real MoR code
|
|
||||||
taxCodeByChargeType: {},
|
taxCodeByChargeType: {},
|
||||||
taxRateByChargeType: {},
|
taxRateByChargeType: {},
|
||||||
exciseByChargeType: {},
|
exciseByChargeType: {},
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
|
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
|
||||||
import { Invoice } from '../../billing/entities/invoice.entity';
|
import { Invoice } from '../../billing/entities/invoice.entity';
|
||||||
|
import { invoicePaymentMethodExpr } from '../../billing/invoice-settlement.util';
|
||||||
|
import { PaymentEntity } from '../../payment/entities/payment.entity';
|
||||||
|
import { Booking } from '../../bookings/entities/booking.entity';
|
||||||
import { Company } from '../../companies/entities/company.entity';
|
import { Company } from '../../companies/entities/company.entity';
|
||||||
import { CompanyProfile } from '../../companies/entities/company-profile.entity';
|
import { CompanyProfile } from '../../companies/entities/company-profile.entity';
|
||||||
import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity';
|
import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity';
|
||||||
import { applyBookingRefDirectionScope } from '../../user-trade-access/trade-scope.util';
|
import { applyBookingRefDirectionScope } from '../../user-trade-access/trade-scope.util';
|
||||||
import { ExportDataset } from '../export.types';
|
import { ExportDataset } from '../export.types';
|
||||||
|
|
||||||
|
/** Same expression the list endpoint filters by, in this dataset's aliases. */
|
||||||
|
const PAYMENT_METHOD = invoicePaymentMethodExpr('i', 'p');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sensitive EIMS internals are deliberately absent: `eims_signed_qr` (a
|
* Sensitive EIMS internals are deliberately absent: `eims_signed_qr` (a
|
||||||
* signature blob) and `eims_last_error` (a raw error dump). The
|
* signature blob) and `eims_last_error` (a raw error dump). The
|
||||||
@@ -26,8 +32,16 @@ export const invoicesDataset: ExportDataset = {
|
|||||||
// with a second query. In a dataset it is just a join by column.
|
// with a second query. In a dataset it is just a join by column.
|
||||||
{ alias: 'slc', entity: ShippingLineCompany, on: 'slc.id = i.shipping_line_company_id' },
|
{ alias: 'slc', entity: ShippingLineCompany, on: 'slc.id = i.shipping_line_company_id' },
|
||||||
{ alias: 'rel', entity: Invoice, on: 'rel.id = i.related_invoice_id' },
|
{ alias: 'rel', entity: Invoice, on: 'rel.id = i.related_invoice_id' },
|
||||||
|
// The gateway payment behind the invoice — provider method and its
|
||||||
|
// transaction reference. Always joined: `scope()` filters on it.
|
||||||
|
{ alias: 'p', entity: PaymentEntity, on: 'p.id = i.payment_id' },
|
||||||
|
// Booking behind the invoice, for the PNR alone. `i.source_id` is a bare
|
||||||
|
// varchar pointer that is not always a UUID (EIMS self-test rows carry a
|
||||||
|
// slug), so the cast goes on `bk.id`, never on `source_id` — casting the
|
||||||
|
// other way throws on those rows.
|
||||||
|
{ alias: 'bk', entity: Booking, on: "bk.id::text = i.source_id AND i.source = 'booking'" },
|
||||||
],
|
],
|
||||||
alwaysJoin: ['c'],
|
alwaysJoin: ['c', 'p', 'bk'],
|
||||||
|
|
||||||
groups: [
|
groups: [
|
||||||
{ id: 'invoice', label: 'Invoice' },
|
{ id: 'invoice', label: 'Invoice' },
|
||||||
@@ -66,6 +80,12 @@ export const invoicesDataset: ExportDataset = {
|
|||||||
{ key: 'currency', label: 'Currency', type: 'string', group: 'amounts', default: true, select: 'i.currency' },
|
{ key: 'currency', label: 'Currency', type: 'string', group: 'amounts', default: true, select: 'i.currency' },
|
||||||
|
|
||||||
{ key: 'paidAt', label: 'Paid at', type: 'datetime', group: 'payment', select: `to_char(i.paid_at, 'YYYY-MM-DD HH24:MI')` },
|
{ key: 'paidAt', label: 'Paid at', type: 'datetime', group: 'payment', select: `to_char(i.paid_at, 'YYYY-MM-DD HH24:MI')` },
|
||||||
|
{ key: 'paymentMethod', label: 'Payment method', type: 'string', group: 'payment', default: true, requires: ['p'], select: PAYMENT_METHOD, sortExpr: PAYMENT_METHOD },
|
||||||
|
{ key: 'transactionRef', label: 'Transaction ref', type: 'string', group: 'payment', requires: ['p'], select: 'p.transaction_id' },
|
||||||
|
// The CBE_BILL reference the customer pays against — stamped onto the
|
||||||
|
// booking at payment-initiation time, not held on the invoice or payment.
|
||||||
|
{ key: 'pnrCode', label: 'PNR', type: 'string', group: 'payment', requires: ['bk'], select: 'bk.pnr_code' },
|
||||||
|
{ key: 'paymentStatus', label: 'Payment status', type: 'string', group: 'payment', requires: ['p'], select: 'p.status::text' },
|
||||||
{
|
{
|
||||||
key: 'daysOverdue', label: 'Days overdue', type: 'number', group: 'payment',
|
key: 'daysOverdue', label: 'Days overdue', type: 'number', group: 'payment',
|
||||||
select: `CASE WHEN i.balance_amount > 0 AND i.due_at < now()
|
select: `CASE WHEN i.balance_amount > 0 AND i.due_at < now()
|
||||||
@@ -104,6 +124,7 @@ export const invoicesDataset: ExportDataset = {
|
|||||||
{ key: 'status', label: 'Status (single)', type: 'text' },
|
{ key: 'status', label: 'Status (single)', type: 'text' },
|
||||||
{ key: 'sources', label: 'Source', type: 'multiselect' },
|
{ key: 'sources', label: 'Source', type: 'multiselect' },
|
||||||
{ key: 'eimsStatuses', label: 'EIMS status', type: 'multiselect' },
|
{ key: 'eimsStatuses', label: 'EIMS status', type: 'multiselect' },
|
||||||
|
{ key: 'paymentMethods', label: 'Payment method', type: 'multiselect' },
|
||||||
{ key: 'currency', label: 'Currency', type: 'select', options: [
|
{ key: 'currency', label: 'Currency', type: 'select', options: [
|
||||||
{ value: 'ETB', label: 'ETB' },
|
{ value: 'ETB', label: 'ETB' },
|
||||||
{ value: 'USD', label: 'USD' },
|
{ value: 'USD', label: 'USD' },
|
||||||
@@ -113,7 +134,7 @@ export const invoicesDataset: ExportDataset = {
|
|||||||
{ key: 'hasBalance', label: 'Outstanding only', type: 'text' },
|
{ key: 'hasBalance', label: 'Outstanding only', type: 'text' },
|
||||||
{ key: 'overdue', label: 'Overdue only', type: 'text' },
|
{ key: 'overdue', label: 'Overdue only', type: 'text' },
|
||||||
{ key: 'companyId', label: 'Customer', type: 'text' },
|
{ key: 'companyId', label: 'Customer', type: 'text' },
|
||||||
{ key: 'search', label: 'Search invoice no. or customer', type: 'text' },
|
{ key: 'search', label: 'Search invoice no., customer, PNR or transaction ref', type: 'text' },
|
||||||
],
|
],
|
||||||
|
|
||||||
defaultSort: { key: 'issuedAt', dir: 'DESC' },
|
defaultSort: { key: 'issuedAt', dir: 'DESC' },
|
||||||
@@ -132,6 +153,10 @@ export const invoicesDataset: ExportDataset = {
|
|||||||
if (sources?.length) qb.andWhere('i.source IN (:...sources)', { sources });
|
if (sources?.length) qb.andWhere('i.source IN (:...sources)', { sources });
|
||||||
const eimsStatuses = params.eimsStatuses as string[] | null;
|
const eimsStatuses = params.eimsStatuses as string[] | null;
|
||||||
if (eimsStatuses?.length) qb.andWhere('i.eims_status IN (:...eimsStatuses)', { eimsStatuses });
|
if (eimsStatuses?.length) qb.andWhere('i.eims_status IN (:...eimsStatuses)', { eimsStatuses });
|
||||||
|
const paymentMethods = params.paymentMethods as string[] | null;
|
||||||
|
if (paymentMethods?.length) {
|
||||||
|
qb.andWhere(`${PAYMENT_METHOD} IN (:...paymentMethods)`, { paymentMethods });
|
||||||
|
}
|
||||||
// Casing has drifted in the data ("usd" rows exist) — normalise both sides,
|
// Casing has drifted in the data ("usd" rows exist) — normalise both sides,
|
||||||
// same as the list endpoint does.
|
// same as the list endpoint does.
|
||||||
if (params.currency) {
|
if (params.currency) {
|
||||||
@@ -146,7 +171,18 @@ export const invoicesDataset: ExportDataset = {
|
|||||||
if (params.overdue === 'true') qb.andWhere('i.balance_amount > 0 AND i.due_at < now()');
|
if (params.overdue === 'true') qb.andWhere('i.balance_amount > 0 AND i.due_at < now()');
|
||||||
if (params.companyId) qb.andWhere('i.company_id = :companyId', { companyId: params.companyId });
|
if (params.companyId) qb.andWhere('i.company_id = :companyId', { companyId: params.companyId });
|
||||||
if (params.search) {
|
if (params.search) {
|
||||||
qb.andWhere('(i.invoice_number ILIKE :search OR c.name ILIKE :search)', { search: `%${params.search as string}%` });
|
// Same reach as the list page's search box, minus the source-record
|
||||||
|
// lookups it does with correlated subqueries: number, customer, the PNR
|
||||||
|
// the customer pays against, and the payment references support desks
|
||||||
|
// quote back.
|
||||||
|
qb.andWhere(
|
||||||
|
`(i.invoice_number ILIKE :search
|
||||||
|
OR c.name ILIKE :search
|
||||||
|
OR bk.pnr_code ILIKE :search
|
||||||
|
OR p.transaction_id ILIKE :search
|
||||||
|
OR p.merchant_order_id ILIKE :search)`,
|
||||||
|
{ search: `%${params.search as string}%` },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// ACL: invoices.source_id is a varchar pointer at the originating booking.
|
// ACL: invoices.source_id is a varchar pointer at the originating booking.
|
||||||
applyBookingRefDirectionScope(qb, 'i.source_id', directions);
|
applyBookingRefDirectionScope(qb, 'i.source_id', directions);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
|||||||
import { InjectDataSource } from '@nestjs/typeorm';
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { CurrentUser } from '@edr/api-common';
|
import { CurrentUser } from '@edr/api-common';
|
||||||
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
import { FreightJwtGuard } from '../../common/freight-jwt.guard';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ const toCatalogEntry = (dataset: ExportDataset): ExportCatalogEntry => ({
|
|||||||
@ApiTags('Exports')
|
@ApiTags('Exports')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller('exports')
|
@Controller('exports')
|
||||||
@UseGuards(JwtGuard)
|
@UseGuards(FreightJwtGuard)
|
||||||
export class ExportsController {
|
export class ExportsController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly runner: ExportRunnerService,
|
private readonly runner: ExportRunnerService,
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { Module } from '@nestjs/common';
|
|||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
import { BookingsModule } from '../bookings/bookings.module';
|
import { BookingsModule } from '../bookings/bookings.module';
|
||||||
|
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
|
||||||
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
import { WarehousesModule } from '../warehouses/warehouses.module';
|
import { WarehousesModule } from '../warehouses/warehouses.module';
|
||||||
import { DjiboutiIncident } from './entities/djibouti-incident.entity';
|
import { DjiboutiIncident } from './entities/djibouti-incident.entity';
|
||||||
import { EmptyContainerReturn } from './entities/empty-container-return.entity';
|
import { EmptyContainerReturn } from './entities/empty-container-return.entity';
|
||||||
@@ -18,9 +20,12 @@ import { ImportOperationsService } from './import-operations.service';
|
|||||||
]),
|
]),
|
||||||
// WarehouseReleaseDocumentService (the shared PDF renderer) for the
|
// WarehouseReleaseDocumentService (the shared PDF renderer) for the
|
||||||
// equipment interchange receipt; BookingsModule for the customer
|
// equipment interchange receipt; BookingsModule for the customer
|
||||||
// ownership check on that same route.
|
// ownership check on that same route; the notification modules to tell
|
||||||
|
// the customer their receipt is ready at handover.
|
||||||
WarehousesModule,
|
WarehousesModule,
|
||||||
BookingsModule,
|
BookingsModule,
|
||||||
|
NotificationInboxModule,
|
||||||
|
NotificationsModule,
|
||||||
],
|
],
|
||||||
controllers: [ImportOperationsController],
|
controllers: [ImportOperationsController],
|
||||||
providers: [ImportOperationsService],
|
providers: [ImportOperationsService],
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { In, Repository } from 'typeorm';
|
import { In, Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import { NotificationAudience, NotificationType } from '@edr/types';
|
||||||
import { LogoSettingsService } from '../logo-settings/logo-settings.service';
|
import { LogoSettingsService } from '../logo-settings/logo-settings.service';
|
||||||
import { logoImageCss, logoMarkup } from '../billing/documents/logo-markup.util';
|
import { logoImageCss, logoMarkup } from '../billing/documents/logo-markup.util';
|
||||||
|
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||||
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
|
import { sendCompanyChannels } from '../notifications/notify-company.util';
|
||||||
import { WarehouseReleaseDocumentService } from '../warehouses/warehouse-release-document.service';
|
import { WarehouseReleaseDocumentService } from '../warehouses/warehouse-release-document.service';
|
||||||
import {
|
import {
|
||||||
CreateDjiboutiIncidentDto,
|
CreateDjiboutiIncidentDto,
|
||||||
@@ -35,6 +39,8 @@ const DAMAGE_INCIDENTS: DjiboutiIncidentType[] = [
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ImportOperationsService {
|
export class ImportOperationsService {
|
||||||
|
private readonly logger = new Logger(ImportOperationsService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(DjiboutiIncident)
|
@InjectRepository(DjiboutiIncident)
|
||||||
private readonly incidents: Repository<DjiboutiIncident>,
|
private readonly incidents: Repository<DjiboutiIncident>,
|
||||||
@@ -44,6 +50,8 @@ export class ImportOperationsService {
|
|||||||
private readonly emptyReturns: Repository<EmptyContainerReturn>,
|
private readonly emptyReturns: Repository<EmptyContainerReturn>,
|
||||||
private readonly pdfDocuments: WarehouseReleaseDocumentService,
|
private readonly pdfDocuments: WarehouseReleaseDocumentService,
|
||||||
private readonly logoSettings: LogoSettingsService,
|
private readonly logoSettings: LogoSettingsService,
|
||||||
|
private readonly inbox: NotificationInboxService,
|
||||||
|
private readonly notifications: NotificationsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
listIncidents(bookingId?: string) {
|
listIncidents(bookingId?: string) {
|
||||||
@@ -161,7 +169,7 @@ export class ImportOperationsService {
|
|||||||
|
|
||||||
async createEmptyReturn(dto: CreateEmptyContainerReturnDto) {
|
async createEmptyReturn(dto: CreateEmptyContainerReturnDto) {
|
||||||
const returnDate = dto.returnDate ? new Date(dto.returnDate) : new Date();
|
const returnDate = dto.returnDate ? new Date(dto.returnDate) : new Date();
|
||||||
return this.emptyReturns.save(
|
const saved = await this.emptyReturns.save(
|
||||||
this.emptyReturns.create({
|
this.emptyReturns.create({
|
||||||
containerNumber: dto.containerNumber,
|
containerNumber: dto.containerNumber,
|
||||||
bookingId: dto.bookingId ?? null,
|
bookingId: dto.bookingId ?? null,
|
||||||
@@ -180,6 +188,15 @@ export class ImportOperationsService {
|
|||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// RETURNED is the physical interchange itself — the customer's/trucker's
|
||||||
|
// custody of the box ends here, EDR's begins. The receipt exists from this
|
||||||
|
// point on, so tell the customer now, not at some later internal status.
|
||||||
|
// Standalone returns (no booking) have no company to notify.
|
||||||
|
if (saved.bookingId) {
|
||||||
|
await this.notifyEquipmentInterchangeReady(saved);
|
||||||
|
}
|
||||||
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -257,6 +274,34 @@ export class ImportOperationsService {
|
|||||||
return this.emptyReturns.findOneOrFail({ where: { id } });
|
return this.emptyReturns.findOneOrFail({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async notifyEquipmentInterchangeReady(row: EmptyContainerReturn): Promise<void> {
|
||||||
|
try {
|
||||||
|
const [booking]: Array<{ companyId: string | null; reference: string }> =
|
||||||
|
await this.emptyReturns.manager.query(
|
||||||
|
`SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
|
||||||
|
[row.bookingId],
|
||||||
|
);
|
||||||
|
if (!booking?.companyId) return;
|
||||||
|
const body = `Container ${row.containerNumber} was handed over${
|
||||||
|
row.facility ? ` at ${row.facility}` : ''
|
||||||
|
}. Your equipment interchange receipt for booking ${booking.reference} is ready to download from the portal.`;
|
||||||
|
await this.inbox.notify({
|
||||||
|
recipients: { companyId: booking.companyId },
|
||||||
|
audience: NotificationAudience.PORTAL,
|
||||||
|
type: NotificationType.DOCUMENT_ACTION,
|
||||||
|
title: 'Equipment interchange receipt ready',
|
||||||
|
body,
|
||||||
|
link: `/bookings/${row.bookingId}`,
|
||||||
|
data: { bookingId: row.bookingId, emptyContainerReturnId: row.id },
|
||||||
|
});
|
||||||
|
await sendCompanyChannels(this.emptyReturns.manager.connection, this.notifications, booking.companyId, body);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to notify equipment interchange ready for return ${row.id}: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getEmptyReturnOrThrow(id: string): Promise<EmptyContainerReturn> {
|
async getEmptyReturnOrThrow(id: string): Promise<EmptyContainerReturn> {
|
||||||
const row = await this.emptyReturns.findOne({ where: { id } });
|
const row = await this.emptyReturns.findOne({ where: { id } });
|
||||||
if (!row) {
|
if (!row) {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
UseGuards,
|
UseGuards,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard";
|
import { FreightJwtGuard } from "../../common/freight-jwt.guard";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AuthUserPayload,
|
AuthUserPayload,
|
||||||
@@ -21,7 +21,7 @@ import { NotificationInboxService } from "./notification-inbox.service";
|
|||||||
|
|
||||||
@ApiTags("notifications")
|
@ApiTags("notifications")
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtGuard)
|
@UseGuards(FreightJwtGuard)
|
||||||
@Controller("notifications")
|
@Controller("notifications")
|
||||||
export class NotificationInboxController {
|
export class NotificationInboxController {
|
||||||
constructor(private readonly service: NotificationInboxService) {}
|
constructor(private readonly service: NotificationInboxService) {}
|
||||||
|
|||||||
@@ -67,6 +67,24 @@ export class UpdateOperationsStandardsDto {
|
|||||||
@Min(0)
|
@Min(0)
|
||||||
delayToleranceMinutes?: number;
|
delayToleranceMinutes?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handling standards have no spec figure, so they are the only two that may
|
||||||
|
* be cleared: null puts the report back to reporting hours without a rate.
|
||||||
|
*/
|
||||||
|
@ApiPropertyOptional({ example: 6.75, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(toNumber)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.01)
|
||||||
|
handlingStandardHoursContainer?: number | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 12, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(toNumber)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.01)
|
||||||
|
handlingStandardHoursBulk?: number | null;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: 20 })
|
@ApiPropertyOptional({ example: 20 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Transform(toNumber)
|
@Transform(toNumber)
|
||||||
|
|||||||
@@ -179,6 +179,34 @@ export class OperationsStandard extends BaseEntity {
|
|||||||
@Column({ name: 'default_full_trainset_wagons', type: 'int', default: 50 })
|
@Column({ name: 'default_full_trainset_wagons', type: 'int', default: 50 })
|
||||||
defaultFullTrainsetWagons!: number;
|
defaultFullTrainsetWagons!: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard loading-and-unloading time for a container train's stop, in hours.
|
||||||
|
*
|
||||||
|
* Null until a planner sets it, and deliberately so: the reporting spec names
|
||||||
|
* no handling standard, so an unset value reports no rate rather than judging
|
||||||
|
* a train against a guess. Same for the bulk figure below.
|
||||||
|
*/
|
||||||
|
@Column({
|
||||||
|
name: 'handling_standard_hours_container',
|
||||||
|
type: 'numeric',
|
||||||
|
precision: 6,
|
||||||
|
scale: 2,
|
||||||
|
nullable: true,
|
||||||
|
transformer: asNumber,
|
||||||
|
})
|
||||||
|
handlingStandardHoursContainer?: number | null;
|
||||||
|
|
||||||
|
/** Standard loading-and-unloading time for a bulk train's stop, in hours. */
|
||||||
|
@Column({
|
||||||
|
name: 'handling_standard_hours_bulk',
|
||||||
|
type: 'numeric',
|
||||||
|
precision: 6,
|
||||||
|
scale: 2,
|
||||||
|
nullable: true,
|
||||||
|
transformer: asNumber,
|
||||||
|
})
|
||||||
|
handlingStandardHoursBulk?: number | null;
|
||||||
|
|
||||||
/** IAM user id of the last operator to change a standard. */
|
/** IAM user id of the last operator to change a standard. */
|
||||||
@Column({ name: 'updated_by_id', type: 'uuid', nullable: true })
|
@Column({ name: 'updated_by_id', type: 'uuid', nullable: true })
|
||||||
updatedById?: string | null;
|
updatedById?: string | null;
|
||||||
|
|||||||
@@ -1,93 +1,173 @@
|
|||||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||||
|
|
||||||
|
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||||
import { ReportContext, ReportDefinition } from '../report.types';
|
import { ReportContext, ReportDefinition } from '../report.types';
|
||||||
import {
|
import {
|
||||||
ACTUAL_TONS_EXPR,
|
ACTUAL_TONS_EXPR,
|
||||||
CARGO_CATEGORY_EXPR,
|
ALLOC_CONTAINERS_20,
|
||||||
CARGO_CATEGORY_FILTER,
|
ALLOC_CONTAINERS_40,
|
||||||
CARGO_CATEGORY_LABEL_EXPR,
|
|
||||||
CHARGED_TONS_EXPR,
|
CHARGED_TONS_EXPR,
|
||||||
LOADED_WAGONS_EXPR,
|
LOADED_WAGONS_EXPR,
|
||||||
OPERATIONS_FILTERS,
|
OPERATIONS_FILTERS,
|
||||||
SCHEDULE_EMPTY_WAGONS,
|
REVENUE_CARGO_CATEGORY_EXPR,
|
||||||
|
REVENUE_CARGO_FILTER,
|
||||||
SCHEDULE_KM_EXPR,
|
SCHEDULE_KM_EXPR,
|
||||||
TEU_EXPR,
|
TEU_EXPR,
|
||||||
allocationLedgerQb,
|
allocationLedgerQb,
|
||||||
applyCategoryFilter,
|
applyCategoryFilter,
|
||||||
|
distanceKmBetween,
|
||||||
} from '../operations-classification';
|
} from '../operations-classification';
|
||||||
|
import { CATEGORY_LABEL_OF } from '../revenue-classification';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Distance and empty-wagon count belong to the departure, so they are constant
|
* A leg is one station-to-station move the train actually made: two consecutive
|
||||||
* within a group that includes `ts.id` — MAX() satisfies Postgres without
|
* checkpoints at different yards. DISTINCT because a train that works the same
|
||||||
* dragging a scalar subselect through the GROUP BY.
|
* pair twice in one departure is still one leg — without it the join would fan
|
||||||
|
* the cargo out again and double every SUM in the group.
|
||||||
*/
|
*/
|
||||||
const ROUTE_KM = `MAX(${SCHEDULE_KM_EXPR})`;
|
const LEGS = `(
|
||||||
const EMPTY_WAGONS = `MAX(${SCHEDULE_EMPTY_WAGONS})`;
|
SELECT DISTINCT e.train_schedule_id, e.from_yard_id, e.to_yard_id
|
||||||
|
FROM (
|
||||||
|
SELECT ev.train_schedule_id,
|
||||||
|
ev.yard_id AS from_yard_id,
|
||||||
|
lead(ev.yard_id) OVER (
|
||||||
|
PARTITION BY ev.train_schedule_id ORDER BY ev.occurred_at
|
||||||
|
) AS to_yard_id
|
||||||
|
FROM freight.train_checkpoint_events ev
|
||||||
|
WHERE ev.deleted_at IS NULL
|
||||||
|
) e
|
||||||
|
WHERE e.to_yard_id IS NOT NULL AND e.to_yard_id <> e.from_yard_id
|
||||||
|
)`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LEFT joined, and both ends fall back to the schedule's own corridor: a
|
||||||
|
* departure with no checkpoints logged has no legs, and must keep the single
|
||||||
|
* origin-to-destination row it had before this report knew about legs.
|
||||||
|
*/
|
||||||
|
const LEG_FROM = 'COALESCE(leg.from_yard_id, ts.origin_station_id)';
|
||||||
|
const LEG_TO = 'COALESCE(leg.to_yard_id, ts.destination_station_id)';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Distance is constant within a group that includes `ts.id` and the leg —
|
||||||
|
* MAX() satisfies Postgres without dragging a scalar subselect through the
|
||||||
|
* GROUP BY.
|
||||||
|
*/
|
||||||
|
const LEG_KM = `MAX(${distanceKmBetween(LEG_FROM, LEG_TO)})`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ledger carries the empty wagons as rows of their own, so both counts are
|
||||||
|
* plain aggregates over the group: an empty-wagon row has no loaded wagons and
|
||||||
|
* a cargo row has no empty ones. Read down a departure's rows and its wagons
|
||||||
|
* add up once, instead of every row repeating the train's empty total.
|
||||||
|
*/
|
||||||
|
const EMPTY_WAGONS = 'COUNT(DISTINCT tsw.id) FILTER (WHERE wba.id IS NULL)';
|
||||||
|
const TOTAL_WAGONS = 'COUNT(DISTINCT tsw.id)::int';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cargo in the revenue vocabulary, plus the wagon that carried none.
|
||||||
|
*
|
||||||
|
* The label is built out of the key expression rather than beside it: Postgres
|
||||||
|
* only accepts an aggregate-query column inside a GROUP BY expression it can
|
||||||
|
* match verbatim, so a second `wba.id IS NULL` test of its own would demand
|
||||||
|
* `wba.id` in the GROUP BY — which would split the grain down to one row per
|
||||||
|
* allocation.
|
||||||
|
*/
|
||||||
|
const CATEGORY_EXPR = `CASE WHEN wba.id IS NULL THEN 'EMPTY_WAGON'
|
||||||
|
ELSE ${REVENUE_CARGO_CATEGORY_EXPR} END`;
|
||||||
|
const CATEGORY_LABEL_EXPR = `CASE WHEN (${CATEGORY_EXPR}) = 'EMPTY_WAGON' THEN 'Empty wagon'
|
||||||
|
ELSE ${CATEGORY_LABEL_OF(CATEGORY_EXPR)} END`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ton/Km and Vehicle-Km are NULL — not zero — when the yard pair has no
|
* Ton/Km and Vehicle-Km are NULL — not zero — when the yard pair has no
|
||||||
* configured distance. A missing distance is not a zero distance, and zeroing
|
* configured distance. A missing distance is not a zero distance, and zeroing
|
||||||
* it would understate the corridor's work without anyone noticing.
|
* it would understate the corridor's work without anyone noticing.
|
||||||
*/
|
*/
|
||||||
const TON_KM = `ROUND((${CHARGED_TONS_EXPR})::numeric * ${ROUTE_KM}, 1)::float8`;
|
const TON_KM = `ROUND((${CHARGED_TONS_EXPR})::numeric * ${LEG_KM}, 1)::float8`;
|
||||||
const VEHICLE_KM = `ROUND(${EMPTY_WAGONS}::numeric * ${ROUTE_KM}, 1)::float8`;
|
const VEHICLE_KM = `ROUND(${EMPTY_WAGONS}::numeric * ${LEG_KM}, 1)::float8`;
|
||||||
|
|
||||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||||
const qb = allocationLedgerQb(ctx);
|
const qb = allocationLedgerQb(ctx, { includeEmptyWagons: true });
|
||||||
applyCategoryFilter(qb, ctx.params);
|
applyCategoryFilter(qb, ctx.params, CATEGORY_EXPR);
|
||||||
return qb;
|
return qb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The row grain: one leg of one departure. Only `query()` needs it — the KPIs
|
||||||
|
* are corridor-level and would count the same cargo once per leg if they had
|
||||||
|
* this join. */
|
||||||
|
function legQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||||
|
return baseQuery(ctx)
|
||||||
|
.leftJoin(LEGS, 'leg', 'leg.train_schedule_id = ts.id')
|
||||||
|
.leftJoin(Yard, 'lfy', `lfy.id = ${LEG_FROM}`)
|
||||||
|
.leftJoin(Yard, 'lty', `lty.id = ${LEG_TO}`);
|
||||||
|
}
|
||||||
|
|
||||||
export const chargedVsActualVolumeReport: ReportDefinition = {
|
export const chargedVsActualVolumeReport: ReportDefinition = {
|
||||||
key: 'charged-vs-actual-volume',
|
key: 'charged-vs-actual-volume',
|
||||||
title: 'Charged and Actual Volumes',
|
title: 'Charged and Actual Volumes',
|
||||||
description:
|
description:
|
||||||
'Charged versus actual volume per train and cargo type, with Ton/Km and Vehicle-Km. ' +
|
'Charged versus actual volume per leg and cargo type, with Ton/Km and Vehicle-Km. ' +
|
||||||
'Charged volume is the standard weight capacity — 20 and 40 tons per laden container, ' +
|
'A leg is one station-to-station move the train actually made, read from its logged ' +
|
||||||
'2.24 and 3.88 empty, 70 tons per wagon of steel or fertilizer, 38 for perishables — ' +
|
'checkpoints; a departure with no checkpoints logged shows as its single planned ' +
|
||||||
'all editable in Operating standards. Actual volume is what the marshalling recorded. ' +
|
'corridor. Charged volume is the standard weight capacity — 20 and 40 tons per laden ' +
|
||||||
'Vehicle-Km counts the empty wagons on that train, so it repeats across the train’s ' +
|
'container, 2.24 and 3.88 empty, 70 tons per wagon of steel or fertilizer, 38 for ' +
|
||||||
'cargo types rather than being split between them.',
|
'perishables — all editable in Operating standards. Actual volume is what the ' +
|
||||||
|
'marshalling recorded. Cargo types are the revenue categories the money side bills ' +
|
||||||
|
'against, so a corridor’s tonnage and its revenue read in the same buckets; wagons ' +
|
||||||
|
'that carried nothing are their own “Empty wagon” line. Volumes and wagon counts ' +
|
||||||
|
'belong to the train, not to the leg, so they repeat on every leg it ran rather than ' +
|
||||||
|
'being split between them — the KPIs above count each train once. Ton/Km and ' +
|
||||||
|
'Vehicle-Km are the exception and are the leg’s own, so they add up across legs into ' +
|
||||||
|
'the real corridor figure.',
|
||||||
group: 'Operations',
|
group: 'Operations',
|
||||||
filters: [...OPERATIONS_FILTERS, CARGO_CATEGORY_FILTER],
|
filters: [...OPERATIONS_FILTERS, REVENUE_CARGO_FILTER],
|
||||||
columns: [
|
columns: [
|
||||||
{ key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'ts.train_number' },
|
{ key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'ts.train_number' },
|
||||||
{ key: 'departedAt', label: 'Departure', type: 'date', sortable: true, sortExpr: 'ts.scheduled_departure_date' },
|
{ key: 'departedAt', label: 'Departure', type: 'date', sortable: true, sortExpr: 'ts.scheduled_departure_date' },
|
||||||
{ key: 'station', label: 'Station', type: 'string' },
|
{ key: 'legFrom', label: 'From', type: 'string', sortable: true, sortExpr: 'COALESCE(lfy.label, lfy.code)' },
|
||||||
{ key: 'category', label: 'Cargo type', type: 'string', sortable: true, sortExpr: CARGO_CATEGORY_EXPR },
|
{ key: 'legTo', label: 'To', type: 'string', sortable: true, sortExpr: 'COALESCE(lty.label, lty.code)' },
|
||||||
|
{ key: 'category', label: 'Cargo type', type: 'string', sortable: true, sortExpr: CATEGORY_EXPR },
|
||||||
{ key: 'chargedTons', label: 'Charged volume', type: 'tons', sortable: true },
|
{ key: 'chargedTons', label: 'Charged volume', type: 'tons', sortable: true },
|
||||||
{ key: 'actualTons', label: 'Actual volume', type: 'tons', sortable: true },
|
{ key: 'actualTons', label: 'Actual volume', type: 'tons', sortable: true },
|
||||||
|
{ key: 'containers20', label: '20ft', type: 'number', sortable: true },
|
||||||
|
{ key: 'containers40', label: '40ft', type: 'number', sortable: true },
|
||||||
{ key: 'teu', label: 'TEU', type: 'number' },
|
{ key: 'teu', label: 'TEU', type: 'number' },
|
||||||
{ key: 'wagons', label: 'Loaded wagons', type: 'number' },
|
{ key: 'wagons', label: 'Loaded wagons', type: 'number' },
|
||||||
{ key: 'emptyWagons', label: 'Empty wagons', type: 'number' },
|
{ key: 'emptyWagons', label: 'Empty wagons', type: 'number' },
|
||||||
|
{ key: 'totalWagons', label: 'Total wagons', type: 'number', sortable: true },
|
||||||
{ key: 'distanceKm', label: 'Distance (km)', type: 'number' },
|
{ key: 'distanceKm', label: 'Distance (km)', type: 'number' },
|
||||||
{ key: 'tonKm', label: 'Ton/Km', type: 'number', sortable: true },
|
{ key: 'tonKm', label: 'Ton/Km', type: 'number', sortable: true },
|
||||||
{ key: 'vehicleKm', label: 'Vehicle-Km', type: 'number' },
|
{ key: 'vehicleKm', label: 'Vehicle-Km', type: 'number' },
|
||||||
],
|
],
|
||||||
defaultSort: { key: 'departedAt', dir: 'DESC' },
|
defaultSort: { key: 'departedAt', dir: 'DESC' },
|
||||||
query(ctx) {
|
query(ctx) {
|
||||||
return baseQuery(ctx)
|
return legQuery(ctx)
|
||||||
.select("COALESCE(ts.train_number, '—')", 'trainNumber')
|
.select("COALESCE(ts.train_number, '—')", 'trainNumber')
|
||||||
.addSelect(`to_char(COALESCE(ts.actual_departure_at, ts.scheduled_departure_date), 'YYYY-MM-DD')`, 'departedAt')
|
.addSelect(`to_char(COALESCE(ts.actual_departure_at, ts.scheduled_departure_date), 'YYYY-MM-DD HH24:MI')`, 'departedAt')
|
||||||
.addSelect("COALESCE(oy.label, oy.code, '?') || ' → ' || COALESCE(dy.label, dy.code, '?')", 'station')
|
.addSelect("COALESCE(lfy.label, lfy.code, '?')", 'legFrom')
|
||||||
.addSelect(CARGO_CATEGORY_LABEL_EXPR, 'category')
|
.addSelect("COALESCE(lty.label, lty.code, '?')", 'legTo')
|
||||||
|
.addSelect(CATEGORY_LABEL_EXPR, 'category')
|
||||||
.addSelect(`ROUND((${CHARGED_TONS_EXPR})::numeric, 2)::float8`, 'chargedTons')
|
.addSelect(`ROUND((${CHARGED_TONS_EXPR})::numeric, 2)::float8`, 'chargedTons')
|
||||||
.addSelect(`ROUND((${ACTUAL_TONS_EXPR})::numeric, 2)::float8`, 'actualTons')
|
.addSelect(`ROUND((${ACTUAL_TONS_EXPR})::numeric, 2)::float8`, 'actualTons')
|
||||||
|
.addSelect(`COALESCE(SUM(${ALLOC_CONTAINERS_20}), 0)::int`, 'containers20')
|
||||||
|
.addSelect(`COALESCE(SUM(${ALLOC_CONTAINERS_40}), 0)::int`, 'containers40')
|
||||||
.addSelect(TEU_EXPR, 'teu')
|
.addSelect(TEU_EXPR, 'teu')
|
||||||
.addSelect(LOADED_WAGONS_EXPR, 'wagons')
|
.addSelect(LOADED_WAGONS_EXPR, 'wagons')
|
||||||
.addSelect(`${EMPTY_WAGONS}::int`, 'emptyWagons')
|
.addSelect(`${EMPTY_WAGONS}::int`, 'emptyWagons')
|
||||||
.addSelect(`${ROUTE_KM}::float8`, 'distanceKm')
|
.addSelect(TOTAL_WAGONS, 'totalWagons')
|
||||||
|
.addSelect(`${LEG_KM}::float8`, 'distanceKm')
|
||||||
.addSelect(TON_KM, 'tonKm')
|
.addSelect(TON_KM, 'tonKm')
|
||||||
.addSelect(VEHICLE_KM, 'vehicleKm')
|
.addSelect(VEHICLE_KM, 'vehicleKm')
|
||||||
.groupBy('ts.id')
|
.groupBy('ts.id')
|
||||||
.addGroupBy('ts.train_number')
|
.addGroupBy('ts.train_number')
|
||||||
.addGroupBy('ts.actual_departure_at')
|
.addGroupBy('ts.actual_departure_at')
|
||||||
.addGroupBy('ts.scheduled_departure_date')
|
.addGroupBy('ts.scheduled_departure_date')
|
||||||
.addGroupBy('oy.label')
|
.addGroupBy('leg.from_yard_id')
|
||||||
.addGroupBy('oy.code')
|
.addGroupBy('leg.to_yard_id')
|
||||||
.addGroupBy('dy.label')
|
.addGroupBy('lfy.label')
|
||||||
.addGroupBy('dy.code')
|
.addGroupBy('lfy.code')
|
||||||
.addGroupBy(CARGO_CATEGORY_EXPR);
|
.addGroupBy('lty.label')
|
||||||
|
.addGroupBy('lty.code')
|
||||||
|
.addGroupBy(CATEGORY_EXPR);
|
||||||
},
|
},
|
||||||
async summary(ctx) {
|
async summary(ctx) {
|
||||||
const row = await baseQuery(ctx)
|
const row = await baseQuery(ctx)
|
||||||
|
|||||||
@@ -91,8 +91,8 @@ export const contractUtilizationReport: ReportDefinition = {
|
|||||||
.addSelect('c.name', 'customer')
|
.addSelect('c.name', 'customer')
|
||||||
.addSelect('ct.status', 'status')
|
.addSelect('ct.status', 'status')
|
||||||
.addSelect('ct.contract_kind', 'kind')
|
.addSelect('ct.contract_kind', 'kind')
|
||||||
.addSelect(`to_char(ct.contract_valid_from, 'YYYY-MM-DD')`, 'validFrom')
|
.addSelect(`to_char(ct.contract_valid_from, 'YYYY-MM-DD HH24:MI')`, 'validFrom')
|
||||||
.addSelect(`to_char(ct.contract_valid_until, 'YYYY-MM-DD')`, 'validUntil')
|
.addSelect(`to_char(ct.contract_valid_until, 'YYYY-MM-DD HH24:MI')`, 'validUntil')
|
||||||
.addSelect('COALESCE(cap.committed, 0)::float8', 'committed')
|
.addSelect('COALESCE(cap.committed, 0)::float8', 'committed')
|
||||||
.addSelect('COALESCE(booked.tons, 0)::float8', 'bookedTons')
|
.addSelect('COALESCE(booked.tons, 0)::float8', 'bookedTons')
|
||||||
.addSelect('COALESCE(booked.cnt, 0)', 'bookings')
|
.addSelect('COALESCE(booked.cnt, 0)', 'bookings')
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
|||||||
import { Booking } from '../../bookings/entities/booking.entity';
|
import { Booking } from '../../bookings/entities/booking.entity';
|
||||||
import { Company } from '../../companies/entities/company.entity';
|
import { Company } from '../../companies/entities/company.entity';
|
||||||
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
|
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
|
||||||
|
import { applyDirectionScope } from '../../user-trade-access/trade-scope.util';
|
||||||
import { ReportContext, ReportDefinition } from '../report.types';
|
import { ReportContext, ReportDefinition } from '../report.types';
|
||||||
|
|
||||||
// FirstMile and LastMile are separate tables with an identical shape (status,
|
// FirstMile and LastMile are separate tables with an identical shape (status,
|
||||||
@@ -27,11 +28,11 @@ const STATUS_OPTIONS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||||
const { params } = ctx;
|
const { params, directions } = ctx;
|
||||||
const qb = ctx.ds
|
const qb = ctx.ds
|
||||||
.createQueryBuilder()
|
.createQueryBuilder()
|
||||||
.from(LEG_UNION, 'fl')
|
.from(LEG_UNION, 'fl')
|
||||||
.innerJoin(Booking, 'b', 'b.id = fl.booking_id')
|
.innerJoin(Booking, 'b', 'b.id = fl.booking_id AND b.deleted_at IS NULL')
|
||||||
.leftJoin(Company, 'c', 'c.id = b.company_id')
|
.leftJoin(Company, 'c', 'c.id = b.company_id')
|
||||||
.leftJoin(Vehicle, 'v', 'v.id = fl.vehicle_id')
|
.leftJoin(Vehicle, 'v', 'v.id = fl.vehicle_id')
|
||||||
.where('1 = 1');
|
.where('1 = 1');
|
||||||
@@ -41,6 +42,10 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
|||||||
if (params.dateTo) qb.andWhere('fl.created_at < :dateTo', { dateTo: params.dateTo });
|
if (params.dateTo) qb.andWhere('fl.created_at < :dateTo', { dateTo: params.dateTo });
|
||||||
const statuses = params.statuses as string[] | null;
|
const statuses = params.statuses as string[] | null;
|
||||||
if (statuses) qb.andWhere('fl.status IN (:...statuses)', { statuses });
|
if (statuses) qb.andWhere('fl.status IN (:...statuses)', { statuses });
|
||||||
|
|
||||||
|
// Every leg hangs off a booking, so the trade scope is the booking's own
|
||||||
|
// direction — the same rule the booking-grain reports apply.
|
||||||
|
applyDirectionScope(qb, 'b.trade_direction', directions);
|
||||||
return qb;
|
return qb;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +77,7 @@ export const firstLastMileBookingsReport: ReportDefinition = {
|
|||||||
.addSelect('fl.status', 'status')
|
.addSelect('fl.status', 'status')
|
||||||
.addSelect("COALESCE(v.plate_number, '—')", 'truck')
|
.addSelect("COALESCE(v.plate_number, '—')", 'truck')
|
||||||
.addSelect("CASE WHEN fl.vehicle_id IS NOT NULL THEN 'Assigned' ELSE 'Unassigned' END", 'assigned')
|
.addSelect("CASE WHEN fl.vehicle_id IS NOT NULL THEN 'Assigned' ELSE 'Unassigned' END", 'assigned')
|
||||||
.addSelect(`to_char(fl.created_at, 'YYYY-MM-DD')`, 'createdAt');
|
.addSelect(`to_char(fl.created_at, 'YYYY-MM-DD HH24:MI')`, 'createdAt');
|
||||||
},
|
},
|
||||||
async summary(ctx) {
|
async summary(ctx) {
|
||||||
const row = await baseQuery(ctx)
|
const row = await baseQuery(ctx)
|
||||||
|
|||||||
@@ -2,22 +2,28 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
|||||||
|
|
||||||
import { ScheduleWagonAdjustmentLog } from '../../train-schedules/entities/schedule-wagon-adjustment-log.entity';
|
import { ScheduleWagonAdjustmentLog } from '../../train-schedules/entities/schedule-wagon-adjustment-log.entity';
|
||||||
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
|
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
|
||||||
|
import { directionScopeSql } from '../../user-trade-access/trade-scope.util';
|
||||||
import { ReportContext, ReportDefinition } from '../report.types';
|
import { ReportContext, ReportDefinition } from '../report.types';
|
||||||
|
|
||||||
// ADD = allocated, REMOVE = cancelled. SWITCH (a physical wagon swap, net
|
// ADD = allocated, REMOVE = cancelled. SWITCH (a physical wagon swap, net
|
||||||
// count unchanged) is excluded — it's neither an allocation nor a cancellation.
|
// count unchanged) is excluded — it's neither an allocation nor a cancellation.
|
||||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||||
const { params } = ctx;
|
const { params, directions } = ctx;
|
||||||
const qb = ctx.ds
|
const qb = ctx.ds
|
||||||
.createQueryBuilder()
|
.createQueryBuilder()
|
||||||
.from(ScheduleWagonAdjustmentLog, 'l')
|
.from(ScheduleWagonAdjustmentLog, 'l')
|
||||||
.leftJoin(TrainSchedule, 'ts', 'ts.id = l.train_schedule_id')
|
.leftJoin(TrainSchedule, 'ts', 'ts.id = l.train_schedule_id AND ts.deleted_at IS NULL')
|
||||||
.where('l.deleted_at IS NULL')
|
.where('l.deleted_at IS NULL')
|
||||||
.andWhere("l.action IN ('ADD', 'REMOVE')");
|
.andWhere("l.action IN ('ADD', 'REMOVE')");
|
||||||
|
|
||||||
if (params.dateFrom) qb.andWhere('l.occurred_at >= :dateFrom', { dateFrom: params.dateFrom });
|
if (params.dateFrom) qb.andWhere('l.occurred_at >= :dateFrom', { dateFrom: params.dateFrom });
|
||||||
if (params.dateTo) qb.andWhere('l.occurred_at < :dateTo', { dateTo: params.dateTo });
|
if (params.dateTo) qb.andWhere('l.occurred_at < :dateTo', { dateTo: params.dateTo });
|
||||||
if (params.direction) qb.andWhere('ts.direction = :direction', { direction: params.direction });
|
if (params.direction) qb.andWhere('ts.direction = :direction', { direction: params.direction });
|
||||||
|
|
||||||
|
// A log row whose schedule is gone carries no direction to scope by and
|
||||||
|
// stays visible — the rule the other ledgers apply to booking-less rows.
|
||||||
|
const scope = directionScopeSql('ts.direction', directions);
|
||||||
|
qb.andWhere(`(ts.id IS NULL OR ${scope.sql})`, scope.params);
|
||||||
return qb;
|
return qb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
|||||||
import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity';
|
import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity';
|
||||||
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
|
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
|
||||||
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||||
|
import { applyDirectionScope } from '../../user-trade-access/trade-scope.util';
|
||||||
import { ReportContext, ReportDefinition } from '../report.types';
|
import { ReportContext, ReportDefinition } from '../report.types';
|
||||||
|
|
||||||
// train_set_wagons.assigned_weight_tons is the planned load per slot, already
|
// train_set_wagons.assigned_weight_tons is the planned load per slot, already
|
||||||
// maintained by the wagon-allocation flow — no need to re-derive it from
|
// maintained by the wagon-allocation flow — no need to re-derive it from
|
||||||
// bulk/container line items.
|
// bulk/container line items.
|
||||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||||
const { params } = ctx;
|
const { params, directions } = ctx;
|
||||||
const qb = ctx.ds
|
const qb = ctx.ds
|
||||||
.createQueryBuilder()
|
.createQueryBuilder()
|
||||||
.from(TrainSetWagon, 'tsw')
|
.from(TrainSetWagon, 'tsw')
|
||||||
@@ -24,6 +25,8 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
|||||||
qb.andWhere('ts.scheduled_departure_date >= :dateFrom', { dateFrom: params.dateFrom });
|
qb.andWhere('ts.scheduled_departure_date >= :dateFrom', { dateFrom: params.dateFrom });
|
||||||
}
|
}
|
||||||
if (params.dateTo) qb.andWhere('ts.scheduled_departure_date < :dateTo', { dateTo: params.dateTo });
|
if (params.dateTo) qb.andWhere('ts.scheduled_departure_date < :dateTo', { dateTo: params.dateTo });
|
||||||
|
|
||||||
|
applyDirectionScope(qb, 'ts.direction', directions);
|
||||||
return qb;
|
return qb;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +52,7 @@ export const loadedCapacityReport: ReportDefinition = {
|
|||||||
query(ctx) {
|
query(ctx) {
|
||||||
return baseQuery(ctx)
|
return baseQuery(ctx)
|
||||||
.select('ts.train_number', 'trainNumber')
|
.select('ts.train_number', 'trainNumber')
|
||||||
.addSelect(`to_char(ts.scheduled_departure_date, 'YYYY-MM-DD')`, 'departureDate')
|
.addSelect(`to_char(ts.scheduled_departure_date, 'YYYY-MM-DD HH24:MI')`, 'departureDate')
|
||||||
.addSelect("COALESCE(wt.name, 'Unknown')", 'wagonType')
|
.addSelect("COALESCE(wt.name, 'Unknown')", 'wagonType')
|
||||||
.addSelect('COUNT(*)::int', 'wagons')
|
.addSelect('COUNT(*)::int', 'wagons')
|
||||||
.addSelect('COALESCE(SUM(tsw.capacity_tons), 0)::float8', 'capacityTons')
|
.addSelect('COALESCE(SUM(tsw.capacity_tons), 0)::float8', 'capacityTons')
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { visibleColumns } from '../report-runner.service';
|
||||||
|
import { loadingUnloadingReport as def } from './loading-unloading.report';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two grains select different columns — per train the stop's own times,
|
||||||
|
* per station the averages over it. A column shown under a grain its query
|
||||||
|
* doesn't select is a blank column; a column SORTED under one is a 42703.
|
||||||
|
*/
|
||||||
|
describe('loading-unloading', () => {
|
||||||
|
const keys = (grain: string) => visibleColumns(def, { grain }).map((c) => c.key);
|
||||||
|
|
||||||
|
it('shows the stop times per train and the averages per station, never both', () => {
|
||||||
|
expect(keys('train')).toEqual(expect.arrayContaining(['arrivedAt', 'loadUnloadHours']));
|
||||||
|
expect(keys('train')).not.toEqual(expect.arrayContaining(['avgLoadUnloadHours', 'stops']));
|
||||||
|
expect(keys('station')).toEqual(expect.arrayContaining(['avgLoadUnloadHours', 'stops']));
|
||||||
|
expect(keys('station')).not.toEqual(expect.arrayContaining(['arrivedAt', 'trainNumber']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sorts by a column both grains select, so the default sort never 42703s', () => {
|
||||||
|
for (const grain of ['train', 'station']) {
|
||||||
|
expect(keys(grain)).toContain(def.defaultSort!.key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults the grain, so an unset filter can't show the wrong half", () => {
|
||||||
|
const grain = def.filters.find((f) => f.key === 'grain')!.defaultValue;
|
||||||
|
expect(grain).toBe('train');
|
||||||
|
expect(keys(grain!)).toEqual(keys('train'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
import { ReportContext, ReportDefinition, ReportFilterDef } from '../report.types';
|
||||||
|
import {
|
||||||
|
COUNTRY_FILTER,
|
||||||
|
DIRECTION_FILTER,
|
||||||
|
TRAIN_TYPE_FILTER,
|
||||||
|
cycleRateExpr,
|
||||||
|
handlingHours,
|
||||||
|
hoursBetween,
|
||||||
|
loadingEnd,
|
||||||
|
loadingHours,
|
||||||
|
loadingSource,
|
||||||
|
loadingStart,
|
||||||
|
otherActivityHours,
|
||||||
|
stationStaysQb,
|
||||||
|
unloadingHours,
|
||||||
|
} from '../operations-classification';
|
||||||
|
import { PERIOD_FILTER, periodExprOn, periodTruncExprOn } from '../revenue-classification';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loading and unloading per train — the spec's own report format: train number,
|
||||||
|
* total loading and unloading time, other activity, station staying time.
|
||||||
|
*
|
||||||
|
* Two shapes, one definition. Per train the row is the stop itself: the logged
|
||||||
|
* arrival, departure, unloading and loading times and that stop's own
|
||||||
|
* durations, because the train number is what makes a specific stop worth
|
||||||
|
* naming. Per station it rolls up into the chosen period — one row per station,
|
||||||
|
* averaged over every train that called there, which is what "for week report,
|
||||||
|
* calculate average in the week" asks for. The station stays in both grains
|
||||||
|
* because a train works both ends of the corridor and the standard it is judged
|
||||||
|
* against differs by side (10h Ethiopia, 13h Djibouti) — averaging a train's
|
||||||
|
* Nagad and Gelan stops together would compare that mixture to one standard.
|
||||||
|
*
|
||||||
|
* Container work reads as one handling window (unloading start to loading end)
|
||||||
|
* because that is how the corridor measures a turnaround; bulk stations, which
|
||||||
|
* load OR unload rather than both, get the two halves in their own columns.
|
||||||
|
*
|
||||||
|
* Averages skip the stops with no handling window rather than counting them as
|
||||||
|
* zero — `AVG` ignores nulls — so `Stops` is the population and `Handling
|
||||||
|
* measured` says how much of it the handling averages actually rest on.
|
||||||
|
*/
|
||||||
|
const STAYING_HOURS = hoursBetween('s.arrived_at', 's.departed_at');
|
||||||
|
const HANDLING_HOURS = handlingHours('s');
|
||||||
|
const OTHER_ACTIVITY_HOURS = otherActivityHours(STAYING_HOURS, HANDLING_HOURS);
|
||||||
|
|
||||||
|
const avg = (expr: string): string => `ROUND(AVG((${expr})::numeric), 1)::float8`;
|
||||||
|
|
||||||
|
/** Reused verbatim in the GROUP BY — the group key is the coalesced value. */
|
||||||
|
const TRAIN_NUMBER = "COALESCE(s.train_number, '—')";
|
||||||
|
|
||||||
|
const HANDLING_STANDARD = 'MAX(s.handling_standard_hours)';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which way the rows roll up.
|
||||||
|
*
|
||||||
|
* Per train is the spec's container format; per station is its bulk one, which
|
||||||
|
* asks for the loading and unloading time AT Negad, BCC, DMP, Sebeta, GMP,
|
||||||
|
* Adama and Modjo rather than per train. Same measurements either way — only
|
||||||
|
* the group key moves — so one definition serves both.
|
||||||
|
*/
|
||||||
|
const GRAIN_FILTER: ReportFilterDef = {
|
||||||
|
key: 'grain',
|
||||||
|
label: 'Group by',
|
||||||
|
type: 'select',
|
||||||
|
defaultValue: 'train',
|
||||||
|
options: [
|
||||||
|
{ value: 'train', label: 'Train' },
|
||||||
|
{ value: 'station', label: 'Station' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Per train the rows are stops, so they carry times; per station, averages. */
|
||||||
|
const TRAIN_ONLY = { grain: 'station' };
|
||||||
|
const STATION_ONLY = { grain: 'train' };
|
||||||
|
|
||||||
|
/** Same display as the staying-time report, so a stop reads alike in both. */
|
||||||
|
const at = (expr: string): string => `to_char(${expr}, 'YYYY-MM-DD HH24:MI')`;
|
||||||
|
|
||||||
|
/** Whitelisted here, so the user's value never reaches SQL. */
|
||||||
|
const byStation = (ctx: ReportContext): boolean => ctx.params.grain === 'station';
|
||||||
|
|
||||||
|
export const loadingUnloadingReport: ReportDefinition = {
|
||||||
|
key: 'loading-unloading',
|
||||||
|
title: 'Loading & Unloading',
|
||||||
|
description:
|
||||||
|
'Loading and unloading, at the granularity you choose. Grouped by Train the row is one ' +
|
||||||
|
'stop — its logged arrival, departure, unloading and loading times and that stop’s own ' +
|
||||||
|
'durations. Grouped by Station it is one row per station per period, averaged over every ' +
|
||||||
|
'train that called there, the way the OCC report publishes it. Total loading and unloading is ' +
|
||||||
|
'the stop’s handling window, unloading start to loading end, which is the container ' +
|
||||||
|
'measure; the unloading and loading columns split it for bulk stations that only do ' +
|
||||||
|
'one of the two (Nagad, BCC and DMP on the Djibouti side; Sebeta, GMP, Adama and Modjo ' +
|
||||||
|
'on the Ethiopian) — switch Group by to Station for that view. Other activity is the rest ' +
|
||||||
|
'of the station stay, against the 10h Ethiopia / 13h Djibouti standard from Operating ' +
|
||||||
|
'standards. The loading window falls back to the first and last booking loaded here when ' +
|
||||||
|
'nobody recorded it by hand, which “Loading from” reports as Derived; unloading is only ' +
|
||||||
|
'ever hand-recorded. Averages rest only on the stops that have a handling window at all — ' +
|
||||||
|
'“Handling measured” counts them. Handling rate needs a handling standard set in Operating ' +
|
||||||
|
'standards and reads empty until there is one.',
|
||||||
|
group: 'Operations',
|
||||||
|
filters: [
|
||||||
|
PERIOD_FILTER,
|
||||||
|
GRAIN_FILTER,
|
||||||
|
{ key: 'date', label: 'Arrival', type: 'daterange' },
|
||||||
|
TRAIN_TYPE_FILTER,
|
||||||
|
DIRECTION_FILTER,
|
||||||
|
{ key: 'trainNumber', label: 'Train No.', type: 'text' },
|
||||||
|
{ key: 'station', label: 'Station', type: 'text' },
|
||||||
|
COUNTRY_FILTER,
|
||||||
|
],
|
||||||
|
columns: [
|
||||||
|
{ key: 'period', label: 'Period', type: 'string', sortable: true },
|
||||||
|
{
|
||||||
|
key: 'trainNumber',
|
||||||
|
label: 'Train No.',
|
||||||
|
type: 'string',
|
||||||
|
sortable: true,
|
||||||
|
hideWhen: TRAIN_ONLY,
|
||||||
|
},
|
||||||
|
{ key: 'station', label: 'Station', type: 'string', sortable: true },
|
||||||
|
{ key: 'country', label: 'Country', type: 'string' },
|
||||||
|
// Per station this would be a MAX over whatever mix of trains called there.
|
||||||
|
{
|
||||||
|
key: 'trainType',
|
||||||
|
label: 'Train type',
|
||||||
|
type: 'string',
|
||||||
|
hideWhen: TRAIN_ONLY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'stops',
|
||||||
|
label: 'Stops',
|
||||||
|
type: 'number',
|
||||||
|
sortable: true,
|
||||||
|
hideWhen: STATION_ONLY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'handlingMeasured',
|
||||||
|
label: 'Handling measured',
|
||||||
|
type: 'number',
|
||||||
|
hideWhen: STATION_ONLY,
|
||||||
|
},
|
||||||
|
{ key: 'loadingSource', label: 'Loading from', type: 'string' },
|
||||||
|
// Per train: this stop's own clock, not a mean of several.
|
||||||
|
{
|
||||||
|
key: 'arrivedAt',
|
||||||
|
label: 'Arrived',
|
||||||
|
type: 'date',
|
||||||
|
sortable: true,
|
||||||
|
hideWhen: TRAIN_ONLY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'departedAt',
|
||||||
|
label: 'Departed',
|
||||||
|
type: 'date',
|
||||||
|
hideWhen: TRAIN_ONLY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'unloadingStartedAt',
|
||||||
|
label: 'Unloading start',
|
||||||
|
type: 'date',
|
||||||
|
hideWhen: TRAIN_ONLY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'unloadingCompletedAt',
|
||||||
|
label: 'Unloading end',
|
||||||
|
type: 'date',
|
||||||
|
hideWhen: TRAIN_ONLY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'loadingStartedAt',
|
||||||
|
label: 'Loading start',
|
||||||
|
type: 'date',
|
||||||
|
hideWhen: TRAIN_ONLY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'loadingCompletedAt',
|
||||||
|
label: 'Loading end',
|
||||||
|
type: 'date',
|
||||||
|
hideWhen: TRAIN_ONLY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'unloadingHours',
|
||||||
|
label: 'Unloading (hrs)',
|
||||||
|
type: 'number',
|
||||||
|
sortable: true,
|
||||||
|
hideWhen: TRAIN_ONLY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'loadingHours',
|
||||||
|
label: 'Loading (hrs)',
|
||||||
|
type: 'number',
|
||||||
|
sortable: true,
|
||||||
|
hideWhen: TRAIN_ONLY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'loadUnloadHours',
|
||||||
|
label: 'Loading + unloading (hrs)',
|
||||||
|
type: 'number',
|
||||||
|
sortable: true,
|
||||||
|
hideWhen: TRAIN_ONLY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'otherActivityHours',
|
||||||
|
label: 'Other activity (hrs)',
|
||||||
|
type: 'number',
|
||||||
|
hideWhen: TRAIN_ONLY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'stayingHours',
|
||||||
|
label: 'Staying (hrs)',
|
||||||
|
type: 'number',
|
||||||
|
sortable: true,
|
||||||
|
hideWhen: TRAIN_ONLY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'avgUnloadingHours',
|
||||||
|
label: 'Avg unloading (hrs)',
|
||||||
|
type: 'number',
|
||||||
|
sortable: true,
|
||||||
|
hideWhen: STATION_ONLY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'avgLoadingHours',
|
||||||
|
label: 'Avg loading (hrs)',
|
||||||
|
type: 'number',
|
||||||
|
sortable: true,
|
||||||
|
hideWhen: STATION_ONLY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'avgLoadUnloadHours',
|
||||||
|
label: 'Avg loading + unloading (hrs)',
|
||||||
|
type: 'number',
|
||||||
|
sortable: true,
|
||||||
|
hideWhen: STATION_ONLY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'avgOtherActivityHours',
|
||||||
|
label: 'Avg other activity (hrs)',
|
||||||
|
type: 'number',
|
||||||
|
hideWhen: STATION_ONLY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'avgStayingHours',
|
||||||
|
label: 'Avg staying (hrs)',
|
||||||
|
type: 'number',
|
||||||
|
sortable: true,
|
||||||
|
hideWhen: STATION_ONLY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'stayStandardHours',
|
||||||
|
label: 'Staying standard (hrs)',
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
{ key: 'stayVerdict', label: 'Staying verdict', type: 'string' },
|
||||||
|
{
|
||||||
|
key: 'handlingStandardHours',
|
||||||
|
label: 'Handling standard (hrs)',
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'handlingRate',
|
||||||
|
label: 'Handling rate',
|
||||||
|
type: 'percent',
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
defaultSort: { key: 'period', dir: 'DESC' },
|
||||||
|
// Only plottable at station grain — per train the rows are individual stops,
|
||||||
|
// and the frontend drops the chart toggle when its columns are hidden.
|
||||||
|
chart: { type: 'bar', x: 'station', y: ['avgLoadUnloadHours'] },
|
||||||
|
query(ctx) {
|
||||||
|
const { params } = ctx;
|
||||||
|
|
||||||
|
// Per train the row IS the stop: its own logged times and its own
|
||||||
|
// durations, since an average of one stop is just the stop with the clock
|
||||||
|
// thrown away. Averaging starts where the grain stops naming the train.
|
||||||
|
if (!byStation(ctx)) {
|
||||||
|
return stationStaysQb(ctx)
|
||||||
|
.select(periodExprOn('s.arrived_at', params), 'period')
|
||||||
|
.addSelect(TRAIN_NUMBER, 'trainNumber')
|
||||||
|
.addSelect('s.station', 'station')
|
||||||
|
.addSelect('s.country', 'country')
|
||||||
|
.addSelect('s.train_type', 'trainType')
|
||||||
|
.addSelect(loadingSource('s'), 'loadingSource')
|
||||||
|
.addSelect(at('s.arrived_at'), 'arrivedAt')
|
||||||
|
.addSelect(at('s.departed_at'), 'departedAt')
|
||||||
|
.addSelect(at('s.unloading_started_at'), 'unloadingStartedAt')
|
||||||
|
.addSelect(at('s.unloading_completed_at'), 'unloadingCompletedAt')
|
||||||
|
.addSelect(at(loadingStart('s')), 'loadingStartedAt')
|
||||||
|
.addSelect(at(loadingEnd('s')), 'loadingCompletedAt')
|
||||||
|
.addSelect(unloadingHours('s'), 'unloadingHours')
|
||||||
|
.addSelect(loadingHours('s'), 'loadingHours')
|
||||||
|
.addSelect(HANDLING_HOURS, 'loadUnloadHours')
|
||||||
|
.addSelect(OTHER_ACTIVITY_HOURS, 'otherActivityHours')
|
||||||
|
.addSelect(STAYING_HOURS, 'stayingHours')
|
||||||
|
.addSelect('s.standard_hours::float8', 'stayStandardHours')
|
||||||
|
.addSelect(
|
||||||
|
`CASE WHEN (${STAYING_HOURS})::numeric <= s.standard_hours
|
||||||
|
THEN 'Encouraging' ELSE 'Needs reason' END`,
|
||||||
|
'stayVerdict',
|
||||||
|
)
|
||||||
|
.addSelect('s.handling_standard_hours::float8', 'handlingStandardHours')
|
||||||
|
.addSelect(
|
||||||
|
cycleRateExpr(`(${HANDLING_HOURS})::numeric`, 's.handling_standard_hours'),
|
||||||
|
'handlingRate',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reused verbatim in the GROUP BY, per the trap documented on `periodExpr`.
|
||||||
|
const bucket = periodTruncExprOn('s.arrived_at', params);
|
||||||
|
|
||||||
|
return (
|
||||||
|
stationStaysQb(ctx)
|
||||||
|
.select(periodExprOn('s.arrived_at', params), 'period')
|
||||||
|
.addSelect('s.station', 'station')
|
||||||
|
.addSelect('s.country', 'country')
|
||||||
|
.addSelect('COUNT(*)::int', 'stops')
|
||||||
|
.addSelect(`COUNT(${HANDLING_HOURS})::int`, 'handlingMeasured')
|
||||||
|
// Which side of the COALESCE the loading columns came from. A group that
|
||||||
|
// mixes both says so rather than claiming either.
|
||||||
|
.addSelect(
|
||||||
|
`CASE WHEN COUNT(DISTINCT ${loadingSource('s')}) > 1 THEN 'Mixed'
|
||||||
|
ELSE MAX(${loadingSource('s')}) END`,
|
||||||
|
'loadingSource',
|
||||||
|
)
|
||||||
|
.addSelect(avg(unloadingHours('s')), 'avgUnloadingHours')
|
||||||
|
.addSelect(avg(loadingHours('s')), 'avgLoadingHours')
|
||||||
|
.addSelect(avg(HANDLING_HOURS), 'avgLoadUnloadHours')
|
||||||
|
.addSelect(avg(OTHER_ACTIVITY_HOURS), 'avgOtherActivityHours')
|
||||||
|
.addSelect(avg(STAYING_HOURS), 'avgStayingHours')
|
||||||
|
.addSelect('MAX(s.standard_hours)::float8', 'stayStandardHours')
|
||||||
|
.addSelect(
|
||||||
|
`CASE WHEN AVG((${STAYING_HOURS})::numeric) <= MAX(s.standard_hours)
|
||||||
|
THEN 'Encouraging' ELSE 'Needs reason' END`,
|
||||||
|
'stayVerdict',
|
||||||
|
)
|
||||||
|
.addSelect(`${HANDLING_STANDARD}::float8`, 'handlingStandardHours')
|
||||||
|
// Same formula the turnaround cycle publishes, so the two read alike.
|
||||||
|
// NULL standard in, NULL rate out — nothing to measure against yet.
|
||||||
|
.addSelect(
|
||||||
|
cycleRateExpr(`AVG((${HANDLING_HOURS})::numeric)`, HANDLING_STANDARD),
|
||||||
|
'handlingRate',
|
||||||
|
)
|
||||||
|
.groupBy(bucket)
|
||||||
|
.addGroupBy('s.station')
|
||||||
|
.addGroupBy('s.country')
|
||||||
|
);
|
||||||
|
},
|
||||||
|
async summary(ctx) {
|
||||||
|
const row = await stationStaysQb(ctx)
|
||||||
|
.select('COUNT(*)::int', 'stops')
|
||||||
|
.addSelect(`COUNT(${HANDLING_HOURS})::int`, 'measured')
|
||||||
|
.addSelect(avg(HANDLING_HOURS), 'avgHandling')
|
||||||
|
.addSelect(avg(OTHER_ACTIVITY_HOURS), 'avgOther')
|
||||||
|
.getRawOne<{
|
||||||
|
stops: number;
|
||||||
|
measured: number;
|
||||||
|
avgHandling: number;
|
||||||
|
avgOther: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ label: 'Stops measured', value: Number(row?.stops ?? 0) },
|
||||||
|
{ label: 'Handling measured', value: Number(row?.measured ?? 0) },
|
||||||
|
{
|
||||||
|
label: 'Average loading + unloading',
|
||||||
|
value: Number(row?.avgHandling ?? 0),
|
||||||
|
unit: 'h',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Average other activity',
|
||||||
|
value: Number(row?.avgOther ?? 0),
|
||||||
|
unit: 'h',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -12,7 +12,10 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
|||||||
.createQueryBuilder()
|
.createQueryBuilder()
|
||||||
.from(Locomotive, 'l')
|
.from(Locomotive, 'l')
|
||||||
.leftJoin(Yard, 'y', 'y.id = l.current_yard_id')
|
.leftJoin(Yard, 'y', 'y.id = l.current_yard_id')
|
||||||
.where('l.deleted_at IS NULL');
|
.where('l.deleted_at IS NULL')
|
||||||
|
// Names ending '#' are excluded from the fleet report by request; the
|
||||||
|
// marker is a roster convention, not a column the schema tracks.
|
||||||
|
.andWhere("COALESCE(l.name, '') NOT LIKE '%#'");
|
||||||
|
|
||||||
const statuses = params.statuses as string[] | null;
|
const statuses = params.statuses as string[] | null;
|
||||||
if (statuses) qb.andWhere('l.status IN (:...statuses)', { statuses });
|
if (statuses) qb.andWhere('l.status IN (:...statuses)', { statuses });
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import {
|
||||||
|
LINES,
|
||||||
|
countAliases,
|
||||||
|
lineRefs,
|
||||||
|
portWarehouseSummaryReport,
|
||||||
|
} from "./port-warehouse-summary.report";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sheet's lines are SQL fragments over the aggregate's select aliases, so a
|
||||||
|
* renamed or dropped count is invisible to the type-checker and surfaces as a
|
||||||
|
* 42703 the first time someone opens the report.
|
||||||
|
*/
|
||||||
|
describe("port-warehouse-summary", () => {
|
||||||
|
it("every line reads a column the aggregate selects", () => {
|
||||||
|
expect(lineRefs().filter((ref) => !countAliases().includes(ref))).toEqual(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("every selected count is used by a line", () => {
|
||||||
|
expect(
|
||||||
|
countAliases().filter((alias) => !lineRefs().includes(alias)),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("labels are unique — the sheet groups on them", () => {
|
||||||
|
const labels = LINES.map((l) => l.label);
|
||||||
|
expect(new Set(labels).size).toBe(labels.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("declares a column for every direction the pivot emits", () => {
|
||||||
|
const keys = portWarehouseSummaryReport.columns.map((c) => c.key);
|
||||||
|
expect(keys).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
"sn",
|
||||||
|
"section",
|
||||||
|
"metric",
|
||||||
|
"export",
|
||||||
|
"import",
|
||||||
|
"domestic",
|
||||||
|
"total",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
import { ObjectLiteral, SelectQueryBuilder } from "typeorm";
|
||||||
|
|
||||||
|
import { ReportContext, ReportDefinition } from "../report.types";
|
||||||
|
import {
|
||||||
|
ALLOC_CONTAINERS_20,
|
||||||
|
ALLOC_CONTAINERS_40,
|
||||||
|
OPERATIONS_FILTERS,
|
||||||
|
SCHEDULE_IS_CONTAINER,
|
||||||
|
allocationLedgerQb,
|
||||||
|
} from "../operations-classification";
|
||||||
|
import { yardOptions } from "../revenue-classification";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The monthly operations summary a port warehouse publishes — the shape of the
|
||||||
|
* GMP workbook: one line per operation, counted separately for export and
|
||||||
|
* import, and again over everything.
|
||||||
|
*
|
||||||
|
* Every other operations report is a normal grouped table; this one is a
|
||||||
|
* transposed count sheet, because that is the artefact being reproduced. It is
|
||||||
|
* built by aggregating the allocation ledger once per direction and once
|
||||||
|
* overall, unpivoting each of those rows into one row per named operation, then
|
||||||
|
* pivoting direction back out into columns.
|
||||||
|
*
|
||||||
|
* Two deliberate departures from the workbook:
|
||||||
|
*
|
||||||
|
* - **Wagons are counted, not derived.** The workbook computes wagons as
|
||||||
|
* `20ft/2 + 40ft + bulk wagons` because it has no marshalling record. We do —
|
||||||
|
* `COUNT(DISTINCT train_set_wagons.id)` is what actually carried the cargo.
|
||||||
|
* The two disagree whenever a wagon ran part-loaded, and the counted figure
|
||||||
|
* is the true one.
|
||||||
|
* - **Demurrage is not here.** It is billed on invoice lines, a different fact
|
||||||
|
* table entirely; Revenue by Category filtered to Demurrage already answers
|
||||||
|
* it and joining it in at allocation grain would double-count.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Booking-level empty marker, NULL-safe so an allocation with no booking is "laden". */
|
||||||
|
const IS_EMPTY = "COALESCE(b.equipment_return = 'RETURN', false)";
|
||||||
|
const IS_CONTAINER_LOAD = "wba.load_type = 'CONTAINER'";
|
||||||
|
|
||||||
|
/** The bulk twin of {@link SCHEDULE_IS_CONTAINER} — same train-set grain. */
|
||||||
|
const SCHEDULE_IS_BULK = `EXISTS (
|
||||||
|
SELECT 1 FROM freight.wagon_booking_allocations a
|
||||||
|
JOIN freight.train_set_wagons w ON w.id = a.train_set_wagon_id AND w.deleted_at IS NULL
|
||||||
|
WHERE w.train_set_id = ts.train_set_id
|
||||||
|
AND a.deleted_at IS NULL AND a.load_type <> 'CONTAINER'
|
||||||
|
)`;
|
||||||
|
|
||||||
|
const DIRECTION = "COALESCE(b.trade_direction, ts.direction)";
|
||||||
|
|
||||||
|
const boxes = (perAllocation: string, cond: string): string =>
|
||||||
|
`(COALESCE(SUM(${perAllocation}) FILTER (WHERE ${cond}), 0))::int`;
|
||||||
|
|
||||||
|
const trains = (cond?: string): string =>
|
||||||
|
`(COUNT(DISTINCT ts.id)${cond ? ` FILTER (WHERE ${cond})` : ""})::int`;
|
||||||
|
|
||||||
|
const wagons = (cond?: string): string =>
|
||||||
|
`(COUNT(DISTINCT tsw.id)${cond ? ` FILTER (WHERE ${cond})` : ""})::int`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The raw counts the sheet is built from, keyed by the alias each is selected
|
||||||
|
* as. {@link LINES} may only reference these; the spec beside this file is what
|
||||||
|
* keeps the two in step, since a stale `p.<alias>` is a runtime 42703 that
|
||||||
|
* neither tsc nor a type-check can see.
|
||||||
|
*/
|
||||||
|
const COUNTS: Record<string, string> = {
|
||||||
|
trains: trains(),
|
||||||
|
container_trains: trains(
|
||||||
|
`${SCHEDULE_IS_CONTAINER} AND NOT ${SCHEDULE_IS_BULK}`,
|
||||||
|
),
|
||||||
|
bulk_trains: trains(`${SCHEDULE_IS_BULK} AND NOT ${SCHEDULE_IS_CONTAINER}`),
|
||||||
|
mixed_trains: trains(`${SCHEDULE_IS_CONTAINER} AND ${SCHEDULE_IS_BULK}`),
|
||||||
|
full_20: boxes(
|
||||||
|
ALLOC_CONTAINERS_20,
|
||||||
|
`${IS_CONTAINER_LOAD} AND NOT ${IS_EMPTY}`,
|
||||||
|
),
|
||||||
|
full_40: boxes(
|
||||||
|
ALLOC_CONTAINERS_40,
|
||||||
|
`${IS_CONTAINER_LOAD} AND NOT ${IS_EMPTY}`,
|
||||||
|
),
|
||||||
|
empty_20: boxes(ALLOC_CONTAINERS_20, `${IS_CONTAINER_LOAD} AND ${IS_EMPTY}`),
|
||||||
|
empty_40: boxes(ALLOC_CONTAINERS_40, `${IS_CONTAINER_LOAD} AND ${IS_EMPTY}`),
|
||||||
|
full_wagons: wagons(`${IS_CONTAINER_LOAD} AND NOT ${IS_EMPTY}`),
|
||||||
|
empty_wagons: wagons(`${IS_CONTAINER_LOAD} AND ${IS_EMPTY}`),
|
||||||
|
bulk_wagons: wagons(`NOT ${IS_CONTAINER_LOAD}`),
|
||||||
|
wagons: wagons(),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The operation lines, in the workbook's order. `value` is an expression over
|
||||||
|
* the per-direction aggregate `p`, so a derived line (totals, TEU) is plain
|
||||||
|
* arithmetic rather than a second pass over the ledger.
|
||||||
|
*/
|
||||||
|
export const LINES: { section: string; label: string; value: string }[] = [
|
||||||
|
{ section: "Trains", label: "Total trains", value: "p.trains" },
|
||||||
|
{ section: "Trains", label: "Container trains", value: "p.container_trains" },
|
||||||
|
{ section: "Trains", label: "Bulk cargo trains", value: "p.bulk_trains" },
|
||||||
|
{
|
||||||
|
section: "Trains",
|
||||||
|
label: "Mixed bulk and container trains",
|
||||||
|
value: "p.mixed_trains",
|
||||||
|
},
|
||||||
|
{ section: "Containers", label: "Full containers 20ft", value: "p.full_20" },
|
||||||
|
{ section: "Containers", label: "Full containers 40ft", value: "p.full_40" },
|
||||||
|
{
|
||||||
|
section: "Containers",
|
||||||
|
label: "Empty containers 20ft",
|
||||||
|
value: "p.empty_20",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
section: "Containers",
|
||||||
|
label: "Empty containers 40ft",
|
||||||
|
value: "p.empty_40",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
section: "Containers",
|
||||||
|
label: "Total 20ft containers",
|
||||||
|
value: "p.full_20 + p.empty_20",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
section: "Containers",
|
||||||
|
label: "Total 40ft containers",
|
||||||
|
value: "p.full_40 + p.empty_40",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
section: "Containers",
|
||||||
|
label: "Total containers",
|
||||||
|
value: "p.full_20 + p.empty_20 + p.full_40 + p.empty_40",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
section: "Containers",
|
||||||
|
label: "Total TEU",
|
||||||
|
value: "p.full_20 + p.empty_20 + (p.full_40 + p.empty_40) * 2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
section: "Wagons",
|
||||||
|
label: "Wagons loaded with full containers",
|
||||||
|
value: "p.full_wagons",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
section: "Wagons",
|
||||||
|
label: "Wagons loaded with empty containers",
|
||||||
|
value: "p.empty_wagons",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
section: "Wagons",
|
||||||
|
label: "Wagons loaded with bulk cargo",
|
||||||
|
value: "p.bulk_wagons",
|
||||||
|
},
|
||||||
|
{ section: "Wagons", label: "Total wagons", value: "p.wagons" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Every `p.<alias>` a line reads, or the aliases the aggregate offers. */
|
||||||
|
export const lineRefs = (): string[] => [
|
||||||
|
...new Set(
|
||||||
|
LINES.flatMap((l) => [...l.value.matchAll(/\bp\.(\w+)/g)].map((m) => m[1])),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
export const countAliases = (): string[] => Object.keys(COUNTS);
|
||||||
|
|
||||||
|
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||||
|
const qb = allocationLedgerQb(ctx);
|
||||||
|
if (ctx.params.station) {
|
||||||
|
// Either end of the corridor — a warehouse reports the trains it worked,
|
||||||
|
// whichever direction they ran.
|
||||||
|
qb.andWhere("(oy.code = :station OR dy.code = :station)", {
|
||||||
|
station: ctx.params.station,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return qb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The raw counts, one row per direction — or, with `grouped` false, one row for
|
||||||
|
* everything under the pseudo-direction `ALL`.
|
||||||
|
*
|
||||||
|
* The second form is not a convenience: trains and wagons are counted
|
||||||
|
* distinctly, and a train carrying both an import and an export booking belongs
|
||||||
|
* to both directions. Adding the direction columns up would count it twice, so
|
||||||
|
* the Total column reads this row instead of summing the others.
|
||||||
|
*/
|
||||||
|
function aggregate(
|
||||||
|
ctx: ReportContext,
|
||||||
|
grouped: boolean,
|
||||||
|
): SelectQueryBuilder<ObjectLiteral> {
|
||||||
|
const qb = baseQuery(ctx).select(grouped ? DIRECTION : "'ALL'", "dir");
|
||||||
|
for (const [alias, expr] of Object.entries(COUNTS)) qb.addSelect(expr, alias);
|
||||||
|
if (grouped) qb.groupBy(DIRECTION);
|
||||||
|
return qb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bulk sheet: wagons per cargo type. Grouped on the cargo type itself, not
|
||||||
|
* the coarse cargo category the other operations reports use — the workbook
|
||||||
|
* lists wheat, sugar and lentils separately and the category vocabulary folds
|
||||||
|
* all three into BULK.
|
||||||
|
*/
|
||||||
|
function bulkByCargoType(
|
||||||
|
ctx: ReportContext,
|
||||||
|
grouped: boolean,
|
||||||
|
): SelectQueryBuilder<ObjectLiteral> {
|
||||||
|
const qb = baseQuery(ctx)
|
||||||
|
.andWhere(`NOT ${IS_CONTAINER_LOAD}`)
|
||||||
|
.select(grouped ? DIRECTION : "'ALL'", "dir")
|
||||||
|
.addSelect("900", "sn")
|
||||||
|
.addSelect("'Bulk cargo'", "section")
|
||||||
|
.addSelect("COALESCE(ct.cargo_type_name, 'Unclassified')", "metric")
|
||||||
|
.addSelect(wagons(), "value")
|
||||||
|
.groupBy("ct.cargo_type_name");
|
||||||
|
if (grouped) qb.addGroupBy(DIRECTION);
|
||||||
|
return qb;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const portWarehouseSummaryReport: ReportDefinition = {
|
||||||
|
key: "port-warehouse-summary",
|
||||||
|
title: "Port Warehouse Operations Summary",
|
||||||
|
description:
|
||||||
|
"The monthly count sheet a port warehouse publishes: trains, containers by size and " +
|
||||||
|
"laden state, wagons and TEU, each split into export and import beside an overall total, " +
|
||||||
|
"followed " +
|
||||||
|
"by bulk cargo wagons per cargo type. Pick a station to report one warehouse and a date " +
|
||||||
|
"range to report one month. Counted from the marshalling record — what was actually put " +
|
||||||
|
"on the train. Wagons are counted distinctly rather than derived from container counts, " +
|
||||||
|
"so a part-loaded wagon counts once. Total is counted over everything rather than summed " +
|
||||||
|
"across the direction columns, because a train carrying both an import and an export " +
|
||||||
|
"booking belongs to both and would otherwise count twice. Demurrage is billed on invoice lines and is not " +
|
||||||
|
"part of this report; use Revenue by Category filtered to Demurrage.",
|
||||||
|
group: "Operations",
|
||||||
|
filters: [
|
||||||
|
...OPERATIONS_FILTERS,
|
||||||
|
{
|
||||||
|
key: "station",
|
||||||
|
label: "Station / warehouse",
|
||||||
|
type: "select",
|
||||||
|
optionsQuery: yardOptions,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
columns: [
|
||||||
|
{ key: "sn", label: "S/N", type: "number", sortable: true },
|
||||||
|
{ key: "section", label: "Section", type: "string", sortable: true },
|
||||||
|
{
|
||||||
|
key: "metric",
|
||||||
|
label: "Name of operation",
|
||||||
|
type: "string",
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
{ key: "export", label: "Export", type: "number", sortable: true },
|
||||||
|
{ key: "import", label: "Import", type: "number", sortable: true },
|
||||||
|
{ key: "domestic", label: "Domestic", type: "number", sortable: true },
|
||||||
|
{ key: "total", label: "Total", type: "number", sortable: true },
|
||||||
|
],
|
||||||
|
defaultSort: { key: "sn", dir: "ASC" },
|
||||||
|
query(ctx) {
|
||||||
|
const aggs = [aggregate(ctx, true), aggregate(ctx, false)];
|
||||||
|
const bulks = [bulkByCargoType(ctx, true), bulkByCargoType(ctx, false)];
|
||||||
|
|
||||||
|
// The fixed lines, unpivoted. sn is the line's position in LINES, so the
|
||||||
|
// sheet keeps the workbook's order regardless of what the values are.
|
||||||
|
const values = LINES.map(
|
||||||
|
(l, i) =>
|
||||||
|
`(${i + 1}, '${l.section}', '${l.label.replace(/'/g, "''")}', (${l.value})::int)`,
|
||||||
|
).join(",\n ");
|
||||||
|
|
||||||
|
const long = [
|
||||||
|
...aggs.map(
|
||||||
|
(agg) => `
|
||||||
|
SELECT p.dir, v.sn, v.section, v.metric, v.value
|
||||||
|
FROM (${agg.getQuery()}) p
|
||||||
|
CROSS JOIN LATERAL (VALUES
|
||||||
|
${values}
|
||||||
|
) AS v(sn, section, metric, value)`,
|
||||||
|
),
|
||||||
|
...bulks.map(
|
||||||
|
(bulk) =>
|
||||||
|
`SELECT bq.dir, bq.sn, bq.section, bq.metric, bq.value FROM (${bulk.getQuery()}) bq`,
|
||||||
|
),
|
||||||
|
].join("\n UNION ALL\n");
|
||||||
|
|
||||||
|
const dirSum = (dir: string): string =>
|
||||||
|
`(COALESCE(SUM(l.value) FILTER (WHERE l.dir = '${dir}'), 0))::int`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
ctx.ds
|
||||||
|
.createQueryBuilder()
|
||||||
|
.from(`(${long})`, "l")
|
||||||
|
.setParameters(
|
||||||
|
Object.assign(
|
||||||
|
{},
|
||||||
|
...[...aggs, ...bulks].map((qb) => qb.getParameters()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// Renumbered after grouping so the bulk lines continue the sheet's
|
||||||
|
// numbering instead of all sharing the 900 that ordered them.
|
||||||
|
.select("(ROW_NUMBER() OVER (ORDER BY l.sn, l.metric))::int", "sn")
|
||||||
|
.addSelect("l.section", "section")
|
||||||
|
.addSelect("l.metric", "metric")
|
||||||
|
.addSelect(dirSum("EXPORT"), "export")
|
||||||
|
.addSelect(dirSum("IMPORT"), "import")
|
||||||
|
.addSelect(dirSum("DOMESTIC"), "domestic")
|
||||||
|
.addSelect(dirSum("ALL"), "total")
|
||||||
|
.groupBy("l.sn")
|
||||||
|
.addGroupBy("l.section")
|
||||||
|
.addGroupBy("l.metric")
|
||||||
|
);
|
||||||
|
},
|
||||||
|
async summary(ctx) {
|
||||||
|
const row = await baseQuery(ctx)
|
||||||
|
.select(trains(), "trains")
|
||||||
|
.addSelect(wagons(), "wagons")
|
||||||
|
.addSelect(
|
||||||
|
`${boxes(ALLOC_CONTAINERS_20, IS_CONTAINER_LOAD)} + ${boxes(ALLOC_CONTAINERS_40, IS_CONTAINER_LOAD)} * 2`,
|
||||||
|
"teu",
|
||||||
|
)
|
||||||
|
.getRawOne<{ trains: number; wagons: number; teu: number }>();
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ label: "Trains", value: Number(row?.trains ?? 0) },
|
||||||
|
{ label: "Wagons", value: Number(row?.wagons ?? 0) },
|
||||||
|
{ label: "TEU", value: Number(row?.teu ?? 0) },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -378,7 +378,7 @@ export const receivablesPayablesReport: ReportDefinition = {
|
|||||||
query(ctx) {
|
query(ctx) {
|
||||||
return baseQuery(ctx)
|
return baseQuery(ctx)
|
||||||
.select(SIDE_LABEL_OF('r.side_key'), 'side')
|
.select(SIDE_LABEL_OF('r.side_key'), 'side')
|
||||||
.addSelect("to_char(r.txn_date, 'YYYY-MM-DD')", 'issuedAt')
|
.addSelect("to_char(r.txn_date, 'YYYY-MM-DD HH24:MI')", 'issuedAt')
|
||||||
.addSelect('r.doc_ref', 'invoiceNumber')
|
.addSelect('r.doc_ref', 'invoiceNumber')
|
||||||
.addSelect('r.booking_ref', 'bookingRef')
|
.addSelect('r.booking_ref', 'bookingRef')
|
||||||
.addSelect('r.booking_status', 'bookingStatus')
|
.addSelect('r.booking_status', 'bookingStatus')
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export const revenueReconciliationReport: ReportDefinition = {
|
|||||||
defaultSort: { key: 'variance', dir: 'DESC' },
|
defaultSort: { key: 'variance', dir: 'DESC' },
|
||||||
query(ctx) {
|
query(ctx) {
|
||||||
return baseQuery(ctx)
|
return baseQuery(ctx)
|
||||||
.select(`to_char(${REVENUE_DATE}, 'YYYY-MM-DD')`, 'issuedAt')
|
.select(`to_char(${REVENUE_DATE}, 'YYYY-MM-DD HH24:MI')`, 'issuedAt')
|
||||||
.addSelect('i.invoice_number', 'invoiceNumber')
|
.addSelect('i.invoice_number', 'invoiceNumber')
|
||||||
.addSelect("COALESCE(b.reference, '—')", 'bookingRef')
|
.addSelect("COALESCE(b.reference, '—')", 'bookingRef')
|
||||||
.addSelect(PAYER_EXPR, 'customer')
|
.addSelect(PAYER_EXPR, 'customer')
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
import { ObjectLiteral, SelectQueryBuilder } from "typeorm";
|
||||||
|
|
||||||
import { ReportContext, ReportDefinition } from '../report.types';
|
import { ReportContext, ReportDefinition } from "../report.types";
|
||||||
import {
|
import {
|
||||||
PAYMENT_CLASS_EXPR,
|
PAYMENT_CLASS_EXPR,
|
||||||
PAYER_EXPR,
|
PAYER_EXPR,
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
currencyOf,
|
currencyOf,
|
||||||
periodExpr,
|
periodExpr,
|
||||||
revenueLedgerQb,
|
revenueLedgerQb,
|
||||||
} from '../revenue-classification';
|
} from "../revenue-classification";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The gateway payment behind an invoice, for traceability. `invoices.payment_id`
|
* The gateway payment behind an invoice, for traceability. `invoices.payment_id`
|
||||||
@@ -51,81 +51,109 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const revenueTransactionsReport: ReportDefinition = {
|
export const revenueTransactionsReport: ReportDefinition = {
|
||||||
key: 'revenue-transactions',
|
key: "revenue-transactions",
|
||||||
title: 'Revenue Transactions',
|
title: "Revenue Transactions",
|
||||||
description:
|
description:
|
||||||
'Every billed revenue line, at transaction level — booking reference, invoice number, ' +
|
"Every billed revenue line, at transaction level — booking reference, invoice number, " +
|
||||||
'charge type, cargo, quantity and the payment reference behind it. This is the ' +
|
"charge type, cargo, quantity and the payment reference behind it. This is the " +
|
||||||
'drill-down target for the revenue summaries and the audit trail for an export.',
|
"drill-down target for the revenue summaries and the audit trail for an export.",
|
||||||
group: 'Finance',
|
group: "Finance",
|
||||||
filters: [
|
filters: [
|
||||||
PERIOD_FILTER,
|
PERIOD_FILTER,
|
||||||
...REVENUE_FILTERS,
|
...REVENUE_FILTERS,
|
||||||
{ key: 'period_value', label: 'Period bucket', type: 'text' },
|
{ key: "period_value", label: "Period bucket", type: "text" },
|
||||||
{
|
{
|
||||||
key: 'categoryKey',
|
key: "categoryKey",
|
||||||
label: 'Category (exact)',
|
label: "Category (exact)",
|
||||||
type: 'select',
|
type: "select",
|
||||||
options: REVENUE_CATEGORIES,
|
options: REVENUE_CATEGORIES,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
columns: [
|
columns: [
|
||||||
{ key: 'issuedAt', label: 'Issued', type: 'date', sortable: true, sortExpr: REVENUE_DATE },
|
{ key: "issuedAt", label: "Issued", type: "date", sortable: true, sortExpr: REVENUE_DATE },
|
||||||
{ key: 'invoiceNumber', label: 'Invoice No.', type: 'string', sortable: true, sortExpr: 'i.invoice_number' },
|
{
|
||||||
{ key: 'bookingRef', label: 'Booking', type: 'string', sortable: true, sortExpr: 'b.reference' },
|
key: "invoiceNumber",
|
||||||
{ key: 'bookingId', label: 'Booking ID', type: 'string' },
|
label: "Invoice No.",
|
||||||
{ key: 'payer', label: 'Customer', type: 'string', sortable: true, sortExpr: PAYER_EXPR },
|
type: "string",
|
||||||
{ key: 'category', label: 'Revenue category', type: 'string', sortable: true, sortExpr: REVENUE_CATEGORY_EXPR },
|
sortable: true,
|
||||||
{ key: 'paymentClass', label: 'Payment class', type: 'string' },
|
sortExpr: "i.invoice_number",
|
||||||
{ key: 'chargeType', label: 'Charge type', type: 'string', sortable: true, sortExpr: 'il.charge_type' },
|
},
|
||||||
{ key: 'cargo', label: 'Cargo', type: 'string' },
|
{
|
||||||
{ key: 'route', label: 'Route', type: 'string' },
|
key: "bookingRef",
|
||||||
{ key: 'quantity', label: 'Qty', type: 'number' },
|
label: "Booking",
|
||||||
{ key: 'unit', label: 'Unit', type: 'string' },
|
type: "string",
|
||||||
{ key: 'unitRate', label: 'Unit rate', type: 'money' },
|
sortable: true,
|
||||||
{ key: 'amount', label: 'Amount', type: 'money', sortable: true, sortExpr: 'il.amount' },
|
sortExpr: "b.reference",
|
||||||
{ key: 'currency', label: 'Currency', type: 'string' },
|
},
|
||||||
{ key: 'invoiceStatus', label: 'Invoice status', type: 'string', sortable: true, sortExpr: 'i.status' },
|
{ key: "payer", label: "Customer", type: "string", sortable: true, sortExpr: PAYER_EXPR },
|
||||||
{ key: 'paymentRef', label: 'Payment ref', type: 'string' },
|
{
|
||||||
{ key: 'paymentMethod', label: 'Method', type: 'string' },
|
key: "category",
|
||||||
{ key: 'paymentStatus', label: 'Payment status', type: 'string' },
|
label: "Revenue category",
|
||||||
|
type: "string",
|
||||||
|
sortable: true,
|
||||||
|
sortExpr: REVENUE_CATEGORY_EXPR,
|
||||||
|
},
|
||||||
|
{ key: "paymentClass", label: "Payment class", type: "string" },
|
||||||
|
{
|
||||||
|
key: "chargeType",
|
||||||
|
label: "Charge type",
|
||||||
|
type: "string",
|
||||||
|
sortable: true,
|
||||||
|
sortExpr: "il.charge_type",
|
||||||
|
},
|
||||||
|
{ key: "cargo", label: "Cargo", type: "string" },
|
||||||
|
{ key: "route", label: "Route", type: "string" },
|
||||||
|
{ key: "quantity", label: "Qty", type: "number" },
|
||||||
|
{ key: "unit", label: "Unit", type: "string" },
|
||||||
|
{ key: "unitRate", label: "Unit rate", type: "money" },
|
||||||
|
{ key: "amount", label: "Amount", type: "money", sortable: true, sortExpr: "il.amount" },
|
||||||
|
{ key: "currency", label: "Currency", type: "string" },
|
||||||
|
{
|
||||||
|
key: "invoiceStatus",
|
||||||
|
label: "Invoice status",
|
||||||
|
type: "string",
|
||||||
|
sortable: true,
|
||||||
|
sortExpr: "i.status",
|
||||||
|
},
|
||||||
|
{ key: "paymentRef", label: "Payment ref", type: "string" },
|
||||||
|
{ key: "paymentMethod", label: "Method", type: "string" },
|
||||||
|
{ key: "paymentStatus", label: "Payment status", type: "string" },
|
||||||
],
|
],
|
||||||
defaultSort: { key: 'issuedAt', dir: 'DESC' },
|
defaultSort: { key: "issuedAt", dir: "DESC" },
|
||||||
query(ctx) {
|
query(ctx) {
|
||||||
return baseQuery(ctx)
|
return baseQuery(ctx)
|
||||||
.select(`to_char(${REVENUE_DATE}, 'YYYY-MM-DD')`, 'issuedAt')
|
.select(`to_char(${REVENUE_DATE}, 'YYYY-MM-DD HH24:MI')`, "issuedAt")
|
||||||
.addSelect('i.invoice_number', 'invoiceNumber')
|
.addSelect("i.invoice_number", "invoiceNumber")
|
||||||
.addSelect("COALESCE(b.reference, '—')", 'bookingRef')
|
.addSelect("COALESCE(b.reference, '—')", "bookingRef")
|
||||||
.addSelect("COALESCE(b.id::text, '')", 'bookingId')
|
.addSelect(PAYER_EXPR, "payer")
|
||||||
.addSelect(PAYER_EXPR, 'payer')
|
.addSelect(REVENUE_CATEGORY_EXPR, "category")
|
||||||
.addSelect(REVENUE_CATEGORY_EXPR, 'category')
|
.addSelect(PAYMENT_CLASS_EXPR, "paymentClass")
|
||||||
.addSelect(PAYMENT_CLASS_EXPR, 'paymentClass')
|
.addSelect("il.charge_type", "chargeType")
|
||||||
.addSelect('il.charge_type', 'chargeType')
|
.addSelect("COALESCE(ct.cargo_type_name, b.cargo_free_text, '—')", "cargo")
|
||||||
.addSelect("COALESCE(ct.cargo_type_name, b.cargo_free_text, '—')", 'cargo')
|
.addSelect("COALESCE(oy.label, '?') || ' → ' || COALESCE(dy.label, '?')", "route")
|
||||||
.addSelect("COALESCE(oy.label, '?') || ' → ' || COALESCE(dy.label, '?')", 'route')
|
.addSelect("il.quantity::float8", "quantity")
|
||||||
.addSelect('il.quantity::float8', 'quantity')
|
.addSelect("COALESCE(il.metadata->>'unit', '')", "unit")
|
||||||
.addSelect("COALESCE(il.metadata->>'unit', '')", 'unit')
|
.addSelect("ROUND(il.unit_rate, 2)::float8", "unitRate")
|
||||||
.addSelect('ROUND(il.unit_rate, 2)::float8', 'unitRate')
|
.addSelect("ROUND(il.amount, 2)::float8", "amount")
|
||||||
.addSelect('ROUND(il.amount, 2)::float8', 'amount')
|
.addSelect("il.currency", "currency")
|
||||||
.addSelect('il.currency', 'currency')
|
.addSelect("i.status", "invoiceStatus")
|
||||||
.addSelect('i.status', 'invoiceStatus')
|
|
||||||
.addSelect(
|
.addSelect(
|
||||||
`COALESCE(${latestPayment('transaction_id')}, ${latestPayment('merchant_order_id')}, '')`,
|
`COALESCE(${latestPayment("transaction_id")}, ${latestPayment("merchant_order_id")}, '')`,
|
||||||
'paymentRef',
|
"paymentRef",
|
||||||
)
|
)
|
||||||
.addSelect(`COALESCE(${latestPayment('method')}, '')`, 'paymentMethod')
|
.addSelect(`COALESCE(${latestPayment("method")}, '')`, "paymentMethod")
|
||||||
.addSelect(`COALESCE(${latestPayment('status')}, '')`, 'paymentStatus');
|
.addSelect(`COALESCE(${latestPayment("status")}, '')`, "paymentStatus");
|
||||||
},
|
},
|
||||||
async summary(ctx) {
|
async summary(ctx) {
|
||||||
const row = await baseQuery(ctx)
|
const row = await baseQuery(ctx)
|
||||||
.select(REVENUE_SUM, 'revenue')
|
.select(REVENUE_SUM, "revenue")
|
||||||
.addSelect('COUNT(*)::int', 'lines')
|
.addSelect("COUNT(*)::int", "lines")
|
||||||
.addSelect('COUNT(DISTINCT i.id)::int', 'invoices')
|
.addSelect("COUNT(DISTINCT i.id)::int", "invoices")
|
||||||
.getRawOne<{ revenue: number; lines: number; invoices: number }>();
|
.getRawOne<{ revenue: number; lines: number; invoices: number }>();
|
||||||
return [
|
return [
|
||||||
{ label: 'Lines', value: Number(row?.lines ?? 0) },
|
{ label: "Lines", value: Number(row?.lines ?? 0) },
|
||||||
{ label: 'Invoices', value: Number(row?.invoices ?? 0) },
|
{ label: "Invoices", value: Number(row?.invoices ?? 0) },
|
||||||
{ label: 'Revenue', value: Number(row?.revenue ?? 0), unit: currencyOf(ctx.params) },
|
{ label: "Revenue", value: Number(row?.revenue ?? 0), unit: currencyOf(ctx.params) },
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,87 +1,18 @@
|
|||||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
import { ReportDefinition } from '../report.types';
|
||||||
|
|
||||||
import { OperationsStandard } from '../../operations-reporting/entities/operations-standard.entity';
|
|
||||||
import { TrainCheckpointEvent } from '../../train-scheduling/entities/train-checkpoint-event.entity';
|
|
||||||
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
|
|
||||||
import { TrainSet } from '../../train-sets/entities/train-set.entity';
|
|
||||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
|
||||||
import { applyDirectionScope } from '../../user-trade-access/trade-scope.util';
|
|
||||||
import { ReportContext, ReportDefinition } from '../report.types';
|
|
||||||
import {
|
import {
|
||||||
COUNTRY_FILTER,
|
COUNTRY_FILTER,
|
||||||
DIRECTION_FILTER,
|
DIRECTION_FILTER,
|
||||||
OPS_DATE,
|
handlingHours,
|
||||||
STANDARDS_JOIN,
|
|
||||||
STATION_STANDARD_HOURS_EXPR,
|
|
||||||
hoursBetween,
|
hoursBetween,
|
||||||
|
otherActivityHours,
|
||||||
|
stationStaysQb,
|
||||||
} from '../operations-classification';
|
} from '../operations-classification';
|
||||||
|
|
||||||
/**
|
|
||||||
* A stay is an ARRIVED followed by the next DEPARTED at the same station by the
|
|
||||||
* same physical train — NOT by the same schedule.
|
|
||||||
*
|
|
||||||
* When a train turns around at a station the two halves belong to different
|
|
||||||
* departures: the arrival closes the inbound schedule and the departure opens
|
|
||||||
* the outbound one. Pairing within a schedule finds only pass-through stops and
|
|
||||||
* silently drops every turnaround, which is the longest stay a train makes.
|
|
||||||
*/
|
|
||||||
const TRAIN_KEY = 'COALESCE(tset.train_id::text, ts.train_set_id::text)';
|
|
||||||
const STAY_WINDOW = `PARTITION BY ${TRAIN_KEY}, ev.yard_id ORDER BY ev.occurred_at`;
|
|
||||||
|
|
||||||
const STAYING_HOURS = hoursBetween('s.arrived_at', 's.departed_at');
|
const STAYING_HOURS = hoursBetween('s.arrived_at', 's.departed_at');
|
||||||
|
const HANDLING_HOURS = handlingHours('s');
|
||||||
|
const OTHER_ACTIVITY_HOURS = otherActivityHours(STAYING_HOURS, HANDLING_HOURS);
|
||||||
const STANDARD_HOURS = 's.standard_hours';
|
const STANDARD_HOURS = 's.standard_hours';
|
||||||
|
|
||||||
/** Every logged stop, with the event that followed it at the same station. */
|
|
||||||
function stopsQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
|
||||||
const { params, directions } = ctx;
|
|
||||||
|
|
||||||
const qb = ctx.ds
|
|
||||||
.createQueryBuilder()
|
|
||||||
.from(TrainCheckpointEvent, 'ev')
|
|
||||||
.innerJoin(TrainSchedule, 'ts', 'ts.id = ev.train_schedule_id AND ts.deleted_at IS NULL')
|
|
||||||
.leftJoin(TrainSet, 'tset', 'tset.id = ts.train_set_id AND tset.deleted_at IS NULL')
|
|
||||||
.innerJoin(Yard, 'y', 'y.id = ev.yard_id')
|
|
||||||
.leftJoin(Yard, 'oy', 'oy.id = ts.origin_station_id')
|
|
||||||
.leftJoin(Yard, 'dy', 'dy.id = ts.destination_station_id')
|
|
||||||
.leftJoin(OperationsStandard, 'std', STANDARDS_JOIN)
|
|
||||||
.where('ev.deleted_at IS NULL')
|
|
||||||
.andWhere("ev.kind IN ('ARRIVED', 'DEPARTED')")
|
|
||||||
.select('ts.train_number', 'train_number')
|
|
||||||
.addSelect("COALESCE(y.label, y.code, '—')", 'station')
|
|
||||||
.addSelect("COALESCE(y.country, '—')", 'country')
|
|
||||||
.addSelect('ev.kind', 'kind')
|
|
||||||
.addSelect('ev.occurred_at', 'arrived_at')
|
|
||||||
.addSelect(`lead(ev.occurred_at) OVER (${STAY_WINDOW})`, 'departed_at')
|
|
||||||
.addSelect(`lead(ev.kind) OVER (${STAY_WINDOW})`, 'next_kind')
|
|
||||||
.addSelect(`ROUND(${STATION_STANDARD_HOURS_EXPR}, 1)`, 'standard_hours')
|
|
||||||
.addSelect("COALESCE(ev.note, '')", 'note');
|
|
||||||
|
|
||||||
if (params.dateFrom) qb.andWhere(`${OPS_DATE} >= :dateFrom`, { dateFrom: params.dateFrom });
|
|
||||||
if (params.dateTo) qb.andWhere(`${OPS_DATE} < :dateTo`, { dateTo: params.dateTo });
|
|
||||||
if (params.direction) qb.andWhere('ts.direction = :direction', { direction: params.direction });
|
|
||||||
if (params.trainNumber) {
|
|
||||||
qb.andWhere('ts.train_number ILIKE :trainNumber', {
|
|
||||||
trainNumber: `%${params.trainNumber as string}%`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (params.station) qb.andWhere('y.code = :station', { station: params.station });
|
|
||||||
if (params.country) qb.andWhere('y.country = :country', { country: params.country });
|
|
||||||
|
|
||||||
applyDirectionScope(qb, 'ts.direction', directions);
|
|
||||||
return qb;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Only completed stops — an arrival whose departure was also logged. */
|
|
||||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
|
||||||
const inner = stopsQuery(ctx);
|
|
||||||
return ctx.ds
|
|
||||||
.createQueryBuilder()
|
|
||||||
.from(`(${inner.getQuery()})`, 's')
|
|
||||||
.setParameters(inner.getParameters())
|
|
||||||
.where("s.kind = 'ARRIVED'")
|
|
||||||
.andWhere("s.next_kind = 'DEPARTED'");
|
|
||||||
}
|
|
||||||
|
|
||||||
export const stationStayingTimeReport: ReportDefinition = {
|
export const stationStayingTimeReport: ReportDefinition = {
|
||||||
key: 'station-staying-time',
|
key: 'station-staying-time',
|
||||||
title: 'Station Staying Time',
|
title: 'Station Staying Time',
|
||||||
@@ -89,8 +20,9 @@ export const stationStayingTimeReport: ReportDefinition = {
|
|||||||
'How long each train stood at each station — the logged arrival to the same train’s ' +
|
'How long each train stood at each station — the logged arrival to the same train’s ' +
|
||||||
'next departure from that station — against the standard for that side of the line ' +
|
'next departure from that station — against the standard for that side of the line ' +
|
||||||
'(10h Ethiopia, 13h Djibouti, both editable in Operating standards). A stop over ' +
|
'(10h Ethiopia, 13h Djibouti, both editable in Operating standards). A stop over ' +
|
||||||
'standard needs a reason. Loading and unloading times are not split out: nothing in ' +
|
'standard needs a reason. Loading and unloading time is the stop’s logged handling ' +
|
||||||
'the system records when they start and end yet.',
|
'window, unloading start to loading end, and other activity is whatever is left of the ' +
|
||||||
|
'stay; both read empty on a stop whose handling was never logged, rather than zero.',
|
||||||
group: 'Operations',
|
group: 'Operations',
|
||||||
filters: [
|
filters: [
|
||||||
{ key: 'date', label: 'Departure', type: 'daterange' },
|
{ key: 'date', label: 'Departure', type: 'daterange' },
|
||||||
@@ -106,6 +38,14 @@ export const stationStayingTimeReport: ReportDefinition = {
|
|||||||
{ key: 'arrivedAt', label: 'Arrived', type: 'date', sortable: true, sortExpr: 's.arrived_at' },
|
{ key: 'arrivedAt', label: 'Arrived', type: 'date', sortable: true, sortExpr: 's.arrived_at' },
|
||||||
{ key: 'departedAt', label: 'Departed', type: 'date' },
|
{ key: 'departedAt', label: 'Departed', type: 'date' },
|
||||||
{ key: 'stayingHours', label: 'Staying (hrs)', type: 'number', sortable: true, sortExpr: STAYING_HOURS },
|
{ key: 'stayingHours', label: 'Staying (hrs)', type: 'number', sortable: true, sortExpr: STAYING_HOURS },
|
||||||
|
{
|
||||||
|
key: 'loadUnloadHours',
|
||||||
|
label: 'Loading + unloading (hrs)',
|
||||||
|
type: 'number',
|
||||||
|
sortable: true,
|
||||||
|
sortExpr: HANDLING_HOURS,
|
||||||
|
},
|
||||||
|
{ key: 'otherActivityHours', label: 'Other activity (hrs)', type: 'number' },
|
||||||
{ key: 'standardHours', label: 'Standard (hrs)', type: 'number' },
|
{ key: 'standardHours', label: 'Standard (hrs)', type: 'number' },
|
||||||
{ key: 'varianceHours', label: 'Variance (hrs)', type: 'number' },
|
{ key: 'varianceHours', label: 'Variance (hrs)', type: 'number' },
|
||||||
{ key: 'verdict', label: 'Verdict', type: 'string' },
|
{ key: 'verdict', label: 'Verdict', type: 'string' },
|
||||||
@@ -113,13 +53,15 @@ export const stationStayingTimeReport: ReportDefinition = {
|
|||||||
],
|
],
|
||||||
defaultSort: { key: 'arrivedAt', dir: 'DESC' },
|
defaultSort: { key: 'arrivedAt', dir: 'DESC' },
|
||||||
query(ctx) {
|
query(ctx) {
|
||||||
return baseQuery(ctx)
|
return stationStaysQb(ctx)
|
||||||
.select("COALESCE(s.train_number, '—')", 'trainNumber')
|
.select("COALESCE(s.train_number, '—')", 'trainNumber')
|
||||||
.addSelect('s.station', 'station')
|
.addSelect('s.station', 'station')
|
||||||
.addSelect('s.country', 'country')
|
.addSelect('s.country', 'country')
|
||||||
.addSelect(`to_char(s.arrived_at, 'YYYY-MM-DD HH24:MI')`, 'arrivedAt')
|
.addSelect(`to_char(s.arrived_at, 'YYYY-MM-DD HH24:MI')`, 'arrivedAt')
|
||||||
.addSelect(`to_char(s.departed_at, 'YYYY-MM-DD HH24:MI')`, 'departedAt')
|
.addSelect(`to_char(s.departed_at, 'YYYY-MM-DD HH24:MI')`, 'departedAt')
|
||||||
.addSelect(STAYING_HOURS, 'stayingHours')
|
.addSelect(STAYING_HOURS, 'stayingHours')
|
||||||
|
.addSelect(HANDLING_HOURS, 'loadUnloadHours')
|
||||||
|
.addSelect(OTHER_ACTIVITY_HOURS, 'otherActivityHours')
|
||||||
.addSelect(`${STANDARD_HOURS}::float8`, 'standardHours')
|
.addSelect(`${STANDARD_HOURS}::float8`, 'standardHours')
|
||||||
.addSelect(`ROUND((${STAYING_HOURS})::numeric - ${STANDARD_HOURS}, 1)::float8`, 'varianceHours')
|
.addSelect(`ROUND((${STAYING_HOURS})::numeric - ${STANDARD_HOURS}, 1)::float8`, 'varianceHours')
|
||||||
.addSelect(
|
.addSelect(
|
||||||
@@ -132,18 +74,20 @@ export const stationStayingTimeReport: ReportDefinition = {
|
|||||||
.addSelect('s.note', 'reason');
|
.addSelect('s.note', 'reason');
|
||||||
},
|
},
|
||||||
async summary(ctx) {
|
async summary(ctx) {
|
||||||
const row = await baseQuery(ctx)
|
const row = await stationStaysQb(ctx)
|
||||||
.select('COUNT(*)::int', 'stops')
|
.select('COUNT(*)::int', 'stops')
|
||||||
.addSelect(`ROUND(AVG((${STAYING_HOURS})::numeric), 1)::float8`, 'avgHours')
|
.addSelect(`ROUND(AVG((${STAYING_HOURS})::numeric), 1)::float8`, 'avgHours')
|
||||||
|
.addSelect(`ROUND(AVG((${HANDLING_HOURS})::numeric), 1)::float8`, 'avgHandling')
|
||||||
.addSelect(
|
.addSelect(
|
||||||
`COUNT(*) FILTER (WHERE (${STAYING_HOURS})::numeric > ${STANDARD_HOURS})::int`,
|
`COUNT(*) FILTER (WHERE (${STAYING_HOURS})::numeric > ${STANDARD_HOURS})::int`,
|
||||||
'overStandard',
|
'overStandard',
|
||||||
)
|
)
|
||||||
.getRawOne<{ stops: number; avgHours: number; overStandard: number }>();
|
.getRawOne<{ stops: number; avgHours: number; avgHandling: number; overStandard: number }>();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{ label: 'Stops measured', value: Number(row?.stops ?? 0) },
|
{ label: 'Stops measured', value: Number(row?.stops ?? 0) },
|
||||||
{ label: 'Average stay', value: Number(row?.avgHours ?? 0), unit: 'h' },
|
{ label: 'Average stay', value: Number(row?.avgHours ?? 0), unit: 'h' },
|
||||||
|
{ label: 'Average loading + unloading', value: Number(row?.avgHandling ?? 0), unit: 'h' },
|
||||||
{ label: 'Over standard', value: Number(row?.overStandard ?? 0) },
|
{ label: 'Over standard', value: Number(row?.overStandard ?? 0) },
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user