import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { DatabaseService } from '../../../infrastructure/database/database.service';
import { StockMovementService } from './stock-movement.service';
import { DomainConflictError } from '../../../common/errors/domain-conflict.error';
import { OutboxService } from '../../../infrastructure/outbox/outbox.service';
import { assertTransferInput } from './inventory-input-validation';
@Injectable()
export class StockTransferService {
constructor(
private readonly database:DatabaseService,
private readonly movements:StockMovementService,
private readonly outbox:OutboxService,
) {}
async transfer(input:{
organizationId:string;locationId:string;stockItemId:string;
fromStorageLocationId:string;toStorageLocationId:string;
quantity:string;unitCode:string;normalizedQuantity:string;normalizedUnitCode:string;
businessDate:string;reason?:string;employeeId?:string;
}) {
assertTransferInput(input);
if(input.fromStorageLocationId===input.toStorageLocationId) {
throw new DomainConflictError('inventory.transfer_same_location');
}
return this.database.transaction(async(tx)=>{
const ordered=[input.fromStorageLocationId,input.toStorageLocationId].sort();
const storage=await tx.query<{id:string}>(
`select id from storage_locations
where organization_id=$1 and location_id=$2
and id=any($3::uuid[]) and is_active=true
order by id for update`,
[input.organizationId,input.locationId,ordered],
);
if(storage.length!==2) throw new DomainConflictError('inventory.transfer_storage_invalid');
const transferId=randomUUID();
await tx.query(
`insert into stock_transfer_records(
id,organization_id,location_id,stock_item_id,
from_storage_location_id,to_storage_location_id,
quantity,unit_code,normalized_quantity,reason,created_by_employee_id
) values($1,$2,$3,$4,$5,$6,$7::numeric,$8,$9::numeric,$10,$11)`,
[
transferId,input.organizationId,input.locationId,input.stockItemId,
input.fromStorageLocationId,input.toStorageLocationId,input.quantity,input.unitCode,
input.normalizedQuantity,input.reason??null,input.employeeId??null,
],
);
const outMovement=await this.movements.append({
organizationId:input.organizationId,locationId:input.locationId,
stockItemId:input.stockItemId,storageLocationId:input.fromStorageLocationId,
movementType:'transfer_out',quantityDelta:`-${input.quantity}`,unitCode:input.unitCode,
normalizedQuantityDelta:`-${input.normalizedQuantity}`,
normalizedUnitCode:input.normalizedUnitCode,sourceType:'stock_transfer',
sourceId:transferId,businessDate:input.businessDate,reason:input.reason,employeeId:input.employeeId,
},tx);
const inMovement=await this.movements.append({
organizationId:input.organizationId,locationId:input.locationId,
stockItemId:input.stockItemId,storageLocationId:input.toStorageLocationId,
movementType:'transfer_in',quantityDelta:input.quantity,unitCode:input.unitCode,
normalizedQuantityDelta:input.normalizedQuantity,normalizedUnitCode:input.normalizedUnitCode,
sourceType:'stock_transfer',sourceId:transferId,businessDate:input.businessDate,
reason:input.reason,employeeId:input.employeeId,
},tx);
await this.outbox.append({
organizationId:input.organizationId,aggregateType:'StockTransfer',aggregateId:transferId,
eventType:'StockTransferred',
payload:{
stockTransferId:transferId,stockItemId:input.stockItemId,locationId:input.locationId,
fromStorageLocationId:input.fromStorageLocationId,toStorageLocationId:input.toStorageLocationId,
},
},tx);
return {
id:transferId,
transferOutMovementId:outMovement.id,
transferInMovementId:inMovement.id,
};
},{lockTimeoutMs:3000,statementTimeoutMs:10000});
}
}
