import { Injectable } from '@nestjs/common';
import { InventoryReadModelService } from '../../inventory/read-models/inventory-read-model.service';
import { RecipeWorkspaceService } from '../../recipes/read-models/recipe-workspace.service';
import { RecipeIngredientRequirementsService } from '../../recipes/read-models/recipe-ingredient-requirements.service';
import { CostingReadModelService } from '../../costing/read-models/costing-read-model.service';
import { DatabaseService } from '../../../infrastructure/database/database.service';
export interface RecipeCandidate {
recipeId: string;
name: string;
yieldQuantity: string;
yieldUnitCode: string;
possibleYieldMultiplier: string | null;
possibleYieldQuantity: string | null;
cost: {
totalCostMinor: string | null;
costPerYieldUnitMinor: string | null;
currencyCode: string | null;
qualityState: string | null;
calculatedAt: string | null;
staleAt: string | null;
};
missingIngredients: Array<{
ingredientId: string;
ingredientName: string;
reason: 'no_stock_mapping' | 'incompatible_unit' | 'insufficient_stock';
}>;
}
@Injectable()
export class RecipeCandidatesService {
constructor(
private readonly inventoryReadModel: InventoryReadModelService,
private readonly recipeWorkspace: RecipeWorkspaceService,
private readonly requirements: RecipeIngredientRequirementsService,
private readonly costingReadModel: CostingReadModelService,
private readonly database: DatabaseService,
) {}
async find(input: {
organizationId: string;
locationId: string;
locale: string;
}): Promise<RecipeCandidate[]> {
const [recipes, stock] = await Promise.all([
this.recipeWorkspace.publishedRecipes(
input.organizationId,
input.locale,
),
this.inventoryReadModel.stock({
organizationId: input.organizationId,
locationId: input.locationId,
}),
]);
const stockByIngredient = new Map<string, any[]>();
for (const item of stock) {
if (!item.ingredientId) continue;
const items = stockByIngredient.get(item.ingredientId) ?? [];
items.push(item);
stockByIngredient.set(item.ingredientId, items);
}
const candidates: RecipeCandidate[] = [];
for (const recipe of recipes) {
const [requirements, recipeCost] = await Promise.all([
this.requirements.forPublishedRecipe({
organizationId: input.organizationId,
recipeId: recipe.id,
}),
this.costingReadModel.recipeCost({
organizationId: input.organizationId,
locationId: input.locationId,
recipeId: recipe.id,
}),
]);
const missingIngredients: RecipeCandidate['missingIngredients'] = [];
const ratios: string[] = [];
for (const requirement of requirements) {
const mappedStock =
stockByIngredient.get(requirement.ingredientId) ?? [];
if (mappedStock.length === 0) {
missingIngredients.push({
ingredientId: requirement.ingredientId,
ingredientName: requirement.ingredientName,
reason: 'no_stock_mapping',
});
continue;
}
const balances = mappedStock.map((item) => String(item.balance));
const unitCodes = mappedStock.map((item) => String(item.unitCode));
const rows = await this.database.query<{
compatible_count: number;
available_quantity: string;
ratio: string | null;
}>(
`with stock(balance, unit_code) as (
select *
from unnest($2::text[], $3::text[])
),
converted as (
select
stock.balance::numeric
* source_unit.factor_to_base::numeric
/ nullif(target_unit.factor_to_base::numeric,0)
as quantity
from stock
join units source_unit
on source_unit.code=stock.unit_code
join units target_unit
on target_unit.code=$4
and target_unit.dimension=source_unit.dimension
)
select
count(*)::int as compatible_count,
coalesce(sum(quantity),0)::numeric(30,12)::text
as available_quantity,
case
when coalesce(sum(quantity),0) <= 0 then null
else (
coalesce(sum(quantity),0)
/ nullif($1::numeric,0)
)::numeric(30,12)::text
end as ratio
from converted`,
[
requirement.effectiveQuantity,
balances,
unitCodes,
requirement.unitCode,
],
);
const result = rows[0];
if (!result || result.compatible_count === 0) {
missingIngredients.push({
ingredientId: requirement.ingredientId,
ingredientName: requirement.ingredientName,
reason: 'incompatible_unit',
});
continue;
}
if (result.ratio == null) {
missingIngredients.push({
ingredientId: requirement.ingredientId,
ingredientName: requirement.ingredientName,
reason: 'insufficient_stock',
});
continue;
}
ratios.push(result.ratio);
}
let possibleYieldMultiplier: string | null = null;
let possibleYieldQuantity: string | null = null;
if (
requirements.length > 0 &&
missingIngredients.length === 0 &&
ratios.length === requirements.length
) {
const rows = await this.database.query<{
multiplier: string;
quantity: string;
}>(
`select
least(variadic $1::numeric[])::numeric(30,12)::text
as multiplier,
(
least(variadic $1::numeric[])
* $2::numeric
)::numeric(30,12)::text
as quantity`,
[ratios, recipe.yield_quantity],
);
possibleYieldMultiplier = rows[0]?.multiplier ?? null;
possibleYieldQuantity = rows[0]?.quantity ?? null;
}
candidates.push({
recipeId: recipe.id,
name: recipe.name,
yieldQuantity: recipe.yield_quantity,
yieldUnitCode: recipe.yield_unit_code,
possibleYieldMultiplier,
possibleYieldQuantity,
cost: {
totalCostMinor: recipeCost?.total_cost_minor != null
? String(recipeCost.total_cost_minor)
: null,
costPerYieldUnitMinor: recipeCost?.cost_per_yield_unit_minor != null
? String(recipeCost.cost_per_yield_unit_minor)
: null,
currencyCode: recipeCost?.currency_code ?? null,
qualityState: recipeCost?.quality_state ?? null,
calculatedAt: recipeCost?.calculated_at ?? null,
staleAt: recipeCost?.stale_at ?? null,
},
missingIngredients,
});
}
return candidates;
}
}
