mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 07:22:53 +00:00
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>
174 lines
5.7 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|