mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fix(portal): show declaration, T1 and Djibouti clearance documents to the customer
This commit is contained in:
@@ -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"
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user