mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 04:20:55 +00:00
Merge pull request #1364 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -47,6 +47,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
||||
"POST /api/bookings/:id/clearance/proceed": ["Customer requests operation with a schedule day", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/release-order": ["Upload Booking Release Order", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/review": ["GL reviews a clearance document (Approve | Query)", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/doc-requests": ["GL asks the customer for additional clearance documents", "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"],
|
||||
"POST /api/bookings/:id/clearance/charges/:chargeId/send": ["GL Ethiopia issues the clearance charge invoice to the customer", "POST", "Booking"],
|
||||
|
||||
@@ -303,22 +303,8 @@ export class BookingClearanceChargeService {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
this.assertClearanceFinalized(booking);
|
||||
|
||||
const port = await this.repo().findOne({
|
||||
where: { bookingId, type: 'PORT_CHARGES' },
|
||||
});
|
||||
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.',
|
||||
);
|
||||
}
|
||||
// No ordering and no cap: a miscellaneous charge may be raised before,
|
||||
// after or alongside the port charge, and a booking may carry several.
|
||||
if (!(input.amount > 0)) {
|
||||
throw new BadRequestException('Amount must be greater than zero.');
|
||||
}
|
||||
@@ -326,21 +312,15 @@ export class BookingClearanceChargeService {
|
||||
throw new BadRequestException('Currency is required.');
|
||||
}
|
||||
|
||||
const record = await this.filesService.upsertByCode(
|
||||
{
|
||||
resourceId: bookingId,
|
||||
resource: 'bookings',
|
||||
code: CHARGE_FILE_CODE.MISCELLANEOUS,
|
||||
file,
|
||||
},
|
||||
{ userId: staffId },
|
||||
);
|
||||
await this.repo().save(
|
||||
// Save the row first so its id can key the document. A booking may carry
|
||||
// several miscellaneous charges, and `upsertByCode` retires whatever sits
|
||||
// under the same code — a shared code would silently delete the previous
|
||||
// charge's document.
|
||||
const charge = await this.repo().save(
|
||||
this.repo().create({
|
||||
bookingId,
|
||||
type: 'MISCELLANEOUS',
|
||||
status: 'BILLED',
|
||||
fileRecordId: record.id,
|
||||
amount: input.amount.toFixed(2),
|
||||
currency: input.currency.trim().toUpperCase(),
|
||||
uploadedByStaffId: staffId,
|
||||
@@ -349,6 +329,16 @@ export class BookingClearanceChargeService {
|
||||
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({
|
||||
bookingId,
|
||||
action: 'CHARGE_MISC_CREATED',
|
||||
|
||||
@@ -202,6 +202,17 @@ export class BookingLifecycleNotifierService {
|
||||
}
|
||||
|
||||
/** A clearance document was queried and needs the customer to re-upload. */
|
||||
/** GL asked the customer for additional clearance document(s). */
|
||||
additionalDocsRequested(b: Booking, note: string): void {
|
||||
const msg =
|
||||
`Additional document(s) requested on booking ${b.reference}: ` +
|
||||
`${note} Please upload them from the portal.`;
|
||||
void this.notifyContact(b, msg, 'ADDITIONAL DOCUMENTS REQUESTED');
|
||||
this.inApp(b, 'Additional documents requested', msg, {
|
||||
type: NotificationType.DOCUMENT_ACTION,
|
||||
});
|
||||
}
|
||||
|
||||
documentQueried(b: Booking, fileKey: string, note: string): void {
|
||||
const msg =
|
||||
`A clearance document on booking ${b.reference} needs attention: "${fileKey}". ` +
|
||||
|
||||
@@ -643,6 +643,12 @@ export class BookingTransitionService {
|
||||
}>;
|
||||
allApproved: boolean;
|
||||
documentsOpen: boolean;
|
||||
docRequests: Array<{
|
||||
id: string;
|
||||
note: string;
|
||||
byName: string | null;
|
||||
at: string;
|
||||
}>;
|
||||
phase?: string | null;
|
||||
milestones?: unknown[];
|
||||
nextAction?: unknown;
|
||||
@@ -674,9 +680,14 @@ export class BookingTransitionService {
|
||||
bookingId,
|
||||
"CHANGES_REQUESTED",
|
||||
);
|
||||
const docRequestNotes = await this.bookingsRepository.findReviewNotes(
|
||||
bookingId,
|
||||
"ADDITIONAL_DOC_REQUEST",
|
||||
);
|
||||
const reviewerNames = await this.bookingsRepository.resolveStaffNames([
|
||||
...reviews.map((r) => r.reviewedByStaffId),
|
||||
...queryNotes.map((n) => n.authorId),
|
||||
...docRequestNotes.map((n) => n.authorId),
|
||||
]);
|
||||
|
||||
const documents: Awaited<
|
||||
@@ -763,9 +774,51 @@ export class BookingTransitionService {
|
||||
documents,
|
||||
allApproved,
|
||||
documentsOpen: clearanceDocumentsOpen(booking),
|
||||
docRequests: docRequestNotes.map((n) => ({
|
||||
id: n.id,
|
||||
note: n.note,
|
||||
byName: n.authorId ? (reviewerNames.get(n.authorId) ?? null) : null,
|
||||
at: n.createdAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GL asks the customer for additional clearance document(s). Stored as a
|
||||
* review-note thread shown on both the GL clearance page and the customer's
|
||||
* portal; the customer answers with an ad-hoc upload. Allowed for as long as
|
||||
* documents are open (until the shipment is paid).
|
||||
*/
|
||||
async requestAdditionalDocuments(
|
||||
bookingId: string,
|
||||
note: string,
|
||||
staffId: string,
|
||||
): Promise<void> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
if (!clearanceDocumentsOpen(booking)) {
|
||||
throw new ConflictException(
|
||||
`Clearance documents are closed for this booking (status "${booking.status}").`,
|
||||
);
|
||||
}
|
||||
if (!note?.trim()) {
|
||||
throw new BadRequestException("Describe the document(s) you need.");
|
||||
}
|
||||
await this.bookingsRepository.createReviewNote(
|
||||
bookingId,
|
||||
note.trim(),
|
||||
"ADDITIONAL_DOC_REQUEST",
|
||||
staffId,
|
||||
);
|
||||
await this.clearanceEvents.record({
|
||||
bookingId,
|
||||
action: "ADDITIONAL_DOCS_REQUESTED",
|
||||
label: "Requested additional document(s) from the customer",
|
||||
actorId: staffId,
|
||||
metadata: { note: note.trim() },
|
||||
});
|
||||
this.notifier.additionalDocsRequested(booking, note.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* True when every REQUIRED field of the booking's customer-input clearance set
|
||||
* has an APPROVED review row. The 100% gate before clearance can be finalized.
|
||||
|
||||
@@ -1083,6 +1083,25 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(":id/clearance/doc-requests")
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"GL asks the customer for additional clearance document(s) — shown on the portal with author and time",
|
||||
})
|
||||
async requestAdditionalDocuments(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body("note") note: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
await this.transitionService.requestAdditionalDocuments(
|
||||
id,
|
||||
note,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Get(":id/clearance/history")
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||
|
||||
@@ -14,15 +14,15 @@ export const CLEARANCE_CHARGE_STATUSES = [
|
||||
export type ClearanceChargeStatus = (typeof CLEARANCE_CHARGE_STATUSES)[number];
|
||||
|
||||
/**
|
||||
* Post-finalization clearance charge billed to the customer — at most one
|
||||
* PORT_CHARGES and one MISCELLANEOUS row per booking. GL Djibouti uploads the
|
||||
* port-charges document (DOC_UPLOADED); GL Ethiopia sets amount + currency
|
||||
* (BILLED) and issues the invoice (SENT); the billing `clearance_charge.invoice.paid`
|
||||
* event marks it PAID. MISCELLANEOUS is created whole by GL Ethiopia and only
|
||||
* after the port charge is paid.
|
||||
* Clearance charge billed to the customer. One PORT_CHARGES row per booking
|
||||
* (enforced by a partial unique index) and any number of MISCELLANEOUS rows.
|
||||
* GL Djibouti uploads the port-charges document (DOC_UPLOADED); GL Ethiopia
|
||||
* sets amount + currency (BILLED) and issues the invoice (SENT); the billing
|
||||
* `clearance_charge.invoice.paid` event marks it PAID. The two levels are
|
||||
* independent — either may be raised first.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'booking_clearance_charge' })
|
||||
@Index(['bookingId', 'type'], { unique: true })
|
||||
@Index(['bookingId'])
|
||||
export class BookingClearanceCharge extends BaseEntity {
|
||||
@Column({ name: 'booking_id', type: 'uuid' })
|
||||
bookingId!: string;
|
||||
|
||||
@@ -11,6 +11,12 @@ export const REVIEW_NOTE_TYPES = [
|
||||
* (price/files). One row per round — the draft/change-request loop can repeat.
|
||||
*/
|
||||
'DRAFT_DECL_CHANGE_REQUEST',
|
||||
/**
|
||||
* GL asked the customer for additional clearance document(s). Shown as a
|
||||
* thread on both the GL clearance page and the customer's portal — the
|
||||
* customer answers by uploading an ad-hoc document.
|
||||
*/
|
||||
'ADDITIONAL_DOC_REQUEST',
|
||||
] as const;
|
||||
export type ReviewNoteType = (typeof REVIEW_NOTE_TYPES)[number];
|
||||
|
||||
|
||||
@@ -66,6 +66,12 @@ export interface BookingClearanceView {
|
||||
}>;
|
||||
allApproved: boolean;
|
||||
documentsOpen: boolean;
|
||||
docRequests: Array<{
|
||||
id: string;
|
||||
note: string;
|
||||
byName: string | null;
|
||||
at: string;
|
||||
}>;
|
||||
phase?: string | null;
|
||||
milestones?: Array<{
|
||||
id: string;
|
||||
@@ -206,9 +212,14 @@ export class BookingClearanceService {
|
||||
bookingId,
|
||||
'CHANGES_REQUESTED',
|
||||
);
|
||||
const docRequestNotes = await this.bookingsRepository.findReviewNotes(
|
||||
bookingId,
|
||||
'ADDITIONAL_DOC_REQUEST',
|
||||
);
|
||||
const reviewerNames = await this.bookingsRepository.resolveStaffNames([
|
||||
...reviews.map((r) => r.reviewedByStaffId),
|
||||
...queryNotes.map((n) => n.authorId),
|
||||
...docRequestNotes.map((n) => n.authorId),
|
||||
]);
|
||||
|
||||
const documents: BookingClearanceView['documents'] = [];
|
||||
@@ -357,6 +368,12 @@ export class BookingClearanceService {
|
||||
documents,
|
||||
allApproved,
|
||||
documentsOpen: clearanceDocumentsOpen(booking),
|
||||
docRequests: docRequestNotes.map((n) => ({
|
||||
id: n.id,
|
||||
note: n.note,
|
||||
byName: n.authorId ? (reviewerNames.get(n.authorId) ?? null) : null,
|
||||
at: n.createdAt.toISOString(),
|
||||
})),
|
||||
phase,
|
||||
milestones: milestones.map((m) => ({
|
||||
id: m.id,
|
||||
|
||||
Reference in New Issue
Block a user