Files
edr-platform/apps/edr-freight-api/src/modules/procurement/procurement.service.ts
Hagernesh 8dc4dd585e 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>
2026-07-22 12:15:47 +00:00

174 lines
5.7 KiB
TypeScript

import { BadRequestException, Injectable } from '@nestjs/common';
import { ProcurementRepository } from './procurement.repository';
import { Vendor } from './entities/vendor.entity';
import { AcquisitionType, AssetAcquisition } from './entities/asset-acquisition.entity';
import { AssetDisposal } from './entities/asset-disposal.entity';
import {
CreateVendorDto,
UpdateVendorDto,
CreateAcquisitionDto,
UpdateAcquisitionDto,
CreateDisposalDto,
} from './dto/procurement.dto';
export interface DepreciationResult {
method: 'STRAIGHT_LINE';
cost: number;
salvageValue: number;
usefulLifeMonths: number;
monthsElapsed: number;
monthlyDepreciation: number;
bookValue: number;
}
export interface LifecycleResult {
vehicleId: string;
acquisition: AssetAcquisition | null;
disposal: AssetDisposal | null;
depreciation: DepreciationResult | null;
}
@Injectable()
export class ProcurementService {
constructor(private readonly procurementRepository: ProcurementRepository) {}
// ---- Vendors ----
async createVendor(dto: CreateVendorDto): Promise<Vendor> {
return this.procurementRepository.createVendor(dto);
}
async listVendors(): Promise<Vendor[]> {
return this.procurementRepository.findVendors();
}
async updateVendor(id: string, dto: UpdateVendorDto): Promise<Vendor | null> {
return this.procurementRepository.updateVendor(id, dto);
}
async deleteVendor(id: string): Promise<{ success: boolean }> {
await this.procurementRepository.softDeleteVendor(id);
return { success: true };
}
// ---- 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> {
this.assertLeaseFieldsValid(dto);
return this.procurementRepository.createAcquisition(dto);
}
async listAcquisitions(vehicleId?: string): Promise<AssetAcquisition[]> {
return this.procurementRepository.findAcquisitions(vehicleId);
}
async getAcquisition(id: string): Promise<AssetAcquisition | null> {
return this.procurementRepository.findAcquisitionById(id);
}
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);
}
async deleteAcquisition(id: string): Promise<{ success: boolean }> {
await this.procurementRepository.softDeleteAcquisition(id);
return { success: true };
}
// ---- Disposals ----
async createDisposal(dto: CreateDisposalDto): Promise<AssetDisposal> {
return this.procurementRepository.createDisposal(dto);
}
async listDisposals(): Promise<AssetDisposal[]> {
return this.procurementRepository.findDisposals();
}
async deleteDisposal(id: string): Promise<{ success: boolean }> {
await this.procurementRepository.softDeleteDisposal(id);
return { success: true };
}
// ---- Lifecycle ----
async lifecycle(vehicleId: string): Promise<LifecycleResult> {
const acquisition = await this.procurementRepository.findLatestAcquisitionByVehicle(vehicleId);
const disposal = await this.procurementRepository.findLatestDisposalByVehicle(vehicleId);
return {
vehicleId,
acquisition,
disposal,
depreciation: this.computeStraightLineDepreciation(acquisition),
};
}
/**
* Straight-line depreciation. Requires a cost and a positive useful life.
* monthlyDep = (cost - salvageValue) / usefulLifeMonths
* bookValue = cost - monthlyDep * monthsElapsedSinceAcquisition, floored at salvageValue.
*/
private computeStraightLineDepreciation(
acquisition: AssetAcquisition | null,
): DepreciationResult | null {
if (!acquisition) return null;
const cost = acquisition.cost != null ? Number(acquisition.cost) : null;
const usefulLifeMonths =
acquisition.usefulLifeMonths != null ? Number(acquisition.usefulLifeMonths) : null;
if (cost == null || usefulLifeMonths == null || usefulLifeMonths <= 0) {
return null;
}
const salvageValue = acquisition.salvageValue != null ? Number(acquisition.salvageValue) : 0;
const monthlyDepreciation = (cost - salvageValue) / usefulLifeMonths;
const acquiredAt = new Date(acquisition.acquisitionDate);
const now = new Date();
const monthsElapsed = Math.max(
0,
(now.getFullYear() - acquiredAt.getFullYear()) * 12 +
(now.getMonth() - acquiredAt.getMonth()),
);
const bookValue = Math.max(cost - monthlyDepreciation * monthsElapsed, salvageValue);
return {
method: 'STRAIGHT_LINE',
cost,
salvageValue,
usefulLifeMonths,
monthsElapsed,
monthlyDepreciation,
bookValue,
};
}
}