mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 04:08:11 +00:00
shipping line
This commit is contained in:
@@ -2,6 +2,7 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Transform, Type } from "class-transformer";
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsIn,
|
||||
IsInt,
|
||||
@@ -15,15 +16,61 @@ import {
|
||||
|
||||
import { PAYMENT_CURRENCIES } from "../../contracts/dto/create-contract.dto";
|
||||
|
||||
/**
|
||||
* One physical container on a line — number, seal, VGM and its per-container
|
||||
* handling switches. Same shape the customer shipment form submits.
|
||||
*/
|
||||
export class CompleteShippingLineContainerUnitDto {
|
||||
@ApiProperty({ example: "MSCU1234567" })
|
||||
@IsString()
|
||||
containerNumber!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sealNumber?: string;
|
||||
|
||||
@ApiProperty({ minimum: 0, description: "VGM of this container, tons." })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
vgmTons!: number;
|
||||
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isHazardous?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isReefer?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* One container line the shipping line ships — container type + count, the
|
||||
* same shape the customer one-time form collects (no per-unit ISO numbers;
|
||||
* those are captured downstream at yard operations, as for customers).
|
||||
* same shape the customer one-time form collects. When `units` is sent (the
|
||||
* full booking page), per-container numbers/seals/VGM and handling switches
|
||||
* are persisted exactly like the customer shipment form; without it (legacy
|
||||
* modal shape) the line-level counts stand alone.
|
||||
*/
|
||||
export class CompleteShippingLineContainerLineDto {
|
||||
@ApiProperty({ format: "uuid", description: "Container type being shipped." })
|
||||
@ApiPropertyOptional({
|
||||
format: "uuid",
|
||||
description:
|
||||
"Container type being shipped. Optional when containerSize is sent — the server resolves the type from the size.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
containerTypeId!: string;
|
||||
containerTypeId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Container size, e.g. "20ft" | "40ft". The server maps it to the configured container type (reefer variant when the line carries reefer boxes) — so the client never needs the type catalog.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
containerSize?: string;
|
||||
|
||||
@ApiProperty({ minimum: 1 })
|
||||
@IsInt()
|
||||
@@ -51,6 +98,17 @@ export class CompleteShippingLineContainerLineDto {
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
reeferQuantity?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: [CompleteShippingLineContainerUnitDto],
|
||||
description:
|
||||
"Per-container details. When present, the handling counts and VGM are derived from these rows.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CompleteShippingLineContainerUnitDto)
|
||||
units?: CompleteShippingLineContainerUnitDto[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,6 +124,15 @@ export class CompleteShippingLineBookingDto {
|
||||
@IsDateString()
|
||||
scheduledDate!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: "uuid",
|
||||
description:
|
||||
"Which of the line's dedicated trains this booking rides. Required when more than one departs on the chosen day; implicit with a single departure.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
trainScheduleId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: PAYMENT_CURRENCIES })
|
||||
@IsOptional()
|
||||
@IsIn([...PAYMENT_CURRENCIES])
|
||||
@@ -99,6 +166,26 @@ export class CompleteShippingLineBookingDto {
|
||||
@Transform(({ value }) => Number(value))
|
||||
cargoWeightTons?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
minimum: 0,
|
||||
description: "Bulk freight: hazardous portion of the cargo.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
bulkHazardousQuantity?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
minimum: 0,
|
||||
description: "Bulk freight: refrigerated portion of the cargo.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
bulkReeferQuantity?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: "What the containers carry." })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -38,6 +38,40 @@ export class GenerateCreditInvoiceDto {
|
||||
dueInDays?: number;
|
||||
}
|
||||
|
||||
/** Finance's request for a manual action on a credit invoice (maker step). */
|
||||
export class RequestInvoiceActionDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Why the action is needed. Shown to the approver and kept for audit.",
|
||||
example: "Paid by bank transfer, slip #TT-4491",
|
||||
})
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
@MaxLength(500)
|
||||
reason!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Offline payment reference (bank slip / transfer number). MARK_PAID requests only.",
|
||||
example: "TT-4491",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
paymentReference?: string;
|
||||
}
|
||||
|
||||
/** The decision on a pending request (approve and reject routes). */
|
||||
export class DecideInvoiceActionDto {
|
||||
@ApiPropertyOptional({
|
||||
description: "Decision note. Required when rejecting.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/** Write-off of a single unbilled credit. */
|
||||
export class CancelCreditDto {
|
||||
@ApiProperty({
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
|
||||
import { Invoice } from "../../billing/entities/invoice.entity";
|
||||
|
||||
/** What finance asked to do to a shipping-line credit invoice. */
|
||||
export enum ShippingLineInvoiceActionType {
|
||||
/** Record a full offline settlement (paid outside the gateway). */
|
||||
MarkPaid = "MARK_PAID",
|
||||
/** Void the invoice; its credits return to the unbilled pool. */
|
||||
Cancel = "CANCEL",
|
||||
}
|
||||
|
||||
export enum ShippingLineInvoiceActionStatus {
|
||||
Pending = "PENDING",
|
||||
Approved = "APPROVED",
|
||||
Rejected = "REJECTED",
|
||||
}
|
||||
|
||||
/**
|
||||
* Maker–checker for manual actions on shipping-line credit invoices.
|
||||
*
|
||||
* Marking an invoice paid by hand erases real debt, and cancelling one
|
||||
* releases its credits back to the unbilled pool — either done unilaterally is
|
||||
* a one-person fraud path. So finance REQUESTS the action (one permission)
|
||||
* and a chief APPROVES or REJECTS it (a separate permission, different
|
||||
* person). Every request is kept, decided or not: the table is the audit
|
||||
* trail of who asked, who decided, and why.
|
||||
*/
|
||||
@Entity({ schema: "freight", name: "shipping_line_invoice_approvals" })
|
||||
@Index(["invoiceId", "status"])
|
||||
export class ShippingLineInvoiceApproval extends BaseEntity {
|
||||
@Column({ name: "invoice_id", type: "uuid" })
|
||||
invoiceId!: string;
|
||||
|
||||
@ManyToOne(() => Invoice)
|
||||
@JoinColumn({ name: "invoice_id" })
|
||||
invoice?: Invoice;
|
||||
|
||||
@Column({ name: "action", type: "enum", enum: ShippingLineInvoiceActionType })
|
||||
action!: ShippingLineInvoiceActionType;
|
||||
|
||||
@Column({
|
||||
name: "status",
|
||||
type: "enum",
|
||||
enum: ShippingLineInvoiceActionStatus,
|
||||
default: ShippingLineInvoiceActionStatus.Pending,
|
||||
})
|
||||
status!: ShippingLineInvoiceActionStatus;
|
||||
|
||||
/** IAM user id of the finance staff who raised the request. */
|
||||
@Column({ name: "requested_by", type: "uuid" })
|
||||
requestedBy!: string;
|
||||
|
||||
/** Why the action is needed; shown to the approver, kept for audit. */
|
||||
@Column({ name: "reason", type: "varchar", length: 500 })
|
||||
reason!: string;
|
||||
|
||||
/** Offline payment reference (bank slip no. etc.) for MARK_PAID requests. */
|
||||
@Column({
|
||||
name: "payment_reference",
|
||||
type: "varchar",
|
||||
length: 255,
|
||||
nullable: true,
|
||||
})
|
||||
paymentReference?: string | null;
|
||||
|
||||
/** IAM user id of the chief who approved/rejected; null while pending. */
|
||||
@Column({ name: "decided_by", type: "uuid", nullable: true })
|
||||
decidedBy?: string | null;
|
||||
|
||||
@Column({ name: "decided_at", type: "timestamptz", nullable: true })
|
||||
decidedAt?: Date | null;
|
||||
|
||||
@Column({
|
||||
name: "decision_note",
|
||||
type: "varchar",
|
||||
length: 500,
|
||||
nullable: true,
|
||||
})
|
||||
decisionNote?: string | null;
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Post } from "@nestjs/common";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { PortalCustomer } from "../../common/booking-guards";
|
||||
@@ -37,6 +45,54 @@ export class ShippingLineBookingCompletionController {
|
||||
return this.completionService.availableDaysMine(user.id, id);
|
||||
}
|
||||
|
||||
@Get(":id/trains")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"The line's dedicated trains on the booking's lane for a shipment day, each with per-wagon-type free space — for the completion form's train picker. Cargo context (sizes/cargoTypeId/wagons) refines the availability.",
|
||||
})
|
||||
async trainsForDay(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Query("date") date?: string,
|
||||
@Query("sizes") sizes?: string,
|
||||
@Query("cargoTypeId") cargoTypeId?: string,
|
||||
@Query("wagons") wagons?: string,
|
||||
) {
|
||||
return this.completionService.trainsForDayMine(user.id, id, date, {
|
||||
containerSizes: sizes ? sizes.split(",").filter(Boolean) : undefined,
|
||||
cargoTypeId: cargoTypeId || undefined,
|
||||
wagons: wagons ? Number(wagons) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Post(":id/price-preview")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Authoritative price quote for the completion payload — same compute as /complete, saved as the booking's breakdown + rate snapshots (refreshed on every re-preview). Persists nothing else.",
|
||||
})
|
||||
async pricePreview(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: CompleteShippingLineBookingDto,
|
||||
) {
|
||||
return this.completionService.previewPriceMine(user.id, id, dto);
|
||||
}
|
||||
|
||||
@Get(":id/operations")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Operations view of the booking: the train it rides (assigned or requested) and the wagons allocated to it.",
|
||||
})
|
||||
async operations(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
) {
|
||||
return this.completionService.operationsMine(user.id, id);
|
||||
}
|
||||
|
||||
@Post(":id/complete")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
|
||||
@@ -11,12 +11,18 @@ import { BookingPricingService } from "../bookings/booking-pricing.service";
|
||||
import { BookingTransitionService } from "../bookings/booking-transition.service";
|
||||
import { BookingsService } from "../bookings/bookings.service";
|
||||
import { BookingContainer } from "../bookings/entities/booking-container.entity";
|
||||
import { BookingContainerUnit } from "../bookings/entities/booking-container-unit.entity";
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { wagonsPerUnitForSize } from "../rule-engine/container-type.util";
|
||||
import { CargoType } from "../rule-engine/entities/cargo-type.entity";
|
||||
import { ContainerType } from "../rule-engine/entities/container-type.entity";
|
||||
import { eatDay } from "../train-scheduling/batch-window.util";
|
||||
import {
|
||||
BookingBatchService,
|
||||
type TrainOptionCargoOverrides,
|
||||
} from "../train-scheduling/booking-batch.service";
|
||||
import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
|
||||
import { WagonBookingAllocation } from "../train-schedules/entities/wagon-booking-allocation.entity";
|
||||
import { TrainSchedulingService } from "../train-scheduling/services/train-scheduling.service";
|
||||
import { CompleteShippingLineBookingDto } from "./dto/complete-shipping-line-booking.dto";
|
||||
import {
|
||||
@@ -49,6 +55,7 @@ export class ShippingLineBookingCompletionService {
|
||||
private readonly bookingPricingService: BookingPricingService,
|
||||
private readonly bookingTransitionService: BookingTransitionService,
|
||||
private readonly trainSchedulingService: TrainSchedulingService,
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
private readonly creditsService: ShippingLineCreditsService,
|
||||
) {}
|
||||
|
||||
@@ -108,6 +115,28 @@ export class ShippingLineBookingCompletionService {
|
||||
return closesAt.getTime() > Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* The line's dedicated trains on the booking's lane for one shipment day,
|
||||
* each with per-wagon-type free space — the completion form's train picker.
|
||||
* A booking rides ONE schedule, so with several departures that day the
|
||||
* line picks which; the pick is validated again at complete time.
|
||||
*/
|
||||
async trainsForDayMine(
|
||||
userId: string,
|
||||
bookingId: string,
|
||||
date: string | undefined,
|
||||
overrides?: TrainOptionCargoOverrides,
|
||||
) {
|
||||
const booking = await this.requireOwnBooking(userId, bookingId);
|
||||
if (!booking.shippingLineCompanyId) return [];
|
||||
return this.bookingBatchService.dedicatedTrainOptionsForDay(
|
||||
booking,
|
||||
date ? eatDay(new Date(date)) : null,
|
||||
booking.shippingLineCompanyId,
|
||||
overrides,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Days the shipping line may pick as the shipment day.
|
||||
*
|
||||
@@ -174,12 +203,32 @@ export class ShippingLineBookingCompletionService {
|
||||
(s) => eatDay(s.scheduledDepartureDate) === pickedDay,
|
||||
);
|
||||
let bypassDayPool = false;
|
||||
let requestedTrainScheduleId: string | null = null;
|
||||
if (dedicatedOnDay.length > 0) {
|
||||
if (!dedicatedOnDay.some((s) => this.isStillOpen(s))) {
|
||||
const openOnDay = dedicatedOnDay.filter((s) => this.isStillOpen(s));
|
||||
if (openOnDay.length === 0) {
|
||||
throw new BadRequestException(
|
||||
"Booking for your train on this day has closed — the cut-off before departure has passed.",
|
||||
);
|
||||
}
|
||||
// A booking rides ONE schedule. Several departures that day → the line
|
||||
// must say which; a single one is picked implicitly. The id comes from
|
||||
// the request, so it is validated against the day's own trains.
|
||||
if (dto.trainScheduleId) {
|
||||
const picked = openOnDay.find((s) => s.id === dto.trainScheduleId);
|
||||
if (!picked) {
|
||||
throw new BadRequestException(
|
||||
"The selected train does not run your route on that day (or its booking cut-off has passed) — pick another train.",
|
||||
);
|
||||
}
|
||||
requestedTrainScheduleId = picked.id;
|
||||
} else if (openOnDay.length === 1) {
|
||||
requestedTrainScheduleId = openOnDay[0].id;
|
||||
} else {
|
||||
throw new BadRequestException(
|
||||
"More than one of your trains departs that day — select which train this booking rides.",
|
||||
);
|
||||
}
|
||||
// The day is backed by the line's own train, which every customer pool
|
||||
// deliberately excludes — so the day-pool gate downstream must not run.
|
||||
bypassDayPool = true;
|
||||
@@ -260,14 +309,35 @@ export class ShippingLineBookingCompletionService {
|
||||
booking.freightType === "BULK" ? Number(dto.cargoWeightTons) : 0,
|
||||
bulkTotalWeightTons:
|
||||
booking.freightType === "BULK" ? Number(dto.cargoWeightTons) : null,
|
||||
// Bulk handling portions — sized against the cargo, billed by pricing.
|
||||
...(booking.freightType === "BULK"
|
||||
? {
|
||||
bulkHazardousQuantity: Number(dto.bulkHazardousQuantity ?? 0),
|
||||
bulkReeferQuantity: Number(dto.bulkReeferQuantity ?? 0),
|
||||
}
|
||||
: {}),
|
||||
// Hazard is per-line for containers; the booking-level flag is what
|
||||
// pricing bills the surcharge from.
|
||||
isHazardous: (dto.containers ?? []).some(
|
||||
(line) => Number(line.hazardousQuantity ?? 0) > 0,
|
||||
),
|
||||
// Completion fixes the cargo — and therefore the price — so it is also
|
||||
// where the billing currency is chosen.
|
||||
paymentCurrency: dto.paymentCurrency ?? booking.paymentCurrency,
|
||||
isHazardous:
|
||||
(dto.containers ?? []).some(
|
||||
(line) =>
|
||||
Number(line.hazardousQuantity ?? 0) > 0 ||
|
||||
(line.units ?? []).some((u) => u.isHazardous),
|
||||
) || Number(dto.bulkHazardousQuantity ?? 0) > 0,
|
||||
// Same for reefer: the rule engine's REEFER trigger fires on the
|
||||
// booking-level flag (or a reefer container TYPE) — a ticked reefer
|
||||
// switch on a standard box only sets the per-line count, so without
|
||||
// this flag the surcharge silently never bills.
|
||||
isReefer:
|
||||
(dto.containers ?? []).some(
|
||||
(line) =>
|
||||
Number(line.reeferQuantity ?? 0) > 0 ||
|
||||
(line.units ?? []).some((u) => u.isReefer),
|
||||
) || Number(dto.bulkReeferQuantity ?? 0) > 0,
|
||||
// Shipping lines are always billed in ETB: the charge lands on the
|
||||
// ETB credit ledger, so the currency is enforced here rather than
|
||||
// trusted from the payload.
|
||||
paymentCurrency: "ETB",
|
||||
} as never);
|
||||
|
||||
const loaded = await this.bookingsRepository.findOne({
|
||||
@@ -305,14 +375,10 @@ export class ShippingLineBookingCompletionService {
|
||||
computed.appliedModifiers,
|
||||
);
|
||||
|
||||
// The charge goes on the line's credit ledger ("use now, pay later") —
|
||||
// idempotent per booking, so a retried completion cannot double the debt.
|
||||
await this.creditsService.recordCredit({
|
||||
bookingId,
|
||||
amount: computed.totalAmount,
|
||||
currency: computed.currency,
|
||||
description: `Freight service — booking ${booking.reference}`,
|
||||
});
|
||||
// No credit is recorded here: completion only REQUESTS the operation.
|
||||
// The charge lands on the line's ledger when Operations accepts —
|
||||
// `shipping_line_booking.accepted` → ShippingLineCreditsService — so a
|
||||
// request that is returned or never accepted creates no debt.
|
||||
}
|
||||
|
||||
// Binding day, OPERATION_REQUEST_PENDING and the staff notification — the
|
||||
@@ -321,11 +387,307 @@ export class ShippingLineBookingCompletionService {
|
||||
return this.bookingTransitionService.requestOperation(
|
||||
bookingId,
|
||||
dto.scheduledDate,
|
||||
null,
|
||||
requestedTrainScheduleId,
|
||||
bypassDayPool ? { bypassDayPool: true } : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authoritative price preview for the completion form's confirm step: the
|
||||
* SAME compute the completion itself runs, over an in-memory probe shaped
|
||||
* exactly like completeMine would persist the booking — so the figure the
|
||||
* shipping line confirms is line-for-line what it will owe.
|
||||
*
|
||||
* The result is not advisory-only: the breakdown is saved on the booking and
|
||||
* the rate snapshots are (re)written, so every re-preview refreshes them.
|
||||
* Nothing else is persisted — no cargo rows, no credit, no transition.
|
||||
*/
|
||||
async previewPriceMine(
|
||||
userId: string,
|
||||
bookingId: string,
|
||||
dto: CompleteShippingLineBookingDto,
|
||||
) {
|
||||
const booking = await this.requireOwnBooking(userId, bookingId, {
|
||||
bookingContainers: true,
|
||||
});
|
||||
if (
|
||||
!["CLEARANCE_READY", "OPERATION_CHANGES_REQUESTED"].includes(
|
||||
booking.status,
|
||||
)
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"Your documents must be approved before the booking can be priced.",
|
||||
);
|
||||
}
|
||||
|
||||
// In-memory cargo, mirroring what completeMine persists.
|
||||
let probeContainers: Partial<BookingContainer>[] = [];
|
||||
let bulkFields: Record<string, unknown> = {};
|
||||
if (booking.freightType === "CONTAINER") {
|
||||
const lines = dto.containers ?? [];
|
||||
if (!lines.length) {
|
||||
throw new BadRequestException(
|
||||
"At least one container line is required.",
|
||||
);
|
||||
}
|
||||
for (const line of lines) {
|
||||
const containerType = await this.resolveContainerType(line);
|
||||
const figures = this.lineFigures(line);
|
||||
probeContainers.push({
|
||||
containerTypeId: containerType.id,
|
||||
containerSize: containerType.sizeFt
|
||||
? `${containerType.sizeFt}ft`
|
||||
: null,
|
||||
quantity: line.quantity,
|
||||
hazardousQuantity: figures.hazardous,
|
||||
reeferQuantity: figures.reefer,
|
||||
returnQuantity: 0,
|
||||
vgmPerUnitTons: figures.vgmPerUnit,
|
||||
totalVgmTons: figures.totalVgm,
|
||||
wagonsRequired: Math.ceil(
|
||||
line.quantity * wagonsPerUnitForSize(containerType.sizeFt),
|
||||
),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (!dto.cargoTypeId || !(Number(dto.cargoWeightTons) > 0)) {
|
||||
throw new BadRequestException(
|
||||
"Bulk bookings need a cargo type and a total weight in tons.",
|
||||
);
|
||||
}
|
||||
const cargoType = await this.bookingsRepository.manager
|
||||
.getRepository(CargoType)
|
||||
.findOne({ where: { id: dto.cargoTypeId, isActive: true } });
|
||||
if (!cargoType) {
|
||||
throw new NotFoundException(`Cargo type ${dto.cargoTypeId} not found`);
|
||||
}
|
||||
bulkFields = {
|
||||
cargoTypeId: dto.cargoTypeId,
|
||||
cargoTotalWeightVgm: Number(dto.cargoWeightTons),
|
||||
bulkTotalWeightTons: Number(dto.cargoWeightTons),
|
||||
bulkHazardousQuantity: Number(dto.bulkHazardousQuantity ?? 0),
|
||||
bulkReeferQuantity: Number(dto.bulkReeferQuantity ?? 0),
|
||||
};
|
||||
probeContainers = [];
|
||||
}
|
||||
|
||||
// Prototype-preserving clone so entity getters keep working — the same
|
||||
// probe trick the contract preview uses.
|
||||
const probe = Object.assign(
|
||||
Object.create(Object.getPrototypeOf(booking)),
|
||||
booking,
|
||||
{
|
||||
bookingContainers: probeContainers,
|
||||
paymentCurrency: "ETB",
|
||||
isHazardous:
|
||||
(dto.containers ?? []).some(
|
||||
(line) =>
|
||||
Number(line.hazardousQuantity ?? 0) > 0 ||
|
||||
(line.units ?? []).some((u) => u.isHazardous),
|
||||
) || Number(dto.bulkHazardousQuantity ?? 0) > 0,
|
||||
// Mirrors completeMine: without the booking-level flag the engine's
|
||||
// REEFER trigger never fires for reefer opt-ins on standard boxes,
|
||||
// and the quote would show base freight only.
|
||||
isReefer:
|
||||
(dto.containers ?? []).some(
|
||||
(line) =>
|
||||
Number(line.reeferQuantity ?? 0) > 0 ||
|
||||
(line.units ?? []).some((u) => u.isReefer),
|
||||
) || Number(dto.bulkReeferQuantity ?? 0) > 0,
|
||||
...bulkFields,
|
||||
},
|
||||
) as Booking;
|
||||
|
||||
const computed =
|
||||
await this.bookingPricingService.computePriceForBooking(probe);
|
||||
if (!(computed.totalAmount > 0) || computed.hardBlocked.length > 0) {
|
||||
throw new BadRequestException(
|
||||
computed.hardBlocked.length > 0
|
||||
? computed.hardBlocked.join("; ")
|
||||
: "No rate is configured for your shipping line on this route/cargo — please contact Operations.",
|
||||
);
|
||||
}
|
||||
|
||||
// Persist the quoted figure: breakdown on the booking, snapshots of the
|
||||
// rates it was built from. createPricingSnapshots clears the previous
|
||||
// artifacts first, so a re-preview replaces the old quote rather than
|
||||
// stacking a second one.
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
pricingBreakdown: {
|
||||
lineItems: computed.lineItems,
|
||||
totalAmount: computed.totalAmount,
|
||||
currency: computed.currency,
|
||||
generatedAt: new Date().toISOString(),
|
||||
},
|
||||
} as never);
|
||||
await this.bookingPricingService.createPricingSnapshots(
|
||||
bookingId,
|
||||
computed.usedRates,
|
||||
computed.appliedModifiers,
|
||||
);
|
||||
|
||||
return {
|
||||
totalAmount: computed.totalAmount,
|
||||
currency: computed.currency,
|
||||
lineItems: computed.lineItems,
|
||||
warnings: computed.warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What operations has done with the booking so far: the train it rides
|
||||
* (assigned, or the requested one before assignment) and the wagons the
|
||||
* batch engine allocated to it, with any container numbers loaded per wagon.
|
||||
* Read-only, owner-scoped — feeds the detail page's Wagons & Train tab.
|
||||
*/
|
||||
async operationsMine(userId: string, bookingId: string) {
|
||||
const booking = await this.requireOwnBooking(userId, bookingId);
|
||||
const manager = this.bookingsRepository.manager;
|
||||
|
||||
const scheduleId =
|
||||
booking.trainScheduleId ?? booking.requestedTrainScheduleId ?? null;
|
||||
let train: Record<string, unknown> | null = null;
|
||||
if (scheduleId) {
|
||||
const schedule = await manager.getRepository(TrainSchedule).findOne({
|
||||
where: { id: scheduleId },
|
||||
relations: { originStation: true, destinationStation: true },
|
||||
});
|
||||
if (schedule) {
|
||||
train = {
|
||||
id: schedule.id,
|
||||
reference: schedule.reference,
|
||||
trainNumber: schedule.trainNumber,
|
||||
status: schedule.status,
|
||||
direction: schedule.direction,
|
||||
scheduledDepartureDate: schedule.scheduledDepartureDate,
|
||||
scheduledArrivalDate: schedule.scheduledArrivalDate,
|
||||
originLabel:
|
||||
schedule.originStation?.label ??
|
||||
schedule.originStation?.code ??
|
||||
"Origin",
|
||||
destinationLabel:
|
||||
schedule.destinationStation?.label ??
|
||||
schedule.destinationStation?.code ??
|
||||
"Destination",
|
||||
// Whether this is the confirmed assignment or still the request.
|
||||
assigned: Boolean(booking.trainScheduleId),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const allocations = await manager
|
||||
.getRepository(WagonBookingAllocation)
|
||||
.find({
|
||||
where: { bookingId },
|
||||
relations: {
|
||||
trainSetWagon: { wagonType: true, physicalWagon: true },
|
||||
containerItems: true,
|
||||
},
|
||||
order: { createdAt: "ASC" },
|
||||
});
|
||||
|
||||
const wagons = allocations.map((allocation) => ({
|
||||
id: allocation.id,
|
||||
status: allocation.status,
|
||||
loadType: allocation.loadType,
|
||||
allocatedWeightTons: Number(allocation.allocatedWeightTons),
|
||||
sequenceNo: allocation.trainSetWagon?.sequenceNo ?? null,
|
||||
wagonNumber: allocation.trainSetWagon?.physicalWagon?.wagonNumber ?? null,
|
||||
wagonType:
|
||||
allocation.trainSetWagon?.wagonType?.name ??
|
||||
allocation.trainSetWagon?.wagonType?.code ??
|
||||
null,
|
||||
capacityTons: Number(allocation.trainSetWagon?.capacityTons ?? 0),
|
||||
containerNumbers: (allocation.containerItems ?? [])
|
||||
.map((item) => item.containerNumber)
|
||||
.filter((n): n is string => Boolean(n)),
|
||||
}));
|
||||
|
||||
return { train, wagons };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a line's container type: by id when the payload carries one, else
|
||||
* from the size string ("40ft" → the active 40ft type, preferring the reefer
|
||||
* variant when the line ships reefer boxes). Resolution lives HERE, not in
|
||||
* the portal, so a slow or failed catalog fetch can never block a booking
|
||||
* with a phantom "type not configured" error — mirrors the customer flow's
|
||||
* server-side size→type mapping.
|
||||
*/
|
||||
private async resolveContainerType(line: {
|
||||
containerTypeId?: string;
|
||||
containerSize?: string;
|
||||
reeferQuantity?: number;
|
||||
units?: { isReefer?: boolean }[];
|
||||
}): Promise<ContainerType> {
|
||||
const containerTypeRepo =
|
||||
this.bookingsRepository.manager.getRepository(ContainerType);
|
||||
|
||||
if (line.containerTypeId) {
|
||||
const byId = await containerTypeRepo.findOne({
|
||||
where: { id: line.containerTypeId, isActive: true },
|
||||
});
|
||||
if (!byId) {
|
||||
throw new NotFoundException(
|
||||
`Container type ${line.containerTypeId} not found`,
|
||||
);
|
||||
}
|
||||
return byId;
|
||||
}
|
||||
|
||||
const sizeFt = parseInt(line.containerSize ?? "", 10);
|
||||
if (!Number.isFinite(sizeFt)) {
|
||||
throw new BadRequestException(
|
||||
"Each container line needs a containerTypeId or a containerSize.",
|
||||
);
|
||||
}
|
||||
const candidates = await containerTypeRepo.find({
|
||||
where: { isActive: true },
|
||||
});
|
||||
const ofSize = candidates.filter((ct) => Number(ct.sizeFt) === sizeFt);
|
||||
if (!ofSize.length) {
|
||||
throw new BadRequestException(
|
||||
`No ${sizeFt}ft container type is configured — please contact Operations.`,
|
||||
);
|
||||
}
|
||||
const wantsReefer =
|
||||
Number(line.reeferQuantity ?? 0) > 0 ||
|
||||
(line.units ?? []).some((u) => u.isReefer);
|
||||
if (wantsReefer) {
|
||||
const reefer = ofSize.find((ct) => ct.isReefer);
|
||||
if (reefer) return reefer;
|
||||
}
|
||||
return ofSize.find((ct) => !ct.isReefer) ?? ofSize[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* A line's derived figures. With per-container rows (the full booking page),
|
||||
* counts and VGM come FROM the rows — each container's switches are the
|
||||
* source of truth. Without them, the line-level figures stand alone.
|
||||
*/
|
||||
private lineFigures(line: {
|
||||
quantity: number;
|
||||
vgmPerUnitTons?: number;
|
||||
hazardousQuantity?: number;
|
||||
reeferQuantity?: number;
|
||||
units?: { vgmTons?: number; isHazardous?: boolean; isReefer?: boolean }[];
|
||||
}) {
|
||||
const units = line.units ?? [];
|
||||
const hazardous = units.length
|
||||
? units.filter((u) => u.isHazardous).length
|
||||
: Math.min(Number(line.hazardousQuantity ?? 0), line.quantity);
|
||||
const reefer = units.length
|
||||
? units.filter((u) => u.isReefer).length
|
||||
: Math.min(Number(line.reeferQuantity ?? 0), line.quantity);
|
||||
const totalVgm = units.length
|
||||
? units.reduce((s, u) => s + Number(u.vgmTons ?? 0), 0)
|
||||
: Number(line.vgmPerUnitTons ?? 0) * line.quantity;
|
||||
const vgmPerUnit = units.length
|
||||
? totalVgm / units.length
|
||||
: Number(line.vgmPerUnitTons ?? 0);
|
||||
return { hazardous, reefer, totalVgm, vgmPerUnit };
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the container lines of a CONTAINER completion. Same row shape the
|
||||
* customer paths write (quantity per type, VGM totals, wagon share) — the
|
||||
@@ -341,27 +703,18 @@ export class ShippingLineBookingCompletionService {
|
||||
throw new BadRequestException("At least one container line is required.");
|
||||
}
|
||||
|
||||
const containerTypeRepo =
|
||||
this.bookingsRepository.manager.getRepository(ContainerType);
|
||||
const containerRepo =
|
||||
this.bookingsRepository.manager.getRepository(BookingContainer);
|
||||
const unitRepo =
|
||||
this.bookingsRepository.manager.getRepository(BookingContainerUnit);
|
||||
|
||||
for (const line of lines) {
|
||||
const containerType = await containerTypeRepo.findOne({
|
||||
where: { id: line.containerTypeId, isActive: true },
|
||||
});
|
||||
if (!containerType) {
|
||||
throw new NotFoundException(
|
||||
`Container type ${line.containerTypeId} not found`,
|
||||
);
|
||||
}
|
||||
const hazardous = Math.min(
|
||||
Number(line.hazardousQuantity ?? 0),
|
||||
line.quantity,
|
||||
);
|
||||
const reefer = Math.min(Number(line.reeferQuantity ?? 0), line.quantity);
|
||||
const vgmPerUnit = Number(line.vgmPerUnitTons ?? 0);
|
||||
await containerRepo.save(
|
||||
const containerType = await this.resolveContainerType(line);
|
||||
// Counts and VGM derived by lineFigures — the same math the price
|
||||
// preview runs, so the persisted cargo always matches the quote.
|
||||
const units = line.units ?? [];
|
||||
const figures = this.lineFigures(line);
|
||||
const containerRow = await containerRepo.save(
|
||||
containerRepo.create({
|
||||
bookingId: booking.id,
|
||||
containerTypeId: containerType.id,
|
||||
@@ -369,16 +722,31 @@ export class ShippingLineBookingCompletionService {
|
||||
? `${containerType.sizeFt}ft`
|
||||
: null,
|
||||
quantity: line.quantity,
|
||||
hazardousQuantity: hazardous,
|
||||
reeferQuantity: reefer,
|
||||
hazardousQuantity: figures.hazardous,
|
||||
reeferQuantity: figures.reefer,
|
||||
returnQuantity: 0,
|
||||
vgmPerUnitTons: vgmPerUnit,
|
||||
totalVgmTons: vgmPerUnit * line.quantity,
|
||||
vgmPerUnitTons: figures.vgmPerUnit,
|
||||
totalVgmTons: figures.totalVgm,
|
||||
wagonsRequired: Math.ceil(
|
||||
line.quantity * wagonsPerUnitForSize(containerType.sizeFt),
|
||||
),
|
||||
}),
|
||||
);
|
||||
let sortOrder = 0;
|
||||
for (const unit of units) {
|
||||
await unitRepo.save(
|
||||
unitRepo.create({
|
||||
bookingContainerId: containerRow.id,
|
||||
containerNumber: unit.containerNumber.trim().toUpperCase(),
|
||||
sealNumber: unit.sealNumber?.trim() || null,
|
||||
vgmTons: Number(unit.vgmTons ?? 0),
|
||||
isHazardous: unit.isHazardous ?? false,
|
||||
isReefer: unit.isReefer ?? false,
|
||||
isReturn: false,
|
||||
sortOrder: sortOrder++,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -319,8 +319,16 @@ export class ShippingLineBookingsService {
|
||||
const booking = await this.bookingsRepository.findOne({
|
||||
where: { id: bookingId, shippingLineCompanyId: shippingLine.id },
|
||||
// Yards are loaded so the portal can render the lane without a second
|
||||
// lookup — they are set at initiate time from the chosen route.
|
||||
relations: { originYard: true, destinationYard: true, serviceType: true },
|
||||
// lookup — they are set at initiate time from the chosen route. Cargo
|
||||
// (container lines + units, bulk cargo type) rides along for the detail
|
||||
// page's cargo tab once the booking is completed.
|
||||
relations: {
|
||||
originYard: true,
|
||||
destinationYard: true,
|
||||
serviceType: true,
|
||||
cargoType: true,
|
||||
bookingContainers: { containerType: true, units: true },
|
||||
},
|
||||
});
|
||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
|
||||
@@ -330,7 +338,24 @@ export class ShippingLineBookingsService {
|
||||
.getRepository(BookingDocumentReview)
|
||||
.count({ where: { bookingId, status: "QUERIED" } });
|
||||
|
||||
return { ...booking, hasQueriedDocuments: queriedCount > 0 };
|
||||
// The note Operations wrote when returning the request — the line has to
|
||||
// read it to know what to fix. Only the latest CHANGES_REQUESTED note is
|
||||
// exposed; the other review-note types are staff-internal.
|
||||
const changeNote =
|
||||
booking.status === "OPERATION_CHANGES_REQUESTED"
|
||||
? await this.bookingsRepository.manager
|
||||
.getRepository(BookingReviewNote)
|
||||
.findOne({
|
||||
where: { bookingId, type: "CHANGES_REQUESTED" },
|
||||
order: { createdAt: "DESC" },
|
||||
})
|
||||
: null;
|
||||
|
||||
return {
|
||||
...booking,
|
||||
hasQueriedDocuments: queriedCount > 0,
|
||||
operationChangeNote: changeNote?.note ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -49,7 +49,12 @@ export class ShippingLineCompaniesController {
|
||||
}
|
||||
|
||||
@Get()
|
||||
@BookingStaff(FREIGHT_PERMS.shippingLines.view)
|
||||
// OR'd: the credits view needs this list as its line picker, so holding
|
||||
// shipping_line_credits:view alone is enough to read it.
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.shippingLines.view,
|
||||
FREIGHT_PERMS.shippingLineCredits.view,
|
||||
])
|
||||
@ApiOperation({ summary: "List shipping lines (paginated)" })
|
||||
async list(
|
||||
@Query("page") page?: string,
|
||||
|
||||
@@ -9,6 +9,8 @@ import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { OtpModule } from "../otp/otp.module";
|
||||
import { ShippingLineCompany } from "./entities/shipping-line-company.entity";
|
||||
import { ShippingLineCredit } from "./entities/shipping-line-credit.entity";
|
||||
import { ShippingLineInvoiceApproval } from "./entities/shipping-line-invoice-approval.entity";
|
||||
import { ShippingLineInvoiceApprovalsRepository } from "./shipping-line-invoice-approvals.repository";
|
||||
import { ShippingLineBookingsController } from "./shipping-line-bookings.controller";
|
||||
import { ShippingLineBookingsService } from "./shipping-line-bookings.service";
|
||||
import { ShippingLineCompaniesController } from "./shipping-line-companies.controller";
|
||||
@@ -25,6 +27,7 @@ import { ShippingLineCreditsService } from "./shipping-line-credits.service";
|
||||
TypeOrmModule.forFeature([
|
||||
ShippingLineCompany,
|
||||
ShippingLineCredit,
|
||||
ShippingLineInvoiceApproval,
|
||||
User,
|
||||
Booking,
|
||||
]),
|
||||
@@ -48,6 +51,7 @@ import { ShippingLineCreditsService } from "./shipping-line-credits.service";
|
||||
ShippingLineBookingsService,
|
||||
ShippingLineCreditsService,
|
||||
ShippingLineCreditsRepository,
|
||||
ShippingLineInvoiceApprovalsRepository,
|
||||
],
|
||||
// Exported so whatever prices a shipping-line booking can record the charge.
|
||||
exports: [ShippingLineCompaniesService, ShippingLineCreditsService],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
import { Freight } from "@edr/types";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
@@ -14,9 +15,12 @@ import { BookingStaff, PortalCustomer } from "../../common/booking-guards";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import {
|
||||
CancelCreditDto,
|
||||
DecideInvoiceActionDto,
|
||||
GenerateCreditInvoiceDto,
|
||||
RequestInvoiceActionDto,
|
||||
} from "./dto/shipping-line-credit.dto";
|
||||
import { ShippingLineCreditStatus } from "./entities/shipping-line-credit.entity";
|
||||
import { ShippingLineInvoiceActionType } from "./entities/shipping-line-invoice-approval.entity";
|
||||
import { ShippingLineCreditsService } from "./shipping-line-credits.service";
|
||||
|
||||
interface CurrentIamUser {
|
||||
@@ -37,6 +41,159 @@ interface CurrentIamUser {
|
||||
export class ShippingLineCreditsController {
|
||||
constructor(private readonly credits: ShippingLineCreditsService) {}
|
||||
|
||||
@Get()
|
||||
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.view)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"The whole credit ledger across every shipping line (paginated), optionally filtered by line and/or status.",
|
||||
})
|
||||
async listAll(
|
||||
@Query("page") page?: string,
|
||||
@Query("pageSize") pageSize?: string,
|
||||
@Query("status") status?: ShippingLineCreditStatus,
|
||||
@Query("shippingLineId", new ParseUUIDPipe({ optional: true }))
|
||||
shippingLineId?: string,
|
||||
) {
|
||||
return this.credits.listAll(
|
||||
page ? Number(page) : 1,
|
||||
pageSize ? Number(pageSize) : 20,
|
||||
status,
|
||||
shippingLineId,
|
||||
);
|
||||
}
|
||||
|
||||
// Declared before the parameterised staff routes so "summary" is never
|
||||
// captured as a shipping-line id.
|
||||
@Get("summary")
|
||||
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.view)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Outstanding totals across every shipping line, or one line when shippingLineId is given.",
|
||||
})
|
||||
async summaryAll(
|
||||
@Query("shippingLineId", new ParseUUIDPipe({ optional: true }))
|
||||
shippingLineId?: string,
|
||||
) {
|
||||
return this.credits.summary(shippingLineId);
|
||||
}
|
||||
|
||||
// Declared before ":shippingLineId" so "invoices" is never captured as an id.
|
||||
@Get("invoices")
|
||||
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.view)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Credit invoices across every shipping line (paginated), each with any pending manual-action request.",
|
||||
})
|
||||
async listInvoices(
|
||||
@Query("page") page?: string,
|
||||
@Query("pageSize") pageSize?: string,
|
||||
@Query("status") status?: string,
|
||||
@Query("shippingLineId", new ParseUUIDPipe({ optional: true }))
|
||||
shippingLineId?: string,
|
||||
) {
|
||||
return this.credits.listCreditInvoices(
|
||||
page ? Number(page) : 1,
|
||||
pageSize ? Number(pageSize) : 20,
|
||||
status as Freight.InvoiceStatus | undefined,
|
||||
shippingLineId,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("invoice-actions/pending")
|
||||
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.view)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Undecided manual-action requests for a batch of invoices (one lookup for a list page).",
|
||||
})
|
||||
async pendingInvoiceActions(@Query("invoiceIds") invoiceIds?: string) {
|
||||
const ids = (invoiceIds ?? "")
|
||||
.split(",")
|
||||
.map((id) => id.trim())
|
||||
.filter(Boolean);
|
||||
return this.credits.pendingInvoiceActions(ids);
|
||||
}
|
||||
|
||||
// ── Maker–checker on credit invoices ──────────────────────────────────────
|
||||
// Request and approve are DIFFERENT permissions, and the service refuses a
|
||||
// decision by the requester — marking debt paid or voiding an invoice is
|
||||
// never a one-person action.
|
||||
|
||||
@Post("invoices/:invoiceId/mark-paid-request")
|
||||
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.invoiceMarkPaid)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Request recording a full offline payment against a credit invoice (awaits chief approval).",
|
||||
})
|
||||
async requestMarkPaid(
|
||||
@Param("invoiceId", ParseUUIDPipe) invoiceId: string,
|
||||
@Body() dto: RequestInvoiceActionDto,
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
) {
|
||||
return this.credits.requestInvoiceAction(
|
||||
invoiceId,
|
||||
ShippingLineInvoiceActionType.MarkPaid,
|
||||
user.id,
|
||||
dto.reason,
|
||||
dto.paymentReference,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("invoices/:invoiceId/cancel-request")
|
||||
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.invoiceCancel)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Request voiding a credit invoice — its credits return to the unbilled pool (awaits chief approval).",
|
||||
})
|
||||
async requestCancel(
|
||||
@Param("invoiceId", ParseUUIDPipe) invoiceId: string,
|
||||
@Body() dto: RequestInvoiceActionDto,
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
) {
|
||||
return this.credits.requestInvoiceAction(
|
||||
invoiceId,
|
||||
ShippingLineInvoiceActionType.Cancel,
|
||||
user.id,
|
||||
dto.reason,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("invoice-actions/:approvalId/approve")
|
||||
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.invoiceApprove)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Approve a pending invoice request — executes the offline settlement or the cancellation.",
|
||||
})
|
||||
async approveInvoiceAction(
|
||||
@Param("approvalId", ParseUUIDPipe) approvalId: string,
|
||||
@Body() dto: DecideInvoiceActionDto,
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
) {
|
||||
return this.credits.decideInvoiceAction(
|
||||
approvalId,
|
||||
user.id,
|
||||
true,
|
||||
dto.note,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("invoice-actions/:approvalId/reject")
|
||||
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.invoiceReject)
|
||||
@ApiOperation({
|
||||
summary: "Reject a pending invoice request — nothing is changed.",
|
||||
})
|
||||
async rejectInvoiceAction(
|
||||
@Param("approvalId", ParseUUIDPipe) approvalId: string,
|
||||
@Body() dto: DecideInvoiceActionDto,
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
) {
|
||||
return this.credits.decideInvoiceAction(
|
||||
approvalId,
|
||||
user.id,
|
||||
false,
|
||||
dto.note,
|
||||
);
|
||||
}
|
||||
|
||||
// Declared before the parameterised staff routes so "me" is never captured
|
||||
// as a shipping-line id.
|
||||
@Get("me")
|
||||
|
||||
@@ -76,25 +76,32 @@ export class ShippingLineCreditsRepository extends BaseRepository<ShippingLineCr
|
||||
|
||||
/**
|
||||
* Derived debt — never a stored column. Grouped in one query so the detail
|
||||
* page does not fan out per status.
|
||||
* page does not fan out per status. Without a line id it totals every line —
|
||||
* the back-office overview figure.
|
||||
*/
|
||||
async outstandingFor(
|
||||
shippingLineCompanyId: string,
|
||||
shippingLineCompanyId?: string,
|
||||
): Promise<OutstandingTotals> {
|
||||
const rows = await this.credits
|
||||
const qb = this.credits
|
||||
.createQueryBuilder("credit")
|
||||
.select("credit.status", "status")
|
||||
.addSelect("COALESCE(SUM(credit.amount), 0)", "amount")
|
||||
.addSelect("COUNT(*)", "count")
|
||||
.where("credit.shippingLineCompanyId = :shippingLineCompanyId", {
|
||||
shippingLineCompanyId,
|
||||
})
|
||||
.andWhere("credit.status IN (:...statuses)", {
|
||||
.where("credit.status IN (:...statuses)", {
|
||||
statuses: [...OUTSTANDING_CREDIT_STATUSES],
|
||||
})
|
||||
.andWhere("credit.deletedAt IS NULL")
|
||||
.groupBy("credit.status")
|
||||
.getRawMany<{ status: string; amount: string; count: string }>();
|
||||
.groupBy("credit.status");
|
||||
if (shippingLineCompanyId) {
|
||||
qb.andWhere("credit.shippingLineCompanyId = :shippingLineCompanyId", {
|
||||
shippingLineCompanyId,
|
||||
});
|
||||
}
|
||||
const rows = await qb.getRawMany<{
|
||||
status: string;
|
||||
amount: string;
|
||||
count: string;
|
||||
}>();
|
||||
|
||||
const totals = (status: ShippingLineCreditStatus) => {
|
||||
const row = rows.find((r) => r.status === status);
|
||||
@@ -117,19 +124,22 @@ export class ShippingLineCreditsRepository extends BaseRepository<ShippingLineCr
|
||||
};
|
||||
}
|
||||
|
||||
/** Paginated ledger for one line — every credit, whatever its status. */
|
||||
/**
|
||||
* Paginated ledger — every credit, whatever its status. Scoped to one line
|
||||
* when an id is given, across all lines otherwise.
|
||||
*/
|
||||
findAllPaginated(
|
||||
shippingLineCompanyId: string,
|
||||
shippingLineCompanyId: string | undefined,
|
||||
skip: number,
|
||||
take: number,
|
||||
status?: ShippingLineCreditStatus,
|
||||
): Promise<[ShippingLineCredit[], number]> {
|
||||
return this.credits.findAndCount({
|
||||
where: {
|
||||
shippingLineCompanyId,
|
||||
...(shippingLineCompanyId ? { shippingLineCompanyId } : {}),
|
||||
...(status ? { status } : {}),
|
||||
},
|
||||
relations: { booking: true, invoice: true },
|
||||
relations: { booking: true, invoice: true, shippingLineCompany: true },
|
||||
order: { createdAt: "DESC" },
|
||||
skip,
|
||||
take,
|
||||
|
||||
@@ -77,6 +77,14 @@ describe("ShippingLineCreditsService", () => {
|
||||
service = new ShippingLineCreditsService(
|
||||
dataSource as never,
|
||||
creditsRepo as never,
|
||||
// Approvals repo — only the invoice maker–checker paths touch it.
|
||||
{
|
||||
findPendingByInvoice: jest.fn(),
|
||||
findPendingByInvoiceIds: jest.fn().mockResolvedValue([]),
|
||||
findByIdForUpdate: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
} as never,
|
||||
billing as never,
|
||||
shippingLines as never,
|
||||
);
|
||||
|
||||
@@ -21,7 +21,14 @@ import {
|
||||
ShippingLineCredit,
|
||||
ShippingLineCreditStatus,
|
||||
} from "./entities/shipping-line-credit.entity";
|
||||
import {
|
||||
ShippingLineInvoiceApproval,
|
||||
ShippingLineInvoiceActionStatus,
|
||||
ShippingLineInvoiceActionType,
|
||||
} from "./entities/shipping-line-invoice-approval.entity";
|
||||
import { ShippingLineCreditsRepository } from "./shipping-line-credits.repository";
|
||||
import { ShippingLineInvoiceApprovalsRepository } from "./shipping-line-invoice-approvals.repository";
|
||||
import { ShippingLineCompany } from "./entities/shipping-line-company.entity";
|
||||
import { ShippingLineCompaniesService } from "./shipping-line-companies.service";
|
||||
|
||||
/** A charge to record against a shipping line's booking. */
|
||||
@@ -39,6 +46,18 @@ export interface GenerateCreditInvoiceOptions {
|
||||
dueInDays?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emitted by the booking-transition accept path for shipping-line bookings.
|
||||
* An event rather than a service call: BookingsModule cannot import the
|
||||
* shipping-line modules without closing a module cycle.
|
||||
*/
|
||||
export interface ShippingLineBookingAcceptedPayload {
|
||||
bookingId: string;
|
||||
reference: string;
|
||||
/** The booking's priced total, frozen at completion time. */
|
||||
amount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The credit ledger for shipping lines — "use the service now, pay later".
|
||||
*
|
||||
@@ -66,6 +85,7 @@ export class ShippingLineCreditsService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly credits: ShippingLineCreditsRepository,
|
||||
private readonly approvals: ShippingLineInvoiceApprovalsRepository,
|
||||
private readonly billing: BillingService,
|
||||
private readonly shippingLines: ShippingLineCompaniesService,
|
||||
) {}
|
||||
@@ -144,6 +164,32 @@ export class ShippingLineCreditsService {
|
||||
return manager ? run(manager) : this.dataSource.transaction(run);
|
||||
}
|
||||
|
||||
/**
|
||||
* The moment a shipping-line booking becomes debt: Operations accepted it.
|
||||
* Swallows its own failures with a loud log instead of throwing — the accept
|
||||
* has already committed, and failing the staff response for a ledger write
|
||||
* would present a succeeded accept as an error. `recordCredit` is idempotent
|
||||
* per booking, so a re-accepted (previously reverted) booking cannot double
|
||||
* the debt.
|
||||
*/
|
||||
@OnEvent("shipping_line_booking.accepted")
|
||||
async onBookingAccepted(
|
||||
payload: ShippingLineBookingAcceptedPayload,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.recordCredit({
|
||||
bookingId: payload.bookingId,
|
||||
amount: payload.amount,
|
||||
// Shipping lines are always billed in ETB (enforced at completion).
|
||||
currency: "ETB",
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to record credit for accepted shipping-line booking ${payload.reference} (${payload.bookingId}): ${(err as Error).message} — the debt is NOT on the ledger; record it manually or re-trigger.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. Bill ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -326,6 +372,39 @@ export class ShippingLineCreditsService {
|
||||
return this.credits.outstandingFor(shippingLineCompanyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Back-office overview: outstanding totals across every line, or one line
|
||||
* when an id is given.
|
||||
*/
|
||||
async summary(shippingLineCompanyId?: string) {
|
||||
if (shippingLineCompanyId) {
|
||||
await this.requireShippingLine(shippingLineCompanyId);
|
||||
}
|
||||
return this.credits.outstandingFor(shippingLineCompanyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole ledger across every shipping line, newest first — finance's
|
||||
* landing list. Optionally narrowed to one line and/or one status.
|
||||
*/
|
||||
async listAll(
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
status?: ShippingLineCreditStatus,
|
||||
shippingLineCompanyId?: string,
|
||||
) {
|
||||
if (shippingLineCompanyId) {
|
||||
await this.requireShippingLine(shippingLineCompanyId);
|
||||
}
|
||||
const [items, total] = await this.credits.findAllPaginated(
|
||||
shippingLineCompanyId,
|
||||
(page - 1) * pageSize,
|
||||
pageSize,
|
||||
status,
|
||||
);
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
/** Full ledger for one line, newest first. */
|
||||
async listCredits(
|
||||
shippingLineCompanyId: string,
|
||||
@@ -369,6 +448,287 @@ export class ShippingLineCreditsService {
|
||||
};
|
||||
}
|
||||
|
||||
// ── Credit invoices: list + maker–checker manual actions ─────────────────
|
||||
|
||||
/**
|
||||
* Staff list of the invoices minted from credit batches, each with its line
|
||||
* name and any undecided manual-action request attached — the data the
|
||||
* back-office actions column renders from.
|
||||
*/
|
||||
async listCreditInvoices(
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
status?: Freight.InvoiceStatus,
|
||||
shippingLineCompanyId?: string,
|
||||
) {
|
||||
const [invoices, total] = await this.dataSource
|
||||
.getRepository(Invoice)
|
||||
.findAndCount({
|
||||
where: {
|
||||
source: Freight.InvoiceSource.ShippingLineCredit,
|
||||
...(status ? { status } : {}),
|
||||
...(shippingLineCompanyId ? { shippingLineCompanyId } : {}),
|
||||
},
|
||||
order: { createdAt: "DESC" },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
});
|
||||
|
||||
const lineIds = [
|
||||
...new Set(
|
||||
invoices
|
||||
.map((inv) => inv.shippingLineCompanyId)
|
||||
.filter((id): id is string => !!id),
|
||||
),
|
||||
];
|
||||
const lines = lineIds.length
|
||||
? await this.dataSource
|
||||
.getRepository(ShippingLineCompany)
|
||||
.find({ where: { id: In(lineIds) } })
|
||||
: [];
|
||||
const nameById = new Map(lines.map((l) => [l.id, l.name]));
|
||||
|
||||
const pending = await this.approvals.findPendingByInvoiceIds(
|
||||
invoices.map((inv) => inv.id),
|
||||
);
|
||||
const pendingByInvoice = new Map(pending.map((p) => [p.invoiceId, p]));
|
||||
|
||||
return {
|
||||
items: invoices.map((inv) => ({
|
||||
...inv,
|
||||
shippingLineName: inv.shippingLineCompanyId
|
||||
? (nameById.get(inv.shippingLineCompanyId) ?? null)
|
||||
: null,
|
||||
pendingAction: pendingByInvoice.get(inv.id) ?? null,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
/** Undecided requests for a batch of invoices — feeds any invoice list. */
|
||||
async pendingInvoiceActions(
|
||||
invoiceIds: string[],
|
||||
): Promise<ShippingLineInvoiceApproval[]> {
|
||||
// Bounded to a list page's worth of ids; anything larger is a misuse.
|
||||
return this.approvals.findPendingByInvoiceIds(invoiceIds.slice(0, 100));
|
||||
}
|
||||
|
||||
/**
|
||||
* Finance raises a manual action on a credit invoice: record an offline
|
||||
* payment (MARK_PAID) or void it (CANCEL). Nothing happens to the invoice
|
||||
* yet — a chief with the matching approve permission decides it. One
|
||||
* undecided request per invoice (backed by a partial unique index).
|
||||
*/
|
||||
async requestInvoiceAction(
|
||||
invoiceId: string,
|
||||
action: ShippingLineInvoiceActionType,
|
||||
requestedBy: string,
|
||||
reason: string,
|
||||
paymentReference?: string,
|
||||
): Promise<ShippingLineInvoiceApproval> {
|
||||
const invoice = await this.dataSource
|
||||
.getRepository(Invoice)
|
||||
.findOne({ where: { id: invoiceId } });
|
||||
if (!invoice) {
|
||||
throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||
}
|
||||
if (invoice.source !== Freight.InvoiceSource.ShippingLineCredit) {
|
||||
throw new BadRequestException(
|
||||
"Manual actions here apply only to shipping-line credit invoices.",
|
||||
);
|
||||
}
|
||||
// Fast feedback only — the billing service re-validates authoritatively
|
||||
// (under lock) when the request is approved.
|
||||
if (
|
||||
action === ShippingLineInvoiceActionType.MarkPaid &&
|
||||
invoice.status === Freight.InvoiceStatus.Paid
|
||||
) {
|
||||
throw new BadRequestException("Invoice is already paid.");
|
||||
}
|
||||
if (invoice.status === Freight.InvoiceStatus.Cancelled) {
|
||||
throw new BadRequestException("Invoice is already cancelled.");
|
||||
}
|
||||
if (
|
||||
action === ShippingLineInvoiceActionType.Cancel &&
|
||||
Number(invoice.paidAmount) > 0
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"Cannot cancel an invoice that has payments recorded against it.",
|
||||
);
|
||||
}
|
||||
const existing = await this.approvals.findPendingByInvoice(invoiceId);
|
||||
if (existing) {
|
||||
throw new BadRequestException(
|
||||
`A ${existing.action} request is already awaiting decision on this invoice.`,
|
||||
);
|
||||
}
|
||||
|
||||
const approval = await this.approvals.create({
|
||||
invoiceId,
|
||||
action,
|
||||
status: ShippingLineInvoiceActionStatus.Pending,
|
||||
requestedBy,
|
||||
reason,
|
||||
paymentReference: paymentReference ?? null,
|
||||
});
|
||||
|
||||
logCtx(
|
||||
{
|
||||
approvalId: approval.id,
|
||||
invoiceId,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
action,
|
||||
requestedBy,
|
||||
},
|
||||
{ path: "shippingLineCredit.invoiceAction.requested" },
|
||||
);
|
||||
|
||||
return approval;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide a pending request. Gated purely by permission (the approve/reject
|
||||
* grants on the controller routes) — a decider holding the grant may decide
|
||||
* ANY pending request, their own included; that trade-off is deliberate.
|
||||
*
|
||||
* Approval executes the real action through the billing service AFTER the
|
||||
* decision row commits — its settlement/cancellation events must fire from
|
||||
* billing's own committed transaction (the credits listeners react to
|
||||
* them). If billing then rejects the action, the decision is compensated
|
||||
* back to PENDING so the request is not silently lost.
|
||||
*/
|
||||
async decideInvoiceAction(
|
||||
approvalId: string,
|
||||
decidedBy: string,
|
||||
approve: boolean,
|
||||
note?: string,
|
||||
): Promise<ShippingLineInvoiceApproval> {
|
||||
if (!approve && !note?.trim()) {
|
||||
throw new BadRequestException(
|
||||
"A note is required when rejecting a request.",
|
||||
);
|
||||
}
|
||||
|
||||
const decided = await this.dataSource.transaction(async (mg) => {
|
||||
const approval = await this.approvals.findByIdForUpdate(mg, approvalId);
|
||||
if (!approval) {
|
||||
throw new NotFoundException(`Request ${approvalId} not found`);
|
||||
}
|
||||
if (approval.status !== ShippingLineInvoiceActionStatus.Pending) {
|
||||
throw new BadRequestException(
|
||||
`This request was already ${approval.status.toLowerCase()}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const status = approve
|
||||
? ShippingLineInvoiceActionStatus.Approved
|
||||
: ShippingLineInvoiceActionStatus.Rejected;
|
||||
await mg.update(
|
||||
ShippingLineInvoiceApproval,
|
||||
{ id: approvalId },
|
||||
{
|
||||
status,
|
||||
decidedBy,
|
||||
decidedAt: new Date(),
|
||||
decisionNote: note ?? null,
|
||||
},
|
||||
);
|
||||
return { ...approval, status, decidedBy, decisionNote: note ?? null };
|
||||
});
|
||||
|
||||
if (!approve) {
|
||||
logCtx(
|
||||
{ approvalId, invoiceId: decided.invoiceId, decidedBy },
|
||||
{ path: "shippingLineCredit.invoiceAction.rejected" },
|
||||
);
|
||||
return decided;
|
||||
}
|
||||
|
||||
try {
|
||||
if (decided.action === ShippingLineInvoiceActionType.MarkPaid) {
|
||||
const invoice = await this.dataSource
|
||||
.getRepository(Invoice)
|
||||
.findOne({ where: { id: decided.invoiceId } });
|
||||
if (!invoice) {
|
||||
throw new NotFoundException(`Invoice ${decided.invoiceId} not found`);
|
||||
}
|
||||
// Full settlement of the outstanding balance; billing emits
|
||||
// `shipping_line_credit.invoice.paid`, which marks the credits PAID.
|
||||
await this.billing.recordPayment(decided.invoiceId, {
|
||||
amount: Number(invoice.balanceAmount ?? invoice.totalAmount),
|
||||
method: "OFFLINE",
|
||||
reference: decided.paymentReference ?? undefined,
|
||||
metadata: {
|
||||
approvalId: decided.id,
|
||||
requestedBy: decided.requestedBy,
|
||||
approvedBy: decidedBy,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Billing emits `shipping_line_credit.invoice.cancelled`;
|
||||
// onInvoiceCancelled releases the credits back to the unbilled pool.
|
||||
await this.billing.cancelInvoice(decided.invoiceId);
|
||||
}
|
||||
} catch (err) {
|
||||
// The action was refused (state changed since the request — e.g. the
|
||||
// line paid through CBE in the meantime). Put the request back so it is
|
||||
// not recorded as approved-but-unexecuted.
|
||||
await this.approvals.update(approvalId, {
|
||||
status: ShippingLineInvoiceActionStatus.Pending,
|
||||
decidedBy: null,
|
||||
decidedAt: null,
|
||||
decisionNote: null,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
|
||||
logCtx(
|
||||
{
|
||||
approvalId,
|
||||
invoiceId: decided.invoiceId,
|
||||
action: decided.action,
|
||||
decidedBy,
|
||||
},
|
||||
{ path: "shippingLineCredit.invoiceAction.approved" },
|
||||
);
|
||||
|
||||
return decided;
|
||||
}
|
||||
|
||||
/**
|
||||
* When a credit invoice is cancelled — through the approval flow or any
|
||||
* other billing path — its BILLED credits return to the unbilled pool so
|
||||
* the debt can be re-billed. The debt itself never disappears on invoice
|
||||
* cancellation; only {@link cancelCredit} writes debt off.
|
||||
*/
|
||||
@OnEvent("shipping_line_credit.invoice.cancelled")
|
||||
async onInvoiceCancelled(payload: InvoiceEventPayload): Promise<void> {
|
||||
const result = await this.dataSource
|
||||
.getRepository(ShippingLineCredit)
|
||||
.update(
|
||||
{
|
||||
invoiceId: payload.invoiceId,
|
||||
status: ShippingLineCreditStatus.Billed,
|
||||
},
|
||||
{
|
||||
status: ShippingLineCreditStatus.Unbilled,
|
||||
invoiceId: null,
|
||||
billedAt: null,
|
||||
},
|
||||
);
|
||||
|
||||
logCtx(
|
||||
{
|
||||
invoiceId: payload.invoiceId,
|
||||
invoiceNumber: payload.invoiceNumber,
|
||||
creditsReleased: result.affected ?? 0,
|
||||
},
|
||||
{ path: "shippingLineCredit.invoiceCancelled.released" },
|
||||
);
|
||||
}
|
||||
|
||||
// ── Cancellation ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { EntityManager, In, Repository } from "typeorm";
|
||||
|
||||
import {
|
||||
ShippingLineInvoiceApproval,
|
||||
ShippingLineInvoiceActionStatus,
|
||||
} from "./entities/shipping-line-invoice-approval.entity";
|
||||
|
||||
@Injectable()
|
||||
export class ShippingLineInvoiceApprovalsRepository extends BaseRepository<ShippingLineInvoiceApproval> {
|
||||
constructor(
|
||||
@InjectRepository(ShippingLineInvoiceApproval)
|
||||
private readonly approvals: Repository<ShippingLineInvoiceApproval>,
|
||||
) {
|
||||
super(approvals);
|
||||
}
|
||||
|
||||
findPendingByInvoice(
|
||||
invoiceId: string,
|
||||
): Promise<ShippingLineInvoiceApproval | null> {
|
||||
return this.approvals.findOne({
|
||||
where: { invoiceId, status: ShippingLineInvoiceActionStatus.Pending },
|
||||
});
|
||||
}
|
||||
|
||||
/** Pending requests for a page of invoices — one query, no N+1. */
|
||||
findPendingByInvoiceIds(
|
||||
invoiceIds: string[],
|
||||
): Promise<ShippingLineInvoiceApproval[]> {
|
||||
if (!invoiceIds.length) return Promise.resolve([]);
|
||||
return this.approvals.find({
|
||||
where: {
|
||||
invoiceId: In(invoiceIds),
|
||||
status: ShippingLineInvoiceActionStatus.Pending,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Load one request inside the caller's transaction, locked for decision. */
|
||||
findByIdForUpdate(
|
||||
manager: EntityManager,
|
||||
id: string,
|
||||
): Promise<ShippingLineInvoiceApproval | null> {
|
||||
return manager.getRepository(ShippingLineInvoiceApproval).findOne({
|
||||
where: { id },
|
||||
lock: { mode: "pessimistic_write" },
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user