import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { DatabaseService } from '../../../infrastructure/database/database.service';
import { DomainConflictError } from '../../../common/errors/domain-conflict.error';
import { AuditService } from '../../audit/audit.service';
import {
assertCostingBoolean,
assertCostingCurrency,
assertCostingScope,
assertCostingText,
assertOptionalCostingDecimal,
assertOptionalCostingText,
} from './costing-input-validation';
@Injectable()
export class CostingConfigurationService {
constructor(
private readonly database: DatabaseService,
private readonly audit: AuditService,
) {}
async get(input: { organizationId: string; locationId: string }) {
assertCostingScope(input, 'costing.config_input_invalid');
const rows = await this.database.query<any>(
`select *
from costing_configurations
where organization_id=$1
and is_active=true
and (location_id=$2 or location_id is null)
order by case when location_id=$2 then 0 else 1 end
limit 1`,
[input.organizationId,input.locationId],
);
return rows[0] ?? {
organization_id: input.organizationId,
location_id: input.locationId,
method: 'weighted_average',
currency_code: 'EUR',
include_waste_factor: true,
target_food_cost_percent: null,
minimum_gross_margin_percent: null,
is_active: true,
};
}
async put(input: {
organizationId: string;
locationId: string;
method: string;
currencyCode: string;
includeWasteFactor: boolean;
targetFoodCostPercent?: string;
minimumGrossMarginPercent?: string;
userId?: string;
}) {
assertCostingScope(input, 'costing.config_input_invalid');
assertCostingText(input.method, 80, 'costing.config_input_invalid');
assertCostingCurrency(input.currencyCode, 'costing.config_input_invalid');
assertCostingBoolean(input.includeWasteFactor, 'costing.config_input_invalid');
assertOptionalCostingDecimal(input.targetFoodCostPercent, 'costing.config_input_invalid', { allowNegative: false });
assertOptionalCostingDecimal(input.minimumGrossMarginPercent, 'costing.config_input_invalid');
assertOptionalCostingText(input.userId, 200, 'costing.config_input_invalid');
if (input.method !== 'weighted_average' &&
input.method !== 'last_purchase_price' && input.method !== 'standard_cost') {
throw new DomainConflictError('costing.method_invalid');
}
const existing = await this.database.query<{ id: string }>(
`select id from costing_configurations
where organization_id=$1 and location_id=$2 and is_active=true`,
[input.organizationId,input.locationId],
);
const id = existing[0]?.id ?? randomUUID();
if (existing[0]) {
await this.database.query(
`update costing_configurations
set method=$1,currency_code=$2,include_waste_factor=$3,
target_food_cost_percent=$4,minimum_gross_margin_percent=$5,
updated_at=now(),version=version+1
where id=$6`,
[
input.method,input.currencyCode,input.includeWasteFactor,
input.targetFoodCostPercent ?? null,input.minimumGrossMarginPercent ?? null,id,
],
);
} else {
await this.database.query(
`insert into costing_configurations(
id,organization_id,location_id,method,currency_code,
include_waste_factor,target_food_cost_percent,minimum_gross_margin_percent
) values($1,$2,$3,$4,$5,$6,$7,$8)`,
[
id,input.organizationId,input.locationId,input.method,input.currencyCode,
input.includeWasteFactor,input.targetFoodCostPercent ?? null,
input.minimumGrossMarginPercent ?? null,
],
);
}
await this.audit.record({
organizationId: input.organizationId,
actorUserId: input.userId,
eventType: 'CostingConfigurationChanged',
entityType: 'CostingConfiguration',
entityId: id,
metadata: {
locationId: input.locationId,
method: input.method,
currencyCode: input.currencyCode,
},
});
return { id };
}
}
