mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 21:15:41 +00:00
Merge pull request #910 from Tria-plc/testfixes
Operations permissons and dispatechand procurement and Asset Feature
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.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
|
||||||
import { CreateWarehouseYardDto } from './dto/create-warehouse-yard.dto';
|
import { CreateWarehouseYardDto } from './dto/create-warehouse-yard.dto';
|
||||||
import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto';
|
import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto';
|
||||||
@@ -44,6 +44,7 @@ export class WarehouseYardsService {
|
|||||||
// Ensure the parent warehouse exists.
|
// Ensure the parent warehouse exists.
|
||||||
await this.warehousesService.findById(warehouseId);
|
await this.warehousesService.findById(warehouseId);
|
||||||
await this.assertCodeUnique(warehouseId, dto.code.trim());
|
await this.assertCodeUnique(warehouseId, dto.code.trim());
|
||||||
|
await this.assertCapacityWithinWarehouse(warehouseId, dto.capacityWeight ?? null, dto.capacityContainers ?? null);
|
||||||
|
|
||||||
return this.yardsRepository.create({
|
return this.yardsRepository.create({
|
||||||
warehouseId,
|
warehouseId,
|
||||||
@@ -69,14 +70,22 @@ export class WarehouseYardsService {
|
|||||||
await this.assertCodeUnique(existing.warehouseId, dto.code.trim(), id);
|
await this.assertCodeUnique(existing.warehouseId, dto.code.trim(), id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const newCapacityWeight = dto.capacityWeight ?? existing.capacityWeight ?? null;
|
||||||
|
const newCapacityContainers = dto.capacityContainers ?? existing.capacityContainers ?? null;
|
||||||
|
|
||||||
|
// Validate updated capacity doesn't exceed warehouse limits
|
||||||
|
if (newCapacityWeight !== (existing.capacityWeight ?? null) || newCapacityContainers !== (existing.capacityContainers ?? null)) {
|
||||||
|
await this.assertCapacityWithinWarehouse(existing.warehouseId, newCapacityWeight, newCapacityContainers, id);
|
||||||
|
}
|
||||||
|
|
||||||
const status = dto.status ?? existing.status;
|
const status = dto.status ?? existing.status;
|
||||||
|
|
||||||
const updated = await this.yardsRepository.update(id, {
|
const updated = await this.yardsRepository.update(id, {
|
||||||
name: dto.name?.trim() ?? existing.name,
|
name: dto.name?.trim() ?? existing.name,
|
||||||
code: dto.code?.trim() ?? existing.code,
|
code: dto.code?.trim() ?? existing.code,
|
||||||
type: dto.type ?? existing.type,
|
type: dto.type ?? existing.type,
|
||||||
capacityWeight: dto.capacityWeight ?? existing.capacityWeight,
|
capacityWeight: newCapacityWeight,
|
||||||
capacityContainers: dto.capacityContainers ?? existing.capacityContainers,
|
capacityContainers: newCapacityContainers,
|
||||||
maxWeight: dto.maxWeight ?? existing.maxWeight,
|
maxWeight: dto.maxWeight ?? existing.maxWeight,
|
||||||
maxVolume: dto.maxVolume ?? existing.maxVolume,
|
maxVolume: dto.maxVolume ?? existing.maxVolume,
|
||||||
status,
|
status,
|
||||||
@@ -97,4 +106,39 @@ export class WarehouseYardsService {
|
|||||||
throw new ConflictException(`Yard code ${code} already exists in this warehouse`);
|
throw new ConflictException(`Yard code ${code} already exists in this warehouse`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async assertCapacityWithinWarehouse(
|
||||||
|
warehouseId: string,
|
||||||
|
newCapacityWeight: number | null,
|
||||||
|
newCapacityContainers: number | null,
|
||||||
|
excludeYardId?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const warehouse = await this.warehousesService.findById(warehouseId);
|
||||||
|
const yards = await this.findByWarehouse(warehouseId);
|
||||||
|
|
||||||
|
// Sum existing yard capacities, excluding the yard being updated if provided
|
||||||
|
const otherYards = excludeYardId ? yards.filter((y) => y.id !== excludeYardId) : yards;
|
||||||
|
const totalExistingWeight = otherYards.reduce((sum, y) => sum + (y.capacityWeight ?? 0), 0);
|
||||||
|
const totalExistingContainers = otherYards.reduce((sum, y) => sum + (y.capacityContainers ?? 0), 0);
|
||||||
|
|
||||||
|
// Check weight capacity
|
||||||
|
if (newCapacityWeight !== null && warehouse.capacityWeight != null) {
|
||||||
|
const totalWeight = totalExistingWeight + newCapacityWeight;
|
||||||
|
if (totalWeight > warehouse.capacityWeight) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Total yard weight capacity (${totalWeight}t) exceeds warehouse limit (${warehouse.capacityWeight}t)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check container capacity
|
||||||
|
if (newCapacityContainers !== null && warehouse.capacityContainers != null) {
|
||||||
|
const totalContainers = totalExistingContainers + newCapacityContainers;
|
||||||
|
if (totalContainers > warehouse.capacityContainers) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Total yard container capacity (${totalContainers}) exceeds warehouse limit (${warehouse.capacityContainers})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
|
||||||
import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto';
|
import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto';
|
||||||
import { UpdateWarehouseZoneDto } from './dto/update-warehouse-zone.dto';
|
import { UpdateWarehouseZoneDto } from './dto/update-warehouse-zone.dto';
|
||||||
@@ -43,6 +43,7 @@ export class WarehouseZonesService {
|
|||||||
// Ensure the parent yard exists.
|
// Ensure the parent yard exists.
|
||||||
await this.yardsService.findById(yardId);
|
await this.yardsService.findById(yardId);
|
||||||
await this.assertCodeUnique(yardId, dto.code.trim());
|
await this.assertCodeUnique(yardId, dto.code.trim());
|
||||||
|
await this.assertCapacityWithinYard(yardId, dto.capacityWeight ?? null, dto.capacityContainers ?? null);
|
||||||
|
|
||||||
return this.zonesRepository.create({
|
return this.zonesRepository.create({
|
||||||
yardId,
|
yardId,
|
||||||
@@ -68,14 +69,22 @@ export class WarehouseZonesService {
|
|||||||
await this.assertCodeUnique(existing.yardId, dto.code.trim(), id);
|
await this.assertCodeUnique(existing.yardId, dto.code.trim(), id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const newCapacityWeight = dto.capacityWeight ?? existing.capacityWeight ?? null;
|
||||||
|
const newCapacityContainers = dto.capacityContainers ?? existing.capacityContainers ?? null;
|
||||||
|
|
||||||
|
// Validate updated capacity doesn't exceed yard limits
|
||||||
|
if (newCapacityWeight !== (existing.capacityWeight ?? null) || newCapacityContainers !== (existing.capacityContainers ?? null)) {
|
||||||
|
await this.assertCapacityWithinYard(existing.yardId, newCapacityWeight, newCapacityContainers, id);
|
||||||
|
}
|
||||||
|
|
||||||
const status = dto.status ?? existing.status;
|
const status = dto.status ?? existing.status;
|
||||||
|
|
||||||
const updated = await this.zonesRepository.update(id, {
|
const updated = await this.zonesRepository.update(id, {
|
||||||
name: dto.name?.trim() ?? existing.name,
|
name: dto.name?.trim() ?? existing.name,
|
||||||
code: dto.code?.trim() ?? existing.code,
|
code: dto.code?.trim() ?? existing.code,
|
||||||
type: dto.type ?? existing.type,
|
type: dto.type ?? existing.type,
|
||||||
capacityWeight: dto.capacityWeight ?? existing.capacityWeight,
|
capacityWeight: newCapacityWeight,
|
||||||
capacityContainers: dto.capacityContainers ?? existing.capacityContainers,
|
capacityContainers: newCapacityContainers,
|
||||||
maxWeight: dto.maxWeight ?? existing.maxWeight,
|
maxWeight: dto.maxWeight ?? existing.maxWeight,
|
||||||
maxVolume: dto.maxVolume ?? existing.maxVolume,
|
maxVolume: dto.maxVolume ?? existing.maxVolume,
|
||||||
status,
|
status,
|
||||||
@@ -96,4 +105,39 @@ export class WarehouseZonesService {
|
|||||||
throw new ConflictException(`Zone code ${code} already exists in this yard`);
|
throw new ConflictException(`Zone code ${code} already exists in this yard`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async assertCapacityWithinYard(
|
||||||
|
yardId: string,
|
||||||
|
newCapacityWeight: number | null,
|
||||||
|
newCapacityContainers: number | null,
|
||||||
|
excludeZoneId?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const yard = await this.yardsService.findById(yardId);
|
||||||
|
const zones = await this.findByYard(yardId);
|
||||||
|
|
||||||
|
// Sum existing zone capacities, excluding the zone being updated if provided
|
||||||
|
const otherZones = excludeZoneId ? zones.filter((z) => z.id !== excludeZoneId) : zones;
|
||||||
|
const totalExistingWeight = otherZones.reduce((sum, z) => sum + (z.capacityWeight ?? 0), 0);
|
||||||
|
const totalExistingContainers = otherZones.reduce((sum, z) => sum + (z.capacityContainers ?? 0), 0);
|
||||||
|
|
||||||
|
// Check weight capacity
|
||||||
|
if (newCapacityWeight !== null && yard.capacityWeight != null) {
|
||||||
|
const totalWeight = totalExistingWeight + newCapacityWeight;
|
||||||
|
if (totalWeight > yard.capacityWeight) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Total zone weight capacity (${totalWeight}t) exceeds yard limit (${yard.capacityWeight}t)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check container capacity
|
||||||
|
if (newCapacityContainers !== null && yard.capacityContainers != null) {
|
||||||
|
const totalContainers = totalExistingContainers + newCapacityContainers;
|
||||||
|
if (totalContainers > yard.capacityContainers) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Total zone container capacity (${totalContainers}) exceeds yard limit (${yard.capacityContainers})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -808,41 +808,41 @@ export const POSITION_PERMISSION_PRESETS = {
|
|||||||
// permission catalog (all CRUD across bookings, contracts, scheduling,
|
// permission catalog (all CRUD across bookings, contracts, scheduling,
|
||||||
// fleet, warehouse, mile, finance, settings, staff).
|
// fleet, warehouse, mile, finance, settings, staff).
|
||||||
operationsChief: dedupe([...BOOKING_RULE_ENGINE_PERMISSION_KEYS]),
|
operationsChief: dedupe([...BOOKING_RULE_ENGINE_PERMISSION_KEYS]),
|
||||||
// Dispatcher: warehouse floor operations — receive/GRN, move, load/unload,
|
// Dispatcher: full CRUD on warehouse management (incl. import/export/intercity
|
||||||
// inspect, dispatch, gate, release/deliver, interchange docs, fee invoices,
|
// inventory flows) and fleet management, plus truck dispatch on the mile legs
|
||||||
// plus truck dispatch on the mile legs and read-only operational context.
|
// and operational context. The ONE carve-out: allocation & fee rules stay
|
||||||
// Allocation & fee rules are VIEW-ONLY — never create/update/delete.
|
// VIEW-ONLY — a dispatcher never creates/updates/deletes those rules.
|
||||||
dispatcher: dedupe([
|
dispatcher: dedupe([
|
||||||
|
// Warehouse management — full CRUD.
|
||||||
FREIGHT_PERMS.warehouseDashboard.view,
|
FREIGHT_PERMS.warehouseDashboard.view,
|
||||||
FREIGHT_PERMS.warehouses.view,
|
...Object.values(FREIGHT_PERMS.warehouses),
|
||||||
FREIGHT_PERMS.warehouseYards.view,
|
...Object.values(FREIGHT_PERMS.warehouseYards),
|
||||||
FREIGHT_PERMS.warehouseZones.view,
|
...Object.values(FREIGHT_PERMS.warehouseZones),
|
||||||
FREIGHT_PERMS.warehouseInventory.view,
|
...Object.values(FREIGHT_PERMS.warehouseInventory),
|
||||||
FREIGHT_PERMS.warehouseInventory.receive,
|
...Object.values(FREIGHT_PERMS.warehouseInspectionReports),
|
||||||
FREIGHT_PERMS.warehouseInventory.move,
|
...Object.values(FREIGHT_PERMS.interchangeDocuments),
|
||||||
FREIGHT_PERMS.warehouseInventory.load,
|
...Object.values(FREIGHT_PERMS.warehouseFeeInvoices),
|
||||||
FREIGHT_PERMS.warehouseInventory.unload,
|
|
||||||
FREIGHT_PERMS.warehouseInventory.dispatch,
|
|
||||||
FREIGHT_PERMS.warehouseInventory.gatePass,
|
|
||||||
FREIGHT_PERMS.warehouseInventory.release,
|
|
||||||
FREIGHT_PERMS.warehouseInventory.deliver,
|
|
||||||
FREIGHT_PERMS.warehouseInventory.inspect,
|
|
||||||
FREIGHT_PERMS.warehouseInspectionReports.view,
|
|
||||||
FREIGHT_PERMS.warehouseInspectionReports.create,
|
|
||||||
FREIGHT_PERMS.warehouseInspectionReports.update,
|
|
||||||
FREIGHT_PERMS.interchangeDocuments.view,
|
|
||||||
FREIGHT_PERMS.interchangeDocuments.generate,
|
|
||||||
FREIGHT_PERMS.interchangeDocuments.acknowledge,
|
|
||||||
FREIGHT_PERMS.warehouseFeeInvoices.view,
|
|
||||||
FREIGHT_PERMS.warehouseFeeInvoices.generate,
|
|
||||||
// View-only on the rules that govern allocation and fees.
|
// View-only on the rules that govern allocation and fees.
|
||||||
FREIGHT_PERMS.warehouseAllocationRules.view,
|
FREIGHT_PERMS.warehouseAllocationRules.view,
|
||||||
FREIGHT_PERMS.warehouseFeeRules.view,
|
FREIGHT_PERMS.warehouseFeeRules.view,
|
||||||
|
// Fleet management — full CRUD.
|
||||||
|
...Object.values(FREIGHT_PERMS.fleet),
|
||||||
|
FREIGHT_PERMS.fleetDashboard.view,
|
||||||
|
...Object.values(FREIGHT_PERMS.fleetReports),
|
||||||
|
...Object.values(FREIGHT_PERMS.vehicles),
|
||||||
|
...Object.values(FREIGHT_PERMS.drivers),
|
||||||
|
...Object.values(FREIGHT_PERMS.tracking),
|
||||||
|
...Object.values(FREIGHT_PERMS.fuel),
|
||||||
|
...Object.values(FREIGHT_PERMS.maintenance),
|
||||||
|
...Object.values(FREIGHT_PERMS.locomotives),
|
||||||
|
...Object.values(FREIGHT_PERMS.wagons),
|
||||||
|
...Object.values(FREIGHT_PERMS.trains),
|
||||||
|
...Object.values(FREIGHT_PERMS.routes),
|
||||||
|
...Object.values(FREIGHT_PERMS.containers),
|
||||||
|
...Object.values(FREIGHT_PERMS.cargoes),
|
||||||
// Truck dispatch on the EDR mile legs + operational context.
|
// Truck dispatch on the EDR mile legs + operational context.
|
||||||
FREIGHT_PERMS.firstMile.view,
|
...Object.values(FREIGHT_PERMS.firstMile),
|
||||||
FREIGHT_PERMS.firstMile.assignVehicles,
|
...Object.values(FREIGHT_PERMS.lastMile),
|
||||||
FREIGHT_PERMS.lastMile.view,
|
|
||||||
FREIGHT_PERMS.lastMile.assignVehicles,
|
|
||||||
FREIGHT_PERMS.trainScheduling.view,
|
FREIGHT_PERMS.trainScheduling.view,
|
||||||
FREIGHT_PERMS.bookings.operations,
|
FREIGHT_PERMS.bookings.operations,
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -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