@humanagencyp/erp-mcp 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -2
- package/dist/index.js +352 -96
- package/examples/company.example.json +13 -0
- package/examples/scenario.example.json +8 -0
- package/package.json +1 -1
- package/src/cli.ts +99 -0
- package/src/company.ts +115 -0
- package/src/db.ts +104 -59
- package/src/dispatch.ts +66 -0
- package/src/index.ts +12 -49
- package/src/mode.ts +26 -0
- package/test/simulation.test.ts +215 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simulation mode: the three-week test runs on this connector, its tools and its
|
|
3
|
+
* profile — only the system behind it is simulated. These tests pin what makes
|
|
4
|
+
* that test trustworthy:
|
|
5
|
+
*
|
|
6
|
+
* - live mode refuses loudly (no adapter in 0.x) and touches nothing — a go-live
|
|
7
|
+
* that is secretly still simulated, or a test that believes it is live, must
|
|
8
|
+
* not be silent;
|
|
9
|
+
* - the company file is loaded whole or refused whole;
|
|
10
|
+
* - a call refused after the gateway let it through is recorded with its
|
|
11
|
+
* receipt_id — the only trace that a ticket exists for an action that never
|
|
12
|
+
* happened;
|
|
13
|
+
* - request hand-over times are recorded, and the export lines up
|
|
14
|
+
* trigger → ticket (receipt_id) → effect.
|
|
15
|
+
*/
|
|
16
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
17
|
+
import { tmpdir } from "os";
|
|
18
|
+
import { join } from "path";
|
|
19
|
+
import { rmSync, writeFileSync } from "fs";
|
|
20
|
+
import { createDb, resolveCompany, DEMO_COMPANY, type Db } from "../src/db.js";
|
|
21
|
+
import { parseCompany } from "../src/company.js";
|
|
22
|
+
import { getMode, LIVE_NOT_AVAILABLE } from "../src/mode.js";
|
|
23
|
+
import { callTool } from "../src/dispatch.js";
|
|
24
|
+
import { loadScenario, nextRequest, exportRecord } from "../src/cli.js";
|
|
25
|
+
|
|
26
|
+
const tmp = (ext: string) => join(tmpdir(), `erp-sim-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`);
|
|
27
|
+
|
|
28
|
+
let dbPath: string;
|
|
29
|
+
let db: Db;
|
|
30
|
+
|
|
31
|
+
async function freshDb(company = DEMO_COMPANY) {
|
|
32
|
+
dbPath = tmp("db");
|
|
33
|
+
process.env.DATABASE_URL = dbPath;
|
|
34
|
+
db = await createDb(company);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
afterEach(async () => {
|
|
38
|
+
if (db) await db.close();
|
|
39
|
+
if (dbPath) rmSync(dbPath, { force: true });
|
|
40
|
+
db = undefined as unknown as Db;
|
|
41
|
+
dbPath = "";
|
|
42
|
+
delete process.env.ERP_COMPANY_FILE;
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const quote = (over: Record<string, unknown> = {}) => ({
|
|
46
|
+
customer_id: "cust-4", lines: [{ item_id: "item-7", qty: 1 }, { item_id: "item-1", qty: 1 }],
|
|
47
|
+
discount_pct: 0, value: 37, currency: "EUR", ...over,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("mode switch", () => {
|
|
51
|
+
it("defaults to simulation", () => {
|
|
52
|
+
expect(getMode({})).toBe("simulation");
|
|
53
|
+
expect(getMode({ ERP_MODE: "Simulation " })).toBe("simulation");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("refuses an unknown mode at start instead of guessing", () => {
|
|
57
|
+
expect(() => getMode({ ERP_MODE: "production" })).toThrow(/ERP_MODE must be one of simulation, live/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("live mode refuses every tool, reads included, and changes nothing", async () => {
|
|
61
|
+
await freshDb();
|
|
62
|
+
await expect(callTool(db, "live", "list_items", {})).rejects.toThrow(LIVE_NOT_AVAILABLE);
|
|
63
|
+
await expect(callTool(db, "live", "create_quote", { ...quote(), receipt_id: "t-1" })).rejects.toThrow(/live mode/);
|
|
64
|
+
expect(await db.all(`SELECT * FROM quotes`)).toHaveLength(0);
|
|
65
|
+
// Nothing local refused it — there is no local system in live mode — so nothing is recorded.
|
|
66
|
+
expect(await db.all(`SELECT * FROM refusals`)).toHaveLength(0);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("company file", () => {
|
|
71
|
+
const company = {
|
|
72
|
+
name: "Bergmann Ersatzteile GmbH", currency: "EUR",
|
|
73
|
+
items: [{ sku: "SP-100", name: "Hydraulic seal kit", list_price: 84.5, stock: 40 }],
|
|
74
|
+
customers: [{ name: "Huber Maschinenbau", country: "AT", credit_limit: 15000, open_balance: 2000 }],
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
it("seeds an empty database from the file", async () => {
|
|
78
|
+
const path = tmp("json");
|
|
79
|
+
writeFileSync(path, JSON.stringify(company));
|
|
80
|
+
process.env.ERP_COMPANY_FILE = path;
|
|
81
|
+
await freshDb(resolveCompany());
|
|
82
|
+
const items = (await callTool(db, "simulation", "list_items", {})) as any[];
|
|
83
|
+
expect(items).toEqual([expect.objectContaining({ id: "item-1", sku: "SP-100", list_price: 84.5, currency: "EUR", stock: 40 })]);
|
|
84
|
+
const cust = (await callTool(db, "simulation", "get_customer", { id: "cust-1" })) as any;
|
|
85
|
+
expect(cust).toMatchObject({ name: "Huber Maschinenbau", available_credit: 13000 });
|
|
86
|
+
rmSync(path, { force: true });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("the refusals still hold on seeded data — over the credit limit", async () => {
|
|
90
|
+
await freshDb(parseCompany(company));
|
|
91
|
+
const q = (await callTool(db, "simulation", "create_quote", {
|
|
92
|
+
customer_id: "cust-1", lines: [{ item_id: "item-1", qty: 40 }], discount_pct: 0, value: 3380, currency: "EUR",
|
|
93
|
+
})) as any;
|
|
94
|
+
await callTool(db, "simulation", "send_quote", { id: q.id, value: 3380, discount_pct: 0, currency: "EUR" });
|
|
95
|
+
// 2,000 open + 3,380 is within 15,000 — so drop the limit to prove the check reads the seeded row.
|
|
96
|
+
await db.run(`UPDATE customers SET credit_limit = 4000 WHERE id = 'cust-1'`);
|
|
97
|
+
await expect(callTool(db, "simulation", "convert_quote_to_order", { id: q.id, value: 3380, discount_pct: 0, currency: "EUR" }))
|
|
98
|
+
.rejects.toThrow(/credit/i);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it.each([
|
|
102
|
+
["a duplicate sku", { ...company, items: [company.items[0], company.items[0]] }, /sku "SP-100" appears twice/],
|
|
103
|
+
["a missing price", { ...company, items: [{ sku: "X", name: "X", stock: 1 }] }, /items\[0\]\.list_price/],
|
|
104
|
+
["fractional stock", { ...company, items: [{ sku: "X", name: "X", list_price: 1, stock: 1.5 }] }, /items\[0\]\.stock/],
|
|
105
|
+
["a bad currency", { ...company, currency: "euro" }, /currency/],
|
|
106
|
+
["no customers", { ...company, customers: [] }, /customers/],
|
|
107
|
+
])("refuses the whole file on %s", (_label, bad, msg) => {
|
|
108
|
+
expect(() => parseCompany(bad)).toThrow(msg);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("the shipped example company file is valid", () => {
|
|
112
|
+
const c = resolveCompany({ ERP_COMPANY_FILE: join(__dirname, "..", "examples", "company.example.json") });
|
|
113
|
+
expect(c.items.length).toBeGreaterThan(0);
|
|
114
|
+
expect(c.customers.length).toBeGreaterThan(0);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("the shipped example scenario is valid", () => {
|
|
118
|
+
expect(loadScenario(join(__dirname, "..", "examples", "scenario.example.json")).length).toBeGreaterThan(0);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("refuses an unreadable file at start rather than falling back to the demo", () => {
|
|
122
|
+
expect(() => resolveCompany({ ERP_COMPANY_FILE: tmp("json") })).toThrow(/cannot be read as JSON/);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("refusals after the gateway let a call through", () => {
|
|
127
|
+
beforeEach(() => freshDb());
|
|
128
|
+
|
|
129
|
+
it("records a false declared value with the ticket's receipt_id", async () => {
|
|
130
|
+
await expect(callTool(db, "simulation", "create_quote", { ...quote({ value: 20 }), receipt_id: "ticket-20eur" }))
|
|
131
|
+
.rejects.toThrow(/declared 20, expected 37/);
|
|
132
|
+
const rows = await db.all<any>(`SELECT * FROM refusals`);
|
|
133
|
+
expect(rows).toEqual([expect.objectContaining({ tool: "create_quote", receipt_id: "ticket-20eur" })]);
|
|
134
|
+
expect(rows[0].message).toMatch(/declared 20, expected 37/);
|
|
135
|
+
expect(await db.all(`SELECT * FROM quotes`)).toHaveLength(0);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("records a wrong state transition too (send of an unknown quote)", async () => {
|
|
139
|
+
await expect(callTool(db, "simulation", "send_quote", { id: "nope", value: 1, discount_pct: 0, currency: "EUR", receipt_id: "t-x" }))
|
|
140
|
+
.rejects.toThrow();
|
|
141
|
+
expect(await db.all<any>(`SELECT receipt_id FROM refusals`)).toEqual([{ receipt_id: "t-x" }]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("does not record a failed read — reads carry no ticket", async () => {
|
|
145
|
+
await expect(callTool(db, "simulation", "get_quote", { id: "nope" })).rejects.toThrow();
|
|
146
|
+
expect(await db.all(`SELECT * FROM refusals`)).toHaveLength(0);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("records a successful change as a change, not a refusal", async () => {
|
|
150
|
+
await callTool(db, "simulation", "create_quote", { ...quote(), receipt_id: "t-ok" });
|
|
151
|
+
expect(await db.all(`SELECT * FROM refusals`)).toHaveLength(0);
|
|
152
|
+
expect(await db.all<any>(`SELECT tool, receipt_id, document_number, status, net_total FROM changes`)).toEqual([
|
|
153
|
+
{ tool: "create_quote", receipt_id: "t-ok", document_number: "Q-0001", status: "draft", net_total: 37 },
|
|
154
|
+
]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("keeps one change per ticket even when several tickets act on the same document", async () => {
|
|
158
|
+
// The quote row only holds its latest receipt_id (send overwrites create). The
|
|
159
|
+
// change record must still show both tickets — otherwise create's ticket has no trace.
|
|
160
|
+
const q = (await callTool(db, "simulation", "create_quote", { ...quote(), receipt_id: "t-create" })) as any;
|
|
161
|
+
await callTool(db, "simulation", "send_quote", { id: q.id, value: 37, discount_pct: 0, currency: "EUR", receipt_id: "t-send" });
|
|
162
|
+
expect((await db.get<any>(`SELECT receipt_id FROM quotes WHERE id = ?`, [q.id]))!.receipt_id).toBe("t-send");
|
|
163
|
+
expect((await db.all<any>(`SELECT receipt_id, status FROM changes ORDER BY at`))).toEqual([
|
|
164
|
+
{ receipt_id: "t-create", status: "draft" },
|
|
165
|
+
{ receipt_id: "t-send", status: "sent" },
|
|
166
|
+
]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("records no change for a read", async () => {
|
|
170
|
+
await callTool(db, "simulation", "list_items", {});
|
|
171
|
+
expect(await db.all(`SELECT * FROM changes`)).toHaveLength(0);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("scenario and export", () => {
|
|
176
|
+
beforeEach(() => freshDb());
|
|
177
|
+
|
|
178
|
+
const scenarioFile = () => {
|
|
179
|
+
const path = tmp("json");
|
|
180
|
+
writeFileSync(path, JSON.stringify({ requests: [
|
|
181
|
+
{ id: "r1", request: "Meridian asks for 1 × Cable 5m and 1 × Widget 100", expected: { customer_id: "cust-4", value: 37 } },
|
|
182
|
+
{ id: "r2", request: "Alpine asks for 2 × Gear A1" },
|
|
183
|
+
] }));
|
|
184
|
+
return path;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
it("hands requests over in order, records the time, then reports done", async () => {
|
|
188
|
+
const s = loadScenario(scenarioFile());
|
|
189
|
+
expect((await nextRequest(db, s))?.id).toBe("r1");
|
|
190
|
+
expect((await nextRequest(db, s))?.id).toBe("r2");
|
|
191
|
+
expect(await nextRequest(db, s)).toBeNull();
|
|
192
|
+
expect(await db.all(`SELECT scenario_id FROM triggers`)).toHaveLength(2);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("refuses a scenario with duplicate ids", () => {
|
|
196
|
+
const path = tmp("json");
|
|
197
|
+
writeFileSync(path, JSON.stringify({ requests: [{ id: "a", request: "x" }, { id: "a", request: "y" }] }));
|
|
198
|
+
expect(() => loadScenario(path)).toThrow(/appears twice/);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("the export lines up trigger → receipt_id → effect, and states the mode", async () => {
|
|
202
|
+
await nextRequest(db, loadScenario(scenarioFile()));
|
|
203
|
+
const q = (await callTool(db, "simulation", "create_quote", { ...quote(), receipt_id: "t-quote" })) as any;
|
|
204
|
+
await callTool(db, "simulation", "send_quote", { id: q.id, value: 37, discount_pct: 0, currency: "EUR", receipt_id: "t-send" });
|
|
205
|
+
await callTool(db, "simulation", "create_quote", { ...quote({ value: 20 }), receipt_id: "t-false" }).catch(() => {});
|
|
206
|
+
|
|
207
|
+
const rec = await exportRecord(db, "simulation");
|
|
208
|
+
expect(rec.mode).toBe("simulation");
|
|
209
|
+
expect(rec.triggers).toEqual([expect.objectContaining({ scenario_id: "r1", expected: { customer_id: "cust-4", value: 37 } })]);
|
|
210
|
+
expect(rec.quotes).toEqual([expect.objectContaining({ number: "Q-0001", status: "sent", net_total: 37, receipt_id: "t-send" })]);
|
|
211
|
+
expect(rec.quotes[0].lines).toHaveLength(2);
|
|
212
|
+
expect(rec.refusals).toEqual([expect.objectContaining({ receipt_id: "t-false", tool: "create_quote" })]);
|
|
213
|
+
expect(rec.changes.map((c: any) => [c.tool, c.receipt_id])).toEqual([["create_quote", "t-quote"], ["send_quote", "t-send"]]);
|
|
214
|
+
});
|
|
215
|
+
});
|