ДОМЕНЫ - ВОПРОСЫ - СКЛАД - Протянуть exact capability складских остатков товаров на дату

This commit is contained in:
2026-04-13 20:40:24 +03:00
parent c2ac0c610b
commit 2b48229312
27 changed files with 1781 additions and 95 deletions
@@ -12,6 +12,10 @@ export interface ComposeStageRow {
account_kt: string | null;
amount: number | null;
analytics: string[];
quantity?: number | null;
item?: string | null;
warehouse?: string | null;
organization?: string | null;
}
export interface VatDirectSourceProbeItem {
@@ -750,6 +754,162 @@ function extractCounterpartyName(row: ComposeStageRow): string | null {
return null;
}
function extractInventoryItemName(row: ComposeStageRow): string | null {
const direct = String(row.item ?? "").trim();
if (direct) {
return direct;
}
for (const token of row.analytics) {
const normalized = String(token ?? "").trim();
if (!normalized) {
continue;
}
if (/^(?:0|<пусто>|пустая ссылка)$/iu.test(normalized)) {
continue;
}
if (/(?:склад|warehouse|ооо|ао|пао|зао|ип|организац)/iu.test(normalized)) {
continue;
}
return normalized;
}
return null;
}
function extractInventoryWarehouseName(row: ComposeStageRow): string | null {
const direct = String(row.warehouse ?? "").trim();
if (direct) {
return direct;
}
for (const token of row.analytics) {
const normalized = String(token ?? "").trim();
if (/(?:склад|warehouse)/iu.test(normalized)) {
return normalized;
}
}
return null;
}
function extractInventoryOrganizationName(row: ComposeStageRow): string | null {
const direct = String(row.organization ?? "").trim();
if (direct) {
return direct;
}
for (const token of row.analytics) {
const normalized = String(token ?? "").trim();
if (!normalized) {
continue;
}
if (/(?:(?:^|[\s"'«»„“()\\\/])(?:ооо|ао|пао|зао|оао|ип|гку)(?=$|[\s"'«»„“()\\\/.,;:]))|организац|комитет|департамент|министерств|служб|управлени|казенн|администрац/iu.test(normalized)) {
return normalized;
}
}
return null;
}
function extractInventoryQuantity(row: ComposeStageRow): number | null {
return typeof row.quantity === "number" && Number.isFinite(row.quantity) ? row.quantity : null;
}
interface InventoryOnHandAggregate {
item: string;
warehouse: string | null;
organization: string | null;
quantity: number;
amount: number;
operations: number;
firstPeriod: string | null;
lastPeriod: string | null;
sourceRefs: string[];
}
function buildInventoryOnHandAggregate(rows: ComposeStageRow[], asOfDate: string): InventoryOnHandAggregate[] {
const byPosition = new Map<
string,
{
item: string;
warehouse: string | null;
organization: string | null;
quantity: number;
amount: number;
operations: number;
firstPeriod: string | null;
lastPeriod: string | null;
sourceRefs: Set<string>;
}
>();
const asOfTimestamp = toUtcDayTimestamp(asOfDate);
for (const row of rows) {
const item = extractInventoryItemName(row);
if (!item) {
continue;
}
const rowTimestamp = toUtcDayTimestamp(row.period);
if (asOfTimestamp !== null && rowTimestamp !== null && rowTimestamp > asOfTimestamp) {
continue;
}
const quantity = extractInventoryQuantity(row);
if (quantity === null || quantity <= 0) {
continue;
}
const warehouse = extractInventoryWarehouseName(row);
const organization = extractInventoryOrganizationName(row);
const amount = typeof row.amount === "number" && Number.isFinite(row.amount) ? row.amount : 0;
const key = [normalizeEntityToken(item), normalizeEntityToken(warehouse), normalizeEntityToken(organization)].join("|");
const registrator = String(row.registrator ?? "").trim();
const current = byPosition.get(key);
if (!current) {
byPosition.set(key, {
item,
warehouse,
organization,
quantity,
amount,
operations: 1,
firstPeriod: row.period,
lastPeriod: row.period,
sourceRefs: new Set(registrator && registrator !== "Остатки на дату" ? [registrator] : [])
});
continue;
}
current.quantity += quantity;
current.amount += amount;
current.operations += 1;
if ((row.period ?? "") < (current.firstPeriod ?? "")) {
current.firstPeriod = row.period;
}
if ((row.period ?? "") > (current.lastPeriod ?? "")) {
current.lastPeriod = row.period;
}
if (registrator && registrator !== "Остатки на дату") {
current.sourceRefs.add(registrator);
}
}
return Array.from(byPosition.values())
.map((item) => ({
item: item.item,
warehouse: item.warehouse,
organization: item.organization,
quantity: item.quantity,
amount: item.amount,
operations: item.operations,
firstPeriod: item.firstPeriod,
lastPeriod: item.lastPeriod,
sourceRefs: Array.from(item.sourceRefs).slice(0, 3)
}))
.filter((item) => item.quantity > 0)
.sort((left, right) => {
if (right.quantity !== left.quantity) {
return right.quantity - left.quantity;
}
if (right.amount !== left.amount) {
return right.amount - left.amount;
}
return left.item.localeCompare(right.item, "ru");
});
}
interface CounterpartyRiskAggregate {
name: string;
totalAmount: number;
@@ -3502,6 +3662,63 @@ export function composeFactualReply(
};
}
if (intent === "inventory_on_hand_as_of_date") {
const asOfDate = resolvePayablesAsOfDate(options);
const positions = buildInventoryOnHandAggregate(rows, asOfDate);
const uniqueItems = uniqueStrings(positions.map((item) => item.item));
const uniqueWarehouses = uniqueStrings(
positions.map((item) => String(item.warehouse ?? "").trim()).filter((item) => item.length > 0)
);
const totalQuantity = positions.reduce((sum, item) => sum + item.quantity, 0);
const totalAmount = positions.reduce((sum, item) => sum + item.amount, 0);
const lines: string[] = [
`Собран подтвержденный срез товаров на складах на ${formatDateRu(asOfDate)}.`,
"",
"Блок 1. Статус результата",
"- Результат: подтвержденный список товарных остатков на дату.",
"",
"Блок 2. Что учтено",
`- Дата среза: ${formatDateRu(asOfDate)}.`,
"- Контур: остатки по счету 41.01 «Товары на складах».",
"- Базовая единица детализации: одна строка = товар, склад и организация на дату.",
"",
"Блок 3. Сводка",
`- Строк в выборке: ${formatNumberWithDots(rows.length)}.`,
`- Позиции с ненулевым остатком: ${formatNumberWithDots(positions.length)}.`,
`- Уникальных товаров: ${formatNumberWithDots(uniqueItems.length)}.`,
`- Уникальных складов: ${formatNumberWithDots(uniqueWarehouses.length)}.`,
`- Суммарное количество: ${formatNumberWithDots(totalQuantity, 3)}.`,
`- Суммарная стоимость: ${formatMoneyRub(totalAmount)}.`,
"",
"Блок 4. Подтвержденные позиции"
];
if (positions.length > 0) {
lines.push(
...positions.slice(0, 20).map((item, index) => {
const warehouseLabel = item.warehouse ?? "склад не определен";
const organizationLabel = item.organization ? ` | организация: ${item.organization}` : "";
const periodLabel = item.lastPeriod ? ` | дата строки: ${item.lastPeriod}` : "";
const refsLabel = item.sourceRefs.length > 0 ? ` | source refs: ${item.sourceRefs.slice(0, 2).join("; ")}` : "";
return `${index + 1}. ${item.item} | склад: ${warehouseLabel} | количество: ${formatNumberWithDots(item.quantity, 3)} | стоимость: ${formatMoneyRub(item.amount)}${organizationLabel}${periodLabel}${refsLabel}`;
})
);
} else {
lines.push("- На дату среза товары с ненулевым остатком по счету 41.01 не найдены.");
}
return {
responseType: positions.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY",
text: joinLines(lines),
semantics: {
result_mode: "confirmed_balance",
evidence_strength: positions.length > 0 ? "strong" : "medium",
balance_confirmed: true
}
};
}
if (intent === "open_contracts_confirmed_as_of_date") {
const asOfDate = resolvePayablesAsOfDate(options);
const confirmedContracts = buildOpenContractConfirmedBalanceAggregate(rows, asOfDate);
@@ -367,13 +367,22 @@ function mergeFollowupFilters(
const merged: AddressFilterSet = { ...current };
const reasons: string[] = [];
if (!followupContext) {
if ((intent === "list_open_contracts" || intent === "open_contracts_confirmed_as_of_date") && !toNonEmptyString(merged.as_of_date)) {
if (
(intent === "list_open_contracts" ||
intent === "open_contracts_confirmed_as_of_date" ||
intent === "inventory_on_hand_as_of_date") &&
!toNonEmptyString(merged.as_of_date)
) {
const periodToForOpenContracts = toNonEmptyString(merged.period_to);
const periodFromForOpenContracts = toNonEmptyString(merged.period_from);
const derivedAsOfDate = periodToForOpenContracts ?? periodFromForOpenContracts;
if (derivedAsOfDate) {
merged.as_of_date = derivedAsOfDate;
reasons.push("as_of_date_derived_from_period_for_open_contracts");
reasons.push(
intent === "inventory_on_hand_as_of_date"
? "as_of_date_derived_from_period_for_inventory"
: "as_of_date_derived_from_period_for_open_contracts"
);
}
}
return { filters: merged, reasons };
@@ -480,6 +489,7 @@ function mergeFollowupFilters(
intent === "open_items_by_counterparty_or_contract" ||
intent === "list_open_contracts" ||
intent === "open_contracts_confirmed_as_of_date" ||
intent === "inventory_on_hand_as_of_date" ||
intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date" ||
intent === "vat_payable_confirmed_as_of_date"
@@ -561,6 +571,7 @@ function mergeFollowupFilters(
intent === "account_balance_snapshot" ||
intent === "documents_forming_balance" ||
intent === "open_contracts_confirmed_as_of_date" ||
intent === "inventory_on_hand_as_of_date" ||
intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date" ||
intent === "vat_payable_confirmed_as_of_date";
@@ -598,13 +609,22 @@ function mergeFollowupFilters(
reasons.push("period_from_followup_context");
}
if ((intent === "list_open_contracts" || intent === "open_contracts_confirmed_as_of_date") && !toNonEmptyString(merged.as_of_date)) {
if (
(intent === "list_open_contracts" ||
intent === "open_contracts_confirmed_as_of_date" ||
intent === "inventory_on_hand_as_of_date") &&
!toNonEmptyString(merged.as_of_date)
) {
const periodToForOpenContracts = toNonEmptyString(merged.period_to);
const periodFromForOpenContracts = toNonEmptyString(merged.period_from);
const derivedAsOfDate = periodToForOpenContracts ?? periodFromForOpenContracts;
if (derivedAsOfDate) {
merged.as_of_date = derivedAsOfDate;
reasons.push("as_of_date_derived_from_period_for_open_contracts");
reasons.push(
intent === "inventory_on_hand_as_of_date"
? "as_of_date_derived_from_period_for_inventory"
: "as_of_date_derived_from_period_for_open_contracts"
);
}
}
@@ -615,6 +635,7 @@ function resolveMissingRequiredFilters(intent: AddressIntent, filters: AddressFi
const requiredByIntent: Record<string, Array<keyof AddressFilterSet>> = {
account_balance_snapshot: ["account", "as_of_date"],
documents_forming_balance: ["account", "as_of_date"],
inventory_on_hand_as_of_date: ["as_of_date"],
open_contracts_confirmed_as_of_date: ["as_of_date"],
payables_confirmed_as_of_date: ["as_of_date"],
receivables_confirmed_as_of_date: ["as_of_date"],