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:
@@ -2,12 +2,12 @@
|
||||
//! invoices (ERPNext now; a webhook, Zoho or a Tally import later). Everything target-specific lives in a
|
||||
//! submodule; the rest of the app only talks to this trait.
|
||||
|
||||
// The push commands that use the sink arrive in the next step.
|
||||
// Parts of the sink API are only used by the later UI step.
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod erpnext;
|
||||
|
||||
use crate::models::Invoice;
|
||||
use serde::Serialize;
|
||||
use std::future::Future;
|
||||
|
||||
/// What a sink reports after accepting an invoice.
|
||||
@@ -19,12 +19,55 @@ pub struct PushedInvoice {
|
||||
pub remote_docstatus: i64,
|
||||
/// False when the remote already had this invoice (an idempotent re-push).
|
||||
pub created: bool,
|
||||
/// Non-fatal notes, e.g. "pushed without the PDF because the invoice is not archived".
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct PushRequest<'a> {
|
||||
pub invoice: &'a Invoice,
|
||||
/// Submit on the remote system instead of leaving a draft for review.
|
||||
pub submit: bool,
|
||||
pub struct PushRequest {
|
||||
pub invoice_id: i64,
|
||||
/// Submit on the remote system instead of leaving a draft for review; `None` follows the sink's setting.
|
||||
pub submit: Option<bool>,
|
||||
}
|
||||
|
||||
pub struct PaymentRequest {
|
||||
pub payment_id: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PushedPayment {
|
||||
pub remote_name: String,
|
||||
/// False when the payment had already been sent (an idempotent re-push).
|
||||
pub created: bool,
|
||||
}
|
||||
|
||||
/// The locally recorded sync state of one invoice. `status` is `none` until something was pushed, then
|
||||
/// `synced`, `error` or `conflict`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SyncStatus {
|
||||
pub invoice_id: i64,
|
||||
pub status: String,
|
||||
pub remote_name: String,
|
||||
/// 0 draft, 1 submitted.
|
||||
pub remote_docstatus: i64,
|
||||
pub last_error: String,
|
||||
pub synced_at: Option<String>,
|
||||
/// The archived PDF was uploaded to the remote document.
|
||||
pub attached: bool,
|
||||
}
|
||||
|
||||
impl SyncStatus {
|
||||
pub fn none(invoice_id: i64) -> Self {
|
||||
SyncStatus {
|
||||
invoice_id,
|
||||
status: "none".into(),
|
||||
remote_name: String::new(),
|
||||
remote_docstatus: 0,
|
||||
last_error: String::new(),
|
||||
synced_at: None,
|
||||
attached: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait InvoiceSink: Send + Sync {
|
||||
@@ -33,6 +76,14 @@ pub trait InvoiceSink: Send + Sync {
|
||||
|
||||
fn push_invoice<'a>(
|
||||
&'a self,
|
||||
request: PushRequest<'a>,
|
||||
request: PushRequest,
|
||||
) -> impl Future<Output = Result<PushedInvoice, String>> + Send + 'a;
|
||||
|
||||
fn push_payment<'a>(
|
||||
&'a self,
|
||||
request: PaymentRequest,
|
||||
) -> impl Future<Output = Result<PushedPayment, String>> + Send + 'a;
|
||||
|
||||
/// The locally recorded state; never touches the network.
|
||||
fn status(&self, invoice_id: i64) -> Result<SyncStatus, String>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user