diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9814f88 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,45 @@ +# Changelog + +## [0.3.0] - 2026-03-16 + +### Added +- MCP Server expanded: 13 → 53 tools (list + create + action + report for all modules) +- 63 business flow tests verified (test_all_flows.sh) + +### Fixed +- Invoice void: now accepts posted status + payment guard prevents voiding paid invoices +- CLI: auth login piped stdin TTY detection +- CLI: invoice/payment/bill response shape (items vs data) +- CLI: tax/roles/webhooks flat array handling +- CLI: settings response unwrapping +- CLI: AR paths /ar/invoices → /invoices +- All 34 CLI commands verified passing + +## [0.2.0] - 2026-03-16 + +### Added +- MCP Server (`apps/mcp`) — 13 tools for AI integration via Model Context Protocol +- Architecture diagram + Test plan document (346 test cases) + +### Fixed +- Web UI data binding: null safety (BigInt ?? 0), status mapping (void→voided) +- 16 Web UI pages fixed +- Auth hydration: protected layout waits for zustand rehydrate +- Rate limiter: 10,000 req/min in dev mode + +### Changed +- Sidebar redesigned: SAP-style grouped menus (bilingual Thai+EN) +- README rewritten: explains EIP vs ERP, pain points, AI-Native approach +- API descriptions: 186/186 endpoints documented in Swagger + +## [0.1.0] - 2026-03-15 + +### Added +- Initial release: 31 ERP modules +- 186 API endpoints (Fastify 5.8) +- 81 Web UI pages (Next.js 15) +- 39 CLI commands (Commander.js) +- 58 DB tables (PostgreSQL 17, RLS multi-tenant) +- Thai compliance: VAT 7%, WHT, SSC, PDPA, TFAC +- Audit trail: auto-log all mutations +- 417 unit tests passing diff --git a/apps/api/src/routes/ar/invoices.ts b/apps/api/src/routes/ar/invoices.ts index 2a3155a..537a6c0 100644 --- a/apps/api/src/routes/ar/invoices.ts +++ b/apps/api/src/routes/ar/invoices.ts @@ -588,7 +588,7 @@ export async function invoiceRoutes( const rows = await fastify.sql<[InvoiceRow?]>` UPDATE invoices SET status = 'void', updated_at = NOW() - WHERE id = ${id} AND tenant_id = ${tenantId} AND status IN ('draft', 'sent') + WHERE id = ${id} AND tenant_id = ${tenantId} AND status IN ('draft', 'sent', 'posted') RETURNING * `; const inv = rows[0]; diff --git a/apps/cli/src/commands/ap/bill.ts b/apps/cli/src/commands/ap/bill.ts index 192e66e..dfb9e23 100644 --- a/apps/cli/src/commands/ap/bill.ts +++ b/apps/cli/src/commands/ap/bill.ts @@ -52,12 +52,13 @@ interface Bill { createdAt: string; } -/** Paginated list response wrapper. */ +/** Paginated list response wrapper (API returns items/total/limit/offset). */ interface PaginatedResponse { - data: T[]; + items: T[]; total: number; - page: number; - pageSize: number; + limit: number; + offset: number; + hasMore?: boolean; } /** Options accepted by `ap bill list`. */ @@ -100,9 +101,11 @@ function daysFromToday(days: number): string { // --------------------------------------------------------------------------- async function billList(options: BillListOptions): Promise { + const pageNum = parseInt(options.page, 10); + const pageSizeNum = parseInt(options.pageSize, 10); const params: Record = { - page: options.page, - pageSize: options.pageSize, + limit: options.pageSize, + offset: String((pageNum - 1) * pageSizeNum), }; if (options.status !== undefined && options.status !== '') { @@ -119,11 +122,13 @@ async function billList(options: BillListOptions): Promise { process.exit(1); } - const { data, total, page, pageSize } = result.data; + const { items, total, limit, offset } = result.data; + const page = Math.floor(offset / limit) + 1; + const totalPages = Math.ceil(total / limit) || 1; printSuccess( - data, - `Showing ${String(data.length)} of ${String(total)} bills (page ${String(page)}/${String(Math.ceil(total / pageSize))})`, + items, + `Showing ${String(items.length)} of ${String(total)} bills (page ${String(page)}/${String(totalPages)})`, ); } diff --git a/apps/cli/src/commands/ap/payment.ts b/apps/cli/src/commands/ap/payment.ts index 7fedb8f..b902868 100644 --- a/apps/cli/src/commands/ap/payment.ts +++ b/apps/cli/src/commands/ap/payment.ts @@ -50,12 +50,13 @@ interface BillPayment { createdAt: string; } -/** Paginated list response wrapper. */ +/** Paginated list response wrapper (API returns items/total/limit/offset). */ interface PaginatedResponse { - data: T[]; + items: T[]; total: number; - page: number; - pageSize: number; + limit: number; + offset: number; + hasMore?: boolean; } /** Options accepted by `ap payment list`. */ @@ -103,9 +104,11 @@ function today(): string { // --------------------------------------------------------------------------- async function apPaymentList(options: ApPaymentListOptions): Promise { + const pageNum = parseInt(options.page, 10); + const pageSizeNum = parseInt(options.pageSize, 10); const params: Record = { - page: options.page, - pageSize: options.pageSize, + limit: options.pageSize, + offset: String((pageNum - 1) * pageSizeNum), }; if (options.vendorId !== undefined && options.vendorId !== '') { @@ -122,11 +125,13 @@ async function apPaymentList(options: ApPaymentListOptions): Promise { process.exit(1); } - const { data, total, page, pageSize } = result.data; + const { items, total, limit, offset } = result.data; + const page = Math.floor(offset / limit) + 1; + const totalPages = Math.ceil(total / limit) || 1; printSuccess( - data, - `Showing ${String(data.length)} of ${String(total)} payments (page ${String(page)}/${String(Math.ceil(total / pageSize))})`, + items, + `Showing ${String(items.length)} of ${String(total)} payments (page ${String(page)}/${String(totalPages)})`, ); } diff --git a/apps/cli/src/commands/ar/invoice.ts b/apps/cli/src/commands/ar/invoice.ts index 87d844b..03a1aaa 100644 --- a/apps/cli/src/commands/ar/invoice.ts +++ b/apps/cli/src/commands/ar/invoice.ts @@ -50,12 +50,13 @@ interface Invoice { createdAt: string; } -/** Paginated list response wrapper. */ +/** Paginated list response wrapper (API returns items/total/limit/offset). */ interface PaginatedResponse { - data: T[]; + items: T[]; total: number; - page: number; - pageSize: number; + limit: number; + offset: number; + hasMore?: boolean; } /** Options accepted by `ar invoice list`. */ @@ -160,7 +161,7 @@ async function invoiceCreate(): Promise { }; const result = await api.post<{ data: Invoice }>( - '/api/v1/ar/invoices', + '/api/v1/invoices', payload, ); @@ -173,9 +174,11 @@ async function invoiceCreate(): Promise { } async function invoiceList(options: InvoiceListOptions): Promise { + const pageNum = parseInt(options.page, 10); + const pageSizeNum = parseInt(options.pageSize, 10); const params: Record = { - page: options.page, - pageSize: options.pageSize, + limit: options.pageSize, + offset: String((pageNum - 1) * pageSizeNum), }; if (options.status !== undefined && options.status !== '') { @@ -186,7 +189,7 @@ async function invoiceList(options: InvoiceListOptions): Promise { } const result = await api.get>( - '/api/v1/ar/invoices', + '/api/v1/invoices', params, ); @@ -195,11 +198,13 @@ async function invoiceList(options: InvoiceListOptions): Promise { process.exit(1); } - const { data, total, page, pageSize } = result.data; + const { items, total, limit, offset } = result.data; + const page = Math.floor(offset / limit) + 1; + const totalPages = Math.ceil(total / limit) || 1; printSuccess( - data, - `Showing ${String(data.length)} of ${String(total)} invoices (page ${String(page)}/${String(Math.ceil(total / pageSize))})`, + items, + `Showing ${String(items.length)} of ${String(total)} invoices (page ${String(page)}/${String(totalPages)})`, ); } @@ -210,7 +215,7 @@ async function invoiceVoid(id: string): Promise { } const result = await api.post<{ data: Invoice }>( - `/api/v1/ar/invoices/${id}/void`, + `/api/v1/invoices/${id}/void`, ); if (!result.ok) { diff --git a/apps/cli/src/commands/ar/payment.ts b/apps/cli/src/commands/ar/payment.ts index 8731398..68316ae 100644 --- a/apps/cli/src/commands/ar/payment.ts +++ b/apps/cli/src/commands/ar/payment.ts @@ -50,12 +50,13 @@ interface Payment { createdAt: string; } -/** Paginated list response wrapper. */ +/** Paginated list response wrapper (API returns items/total/limit/offset). */ interface PaginatedResponse { - data: T[]; + items: T[]; total: number; - page: number; - pageSize: number; + limit: number; + offset: number; + hasMore?: boolean; } /** Options accepted by `ar payment list`. */ @@ -172,7 +173,7 @@ async function paymentCreate(): Promise { }; const result = await api.post<{ data: Payment }>( - '/api/v1/ar/payments', + '/api/v1/payments', payload, ); @@ -185,9 +186,11 @@ async function paymentCreate(): Promise { } async function paymentList(options: PaymentListOptions): Promise { + const pageNum = parseInt(options.page, 10); + const pageSizeNum = parseInt(options.pageSize, 10); const params: Record = { - page: options.page, - pageSize: options.pageSize, + limit: options.pageSize, + offset: String((pageNum - 1) * pageSizeNum), }; if (options.customerId !== undefined && options.customerId !== '') { @@ -198,7 +201,7 @@ async function paymentList(options: PaymentListOptions): Promise { } const result = await api.get>( - '/api/v1/ar/payments', + '/api/v1/payments', params, ); @@ -207,11 +210,13 @@ async function paymentList(options: PaymentListOptions): Promise { process.exit(1); } - const { data, total, page, pageSize } = result.data; + const { items, total, limit, offset } = result.data; + const page = Math.floor(offset / limit) + 1; + const totalPages = Math.ceil(total / limit) || 1; printSuccess( - data, - `Showing ${String(data.length)} of ${String(total)} payments (page ${String(page)}/${String(Math.ceil(total / pageSize))})`, + items, + `Showing ${String(items.length)} of ${String(total)} payments (page ${String(page)}/${String(totalPages)})`, ); } diff --git a/apps/cli/src/commands/auth.ts b/apps/cli/src/commands/auth.ts index 6885554..6a79801 100644 --- a/apps/cli/src/commands/auth.ts +++ b/apps/cli/src/commands/auth.ts @@ -41,42 +41,54 @@ interface JwtClaims { /** * Prompt the user for a value on stdin and return what they typed. * When `hidden` is true the input is masked (used for passwords). + * Falls back gracefully to plain readline when stdin is not a TTY + * (e.g. when input is piped via `printf "email\npass\n" | neip auth login`). */ async function prompt(question: string, hidden = false): Promise { return new Promise((resolve) => { + const isTTY = process.stdin.isTTY === true; + + // If not a TTY (piped) or hidden mode not needed, use plain readline + if (!hidden || !isTTY) { + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + terminal: false, + }); + process.stdout.write(question); + rl.once('line', (answer) => { + rl.close(); + resolve(answer.trim()); + }); + return; + } + + // TTY hidden mode — suppress echoing with setRawMode const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true, }); - if (hidden) { - // Write the prompt manually so we can suppress echoing - process.stdout.write(question); - process.stdin.setRawMode(true); - - let input = ''; - process.stdin.on('data', function onData(buf: Buffer) { - const char = buf.toString(); - if (char === '\n' || char === '\r' || char === '\u0003') { - process.stdin.setRawMode(false); - process.stdin.removeListener('data', onData); - process.stdout.write('\n'); - rl.close(); - resolve(input); - } else if (char === '\u007f') { - // Backspace - input = input.slice(0, -1); - } else { - input += char; - } - }); - } else { - rl.question(question, (answer) => { + process.stdout.write(question); + process.stdin.setRawMode(true); + + let input = ''; + process.stdin.on('data', function onData(buf: Buffer) { + const char = buf.toString(); + if (char === '\n' || char === '\r' || char === '\u0003') { + process.stdin.setRawMode(false); + process.stdin.removeListener('data', onData); + process.stdout.write('\n'); rl.close(); - resolve(answer); - }); - } + resolve(input); + } else if (char === '\u007f') { + // Backspace + input = input.slice(0, -1); + } else { + input += char; + } + }); }); } diff --git a/apps/cli/src/commands/budgets.ts b/apps/cli/src/commands/budgets.ts index 736504f..29c0e15 100644 --- a/apps/cli/src/commands/budgets.ts +++ b/apps/cli/src/commands/budgets.ts @@ -34,18 +34,18 @@ interface Budget { createdAt: string; } -/** Paginated list response wrapper. */ +/** Paginated list response wrapper (API returns items/total/limit/offset). */ interface PaginatedResponse { - data: T[]; + items: T[]; total: number; - page: number; - pageSize: number; + limit: number; + offset: number; + hasMore?: boolean; } /** Options accepted by `budgets list`. */ interface BudgetsListOptions { - page: string; - pageSize: string; + limit: string; year?: string; status?: string; } @@ -71,8 +71,7 @@ function promptLine(question: string): Promise { async function budgetsList(options: BudgetsListOptions): Promise { const params: Record = { - page: options.page, - pageSize: options.pageSize, + limit: options.limit, }; if (options.year !== undefined && options.year !== '') params['year'] = options.year; @@ -85,11 +84,11 @@ async function budgetsList(options: BudgetsListOptions): Promise { process.exit(1); } - const { data, total, page, pageSize } = result.data; + const { items, total } = result.data; printSuccess( - data, - `Showing ${String(data.length)} of ${String(total)} budgets (page ${String(page)}/${String(Math.ceil(total / pageSize))})`, + items, + `Showing ${String(items.length)} of ${String(total)} budgets`, ); } @@ -214,8 +213,7 @@ Examples: budgets .command('list') .description('แสดงงบประมาณทั้งหมด — List all budgets with optional filters') - .option('--page ', 'หน้าที่ — Page number', '1') - .option('--page-size ', 'จำนวนต่อหน้า — Number of budgets per page', '20') + .option('--limit ', 'จำนวนสูงสุด — Maximum number of budgets to return', '50') .option('--year ', 'กรองตามปี — Filter by calendar year') .option('--status ', 'กรองตามสถานะ: draft, approved, archived — Filter by status') .action(async (options: BudgetsListOptions) => { diff --git a/apps/cli/src/commands/fiscal.ts b/apps/cli/src/commands/fiscal.ts index 64b3a7e..3d80990 100644 --- a/apps/cli/src/commands/fiscal.ts +++ b/apps/cli/src/commands/fiscal.ts @@ -39,18 +39,14 @@ interface FiscalPeriod { status: 'open' | 'closed'; } -/** Paginated list response wrapper. */ -interface PaginatedResponse { - data: T[]; - total: number; - page: number; - pageSize: number; +/** List response wrapper (API returns { items: [...] }). */ +interface ItemsResponse { + items: T[]; } /** Options accepted by `fiscal years list` (implicit). */ interface FiscalYearsListOptions { - page: string; - pageSize: string; + limit: string; } // --------------------------------------------------------------------------- @@ -74,22 +70,21 @@ function promptLine(question: string): Promise { async function fiscalYearsList(options: FiscalYearsListOptions): Promise { const params: Record = { - page: options.page, - pageSize: options.pageSize, + limit: options.limit, }; - const result = await api.get>('/api/v1/fiscal-years', params); + const result = await api.get>('/api/v1/fiscal-years', params); if (!result.ok) { printError(result.error.detail, result.error.status); process.exit(1); } - const { data, total, page, pageSize } = result.data; + const { items } = result.data; printSuccess( - data, - `Showing ${String(data.length)} of ${String(total)} fiscal years (page ${String(page)}/${String(Math.ceil(total / pageSize))})`, + items, + `Showing ${String(items.length)} fiscal years`, ); } @@ -183,8 +178,7 @@ Examples: .description('จัดการปีบัญชี — Fiscal year operations'); years - .option('--page ', 'หน้าที่ — Page number', '1') - .option('--page-size ', 'จำนวนต่อหน้า — Number of fiscal years per page', '20') + .option('--limit ', 'จำนวนสูงสุด — Maximum number of fiscal years to return', '20') .action(async (options: FiscalYearsListOptions) => { await fiscalYearsList(options); }); diff --git a/apps/cli/src/commands/roles.ts b/apps/cli/src/commands/roles.ts index b07ef92..ae2571f 100644 --- a/apps/cli/src/commands/roles.ts +++ b/apps/cli/src/commands/roles.ts @@ -27,18 +27,14 @@ interface Role { createdAt: string; } -/** Paginated list response wrapper. */ -interface PaginatedResponse { +/** List response wrapper (API returns { data: [...] }). */ +interface ListResponse { data: T[]; - total: number; - page: number; - pageSize: number; } /** Options accepted by `roles list`. */ interface RolesListOptions { - page: string; - pageSize: string; + limit: string; } // --------------------------------------------------------------------------- @@ -62,22 +58,21 @@ function promptLine(question: string): Promise { async function rolesList(options: RolesListOptions): Promise { const params: Record = { - page: options.page, - pageSize: options.pageSize, + limit: options.limit, }; - const result = await api.get>('/api/v1/roles', params); + const result = await api.get>('/api/v1/roles', params); if (!result.ok) { printError(result.error.detail, result.error.status); process.exit(1); } - const { data, total, page, pageSize } = result.data; + const { data } = result.data; printSuccess( data, - `Showing ${String(data.length)} of ${String(total)} roles (page ${String(page)}/${String(Math.ceil(total / pageSize))})`, + `Showing ${String(data.length)} roles`, ); } @@ -188,8 +183,7 @@ Permissions format: invoices.read, payments.write, reports.view, ... roles .command('list') .description('แสดงรายการ roles ทั้งหมดในองค์กร — List all roles in the organisation') - .option('--page ', 'หน้าที่ — Page number', '1') - .option('--page-size ', 'จำนวนต่อหน้า — Number of roles per page', '20') + .option('--limit ', 'จำนวนสูงสุด — Maximum number of roles to return', '50') .action(async (options: RolesListOptions) => { await rolesList(options); }); diff --git a/apps/cli/src/commands/settings.ts b/apps/cli/src/commands/settings.ts index 83ec870..4c93cd9 100644 --- a/apps/cli/src/commands/settings.ts +++ b/apps/cli/src/commands/settings.ts @@ -68,7 +68,7 @@ function requireOrgId(): string { async function settingsGet(): Promise { const orgId = requireOrgId(); - const result = await api.get<{ data: Organisation }>(`/api/v1/organizations/${orgId}`); + const result = await api.get(`/api/v1/organizations/${orgId}`); if (!result.ok) { printError(result.error.detail, result.error.status); @@ -106,7 +106,7 @@ async function settingsUpdate(): Promise { process.exit(1); } - const result = await api.put<{ data: Organisation }>(`/api/v1/organizations/${orgId}`, body); + const result = await api.put(`/api/v1/organizations/${orgId}`, body); if (!result.ok) { printError(result.error.detail, result.error.status); @@ -143,7 +143,7 @@ async function settingsAi(): Promise { process.exit(1); } - const result = await api.put<{ data: Organisation }>(`/api/v1/organizations/${orgId}/settings`, body); + const result = await api.put(`/api/v1/organizations/${orgId}/settings`, body); if (!result.ok) { printError(result.error.detail, result.error.status); diff --git a/apps/cli/src/commands/tax.ts b/apps/cli/src/commands/tax.ts index 691fa1b..3556cd7 100644 --- a/apps/cli/src/commands/tax.ts +++ b/apps/cli/src/commands/tax.ts @@ -27,18 +27,14 @@ interface TaxRate { createdAt: string; } -/** Paginated list response wrapper. */ -interface PaginatedResponse { +/** List response wrapper (API returns { data: [...] }). */ +interface ListResponse { data: T[]; - total: number; - page: number; - pageSize: number; } /** Options accepted by `tax list`. */ interface TaxListOptions { - page: string; - pageSize: string; + limit: string; active?: string; } @@ -63,26 +59,25 @@ function promptLine(question: string): Promise { async function taxList(options: TaxListOptions): Promise { const params: Record = { - page: options.page, - pageSize: options.pageSize, + limit: options.limit, }; if (options.active !== undefined && options.active !== '') { params['active'] = options.active; } - const result = await api.get>('/api/v1/tax-rates', params); + const result = await api.get>('/api/v1/tax-rates', params); if (!result.ok) { printError(result.error.detail, result.error.status); process.exit(1); } - const { data, total, page, pageSize } = result.data; + const { data } = result.data; printSuccess( data, - `Showing ${String(data.length)} of ${String(total)} tax rates (page ${String(page)}/${String(Math.ceil(total / pageSize))})`, + `Showing ${String(data.length)} tax rates`, ); } @@ -203,8 +198,7 @@ Examples: tax .command('list') .description('แสดงอัตราภาษีทั้งหมด — List all tax rates') - .option('--page ', 'หน้าที่ (เริ่มต้น 1) — Page number', '1') - .option('--page-size ', 'จำนวนต่อหน้า — Number of tax rates per page', '20') + .option('--limit ', 'จำนวนสูงสุด — Maximum number of tax rates to return', '50') .option('--active ', 'กรองตาม active status: true หรือ false — Filter by active status') .action(async (options: TaxListOptions) => { await taxList(options); diff --git a/apps/cli/src/commands/webhooks.ts b/apps/cli/src/commands/webhooks.ts index 5dde9c5..3ed185b 100644 --- a/apps/cli/src/commands/webhooks.ts +++ b/apps/cli/src/commands/webhooks.ts @@ -26,18 +26,14 @@ interface Webhook { createdAt: string; } -/** Paginated list response wrapper. */ -interface PaginatedResponse { +/** List response wrapper (API returns { data: [...] }). */ +interface ListResponse { data: T[]; - total: number; - page: number; - pageSize: number; } /** Options accepted by `webhooks list`. */ interface WebhooksListOptions { - page: string; - pageSize: string; + limit: string; } // --------------------------------------------------------------------------- @@ -61,22 +57,21 @@ function promptLine(question: string): Promise { async function webhooksList(options: WebhooksListOptions): Promise { const params: Record = { - page: options.page, - pageSize: options.pageSize, + limit: options.limit, }; - const result = await api.get>('/api/v1/webhooks', params); + const result = await api.get>('/api/v1/webhooks', params); if (!result.ok) { printError(result.error.detail, result.error.status); process.exit(1); } - const { data, total, page, pageSize } = result.data; + const { data } = result.data; printSuccess( data, - `Showing ${String(data.length)} of ${String(total)} webhooks (page ${String(page)}/${String(Math.ceil(total / pageSize))})`, + `Showing ${String(data.length)} webhooks`, ); } @@ -156,8 +151,7 @@ Available events: invoice.created, invoice.paid, payment.received, webhooks .command('list') .description('แสดงรายการ webhook endpoints ทั้งหมด — List all registered webhook endpoints') - .option('--page ', 'หน้าที่ — Page number', '1') - .option('--page-size ', 'จำนวนต่อหน้า — Number of webhooks per page', '20') + .option('--limit ', 'จำนวนสูงสุด — Maximum number of webhooks to return', '50') .action(async (options: WebhooksListOptions) => { await webhooksList(options); }); diff --git a/apps/mcp/package.json b/apps/mcp/package.json new file mode 100644 index 0000000..b878b47 --- /dev/null +++ b/apps/mcp/package.json @@ -0,0 +1,25 @@ +{ + "name": "@neip/mcp", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "nEIP MCP Server — Model Context Protocol for AI integration", + "main": "./dist/index.js", + "bin": { + "neip-mcp": "./dist/index.js" + }, + "scripts": { + "build": "tsc --build", + "dev": "tsc --build --watch", + "start": "node dist/index.js", + "clean": "rm -rf dist .turbo tsconfig.tsbuildinfo" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.1", + "zod": "^3.24.0" + }, + "devDependencies": { + "@types/node": "25.5.0", + "typescript": "*" + } +} diff --git a/apps/mcp/src/api.ts b/apps/mcp/src/api.ts new file mode 100644 index 0000000..d718668 --- /dev/null +++ b/apps/mcp/src/api.ts @@ -0,0 +1,33 @@ +/** + * API helper and auth token management for nEIP MCP Server. + */ + +const API_BASE = process.env['NEIP_API_URL'] ?? 'http://localhost:5400'; +let authToken: string | null = process.env['NEIP_TOKEN'] ?? null; + +export function setAuthToken(token: string): void { + authToken = token; +} + +export async function apiCall(method: string, path: string, body?: unknown): Promise { + const headers: Record = { + 'Content-Type': 'application/json', + }; + if (authToken) { + headers['Authorization'] = `Bearer ${authToken}`; + } + + const init: RequestInit = { method, headers }; + if (body) { + init.body = JSON.stringify(body); + } + const res = await fetch(`${API_BASE}/api/v1${path}`, init); + + if (!res.ok) { + const err = await res.json().catch(() => ({ detail: res.statusText })) as Record; + throw new Error(`API ${res.status}: ${(err['detail'] as string) ?? res.statusText}`); + } + + if (res.status === 204) return undefined as T; + return res.json() as Promise; +} diff --git a/apps/mcp/src/index.ts b/apps/mcp/src/index.ts new file mode 100644 index 0000000..0131359 --- /dev/null +++ b/apps/mcp/src/index.ts @@ -0,0 +1,40 @@ +#!/usr/bin/env node +/** + * nEIP MCP Server — Model Context Protocol for AI integration. + * + * Exposes nEIP ERP data and operations as MCP tools that AI agents + * (Claude Desktop, Cursor, etc.) can call directly. + * + * Transport: stdio (standard MCP transport) + * Auth: Uses the same REST API with JWT token + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { registerAuthTools } from './tools/auth.js'; +import { registerListTools } from './tools/list.js'; +import { registerCreateTools } from './tools/create.js'; +import { registerActionTools } from './tools/action.js'; +import { registerReportTools } from './tools/report.js'; + +const server = new McpServer({ + name: 'neip-erp', + version: '1.0.0', +}); + +registerAuthTools(server); +registerListTools(server); +registerCreateTools(server); +registerActionTools(server); +registerReportTools(server); + +async function main(): Promise { + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error('nEIP MCP Server running on stdio'); +} + +main().catch((err) => { + console.error('MCP Server error:', err); + process.exit(1); +}); diff --git a/apps/mcp/src/tools/action.ts b/apps/mcp/src/tools/action.ts new file mode 100644 index 0000000..7b8bf20 --- /dev/null +++ b/apps/mcp/src/tools/action.ts @@ -0,0 +1,108 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { apiCall } from '../api.js'; + +export function registerActionTools(server: McpServer): void { + // --------------------------------------------------------------------------- + // Tool: post_invoice + // --------------------------------------------------------------------------- + + server.tool( + 'post_invoice', + 'Post ใบแจ้งหนี้ (draft → posted) — Post an invoice, creating journal entries', + { + invoiceId: z.string().describe('Invoice ID to post'), + }, + async ({ invoiceId }) => { + try { + const data = await apiCall>('POST', `/invoices/${invoiceId}/post`); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); + + // --------------------------------------------------------------------------- + // Tool: void_invoice + // --------------------------------------------------------------------------- + + server.tool( + 'void_invoice', + 'ยกเลิกใบแจ้งหนี้ — Void an invoice, preventing further payment', + { + invoiceId: z.string().describe('Invoice ID to void'), + }, + async ({ invoiceId }) => { + try { + const data = await apiCall>('POST', `/invoices/${invoiceId}/void`); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); + + // --------------------------------------------------------------------------- + // Tool: post_bill + // --------------------------------------------------------------------------- + + server.tool( + 'post_bill', + 'Post บิล (draft → posted) — Post a bill, creating journal entries', + { + billId: z.string().describe('Bill ID to post'), + }, + async ({ billId }) => { + try { + const data = await apiCall>('POST', `/bills/${billId}/post`); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); + + // --------------------------------------------------------------------------- + // Tool: close_fiscal_period + // --------------------------------------------------------------------------- + + server.tool( + 'close_fiscal_period', + 'ปิดงวดบัญชี — Close a fiscal period to prevent further postings', + { + periodId: z.string().describe('Fiscal period ID to close'), + }, + async ({ periodId }) => { + try { + const data = await apiCall>('POST', `/fiscal-periods/${periodId}/close`); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); + + // --------------------------------------------------------------------------- + // Tool: month_end_close + // --------------------------------------------------------------------------- + + server.tool( + 'month_end_close', + 'ปิดงวดสิ้นเดือน (month-end close) — Run month-end closing procedures', + { + fiscalYear: z.number().describe('Fiscal year (e.g. 2026)'), + fiscalPeriod: z.number().describe('Fiscal period number (1-12)'), + }, + async ({ fiscalYear, fiscalPeriod }) => { + try { + const data = await apiCall>('POST', '/month-end/close', { + fiscalYear, fiscalPeriod, + }); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); +} diff --git a/apps/mcp/src/tools/auth.ts b/apps/mcp/src/tools/auth.ts new file mode 100644 index 0000000..b50f29a --- /dev/null +++ b/apps/mcp/src/tools/auth.ts @@ -0,0 +1,27 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { apiCall, setAuthToken } from '../api.js'; + +export function registerAuthTools(server: McpServer): void { + // --------------------------------------------------------------------------- + // Tool: auth_login + // --------------------------------------------------------------------------- + + server.tool( + 'auth_login', + 'เข้าสู่ระบบ nEIP — Login and get JWT token', + { + email: z.string().describe('Email address'), + password: z.string().describe('Password'), + }, + async ({ email, password }) => { + try { + const data = await apiCall<{ accessToken: string }>('POST', '/auth/login', { email, password }); + setAuthToken(data.accessToken); + return { content: [{ type: 'text' as const, text: `Login successful. Token set.` }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Login failed: ${(e as Error).message}` }], isError: true }; + } + }, + ); +} diff --git a/apps/mcp/src/tools/create.ts b/apps/mcp/src/tools/create.ts new file mode 100644 index 0000000..60a3116 --- /dev/null +++ b/apps/mcp/src/tools/create.ts @@ -0,0 +1,268 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { apiCall } from '../api.js'; + +export function registerCreateTools(server: McpServer): void { + // --------------------------------------------------------------------------- + // Tool: create_invoice + // --------------------------------------------------------------------------- + + server.tool( + 'create_invoice', + 'สร้างใบแจ้งหนี้ — Create a new invoice', + { + customerId: z.string().describe('Customer ID'), + dueDate: z.string().describe('Due date (YYYY-MM-DD)'), + lines: z.array(z.object({ + description: z.string(), + quantity: z.number(), + unitPriceSatang: z.string().describe('Unit price in satang'), + accountId: z.string(), + })).describe('Invoice line items'), + }, + async ({ customerId, dueDate, lines }) => { + try { + const data = await apiCall>('POST', '/invoices', { customerId, dueDate, lines }); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); + + // --------------------------------------------------------------------------- + // Tool: create_journal_entry + // --------------------------------------------------------------------------- + + server.tool( + 'create_journal_entry', + 'สร้างรายการบัญชี — Create a journal entry', + { + description: z.string().describe('Journal entry description'), + fiscalYear: z.number().describe('Fiscal year'), + fiscalPeriod: z.number().describe('Fiscal period (1-12)'), + lines: z.array(z.object({ + accountId: z.string(), + description: z.string(), + debitSatang: z.string().describe('Debit amount in satang (0 if credit)'), + creditSatang: z.string().describe('Credit amount in satang (0 if debit)'), + })).describe('Journal entry lines (must balance)'), + }, + async ({ description, fiscalYear, fiscalPeriod, lines }) => { + try { + const data = await apiCall>('POST', '/journal-entries', { description, fiscalYear, fiscalPeriod, lines }); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); + + // --------------------------------------------------------------------------- + // Tool: create_quotation + // --------------------------------------------------------------------------- + + server.tool( + 'create_quotation', + 'สร้างใบเสนอราคา — Create a new quotation (ใบเสนอราคา)', + { + customerId: z.string().describe('Customer ID'), + customerName: z.string().describe('Customer name'), + subject: z.string().describe('Quotation subject/title'), + validUntil: z.string().describe('Validity date (YYYY-MM-DD)'), + notes: z.string().optional().describe('Optional notes'), + lines: z.array(z.object({ + description: z.string(), + quantity: z.number(), + unitPriceSatang: z.string().describe('Unit price in satang (1 THB = 100 satang)'), + })).describe('Line items'), + }, + async ({ customerId, customerName, subject, validUntil, notes, lines }) => { + try { + const data = await apiCall>('POST', '/quotations', { + customerId, customerName, subject, validUntil, notes, lines, + }); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); + + // --------------------------------------------------------------------------- + // Tool: create_sales_order + // --------------------------------------------------------------------------- + + server.tool( + 'create_sales_order', + 'สร้างใบสั่งขาย — Create a new sales order (ใบสั่งขาย)', + { + customerId: z.string().describe('Customer ID'), + customerName: z.string().describe('Customer name'), + orderDate: z.string().describe('Order date (YYYY-MM-DD)'), + expectedDeliveryDate: z.string().optional().describe('Expected delivery date (YYYY-MM-DD)'), + quotationId: z.string().optional().describe('Source quotation ID'), + notes: z.string().optional().describe('Optional notes'), + lines: z.array(z.object({ + description: z.string(), + quantity: z.number(), + unitPriceSatang: z.string().describe('Unit price in satang'), + })).describe('Line items'), + }, + async ({ customerId, customerName, orderDate, expectedDeliveryDate, quotationId, notes, lines }) => { + try { + const data = await apiCall>('POST', '/sales-orders', { + customerId, customerName, orderDate, expectedDeliveryDate, quotationId, notes, lines, + }); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); + + // --------------------------------------------------------------------------- + // Tool: create_bill + // --------------------------------------------------------------------------- + + server.tool( + 'create_bill', + 'สร้างบิลค่าใช้จ่าย (AP) — Create a new bill (Accounts Payable)', + { + vendorId: z.string().describe('Vendor ID'), + billDate: z.string().describe('Bill date (YYYY-MM-DD)'), + dueDate: z.string().describe('Due date (YYYY-MM-DD)'), + reference: z.string().optional().describe('Vendor reference or PO number'), + notes: z.string().optional().describe('Optional notes'), + lines: z.array(z.object({ + description: z.string(), + quantity: z.number(), + unitPrice: z.number().describe('Unit price in THB'), + accountId: z.string().describe('Expense account ID'), + })).describe('Bill line items'), + }, + async ({ vendorId, billDate, dueDate, reference, notes, lines }) => { + try { + const data = await apiCall>('POST', '/bills', { + vendorId, billDate, dueDate, reference, notes, lines, + }); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); + + // --------------------------------------------------------------------------- + // Tool: create_purchase_order + // --------------------------------------------------------------------------- + + server.tool( + 'create_purchase_order', + 'สร้างใบสั่งซื้อ — Create a new purchase order (ใบสั่งซื้อ)', + { + vendorId: z.string().describe('Vendor ID'), + orderDate: z.string().describe('Order date (YYYY-MM-DD)'), + expectedDate: z.string().optional().describe('Expected delivery date (YYYY-MM-DD)'), + notes: z.string().optional().describe('Optional notes'), + lines: z.array(z.object({ + description: z.string(), + quantity: z.number(), + unitPriceSatang: z.string().describe('Unit price in satang'), + })).describe('Line items'), + }, + async ({ vendorId, orderDate, expectedDate, notes, lines }) => { + try { + const data = await apiCall>('POST', '/purchase-orders', { + vendorId, orderDate, expectedDate, notes, lines, + }); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); + + // --------------------------------------------------------------------------- + // Tool: create_contact + // --------------------------------------------------------------------------- + + server.tool( + 'create_contact', + 'สร้าง contact ลูกค้า/ผู้ขาย — Create a new contact (customer or vendor)', + { + contactType: z.enum(['customer', 'vendor', 'both']).describe('Contact type'), + companyName: z.string().describe('Company name'), + email: z.string().optional().describe('Email address'), + phone: z.string().optional().describe('Phone number'), + taxId: z.string().optional().describe('Tax ID (13 digits)'), + province: z.string().optional().describe('Province'), + }, + async ({ contactType, companyName, email, phone, taxId, province }) => { + try { + const data = await apiCall>('POST', '/contacts', { + contactType, companyName, email, phone, taxId, province, + }); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); + + // --------------------------------------------------------------------------- + // Tool: create_product + // --------------------------------------------------------------------------- + + server.tool( + 'create_product', + 'สร้างสินค้าใหม่ — Create a new product', + { + sku: z.string().describe('Product SKU code'), + nameTh: z.string().describe('Product name in Thai'), + nameEn: z.string().describe('Product name in English'), + unit: z.string().optional().default('ชิ้น').describe('Unit of measure'), + costPriceSatang: z.number().optional().default(0).describe('Cost price in satang'), + sellingPriceSatang: z.number().optional().default(0).describe('Selling price in satang'), + minStockLevel: z.number().optional().default(0).describe('Minimum stock level'), + }, + async ({ sku, nameTh, nameEn, unit, costPriceSatang, sellingPriceSatang, minStockLevel }) => { + try { + const data = await apiCall>('POST', '/products', { + sku, nameTh, nameEn, unit, costPriceSatang, sellingPriceSatang, minStockLevel, + }); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); + + // --------------------------------------------------------------------------- + // Tool: create_employee + // --------------------------------------------------------------------------- + + server.tool( + 'create_employee', + 'เพิ่มพนักงานใหม่ — Create a new employee record', + { + employeeCode: z.string().describe('Employee code (e.g. EMP-001)'), + firstNameTh: z.string().describe('First name in Thai'), + lastNameTh: z.string().describe('Last name in Thai'), + hireDate: z.string().describe('Hire date (YYYY-MM-DD)'), + position: z.string().optional().describe('Job position/title'), + salarySatang: z.number().optional().default(0).describe('Monthly salary in satang'), + departmentId: z.string().optional().describe('Department ID'), + }, + async ({ employeeCode, firstNameTh, lastNameTh, hireDate, position, salarySatang, departmentId }) => { + try { + const data = await apiCall>('POST', '/employees', { + employeeCode, firstNameTh, lastNameTh, hireDate, position, salarySatang, departmentId, + }); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); +} diff --git a/apps/mcp/src/tools/list.ts b/apps/mcp/src/tools/list.ts new file mode 100644 index 0000000..34415a4 --- /dev/null +++ b/apps/mcp/src/tools/list.ts @@ -0,0 +1,670 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { apiCall } from '../api.js'; + +export function registerListTools(server: McpServer): void { + // --------------------------------------------------------------------------- + // Tool: list_accounts + // --------------------------------------------------------------------------- + + server.tool( + 'list_accounts', + 'ดูผังบัญชี — List chart of accounts', + { + limit: z.number().optional().default(50).describe('Max items'), + }, + async ({ limit }) => { + try { + const data = await apiCall>('GET', `/accounts?limit=${limit}`); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); + + // --------------------------------------------------------------------------- + // Tool: list_invoices + // --------------------------------------------------------------------------- + + server.tool( + 'list_invoices', + 'ดูรายการใบแจ้งหนี้ — List invoices', + { + status: z.string().optional().describe('Filter by status: draft, posted, paid, voided'), + limit: z.number().optional().default(20).describe('Max items'), + }, + async ({ status, limit }) => { + try { + let path = `/invoices?limit=${limit}`; + if (status) path += `&status=${status}`; + const data = await apiCall>('GET', path); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); + + // --------------------------------------------------------------------------- + // Tool: list_bills + // --------------------------------------------------------------------------- + + server.tool( + 'list_bills', + 'ดูรายการบิล — List bills (AP)', + { + limit: z.number().optional().default(20).describe('Max items'), + }, + async ({ limit }) => { + try { + const data = await apiCall>('GET', `/bills?limit=${limit}`); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); + + // --------------------------------------------------------------------------- + // Tool: list_contacts + // --------------------------------------------------------------------------- + + server.tool( + 'list_contacts', + 'ดูทะเบียนลูกค้า/ผู้ขาย — List contacts (CRM)', + { + type: z.enum(['customer', 'vendor', 'both']).optional().describe('Contact type filter'), + limit: z.number().optional().default(20).describe('Max items'), + }, + async ({ type, limit }) => { + try { + let path = `/contacts?limit=${limit}`; + if (type) path += `&type=${type}`; + const data = await apiCall>('GET', path); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); + + // --------------------------------------------------------------------------- + // Tool: list_products + // --------------------------------------------------------------------------- + + server.tool( + 'list_products', + 'ดูสินค้า — List products', + { + limit: z.number().optional().default(20).describe('Max items'), + }, + async ({ limit }) => { + try { + const data = await apiCall>('GET', `/products?limit=${limit}`); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); + + // --------------------------------------------------------------------------- + // Tool: list_employees + // --------------------------------------------------------------------------- + + server.tool( + 'list_employees', + 'ดูพนักงาน — List employees', + { + limit: z.number().optional().default(20).describe('Max items'), + }, + async ({ limit }) => { + try { + const data = await apiCall>('GET', `/employees?limit=${limit}`); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); + + // --------------------------------------------------------------------------- + // Tool: list_journal_entries + // --------------------------------------------------------------------------- + + server.tool( + 'list_journal_entries', + 'ดูรายการบัญชี — List journal entries', + { + status: z.string().optional().describe('Filter by status: draft, posted, voided'), + limit: z.number().optional().default(20).describe('Max items'), + offset: z.number().optional().default(0).describe('Offset for pagination'), + }, + async ({ status, limit, offset }) => { + try { + let path = `/journal-entries?limit=${limit}&offset=${offset}`; + if (status) path += `&status=${status}`; + const data = await apiCall>('GET', path); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); + + // --------------------------------------------------------------------------- + // Tool: list_payments + // --------------------------------------------------------------------------- + + server.tool( + 'list_payments', + 'ดูรายการรับชำระเงิน (AR) — List AR payments', + { + customerId: z.string().optional().describe('Filter by customer ID'), + status: z.string().optional().describe('Filter by status'), + limit: z.number().optional().default(20).describe('Max items'), + }, + async ({ customerId, status, limit }) => { + try { + let path = `/payments?limit=${limit}`; + if (customerId) path += `&customerId=${customerId}`; + if (status) path += `&status=${status}`; + const data = await apiCall>('GET', path); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); + + // --------------------------------------------------------------------------- + // Tool: list_quotations + // --------------------------------------------------------------------------- + + server.tool( + 'list_quotations', + 'ดูรายการใบเสนอราคา — List quotations', + { + status: z.string().optional().describe('Filter by status: draft, sent, approved, rejected, converted, expired'), + customerId: z.string().optional().describe('Filter by customer ID'), + limit: z.number().optional().default(20).describe('Max items'), + }, + async ({ status, customerId, limit }) => { + try { + let path = `/quotations?limit=${limit}`; + if (status) path += `&status=${status}`; + if (customerId) path += `&customerId=${customerId}`; + const data = await apiCall>('GET', path); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); + + // --------------------------------------------------------------------------- + // Tool: list_sales_orders + // --------------------------------------------------------------------------- + + server.tool( + 'list_sales_orders', + 'ดูรายการใบสั่งขาย — List sales orders (ใบสั่งขาย)', + { + status: z.string().optional().describe('Filter by status: draft, confirmed, delivered, cancelled'), + customerId: z.string().optional().describe('Filter by customer ID'), + limit: z.number().optional().default(20).describe('Max items'), + }, + async ({ status, customerId, limit }) => { + try { + let path = `/sales-orders?limit=${limit}`; + if (status) path += `&status=${status}`; + if (customerId) path += `&customerId=${customerId}`; + const data = await apiCall>('GET', path); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); + + // --------------------------------------------------------------------------- + // Tool: list_delivery_notes + // --------------------------------------------------------------------------- + + server.tool( + 'list_delivery_notes', + 'ดูรายการใบส่งของ — List delivery notes (ใบส่งของ)', + { + status: z.string().optional().describe('Filter by status'), + salesOrderId: z.string().optional().describe('Filter by sales order ID'), + limit: z.number().optional().default(20).describe('Max items'), + }, + async ({ status, salesOrderId, limit }) => { + try { + let path = `/delivery-notes?limit=${limit}`; + if (status) path += `&status=${status}`; + if (salesOrderId) path += `&salesOrderId=${salesOrderId}`; + const data = await apiCall>('GET', path); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); + + // --------------------------------------------------------------------------- + // Tool: list_receipts + // --------------------------------------------------------------------------- + + server.tool( + 'list_receipts', + 'ดูรายการใบเสร็จรับเงิน — List receipts (ใบเสร็จรับเงิน)', + { + status: z.string().optional().describe('Filter by status: issued, voided'), + customerId: z.string().optional().describe('Filter by customer ID'), + limit: z.number().optional().default(20).describe('Max items'), + }, + async ({ status, customerId, limit }) => { + try { + let path = `/receipts?limit=${limit}`; + if (status) path += `&status=${status}`; + if (customerId) path += `&customerId=${customerId}`; + const data = await apiCall>('GET', path); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); + + // --------------------------------------------------------------------------- + // Tool: list_credit_notes + // --------------------------------------------------------------------------- + + server.tool( + 'list_credit_notes', + 'ดูรายการใบลดหนี้ — List credit notes (ใบลดหนี้)', + { + status: z.string().optional().describe('Filter by status: draft, issued, voided'), + customerId: z.string().optional().describe('Filter by customer ID'), + limit: z.number().optional().default(20).describe('Max items'), + }, + async ({ status, customerId, limit }) => { + try { + let path = `/credit-notes?limit=${limit}`; + if (status) path += `&status=${status}`; + if (customerId) path += `&customerId=${customerId}`; + const data = await apiCall>('GET', path); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); + + // --------------------------------------------------------------------------- + // Tool: list_purchase_orders + // --------------------------------------------------------------------------- + + server.tool( + 'list_purchase_orders', + 'ดูรายการใบสั่งซื้อ — List purchase orders (ใบสั่งซื้อ)', + { + status: z.string().optional().describe('Filter by status: draft, sent, received, cancelled'), + vendorId: z.string().optional().describe('Filter by vendor ID'), + limit: z.number().optional().default(20).describe('Max items'), + }, + async ({ status, vendorId, limit }) => { + try { + let path = `/purchase-orders?limit=${limit}`; + if (status) path += `&status=${status}`; + if (vendorId) path += `&vendorId=${vendorId}`; + const data = await apiCall>('GET', path); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); + + // --------------------------------------------------------------------------- + // Tool: list_vendors + // --------------------------------------------------------------------------- + + server.tool( + 'list_vendors', + 'ดูรายการผู้ขาย — List vendors', + { + search: z.string().optional().describe('Search by name or tax ID'), + limit: z.number().optional().default(20).describe('Max items'), + }, + async ({ search, limit }) => { + try { + let path = `/vendors?limit=${limit}`; + if (search) path += `&search=${encodeURIComponent(search)}`; + const data = await apiCall>('GET', path); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); + + // --------------------------------------------------------------------------- + // Tool: list_departments + // --------------------------------------------------------------------------- + + server.tool( + 'list_departments', + 'ดูรายการแผนก — List departments (HR)', + {}, + async () => { + try { + const data = await apiCall>('GET', '/departments'); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); + + // --------------------------------------------------------------------------- + // Tool: list_payroll + // --------------------------------------------------------------------------- + + server.tool( + 'list_payroll', + 'ดูรายการเงินเดือน — List payroll runs', + { + status: z.string().optional().describe('Filter by status: draft, calculated, approved, paid'), + limit: z.number().optional().default(20).describe('Max items'), + }, + async ({ status, limit }) => { + try { + let path = `/payroll?limit=${limit}`; + if (status) path += `&status=${status}`; + const data = await apiCall>('GET', path); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); + + // --------------------------------------------------------------------------- + // Tool: list_leave_requests + // --------------------------------------------------------------------------- + + server.tool( + 'list_leave_requests', + 'ดูรายการคำขอลา — List leave requests', + { + status: z.string().optional().describe('Filter by status: pending, approved, rejected'), + employeeId: z.string().optional().describe('Filter by employee ID'), + limit: z.number().optional().default(20).describe('Max items'), + }, + async ({ status, employeeId, limit }) => { + try { + let path = `/leave-requests?limit=${limit}`; + if (status) path += `&status=${status}`; + if (employeeId) path += `&employeeId=${employeeId}`; + const data = await apiCall>('GET', path); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); + + // --------------------------------------------------------------------------- + // Tool: list_fixed_assets + // --------------------------------------------------------------------------- + + server.tool( + 'list_fixed_assets', + 'ดูรายการสินทรัพย์ถาวร — List fixed assets (FI-AA)', + { + category: z.string().optional().describe('Filter by category: equipment, vehicle, building, land, furniture, it_equipment, other'), + status: z.string().optional().describe('Filter by status: active, disposed, written_off'), + limit: z.number().optional().default(20).describe('Max items'), + }, + async ({ category, status, limit }) => { + try { + let path = `/fixed-assets?limit=${limit}`; + if (category) path += `&category=${category}`; + if (status) path += `&status=${status}`; + const data = await apiCall>('GET', path); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); + + // --------------------------------------------------------------------------- + // Tool: list_bank_accounts + // --------------------------------------------------------------------------- + + server.tool( + 'list_bank_accounts', + 'ดูรายการบัญชีธนาคาร — List bank accounts (FI-BL)', + {}, + async () => { + try { + const data = await apiCall>('GET', '/bank-accounts'); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); + + // --------------------------------------------------------------------------- + // Tool: list_wht_certificates + // --------------------------------------------------------------------------- + + server.tool( + 'list_wht_certificates', + 'ดูรายการใบหัก ณ ที่จ่าย — List WHT certificates (ภ.ง.ด.3/53)', + { + status: z.string().optional().describe('Filter by status: draft, issued, filed, voided'), + taxYear: z.number().optional().describe('Filter by tax year'), + taxMonth: z.number().optional().describe('Filter by tax month (1-12)'), + limit: z.number().optional().default(20).describe('Max items'), + }, + async ({ status, taxYear, taxMonth, limit }) => { + try { + let path = `/wht-certificates?limit=${limit}`; + if (status) path += `&status=${status}`; + if (taxYear) path += `&taxYear=${taxYear}`; + if (taxMonth) path += `&taxMonth=${taxMonth}`; + const data = await apiCall>('GET', path); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); + + // --------------------------------------------------------------------------- + // Tool: list_tax_rates + // --------------------------------------------------------------------------- + + server.tool( + 'list_tax_rates', + 'ดูรายการอัตราภาษี — List tax rates (VAT, WHT)', + { + limit: z.number().optional().default(50).describe('Max items'), + }, + async ({ limit }) => { + try { + const data = await apiCall>('GET', `/tax-rates?limit=${limit}`); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); + + // --------------------------------------------------------------------------- + // Tool: list_cost_centers + // --------------------------------------------------------------------------- + + server.tool( + 'list_cost_centers', + 'ดูรายการศูนย์ต้นทุน — List cost centers (CO-CCA)', + {}, + async () => { + try { + const data = await apiCall>('GET', '/cost-centers'); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); + + // --------------------------------------------------------------------------- + // Tool: list_profit_centers + // --------------------------------------------------------------------------- + + server.tool( + 'list_profit_centers', + 'ดูรายการศูนย์กำไร — List profit centers (CO-PCA)', + {}, + async () => { + try { + const data = await apiCall>('GET', '/profit-centers'); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); + + // --------------------------------------------------------------------------- + // Tool: list_budgets + // --------------------------------------------------------------------------- + + server.tool( + 'list_budgets', + 'ดูรายการงบประมาณ — List budgets', + { + year: z.number().optional().describe('Filter by fiscal year'), + limit: z.number().optional().default(20).describe('Max items'), + }, + async ({ year, limit }) => { + try { + let path = `/budgets?limit=${limit}`; + if (year) path += `&year=${year}`; + const data = await apiCall>('GET', path); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); + + // --------------------------------------------------------------------------- + // Tool: list_roles + // --------------------------------------------------------------------------- + + server.tool( + 'list_roles', + 'ดูรายการ roles — List roles and permissions', + { + limit: z.number().optional().default(50).describe('Max items'), + }, + async ({ limit }) => { + try { + const data = await apiCall>('GET', `/roles?limit=${limit}`); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); + + // --------------------------------------------------------------------------- + // Tool: list_webhooks + // --------------------------------------------------------------------------- + + server.tool( + 'list_webhooks', + 'ดูรายการ webhooks — List webhook subscriptions', + { + limit: z.number().optional().default(50).describe('Max items'), + }, + async ({ limit }) => { + try { + const data = await apiCall>('GET', `/webhooks?limit=${limit}`); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); + + // --------------------------------------------------------------------------- + // Tool: list_fiscal_years + // --------------------------------------------------------------------------- + + server.tool( + 'list_fiscal_years', + 'ดูรายการปีบัญชี — List fiscal years', + { + limit: z.number().optional().default(20).describe('Max items'), + }, + async ({ limit }) => { + try { + const data = await apiCall>('GET', `/fiscal-years?limit=${limit}`); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); + + // --------------------------------------------------------------------------- + // Tool: list_stock_levels + // --------------------------------------------------------------------------- + + server.tool( + 'list_stock_levels', + 'ดูระดับสต็อกสินค้า — List current stock levels', + { + productId: z.string().optional().describe('Filter by product ID'), + }, + async ({ productId }) => { + try { + let path = '/stock-levels'; + if (productId) path += `?productId=${productId}`; + const data = await apiCall>('GET', path); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); + + // --------------------------------------------------------------------------- + // Tool: get_organization + // --------------------------------------------------------------------------- + + server.tool( + 'get_organization', + 'ดูข้อมูลองค์กร — Get organization settings and details', + { + organizationId: z.string().describe('Organization ID (tenantId from JWT)'), + }, + async ({ organizationId }) => { + try { + const data = await apiCall>('GET', `/organizations/${organizationId}`); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); +} diff --git a/apps/mcp/src/tools/report.ts b/apps/mcp/src/tools/report.ts new file mode 100644 index 0000000..fe39539 --- /dev/null +++ b/apps/mcp/src/tools/report.ts @@ -0,0 +1,198 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { apiCall } from '../api.js'; + +export function registerReportTools(server: McpServer): void { + // --------------------------------------------------------------------------- + // Tool: dashboard + // --------------------------------------------------------------------------- + + server.tool( + 'dashboard', + 'ดูภาพรวมธุรกิจ — Executive dashboard with KPIs', + {}, + async () => { + try { + const data = await apiCall>('GET', '/dashboard/executive'); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); + + // --------------------------------------------------------------------------- + // Tool: report_trial_balance + // --------------------------------------------------------------------------- + + server.tool( + 'report_trial_balance', + 'งบทดลอง — Trial balance report', + { + fiscalYear: z.number().optional().describe('Fiscal year e.g. 2026'), + }, + async ({ fiscalYear }) => { + try { + const qs = fiscalYear ? `?fiscalYear=${fiscalYear}` : ''; + const data = await apiCall>('GET', `/reports/trial-balance${qs}`); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); + + // --------------------------------------------------------------------------- + // Tool: report_pnl + // --------------------------------------------------------------------------- + + server.tool( + 'report_pnl', + 'งบกำไรขาดทุน — P&L comparison (monthly/ytd/yoy/mom)', + { + mode: z.enum(['monthly', 'ytd', 'yoy', 'mom']).describe('Report mode'), + fiscalYear: z.number().describe('Fiscal year'), + }, + async ({ mode, fiscalYear }) => { + try { + const data = await apiCall>('GET', `/reports/pnl-comparison?mode=${mode}&fiscalYear=${fiscalYear}`); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); + + // --------------------------------------------------------------------------- + // Tool: report_income_statement + // --------------------------------------------------------------------------- + + server.tool( + 'report_income_statement', + 'งบกำไรขาดทุน — Income statement report', + { + startDate: z.string().optional().describe('Start date (YYYY-MM-DD)'), + endDate: z.string().optional().describe('End date (YYYY-MM-DD)'), + }, + async ({ startDate, endDate }) => { + try { + const params: string[] = []; + if (startDate) params.push(`startDate=${startDate}`); + if (endDate) params.push(`endDate=${endDate}`); + const qs = params.length > 0 ? `?${params.join('&')}` : ''; + const data = await apiCall>('GET', `/reports/income-statement${qs}`); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); + + // --------------------------------------------------------------------------- + // Tool: report_balance_sheet + // --------------------------------------------------------------------------- + + server.tool( + 'report_balance_sheet', + 'งบดุล — Balance sheet report', + { + asOf: z.string().optional().describe('As-of date (YYYY-MM-DD), defaults to today'), + }, + async ({ asOf }) => { + try { + const qs = asOf ? `?asOf=${asOf}` : ''; + const data = await apiCall>('GET', `/reports/balance-sheet${qs}`); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); + + // --------------------------------------------------------------------------- + // Tool: report_budget_variance + // --------------------------------------------------------------------------- + + server.tool( + 'report_budget_variance', + 'รายงานงบประมาณเทียบจริง — Budget vs actual variance report', + { + year: z.number().optional().describe('Fiscal year (e.g. 2026)'), + period: z.number().optional().describe('Fiscal period (1-12)'), + }, + async ({ year, period }) => { + try { + const params: string[] = []; + if (year) params.push(`year=${year}`); + if (period) params.push(`period=${period}`); + const qs = params.length > 0 ? `?${params.join('&')}` : ''; + const data = await apiCall>('GET', `/reports/budget-variance${qs}`); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); + + // --------------------------------------------------------------------------- + // Tool: report_ar_aging + // --------------------------------------------------------------------------- + + server.tool( + 'report_ar_aging', + 'รายงานอายุลูกหนี้ — Accounts receivable aging report', + { + asOf: z.string().optional().describe('As-of date (YYYY-MM-DD), defaults to today'), + }, + async ({ asOf }) => { + try { + const qs = asOf ? `?asOf=${asOf}` : ''; + const data = await apiCall>('GET', `/reports/ar-aging${qs}`); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); + + // --------------------------------------------------------------------------- + // Tool: report_ap_aging + // --------------------------------------------------------------------------- + + server.tool( + 'report_ap_aging', + 'รายงานอายุเจ้าหนี้ — Accounts payable aging report', + { + asOf: z.string().optional().describe('As-of date (YYYY-MM-DD), defaults to today'), + }, + async ({ asOf }) => { + try { + const qs = asOf ? `?asOf=${asOf}` : ''; + const data = await apiCall>('GET', `/reports/ap-aging${qs}`); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); + + // --------------------------------------------------------------------------- + // Tool: audit_logs + // --------------------------------------------------------------------------- + + server.tool( + 'audit_logs', + 'ดูบันทึกการเปลี่ยนแปลง — View audit trail', + { + limit: z.number().optional().default(20).describe('Max items'), + }, + async ({ limit }) => { + try { + const data = await apiCall>('GET', `/audit-logs?limit=${limit}`); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text' as const, text: `Error: ${(e as Error).message}` }], isError: true }; + } + }, + ); +} diff --git a/apps/mcp/tsconfig.json b/apps/mcp/tsconfig.json new file mode 100644 index 0000000..b944e39 --- /dev/null +++ b/apps/mcp/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "module": "NodeNext", + "moduleResolution": "NodeNext" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/package.json b/package.json index e8052f6..e03222c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "neip", - "version": "0.0.0", + "version": "0.3.0", "private": true, "type": "module", "description": "nEIP — next-generation EIP monorepo", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ecad6ce..e839931 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -131,6 +131,22 @@ importers: specifier: '*' version: 5.9.3 + apps/mcp: + dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.12.1 + version: 1.27.1(@cfworker/json-schema@4.1.1)(zod@3.25.76) + zod: + specifier: ^3.24.0 + version: 3.25.76 + devDependencies: + '@types/node': + specifier: 25.5.0 + version: 25.5.0 + typescript: + specifier: '*' + version: 5.9.3 + apps/web: dependencies: '@hookform/resolvers': @@ -236,7 +252,7 @@ importers: version: 1.1.32 '@langchain/langgraph': specifier: ^1.2.2 - version: 1.2.2(@langchain/core@1.1.32)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6) + version: 1.2.2(@langchain/core@1.1.32)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod-to-json-schema@3.25.1(zod@4.3.6))(zod@4.3.6) '@neip/core': specifier: workspace:* version: link:../core @@ -839,6 +855,12 @@ packages: '@fastify/swagger@9.7.0': resolution: {integrity: sha512-Vp1SC1GC2Hrkd3faFILv86BzUNyFz5N4/xdExqtCgkGASOzn/x+eMe4qXIGq7cdT6wif/P/oa6r1Ruqx19paZA==} + '@hono/node-server@1.19.11': + resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@hookform/resolvers@5.2.2': resolution: {integrity: sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==} peerDependencies: @@ -1061,6 +1083,16 @@ packages: resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} engines: {node: '>=8'} + '@modelcontextprotocol/sdk@1.27.1': + resolution: {integrity: sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -1592,6 +1624,10 @@ packages: abstract-logging@2.0.1: resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1749,6 +1785,10 @@ packages: bn.js@4.12.3: resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==} + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -1785,6 +1825,10 @@ packages: resolution: {integrity: sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==} engines: {node: '>=0.2.0'} + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -1861,9 +1905,21 @@ packages: resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} engines: {node: '>=18'} + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + cookie@1.1.1: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} @@ -1871,6 +1927,10 @@ packages: core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + crc-32@1.2.2: resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} engines: {node: '>=0.8'} @@ -2071,12 +2131,19 @@ packages: ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + electron-to-chromium@1.5.313: resolution: {integrity: sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==} emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} @@ -2274,12 +2341,24 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + eventsource-parser@3.0.6: + resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + exceljs@4.4.0: resolution: {integrity: sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==} engines: {node: '>=8.3.0'} @@ -2288,6 +2367,16 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + express-rate-limit@8.3.1: + resolution: {integrity: sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + fast-copy@4.0.2: resolution: {integrity: sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==} @@ -2363,6 +2452,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + find-my-way@9.5.0: resolution: {integrity: sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==} engines: {node: '>=20'} @@ -2382,6 +2475,14 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} @@ -2511,6 +2612,10 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + hono@4.12.8: + resolution: {integrity: sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==} + engines: {node: '>=16.9.0'} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -2518,6 +2623,10 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -2551,6 +2660,14 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + ip-address@10.1.0: + resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + ipaddr.js@2.3.0: resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} engines: {node: '>= 10'} @@ -2626,6 +2743,9 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -2691,6 +2811,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jose@6.2.1: + resolution: {integrity: sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -2729,6 +2852,9 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -3016,6 +3142,14 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -3024,6 +3158,14 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + mime@3.0.0: resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} engines: {node: '>=10.0.0'} @@ -3077,6 +3219,10 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + next@15.5.12: resolution: {integrity: sha512-Fi/wQ4Etlrn60rz78bebG1i1SR20QxvV8tVp6iJspjLUSHcZoeUXCt+vmWoEcza85ElZzExK/jJ/F6SvtGktjA==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} @@ -3167,6 +3313,10 @@ packages: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -3223,6 +3373,10 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -3242,6 +3396,9 @@ packages: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} + path-to-regexp@8.3.0: + resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -3309,6 +3466,10 @@ packages: resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} hasBin: true + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + playwright-core@1.58.2: resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} engines: {node: '>=18'} @@ -3372,6 +3533,10 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} @@ -3379,12 +3544,24 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qs@6.15.0: + resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} + engines: {node: '>=0.6'} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + react-dom@19.2.4: resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} peerDependencies: @@ -3467,6 +3644,10 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -3518,10 +3699,18 @@ packages: engines: {node: '>=10'} hasBin: true + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + serialize-error@13.0.1: resolution: {integrity: sha512-bBZaRwLH9PN5HbLCjPId4dP5bNGEtumcErgOX952IsvOhVPrm3/AeK1y0UHA/QaPG701eg0yEnOKsCOC6X/kaA==} engines: {node: '>=20'} + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} @@ -3793,6 +3982,10 @@ packages: resolution: {integrity: sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw==} engines: {node: '>=20'} + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -3828,6 +4021,10 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + unrs-resolver@1.11.1: resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} @@ -3866,6 +4063,10 @@ packages: resolution: {integrity: sha512-2VNnOC0+XQlwogChUDzy6pe8GQEys9QFZBGOh54l6qVfwoCUwwRvk7rDTgaIsRgsF5GFa5oiNg8LqXE3jofBBg==} hasBin: true + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + vite@8.0.0: resolution: {integrity: sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4000,12 +4201,20 @@ packages: resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==} engines: {node: '>= 10'} + zod-to-json-schema@3.25.1: + resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} + peerDependencies: + zod: ^3.25 || ^4 + zod-validation-error@4.0.2: resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} engines: {node: '>=18.0.0'} peerDependencies: zod: ^3.25.0 || ^4.0.0 + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} @@ -4475,6 +4684,10 @@ snapshots: transitivePeerDependencies: - supports-color + '@hono/node-server@1.19.11(hono@4.12.8)': + dependencies: + hono: 4.12.8 + '@hookform/resolvers@5.2.2(react-hook-form@7.71.2(react@19.2.4))': dependencies: '@standard-schema/utils': 0.3.0 @@ -4643,7 +4856,7 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@langchain/langgraph@1.2.2(@langchain/core@1.1.32)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6)': + '@langchain/langgraph@1.2.2(@langchain/core@1.1.32)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod-to-json-schema@3.25.1(zod@4.3.6))(zod@4.3.6)': dependencies: '@langchain/core': 1.1.32 '@langchain/langgraph-checkpoint': 1.0.0(@langchain/core@1.1.32) @@ -4651,6 +4864,8 @@ snapshots: '@standard-schema/spec': 1.1.0 uuid: 10.0.0 zod: 4.3.6 + optionalDependencies: + zod-to-json-schema: 3.25.1(zod@4.3.6) transitivePeerDependencies: - '@angular/core' - react @@ -4660,6 +4875,30 @@ snapshots: '@lukeed/ms@2.0.2': {} + '@modelcontextprotocol/sdk@1.27.1(@cfworker/json-schema@4.1.1)(zod@3.25.76)': + dependencies: + '@hono/node-server': 1.19.11(hono@4.12.8) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 8.3.1(express@5.2.1) + hono: 4.12.8 + jose: 6.2.1 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.1(zod@3.25.76) + optionalDependencies: + '@cfworker/json-schema': 4.1.1 + transitivePeerDependencies: + - supports-color + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.9.0 @@ -5114,6 +5353,11 @@ snapshots: abstract-logging@2.0.1: {} + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -5319,6 +5563,20 @@ snapshots: bn.js@4.12.3: {} + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.0 + raw-body: 3.0.2 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -5357,6 +5615,8 @@ snapshots: buffers@0.1.1: {} + bytes@3.1.2: {} + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -5426,12 +5686,23 @@ snapshots: content-disposition@1.0.1: {} + content-type@1.0.5: {} + convert-source-map@2.0.0: {} + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + cookie@1.1.1: {} core-util-is@1.0.3: {} + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + crc-32@1.2.2: {} crc32-stream@4.0.3: @@ -5542,10 +5813,14 @@ snapshots: dependencies: safe-buffer: 5.2.1 + ee-first@1.1.1: {} + electron-to-chromium@1.5.313: {} emoji-regex@9.2.2: {} + encodeurl@2.0.0: {} + end-of-stream@1.4.5: dependencies: once: 1.4.0 @@ -5939,10 +6214,18 @@ snapshots: esutils@2.0.3: {} + etag@1.8.1: {} + eventemitter3@4.0.7: {} eventemitter3@5.0.4: {} + eventsource-parser@3.0.6: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.6 + exceljs@4.4.0: dependencies: archiver: 5.3.2 @@ -5957,6 +6240,44 @@ snapshots: expect-type@1.3.0: {} + express-rate-limit@8.3.1(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.1.0 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.0.1 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.0 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + fast-copy@4.0.2: {} fast-csv@4.3.6: @@ -6054,6 +6375,17 @@ snapshots: dependencies: to-regex-range: 5.0.1 + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + find-my-way@9.5.0: dependencies: fast-deep-equal: 3.1.3 @@ -6076,6 +6408,10 @@ snapshots: dependencies: is-callable: 1.2.7 + forwarded@0.2.0: {} + + fresh@2.0.0: {} + fs-constants@1.0.0: {} fs.realpath@1.0.0: {} @@ -6206,6 +6542,8 @@ snapshots: dependencies: hermes-estree: 0.25.1 + hono@4.12.8: {} + html-escaper@2.0.2: {} http-errors@2.0.1: @@ -6216,6 +6554,10 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + ieee754@1.2.1: {} ignore@5.3.2: {} @@ -6244,6 +6586,10 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 + ip-address@10.1.0: {} + + ipaddr.js@1.9.1: {} + ipaddr.js@2.3.0: {} is-array-buffer@3.0.5: @@ -6321,6 +6667,8 @@ snapshots: is-number@7.0.0: {} + is-promise@4.0.0: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -6390,6 +6738,8 @@ snapshots: jiti@2.6.1: {} + jose@6.2.1: {} + joycon@3.1.1: {} js-tiktoken@1.0.21: @@ -6424,6 +6774,8 @@ snapshots: json-schema-traverse@1.0.0: {} + json-schema-typed@8.0.2: {} + json-stable-stringify-without-jsonify@1.0.1: {} json5@1.0.2: @@ -6648,6 +7000,10 @@ snapshots: math-intrinsics@1.1.0: {} + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + merge2@1.4.1: {} micromatch@4.0.8: @@ -6655,6 +7011,12 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + mime@3.0.0: {} minimalistic-assert@1.0.1: {} @@ -6693,6 +7055,8 @@ snapshots: natural-compare@1.4.0: {} + negotiator@1.0.0: {} + next@15.5.12(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@next/env': 15.5.12 @@ -6784,6 +7148,10 @@ snapshots: on-exit-leak-free@2.1.2: {} + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -6843,6 +7211,8 @@ snapshots: dependencies: callsites: 3.1.0 + parseurl@1.3.3: {} + path-exists@4.0.0: {} path-is-absolute@1.0.1: {} @@ -6856,6 +7226,8 @@ snapshots: lru-cache: 11.2.7 minipass: 7.1.3 + path-to-regexp@8.3.0: {} + pathe@2.0.3: {} pg-boss@12.14.0: @@ -6943,6 +7315,8 @@ snapshots: sonic-boom: 4.2.1 thread-stream: 4.0.0 + pkce-challenge@5.0.1: {} + playwright-core@1.58.2: {} playwright@1.58.2: @@ -6993,6 +7367,11 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + pump@3.0.4: dependencies: end-of-stream: 1.4.5 @@ -7000,10 +7379,23 @@ snapshots: punycode@2.3.1: {} + qs@6.15.0: + dependencies: + side-channel: 1.1.0 + queue-microtask@1.2.3: {} quick-format-unescaped@4.0.4: {} + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + react-dom@19.2.4(react@19.2.4): dependencies: react: 19.2.4 @@ -7111,6 +7503,16 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.9 '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.9 + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.3.0 + transitivePeerDependencies: + - supports-color + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -7158,11 +7560,36 @@ snapshots: semver@7.7.4: {} + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + serialize-error@13.0.1: dependencies: non-error: 0.1.0 type-fest: 5.4.4 + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + set-cookie-parser@2.7.2: {} set-function-length@1.2.2: @@ -7470,6 +7897,12 @@ snapshots: dependencies: tagged-tag: 1.0.0 + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 @@ -7525,6 +7958,8 @@ snapshots: undici-types@7.18.2: {} + unpipe@1.0.0: {} + unrs-resolver@1.11.1: dependencies: napi-postinstall: 0.3.4 @@ -7584,6 +8019,8 @@ snapshots: uuidv7@1.1.0: {} + vary@1.1.2: {} + vite@8.0.0(@types/node@25.5.0)(jiti@2.6.1)(yaml@2.8.2): dependencies: '@oxc-project/runtime': 0.115.0 @@ -7695,10 +8132,21 @@ snapshots: compress-commons: 4.1.2 readable-stream: 3.6.2 + zod-to-json-schema@3.25.1(zod@3.25.76): + dependencies: + zod: 3.25.76 + + zod-to-json-schema@3.25.1(zod@4.3.6): + dependencies: + zod: 4.3.6 + optional: true + zod-validation-error@4.0.2(zod@4.3.6): dependencies: zod: 4.3.6 + zod@3.25.76: {} + zod@4.3.6: {} zustand@5.0.11(@types/react@19.2.14)(react@19.2.4): diff --git a/test_all_flows.sh b/test_all_flows.sh new file mode 100755 index 0000000..01d47ae --- /dev/null +++ b/test_all_flows.sh @@ -0,0 +1,794 @@ +#!/usr/bin/env bash +set -e +# ============================================================================= +# nEIP — Full Integration Test Suite +# Tests ALL status transitions via curl +# ============================================================================= + +set -euo pipefail + +API="http://localhost:5400" +PASS=0 +FAIL=0 + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +pass() { echo "[PASS] $1"; PASS=$((PASS+1)); } +fail() { echo "[FAIL] $1 — $2"; FAIL=$((FAIL+1)); } + +check_status() { + local label="$1"; local expected="$2"; local actual="$3" + if [ "$actual" = "$expected" ]; then pass "$label" + else fail "$label" "expected status='$expected' got '$actual'"; fi +} + +check_http() { + local label="$1"; local expected_http="$2"; local actual_http="$3"; local body="$4" + if [ "$actual_http" = "$expected_http" ]; then pass "$label" + else fail "$label" "expected HTTP $expected_http got $actual_http — $body"; fi +} + +check_http_range() { + local label="$1"; local min="$2"; local max="$3"; local actual="$4"; local body="$5" + if [ "$actual" -ge "$min" ] && [ "$actual" -le "$max" ]; then pass "$label" + else fail "$label" "expected HTTP $min-$max got $actual — $body"; fi +} + +# GET request (with auth) +G() { curl -s -w '\n%{http_code}' -H "Authorization: Bearer $TOKEN" "$@"; } +# POST with JSON body +POST() { curl -s -w '\n%{http_code}' -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" "$@"; } +# POST without body (for action endpoints) +NOPOST() { curl -s -w '\n%{http_code}' -X POST -H "Authorization: Bearer $TOKEN" "$@"; } +# PUT with JSON body +PUT() { curl -s -w '\n%{http_code}' -X PUT -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" "$@"; } + +extract_body() { echo "$1" | head -1; } +extract_code() { echo "$1" | tail -1; } +extract_field() { + echo "$1" | head -1 | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('$2',''))" 2>/dev/null +} + +# --------------------------------------------------------------------------- +# Auth +# --------------------------------------------------------------------------- + +LOGIN=$(curl -s -w '\n%{http_code}' -X POST "$API/api/v1/auth/login" \ + -H 'Content-Type: application/json' \ + -d '{"email":"admin@neip.app","password":"SecurePass12345"}') +HTTP_CODE=$(extract_code "$LOGIN") +BODY=$(extract_body "$LOGIN") +TOKEN=$(echo "$BODY" | python3 -c "import sys,json; print(json.load(sys.stdin).get('accessToken',''))" 2>/dev/null) + +if [ -z "$TOKEN" ]; then + echo "[FAIL] Auth: login failed — $BODY" + exit 1 +fi +pass "Auth: login returns accessToken" + +# --------------------------------------------------------------------------- +# Setup: get IDs +# --------------------------------------------------------------------------- + +CUST_RESP=$(G "$API/api/v1/contacts?type=customer&limit=1") +CUST_ID=$(extract_body "$CUST_RESP" | python3 -c "import sys,json; items=json.load(sys.stdin).get('items',[]); print(items[0]['id'] if items else '')" 2>/dev/null) + +# AP Vendors (from /api/v1/vendors endpoint, separate from contacts) +VENDOR_RESP=$(G "$API/api/v1/vendors?limit=5") +AP_VENDOR_ID=$(extract_body "$VENDOR_RESP" | python3 -c " +import sys,json +data = json.load(sys.stdin) +items = data if isinstance(data,list) else data.get('items',data.get('data',[])) +for v in items: + if isinstance(v,dict) and v.get('id'): + print(v['id']); break +" 2>/dev/null) + +ACCTS_RESP=$(G "$API/api/v1/accounts?limit=100") +EXPENSE_ACCT=$(extract_body "$ACCTS_RESP" | python3 -c " +import sys,json +data = json.load(sys.stdin) +accts = data if isinstance(data,list) else data.get('items',data.get('data',[])) +for a in accts: + if isinstance(a,dict) and a.get('accountType') == 'expense': + print(a['id']); break +" 2>/dev/null) +REVENUE_ACCT=$(extract_body "$ACCTS_RESP" | python3 -c " +import sys,json +data = json.load(sys.stdin) +accts = data if isinstance(data,list) else data.get('items',data.get('data',[])) +for a in accts: + if isinstance(a,dict) and a.get('accountType') == 'revenue' and '-' not in a.get('code',''): + print(a['id']); break +" 2>/dev/null) +AR_ACCT=$(extract_body "$ACCTS_RESP" | python3 -c " +import sys,json +data = json.load(sys.stdin) +accts = data if isinstance(data,list) else data.get('items',data.get('data',[])) +for a in accts: + if isinstance(a,dict) and a.get('accountType') == 'asset' and a.get('code','').startswith('1100') and '-' not in a.get('code',''): + print(a['id']); break +" 2>/dev/null) +AP_ACCT=$(extract_body "$ACCTS_RESP" | python3 -c " +import sys,json +data = json.load(sys.stdin) +accts = data if isinstance(data,list) else data.get('items',data.get('data',[])) +for a in accts: + if isinstance(a,dict) and a.get('accountType') == 'liability' and a.get('code','').startswith('2100') and '-' not in a.get('code',''): + print(a['id']); break +" 2>/dev/null) + +# Fiscal year info +FISCAL_RESP=$(G "$API/api/v1/fiscal-years") +FISCAL_YEAR=$(extract_body "$FISCAL_RESP" | python3 -c " +import sys,json +data = json.load(sys.stdin) +fy_list = data if isinstance(data,list) else data.get('items',data.get('data',[data])) +for fy in fy_list: + if isinstance(fy,dict) and fy.get('year'): + for p in fy.get('periods',[]): + if p.get('status')=='open': + print(fy['year']); exit() +" 2>/dev/null) +FISCAL_PERIOD=$(extract_body "$FISCAL_RESP" | python3 -c " +import sys,json +data = json.load(sys.stdin) +fy_list = data if isinstance(data,list) else data.get('items',data.get('data',[data])) +for fy in fy_list: + if isinstance(fy,dict) and str(fy.get('year','')) == '$FISCAL_YEAR': + for p in fy.get('periods',[]): + if p.get('status')=='open': + print(p['periodNumber']); exit() +" 2>/dev/null) +OPEN_PERIOD_ID=$(extract_body "$FISCAL_RESP" | python3 -c " +import sys,json +data = json.load(sys.stdin) +fy_list = data if isinstance(data,list) else data.get('items',data.get('data',[data])) +for fy in fy_list: + if isinstance(fy,dict) and str(fy.get('year','')) == '$FISCAL_YEAR': + for p in fy.get('periods',[]): + if p.get('status')=='open': + print(p['id']); exit() +" 2>/dev/null) +CLOSED_YEAR=$(extract_body "$FISCAL_RESP" | python3 -c " +import sys,json +data = json.load(sys.stdin) +fy_list = data if isinstance(data,list) else data.get('items',data.get('data',[data])) +for fy in fy_list: + if isinstance(fy,dict): + for p in fy.get('periods',[]): + if p.get('status')=='closed': + print(fy['year']); exit() +" 2>/dev/null) +CLOSED_PERIOD_NUM=$(extract_body "$FISCAL_RESP" | python3 -c " +import sys,json +data = json.load(sys.stdin) +fy_list = data if isinstance(data,list) else data.get('items',data.get('data',[data])) +for fy in fy_list: + if isinstance(fy,dict): + for p in fy.get('periods',[]): + if p.get('status')=='closed': + print(p['periodNumber']); exit() +" 2>/dev/null) + +# Employees +EMP_RESP=$(G "$API/api/v1/employees?limit=5") +EMP_ID=$(extract_body "$EMP_RESP" | python3 -c " +import sys,json +data = json.load(sys.stdin) +items = data if isinstance(data,list) else data.get('items',data.get('data',[])) +for e in items: + if isinstance(e,dict) and e.get('status') == 'active': + print(e['id']); break +" 2>/dev/null) + +echo "=== Setup ===" +echo "Customer: $CUST_ID" +echo "AP Vendor: $AP_VENDOR_ID" +echo "Expense account: $EXPENSE_ACCT" +echo "Revenue account: $REVENUE_ACCT" +echo "AR account: $AR_ACCT" +echo "AP account: $AP_ACCT" +echo "Fiscal year: $FISCAL_YEAR, period: $FISCAL_PERIOD, period_id: $OPEN_PERIOD_ID" +echo "Closed year: $CLOSED_YEAR, closed period: $CLOSED_PERIOD_NUM" +echo "Employee: $EMP_ID" +echo "" + +# =========================================================================== +# QUOTATION FLOW +# =========================================================================== +echo "=== QUOTATION FLOW ===" + +R=$(POST "$API/api/v1/quotations" -d "{ + \"customerId\":\"$CUST_ID\", + \"customerName\":\"Test Customer\", + \"subject\":\"Test Quotation\", + \"validUntil\":\"2026-12-31\", + \"lines\":[{\"description\":\"Widget\",\"quantity\":2,\"unitPriceSatang\":\"10000\"}] +}") +QT_STATUS=$(extract_field "$R" "status") +QT_ID=$(extract_field "$R" "id") +check_status "Quotation: create returns status=draft" "draft" "$QT_STATUS" + +R=$(NOPOST "$API/api/v1/quotations/$QT_ID/send") +QT_STATUS=$(extract_field "$R" "status") +check_status "Quotation: send returns status=sent" "sent" "$QT_STATUS" + +R=$(NOPOST "$API/api/v1/quotations/$QT_ID/approve") +QT_STATUS=$(extract_field "$R" "status") +check_status "Quotation: approve returns status=approved" "approved" "$QT_STATUS" + +R=$(NOPOST "$API/api/v1/quotations/$QT_ID/convert") +HTTP=$(extract_code "$R") +QT_STATUS=$(extract_body "$R" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('quotation',{}).get('status',''))" 2>/dev/null) +INV_FROM_QT=$(extract_body "$R" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('invoiceId',''))" 2>/dev/null) +check_status "Quotation: convert returns quotation.status=converted (Bug 1 verify)" "converted" "$QT_STATUS" +check_http "Quotation: convert returns HTTP 201" "201" "$HTTP" "$(extract_body "$R")" + +# --- create → send → reject --- +R=$(POST "$API/api/v1/quotations" -d "{ + \"customerId\":\"$CUST_ID\", + \"customerName\":\"Test Customer\", + \"subject\":\"Reject Test\", + \"validUntil\":\"2026-12-31\", + \"lines\":[{\"description\":\"Item\",\"quantity\":1,\"unitPriceSatang\":\"5000\"}] +}") +QT2_ID=$(extract_field "$R" "id") +R=$(NOPOST "$API/api/v1/quotations/$QT2_ID/send") +R=$(POST "$API/api/v1/quotations/$QT2_ID/reject" -d '{"reason":"Price too high"}') +QT_STATUS=$(extract_field "$R" "status") +check_status "Quotation: reject returns status=rejected" "rejected" "$QT_STATUS" + +# --- create → duplicate --- +R=$(NOPOST "$API/api/v1/quotations/$QT2_ID/duplicate") +DUP_STATUS=$(extract_field "$R" "status") +check_status "Quotation: duplicate returns status=draft" "draft" "$DUP_STATUS" + +# =========================================================================== +# SALES ORDER FLOW +# =========================================================================== +echo "" +echo "=== SALES ORDER FLOW ===" + +R=$(POST "$API/api/v1/sales-orders" -d "{ + \"customerId\":\"$CUST_ID\", + \"customerName\":\"Test Customer\", + \"orderDate\":\"2026-03-16\", + \"expectedDeliveryDate\":\"2026-03-30\", + \"lines\":[{\"description\":\"Product A\",\"quantity\":5,\"unitPriceSatang\":\"20000\"}] +}") +SO_STATUS=$(extract_field "$R" "status") +SO_ID=$(extract_field "$R" "id") +SO_LINE_ID=$(extract_body "$R" | python3 -c "import sys,json; d=json.load(sys.stdin); lines=d.get('lines',[]); print(lines[0]['id'] if lines else '')" 2>/dev/null) +check_status "Sales Order: create returns status=draft" "draft" "$SO_STATUS" + +R=$(NOPOST "$API/api/v1/sales-orders/$SO_ID/confirm") +SO_STATUS=$(extract_field "$R" "status") +check_status "Sales Order: confirm returns status=confirmed" "confirmed" "$SO_STATUS" + +# =========================================================================== +# DELIVERY NOTE FLOW +# =========================================================================== +echo "" +echo "=== DELIVERY NOTE FLOW ===" + +# SO line from the confirmed SO above (need fresh line ID) +# Let's get the actual SO lines from the DB +SO_LINES_RESP=$(G "$API/api/v1/sales-orders/$SO_ID") +SO_LINE_ID=$(extract_body "$SO_LINES_RESP" | python3 -c " +import sys,json +d = json.load(sys.stdin) +lines = d.get('lines', []) +if lines: + print(lines[0].get('id','')) +" 2>/dev/null) + +if [ -n "$SO_LINE_ID" ] && [ -n "$SO_ID" ]; then + R=$(POST "$API/api/v1/delivery-notes" -d "{ + \"salesOrderId\":\"$SO_ID\", + \"customerId\":\"$CUST_ID\", + \"customerName\":\"Test Customer\", + \"deliveryDate\":\"2026-03-20\", + \"lines\":[{ + \"salesOrderLineId\":\"$SO_LINE_ID\", + \"description\":\"Product A\", + \"quantityDelivered\":3 + }] + }") + DN_STATUS=$(extract_field "$R" "status") + DN_ID=$(extract_field "$R" "id") + HTTP_DN=$(extract_code "$R") + check_http "Delivery Note: create from confirmed SO returns HTTP 201" "201" "$HTTP_DN" "$(extract_body "$R")" + + R=$(NOPOST "$API/api/v1/delivery-notes/$DN_ID/deliver") + DN_STATUS=$(extract_field "$R" "status") + check_status "Delivery Note: deliver returns status=delivered" "delivered" "$DN_STATUS" +else + fail "Delivery Note: create from SO" "no SO line ID available" + fail "Delivery Note: deliver" "no delivery note created" +fi + +# =========================================================================== +# INVOICE FLOW +# =========================================================================== +echo "" +echo "=== INVOICE FLOW ===" + +R=$(POST "$API/api/v1/invoices" -d "{ + \"customerId\":\"$CUST_ID\", + \"dueDate\":\"2026-04-30\", + \"lines\":[{\"description\":\"Service\",\"quantity\":1,\"unitPriceSatang\":\"50000\",\"accountId\":\"$REVENUE_ACCT\"}] +}") +INV_STATUS=$(extract_field "$R" "status") +INV_ID=$(extract_field "$R" "id") +check_status "Invoice: create returns status=draft" "draft" "$INV_STATUS" + +R=$(NOPOST "$API/api/v1/invoices/$INV_ID/post") +INV_STATUS=$(extract_field "$R" "status") +check_status "Invoice: post returns status=posted" "posted" "$INV_STATUS" + +# void a posted invoice (Bug 2 fix test) +R=$(NOPOST "$API/api/v1/invoices/$INV_ID/void") +HTTP=$(extract_code "$R") +INV_STATUS=$(extract_field "$R" "status") +check_http "Invoice: void posted invoice returns HTTP 200 (Bug 2 fix)" "200" "$HTTP" "$(extract_body "$R")" +check_status "Invoice: void posted invoice returns status=void" "void" "$INV_STATUS" + +# New invoice for payment flow +R=$(POST "$API/api/v1/invoices" -d "{ + \"customerId\":\"$CUST_ID\", + \"dueDate\":\"2026-04-30\", + \"lines\":[{\"description\":\"Consulting\",\"quantity\":1,\"unitPriceSatang\":\"100000\",\"accountId\":\"$REVENUE_ACCT\"}] +}") +INV2_ID=$(extract_field "$R" "id") +R=$(NOPOST "$API/api/v1/invoices/$INV2_ID/post") +check_status "Invoice: post second invoice returns status=posted" "posted" "$(extract_field "$R" "status")" + +# Receive payment via /api/v1/payments +R=$(POST "$API/api/v1/payments" -d "{ + \"customerId\":\"$CUST_ID\", + \"amountSatang\":\"100000\", + \"paymentDate\":\"2026-03-16\", + \"paymentMethod\":\"bank_transfer\", + \"invoiceId\":\"$INV2_ID\" +}") +HTTP=$(extract_code "$R") +PMT_ID=$(extract_field "$R" "id") +check_http "Invoice: receive payment returns HTTP 201" "201" "$HTTP" "$(extract_body "$R")" + +# =========================================================================== +# BILL FLOW +# =========================================================================== +echo "" +echo "=== BILL FLOW ===" + +R=$(POST "$API/api/v1/bills" -d "{ + \"vendorId\":\"$AP_VENDOR_ID\", + \"dueDate\":\"2026-04-30\", + \"lines\":[{\"description\":\"Office supplies\",\"amountSatang\":\"30000\",\"accountId\":\"$EXPENSE_ACCT\"}] +}") +BILL_STATUS=$(extract_field "$R" "status") +BILL_ID=$(extract_field "$R" "id") +check_status "Bill: create returns status=draft" "draft" "$BILL_STATUS" + +R=$(NOPOST "$API/api/v1/bills/$BILL_ID/post") +BILL_STATUS=$(extract_field "$R" "status") +check_status "Bill: post returns status=posted" "posted" "$BILL_STATUS" + +# Bill payment +R=$(POST "$API/api/v1/bill-payments" -d "{ + \"billId\":\"$BILL_ID\", + \"amountSatang\":\"30000\", + \"paymentDate\":\"2026-03-16\", + \"paymentMethod\":\"bank_transfer\" +}") +HTTP=$(extract_code "$R") +BILL_PMT_STATUS=$(extract_body "$R" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('billStatus',''))" 2>/dev/null) +check_http "Bill: pay (bill-payment) returns HTTP 201" "201" "$HTTP" "$(extract_body "$R")" + +# New bill for void test +R=$(POST "$API/api/v1/bills" -d "{ + \"vendorId\":\"$AP_VENDOR_ID\", + \"dueDate\":\"2026-04-30\", + \"lines\":[{\"description\":\"Utilities\",\"amountSatang\":\"15000\",\"accountId\":\"$EXPENSE_ACCT\"}] +}") +BILL2_ID=$(extract_field "$R" "id") +R=$(NOPOST "$API/api/v1/bills/$BILL2_ID/post") +check_status "Bill: post second bill returns status=posted" "posted" "$(extract_field "$R" "status")" + +R=$(NOPOST "$API/api/v1/bills/$BILL2_ID/void") +HTTP=$(extract_code "$R") +BILL2_STATUS=$(extract_field "$R" "status") +check_http "Bill: void returns HTTP 200" "200" "$HTTP" "$(extract_body "$R")" +check_status "Bill: void returns status=voided" "voided" "$BILL2_STATUS" + +# =========================================================================== +# PURCHASE ORDER FLOW +# =========================================================================== +echo "" +echo "=== PURCHASE ORDER FLOW ===" + +R=$(POST "$API/api/v1/purchase-orders" -d "{ + \"vendorId\":\"$AP_VENDOR_ID\", + \"orderDate\":\"2026-03-16\", + \"expectedDate\":\"2026-03-30\", + \"lines\":[{\"description\":\"Office Chair\",\"quantity\":2,\"unitPriceSatang\":\"500000\"}] +}") +PO_STATUS=$(extract_field "$R" "status") +PO_ID=$(extract_field "$R" "id") +check_status "Purchase Order: create returns status=draft" "draft" "$PO_STATUS" + +R=$(NOPOST "$API/api/v1/purchase-orders/$PO_ID/send") +PO_STATUS=$(extract_field "$R" "status") +check_status "Purchase Order: send returns status=sent" "sent" "$PO_STATUS" + +# Get the PO line IDs to use for receive +PO_DETAIL=$(G "$API/api/v1/purchase-orders/$PO_ID") +PO_LINE_ID=$(extract_body "$PO_DETAIL" | python3 -c " +import sys,json +d = json.load(sys.stdin) +lines = d.get('lines',[]) +print(lines[0]['id'] if lines else '') +" 2>/dev/null) + +R=$(POST "$API/api/v1/purchase-orders/$PO_ID/receive" -d "{ + \"lines\":[{\"lineId\":\"$PO_LINE_ID\",\"quantityReceived\":2}] +}") +HTTP_PO=$(extract_code "$R") +PO_STATUS=$(extract_field "$R" "status") +if [ "$HTTP_PO" = "200" ]; then + pass "Purchase Order: receive returns HTTP 200" +else + fail "Purchase Order: receive returns HTTP 200" "got $HTTP_PO — $(extract_body "$R")" +fi + +R=$(NOPOST "$API/api/v1/purchase-orders/$PO_ID/convert-to-bill") +HTTP=$(extract_code "$R") +PO_BILL_ID=$(extract_body "$R" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('billId',''))" 2>/dev/null) +check_http "Purchase Order: convert-to-bill returns HTTP 201" "201" "$HTTP" "$(extract_body "$R")" + +# =========================================================================== +# RECEIPT FLOW +# =========================================================================== +echo "" +echo "=== RECEIPT FLOW ===" + +R=$(POST "$API/api/v1/receipts" -d "{ + \"customerId\":\"$CUST_ID\", + \"customerName\":\"Test Customer\", + \"amountSatang\":\"25000\", + \"receiptDate\":\"2026-03-16\", + \"paymentMethod\":\"cash\" +}") +HTTP=$(extract_code "$R") +RECEIPT_STATUS=$(extract_field "$R" "status") +RECEIPT_ID=$(extract_field "$R" "id") +check_http "Receipt: create (issued) returns HTTP 201" "201" "$HTTP" "$(extract_body "$R")" + +R=$(NOPOST "$API/api/v1/receipts/$RECEIPT_ID/void") +HTTP=$(extract_code "$R") +RECEIPT_VOID_STATUS=$(extract_field "$R" "status") +check_http "Receipt: void returns HTTP 200" "200" "$HTTP" "$(extract_body "$R")" +check_status "Receipt: void returns status=voided" "voided" "$RECEIPT_VOID_STATUS" + +# =========================================================================== +# CREDIT NOTE FLOW +# =========================================================================== +echo "" +echo "=== CREDIT NOTE FLOW ===" + +# Create and post an invoice to reference +R=$(POST "$API/api/v1/invoices" -d "{ + \"customerId\":\"$CUST_ID\", + \"dueDate\":\"2026-04-30\", + \"lines\":[{\"description\":\"Product for CN\",\"quantity\":1,\"unitPriceSatang\":\"20000\",\"accountId\":\"$REVENUE_ACCT\"}] +}") +CN_INV_ID=$(extract_field "$R" "id") +NOPOST "$API/api/v1/invoices/$CN_INV_ID/post" > /dev/null + +R=$(POST "$API/api/v1/credit-notes" -d "{ + \"invoiceId\":\"$CN_INV_ID\", + \"customerId\":\"$CUST_ID\", + \"customerName\":\"Test Customer\", + \"reason\":\"Return of goods\", + \"lines\":[{\"description\":\"Product returned\",\"quantity\":1,\"unitPriceSatang\":\"20000\",\"accountId\":\"$REVENUE_ACCT\"}] +}") +HTTP=$(extract_code "$R") +CN_STATUS=$(extract_field "$R" "status") +CN_ID=$(extract_field "$R" "id") +check_http "Credit Note: create (draft) returns HTTP 201" "201" "$HTTP" "$(extract_body "$R")" + +R=$(NOPOST "$API/api/v1/credit-notes/$CN_ID/issue") +HTTP=$(extract_code "$R") +CN_STATUS=$(extract_field "$R" "status") +check_http "Credit Note: issue returns HTTP 200" "200" "$HTTP" "$(extract_body "$R")" +check_status "Credit Note: issue returns status=issued" "issued" "$CN_STATUS" + +# =========================================================================== +# JOURNAL ENTRY FLOW +# =========================================================================== +echo "" +echo "=== JOURNAL ENTRY FLOW ===" + +if [ -z "$FISCAL_YEAR" ] || [ -z "$FISCAL_PERIOD" ]; then + FISCAL_YEAR=2031; FISCAL_PERIOD=1 +fi + +R=$(POST "$API/api/v1/journal-entries" -d "{ + \"description\":\"Test JE\", + \"fiscalYear\":$FISCAL_YEAR, + \"fiscalPeriod\":$FISCAL_PERIOD, + \"lines\":[ + {\"accountId\":\"$AR_ACCT\",\"description\":\"Dr AR\",\"debitSatang\":\"10000\",\"creditSatang\":\"0\"}, + {\"accountId\":\"$REVENUE_ACCT\",\"description\":\"Cr Revenue\",\"debitSatang\":\"0\",\"creditSatang\":\"10000\"} + ] +}") +JE_STATUS=$(extract_field "$R" "status") +JE_ID=$(extract_field "$R" "id") +check_status "Journal Entry: create returns status=draft" "draft" "$JE_STATUS" + +R=$(NOPOST "$API/api/v1/journal-entries/$JE_ID/post") +JE_STATUS=$(extract_field "$R" "status") +check_status "Journal Entry: post returns status=posted" "posted" "$JE_STATUS" + +R=$(NOPOST "$API/api/v1/journal-entries/$JE_ID/reverse") +HTTP=$(extract_code "$R") +check_http "Journal Entry: reverse returns HTTP 201" "201" "$HTTP" "$(extract_body "$R")" + +# JE in closed period should be blocked (409) +if [ -n "$CLOSED_YEAR" ] && [ -n "$CLOSED_PERIOD_NUM" ]; then + R=$(POST "$API/api/v1/journal-entries" -d "{ + \"description\":\"Should be blocked\", + \"fiscalYear\":$CLOSED_YEAR, + \"fiscalPeriod\":$CLOSED_PERIOD_NUM, + \"lines\":[ + {\"accountId\":\"$AR_ACCT\",\"description\":\"Dr\",\"debitSatang\":\"5000\",\"creditSatang\":\"0\"}, + {\"accountId\":\"$REVENUE_ACCT\",\"description\":\"Cr\",\"debitSatang\":\"0\",\"creditSatang\":\"5000\"} + ] + }") + HTTP=$(extract_code "$R") + if [ "$HTTP" = "409" ] || [ "$HTTP" = "400" ]; then + pass "Journal Entry: create in closed period is blocked (HTTP $HTTP)" + else + fail "Journal Entry: create in closed period should be blocked" "got HTTP $HTTP — $(extract_body "$R")" + fi +else + echo "[SKIP] Journal Entry: closed period test — no closed period found" +fi + +# =========================================================================== +# EMPLOYEE LIFECYCLE +# =========================================================================== +echo "" +echo "=== EMPLOYEE LIFECYCLE ===" + +DEPT_RESP=$(G "$API/api/v1/departments?limit=1") +DEPT_ID=$(extract_body "$DEPT_RESP" | python3 -c " +import sys,json +data = json.load(sys.stdin) +items = data if isinstance(data,list) else data.get('items',data.get('data',[])) +print(items[0]['id'] if items else '') +" 2>/dev/null) + +R=$(POST "$API/api/v1/employees" -d "{ + \"employeeCode\":\"EMP-TEST-$(date +%s)\", + \"firstNameTh\":\"ทดสอบ\", + \"lastNameTh\":\"พนักงาน\", + \"firstNameEn\":\"Test\", + \"lastNameEn\":\"Employee\", + \"departmentId\":\"$DEPT_ID\", + \"position\":\"Tester\", + \"hireDate\":\"2026-01-01\", + \"salarySatang\":5000000 +}") +HTTP=$(extract_code "$R") +NEW_EMP_ID=$(extract_field "$R" "id") +check_http "Employee: create returns HTTP 201" "201" "$HTTP" "$(extract_body "$R")" + +# Edit employee +R=$(PUT "$API/api/v1/employees/$NEW_EMP_ID" -d '{"position":"Senior Tester"}') +HTTP=$(extract_code "$R") +check_http "Employee: edit returns HTTP 200" "200" "$HTTP" "$(extract_body "$R")" + +# Resign +R=$(POST "$API/api/v1/employees/$NEW_EMP_ID/resign" -d '{"resignationDate":"2026-12-31","notes":"Moving on"}') +HTTP=$(extract_code "$R") +EMP_STATUS=$(extract_field "$R" "status") +check_http "Employee: resign returns HTTP 200" "200" "$HTTP" "$(extract_body "$R")" +check_status "Employee: resign returns status=resigned" "resigned" "$EMP_STATUS" + +# Anonymize +R=$(NOPOST "$API/api/v1/employees/$NEW_EMP_ID/anonymize") +HTTP=$(extract_code "$R") +EMP_STATUS=$(extract_field "$R" "status") +check_http "Employee: anonymize returns HTTP 200" "200" "$HTTP" "$(extract_body "$R")" +check_status "Employee: anonymize returns status=anonymized" "anonymized" "$EMP_STATUS" + +# =========================================================================== +# PAYROLL FLOW +# =========================================================================== +echo "" +echo "=== PAYROLL FLOW ===" + +R=$(POST "$API/api/v1/payroll" -d "{ + \"payPeriodStart\":\"2026-03-01\", + \"payPeriodEnd\":\"2026-03-31\", + \"runDate\":\"2026-03-31\" +}") +HTTP=$(extract_code "$R") +PAY_ID=$(extract_field "$R" "id") +check_http "Payroll: create returns HTTP 201" "201" "$HTTP" "$(extract_body "$R")" + +R=$(NOPOST "$API/api/v1/payroll/$PAY_ID/calculate") +HTTP=$(extract_code "$R") +check_http "Payroll: calculate returns HTTP 200" "200" "$HTTP" "$(extract_body "$R")" + +R=$(NOPOST "$API/api/v1/payroll/$PAY_ID/approve") +HTTP=$(extract_code "$R") +PAY_STATUS=$(extract_field "$R" "status") +check_http "Payroll: approve returns HTTP 200" "200" "$HTTP" "$(extract_body "$R")" +check_status "Payroll: approve returns status=approved" "approved" "$PAY_STATUS" + +R=$(NOPOST "$API/api/v1/payroll/$PAY_ID/pay") +HTTP=$(extract_code "$R") +PAY_STATUS=$(extract_field "$R" "status") +check_http "Payroll: pay returns HTTP 200" "200" "$HTTP" "$(extract_body "$R")" +check_status "Payroll: pay returns status=paid" "paid" "$PAY_STATUS" + +# =========================================================================== +# LEAVE FLOW +# =========================================================================== +echo "" +echo "=== LEAVE FLOW ===" + +R=$(POST "$API/api/v1/leave-types" -d "{ + \"code\":\"TEST-LV-$(date +%s)\", + \"nameTh\":\"ลาทดสอบ\", + \"nameEn\":\"Test Leave\", + \"annualQuotaDays\":10, + \"isPaid\":true +}") +HTTP=$(extract_code "$R") +LEAVE_TYPE_ID=$(extract_field "$R" "id") +check_http "Leave: create type returns HTTP 201" "201" "$HTTP" "$(extract_body "$R")" + +# Use the newly created employee (NEW_EMP_ID) — but it was anonymized. Use EMP_ID with far-future dates. +# Generate unique dates using current timestamp to avoid conflicts +TS=$(date +%s) +# Use dates far in the future based on timestamp to avoid overlap +LV_YEAR=$(( 2030 + (TS % 10) )) +LV_MONTH=$(printf "%02d" $(( (TS % 12) + 1 ))) + +if [ -n "$EMP_ID" ] && [ -n "$LEAVE_TYPE_ID" ]; then + R=$(POST "$API/api/v1/leave-requests" -d "{ + \"employeeId\":\"$EMP_ID\", + \"leaveTypeId\":\"$LEAVE_TYPE_ID\", + \"startDate\":\"${LV_YEAR}-${LV_MONTH}-05\", + \"endDate\":\"${LV_YEAR}-${LV_MONTH}-06\", + \"reason\":\"Personal\" + }") + HTTP=$(extract_code "$R") + LR_ID=$(extract_field "$R" "id") + check_http "Leave: create request returns HTTP 201" "201" "$HTTP" "$(extract_body "$R")" + + R=$(NOPOST "$API/api/v1/leave-requests/$LR_ID/approve") + HTTP=$(extract_code "$R") + LR_STATUS=$(extract_field "$R" "status") + check_http "Leave: approve request returns HTTP 200" "200" "$HTTP" "$(extract_body "$R")" + check_status "Leave: approve returns status=approved" "approved" "$LR_STATUS" + + # Create another leave request + reject (different days) + R=$(POST "$API/api/v1/leave-requests" -d "{ + \"employeeId\":\"$EMP_ID\", + \"leaveTypeId\":\"$LEAVE_TYPE_ID\", + \"startDate\":\"${LV_YEAR}-${LV_MONTH}-20\", + \"endDate\":\"${LV_YEAR}-${LV_MONTH}-20\", + \"reason\":\"Sick\" + }") + LR2_ID=$(extract_field "$R" "id") + + R=$(POST "$API/api/v1/leave-requests/$LR2_ID/reject" -d '{"reason":"Insufficient notice"}') + HTTP=$(extract_code "$R") + LR2_STATUS=$(extract_field "$R" "status") + check_http "Leave: reject request returns HTTP 200" "200" "$HTTP" "$(extract_body "$R")" + check_status "Leave: reject returns status=rejected" "rejected" "$LR2_STATUS" +else + fail "Leave: create request" "no active employee or leave type available" + fail "Leave: approve request" "dependency missing" + fail "Leave: reject request" "dependency missing" +fi + +# =========================================================================== +# MONTH-END FLOW +# =========================================================================== +echo "" +echo "=== MONTH-END FLOW ===" + +FISCAL_RESP3=$(G "$API/api/v1/fiscal-years") +PERIOD_INFO=$(extract_body "$FISCAL_RESP3" | python3 -c " +import sys,json +data = json.load(sys.stdin) +fy_list = data if isinstance(data,list) else data.get('items',data.get('data',[data])) +for fy in fy_list: + if isinstance(fy,dict): + for p in fy.get('periods',[]): + if p.get('status')=='open': + print(fy['year'],p['periodNumber'],p['id']) + exit() +" 2>/dev/null) +ME_YEAR=$(echo $PERIOD_INFO | awk '{print $1}') +ME_PERIOD_NUM=$(echo $PERIOD_INFO | awk '{print $2}') +ME_PERIOD_ID=$(echo $PERIOD_INFO | awk '{print $3}') + +if [ -n "$ME_PERIOD_ID" ]; then + R=$(NOPOST "$API/api/v1/fiscal-periods/$ME_PERIOD_ID/close") + HTTP=$(extract_code "$R") + PERIOD_STATUS=$(extract_field "$R" "status") + check_http "Month-End: close period returns HTTP 200" "200" "$HTTP" "$(extract_body "$R")" + check_status "Month-End: close period returns status=closed" "closed" "$PERIOD_STATUS" + + # Verify JE is blocked in closed period + R=$(POST "$API/api/v1/journal-entries" -d "{ + \"description\":\"Should be blocked after close\", + \"fiscalYear\":$ME_YEAR, + \"fiscalPeriod\":$ME_PERIOD_NUM, + \"lines\":[ + {\"accountId\":\"$AR_ACCT\",\"description\":\"Dr\",\"debitSatang\":\"5000\",\"creditSatang\":\"0\"}, + {\"accountId\":\"$REVENUE_ACCT\",\"description\":\"Cr\",\"debitSatang\":\"0\",\"creditSatang\":\"5000\"} + ] + }") + HTTP=$(extract_code "$R") + if [ "$HTTP" = "409" ] || [ "$HTTP" = "400" ]; then + pass "Month-End: JE blocked in closed period (HTTP $HTTP)" + else + fail "Month-End: JE blocked in closed period" "expected 409/400 got HTTP $HTTP" + fi + + # Reopen period + R=$(NOPOST "$API/api/v1/fiscal-periods/$ME_PERIOD_ID/reopen") + HTTP=$(extract_code "$R") + PERIOD_STATUS=$(extract_field "$R" "status") + check_http "Month-End: reopen period returns HTTP 200" "200" "$HTTP" "$(extract_body "$R")" + check_status "Month-End: reopen period returns status=open" "open" "$PERIOD_STATUS" +else + fail "Month-End: close period" "no open fiscal period found" + fail "Month-End: JE blocked in closed period" "no period closed" + fail "Month-End: reopen period" "no period to reopen" +fi + +# =========================================================================== +# FIXED ASSET FLOW +# =========================================================================== +echo "" +echo "=== FIXED ASSET FLOW ===" + +R=$(POST "$API/api/v1/fixed-assets" -d "{ + \"assetCode\":\"FA-TEST-$(date +%s)\", + \"nameTh\":\"เครื่องทดสอบ\", + \"nameEn\":\"Test Machine\", + \"category\":\"equipment\", + \"purchaseDate\":\"2026-01-01\", + \"purchaseCostSatang\":\"1000000\", + \"usefulLifeMonths\":60, + \"depreciationMethod\":\"straight_line\" +}") +HTTP=$(extract_code "$R") +FA_ID=$(extract_field "$R" "id") +check_http "Fixed Asset: create returns HTTP 201" "201" "$HTTP" "$(extract_body "$R")" + +# Depreciate — body only accepts optional periodDate +R=$(POST "$API/api/v1/fixed-assets/$FA_ID/depreciate" -d "{ + \"periodDate\":\"2026-02-28\" +}") +HTTP=$(extract_code "$R") +check_http "Fixed Asset: depreciate returns HTTP 200" "200" "$HTTP" "$(extract_body "$R")" + +# =========================================================================== +# SUMMARY +# =========================================================================== +echo "" +echo "===============================" +echo "TOTAL: $((PASS+FAIL)) | PASS: $PASS | FAIL: $FAIL" +echo "==============================="