feat(rates): multi-tier distance band entry in last-mile rate form

- tierList field type in rule-engine form dialog (add/remove rows,
  overlap + open-ended validation, From km auto-continues)
- create submits one rate row per tier sequentially
- editing a band row keeps the single From/To/value form
This commit is contained in:
Hagernesh
2026-08-06 04:14:19 +00:00
parent ea9df4407e
commit 4716aa14c3
7 changed files with 304 additions and 6 deletions

View File

@@ -0,0 +1,36 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Last-mile contract fields on freight.last_mile_requests: the customer-chosen
* delivery date, the chief-approved advance amount (held until the invoice is
* generated at signing time), the rate snapshot rendered into the contract,
* and the customer signature bookkeeping. The signed PDF and signature image
* live in freight.files (resource 'last_mile_requests').
*/
export class LastMileRequestContract3290000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.last_mile_requests
ADD COLUMN IF NOT EXISTS requested_delivery_date date,
ADD COLUMN IF NOT EXISTS approved_advance_amount numeric(14,2),
ADD COLUMN IF NOT EXISTS contract_summary jsonb,
ADD COLUMN IF NOT EXISTS contract_generated_at timestamptz,
ADD COLUMN IF NOT EXISTS customer_signed_at timestamptz,
ADD COLUMN IF NOT EXISTS signer_display_name varchar(160),
ADD COLUMN IF NOT EXISTS consent_text text
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.last_mile_requests
DROP COLUMN IF EXISTS requested_delivery_date,
DROP COLUMN IF EXISTS approved_advance_amount,
DROP COLUMN IF EXISTS contract_summary,
DROP COLUMN IF EXISTS contract_generated_at,
DROP COLUMN IF EXISTS customer_signed_at,
DROP COLUMN IF EXISTS signer_display_name,
DROP COLUMN IF EXISTS consent_text
`);
}
}

View File

@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { ArrayNotEmpty, ArrayUnique, IsArray, IsString } from 'class-validator';
import { ArrayNotEmpty, ArrayUnique, IsArray, IsDateString, IsString } from 'class-validator';
export class SubmitLastMileRequestDto {
@ApiProperty({
@@ -12,4 +12,12 @@ export class SubmitLastMileRequestDto {
@ArrayUnique()
@IsString({ each: true })
containerNumbers!: string[];
@ApiProperty({
description:
'Requested last-mile delivery date (ISO date), chosen by the customer against the train departure from Djibouti.',
example: '2026-08-15',
})
@IsDateString()
deliveryDate!: string;
}

View File

@@ -65,6 +65,47 @@ export class LastMileRequest extends BaseEntity {
@Column({ name: 'resulting_last_mile_id', type: 'uuid', nullable: true })
resultingLastMileId?: string | null;
/** Customer-chosen last-mile delivery date (guided by the train's Djibouti departure). */
@Column({ name: 'requested_delivery_date', type: 'date', nullable: true })
requestedDeliveryDate?: string | null;
/** Chief-approved advance — invoiced only after the customer signs the LM contract. */
@Column({
name: 'approved_advance_amount',
type: 'numeric',
precision: 14,
scale: 2,
nullable: true,
transformer: {
to: (v?: number | null) => v,
from: (v?: string | null) => (v == null ? null : Number(v)),
},
})
approvedAdvanceAmount?: number | null;
/** Rate snapshot taken at approval, rendered into the contract document. */
@Column({ name: 'contract_summary', type: 'jsonb', nullable: true })
contractSummary?: {
estimatedKm: number | null;
mode: string | null;
currency: string | null;
total: number | null;
lines: Array<{ description: string; amount: number }>;
advanceAmount: number;
} | null;
@Column({ name: 'contract_generated_at', type: 'timestamptz', nullable: true })
contractGeneratedAt?: Date | null;
@Column({ name: 'customer_signed_at', type: 'timestamptz', nullable: true })
customerSignedAt?: Date | null;
@Column({ name: 'signer_display_name', type: 'varchar', length: 160, nullable: true })
signerDisplayName?: string | null;
@Column({ name: 'consent_text', type: 'text', nullable: true })
consentText?: string | null;
@ManyToOne(() => LastMile, { nullable: true, eager: false })
@JoinColumn({ name: 'resulting_last_mile_id' })
resultingLastMile?: LastMile | null;

View File

@@ -252,7 +252,12 @@ export class LastMileRequestsService {
});
}
async submit(id: string, userId: string | null, containerNumbers: string[]): Promise<LastMileRequest> {
async submit(
id: string,
userId: string | null,
containerNumbers: string[],
deliveryDate: string,
): Promise<LastMileRequest> {
const request = await this.findById(id);
if (request.status !== LastMileRequestStatus.AwaitingConfirmation) {
throw new BadRequestException(`Request is already ${request.status.toLowerCase()}`);
@@ -274,6 +279,7 @@ export class LastMileRequestsService {
await this.requestsRepository.update(id, {
requestedContainerNumbers: selected,
requestedDeliveryDate: deliveryDate,
status: LastMileRequestStatus.Submitted,
submittedByUserId: userId,
submittedAt: new Date(),