293 lines
10 KiB
TypeScript
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;
|