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:
@@ -1,11 +1,14 @@
|
||||
//! Tauri commands for the ERPNext settings: read/save the configuration, test a connection and load the
|
||||
//! lists the settings dropdowns need. The API secret never leaves this module: `erpnext_get_config` returns
|
||||
//! `apiSecretSet` only, and every command resolves a blank secret to the stored one on the Rust side.
|
||||
//! Pushing invoices is a later step.
|
||||
//! The push commands send issued invoices and payments (see `integrations::erpnext::push`); the database lock is
|
||||
//! never held across a network call.
|
||||
|
||||
use crate::integrations::erpnext::client::ErpClient;
|
||||
use crate::integrations::erpnext::config::{self, ErpnextConfig, ErpnextConfigInput, ErpnextConfigView};
|
||||
use crate::integrations::erpnext::discovery::{self, ic_number_ok, ConnectionTest, ErpnextOptions, LocalFacts};
|
||||
use crate::integrations::erpnext::push::{self, PaymentPushResult, PushResult};
|
||||
use crate::integrations::SyncStatus;
|
||||
use crate::AppState;
|
||||
use rusqlite::Connection;
|
||||
use tauri::State;
|
||||
@@ -95,6 +98,67 @@ pub async fn erpnext_load_options(
|
||||
Ok(client.load_options(&cfg.company).await?)
|
||||
}
|
||||
|
||||
/// A client for the saved settings, or a readable reason why there is none.
|
||||
fn saved_client(state: &AppState) -> Result<ErpClient, String> {
|
||||
let cfg = {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
config::load(&conn)?
|
||||
};
|
||||
if cfg.base_url.trim().is_empty() || cfg.api_key.trim().is_empty() || !cfg.api_secret.is_set() {
|
||||
return Err("ERPNext is not set up yet: enter the address, API key and API secret in Settings.".into());
|
||||
}
|
||||
Ok(ErpClient::from_config(&cfg)?)
|
||||
}
|
||||
|
||||
/// Sends one issued invoice. `submit` omitted follows the "submit on push" setting. A failure comes back as a
|
||||
/// result with `ok: false` (and is recorded), not as an `Err`.
|
||||
#[tauri::command]
|
||||
pub async fn erpnext_push_invoice(
|
||||
state: State<'_, AppState>,
|
||||
id: i64,
|
||||
submit: Option<bool>,
|
||||
) -> Result<PushResult, String> {
|
||||
let http = saved_client(&state)?;
|
||||
Ok(push::push_invoice(&state.db, &state.local_data_dir, &http, id, submit).await)
|
||||
}
|
||||
|
||||
/// Sends several invoices one after another; one row's failure never stops the others.
|
||||
#[tauri::command]
|
||||
pub async fn erpnext_push_invoices(
|
||||
state: State<'_, AppState>,
|
||||
ids: Vec<i64>,
|
||||
submit: Option<bool>,
|
||||
) -> Result<Vec<PushResult>, String> {
|
||||
let http = saved_client(&state)?;
|
||||
Ok(push::push_invoices(&state.db, &state.local_data_dir, &http, &ids, submit).await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn erpnext_push_payment(state: State<'_, AppState>, payment_id: i64) -> Result<PaymentPushResult, String> {
|
||||
let http = saved_client(&state)?;
|
||||
Ok(push::push_payment(&state.db, &http, payment_id).await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn erpnext_sync_status(state: State<AppState>, id: i64) -> Result<SyncStatus, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
push::sync_status(&conn, id)
|
||||
}
|
||||
|
||||
/// The sync state of every invoice that has one, for the History list.
|
||||
#[tauri::command]
|
||||
pub fn erpnext_sync_statuses(state: State<AppState>) -> Result<Vec<SyncStatus>, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
push::sync_statuses(&conn)
|
||||
}
|
||||
|
||||
/// `<base>/app/sales-invoice/<name>` for an invoice that was sent.
|
||||
#[tauri::command]
|
||||
pub fn erpnext_open_url(state: State<AppState>, id: i64) -> Result<String, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
push::open_url(&conn, id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
Reference in New Issue
Block a user