Merge pull request #1055 from Tria-plc/freight_feature/usermanagement

enhance contract and train scheduling features
This commit is contained in:
marshal
2026-08-01 08:38:52 +03:00
committed by GitHub
12 changed files with 511 additions and 463 deletions

View File

@@ -14,10 +14,10 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
serviceType: { includesCustoms: false }, // no output set → only the input gate
};
// Input set has two required docs. Non-customs bookings resolve to the
// ONE_TIME self-clearance document set.
// Input set has two required docs. Non-customs bookings resolve to their
// own without-customs document set.
const inputSetting = {
code: 'contract_clearance_selfclear_import_container',
code: 'clearance_import_container_without_customs',
fields: [
{ fileKey: 'commercial_invoice', isRequired: true },
{ fileKey: 'packing_list', isRequired: true },
@@ -201,7 +201,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
*/
describe('BookingTransitionService — submitClearanceDocuments required-fields gate', () => {
const inputSetting = {
code: 'contract_clearance_selfclear_import_container',
code: 'clearance_import_container_without_customs',
fields: [
{ fileKey: 'commercial_invoice', fileLabel: 'Commercial invoice', isRequired: true },
{ fileKey: 'packing_list', fileLabel: 'Packing list', isRequired: true },

View File

@@ -11,10 +11,8 @@ describe('clearance.util — clearanceSettingCode', () => {
expect(clearanceSettingCode('IMPORT', 'CONTAINER', true)).toBe(
'clearance_import_container_with_customs',
);
// Non-customs bookings self-clear with the same document set a ONE_TIME
// self-clear contract uses.
expect(clearanceSettingCode('IMPORT', 'CONTAINER', false)).toBe(
'contract_clearance_selfclear_import_container',
'clearance_import_container_without_customs',
);
});
@@ -23,7 +21,7 @@ describe('clearance.util — clearanceSettingCode', () => {
'clearance_export_bulk_with_customs',
);
expect(clearanceSettingCode('EXPORT', 'BULK', false)).toBe(
'contract_clearance_selfclear_export_bulk',
'clearance_export_bulk_without_customs',
);
});

View File

@@ -39,12 +39,11 @@ export function clearanceSettingCode(
const op = operationFor(tradeDirection);
if (!op) return null;
const freight = freightFor(freightType);
// Non-customs (Path A) bookings self-clear: the customer proves his own
// clearance with the SAME smaller document set a ONE_TIME self-clear
// contract uses (customs declaration, release permit, …) — not the
// GL-oriented booking sets.
// 4 import + 4 export cases (bulk/container × with/without customs) — each
// booking resolves to its own clearance_{op}_{freight}_{with|without}_customs
// set, independent of any contract-level clearance codes.
if (!includesCustoms) {
return `contract_clearance_selfclear_${op}_${freight}`;
return `clearance_${op}_${freight}_without_customs`;
}
return `clearance_${op}_${freight}_with_customs`;
}

View File

@@ -26,30 +26,22 @@ function freightFor(freightType: string): Freight {
/**
* The customer-input clearance setting code, or null when no gate applies.
*
* - Path B (customs bundled): the customer uploads the documents GL needs to do
* the clearance work → `contract_clearance_{op}_{freight}`.
* - Path A (no customs): the customer clears the cargo himself and uploads his
* own (smaller) clearance proof set → `contract_clearance_selfclear_{op}_{freight}`,
* reviewed by Operations rather than GL.
* Contract-level IMPORT/EXPORT clearance has been removed — clearance is
* collected per booking instead (see bookings/clearance.util.ts), so this
* always returns null for IMPORT/EXPORT now.
*
* DOMESTIC/intercity has no border, but a ONE_TIME intercity contract still
* collects the admin-configured intercity document set after both signatures
* (ops-reviewed, like Path A). GENERAL intercity contracts skip the contract
* gate and collect the same set per booking instead.
* (ops-reviewed). GENERAL intercity contracts skip the contract gate and
* collect the same set per booking instead.
*/
export function contractClearanceSettingCode(
tradeDirection: string,
freightType: string,
includesCustoms: boolean,
_freightType: string,
_includesCustoms: boolean,
): string | null {
if (tradeDirection === 'DOMESTIC') return INTERCITY_DOCUMENTS_SETTING_CODE;
const op = operationFor(tradeDirection);
if (!op) return null;
const freight = freightFor(freightType);
if (!includesCustoms) {
return `contract_clearance_selfclear_${op}_${freight}`;
}
return `contract_clearance_${op}_${freight}`;
return null;
}
/** The GL-output (customs output) setting code, keyed on op + freight. */

View File

@@ -42,6 +42,7 @@ describe('ContractsService duplicate guard', () => {
{} as never,
{} as never,
{} as never,
{ buildBreakdown: async () => ({ lineItems: [] }) } as never,
);
return (
service as unknown as {

View File

@@ -28,6 +28,7 @@ import { ContractCargoScope } from './entities/contract-cargo-scope.entity';
import { isEffectivelyExpired } from './utils/contract-expiry.util';
import { diffContractFields } from './contract-document-diff.util';
import { ContractDocumentHistoryService } from './contract-document-history.service';
import { ContractPricingService } from './contract-pricing.service';
import { FileRecord } from '../files/entities/file.entity';
/** Paginated contract list: flat `total` (backoffice) + `meta` block (portal). */
@@ -113,6 +114,7 @@ export class ContractsService {
private readonly filesService: FilesService,
private readonly minioService: MinioService,
private readonly documentHistory: ContractDocumentHistoryService,
private readonly pricingService: ContractPricingService,
) {}
/** Generate a unique contract reference number (CTR-YYYY-NNNNN). */
@@ -331,6 +333,33 @@ export class ContractsService {
);
}
// Price the contract BEFORE anything persists: a lane with no configured
// rate 422s here and the wizard shows its blocking modal — with no orphan
// DRAFT row left behind for the customer to trip over on retry. The probe
// carries exactly the fields buildBreakdown prices from; relation-only
// niceties (cargoType labels) are absent, which only affects display
// lines, never the missing-rate gates.
await this.pricingService.buildBreakdown({
tradeDirection: dto.tradeDirection,
freightType: dto.freightType,
paymentCurrency: 'USD',
customsClearingEnabled: includesCustoms,
isHazardous: dto.isHazardous ?? false,
isReefer: dto.isReefer ?? false,
equipmentReturn: dto.equipmentReturn ?? null,
firstMilePickupAddress: dto.firstMilePickupAddress ?? null,
lastMileDeliveryAddress: dto.lastMileDeliveryAddress ?? null,
routes: (dto.routes ?? []).map((r, i) => ({
originYardId: r.originYardId,
destinationYardId: r.destinationYardId,
sortOrder: r.sortOrder ?? i,
})),
cargoScope: (dto.cargoScope ?? []).map((c) => ({
containerSize: c.containerSize ?? null,
cargoTypeId: c.cargoTypeId ?? null,
})),
} as unknown as Contract);
// An explicit reference is caller-chosen — a collision there is a real
// conflict and should surface. Auto-generated references retry past a
// concurrent insert that grabbed the same sequence number.

View File

@@ -7746,6 +7746,9 @@ export class TrainSchedulingService {
weightTons: sb.booking
? this.grossBookingWeightTons(sb.booking, tareDims)
: 0,
// Cargo only (VGM / bulk tons) — what the customer actually booked,
// without the wagons' tare. The legs tab shows this per booking.
cargoWeightTons: sb.booking ? bookingCargoTons(sb.booking) : 0,
status: sb.booking?.status ?? null,
schedulingStatus: sb.booking?.schedulingStatus ?? null,
freightType: sb.booking?.freightType ?? null,