import { ERPNextConfig, ERPNextListParams, PaginatedResult } from "../types.js"; import { RESOURCE_PATH, METHOD_PATH, CHARACTER_LIMIT, DEFAULT_PAGE_SIZE } from "../constants.js"; export class ERPNextClient { private baseUrl: string; private authHeader: string; constructor(config: ERPNextConfig) { this.baseUrl = config.baseUrl.replace(/\/$/, ""); this.authHeader = `token ${config.apiKey}:${config.apiSecret}`; } private async request( path: string, method: string = "GET", body?: Record, params?: Record ): Promise { let url = `${this.baseUrl}${path}`; if (params) { const searchParams = new URLSearchParams(); for (const [key, value] of Object.entries(params)) { if (value !== undefined && value !== null && value !== "") { searchParams.append(key, value); } } const qs = searchParams.toString(); if (qs) url += `?${qs}`; } const headers: Record = { Authorization: this.authHeader, "Content-Type": "application/json", Accept: "application/json", }; const fetchOptions: RequestInit = { method, headers }; if (body && (method === "POST" || method === "PUT" || method === "DELETE")) { fetchOptions.body = JSON.stringify(body); } const response = await fetch(url, fetchOptions); if (!response.ok) { const errorText = await response.text(); let errorMessage: string; try { const errorJson = JSON.parse(errorText); errorMessage = errorJson.message || errorJson.exc || errorJson._server_messages || errorText; if (typeof errorMessage !== "string") { errorMessage = JSON.stringify(errorMessage); } } catch { errorMessage = errorText; } throw new Error( `ERPNext API error (${response.status} ${response.statusText}): ${errorMessage.slice(0, 500)}` ); } const json = await response.json(); return json as T; } // ── Generic CRUD ────────────────────────────────────────── async listDocuments( doctype: string, listParams?: ERPNextListParams ): Promise>> { const params: Record = {}; if (listParams?.fields?.length) { params.fields = JSON.stringify(listParams.fields); } if (listParams?.filters?.length) { params.filters = JSON.stringify(listParams.filters); } if (listParams?.or_filters?.length) { params.or_filters = JSON.stringify(listParams.or_filters); } if (listParams?.order_by) { params.order_by = listParams.order_by; } if (listParams?.group_by) { params.group_by = listParams.group_by; } const offset = listParams?.limit_start ?? 0; const limit = listParams?.limit_page_length ?? DEFAULT_PAGE_SIZE; params.limit_start = String(offset); params.limit_page_length = String(limit); const result = await this.request<{ data: Record[] }>( `${RESOURCE_PATH}/${encodeURIComponent(doctype)}`, "GET", undefined, params ); // Get total count const countParams: Record = {}; if (listParams?.filters?.length) { countParams.filters = JSON.stringify(listParams.filters); } if (listParams?.or_filters?.length) { countParams.or_filters = JSON.stringify(listParams.or_filters); } countParams.limit_page_length = "0"; let total = result.data.length + offset; try { const countResult = await this.request<{ data: number }>( `${METHOD_PATH}/frappe.client.get_count`, "GET", undefined, { doctype, ...(listParams?.filters?.length ? { filters: JSON.stringify(listParams.filters) } : {}), } ); total = countResult.data ?? (countResult as unknown as { message: number }).message ?? total; } catch { // If count fails, estimate based on returned data if (result.data.length === limit) { total = offset + limit + 1; // Signal there might be more } } return { total, count: result.data.length, offset, items: result.data, has_more: offset + result.data.length < total, ...(offset + result.data.length < total ? { next_offset: offset + result.data.length } : {}), }; } async getDocument( doctype: string, name: string, fields?: string[] ): Promise> { const params: Record = {}; if (fields?.length) { params.fields = JSON.stringify(fields); } const result = await this.request<{ data: Record }>( `${RESOURCE_PATH}/${encodeURIComponent(doctype)}/${encodeURIComponent(name)}`, "GET", undefined, params ); return result.data; } async createDocument( doctype: string, data: Record ): Promise> { const result = await this.request<{ data: Record }>( `${RESOURCE_PATH}/${encodeURIComponent(doctype)}`, "POST", data ); return result.data; } async updateDocument( doctype: string, name: string, data: Record ): Promise> { const result = await this.request<{ data: Record }>( `${RESOURCE_PATH}/${encodeURIComponent(doctype)}/${encodeURIComponent(name)}`, "PUT", data ); return result.data; } async deleteDocument( doctype: string, name: string ): Promise<{ message: string }> { const result = await this.request<{ message: string }>( `${RESOURCE_PATH}/${encodeURIComponent(doctype)}/${encodeURIComponent(name)}`, "DELETE" ); return result; } // ── Workflow / Submit / Cancel / Amend ──────────────────── async submitDocument( doctype: string, name: string ): Promise> { return this.updateDocument(doctype, name, { docstatus: 1 }); } async cancelDocument( doctype: string, name: string ): Promise> { return this.updateDocument(doctype, name, { docstatus: 2 }); } async amendDocument( doctype: string, name: string ): Promise> { const result = await this.callMethod("frappe.client.amend", { doctype, name, }); return result as Record; } // ── Method Calls ────────────────────────────────────────── async callMethod( method: string, args?: Record ): Promise { const result = await this.request<{ message: unknown }>( `${METHOD_PATH}/${method}`, "POST", args ); return result.message ?? result; } async callGetMethod( method: string, params?: Record ): Promise { const result = await this.request<{ message: unknown }>( `${METHOD_PATH}/${method}`, "GET", undefined, params ); return result.message ?? result; } // ── Search / Link ───────────────────────────────────────── async searchLink( doctype: string, txt: string, filters?: Record, pageLength?: number ): Promise> { const params: Record = { doctype, txt, page_length: String(pageLength ?? 20), }; if (filters) { params.filters = JSON.stringify(filters); } const result = await this.callGetMethod( "frappe.desk.search.search_link", params ); return result as Array<{ value: string; description: string }>; } // ── Report ──────────────────────────────────────────────── async runReport( reportName: string, filters?: Record ): Promise { return this.callMethod("frappe.desk.query_report.run", { report_name: reportName, filters: filters ?? {}, }); } // ── File upload ─────────────────────────────────────────── async getFileUrl(fileName: string): Promise { return `${this.baseUrl}/api/method/frappe.client.get_file?file_name=${encodeURIComponent(fileName)}`; } // ── Print format ────────────────────────────────────────── async getPrintFormat( doctype: string, name: string, printFormat?: string ): Promise { const params: Record = { doctype, name, format: printFormat ?? "Standard", }; const result = await this.callGetMethod( "frappe.utils.print_format.download_pdf", params ); return result as string; } // ── Get count ───────────────────────────────────────────── async getCount( doctype: string, filters?: Array<[string, string, string, string | number | boolean]> ): Promise { const params: Record = { doctype }; if (filters?.length) { params.filters = JSON.stringify(filters); } const result = await this.callGetMethod("frappe.client.get_count", params); return result as number; } // ── Get list of DocTypes ────────────────────────────────── async getDocTypeList(): Promise { const result = await this.listDocuments("DocType", { fields: ["name"], limit_page_length: 500, order_by: "name asc", }); return result.items.map((d) => d.name as string); } // ── Get DocType schema/meta ─────────────────────────────── async getDocTypeMeta( doctype: string ): Promise> { const result = await this.callGetMethod("frappe.client.get_list", { doctype: "DocField", filters: JSON.stringify([["parent", "=", doctype]]), fields: JSON.stringify([ "fieldname", "fieldtype", "label", "options", "reqd", "read_only", "hidden", ]), limit_page_length: "500", order_by: "idx asc", }); return { doctype, fields: result }; } } // ── Response Formatting Helpers ─────────────────────────── export function truncateResponse(text: string): string { if (text.length <= CHARACTER_LIMIT) return text; return ( text.slice(0, CHARACTER_LIMIT) + `\n\n--- Response truncated (${text.length} chars). Use filters, pagination, or narrower field selection to get more targeted results. ---` ); } export function formatDocumentMarkdown( doc: Record, doctype?: string ): string { const lines: string[] = []; if (doctype) { lines.push(`# ${doctype}: ${doc.name ?? "Unknown"}`); } else { lines.push(`# ${doc.name ?? "Document"}`); } lines.push(""); for (const [key, value] of Object.entries(doc)) { if ( value === null || value === undefined || value === "" || key.startsWith("_") ) continue; if (Array.isArray(value)) { lines.push(`**${key}**: [${value.length} items]`); } else if (typeof value === "object") { lines.push(`**${key}**: ${JSON.stringify(value)}`); } else { lines.push(`**${key}**: ${value}`); } } return lines.join("\n"); } export function formatListMarkdown( items: Record[], doctype: string, pagination: { total: number; offset: number; has_more: boolean } ): string { const lines: string[] = [ `# ${doctype} List`, `Showing ${items.length} of ${pagination.total} (offset: ${pagination.offset})`, "", ]; for (const item of items) { const name = item.name ?? "unnamed"; const summary = Object.entries(item) .filter(([k]) => k !== "name") .map(([k, v]) => `${k}: ${v}`) .join(" | "); lines.push(`- **${name}**${summary ? ` — ${summary}` : ""}`); } if (pagination.has_more) { lines.push( "", `_More results available. Use offset=${pagination.offset + items.length} to see next page._` ); } return lines.join("\n"); }