mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 09:00:57 +00:00
Implement intercity document handling and rejection notes for contracts
This commit is contained in:
@@ -196,11 +196,12 @@ export class ContractBookingService {
|
||||
// GENERAL without customs (Path A) ALSO clears per booking: the customer
|
||||
// uploads his own clearance proof on each booking and Operations reviews it
|
||||
// (legacy AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY →
|
||||
// requestOperation machine). DOMESTIC has no border, so no gate.
|
||||
// requestOperation machine). GENERAL intercity (DOMESTIC) follows the same
|
||||
// per-booking gate with the intercity document set — ops finalize then puts
|
||||
// the booking straight into the ride-along pool (FULLY_EXECUTED), since
|
||||
// intercity has no shipment-day request step.
|
||||
const generalSelfClear =
|
||||
contract.contractKind === 'GENERAL' &&
|
||||
!contract.customsClearingEnabled &&
|
||||
contract.tradeDirection !== 'DOMESTIC';
|
||||
contract.contractKind === 'GENERAL' && !contract.customsClearingEnabled;
|
||||
|
||||
// Intercity (DOMESTIC) bookings ride on a passing import/export train:
|
||||
// there is no window and no date — staff accept them onto a train at
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import { INTERCITY_DOCUMENTS_SETTING_CODE } from '../bookings/clearance.util';
|
||||
|
||||
/**
|
||||
* Resolves which seeded clearance FileUploadSetting applies to a contract during
|
||||
@@ -29,13 +30,17 @@ function freightFor(freightType: string): Freight {
|
||||
* own (smaller) clearance proof set → `contract_clearance_selfclear_{op}_{freight}`,
|
||||
* reviewed by Operations rather than GL.
|
||||
*
|
||||
* DOMESTIC/intercity has no border, so no clearance gate applies on either path.
|
||||
* 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.
|
||||
*/
|
||||
export function contractClearanceSettingCode(
|
||||
tradeDirection: string,
|
||||
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);
|
||||
|
||||
@@ -1092,8 +1092,8 @@ export class ContractTransitionService {
|
||||
};
|
||||
|
||||
// A clearance gate applies whenever a clearance doc set resolves — Path B
|
||||
// (customs) or Path A self-clearance (IMPORT/EXPORT without customs). DOMESTIC
|
||||
// resolves to null on both paths and skips straight to executed.
|
||||
// (customs), Path A self-clearance (IMPORT/EXPORT without customs), or the
|
||||
// intercity document set (DOMESTIC, ops-reviewed like Path A).
|
||||
const clearanceCode = contractClearanceSettingCode(
|
||||
contract.tradeDirection,
|
||||
contract.freightType,
|
||||
|
||||
@@ -156,6 +156,7 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
// direct download. Loaded separately to keep pagination counts correct.
|
||||
await this.attachContractFiles(items);
|
||||
await this.attachClearancePhases(items);
|
||||
await this.attachRejectionNotes(items);
|
||||
|
||||
const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 0;
|
||||
return {
|
||||
@@ -228,6 +229,31 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the latest REJECTION review-note body to each REJECTED contract so
|
||||
* list consumers (portal rows, backoffice queues) can show why without a
|
||||
* per-contract detail fetch. One query per page, like `attachContractFiles`.
|
||||
*/
|
||||
private async attachRejectionNotes(contracts: Contract[]): Promise<void> {
|
||||
const rejected = contracts.filter((c) => c.status === 'REJECTED');
|
||||
if (rejected.length === 0) return;
|
||||
const ids = rejected.map((c) => c.id);
|
||||
const rows: Array<{ contract_id: string; body: string }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT DISTINCT ON (contract_id) contract_id, body
|
||||
FROM freight.contract_review_notes
|
||||
WHERE contract_id = ANY($1)
|
||||
AND note_type = 'REJECTION'
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY contract_id, created_at DESC`,
|
||||
[ids],
|
||||
);
|
||||
const byContract = new Map(rows.map((r) => [r.contract_id, r.body]));
|
||||
for (const contract of rejected) {
|
||||
contract.latestRejectionNote = byContract.get(contract.id) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
async getStatusCounts(): Promise<Record<string, number>> {
|
||||
const rows = await this.repository
|
||||
.createQueryBuilder('contract')
|
||||
|
||||
@@ -642,6 +642,47 @@ export class ContractsService {
|
||||
}
|
||||
}
|
||||
|
||||
// Surface the rejection reason. The approval-step note is wiped on
|
||||
// send-back resets, so the review-note trail is the only durable source.
|
||||
if (contract.status === 'REJECTED') {
|
||||
try {
|
||||
const note = await this.contractsRepository.findLatestReviewNote(
|
||||
contract.id,
|
||||
'REJECTION',
|
||||
);
|
||||
contract.latestRejectionNote = note?.body ?? null;
|
||||
} catch {
|
||||
contract.latestRejectionNote = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Surface the send-back reason to the returned-to approver, but only while
|
||||
// it is still actionable: once any step acts after the send-back the note
|
||||
// is stale and stays out of the response (the trail keeps it in the DB).
|
||||
if (contract.status === 'PENDING_APPROVAL') {
|
||||
try {
|
||||
const note = await this.contractsRepository.findLatestReviewNote(
|
||||
contract.id,
|
||||
'STAFF_NOTE',
|
||||
);
|
||||
// Stale when any step acted after it (send-back resolved) or when the
|
||||
// chain itself is newer than the note (fresh cycle after a resubmit).
|
||||
const staleAfter = Math.max(
|
||||
0,
|
||||
...(contract.approvalSteps ?? []).flatMap((s) => [
|
||||
s.actedAt ? new Date(s.actedAt).getTime() : 0,
|
||||
s.createdAt ? new Date(s.createdAt).getTime() : 0,
|
||||
]),
|
||||
);
|
||||
contract.latestSendBackNote =
|
||||
note && new Date(note.createdAt).getTime() > staleAfter
|
||||
? note.body
|
||||
: null;
|
||||
} catch {
|
||||
contract.latestSendBackNote = null;
|
||||
}
|
||||
}
|
||||
|
||||
return contract;
|
||||
}
|
||||
|
||||
|
||||
@@ -326,4 +326,19 @@ export class Contract extends BaseEntity {
|
||||
* asked them to fix. Lives in contract_review_notes, not a column here.
|
||||
*/
|
||||
latestChangeRequestNote?: string | null;
|
||||
|
||||
/**
|
||||
* Body of the most recent REJECTION review note, attached by
|
||||
* ContractsService.findById when status is REJECTED so both backoffice and
|
||||
* portal can show why. Lives in contract_review_notes, not a column here.
|
||||
*/
|
||||
latestRejectionNote?: string | null;
|
||||
|
||||
/**
|
||||
* Body of the most recent send-back STAFF_NOTE, attached by
|
||||
* ContractsService.findById while the contract is PENDING_APPROVAL and no
|
||||
* approval step has acted since the send-back. Lives in
|
||||
* contract_review_notes, not a column here.
|
||||
*/
|
||||
latestSendBackNote?: string | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user