2026-06-11 19:56:20 +05:30

293 lines
10 KiB
TypeScript

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<string, any>;
fields?: string[];
limit_start?: number;
limit_page_length?: number;
order_by?: string;
}
// ─── Service ─────────────────────────────────────────────────────────────────
class SalesInvoiceService {
private baseURL = '';
private async getCSRFToken(): Promise<string | null> {
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<Record<string, string>> {
const h: Record<string, string> = { '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<any> {
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<SalesInvoice[]> {
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<SalesInvoice[]> {
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<string, SalesInvoice>();
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<string, any> = {}): Promise<number> {
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<SalesInvoice> {
const r = await this.fetchJson(`${this.baseURL}/api/resource/Sales%20Invoice/${encodeURIComponent(name)}`);
return r.data;
}
async createSalesInvoice(data: Partial<SalesInvoice>): Promise<SalesInvoice> {
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<SalesInvoice>): Promise<SalesInvoice> {
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<void> {
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<SalesInvoice> {
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;