import { toFrappeFilterArray } from '../utils/listFilterUtils'; // ─── Interfaces ─────────────────────────────────────────────────────────────── export interface SalesInvoiceItem { name?: string; item_code?: string; item_name?: string; description?: string; qty?: number; rate?: number; amount?: number; uom?: string; idx?: number; [key: string]: any; } export interface SalesInvoiceTimesheet { name?: string; time_sheet?: string; billing_hours?: number; billing_amount?: number; activity_type?: string; idx?: number; [key: string]: any; } export interface SalesInvoice { name: string; naming_series?: string; customer?: string; customer_name?: string; posting_date?: string; posting_time?: string; currency?: string; status?: string; docstatus?: number; grand_total?: number; net_total?: number; total?: number; total_taxes_and_charges?: number; rounded_total?: number; outstanding_amount?: number; total_billing_hours?: number; total_billing_amount?: number; company?: string; items?: SalesInvoiceItem[]; timesheets?: SalesInvoiceTimesheet[]; owner?: string; creation?: string; modified?: string; [key: string]: any; } export interface SalesInvoiceListParams { filters?: Record; fields?: string[]; limit_start?: number; limit_page_length?: number; order_by?: string; } // ─── Service ───────────────────────────────────────────────────────────────── class SalesInvoiceService { private baseURL = ''; private async getCSRFToken(): Promise { if (typeof window === 'undefined') return null; if ((window as any).csrf_token) return (window as any).csrf_token; if ((window as any).frappe?.csrf_token) return (window as any).frappe.csrf_token; try { const res = await fetch('/api/method/frappe.sessions.get_csrf_token', { credentials: 'include' }); if (res.ok) { const json = await res.json(); if (json.message) { (window as any).csrf_token = json.message; return json.message; } } } catch { /* ignore */ } return null; } private async getHeaders(): Promise> { const h: Record = { 'Content-Type': 'application/json', Accept: 'application/json' }; const csrf = await this.getCSRFToken(); if (csrf) h['X-Frappe-CSRF-Token'] = csrf; return h; } private sanitizeErrorMessage(msg: string): string { if (!msg) return 'Something went wrong.'; // Strip HTML if any const noHtml = msg.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); // Remove python tracebacks / long stacks const tbIdx = noHtml.toLowerCase().indexOf('traceback'); const cleaned = tbIdx >= 0 ? noHtml.slice(0, tbIdx).trim() : noHtml; // Trim very long errors if (cleaned.length > 220) return `${cleaned.slice(0, 220)}…`; return cleaned || 'Something went wrong.'; } private parseFrappeError(body: any): string { if (body?.exc_type === 'ValidationError') return body.exc || body.message || 'Validation error'; if (body?.message) return this.sanitizeErrorMessage(String(body.message)); if (body?._server_messages) { try { const msgs = JSON.parse(body._server_messages); const first = msgs?.[0]; const parsed = typeof first === 'string' ? JSON.parse(first) : first; const m = parsed?.message || first || body._server_messages; return this.sanitizeErrorMessage(String(m)); } catch { return this.sanitizeErrorMessage(String(body._server_messages)); } } return 'Unknown error'; } private async fetchJson(url: string, opts: RequestInit = {}): Promise { const headers = await this.getHeaders(); const r = await fetch(url, { credentials: 'include', headers, ...opts }); const body = await r.json().catch(() => ({})); if (!r.ok) throw new Error(this.parseFrappeError(body)); if (body.exc) throw new Error(this.parseFrappeError(body)); return body; } async getSalesInvoices(params: SalesInvoiceListParams = {}): Promise<{ data: SalesInvoice[] }> { const { filters = {}, fields = ['name','status','customer','customer_name','posting_date','currency','grand_total','outstanding_amount','docstatus','creation'], limit_start = 0, limit_page_length = 20, order_by = 'creation desc', } = params; const q = new URLSearchParams(); q.set('fields', JSON.stringify(fields)); q.set('limit_start', String(limit_start)); q.set('limit_page_length', String(limit_page_length)); q.set('order_by', order_by); if (Object.keys(filters).length > 0) { const fa = toFrappeFilterArray(filters); if (fa.length > 0) q.set('filters', JSON.stringify(fa)); } const r = await this.fetchJson(`${this.baseURL}/api/resource/Sales Invoice?${q}`); return { data: r.data || [] }; } /** * Fetch Sales Invoices linked to the given Sales Orders via Sales Invoice Item.sales_order. * Uses frappe.client.get_list to support child-table filters. */ async getSalesInvoicesBySalesOrders(args: { salesOrders: string[]; limit?: number; orderBy?: string; fields?: string[]; }): Promise { const { salesOrders, limit = 1000, orderBy = 'posting_date asc', fields = ['name', 'posting_date', 'customer', 'customer_name', 'currency', 'grand_total', 'docstatus', 'status'], } = args; if (!salesOrders.length) return []; const r = await this.fetchJson(`${this.baseURL}/api/method/frappe.client.get_list`, { method: 'POST', body: JSON.stringify({ doctype: 'Sales Invoice', fields, filters: [['Sales Invoice Item', 'sales_order', 'in', salesOrders]], order_by: orderBy, limit_page_length: limit, }), }); return (r.message || []) as SalesInvoice[]; } /** * Invoices tied to a Project via document or line-level `project` (not only via Sales Order). * Merges header `project` and `Sales Invoice Item.project` matches — deduped by invoice name. */ async getSalesInvoicesLinkedToProject(args: { project: string; limit?: number; orderBy?: string; fields?: string[]; }): Promise { const { project, limit = 1000, orderBy = 'posting_date asc', fields = ['name', 'posting_date', 'customer', 'customer_name', 'currency', 'grand_total', 'docstatus', 'status'], } = args; if (!project?.trim()) return []; const listPayload = (filters: any[]) => this.fetchJson(`${this.baseURL}/api/method/frappe.client.get_list`, { method: 'POST', body: JSON.stringify({ doctype: 'Sales Invoice', fields, filters, order_by: orderBy, limit_page_length: limit, }), }).then((r: any) => (r.message || []) as SalesInvoice[]).catch(() => [] as SalesInvoice[]); const [fromHeader, fromItems] = await Promise.all([ listPayload([['project', '=', project]]), listPayload([['Sales Invoice Item', 'project', '=', project]]), ]); const byName = new Map(); for (const inv of fromHeader) if (inv?.name) byName.set(inv.name, inv); for (const inv of fromItems) if (inv?.name) byName.set(inv.name, inv); return [...byName.values()].sort((a, b) => String(a.posting_date || '').localeCompare(String(b.posting_date || '')), ); } async getSalesInvoiceCount(filters: Record = {}): Promise { const q = new URLSearchParams(); q.set('fields', JSON.stringify(['count(name) as count'])); if (Object.keys(filters).length > 0) { const fa = toFrappeFilterArray(filters); if (fa.length > 0) q.set('filters', JSON.stringify(fa)); } try { const r = await this.fetchJson(`${this.baseURL}/api/resource/Sales Invoice?${q}`); return r.data?.[0]?.count || 0; } catch { return 0; } } async getSalesInvoice(name: string): Promise { const r = await this.fetchJson(`${this.baseURL}/api/resource/Sales%20Invoice/${encodeURIComponent(name)}`); return r.data; } async createSalesInvoice(data: Partial): Promise { const r = await this.fetchJson(`${this.baseURL}/api/resource/Sales%20Invoice`, { method: 'POST', body: JSON.stringify(data), }); return r.data; } async updateSalesInvoice(name: string, data: Partial): Promise { const r = await this.fetchJson(`${this.baseURL}/api/resource/Sales%20Invoice/${encodeURIComponent(name)}`, { method: 'PUT', body: JSON.stringify(data), }); return r.data; } /** Directly clear stale "None" link values on SI item rows, bypassing document validation. */ async clearItemLinkNones(invoiceName: string): Promise { const headers = await this.getHeaders(); // Fetch the raw SI to get child row names const doc = await this.fetchJson(`${this.baseURL}/api/resource/Sales%20Invoice/${encodeURIComponent(invoiceName)}`); const items: any[] = doc?.data?.items || []; const staleFields = [ 'delivery_note', 'dn_detail', 'sales_order', 'so_detail', 'against_delivery_note', 'against_sales_order', ]; for (const item of items) { if (!item.name) continue; const hasNone = staleFields.some(f => item[f] === 'None' || item[f] === 'none'); if (!hasNone) continue; // frappe.client.set_value writes directly to DB without running validate() await this.fetchJson(`${this.baseURL}/api/method/frappe.client.set_value`, { method: 'POST', headers, body: JSON.stringify({ doctype: 'Sales Invoice Item', name: item.name, fieldname: { delivery_note: '', dn_detail: '', sales_order: '', so_detail: '', against_delivery_note: '', against_sales_order: '', }, }), }); } } async submitSalesInvoice(name: string): Promise { const r = await this.fetchJson(`${this.baseURL}/api/resource/Sales%20Invoice/${encodeURIComponent(name)}`, { method: 'PUT', body: JSON.stringify({ docstatus: 1 }), }); return r.data; } } const salesInvoiceService = new SalesInvoiceService(); export default salesInvoiceService;