import { afterEach, describe, expect, it, vi } from "vitest"; const { executeAddressMcpQueryMock } = vi.hoisted(() => ({ executeAddressMcpQueryMock: vi.fn() })); vi.mock("../src/services/addressMcpClient", async () => { const actual = await vi.importActual( "../src/services/addressMcpClient" ); return { ...actual, executeAddressMcpQuery: executeAddressMcpQueryMock }; }); import { AddressQueryService } from "../src/services/addressQueryService"; import { runAddressDecomposeStage } from "../src/services/address_runtime/decomposeStage"; afterEach(() => { executeAddressMcpQueryMock.mockReset(); vi.restoreAllMocks(); }); describe("inventory profitability selected-object regressions", () => { const followupContext = { previous_intent: "inventory_on_hand_as_of_date" as const, previous_filters: { organization: "ООО \\Альтернатива Плюс\\", as_of_date: "2020-05-31", period_from: "2020-05-01", period_to: "2020-05-31" }, previous_anchor_type: "unknown" as const, previous_anchor_value: null }; const selectedObjectProfitabilityMessage = 'По выбранному объекту "Четки Пост (84*117)": а сколько денег мы заработали с продажжи этих четок'; it("routes selected-object profitability wording into an item profitability intent", () => { const result = runAddressDecomposeStage(selectedObjectProfitabilityMessage, followupContext); expect(result).not.toBeNull(); expect(result?.intent.intent).toBe("inventory_profitability_for_item"); expect(result?.intent.intent).not.toBe("customer_revenue_and_payments"); expect(result?.filters.extracted_filters.item).toBe("Четки Пост (84*117)"); expect(result?.filters.extracted_filters.period_from).toBe("2020-05-01"); expect(result?.filters.extracted_filters.period_to).toBe("2020-05-31"); }); it("answers selected-object profitability with a bounded document spread instead of a recipe gap", async () => { executeAddressMcpQueryMock.mockResolvedValueOnce({ fetched_rows: 2, matched_rows: 2, raw_rows: [ { Period: "2020-05-10T00:00:00Z", Registrator: "Поступление товаров и услуг 000000001 от 10.05.2020", AccountDt: "41.01", AccountKt: "60.01", Amount: 500, Quantity: 10, SubcontoDt1: "Четки Пост (84*117)", SubcontoDt3: "Основной склад", Counterparty: "ООО \\Поставщик\\", Organization: "ООО \\Альтернатива Плюс\\" }, { Period: "2020-05-20T00:00:00Z", Registrator: "Реализация товаров и услуг 000000017 от 20.05.2020", AccountDt: "62.01", AccountKt: "41.01", Amount: 900, Quantity: 10, SubcontoKt1: "Четки Пост (84*117)", SubcontoKt3: "Основной склад", Counterparty: "ИП Покупатель", Organization: "ООО \\Альтернатива Плюс\\" } ], rows: [], error: null }); const service = new AddressQueryService(); const result = await service.tryHandle(selectedObjectProfitabilityMessage, { followupContext }); expect(result?.handled).toBe(true); expect(result?.response_type).toBe("FACTUAL_SUMMARY"); expect(result?.debug.detected_intent).toBe("inventory_profitability_for_item"); expect(result?.debug.selected_recipe).toBe("address_inventory_profitability_for_item_v1"); expect(result?.debug.capability_id).toBe("inventory_inventory_profitability_for_item"); expect(result?.debug.mcp_call_status).toBe("matched_non_empty"); expect(result?.debug.extracted_filters?.item).toBe("Четки Пост (84*117)"); const reply = String(result?.reply_text ?? ""); expect(reply).toContain("Четки Пост (84*117)"); expect(reply).toContain("900"); expect(reply).toContain("500"); expect(reply).toContain("400"); expect(reply).toContain("Маржинальность"); expect(reply).toContain("не чистая прибыль компании"); expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1); }); it("asks for a period before ranking nomenclature by margin", async () => { const service = new AddressQueryService(); const result = await service.tryHandle( "\u041a\u0430\u043a\u0430\u044f \u043d\u043e\u043c\u0435\u043a\u043b\u0430\u0442\u0443\u0440\u0430 \u0442\u043e\u0432\u0430\u0440\u0430 \u0440\u0435\u0430\u043b\u0438\u0437\u043e\u0432\u0430\u043d\u0430 \u0441 \u0432\u044b\u0441\u043e\u043a\u043e\u0439 \u043f\u0440\u0438\u0431\u044b\u043b\u044c\u044e \u043a\u0430\u043a\u0430\u044f \u0441 \u043d\u0438\u0437\u043a\u043e\u0439" ); expect(result?.handled).toBe(true); expect(result?.response_type).toBe("LIMITED_WITH_REASON"); expect(result?.debug.detected_intent).toBe("inventory_margin_ranking_for_nomenclature"); expect(result?.debug.selected_recipe).toBe("address_inventory_margin_ranking_for_nomenclature_v1"); expect(result?.debug.capability_id).toBe("inventory_inventory_margin_ranking_for_nomenclature"); expect(result?.debug.missing_required_filters).toEqual(["period_from", "period_to"]); const reply = String(result?.reply_text ?? ""); expect(reply).toContain("\u043d\u0443\u0436\u0435\u043d \u043f\u0435\u0440\u0438\u043e\u0434"); expect(reply).toContain("\u0441\u0435\u0431\u0435\u0441\u0442\u043e\u0438\u043c\u043e\u0441\u0442\u044c \u0440\u0435\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438"); expect(reply).not.toContain("\u041e\u0421"); expect(reply).not.toContain("\u0430\u043c\u043e\u0440\u0442\u0438\u0437"); expect(reply).not.toContain("\u0437\u0430\u043a\u0443\u043f\u043e\u0447\u043d\u044b\u0439/\u0441\u0435\u0431\u0435\u0441\u0442\u043e\u0438\u043c\u043e\u0441\u0442\u043d\u044b\u0439 \u0441\u043b\u0435\u0434"); expect(reply).not.toContain("settlement"); expect(executeAddressMcpQueryMock).not.toHaveBeenCalled(); }); it("gives a useful accounting limited answer when sales exist but cost is missing", async () => { executeAddressMcpQueryMock.mockResolvedValueOnce({ fetched_rows: 1, matched_rows: 1, raw_rows: [ { Period: "2020-05-20T00:00:00Z", Registrator: "Sales document 1", AccountDt: "62.01", AccountKt: "41.01", Amount: 1500, Quantity: 10, SubcontoKt1: "Item A", Organization: "OOO Alternative Plus" } ], rows: [], error: null }); const service = new AddressQueryService(); const result = await service.tryHandle( "\u041a\u0430\u043a\u0430\u044f \u043d\u043e\u043c\u0435\u043a\u043b\u0430\u0442\u0443\u0440\u0430 \u0442\u043e\u0432\u0430\u0440\u0430 \u0440\u0435\u0430\u043b\u0438\u0437\u043e\u0432\u0430\u043d\u0430 \u0441 \u0432\u044b\u0441\u043e\u043a\u043e\u0439 \u043f\u0440\u0438\u0431\u044b\u043b\u044c\u044e \u043a\u0430\u043a\u0430\u044f \u0441 \u043d\u0438\u0437\u043a\u043e\u0439 \u0437\u0430 \u043c\u0430\u0439 2020" ); expect(result?.handled).toBe(true); expect(result?.response_type).toBe("FACTUAL_SUMMARY"); expect(result?.debug.detected_intent).toBe("inventory_margin_ranking_for_nomenclature"); expect(result?.debug.mcp_call_status).toBe("matched_non_empty"); const reply = String(result?.reply_text ?? ""); expect(reply).toContain("\u0440\u0435\u0439\u0442\u0438\u043d\u0433 \u043f\u0440\u0438\u0431\u044b\u043b\u044c\u043d\u043e\u0441\u0442\u0438 \u043d\u043e\u043c\u0435\u043d\u043a\u043b\u0430\u0442\u0443\u0440\u044b \u043f\u043e\u0441\u0442\u0440\u043e\u0438\u0442\u044c \u043d\u0435\u043b\u044c\u0437\u044f"); expect(reply).toContain("\u0415\u0441\u0442\u044c \u0440\u0435\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u044f"); expect(reply).toContain("\u0441\u0435\u0431\u0435\u0441\u0442\u043e\u0438\u043c\u043e\u0441\u0442\u0438 \u0440\u0435\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438"); expect(reply).toContain("\u0447\u0435\u0441\u0442\u043d\u043e \u043f\u043e\u0441\u0447\u0438\u0442\u0430\u0442\u044c \u043d\u0435\u043b\u044c\u0437\u044f"); expect(reply).toContain("\u0427\u0442\u043e \u043c\u043e\u0436\u043d\u043e \u0441\u0434\u0435\u043b\u0430\u0442\u044c \u0434\u0430\u043b\u044c\u0448\u0435"); expect(reply).toContain("90.01 / 90.02"); expect(reply).not.toContain("\u041e\u0421"); expect(reply).not.toContain("\u0430\u043c\u043e\u0440\u0442\u0438\u0437"); expect(reply).not.toContain("\u0437\u0430\u043a\u0443\u043f\u043e\u0447\u043d\u044b\u0439/\u0441\u0435\u0431\u0435\u0441\u0442\u043e\u0438\u043c\u043e\u0441\u0442\u043d\u044b\u0439 \u0441\u043b\u0435\u0434"); expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1); }); it("does not offer to show realizations when only cost base exists in the period", async () => { executeAddressMcpQueryMock.mockResolvedValueOnce({ fetched_rows: 1, matched_rows: 1, raw_rows: [ { Period: "2017-09-12T00:00:00Z", Registrator: "Purchase document 1", AccountDt: "41.01", AccountKt: "60.01", Amount: 700, Quantity: 2, SubcontoDt1: "Item C", Organization: "OOO Alternative Plus" } ], rows: [], error: null }); const service = new AddressQueryService(); const result = await service.tryHandle( "\u041a\u0430\u043a\u0430\u044f \u043d\u043e\u043c\u0435\u043a\u043b\u0430\u0442\u0443\u0440\u0430 \u0442\u043e\u0432\u0430\u0440\u0430 \u0440\u0435\u0430\u043b\u0438\u0437\u043e\u0432\u0430\u043d\u0430 \u0441 \u0432\u044b\u0441\u043e\u043a\u043e\u0439 \u043f\u0440\u0438\u0431\u044b\u043b\u044c\u044e \u043a\u0430\u043a\u0430\u044f \u0441 \u043d\u0438\u0437\u043a\u043e\u0439 \u0437\u0430 \u0441\u0435\u043d\u0442\u044f\u0431\u0440\u044c 2017" ); expect(result?.handled).toBe(true); expect(result?.response_type).toBe("FACTUAL_SUMMARY"); const reply = String(result?.reply_text ?? ""); expect(reply).toContain("\u0441\u0435\u0431\u0435\u0441\u0442\u043e\u0438\u043c\u043e\u0441\u0442\u043d\u0430\u044f \u0431\u0430\u0437\u0430"); expect(reply).toContain("\u0440\u0435\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438 \u043f\u043e \u043d\u0435\u0439 \u0432 \u043f\u0435\u0440\u0438\u043e\u0434\u0435 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u043e"); expect(reply).toContain("\u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c \u043d\u0430\u0439\u0434\u0435\u043d\u043d\u044b\u0435 \u0441\u0442\u0440\u043e\u043a\u0438 \u0441\u0435\u0431\u0435\u0441\u0442\u043e\u0438\u043c\u043e\u0441\u0442\u043d\u043e\u0439 \u0431\u0430\u0437\u044b"); expect(reply).not.toContain("\u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c \u043d\u0430\u0439\u0434\u0435\u043d\u043d\u044b\u0435 \u0440\u0435\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438"); expect(reply).not.toContain("\u0441\u0435\u0431\u0435\u0441\u0442\u043e\u0438\u043c\u043e\u0441\u0442\u043d\u0430\u044f/\u0437\u0430\u043a\u0443\u043f\u043e\u0447\u043d\u0430\u044f"); expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1); }); it("keeps cost-base line drilldown inside the nomenclature margin route", () => { const marginFollowupContext = { previous_intent: "inventory_margin_ranking_for_nomenclature" as const, target_intent: "inventory_margin_ranking_for_nomenclature" as const, root_intent: "inventory_margin_ranking_for_nomenclature" as const, previous_filters: { organization: "OOO Alternative Plus", period_from: "2017-09-01", period_to: "2017-09-30" }, previous_anchor_type: "unknown" as const, previous_anchor_value: null }; const result = runAddressDecomposeStage( "\u043f\u043e\u043a\u0430\u0436\u0438 \u043d\u0430\u0439\u0434\u0435\u043d\u043d\u044b\u0435 \u0441\u0442\u0440\u043e\u043a\u0438 \u0441\u0435\u0431\u0435\u0441\u0442\u043e\u0438\u043c\u043e\u0441\u0442\u043d\u043e\u0439 \u0431\u0430\u0437\u044b", marginFollowupContext ); expect(result).not.toBeNull(); expect(result?.intent.intent).toBe("inventory_margin_ranking_for_nomenclature"); expect(result?.filters.extracted_filters.period_from).toBe("2017-09-01"); expect(result?.filters.extracted_filters.period_to).toBe("2017-09-30"); expect(result?.filters.missing_required_filters).toEqual([]); expect(result?.intent.reasons).toContain("intent_adjusted_to_inventory_margin_ranking_followup_context"); }); it("does not pivot margin follow-up account-41 correction into a balance snapshot", () => { const marginFollowupContext = { previous_intent: "inventory_margin_ranking_for_nomenclature" as const, target_intent: "inventory_margin_ranking_for_nomenclature" as const, root_intent: "inventory_margin_ranking_for_nomenclature" as const, previous_filters: { organization: "OOO Alternative Plus", period_from: "2017-09-01", period_to: "2017-09-30" }, previous_anchor_type: "unknown" as const, previous_anchor_value: null }; for (const message of [ "\u0430\u043d\u0430\u043b\u0438\u0437 \u043f\u043e 41 \u0441\u0447\u0435\u0442\u0443 \u0430 \u043d\u0435 01", "\u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u0430\u043d\u0430\u043b\u0438\u0437 \u043f\u043e \u0441\u0447\u0435\u0442\u0443 41 \u0432\u043c\u0435\u0441\u0442\u043e \u0441\u0447\u0435\u0442\u0430 01" ]) { const result = runAddressDecomposeStage(message, marginFollowupContext); expect(result).not.toBeNull(); expect(result?.intent.intent).toBe("inventory_margin_ranking_for_nomenclature"); expect(result?.intent.intent).not.toBe("account_balance_snapshot"); expect(result?.filters.extracted_filters.period_from).toBe("2017-09-01"); expect(result?.filters.extracted_filters.period_to).toBe("2017-09-30"); expect(result?.filters.missing_required_filters).toEqual([]); expect(result?.intent.reasons).toContain("intent_adjusted_to_inventory_margin_ranking_followup_context"); } }); it("keeps carried period when executing margin account-41 correction", async () => { executeAddressMcpQueryMock.mockResolvedValueOnce({ fetched_rows: 2, matched_rows: 2, raw_rows: [ { Period: "2017-02-10T00:00:00Z", Registrator: "Purchase document 2017", AccountDt: "41.01", AccountKt: "60.01", Amount: 500, Quantity: 1, SubcontoDt1: "Item A", Organization: "OOO Alternative Plus" }, { Period: "2017-03-10T00:00:00Z", Registrator: "Sales document 2017", AccountDt: "62.01", AccountKt: "41.01", Amount: 900, Quantity: 1, SubcontoKt1: "Item A", Organization: "OOO Alternative Plus" } ], rows: [], error: null }); const marginFollowupContext = { previous_intent: "inventory_margin_ranking_for_nomenclature" as const, target_intent: "inventory_margin_ranking_for_nomenclature" as const, root_intent: "inventory_margin_ranking_for_nomenclature" as const, previous_filters: { organization: "OOO Alternative Plus", period_from: "2017-01-01", period_to: "2017-12-31" }, previous_anchor_type: "organization" as const, previous_anchor_value: "OOO Alternative Plus" }; const service = new AddressQueryService(); const result = await service.tryHandle( "\u0430\u043d\u0430\u043b\u0438\u0437 \u043f\u043e 41 \u0441\u0447\u0435\u0442\u0443 \u0430 \u043d\u0435 01", { followupContext: marginFollowupContext } ); expect(result?.handled).toBe(true); expect(result?.debug.detected_intent).toBe("inventory_margin_ranking_for_nomenclature"); expect(result?.debug.selected_recipe).toBe("address_inventory_margin_ranking_for_nomenclature_v1"); expect(result?.debug.capability_id).toBe("inventory_inventory_margin_ranking_for_nomenclature"); expect(result?.debug.extracted_filters?.period_from).toBe("2017-01-01"); expect(result?.debug.extracted_filters?.period_to).toBe("2017-12-31"); expect(result?.debug.missing_required_filters).toEqual([]); expect(result?.debug.mcp_call_status).toBe("matched_non_empty"); expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1); }); it("answers period-scoped nomenclature margin ranking with high and low gross-margin buckets", async () => { executeAddressMcpQueryMock.mockResolvedValueOnce({ fetched_rows: 4, matched_rows: 4, raw_rows: [ { Period: "2020-01-10T00:00:00Z", Registrator: "Purchase document 1", AccountDt: "41.01", AccountKt: "60.01", Amount: 500, Quantity: 10, SubcontoDt1: "Item A", Organization: "OOO Alternative Plus" }, { Period: "2020-02-10T00:00:00Z", Registrator: "Sales document 1", AccountDt: "62.01", AccountKt: "41.01", Amount: 1500, Quantity: 10, SubcontoKt1: "Item A", Organization: "OOO Alternative Plus" }, { Period: "2020-03-10T00:00:00Z", Registrator: "Purchase document 2", AccountDt: "41.01", AccountKt: "60.01", Amount: 1000, Quantity: 5, SubcontoDt1: "Item B", Organization: "OOO Alternative Plus" }, { Period: "2020-04-10T00:00:00Z", Registrator: "Sales document 2", AccountDt: "62.01", AccountKt: "41.01", Amount: 900, Quantity: 5, SubcontoKt1: "Item B", Organization: "OOO Alternative Plus" } ], rows: [], error: null }); const service = new AddressQueryService(); const result = await service.tryHandle( "\u041a\u0430\u043a\u0430\u044f \u043d\u043e\u043c\u0435\u043a\u043b\u0430\u0442\u0443\u0440\u0430 \u0442\u043e\u0432\u0430\u0440\u0430 \u0440\u0435\u0430\u043b\u0438\u0437\u043e\u0432\u0430\u043d\u0430 \u0441 \u0432\u044b\u0441\u043e\u043a\u043e\u0439 \u043f\u0440\u0438\u0431\u044b\u043b\u044c\u044e \u043a\u0430\u043a\u0430\u044f \u0441 \u043d\u0438\u0437\u043a\u043e\u0439 \u0437\u0430 2020 \u0433\u043e\u0434" ); expect(result?.handled).toBe(true); expect(result?.response_type).toBe("FACTUAL_SUMMARY"); expect(result?.debug.detected_intent).toBe("inventory_margin_ranking_for_nomenclature"); expect(result?.debug.selected_recipe).toBe("address_inventory_margin_ranking_for_nomenclature_v1"); expect(result?.debug.capability_id).toBe("inventory_inventory_margin_ranking_for_nomenclature"); expect(result?.debug.mcp_call_status).toBe("matched_non_empty"); const reply = String(result?.reply_text ?? ""); expect(reply).toContain("\u0412\u044b\u0441\u043e\u043a\u0430\u044f \u0432\u0430\u043b\u043e\u0432\u0430\u044f \u043c\u0430\u0440\u0436\u0438\u043d\u0430\u043b\u044c\u043d\u043e\u0441\u0442\u044c"); expect(reply).toContain("\u041d\u0438\u0437\u043a\u0430\u044f \u0438\u043b\u0438 \u043e\u0442\u0440\u0438\u0446\u0430\u0442\u0435\u043b\u044c\u043d\u0430\u044f"); expect(reply).toContain("Item A"); expect(reply).toContain("Item B"); expect(reply).toContain("\u043d\u0435 \u0447\u0438\u0441\u0442\u0430\u044f \u043f\u0440\u0438\u0431\u044b\u043b\u044c \u043a\u043e\u043c\u043f\u0430\u043d\u0438\u0438"); expect(reply).not.toContain("\u041e\u0421"); expect(reply).not.toContain("\u0430\u043c\u043e\u0440\u0442\u0438\u0437"); expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1); }); });