mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 01:55:41 +00:00
feat(bookings): two-level clearance charges (port + misc) billed to customer with invoices
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource, EntityManager } from 'typeorm';
|
||||
import { Freight } from '@edr/types';
|
||||
|
||||
import { resolveIamUserNames } from '../../common/utils/iam-user-name.util';
|
||||
import {
|
||||
BookingClearanceEvent,
|
||||
ClearanceEventActorType,
|
||||
} from './entities/booking-clearance-event.entity';
|
||||
|
||||
export interface RecordClearanceEventInput {
|
||||
bookingId: string;
|
||||
action: string;
|
||||
/** Human sentence for the History tab, frozen at write time. */
|
||||
label: string;
|
||||
actorType?: ClearanceEventActorType;
|
||||
/** IAM user id (staff or portal customer); name is resolved here. */
|
||||
actorId?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
/** Join the caller's transaction so the event commits (or rolls back) with the action. */
|
||||
manager?: EntityManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* The clearance History tab's write/read path. Every clearance mutation calls
|
||||
* {@link record} — document reviews, phased workflow steps, customer charges.
|
||||
* Recording is deliberately NOT fire-and-forget: the insert shares the caller's
|
||||
* transaction when a manager is passed, and otherwise a failed insert fails the
|
||||
* action, because a silent gap in an audit trail is worse than a retry.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ClearanceEventService {
|
||||
private readonly logger = new Logger(ClearanceEventService.name);
|
||||
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async record(input: RecordClearanceEventInput): Promise<void> {
|
||||
const mg = input.manager ?? this.dataSource.manager;
|
||||
const actorName = input.actorId
|
||||
? ((await resolveIamUserNames(this.dataSource, [input.actorId])).get(
|
||||
input.actorId,
|
||||
) ?? null)
|
||||
: null;
|
||||
await mg.save(
|
||||
mg.create(BookingClearanceEvent, {
|
||||
bookingId: input.bookingId,
|
||||
action: input.action,
|
||||
label: input.label,
|
||||
actorType: input.actorType ?? 'STAFF',
|
||||
actorId: input.actorId ?? null,
|
||||
actorName,
|
||||
metadata: input.metadata ?? null,
|
||||
}),
|
||||
);
|
||||
this.logger.log(
|
||||
`clearance-history ${input.action} on booking ${input.bookingId}${
|
||||
actorName ? ` by ${actorName}` : ''
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** History for one booking, newest first. */
|
||||
async list(bookingId: string): Promise<Freight.ClearanceHistoryEvent[]> {
|
||||
const rows = await this.dataSource
|
||||
.getRepository(BookingClearanceEvent)
|
||||
.find({ where: { bookingId }, order: { createdAt: 'DESC' } });
|
||||
|
||||
// Rows whose actor name failed to resolve at write time get one more try.
|
||||
const missing = rows
|
||||
.filter((r) => !r.actorName && r.actorId)
|
||||
.map((r) => r.actorId as string);
|
||||
const names = missing.length
|
||||
? await resolveIamUserNames(this.dataSource, missing).catch(
|
||||
() => new Map<string, string>(),
|
||||
)
|
||||
: new Map<string, string>();
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
action: r.action,
|
||||
label: r.label,
|
||||
actorType: r.actorType,
|
||||
actorName:
|
||||
r.actorName ?? (r.actorId ? (names.get(r.actorId) ?? null) : null),
|
||||
metadata: r.metadata ?? null,
|
||||
at: r.createdAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user