fix(portal): show declaration, T1 and Djibouti clearance documents to the customer

This commit is contained in:
Marshal
2026-08-20 08:43:08 +00:00
parent d7752f4386
commit 8d7551bb8e
4 changed files with 112 additions and 64 deletions

View File

@@ -0,0 +1,38 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Clearance charges are no longer one-of-each in a fixed order: GL Ethiopia
* may raise several MISCELLANEOUS charges, and either level may be created
* first. Port charges stay unique per booking (one port bill per shipment),
* enforced by a partial index instead of the old blanket (booking_id, type)
* uniqueness that also capped miscellaneous at one.
*/
export class MultipleMiscClearanceCharges3610000000000
implements MigrationInterface
{
name = 'MultipleMiscClearanceCharges3610000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DROP INDEX IF EXISTS "freight"."uq_booking_clearance_charge_booking_type"
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_booking_clearance_charge_port"
ON "freight"."booking_clearance_charge" ("booking_id")
WHERE "type" = 'PORT_CHARGES' AND "deleted_at" IS NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_booking_clearance_charge_booking"
ON "freight"."booking_clearance_charge" ("booking_id")
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// No-op on the uniqueness: restoring the blanket (booking_id, type) index
// would fail on any booking that has since raised a second miscellaneous
// charge, which is exactly what this migration set out to allow.
await queryRunner.query(`
DROP INDEX IF EXISTS "freight"."uq_booking_clearance_charge_port"
`);
}
}

View File

