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:
@@ -90,6 +90,20 @@ pub fn normalize_base_url(raw: &str) -> Result<String, ErpError> {
|
||||
Ok(url.as_str().trim_end_matches('/').to_string())
|
||||
}
|
||||
|
||||
/// A file to upload through a multipart POST.
|
||||
pub struct Upload<'a> {
|
||||
pub file_name: &'a str,
|
||||
pub mime: &'a str,
|
||||
pub bytes: &'a [u8],
|
||||
pub fields: &'a [(&'a str, String)],
|
||||
}
|
||||
|
||||
enum Payload<'a> {
|
||||
None,
|
||||
Json(&'a Value),
|
||||
Upload(&'a Upload<'a>),
|
||||
}
|
||||
|
||||
pub struct ErpClient {
|
||||
http: reqwest::Client,
|
||||
base: Url,
|
||||
@@ -171,14 +185,21 @@ impl ErpClient {
|
||||
|
||||
/// GET, retried on 429, 5xx and timeouts.
|
||||
pub async fn get(&self, segments: &[&str], query: &[(&str, String)]) -> Result<Value, ErpError> {
|
||||
self.send(Method::GET, segments, query, None, true).await
|
||||
self.send(Method::GET, segments, query, Payload::None, true).await
|
||||
}
|
||||
|
||||
/// POST with a JSON body. 429 is always retried (the server refused before doing anything). 5xx and
|
||||
/// timeouts are retried only when `idempotent` is true, because the server may have processed the
|
||||
/// request: a mirrored-name Sales Invoice is idempotent (a repeat is a 409), a series-named one is not.
|
||||
pub async fn post(&self, segments: &[&str], body: &Value, idempotent: bool) -> Result<Value, ErpError> {
|
||||
self.send(Method::POST, segments, &[], Some(body), idempotent).await
|
||||
self.send(Method::POST, segments, &[], Payload::Json(body), idempotent).await
|
||||
}
|
||||
|
||||
/// Multipart POST carrying one file part named `file` (the name Frappe's `upload_file` reads; the file
|
||||
/// name travels in the part's Content-Disposition) plus plain text fields. Never retried after a 5xx or
|
||||
/// timeout, because an upload that may have landed would attach the file twice.
|
||||
pub async fn post_file(&self, segments: &[&str], upload: &Upload<'_>) -> Result<Value, ErpError> {
|
||||
self.send(Method::POST, segments, &[], Payload::Upload(upload), false).await
|
||||
}
|
||||
|
||||
async fn send(
|
||||
@@ -186,7 +207,7 @@ impl ErpClient {
|
||||
method: Method,
|
||||
segments: &[&str],
|
||||
query: &[(&str, String)],
|
||||
body: Option<&Value>,
|
||||
payload: Payload<'_>,
|
||||
retry_ambiguous: bool,
|
||||
) -> Result<Value, ErpError> {
|
||||
let mut attempt: u32 = 0;
|
||||
@@ -195,8 +216,20 @@ impl ErpClient {
|
||||
if !query.is_empty() {
|
||||
req = req.query(query);
|
||||
}
|
||||
if let Some(b) = body {
|
||||
req = req.json(b);
|
||||
match &payload {
|
||||
Payload::None => {}
|
||||
Payload::Json(b) => req = req.json(*b),
|
||||
Payload::Upload(u) => {
|
||||
let part = reqwest::multipart::Part::bytes(u.bytes.to_vec())
|
||||
.file_name(u.file_name.to_string())
|
||||
.mime_str(u.mime)
|
||||
.map_err(|e| ErpError::config(format!("Bad upload type: {e}")))?;
|
||||
let mut form = reqwest::multipart::Form::new();
|
||||
for (k, v) in u.fields {
|
||||
form = form.text(k.to_string(), v.clone());
|
||||
}
|
||||
req = req.multipart(form.part("file", part));
|
||||
}
|
||||
}
|
||||
let can_retry = attempt < self.opts.max_retries;
|
||||
match req.send().await {
|
||||
|
||||
Reference in New Issue
Block a user