mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 05:25:41 +00:00
feat(procurement): validate acquisition lease fields and enforce bulk-receive capacity
Reject lease start/end and monthly payment on PURCHASE acquisitions (create and update, validated against the resulting record). Add asset_acquisitions.item_name column + migration. Enforce warehouse/yard/zone capacity on bulk receive and apply capacity-counter deltas on save. Adds acquisition-guard spec. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,23 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Acquisitions describe WHAT was acquired (vehicle, parts, equipment…) — the
|
||||||
|
* vehicle link is optional and only for acquisitions that ARE a fleet vehicle.
|
||||||
|
*/
|
||||||
|
export class AddAcquisitionItemName2470000000000 implements MigrationInterface {
|
||||||
|
name = 'AddAcquisitionItemName2470000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.asset_acquisitions
|
||||||
|
ADD COLUMN IF NOT EXISTS item_name varchar(200)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.asset_acquisitions
|
||||||
|
DROP COLUMN IF EXISTS item_name
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
IsOptional,
|
IsOptional,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
|
MinLength,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { VendorType } from '../entities/vendor.entity';
|
import { VendorType } from '../entities/vendor.entity';
|
||||||
import { AcquisitionType, AcquisitionStatus } from '../entities/asset-acquisition.entity';
|
import { AcquisitionType, AcquisitionStatus } from '../entities/asset-acquisition.entity';
|
||||||
@@ -72,6 +73,11 @@ export class UpdateVendorDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class CreateAcquisitionDto {
|
export class CreateAcquisitionDto {
|
||||||
|
/** WHAT was acquired — required so an acquisition can't be saved empty. */
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
itemName!: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
vehicleId?: string;
|
vehicleId?: string;
|
||||||
@@ -120,6 +126,11 @@ export class CreateAcquisitionDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateAcquisitionDto {
|
export class UpdateAcquisitionDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
itemName?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
vehicleId?: string;
|
vehicleId?: string;
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ export enum AcquisitionStatus {
|
|||||||
@Entity({ name: 'asset_acquisitions', schema: 'freight' })
|
@Entity({ name: 'asset_acquisitions', schema: 'freight' })
|
||||||
@Index(['vehicleId', 'acquisitionDate'])
|
@Index(['vehicleId', 'acquisitionDate'])
|
||||||
export class AssetAcquisition extends BaseEntity {
|
export class AssetAcquisition extends BaseEntity {
|
||||||
|
/** WHAT was acquired (vehicle, parts, equipment…) — the asset itself. */
|
||||||
|
@Column({ name: 'item_name', type: 'varchar', length: 200, nullable: true })
|
||||||
|
itemName?: string;
|
||||||
|
|
||||||
|
/** Optional link — only when the acquisition IS a fleet vehicle. Parts and
|
||||||
|
* general procurement stay unlinked so reports don't misattribute them. */
|
||||||
@Column({ name: 'vehicle_id', type: 'uuid', nullable: true })
|
@Column({ name: 'vehicle_id', type: 'uuid', nullable: true })
|
||||||
vehicleId?: string;
|
vehicleId?: string;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { ProcurementService } from './procurement.service';
|
||||||
|
import { AcquisitionType } from './entities/asset-acquisition.entity';
|
||||||
|
|
||||||
|
// PURCHASE acquisitions must not carry lease terms; LEASE/RENTAL may.
|
||||||
|
describe('ProcurementService acquisition lease-field guard', () => {
|
||||||
|
const repo = {
|
||||||
|
createAcquisition: jest.fn(async (dto) => dto),
|
||||||
|
findAcquisitionById: jest.fn(async () => ({ acquisitionType: AcquisitionType.PURCHASE })),
|
||||||
|
updateAcquisition: jest.fn(async (_id, dto) => dto),
|
||||||
|
};
|
||||||
|
const svc = new ProcurementService(repo as never);
|
||||||
|
|
||||||
|
it('rejects a PURCHASE with lease dates', async () => {
|
||||||
|
await expect(
|
||||||
|
svc.createAcquisition({
|
||||||
|
itemName: 'Brake pads',
|
||||||
|
acquisitionType: AcquisitionType.PURCHASE,
|
||||||
|
acquisitionDate: '2026-07-22',
|
||||||
|
leaseStart: '2026-07-01',
|
||||||
|
} as never),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a LEASE with lease dates and a plain PURCHASE', async () => {
|
||||||
|
await expect(
|
||||||
|
svc.createAcquisition({
|
||||||
|
itemName: 'Rented crane',
|
||||||
|
acquisitionType: AcquisitionType.LEASE,
|
||||||
|
acquisitionDate: '2026-07-22',
|
||||||
|
leaseStart: '2026-07-01',
|
||||||
|
leaseEnd: '2027-07-01',
|
||||||
|
} as never),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
await expect(
|
||||||
|
svc.createAcquisition({
|
||||||
|
itemName: 'Brake pads',
|
||||||
|
acquisitionType: AcquisitionType.PURCHASE,
|
||||||
|
acquisitionDate: '2026-07-22',
|
||||||
|
} as never),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects adding lease terms to an acquisition that is a PURCHASE', async () => {
|
||||||
|
await expect(
|
||||||
|
svc.updateAcquisition('a1', { monthlyPayment: 500 } as never),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
import { ProcurementRepository } from './procurement.repository';
|
import { ProcurementRepository } from './procurement.repository';
|
||||||
import { Vendor } from './entities/vendor.entity';
|
import { Vendor } from './entities/vendor.entity';
|
||||||
import { AssetAcquisition } from './entities/asset-acquisition.entity';
|
import { AcquisitionType, AssetAcquisition } from './entities/asset-acquisition.entity';
|
||||||
import { AssetDisposal } from './entities/asset-disposal.entity';
|
import { AssetDisposal } from './entities/asset-disposal.entity';
|
||||||
import {
|
import {
|
||||||
CreateVendorDto,
|
CreateVendorDto,
|
||||||
@@ -51,7 +51,23 @@ export class ProcurementService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- Acquisitions ----
|
// ---- Acquisitions ----
|
||||||
|
/** Lease terms only make sense on LEASE / RENTAL — a PURCHASE must not carry them. */
|
||||||
|
private assertLeaseFieldsValid(dto: {
|
||||||
|
acquisitionType?: string;
|
||||||
|
leaseStart?: string;
|
||||||
|
leaseEnd?: string;
|
||||||
|
monthlyPayment?: number;
|
||||||
|
}): void {
|
||||||
|
if (dto.acquisitionType !== AcquisitionType.PURCHASE) return;
|
||||||
|
if (dto.leaseStart || dto.leaseEnd || dto.monthlyPayment != null) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Lease start/end and monthly payment are not valid for a PURCHASE acquisition',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async createAcquisition(dto: CreateAcquisitionDto): Promise<AssetAcquisition> {
|
async createAcquisition(dto: CreateAcquisitionDto): Promise<AssetAcquisition> {
|
||||||
|
this.assertLeaseFieldsValid(dto);
|
||||||
return this.procurementRepository.createAcquisition(dto);
|
return this.procurementRepository.createAcquisition(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +80,20 @@ export class ProcurementService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async updateAcquisition(id: string, dto: UpdateAcquisitionDto): Promise<AssetAcquisition | null> {
|
async updateAcquisition(id: string, dto: UpdateAcquisitionDto): Promise<AssetAcquisition | null> {
|
||||||
|
// Validate against the resulting record, not just the patch — switching an
|
||||||
|
// acquisition to PURCHASE must also shed any stored lease terms.
|
||||||
|
const existing = await this.procurementRepository.findAcquisitionById(id);
|
||||||
|
if (existing) {
|
||||||
|
const next = { ...existing, ...dto };
|
||||||
|
if (next.acquisitionType === AcquisitionType.PURCHASE) {
|
||||||
|
this.assertLeaseFieldsValid({
|
||||||
|
acquisitionType: next.acquisitionType,
|
||||||
|
leaseStart: dto.leaseStart,
|
||||||
|
leaseEnd: dto.leaseEnd,
|
||||||
|
monthlyPayment: dto.monthlyPayment,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
return this.procurementRepository.updateAcquisition(id, dto);
|
return this.procurementRepository.updateAcquisition(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1578,6 +1578,14 @@ export class WarehouseInventoryService {
|
|||||||
notes: `Bulk received (${dto.direction})`,
|
notes: `Bulk received (${dto.direction})`,
|
||||||
truckEntrance,
|
truckEntrance,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Validate capacity before saving
|
||||||
|
const weight = Number(booking.weight) || 0;
|
||||||
|
const containerCount = booking.freightType === 'CONTAINER' ? containerQuantity : 0;
|
||||||
|
this.assertCapacity('Warehouse', warehouse, weight, 0, containerCount);
|
||||||
|
this.assertCapacity('Yard', yard, weight, 0, containerCount);
|
||||||
|
this.assertCapacity('Zone', zone, weight, 0, containerCount);
|
||||||
|
|
||||||
const saved = await manager.getRepository(WarehouseInventory).save(
|
const saved = await manager.getRepository(WarehouseInventory).save(
|
||||||
manager.getRepository(WarehouseInventory).create({
|
manager.getRepository(WarehouseInventory).create({
|
||||||
warehouseId: dto.warehouseId,
|
warehouseId: dto.warehouseId,
|
||||||
@@ -1585,7 +1593,7 @@ export class WarehouseInventoryService {
|
|||||||
zoneId: dto.zoneId,
|
zoneId: dto.zoneId,
|
||||||
bookingId,
|
bookingId,
|
||||||
quantity: booking.freightType === 'CONTAINER' ? containerQuantity : 1,
|
quantity: booking.freightType === 'CONTAINER' ? containerQuantity : 1,
|
||||||
weight: Number(booking.weight) || 0,
|
weight,
|
||||||
grnNumber,
|
grnNumber,
|
||||||
status: 'RECEIVED',
|
status: 'RECEIVED',
|
||||||
arrivedAt: now,
|
arrivedAt: now,
|
||||||
@@ -1593,6 +1601,9 @@ export class WarehouseInventoryService {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Update warehouse/yard/zone capacity counters
|
||||||
|
await this.applyCapacityDelta(manager, dto, weight, 0, containerCount);
|
||||||
|
|
||||||
// Receiving the booking flags every container unit as received into the
|
// Receiving the booking flags every container unit as received into the
|
||||||
// port (self-haul export: the delivering truck's goods are now in) so
|
// port (self-haul export: the delivering truck's goods are now in) so
|
||||||
// staff can raise the per-container GRN over what's received.
|
// staff can raise the per-container GRN over what's received.
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ const clean = <T extends Record<string, unknown>>(obj: T): Partial<T> =>
|
|||||||
) as Partial<T>;
|
) as Partial<T>;
|
||||||
|
|
||||||
const emptyAcquisition = {
|
const emptyAcquisition = {
|
||||||
|
itemName: "",
|
||||||
vehicleId: "",
|
vehicleId: "",
|
||||||
vendorId: "",
|
vendorId: "",
|
||||||
acquisitionType: "PURCHASE" as AcquisitionType,
|
acquisitionType: "PURCHASE" as AcquisitionType,
|
||||||
@@ -239,6 +240,7 @@ export default function ProcurementPage() {
|
|||||||
<Table striped highlightOnHover>
|
<Table striped highlightOnHover>
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
|
<Table.Th>Item / Asset</Table.Th>
|
||||||
<Table.Th>Vehicle</Table.Th>
|
<Table.Th>Vehicle</Table.Th>
|
||||||
<Table.Th>Type</Table.Th>
|
<Table.Th>Type</Table.Th>
|
||||||
<Table.Th>Date</Table.Th>
|
<Table.Th>Date</Table.Th>
|
||||||
@@ -249,7 +251,7 @@ export default function ProcurementPage() {
|
|||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{loadingAcquisitions ? (
|
{loadingAcquisitions ? (
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Td colSpan={5}>
|
<Table.Td colSpan={6}>
|
||||||
<Group justify="center" py="md">
|
<Group justify="center" py="md">
|
||||||
<Loader size="sm" />
|
<Loader size="sm" />
|
||||||
</Group>
|
</Group>
|
||||||
@@ -257,7 +259,7 @@ export default function ProcurementPage() {
|
|||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
) : acquisitions.length === 0 ? (
|
) : acquisitions.length === 0 ? (
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Td colSpan={5}>
|
<Table.Td colSpan={6}>
|
||||||
<Text c="dimmed" ta="center" py="md">
|
<Text c="dimmed" ta="center" py="md">
|
||||||
No acquisitions recorded yet.
|
No acquisitions recorded yet.
|
||||||
</Text>
|
</Text>
|
||||||
@@ -266,6 +268,7 @@ export default function ProcurementPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
{acquisitions.map((a: AssetAcquisition) => (
|
{acquisitions.map((a: AssetAcquisition) => (
|
||||||
<Table.Tr key={a.id}>
|
<Table.Tr key={a.id}>
|
||||||
|
<Table.Td>{a.itemName || "—"}</Table.Td>
|
||||||
<Table.Td>{vehicleLabel(a.vehicle, a.vehicleId)}</Table.Td>
|
<Table.Td>{vehicleLabel(a.vehicle, a.vehicleId)}</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Badge size="sm" color={typeBadgeColor(a.acquisitionType)}>
|
<Badge size="sm" color={typeBadgeColor(a.acquisitionType)}>
|
||||||
@@ -411,31 +414,51 @@ export default function ProcurementPage() {
|
|||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
|
<TextInput
|
||||||
|
label="Item / Asset"
|
||||||
|
placeholder="What was acquired — e.g. brake pads, tyres, truck 3-15288"
|
||||||
|
value={acqForm.itemName}
|
||||||
|
onChange={(e) => setAcqForm({ ...acqForm, itemName: e.currentTarget.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
<Select
|
<Select
|
||||||
label="Vehicle"
|
label="Related vehicle (optional)"
|
||||||
placeholder="Select vehicle"
|
description="Only when the acquisition is a fleet vehicle itself — parts and general procurement stay unlinked."
|
||||||
|
placeholder="Not tied to a vehicle"
|
||||||
data={vehicleOptions}
|
data={vehicleOptions}
|
||||||
value={acqForm.vehicleId}
|
value={acqForm.vehicleId}
|
||||||
onChange={(val) => setAcqForm({ ...acqForm, vehicleId: val || "" })}
|
onChange={(val) => setAcqForm({ ...acqForm, vehicleId: val || "" })}
|
||||||
searchable
|
searchable
|
||||||
clearable
|
clearable
|
||||||
/>
|
/>
|
||||||
<Select
|
<Group gap="xs" align="flex-end" wrap="nowrap">
|
||||||
label="Vendor"
|
<Select
|
||||||
placeholder="Select vendor"
|
style={{ flex: 1 }}
|
||||||
data={vendorOptions}
|
label="Vendor"
|
||||||
value={acqForm.vendorId}
|
placeholder="Select vendor"
|
||||||
onChange={(val) => setAcqForm({ ...acqForm, vendorId: val || "" })}
|
data={vendorOptions}
|
||||||
searchable
|
value={acqForm.vendorId}
|
||||||
clearable
|
onChange={(val) => setAcqForm({ ...acqForm, vendorId: val || "" })}
|
||||||
/>
|
searchable
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
<Button variant="light" size="sm" onClick={() => setVendorModalOpen(true)}>
|
||||||
|
Register vendor
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
<Select
|
<Select
|
||||||
label="Acquisition Type"
|
label="Acquisition Type"
|
||||||
data={ACQUISITION_TYPES}
|
data={ACQUISITION_TYPES}
|
||||||
value={acqForm.acquisitionType}
|
value={acqForm.acquisitionType}
|
||||||
onChange={(val) =>
|
onChange={(val) => {
|
||||||
setAcqForm({ ...acqForm, acquisitionType: (val as AcquisitionType) || "PURCHASE" })
|
const acquisitionType = (val as AcquisitionType) || "PURCHASE";
|
||||||
}
|
// Lease terms are invalid on a purchase — drop them on switch.
|
||||||
|
setAcqForm(
|
||||||
|
acquisitionType === "PURCHASE"
|
||||||
|
? { ...acqForm, acquisitionType, leaseStart: "", leaseEnd: "", monthlyPayment: undefined }
|
||||||
|
: { ...acqForm, acquisitionType },
|
||||||
|
);
|
||||||
|
}}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -471,28 +494,32 @@ export default function ProcurementPage() {
|
|||||||
decimalScale={2}
|
decimalScale={2}
|
||||||
min={0}
|
min={0}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
{acqForm.acquisitionType !== "PURCHASE" && (
|
||||||
label="Lease Start"
|
<>
|
||||||
type="date"
|
<TextInput
|
||||||
value={acqForm.leaseStart}
|
label="Lease Start"
|
||||||
onChange={(e) => setAcqForm({ ...acqForm, leaseStart: e.currentTarget.value })}
|
type="date"
|
||||||
/>
|
value={acqForm.leaseStart}
|
||||||
<TextInput
|
onChange={(e) => setAcqForm({ ...acqForm, leaseStart: e.currentTarget.value })}
|
||||||
label="Lease End"
|
/>
|
||||||
type="date"
|
<TextInput
|
||||||
value={acqForm.leaseEnd}
|
label="Lease End"
|
||||||
onChange={(e) => setAcqForm({ ...acqForm, leaseEnd: e.currentTarget.value })}
|
type="date"
|
||||||
/>
|
value={acqForm.leaseEnd}
|
||||||
<NumberInput
|
onChange={(e) => setAcqForm({ ...acqForm, leaseEnd: e.currentTarget.value })}
|
||||||
label="Monthly Payment"
|
/>
|
||||||
placeholder="0.00"
|
<NumberInput
|
||||||
value={acqForm.monthlyPayment}
|
label="Monthly Payment"
|
||||||
onChange={(val) =>
|
placeholder="0.00"
|
||||||
setAcqForm({ ...acqForm, monthlyPayment: val as number | undefined })
|
value={acqForm.monthlyPayment}
|
||||||
}
|
onChange={(val) =>
|
||||||
decimalScale={2}
|
setAcqForm({ ...acqForm, monthlyPayment: val as number | undefined })
|
||||||
min={0}
|
}
|
||||||
/>
|
decimalScale={2}
|
||||||
|
min={0}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Select
|
<Select
|
||||||
label="Status"
|
label="Status"
|
||||||
data={ACQUISITION_STATUSES}
|
data={ACQUISITION_STATUSES}
|
||||||
@@ -514,7 +541,7 @@ export default function ProcurementPage() {
|
|||||||
<Button
|
<Button
|
||||||
onClick={() => createAcquisition.mutate()}
|
onClick={() => createAcquisition.mutate()}
|
||||||
loading={createAcquisition.isPending}
|
loading={createAcquisition.isPending}
|
||||||
disabled={!acqForm.acquisitionDate}
|
disabled={!acqForm.acquisitionDate || acqForm.itemName.trim().length < 2}
|
||||||
>
|
>
|
||||||
Save Acquisition
|
Save Acquisition
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export interface Vendor {
|
|||||||
|
|
||||||
export interface AssetAcquisition {
|
export interface AssetAcquisition {
|
||||||
id: string;
|
id: string;
|
||||||
|
itemName?: string | null;
|
||||||
vehicleId?: string | null;
|
vehicleId?: string | null;
|
||||||
vendorId?: string | null;
|
vendorId?: string | null;
|
||||||
acquisitionType: AcquisitionType;
|
acquisitionType: AcquisitionType;
|
||||||
|
|||||||
Reference in New Issue
Block a user