Add ERPNext push logic for invoices and payments (Phase F2)
- ErpnextSink implements InvoiceSink. Pushing an issued invoice ensures the Customer and Address (read-back of the returned name, local PIN/state/GSTIN checks), creates the Sales Invoice, optionally submits it in a second call, and attaches the archived PDF as a private file once. - Idempotent: a mirrored-name 409 is compared by grand total (same total records as synced, different total is a conflict and never overwrites); series mode looks up an existing document by its remarks before re-posting; an identical re-push sends nothing. - Create and submit are separate calls so a draft survives a failed submit. A failed push is recorded in erpnext_sync with a readable message. Bulk push never aborts on one failing row. - Payment push requires a submitted parent, maps TDS to Payment Entry deductions and is idempotent through the stored entry name. - Migration M11 rebuilds erpnext_sync to allow a conflict status and adds attachment_sha256. - Commands for single and bulk push, payment push, sync status and the open-in-ERPNext URL, with typed wrappers. No UI yet. Tested against a mock server only. The Payment Entry deductions fields, the series-mode submit route and the payment amount semantics need a live ERPNext check.
This commit is contained in:
@@ -19,6 +19,9 @@ import type {
|
||||
ErpnextConfigInput,
|
||||
ErpnextConnectionTest,
|
||||
ErpnextOptions,
|
||||
ErpnextPaymentPushResult,
|
||||
ErpnextPushResult,
|
||||
ErpnextSyncStatus,
|
||||
} from "./erpnext";
|
||||
import type { FontInspection, ImportMeta, RemoveOutcome, UserFontRow } from "./fontImport";
|
||||
|
||||
@@ -126,4 +129,17 @@ export const api = {
|
||||
erpnextTestConnection: (config: ErpnextConfigInput) =>
|
||||
invoke<ErpnextConnectionTest>("erpnext_test_connection", { config }),
|
||||
erpnextLoadOptions: (config: ErpnextConfigInput) => invoke<ErpnextOptions>("erpnext_load_options", { config }),
|
||||
/** `submit` omitted follows the "submit on push" setting. A failed push resolves with `ok: false`. */
|
||||
erpnextPushInvoice: (id: number, submit?: boolean) =>
|
||||
invoke<ErpnextPushResult>("erpnext_push_invoice", { id, submit: submit ?? null }),
|
||||
/** One result per distinct id, in order; a failing row never stops the rest. */
|
||||
erpnextPushInvoices: (ids: number[], submit?: boolean) =>
|
||||
invoke<ErpnextPushResult[]>("erpnext_push_invoices", { ids, submit: submit ?? null }),
|
||||
erpnextPushPayment: (paymentId: number) =>
|
||||
invoke<ErpnextPaymentPushResult>("erpnext_push_payment", { paymentId }),
|
||||
erpnextSyncStatus: (id: number) => invoke<ErpnextSyncStatus>("erpnext_sync_status", { id }),
|
||||
/** Every invoice that has a sync row (invoices never pushed are absent). */
|
||||
erpnextSyncStatuses: () => invoke<ErpnextSyncStatus[]>("erpnext_sync_statuses"),
|
||||
/** `<base>/app/sales-invoice/<name>`; rejects when the invoice was never sent. */
|
||||
erpnextOpenUrl: (id: number) => invoke<string>("erpnext_open_url", { id }),
|
||||
};
|
||||
|
||||
@@ -88,3 +88,67 @@ export interface ErpnextOptions {
|
||||
/** Lists that failed to load; the others are still filled. */
|
||||
errors: { list: string; message: string }[];
|
||||
}
|
||||
|
||||
export type ErpnextErrorKind =
|
||||
| "auth"
|
||||
| "permission"
|
||||
| "not_found"
|
||||
| "duplicate"
|
||||
| "validation"
|
||||
| "rate_limit"
|
||||
| "server"
|
||||
| "timeout"
|
||||
| "network"
|
||||
| "config"
|
||||
| "protocol"
|
||||
| "conflict"
|
||||
| "precondition"
|
||||
| "other";
|
||||
|
||||
/** `refused`: nothing was sent and nothing was recorded (a draft or cancelled invoice). */
|
||||
export type ErpnextPushStatus = "synced" | "error" | "conflict" | "refused";
|
||||
|
||||
export interface ErpnextPushResult {
|
||||
invoiceId: number;
|
||||
number: string;
|
||||
ok: boolean;
|
||||
status: ErpnextPushStatus;
|
||||
remoteName: string;
|
||||
/** 0 draft, 1 submitted. */
|
||||
remoteDocstatus: number;
|
||||
/** Created by this call, not found already there. */
|
||||
created: boolean;
|
||||
/** Already synced with the same payload; nothing was done. */
|
||||
noOp: boolean;
|
||||
/** The archived PDF is attached on the remote document. */
|
||||
attached: boolean;
|
||||
error: string | null;
|
||||
errorKind: ErpnextErrorKind | null;
|
||||
/** Non-fatal notes, e.g. sent without the PDF because the invoice is not archived. */
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface ErpnextPaymentPushResult {
|
||||
paymentId: number;
|
||||
invoiceId: number;
|
||||
ok: boolean;
|
||||
entryName: string | null;
|
||||
/** Sent earlier; nothing was posted. */
|
||||
alreadySynced: boolean;
|
||||
error: string | null;
|
||||
errorKind: ErpnextErrorKind | null;
|
||||
}
|
||||
|
||||
/** `none` until something was pushed. */
|
||||
export type ErpnextSyncState = "none" | "pending" | "synced" | "error" | "conflict";
|
||||
|
||||
export interface ErpnextSyncStatus {
|
||||
invoiceId: number;
|
||||
status: ErpnextSyncState;
|
||||
remoteName: string;
|
||||
/** 0 draft, 1 submitted. */
|
||||
remoteDocstatus: number;
|
||||
lastError: string;
|
||||
syncedAt: string | null;
|
||||
attached: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user