mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 12:58:13 +00:00
Merge pull request #1354 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Post-finalization clearance charges billed to the customer: one PORT_CHARGES
|
||||
* and one MISCELLANEOUS row max per booking, each carrying a document, amount,
|
||||
* currency and its own payable invoice.
|
||||
*/
|
||||
export class BookingClearanceCharge3590000000000 implements MigrationInterface {
|
||||
name = 'BookingClearanceCharge3590000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "freight"."booking_clearance_charge" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"updated_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"deleted_at" timestamptz,
|
||||
"booking_id" uuid NOT NULL,
|
||||
"type" character varying(20) NOT NULL,
|
||||
"status" character varying(20) NOT NULL DEFAULT 'DOC_UPLOADED',
|
||||
"file_record_id" uuid,
|
||||
"amount" numeric(14,2),
|
||||
"currency" character varying(8),
|
||||
"invoice_id" uuid,
|
||||
"uploaded_by_staff_id" uuid,
|
||||
"uploaded_at" timestamptz,
|
||||
"billed_by_staff_id" uuid,
|
||||
"billed_at" timestamptz,
|
||||
"paid_at" timestamptz,
|
||||
CONSTRAINT "pk_booking_clearance_charge" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "fk_booking_clearance_charge_booking" FOREIGN KEY ("booking_id")
|
||||
REFERENCES "freight"."bookings"("id") ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "uq_booking_clearance_charge_booking_type"
|
||||
ON "freight"."booking_clearance_charge" ("booking_id", "type")
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP TABLE IF EXISTS "freight"."booking_clearance_charge"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { Freight } from '@edr/types';
|
||||
|
||||
import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
|
||||
import { Invoice } from '../billing/entities/invoice.entity';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import {
|
||||
BookingClearanceCharge,
|
||||
ClearanceChargeType,
|
||||
} from './entities/booking-clearance-charge.entity';
|
||||
|
||||
/** File-record codes the charge documents are stored under on the booking. */
|
||||
const CHARGE_FILE_CODE: Record<ClearanceChargeType, string> = {
|
||||
PORT_CHARGES: 'clearance_charge_port',
|
||||
MISCELLANEOUS: 'clearance_charge_misc',
|
||||
};
|
||||
|
||||
const CHARGE_LABEL: Record<ClearanceChargeType, string> = {
|
||||
PORT_CHARGES: 'Port charges',
|
||||
MISCELLANEOUS: 'Miscellaneous charges',
|
||||
};
|
||||
|
||||
/**
|
||||
* Post-finalization clearance charges billed to the customer. Two levels per
|
||||
* booking: GL Djibouti uploads the port-charges document; GL Ethiopia bills it
|
||||
* (amount + currency) and sends the invoice; once that invoice is paid GL
|
||||
* Ethiopia may create and send the miscellaneous charge. ETB invoices are paid
|
||||
* through the portal gateway, other currencies through Finance's manual
|
||||
* settlement worklist — both settle via `clearance_charge.invoice.paid`.
|
||||
*/
|
||||
@Injectable()
|
||||
export class BookingClearanceChargeService {
|
||||
private readonly logger = new Logger(BookingClearanceChargeService.name);
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly bookingsService: BookingsService,
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
) {}
|
||||
|
||||
private repo() {
|
||||
return this.dataSource.getRepository(BookingClearanceCharge);
|
||||
}
|
||||
|
||||
/**
|
||||
* Charges are a post-finalization step: block while the customer's clearance
|
||||
* documents are still being collected/reviewed.
|
||||
*/
|
||||
private assertClearanceFinalized(booking: Booking): void {
|
||||
const inReview =
|
||||
booking.status === 'AWAITING_DOCUMENTS' ||
|
||||
booking.status === 'DOCUMENTS_UNDER_REVIEW';
|
||||
if (inReview && !booking.preClearanceFinalizedAt) {
|
||||
throw new BadRequestException(
|
||||
'Clearance charges open after document clearance is finalized.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async list(bookingId: string): Promise<Freight.ClearanceCharge[]> {
|
||||
const charges = await this.repo().find({
|
||||
where: { bookingId },
|
||||
order: { createdAt: 'ASC' },
|
||||
});
|
||||
if (charges.length === 0) return [];
|
||||
|
||||
const files = await this.filesService.findByResource(bookingId, 'bookings');
|
||||
const fileById = new Map(files.map((f) => [f.id, f]));
|
||||
const names = await this.bookingsRepository.resolveStaffNames(
|
||||
charges.flatMap((c) => [c.uploadedByStaffId, c.billedByStaffId]),
|
||||
);
|
||||
const invoiceIds = charges
|
||||
.map((c) => c.invoiceId)
|
||||
.filter((id): id is string => Boolean(id));
|
||||
const invoices = invoiceIds.length
|
||||
? await this.dataSource
|
||||
.getRepository(Invoice)
|
||||
.find({ where: invoiceIds.map((id) => ({ id })) })
|
||||
: [];
|
||||
const invoiceById = new Map(invoices.map((i) => [i.id, i]));
|
||||
|
||||
return charges.map((c) => {
|
||||
const file = c.fileRecordId ? (fileById.get(c.fileRecordId) ?? null) : null;
|
||||
return {
|
||||
id: c.id,
|
||||
bookingId: c.bookingId,
|
||||
type: c.type,
|
||||
status: c.status,
|
||||
file: file ? { id: file.id, name: file.name, url: file.url } : null,
|
||||
amount: c.amount != null ? Number(c.amount) : null,
|
||||
currency: c.currency ?? null,
|
||||
invoiceId: c.invoiceId ?? null,
|
||||
invoiceNumber: c.invoiceId
|
||||
? (invoiceById.get(c.invoiceId)?.invoiceNumber ?? null)
|
||||
: null,
|
||||
uploadedByName: c.uploadedByStaffId
|
||||
? (names.get(c.uploadedByStaffId) ?? null)
|
||||
: null,
|
||||
uploadedAt: c.uploadedAt ? c.uploadedAt.toISOString() : null,
|
||||
billedByName: c.billedByStaffId
|
||||
? (names.get(c.billedByStaffId) ?? null)
|
||||
: null,
|
||||
billedAt: c.billedAt ? c.billedAt.toISOString() : null,
|
||||
paidAt: c.paidAt ? c.paidAt.toISOString() : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** GL Djibouti uploads (or replaces, until billed) the port-charges document. */
|
||||
async uploadPortDocument(
|
||||
bookingId: string,
|
||||
file: Express.Multer.File,
|
||||
staffId: string,
|
||||
): Promise<Freight.ClearanceCharge[]> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
this.assertClearanceFinalized(booking);
|
||||
|
||||
const existing = await this.repo().findOne({
|
||||
where: { bookingId, type: 'PORT_CHARGES' },
|
||||
});
|
||||
if (existing && existing.status !== 'DOC_UPLOADED') {
|
||||
throw new ConflictException(
|
||||
'The port charge has already been billed — ask GL Ethiopia to revise it instead.',
|
||||
);
|
||||
}
|
||||
|
||||
const record = await this.filesService.upsertByCode(
|
||||
{
|
||||
resourceId: bookingId,
|
||||
resource: 'bookings',
|
||||
code: CHARGE_FILE_CODE.PORT_CHARGES,
|
||||
file,
|
||||
},
|
||||
{ userId: staffId },
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
await this.repo().update(existing.id, {
|
||||
fileRecordId: record.id,
|
||||
uploadedByStaffId: staffId,
|
||||
uploadedAt: new Date(),
|
||||
});
|
||||
} else {
|
||||
await this.repo().save(
|
||||
this.repo().create({
|
||||
bookingId,
|
||||
type: 'PORT_CHARGES',
|
||||
status: 'DOC_UPLOADED',
|
||||
fileRecordId: record.id,
|
||||
uploadedByStaffId: staffId,
|
||||
uploadedAt: new Date(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return this.list(bookingId);
|
||||
}
|
||||
|
||||
/**
|
||||
* GL Ethiopia sets (or, on the customer's request, revises) amount +
|
||||
* currency. Revising a SENT charge cancels its unpaid invoice; a PAID charge
|
||||
* is immutable.
|
||||
*/
|
||||
async billCharge(
|
||||
bookingId: string,
|
||||
chargeId: string,
|
||||
input: { amount: number; currency: string },
|
||||
staffId: string,
|
||||
): Promise<Freight.ClearanceCharge[]> {
|
||||
const charge = await this.repo().findOne({
|
||||
where: { id: chargeId, bookingId },
|
||||
});
|
||||
if (!charge) throw new NotFoundException('Clearance charge not found');
|
||||
if (charge.status === 'PAID') {
|
||||
throw new ConflictException('A paid charge can no longer be changed.');
|
||||
}
|
||||
if (!(input.amount > 0)) {
|
||||
throw new BadRequestException('Amount must be greater than zero.');
|
||||
}
|
||||
if (!input.currency?.trim()) {
|
||||
throw new BadRequestException('Currency is required.');
|
||||
}
|
||||
|
||||
if (charge.status === 'SENT' && charge.invoiceId) {
|
||||
await this.billing.cancelInvoice(charge.invoiceId);
|
||||
}
|
||||
|
||||
await this.repo().update(charge.id, {
|
||||
amount: input.amount.toFixed(2),
|
||||
currency: input.currency.trim().toUpperCase(),
|
||||
status: 'BILLED',
|
||||
invoiceId: null,
|
||||
billedByStaffId: staffId,
|
||||
billedAt: new Date(),
|
||||
});
|
||||
return this.list(bookingId);
|
||||
}
|
||||
|
||||
/** GL Ethiopia issues the payable invoice to the customer. */
|
||||
async sendCharge(
|
||||
bookingId: string,
|
||||
chargeId: string,
|
||||
): Promise<Freight.ClearanceCharge[]> {
|
||||
const charge = await this.repo().findOne({
|
||||
where: { id: chargeId, bookingId },
|
||||
});
|
||||
if (!charge) throw new NotFoundException('Clearance charge not found');
|
||||
if (charge.status !== 'BILLED') {
|
||||
throw new ConflictException(
|
||||
'Set the amount and currency before sending the charge to the customer.',
|
||||
);
|
||||
}
|
||||
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
const invoice = await this.billing.generateInvoice({
|
||||
source: Freight.InvoiceSource.ClearanceCharge,
|
||||
// The charge's own id, NOT the booking id — booking-scoped invoice
|
||||
// lookups (findPayable/expirePayable/CBE billQuery) must never match it.
|
||||
sourceId: charge.id,
|
||||
type: charge.type,
|
||||
companyId: booking.companyId,
|
||||
companyProfileId: booking.companyProfileId,
|
||||
currency: charge.currency ?? 'ETB',
|
||||
lines: [
|
||||
{
|
||||
chargeType: charge.type,
|
||||
description: `${CHARGE_LABEL[charge.type]} — ${booking.reference ?? bookingId}`,
|
||||
amount: Number(charge.amount),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await this.repo().update(charge.id, {
|
||||
status: 'SENT',
|
||||
invoiceId: invoice.id,
|
||||
});
|
||||
this.logger.log(
|
||||
`Clearance charge ${charge.type} on booking ${bookingId} sent as invoice ${invoice.invoiceNumber}`,
|
||||
);
|
||||
return this.list(bookingId);
|
||||
}
|
||||
|
||||
/**
|
||||
* GL Ethiopia creates the miscellaneous charge whole (document + amount +
|
||||
* currency). Second payment level: allowed only once the port charge is paid.
|
||||
*/
|
||||
async createMiscellaneous(
|
||||
bookingId: string,
|
||||
file: Express.Multer.File,
|
||||
input: { amount: number; currency: string },
|
||||
staffId: string,
|
||||
): Promise<Freight.ClearanceCharge[]> {
|
||||
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.',
|
||||
);
|
||||
}
|
||||
if (!(input.amount > 0)) {
|
||||
throw new BadRequestException('Amount must be greater than zero.');
|
||||
}
|
||||
if (!input.currency?.trim()) {
|
||||
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(
|
||||
this.repo().create({
|
||||
bookingId,
|
||||
type: 'MISCELLANEOUS',
|
||||
status: 'BILLED',
|
||||
fileRecordId: record.id,
|
||||
amount: input.amount.toFixed(2),
|
||||
currency: input.currency.trim().toUpperCase(),
|
||||
uploadedByStaffId: staffId,
|
||||
uploadedAt: new Date(),
|
||||
billedByStaffId: staffId,
|
||||
billedAt: new Date(),
|
||||
}),
|
||||
);
|
||||
return this.list(bookingId);
|
||||
}
|
||||
|
||||
/** Gateway and manual settlements both land here (`${source}.invoice.paid`). */
|
||||
@OnEvent('clearance_charge.invoice.paid')
|
||||
async onChargeInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
|
||||
const charge = await this.repo().findOne({
|
||||
where: { id: payload.sourceId },
|
||||
});
|
||||
if (!charge || charge.status === 'PAID') return;
|
||||
await this.repo().update(charge.id, {
|
||||
status: 'PAID',
|
||||
paidAt: new Date(),
|
||||
});
|
||||
this.logger.log(
|
||||
`Clearance charge ${charge.type} on booking ${charge.bookingId} paid (invoice ${payload.invoiceNumber})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,10 @@ import { ContainerValidationService } from './container-validation.service';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { assertBookingStatus } from './booking-status.util';
|
||||
import { clearanceCodesForBooking } from './clearance.util';
|
||||
import {
|
||||
buildClearanceDocHistory,
|
||||
type ClearanceDocEvent,
|
||||
} from './clearance-doc-history.util';
|
||||
import { computeNextStep, type BookingNextStep } from './booking-next-step.util';
|
||||
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
|
||||
import { PriceLineItemDto } from './dto/generate-price-response.dto';
|
||||
@@ -630,6 +634,7 @@ export class BookingTransitionService {
|
||||
uploadedAt: string | null;
|
||||
reviewedAt: string | null;
|
||||
reviewedByName: string | null;
|
||||
history: ClearanceDocEvent[];
|
||||
}>;
|
||||
allApproved: boolean;
|
||||
phase?: string | null;
|
||||
@@ -655,9 +660,18 @@ export class BookingTransitionService {
|
||||
const reviewByKey = new Map(
|
||||
reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r]),
|
||||
);
|
||||
const reviewerNames = await this.bookingsRepository.resolveStaffNames(
|
||||
reviews.map((r) => r.reviewedByStaffId),
|
||||
const allVersions = await this.filesService.findAllVersionsByResource(
|
||||
bookingId,
|
||||
"bookings",
|
||||
);
|
||||
const queryNotes = await this.bookingsRepository.findReviewNotes(
|
||||
bookingId,
|
||||
"CHANGES_REQUESTED",
|
||||
);
|
||||
const reviewerNames = await this.bookingsRepository.resolveStaffNames([
|
||||
...reviews.map((r) => r.reviewedByStaffId),
|
||||
...queryNotes.map((n) => n.authorId),
|
||||
]);
|
||||
|
||||
const documents: Awaited<
|
||||
ReturnType<BookingTransitionService["getClearanceView"]>
|
||||
@@ -691,6 +705,13 @@ export class BookingTransitionService {
|
||||
reviewedByName: review?.reviewedByStaffId
|
||||
? (reviewerNames.get(review.reviewedByStaffId) ?? null)
|
||||
: null,
|
||||
history: buildClearanceDocHistory({
|
||||
fileKey: field.fileKey,
|
||||
allVersions,
|
||||
queryNotes,
|
||||
review,
|
||||
names: reviewerNames,
|
||||
}),
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -716,6 +737,13 @@ export class BookingTransitionService {
|
||||
reviewedByName: review?.reviewedByStaffId
|
||||
? (reviewerNames.get(review.reviewedByStaffId) ?? null)
|
||||
: null,
|
||||
history: buildClearanceDocHistory({
|
||||
fileKey: f.code,
|
||||
allVersions,
|
||||
queryNotes,
|
||||
review,
|
||||
names: reviewerNames,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
@@ -38,6 +39,8 @@ import {
|
||||
} from "@nestjs/swagger";
|
||||
import type { Response } from "express";
|
||||
|
||||
import { BookingClearanceChargeService } from './booking-clearance-charge.service';
|
||||
import { BillClearanceChargeDto } from './dto/clearance-charge.dto';
|
||||
import { BookingContractService } from './booking-contract.service';
|
||||
import { BookingPricingService } from './booking-pricing.service';
|
||||
import { BookingTransitionService } from './booking-transition.service';
|
||||
@@ -170,6 +173,7 @@ export class BookingsController {
|
||||
private readonly userTradeAccessService: UserTradeAccessService,
|
||||
private readonly wagonCancellationService: BookingWagonCancellationService,
|
||||
private readonly consolidationApprovalService: ConsolidationApprovalService,
|
||||
private readonly clearanceChargeService: BookingClearanceChargeService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@@ -1073,6 +1077,96 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
// ── Clearance charges (post-finalization customer billing) ────────────────
|
||||
|
||||
@Get(":id/clearance/charges")
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||
FREIGHT_PERMS.contracts.clearanceDjActions,
|
||||
])
|
||||
@ApiOperation({
|
||||
summary: "Clearance charges billed to the customer (port + miscellaneous)",
|
||||
})
|
||||
getClearanceCharges(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.clearanceChargeService.list(id);
|
||||
}
|
||||
|
||||
@Post(":id/clearance/charges/port-document")
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@UseInterceptors(FileInterceptor("file"))
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
summary: "GL Djibouti uploads (or replaces, until billed) the port-charges document",
|
||||
})
|
||||
uploadPortChargeDocument(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
if (!file) throw new BadRequestException("A document file is required");
|
||||
return this.clearanceChargeService.uploadPortDocument(
|
||||
id,
|
||||
file,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Patch(":id/clearance/charges/:chargeId/bill")
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"GL Ethiopia sets or revises the charge's amount + currency (revising a sent charge cancels its unpaid invoice)",
|
||||
})
|
||||
billClearanceCharge(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("chargeId", ParseUUIDPipe) chargeId: string,
|
||||
@Body() dto: BillClearanceChargeDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.clearanceChargeService.billCharge(
|
||||
id,
|
||||
chargeId,
|
||||
dto,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post(":id/clearance/charges/:chargeId/send")
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"GL Ethiopia issues the charge's payable invoice to the customer (ETB pays via gateway, other currencies via manual settlement)",
|
||||
})
|
||||
sendClearanceCharge(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("chargeId", ParseUUIDPipe) chargeId: string,
|
||||
) {
|
||||
return this.clearanceChargeService.sendCharge(id, chargeId);
|
||||
}
|
||||
|
||||
@Post(":id/clearance/charges/miscellaneous")
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@UseInterceptors(FileInterceptor("file"))
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"GL Ethiopia creates the miscellaneous charge (document + amount + currency); unlocked once the port charge is paid",
|
||||
})
|
||||
createMiscellaneousCharge(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body() dto: BillClearanceChargeDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
if (!file) throw new BadRequestException("A document file is required");
|
||||
return this.clearanceChargeService.createMiscellaneous(
|
||||
id,
|
||||
file,
|
||||
dto,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post(":id/clearance/output-documents")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
|
||||
@@ -37,6 +37,8 @@ import { ContainerValidationService } from './container-validation.service';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||
import { BookingDocumentReview } from './entities/booking-document-review.entity';
|
||||
import { BookingClearanceCharge } from './entities/booking-clearance-charge.entity';
|
||||
import { BookingClearanceChargeService } from './booking-clearance-charge.service';
|
||||
import { BookingContainer } from './entities/booking-container.entity';
|
||||
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
||||
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
|
||||
@@ -76,6 +78,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
CustomerTruckAssignment,
|
||||
CustomerTruckContainer,
|
||||
ConsolidationApproval,
|
||||
BookingClearanceCharge,
|
||||
]),
|
||||
BillingModule,
|
||||
DocumentsModule,
|
||||
@@ -111,6 +114,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
BookingTransitionService,
|
||||
BookingContractService,
|
||||
BookingInvoiceService,
|
||||
BookingClearanceChargeService,
|
||||
ContractTemplateResolver,
|
||||
ContractViewModelBuilder,
|
||||
ContractPricingScheduleBuilder,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { BookingDocumentReview } from './entities/booking-document-review.entity';
|
||||
import type { BookingReviewNote } from './entities/booking-review-note.entity';
|
||||
import type { FileRecord } from '../files/entities/file.entity';
|
||||
|
||||
/** One entry of a clearance document's per-card audit trail, oldest first. */
|
||||
export interface ClearanceDocEvent {
|
||||
type: 'UPLOADED' | 'RESUBMITTED' | 'QUERIED' | 'APPROVED';
|
||||
at: string;
|
||||
byName: string | null;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query review notes are written as `Document "<fileKey>" queried: <note>`
|
||||
* (see BookingTransitionService.reviewDocument) — the only place a past query
|
||||
* decision survives after the customer re-uploads and the review row resets.
|
||||
*/
|
||||
const QUERY_NOTE_RE = /^Document "(.+?)" queried: ([\s\S]*)$/;
|
||||
|
||||
/**
|
||||
* Per-document audit trail assembled from data the flow already persists:
|
||||
* every stored file version (first = customer upload, later ones = the
|
||||
* customer's amendment responses), every query note (who opened it, when,
|
||||
* why), and the review row's current approval. Approvals that were later
|
||||
* reset by a re-upload are the one thing not kept anywhere — the trail shows
|
||||
* the decision that currently stands.
|
||||
*/
|
||||
export function buildClearanceDocHistory(input: {
|
||||
fileKey: string;
|
||||
/** All versions of all files on the booking, createdAt ASC, deleted included. */
|
||||
allVersions: FileRecord[];
|
||||
/** CHANGES_REQUESTED review notes for the booking. */
|
||||
queryNotes: BookingReviewNote[];
|
||||
review: BookingDocumentReview | null;
|
||||
/** staff id → display name. */
|
||||
names: Map<string, string>;
|
||||
}): ClearanceDocEvent[] {
|
||||
const { fileKey, allVersions, queryNotes, review, names } = input;
|
||||
const events: ClearanceDocEvent[] = [];
|
||||
|
||||
const versions = allVersions.filter((v) => v.code === fileKey);
|
||||
versions.forEach((v, i) => {
|
||||
events.push({
|
||||
type: i === 0 ? 'UPLOADED' : 'RESUBMITTED',
|
||||
at: v.createdAt.toISOString(),
|
||||
byName: v.uploadedByName ?? null,
|
||||
note: null,
|
||||
});
|
||||
});
|
||||
|
||||
for (const n of queryNotes) {
|
||||
const m = QUERY_NOTE_RE.exec(n.note);
|
||||
if (!m || m[1] !== fileKey) continue;
|
||||
events.push({
|
||||
type: 'QUERIED',
|
||||
at: n.createdAt.toISOString(),
|
||||
byName: n.authorId ? (names.get(n.authorId) ?? null) : null,
|
||||
note: m[2] || null,
|
||||
});
|
||||
}
|
||||
|
||||
if (review?.status === 'APPROVED' && review.reviewedAt) {
|
||||
events.push({
|
||||
type: 'APPROVED',
|
||||
at: review.reviewedAt.toISOString(),
|
||||
byName: review.reviewedByStaffId
|
||||
? (names.get(review.reviewedByStaffId) ?? null)
|
||||
: null,
|
||||
note: null,
|
||||
});
|
||||
}
|
||||
|
||||
return events.sort((a, b) => a.at.localeCompare(b.at));
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsNumber, IsPositive, IsString, Length } from 'class-validator';
|
||||
|
||||
export class BillClearanceChargeDto {
|
||||
@ApiProperty({ example: 12500.5 })
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@IsPositive()
|
||||
amount!: number;
|
||||
|
||||
@ApiProperty({ example: 'ETB' })
|
||||
@IsString()
|
||||
@Length(3, 8)
|
||||
currency!: string;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { Booking } from './booking.entity';
|
||||
|
||||
export const CLEARANCE_CHARGE_TYPES = ['PORT_CHARGES', 'MISCELLANEOUS'] as const;
|
||||
export type ClearanceChargeType = (typeof CLEARANCE_CHARGE_TYPES)[number];
|
||||
|
||||
export const CLEARANCE_CHARGE_STATUSES = [
|
||||
'DOC_UPLOADED',
|
||||
'BILLED',
|
||||
'SENT',
|
||||
'PAID',
|
||||
] as const;
|
||||
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.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'booking_clearance_charge' })
|
||||
@Index(['bookingId', 'type'], { unique: true })
|
||||
export class BookingClearanceCharge extends BaseEntity {
|
||||
@Column({ name: 'booking_id', type: 'uuid' })
|
||||
bookingId!: string;
|
||||
|
||||
@ManyToOne(() => Booking, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'booking_id' })
|
||||
booking?: Booking;
|
||||
|
||||
@Column({ name: 'type', type: 'varchar', length: 20 })
|
||||
type!: ClearanceChargeType;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 20, default: 'DOC_UPLOADED' })
|
||||
status!: ClearanceChargeStatus;
|
||||
|
||||
/** The supporting charge document (FileRecord). */
|
||||
@Column({ name: 'file_record_id', type: 'uuid', nullable: true })
|
||||
fileRecordId?: string | null;
|
||||
|
||||
@Column({ name: 'amount', type: 'numeric', precision: 14, scale: 2, nullable: true })
|
||||
amount?: string | null;
|
||||
|
||||
@Column({ name: 'currency', type: 'varchar', length: 8, nullable: true })
|
||||
currency?: string | null;
|
||||
|
||||
/** The payable invoice issued for this charge (null until SENT). */
|
||||
@Column({ name: 'invoice_id', type: 'uuid', nullable: true })
|
||||
invoiceId?: string | null;
|
||||
|
||||
@Column({ name: 'uploaded_by_staff_id', type: 'uuid', nullable: true })
|
||||
uploadedByStaffId?: string | null;
|
||||
|
||||
@Column({ name: 'uploaded_at', type: 'timestamptz', nullable: true })
|
||||
uploadedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'billed_by_staff_id', type: 'uuid', nullable: true })
|
||||
billedByStaffId?: string | null;
|
||||
|
||||
@Column({ name: 'billed_at', type: 'timestamptz', nullable: true })
|
||||
billedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'paid_at', type: 'timestamptz', nullable: true })
|
||||
paidAt?: Date | null;
|
||||
}
|
||||
@@ -35,6 +35,11 @@ import { ContractsRepository } from './contracts.repository';
|
||||
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
|
||||
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDeliveryOrderUploads, persistDraftDeclarationUploads, persistReleaseOrderUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util';
|
||||
|
||||
import {
|
||||
buildClearanceDocHistory,
|
||||
type ClearanceDocEvent,
|
||||
} from '../bookings/clearance-doc-history.util';
|
||||
|
||||
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
|
||||
|
||||
export interface BookingClearanceView {
|
||||
@@ -55,6 +60,7 @@ export interface BookingClearanceView {
|
||||
uploadedAt: string | null;
|
||||
reviewedAt: string | null;
|
||||
reviewedByName: string | null;
|
||||
history: ClearanceDocEvent[];
|
||||
}>;
|
||||
allApproved: boolean;
|
||||
phase?: string | null;
|
||||
@@ -188,9 +194,18 @@ export class BookingClearanceService {
|
||||
const fileByCode = new Map(files.map((f) => [f.code, f]));
|
||||
const reviews = await this.bookingsRepository.findDocumentReviews(bookingId);
|
||||
const reviewByKey = new Map(reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r]));
|
||||
const reviewerNames = await this.bookingsRepository.resolveStaffNames(
|
||||
reviews.map((r) => r.reviewedByStaffId),
|
||||
const allVersions = await this.filesService.findAllVersionsByResource(
|
||||
bookingId,
|
||||
'bookings',
|
||||
);
|
||||
const queryNotes = await this.bookingsRepository.findReviewNotes(
|
||||
bookingId,
|
||||
'CHANGES_REQUESTED',
|
||||
);
|
||||
const reviewerNames = await this.bookingsRepository.resolveStaffNames([
|
||||
...reviews.map((r) => r.reviewedByStaffId),
|
||||
...queryNotes.map((n) => n.authorId),
|
||||
]);
|
||||
|
||||
const documents: BookingClearanceView['documents'] = [];
|
||||
|
||||
@@ -219,6 +234,13 @@ export class BookingClearanceService {
|
||||
reviewedByName: review?.reviewedByStaffId
|
||||
? (reviewerNames.get(review.reviewedByStaffId) ?? null)
|
||||
: null,
|
||||
history: buildClearanceDocHistory({
|
||||
fileKey: field.fileKey,
|
||||
allVersions,
|
||||
queryNotes,
|
||||
review,
|
||||
names: reviewerNames,
|
||||
}),
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -243,6 +265,13 @@ export class BookingClearanceService {
|
||||
reviewedByName: review?.reviewedByStaffId
|
||||
? (reviewerNames.get(review.reviewedByStaffId) ?? null)
|
||||
: null,
|
||||
history: buildClearanceDocHistory({
|
||||
fileKey: f.code,
|
||||
allVersions,
|
||||
queryNotes,
|
||||
review,
|
||||
names: reviewerNames,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,17 +3,20 @@ import { BadRequestException } from '@nestjs/common';
|
||||
import { ContractTransitionService } from './contract-transition.service';
|
||||
|
||||
/**
|
||||
* Where the booking-contract view reads the global stamp live, the contracts
|
||||
* path SNAPSHOTS it onto the signature row at signing time, so replacing the
|
||||
* company stamp can never restamp an already-executed contract. These specs
|
||||
* pin the sourcing split: EDR always seals with the global stamp and staff
|
||||
* never supply one, while the customer must upload their own.
|
||||
* The staff signature seals with the ONE global stamp by REFERENCE: the
|
||||
* signature row stores the current global stampFileId instead of re-uploading
|
||||
* a copy per contract. That id stays valid after the stamp is replaced
|
||||
* (StampSettingsService never deletes retired stamp files), so each contract
|
||||
* keeps the exact seal it was signed with. These specs pin the sourcing
|
||||
* split: EDR always seals with the global stamp and staff never supply one,
|
||||
* while the customer must upload their own.
|
||||
*/
|
||||
describe('applySignature stamp sourcing', () => {
|
||||
const contract = { id: 'c-1', reference: 'CTR-1', status: 'SIGNED_CUSTOMER' };
|
||||
const GLOBAL_STAMP = 'data:image/png;base64,RURS';
|
||||
const GLOBAL_STAMP_FILE_ID = 'file-global-stamp';
|
||||
|
||||
const build = (globalStamp: string | null = GLOBAL_STAMP) => {
|
||||
const build = (globalStampFileId: string | null = GLOBAL_STAMP_FILE_ID) => {
|
||||
const uploads: Array<{ code: string; image: string }> = [];
|
||||
const saved: unknown[] = [];
|
||||
const service = Object.create(
|
||||
@@ -22,7 +25,10 @@ describe('applySignature stamp sourcing', () => {
|
||||
Object.assign(service, {
|
||||
logger: { warn: jest.fn(), log: jest.fn() },
|
||||
stampSettings: {
|
||||
getStampImageUrl: jest.fn().mockResolvedValue(globalStamp),
|
||||
get: jest.fn().mockResolvedValue({
|
||||
id: 's-1',
|
||||
stampFileId: globalStampFileId,
|
||||
}),
|
||||
},
|
||||
contractsRepository: {
|
||||
saveSignature: jest.fn((row: unknown) => {
|
||||
@@ -62,35 +68,36 @@ describe('applySignature stamp sourcing', () => {
|
||||
signatureImageBase64: 'data:image/png;base64,U0lH',
|
||||
};
|
||||
|
||||
it('seals the EDR side with the global stamp', async () => {
|
||||
it('seals the EDR side by referencing the global stamp file, without re-uploading it', async () => {
|
||||
const { service, uploads, saved } = build();
|
||||
|
||||
await apply(service, staffDto);
|
||||
|
||||
expect(uploads).toContainEqual({ code: 'stamp_staff', image: GLOBAL_STAMP });
|
||||
expect(uploads.map((u) => u.code)).toEqual(['signature_staff']);
|
||||
expect(saved[0]).toEqual(
|
||||
expect.objectContaining({ stampFileId: 'file-stamp_staff' }),
|
||||
expect.objectContaining({ stampFileId: GLOBAL_STAMP_FILE_ID }),
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores a stamp a staff client tries to supply', async () => {
|
||||
const { service, uploads } = build();
|
||||
const { service, uploads, saved } = build();
|
||||
|
||||
await apply(service, {
|
||||
...staffDto,
|
||||
stampImageBase64: 'data:image/png;base64,SEFDSw==',
|
||||
});
|
||||
|
||||
expect(uploads).toContainEqual({ code: 'stamp_staff', image: GLOBAL_STAMP });
|
||||
expect(uploads.map((u) => u.image)).not.toContain(
|
||||
'data:image/png;base64,SEFDSw==',
|
||||
);
|
||||
expect(saved[0]).toEqual(
|
||||
expect.objectContaining({ stampFileId: GLOBAL_STAMP_FILE_ID }),
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Failing loudly matters here: getStampImageUrl degrades to null when the
|
||||
* stamp cannot be inlined, and silently executing an unsealed contract would
|
||||
* be worse than refusing to counter-sign.
|
||||
* Failing loudly matters here: silently executing an unsealed contract
|
||||
* would be worse than refusing to counter-sign.
|
||||
*/
|
||||
it('refuses to counter-sign when no global stamp is configured', async () => {
|
||||
const { service, saved } = build(null);
|
||||
|
||||
@@ -1128,17 +1128,27 @@ export class ContractTransitionService {
|
||||
);
|
||||
}
|
||||
|
||||
// Snapshot whichever stamp applies onto the signature row rather than
|
||||
// referencing the global one, so replacing the company stamp later can
|
||||
// never restamp an already-executed contract.
|
||||
let stampImageBase64 = dto.stampImageBase64 ?? null;
|
||||
// STAFF seals by REFERENCE to the one global stamp file — no per-contract
|
||||
// copy of the image. Safe because StampSettingsService.setStamp/clearStamp
|
||||
// never delete a replaced stamp file: the referenced id keeps rendering
|
||||
// the exact seal that was current at signing, even after the global stamp
|
||||
// is later replaced. The customer's stamp is their own upload and is still
|
||||
// stored per contract.
|
||||
let stampFileId: string | null = null;
|
||||
if (role === 'STAFF') {
|
||||
stampImageBase64 = await this.stampSettings.getStampImageUrl();
|
||||
if (!stampImageBase64) {
|
||||
stampFileId = (await this.stampSettings.get()).stampFileId ?? null;
|
||||
if (!stampFileId) {
|
||||
throw new BadRequestException(
|
||||
'No company stamp is configured. Upload the company stamp under Settings before counter-signing contracts.',
|
||||
);
|
||||
}
|
||||
} else if (dto.stampImageBase64) {
|
||||
const stampRecord = await this.uploadSignatureAsset(
|
||||
contract,
|
||||
`stamp_${role.toLowerCase()}`,
|
||||
dto.stampImageBase64,
|
||||
);
|
||||
stampFileId = stampRecord.id;
|
||||
}
|
||||
|
||||
const fileRecord = await this.uploadSignatureAsset(
|
||||
@@ -1146,13 +1156,6 @@ export class ContractTransitionService {
|
||||
`signature_${role.toLowerCase()}`,
|
||||
imageBase64,
|
||||
);
|
||||
const stampRecord = stampImageBase64
|
||||
? await this.uploadSignatureAsset(
|
||||
contract,
|
||||
`stamp_${role.toLowerCase()}`,
|
||||
stampImageBase64,
|
||||
)
|
||||
: null;
|
||||
|
||||
await this.contractsRepository.saveSignature({
|
||||
contractId: contract.id,
|
||||
@@ -1160,7 +1163,7 @@ export class ContractTransitionService {
|
||||
signerDisplayName,
|
||||
signedAt: new Date(),
|
||||
signatureFileId: fileRecord.id,
|
||||
stampFileId: stampRecord?.id ?? null,
|
||||
stampFileId,
|
||||
consentText: dto.consentText ?? null,
|
||||
});
|
||||
|
||||
|
||||
@@ -84,6 +84,21 @@ export class FilesRepository extends BaseRepository<FileRecord> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Every version of every document on a resource, oldest first — superseded
|
||||
* versions included. One query for a whole document grid's upload history.
|
||||
*/
|
||||
findAllVersionsByResource(
|
||||
resourceId: string,
|
||||
resource: string,
|
||||
): Promise<FileRecord[]> {
|
||||
return this.repository.find({
|
||||
where: { resourceId, resource },
|
||||
withDeleted: true,
|
||||
order: { createdAt: "ASC" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Documents belonging to any of the given resources that a reviewer has asked
|
||||
* the customer to correct. Used by the approval gate, so it takes a list of
|
||||
|
||||
@@ -327,6 +327,14 @@ export class FilesService {
|
||||
return this.filesRepository.findByResource(resourceId, resource);
|
||||
}
|
||||
|
||||
/** All versions of every document on a resource (superseded included), oldest first. */
|
||||
findAllVersionsByResource(
|
||||
resourceId: string,
|
||||
resource: string,
|
||||
): Promise<FileRecord[]> {
|
||||
return this.filesRepository.findAllVersionsByResource(resourceId, resource);
|
||||
}
|
||||
|
||||
/**
|
||||
* Files for many resources of one kind, grouped by resource id. Resources with
|
||||
* no files are absent from the map (callers should default to `[]`).
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { Readable } from "stream";
|
||||
import { DataSource } from "typeorm";
|
||||
|
||||
import { FilesService } from "../files/files.service";
|
||||
import { FileRecord } from "../files/entities/file.entity";
|
||||
import { MinioService } from "../minio/minio.service";
|
||||
import { StampSettingsRepository } from "./stamp-settings.repository";
|
||||
import { StampSetting } from "./entities/stamp-setting.entity";
|
||||
@@ -28,7 +26,6 @@ export class StampSettingsService {
|
||||
private readonly repository: StampSettingsRepository,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly minioService: MinioService,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/** The settings row, created empty on first access. */
|
||||
@@ -53,14 +50,12 @@ export class StampSettingsService {
|
||||
* `data:` URL or null. Never throws — document generation must succeed even
|
||||
* if the stamp lookup fails; callers fall back to their own seal on null.
|
||||
*
|
||||
* The data-URL-or-null guarantee is load-bearing, not cosmetic. Callers do
|
||||
* two things with this value that a bare MinIO URL silently corrupts:
|
||||
* ContractTransitionService base64-decodes it to snapshot the seal onto a
|
||||
* signature row (a URL decodes to garbage bytes, not an error, permanently
|
||||
* sealing an executed contract with a broken image), and the HTML render
|
||||
* path inlines it into an <img> that headless Chromium cannot fetch. So
|
||||
* where getView() may hand a raw URL to a browser that can load it, this
|
||||
* degrades to null and lets the caller draw its text/vector seal instead.
|
||||
* The data-URL-or-null guarantee is load-bearing, not cosmetic: the HTML
|
||||
* render paths inline this value into an <img> that headless Chromium
|
||||
* cannot fetch over the network. So where getView() may hand a raw URL to
|
||||
* a browser that can load it, this degrades to null and lets the caller
|
||||
* draw its text/vector seal instead. (Contract signing no longer consumes
|
||||
* this — staff signatures reference the stampFileId directly.)
|
||||
*/
|
||||
async getStampImageUrl(): Promise<string | null> {
|
||||
try {
|
||||
@@ -81,13 +76,20 @@ export class StampSettingsService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Replace the stamp image, storing it in MinIO via FilesService. */
|
||||
/**
|
||||
* Replace the stamp image, storing it in MinIO via FilesService.
|
||||
*
|
||||
* The replaced file is NEVER deleted: contract signatures reference stamp
|
||||
* files by id (ContractTransitionService points staff signatures at the
|
||||
* current stampFileId instead of copying the image), so each retired file
|
||||
* is the immutable record of which seal executed the contracts signed while
|
||||
* it was current. Deleting it would strip the seal off those contracts.
|
||||
*/
|
||||
async setStamp(
|
||||
stampImageBase64: string,
|
||||
updatedById?: string | null,
|
||||
): Promise<StampSettingView> {
|
||||
const current = await this.get();
|
||||
const previousFileId = current.stampFileId ?? null;
|
||||
|
||||
const fileRecord = await this.filesService.upload({
|
||||
resourceId: current.id,
|
||||
@@ -102,28 +104,22 @@ export class StampSettingsService {
|
||||
updatedById: updatedById ?? null,
|
||||
});
|
||||
|
||||
if (previousFileId && previousFileId !== fileRecord.id) {
|
||||
await this.dataSource.getRepository(FileRecord).delete(previousFileId);
|
||||
}
|
||||
|
||||
this.logger.log(`Company stamp updated by ${updatedById ?? "unknown user"}`);
|
||||
return this.getView();
|
||||
}
|
||||
|
||||
/** Clear the stamp (invoices fall back to the programmatic seal). */
|
||||
/**
|
||||
* Clear the stamp (invoices fall back to the programmatic seal). The file
|
||||
* is kept for the same reason as in {@link setStamp}.
|
||||
*/
|
||||
async clearStamp(updatedById?: string | null): Promise<StampSettingView> {
|
||||
const current = await this.get();
|
||||
const previousFileId = current.stampFileId ?? null;
|
||||
|
||||
await this.repository.update(current.id, {
|
||||
stampFileId: null,
|
||||
updatedById: updatedById ?? null,
|
||||
});
|
||||
|
||||
if (previousFileId) {
|
||||
await this.dataSource.getRepository(FileRecord).delete(previousFileId);
|
||||
}
|
||||
|
||||
return this.getView();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user