@@ -303,22 +303,8 @@ export class BookingClearanceChargeService {
const booking = await this.bookingsService.findById(bookingId); const booking = await this.bookingsService.findById(bookingId);
this.assertClearanceFinalized(booking); this.assertClearanceFinalized(booking);
const port = await this.repo().findOne({ // No ordering and no cap: a miscellaneous charge may be raised before,
where: { bookingId, type: 'PORT_CHARGES' }, // after or alongside the port charge, and a booking may carry several.
});
if (port?.status !== 'PAID') {
throw new ConflictException(
'Miscellaneous charges open after the port charge is paid.',
);
}
const existing = await this.repo().findOne({
where: { bookingId, type: 'MISCELLANEOUS' },
});
if (existing) {
throw new ConflictException(
'This booking already has a miscellaneous charge — revise it instead.',
);
}
if (!(input.amount > 0)) { if (!(input.amount > 0)) {
throw new BadRequestException('Amount must be greater than zero.'); throw new BadRequestException('Amount must be greater than zero.');
} }
@@ -326,21 +312,15 @@ export class BookingClearanceChargeService {
throw new BadRequestException('Currency is required.'); throw new BadRequestException('Currency is required.');
} }
const record = await this.filesService.upsertByCode( // Save the row first so its id can key the document. A booking may carry
{ // several miscellaneous charges, and `upsertByCode` retires whatever sits
resourceId: bookingId, // under the same code — a shared code would silently delete the previous
resource: 'bookings', // charge's document.
code: CHARGE_FILE_CODE.MISCELLANEOUS, const charge = await this.repo().save(
file,
},
{ userId: staffId },
);
await this.repo().save(
this.repo().create({ this.repo().create({
bookingId, bookingId,
type: 'MISCELLANEOUS', type: 'MISCELLANEOUS',
status: 'BILLED', status: 'BILLED',
fileRecordId: record.id,
amount: input.amount.toFixed(2), amount: input.amount.toFixed(2),
currency: input.currency.trim().toUpperCase(), currency: input.currency.trim().toUpperCase(),
uploadedByStaffId: staffId, uploadedByStaffId: staffId,
@@ -349,6 +329,16 @@ export class BookingClearanceChargeService {
billedAt: new Date(), billedAt: new Date(),
}), }),
); );
const record = await this.filesService.upsertByCode(
{
resourceId: bookingId,
resource: 'bookings',
code: `${CHARGE_FILE_CODE.MISCELLANEOUS}_${charge.id}`,
file,
},
{ userId: staffId },
);
await this.repo().update(charge.id, { fileRecordId: record.id });
await this.clearanceEvents.record({ await this.clearanceEvents.record({
bookingId, bookingId,
action: 'CHARGE_MISC_CREATED', action: 'CHARGE_MISC_CREATED',

View File

@@ -14,15 +14,15 @@ export const CLEARANCE_CHARGE_STATUSES = [
export type ClearanceChargeStatus = (typeof CLEARANCE_CHARGE_STATUSES)[number]; export type ClearanceChargeStatus = (typeof CLEARANCE_CHARGE_STATUSES)[number];
/** /**
* Post-finalization clearance charge billed to the customer — at most one * Clearance charge billed to the customer. One PORT_CHARGES row per booking
* PORT_CHARGES and one MISCELLANEOUS row per booking. GL Djibouti uploads the * (enforced by a partial unique index) and any number of MISCELLANEOUS rows.
* port-charges document (DOC_UPLOADED); GL Ethiopia sets amount + currency * GL Djibouti uploads the port-charges document (DOC_UPLOADED); GL Ethiopia
* (BILLED) and issues the invoice (SENT); the billing `clearance_charge.invoice.paid` * sets amount + currency (BILLED) and issues the invoice (SENT); the billing
* event marks it PAID. MISCELLANEOUS is created whole by GL Ethiopia and only * `clearance_charge.invoice.paid` event marks it PAID. The two levels are
* after the port charge is paid. * independent — either may be raised first.
*/ */
@Entity({ schema: 'freight', name: 'booking_clearance_charge' }) @Entity({ schema: 'freight', name: 'booking_clearance_charge' })
@Index(['bookingId', 'type'], { unique: true }) @Index(['bookingId'])
export class BookingClearanceCharge extends BaseEntity { export class BookingClearanceCharge extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' }) @Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string; bookingId!: string;

View File

@@ -67,6 +67,8 @@ export function ClearanceChargesTab({
onViewFile, onViewFile,
}: ClearanceChargesTabProps) { }: ClearanceChargesTabProps) {
const qc = useQueryClient(); const qc = useQueryClient();
// Bumped after each create so the form remounts empty for the next charge.
const [miscCreated, setMiscCreated] = useState(0);
const { data: charges, isLoading } = useQuery({ const { data: charges, isLoading } = useQuery({
queryKey: ["clearance-charges", bookingId], queryKey: ["clearance-charges", bookingId],
queryFn: () => bookingsService.getClearanceCharges(bookingId), queryFn: () => bookingsService.getClearanceCharges(bookingId),
@@ -109,6 +111,8 @@ export function ClearanceChargesTab({
bookingsService.createMiscellaneousCharge(bookingId, p.file, p), bookingsService.createMiscellaneousCharge(bookingId, p.file, p),
onSuccess: (next) => { onSuccess: (next) => {
toast.success("Miscellaneous charge created"); toast.success("Miscellaneous charge created");
// Remount the form so the next charge starts from an empty one.
setMiscCreated((n) => n + 1);
refresh(next); refresh(next);
}, },
onError, onError,
@@ -124,7 +128,9 @@ export function ClearanceChargesTab({
} }
const port = (charges ?? []).find((c) => c.type === "PORT_CHARGES") ?? null; const port = (charges ?? []).find((c) => c.type === "PORT_CHARGES") ?? null;
const misc = (charges ?? []).find((c) => c.type === "MISCELLANEOUS") ?? null; const miscCharges = (charges ?? []).filter(
(c) => c.type === "MISCELLANEOUS",
);
const busy = const busy =
uploadPort.isPending || bill.isPending || send.isPending || createMisc.isPending; uploadPort.isPending || bill.isPending || send.isPending || createMisc.isPending;
@@ -137,7 +143,7 @@ export function ClearanceChargesTab({
return ( return (
<Stack gap="md" maw={860}> <Stack gap="md" maw={860}>
<ChargeCard <ChargeCard
title="1 · Port charges" title="Port charges"
charge={port} charge={port}
roleMode={roleMode} roleMode={roleMode}
busy={busy} busy={busy}
@@ -175,34 +181,48 @@ export function ClearanceChargesTab({
} }
/> />
<ChargeCard {/* Any number of miscellaneous charges, in any order relative to the
title="2 · Miscellaneous charges" port charge — each is billed and paid on its own. */}
charge={misc} {miscCharges.map((c, i) => (
roleMode={roleMode} <ChargeCard
busy={busy} key={c.id}
emptyHint={ title={
port?.status !== "PAID" miscCharges.length > 1
? "Unlocks once the port charge is paid." ? `Miscellaneous charge ${i + 1}`
: roleMode === "ET" : "Miscellaneous charge"
? "Create the miscellaneous charge with its document, amount and currency." }
: "GL Ethiopia creates this charge once the port charge is paid." charge={c}
} roleMode={roleMode}
onViewFile={onViewFile} busy={busy}
onBill={(amount, currency) => emptyHint=""
misc && bill.mutate({ chargeId: misc.id, amount, currency }) onViewFile={onViewFile}
} onBill={(amount, currency) =>
onSend={() => misc && send.mutate(misc.id)} bill.mutate({ chargeId: c.id, amount, currency })
etCreate={ }
roleMode === "ET" && !misc && port?.status === "PAID" ? ( onSend={() => send.mutate(c.id)}
<MiscCreateForm />
busy={createMisc.isPending} ))}
onCreate={(file, amount, currency) =>
createMisc.mutate({ file, amount, currency }) {roleMode === "ET" && (
} <Paper withBorder radius="md" p="md">
/> <Text fz="14px" fw={700} c="edr-text" mb={4}>
) : null {miscCharges.length > 0
} ? "Add another miscellaneous charge"
/> : "Add a miscellaneous charge"}
</Text>
<Text fz="12px" c="dimmed" mb="sm">
Upload the supporting document and set the amount. You can raise as
many as the shipment needs, before or after the port charge.
</Text>
<MiscCreateForm
key={miscCreated}
busy={createMisc.isPending}
onCreate={(file, amount, currency) =>
createMisc.mutate({ file, amount, currency })
}
/>
</Paper>
)}
{totals.size > 0 && ( {totals.size > 0 && (
<Paper withBorder radius="md" p="md"> <Paper withBorder radius="md" p="md">