@humanagencyp/erp-mcp 0.1.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/.github/workflows/ci.yml +29 -0
- package/.github/workflows/publish.yml +51 -0
- package/README.md +148 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +787 -0
- package/package.json +51 -0
- package/src/db.ts +248 -0
- package/src/index.ts +302 -0
- package/src/money.ts +43 -0
- package/src/tools/customers.ts +50 -0
- package/src/tools/items.ts +36 -0
- package/src/tools/orders.ts +123 -0
- package/src/tools/quotes.ts +227 -0
- package/test/erp.test.ts +518 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Db } from "../db.js";
|
|
2
|
+
|
|
3
|
+
export interface Item {
|
|
4
|
+
id: string;
|
|
5
|
+
sku: string;
|
|
6
|
+
name: string;
|
|
7
|
+
unit: string;
|
|
8
|
+
list_price: number;
|
|
9
|
+
currency: string;
|
|
10
|
+
stock: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function list_items(db: Db, args: Record<string, any>) {
|
|
14
|
+
const { query, limit = 50 } = args;
|
|
15
|
+
|
|
16
|
+
const conditions: string[] = [];
|
|
17
|
+
const params: any[] = [];
|
|
18
|
+
|
|
19
|
+
if (query) {
|
|
20
|
+
conditions.push("(sku LIKE ? OR name LIKE ?)");
|
|
21
|
+
const like = `%${query}%`;
|
|
22
|
+
params.push(like, like);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
26
|
+
params.push(limit);
|
|
27
|
+
|
|
28
|
+
return db.all<Item>(`SELECT * FROM items ${where} ORDER BY sku LIMIT ?`, params);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function get_item(db: Db, args: Record<string, any>) {
|
|
32
|
+
const { id } = args;
|
|
33
|
+
const row = await db.get<Item>("SELECT * FROM items WHERE id = ?", [id]);
|
|
34
|
+
if (!row) throw new Error(`Unknown item id: ${id}`);
|
|
35
|
+
return row;
|
|
36
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { v4 as uuidv4 } from "uuid";
|
|
2
|
+
import type { Db } from "../db.js";
|
|
3
|
+
import type { Item } from "./items.js";
|
|
4
|
+
import { requireCustomer } from "./customers.js";
|
|
5
|
+
import { requireQuote, loadQuoteLines } from "./quotes.js";
|
|
6
|
+
import { computeNetTotal, valuesMatch, refuse, roundToCents } from "../money.js";
|
|
7
|
+
|
|
8
|
+
export interface OrderRow {
|
|
9
|
+
id: string;
|
|
10
|
+
number: string;
|
|
11
|
+
quote_id: string;
|
|
12
|
+
customer_id: string;
|
|
13
|
+
status: "confirmed";
|
|
14
|
+
net_total: number;
|
|
15
|
+
currency: string;
|
|
16
|
+
requested_delivery: string | null;
|
|
17
|
+
created_at: string;
|
|
18
|
+
updated_at: string;
|
|
19
|
+
receipt_id: string | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function nextOrderNumber(db: Db): Promise<string> {
|
|
23
|
+
const { count } = (await db.get<{ count: number }>("SELECT COUNT(*) as count FROM orders")) ?? { count: 0 };
|
|
24
|
+
return `O-${String(count + 1).padStart(4, "0")}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function orderWithLines(db: Db, id: string) {
|
|
28
|
+
const order = await db.get<OrderRow>("SELECT * FROM orders WHERE id = ?", [id]);
|
|
29
|
+
if (!order) throw new Error(`Unknown order id: ${id}`);
|
|
30
|
+
const lines = await loadQuoteLines(db, order.quote_id);
|
|
31
|
+
return { ...order, lines };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function convert_quote_to_order(db: Db, args: Record<string, any>) {
|
|
35
|
+
const { id, value, discount_pct, currency, requested_delivery, receipt_id } = args;
|
|
36
|
+
|
|
37
|
+
const quote = await requireQuote(db, id);
|
|
38
|
+
if (quote.status !== "sent") {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`Quote ${quote.number} (${id}) is ${quote.status}; only sent quotes can be converted to an order` +
|
|
41
|
+
(quote.status === "converted" ? " (it already was)" : "")
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (currency !== quote.currency) {
|
|
46
|
+
refuse("currency", currency, quote.currency, `Quote ${quote.number} is in ${quote.currency}.`);
|
|
47
|
+
}
|
|
48
|
+
if (discount_pct !== quote.discount_pct) {
|
|
49
|
+
refuse("discount_pct", discount_pct, quote.discount_pct, `Quote ${quote.number}'s discount cannot change at conversion time.`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const lines = await loadQuoteLines(db, id);
|
|
53
|
+
const netTotal = computeNetTotal(lines, quote.discount_pct);
|
|
54
|
+
if (!valuesMatch(value, netTotal)) {
|
|
55
|
+
refuse("value", value, netTotal, "Recomputed from the quote's lines and stored discount.");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const customer = await requireCustomer(db, quote.customer_id);
|
|
59
|
+
const projectedBalance = roundToCents(customer.open_balance + netTotal);
|
|
60
|
+
if (projectedBalance > customer.credit_limit) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`Refused: credit limit exceeded for ${customer.name} (${customer.id}) — open balance ${customer.open_balance} + ` +
|
|
63
|
+
`order value ${netTotal} = ${projectedBalance}, credit limit is ${customer.credit_limit}.`
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const items = new Map<string, Item>();
|
|
68
|
+
for (const line of lines) {
|
|
69
|
+
if (!items.has(line.item_id)) {
|
|
70
|
+
const item = await db.get<Item>("SELECT * FROM items WHERE id = ?", [line.item_id]);
|
|
71
|
+
if (!item) throw new Error(`Unknown item id: ${line.item_id}`);
|
|
72
|
+
items.set(line.item_id, item);
|
|
73
|
+
}
|
|
74
|
+
const item = items.get(line.item_id)!;
|
|
75
|
+
if (item.stock < line.qty) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`Refused: insufficient stock for ${item.sku} (${item.name}) — requested ${line.qty}, available ${item.stock}.`
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Reserve: decrement stock for every line. Checked above in full before any
|
|
83
|
+
// write, so a later line's shortfall never leaves an earlier line's stock
|
|
84
|
+
// decremented — the reservation is all-or-nothing.
|
|
85
|
+
for (const line of lines) {
|
|
86
|
+
await db.run("UPDATE items SET stock = stock - ? WHERE id = ?", [line.qty, line.item_id]);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const orderId = uuidv4();
|
|
90
|
+
const number = await nextOrderNumber(db);
|
|
91
|
+
|
|
92
|
+
await db.run(
|
|
93
|
+
`INSERT INTO orders (id, number, quote_id, customer_id, status, net_total, currency, requested_delivery, receipt_id)
|
|
94
|
+
VALUES (?, ?, ?, ?, 'confirmed', ?, ?, ?, ?)`,
|
|
95
|
+
[orderId, number, id, quote.customer_id, netTotal, currency, requested_delivery ?? null, receipt_id ?? null]
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
await db.run(`UPDATE quotes SET status = 'converted', updated_at = datetime('now') WHERE id = ?`, [id]);
|
|
99
|
+
|
|
100
|
+
return orderWithLines(db, orderId);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function list_orders(db: Db, args: Record<string, any>) {
|
|
104
|
+
const { customer_id, limit = 50 } = args;
|
|
105
|
+
|
|
106
|
+
const conditions: string[] = [];
|
|
107
|
+
const params: any[] = [];
|
|
108
|
+
|
|
109
|
+
if (customer_id) {
|
|
110
|
+
conditions.push("customer_id = ?");
|
|
111
|
+
params.push(customer_id);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
115
|
+
params.push(limit);
|
|
116
|
+
|
|
117
|
+
return db.all<OrderRow>(`SELECT * FROM orders ${where} ORDER BY created_at DESC LIMIT ?`, params);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function get_order(db: Db, args: Record<string, any>) {
|
|
121
|
+
const { id } = args;
|
|
122
|
+
return orderWithLines(db, id);
|
|
123
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { v4 as uuidv4 } from "uuid";
|
|
2
|
+
import type { Db } from "../db.js";
|
|
3
|
+
import type { Item } from "./items.js";
|
|
4
|
+
import { requireCustomer } from "./customers.js";
|
|
5
|
+
import { computeNetTotal, valuesMatch, refuse, type PricedLine } from "../money.js";
|
|
6
|
+
|
|
7
|
+
export interface QuoteRow {
|
|
8
|
+
id: string;
|
|
9
|
+
number: string;
|
|
10
|
+
customer_id: string;
|
|
11
|
+
status: "draft" | "sent" | "converted" | "cancelled";
|
|
12
|
+
currency: string;
|
|
13
|
+
discount_pct: number;
|
|
14
|
+
net_total: number;
|
|
15
|
+
valid_until: string | null;
|
|
16
|
+
notes: string | null;
|
|
17
|
+
created_at: string;
|
|
18
|
+
updated_at: string;
|
|
19
|
+
sent_at: string | null;
|
|
20
|
+
receipt_id: string | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface QuoteLineRow extends PricedLine {
|
|
24
|
+
id: string;
|
|
25
|
+
quote_id: string;
|
|
26
|
+
item_id: string;
|
|
27
|
+
line_total: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface LineInput {
|
|
31
|
+
item_id: string;
|
|
32
|
+
qty: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface PricedInputLine {
|
|
36
|
+
item_id: string;
|
|
37
|
+
qty: number;
|
|
38
|
+
list_price: number;
|
|
39
|
+
line_total: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Looks up each line's item and snapshots its current list price. Refuses unknown items and invalid quantities. */
|
|
43
|
+
async function priceLines(db: Db, lines: LineInput[]): Promise<PricedInputLine[]> {
|
|
44
|
+
if (!Array.isArray(lines) || lines.length === 0) {
|
|
45
|
+
throw new Error("lines must be a non-empty array of { item_id, qty }");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const priced: PricedInputLine[] = [];
|
|
49
|
+
for (const line of lines) {
|
|
50
|
+
const { item_id, qty } = line ?? {};
|
|
51
|
+
if (!item_id) {
|
|
52
|
+
throw new Error("Every line requires an item_id");
|
|
53
|
+
}
|
|
54
|
+
if (!Number.isInteger(qty) || qty <= 0) {
|
|
55
|
+
throw new Error(`Invalid qty for item ${item_id}: ${JSON.stringify(qty)} (must be a positive integer)`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const item = await db.get<Item>("SELECT * FROM items WHERE id = ?", [item_id]);
|
|
59
|
+
if (!item) {
|
|
60
|
+
throw new Error(`Unknown item id: ${item_id}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
priced.push({
|
|
64
|
+
item_id,
|
|
65
|
+
qty,
|
|
66
|
+
list_price: item.list_price,
|
|
67
|
+
line_total: Math.round(item.list_price * qty * 100) / 100,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return priced;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function validateDiscountPct(discount_pct: unknown): number {
|
|
74
|
+
if (typeof discount_pct !== "number" || !Number.isFinite(discount_pct) || discount_pct < 0 || discount_pct > 100) {
|
|
75
|
+
refuse("discount_pct", discount_pct, "a number between 0 and 100");
|
|
76
|
+
}
|
|
77
|
+
return discount_pct as number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function nextNumber(db: Db, table: "quotes" | "orders", prefix: "Q" | "O"): Promise<string> {
|
|
81
|
+
const { count } = (await db.get<{ count: number }>(`SELECT COUNT(*) as count FROM ${table}`)) ?? { count: 0 };
|
|
82
|
+
return `${prefix}-${String(count + 1).padStart(4, "0")}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function requireQuote(db: Db, id: string): Promise<QuoteRow> {
|
|
86
|
+
const row = await db.get<QuoteRow>("SELECT * FROM quotes WHERE id = ?", [id]);
|
|
87
|
+
if (!row) throw new Error(`Unknown quote id: ${id}`);
|
|
88
|
+
return row;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function loadQuoteLines(db: Db, quoteId: string): Promise<QuoteLineRow[]> {
|
|
92
|
+
return db.all<QuoteLineRow>("SELECT * FROM quote_lines WHERE quote_id = ? ORDER BY rowid", [quoteId]);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function quoteWithLines(db: Db, id: string) {
|
|
96
|
+
const quote = await requireQuote(db, id);
|
|
97
|
+
const lines = await loadQuoteLines(db, id);
|
|
98
|
+
return { ...quote, lines };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function create_quote(db: Db, args: Record<string, any>) {
|
|
102
|
+
const { customer_id, lines, discount_pct, value, currency, valid_until, notes, receipt_id } = args;
|
|
103
|
+
|
|
104
|
+
const customer = await requireCustomer(db, customer_id);
|
|
105
|
+
const priced = await priceLines(db, lines);
|
|
106
|
+
const discountPct = validateDiscountPct(discount_pct);
|
|
107
|
+
|
|
108
|
+
if (currency !== customer.currency) {
|
|
109
|
+
refuse("currency", currency, customer.currency, `Customer ${customer.name} (${customer.id}) is billed in ${customer.currency}.`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const netTotal = computeNetTotal(priced, discountPct);
|
|
113
|
+
if (!valuesMatch(value, netTotal)) {
|
|
114
|
+
refuse("value", value, netTotal, "Recomputed from the quote lines and the declared discount.");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const id = uuidv4();
|
|
118
|
+
const number = await nextNumber(db, "quotes", "Q");
|
|
119
|
+
|
|
120
|
+
await db.run(
|
|
121
|
+
`INSERT INTO quotes (id, number, customer_id, status, currency, discount_pct, net_total, valid_until, notes, receipt_id)
|
|
122
|
+
VALUES (?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?)`,
|
|
123
|
+
[id, number, customer_id, currency, discountPct, netTotal, valid_until ?? null, notes ?? null, receipt_id ?? null]
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
for (const line of priced) {
|
|
127
|
+
await db.run(
|
|
128
|
+
`INSERT INTO quote_lines (id, quote_id, item_id, qty, list_price, line_total) VALUES (?, ?, ?, ?, ?, ?)`,
|
|
129
|
+
[uuidv4(), id, line.item_id, line.qty, line.list_price, line.line_total]
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return quoteWithLines(db, id);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function update_quote(db: Db, args: Record<string, any>) {
|
|
137
|
+
const { id, lines, discount_pct, value, currency, valid_until, notes, receipt_id } = args;
|
|
138
|
+
|
|
139
|
+
const quote = await requireQuote(db, id);
|
|
140
|
+
if (quote.status !== "draft") {
|
|
141
|
+
throw new Error(`Quote ${quote.number} (${id}) is ${quote.status}; only draft quotes can be updated`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const priced = lines !== undefined ? await priceLines(db, lines) : await loadQuoteLines(db, id);
|
|
145
|
+
const discountPct = validateDiscountPct(discount_pct);
|
|
146
|
+
|
|
147
|
+
if (currency !== quote.currency) {
|
|
148
|
+
refuse("currency", currency, quote.currency, `Quote ${quote.number} was created in ${quote.currency}; currency cannot change on update.`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const netTotal = computeNetTotal(priced, discountPct);
|
|
152
|
+
if (!valuesMatch(value, netTotal)) {
|
|
153
|
+
refuse("value", value, netTotal, "Recomputed from the quote lines and the declared discount.");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (lines !== undefined) {
|
|
157
|
+
await db.run("DELETE FROM quote_lines WHERE quote_id = ?", [id]);
|
|
158
|
+
for (const line of priced) {
|
|
159
|
+
await db.run(
|
|
160
|
+
`INSERT INTO quote_lines (id, quote_id, item_id, qty, list_price, line_total) VALUES (?, ?, ?, ?, ?, ?)`,
|
|
161
|
+
[uuidv4(), id, line.item_id, line.qty, line.list_price, line.line_total]
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
await db.run(
|
|
167
|
+
`UPDATE quotes SET discount_pct = ?, net_total = ?, valid_until = ?, notes = ?, receipt_id = ?, updated_at = datetime('now') WHERE id = ?`,
|
|
168
|
+
[discountPct, netTotal, valid_until ?? quote.valid_until ?? null, notes ?? quote.notes ?? null, receipt_id ?? quote.receipt_id ?? null, id]
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
return quoteWithLines(db, id);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function send_quote(db: Db, args: Record<string, any>) {
|
|
175
|
+
const { id, value, discount_pct, currency, receipt_id } = args;
|
|
176
|
+
|
|
177
|
+
const quote = await requireQuote(db, id);
|
|
178
|
+
if (quote.status !== "draft") {
|
|
179
|
+
throw new Error(`Quote ${quote.number} (${id}) is ${quote.status}; only draft quotes can be sent`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (currency !== quote.currency) {
|
|
183
|
+
refuse("currency", currency, quote.currency, `Quote ${quote.number} is in ${quote.currency}.`);
|
|
184
|
+
}
|
|
185
|
+
if (discount_pct !== quote.discount_pct) {
|
|
186
|
+
refuse("discount_pct", discount_pct, quote.discount_pct, `Quote ${quote.number}'s discount cannot change at send time — edit the draft first.`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const lines = await loadQuoteLines(db, id);
|
|
190
|
+
const netTotal = computeNetTotal(lines, quote.discount_pct);
|
|
191
|
+
if (!valuesMatch(value, netTotal)) {
|
|
192
|
+
refuse("value", value, netTotal, "Recomputed from the quote's lines and stored discount.");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
await db.run(
|
|
196
|
+
`UPDATE quotes SET status = 'sent', sent_at = datetime('now'), receipt_id = ?, updated_at = datetime('now') WHERE id = ?`,
|
|
197
|
+
[receipt_id ?? quote.receipt_id ?? null, id]
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
return quoteWithLines(db, id);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export async function list_quotes(db: Db, args: Record<string, any>) {
|
|
204
|
+
const { status, customer_id, limit = 50 } = args;
|
|
205
|
+
|
|
206
|
+
const conditions: string[] = [];
|
|
207
|
+
const params: any[] = [];
|
|
208
|
+
|
|
209
|
+
if (status) {
|
|
210
|
+
conditions.push("status = ?");
|
|
211
|
+
params.push(status);
|
|
212
|
+
}
|
|
213
|
+
if (customer_id) {
|
|
214
|
+
conditions.push("customer_id = ?");
|
|
215
|
+
params.push(customer_id);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
219
|
+
params.push(limit);
|
|
220
|
+
|
|
221
|
+
return db.all<QuoteRow>(`SELECT * FROM quotes ${where} ORDER BY created_at DESC LIMIT ?`, params);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export async function get_quote(db: Db, args: Record<string, any>) {
|
|
225
|
+
const { id } = args;
|
|
226
|
+
return quoteWithLines(db, id);
|
|
227
|
+
}
|