import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../../../infrastructure/database/database.service';
import { StockMovementService } from '../application/stock-movement.service';
import { InventoryUnitConversionService } from '../application/inventory-unit-conversion.service';
import { DomainConflictError } from '../../../common/errors/domain-conflict.error';
@Injectable()
export class OrderConsumptionWorker {
constructor(
private readonly database: DatabaseService,
private readonly movements: StockMovementService,
private readonly units: InventoryUnitConversionService,
) {}
async consumeOrder(input: {
organizationId: string;
locationId: string;
orderId: string;
businessDate: string;
}) {
const items = await this.database.query<any>(
`select
oi.id as order_item_id,
oi.quantity::text as order_quantity,
p.recipe_id
from order_items oi
join orders o on o.id=oi.order_id
join products p on p.id=oi.product_id
where o.id=$1
and o.organization_id=$2
and o.location_id=$3
and o.status='submitted'
and oi.status in ('active','comped','remake')`,
[input.orderId, input.organizationId, input.locationId],
);
let movementsCreated = 0;
for (const item of items) {
if (item.recipe_id) {
movementsCreated += await this.consumeRecipe({
...input,
recipeId: item.recipe_id,
sourceOrderItemId: item.order_item_id,
orderQuantity: item.order_quantity,
generation: 1,
reason: 'order_submitted',
});
}
const modifiers = await this.database.query<any>(
`select m.recipe_id, oim.quantity::text as modifier_quantity
from order_item_modifiers oim
join modifiers m on m.id=oim.modifier_id
where oim.order_item_id=$1
and m.recipe_id is not null`,
[item.order_item_id],
);
let generation = 1000;
for (const modifier of modifiers) {
movementsCreated += await this.consumeRecipe({
...input,
recipeId: modifier.recipe_id,
sourceOrderItemId: item.order_item_id,
orderQuantity: modifier.modifier_quantity,
generation: generation++,
reason: 'modifier_consumption',
});
}
}
return { movementsCreated };
}
private async consumeRecipe(input: {
organizationId: string;
locationId: string;
orderId: string;
businessDate: string;
recipeId: string;
sourceOrderItemId: string;
orderQuantity: string;
generation: number;
reason: string;
}): Promise<number> {
const versions = await this.database.query<any>(
`select id,yield_quantity::text
from recipe_versions
where recipe_id=$1 and status='published'
order by published_at desc nulls last,version_number desc
limit 1`,
[input.recipeId],
);
const version = versions[0];
if (!version) return 0;
const leaves = await this.database.query<any>(
`with recursive expanded as (
select
rc.ingredient_id,
rc.subrecipe_id,
rc.quantity::numeric as leaf_quantity,
rc.unit_code,
1::numeric as path_multiplier
from recipe_components rc
where rc.recipe_version_id=$1
union all
select
child.ingredient_id,
child.subrecipe_id,
child.quantity::numeric,
child.unit_code,
expanded.path_multiplier
* expanded.leaf_quantity
/ nullif(subv.yield_quantity,0)
from expanded
join recipes subr on subr.id=expanded.subrecipe_id
join recipe_versions subv
on subv.recipe_id=subr.id and subv.status='published'
join recipe_components child
on child.recipe_version_id=subv.id
where expanded.subrecipe_id is not null
)
select
ingredient_id,
leaf_quantity::text,
unit_code,
path_multiplier::text
from expanded
where ingredient_id is not null`,
[version.id],
);
let created = 0;
for (const leaf of leaves) {
const stock = await this.database.query<any>(
`select
si.id as stock_item_id,
si.base_unit_code,
sil.default_storage_location_id
from stock_items si
join stock_item_locations sil
on sil.stock_item_id=si.id
and sil.location_id=$2
and sil.is_tracked=true
where si.organization_id=$3
and si.ingredient_id=$1
and si.is_active=true
limit 2`,
[leaf.ingredient_id, input.locationId, input.organizationId],
);
if (stock.length !== 1 || !stock[0].default_storage_location_id) {
throw new DomainConflictError('inventory.storage_required', {
ingredientId: leaf.ingredient_id,
});
}
const perRecipeLeaf = await this.units.multiplyDivide({
value: leaf.leaf_quantity,
multiplier: leaf.path_multiplier,
divisor: version.yield_quantity,
});
const requiredInRecipeUnit = await this.units.multiplyDivide({
value: perRecipeLeaf,
multiplier: input.orderQuantity,
divisor: '1',
});
const normalized = await this.units.convert({
quantity: requiredInRecipeUnit,
fromUnitCode: leaf.unit_code,
toUnitCode: stock[0].base_unit_code,
});
try {
await this.movements.append({
organizationId: input.organizationId,
locationId: input.locationId,
stockItemId: stock[0].stock_item_id,
storageLocationId: stock[0].default_storage_location_id,
movementType: 'sale_consumption',
quantityDelta: `-${requiredInRecipeUnit}`,
unitCode: leaf.unit_code,
normalizedQuantityDelta: `-${normalized}`,
normalizedUnitCode: stock[0].base_unit_code,
sourceType: 'order_item',
sourceId: input.sourceOrderItemId,
consumptionGeneration: input.generation,
businessDate: input.businessDate,
reason: input.reason,
});
created++;
} catch (error: any) {
if (String(error?.message ?? error).toLowerCase().includes('duplicate')) continue;
throw error;
}
}
return created;
}
}
