Verify the ERPNext integration live and fix the defects it found (Phase F4)
Ran the push flow against ERPNext 15.121.6 on a plain site and on a site with India Compliance 15.32.0 (podman, scripts/erpnext-e2e). 15 ignored live tests pass on both. Fixes, each covered by a mock-based test: - load_options no longer fails on a plain site: the India Compliance-only gstin field is dropped on a 417 and retried. - Code-less rows with fractional hours or minutes send stock_uom, since ERPNext defaults it to Nos. - A created document whose total differs from Voiced's (ERPNext's default Banker's Rounding on half-paise ties) is recorded as a conflict, kept as a draft, not submitted and not given a PDF. The message names Commercial Rounding as the fix. - An existing customer address is reused instead of creating a duplicate on every first push. A bare creation order-by is not used with a Dynamic Link filter. - A re-adopted document no longer gets a second copy of the PDF. - Reverse charge: India Compliance rejects is_reverse_charge unless tax rows are negative RCM amounts, so the flag is sent as 0 with a warning that the invoice lands as a normal taxed invoice. - The 16-character name rule is checked locally in mirror mode under India Compliance, and a TDS-only payment is refused locally with the reason. Payment Entry mapping verified: bank_account is the Account name (paid_to), paid and received amounts equal the cash received, allocated_amount is cash plus TDS, and TDS is one positive deductions row. The integration user needs the Accounts User and Sales User roles. Not verified: ERPNext v14 and v16, other India Compliance versions, the Tauri commands and UI against a live site, SEZ and overseas customers, UTGST supplier states, TLS sites, e-invoicing.
This commit is contained in:
@@ -258,6 +258,7 @@ fn item_rows(ctx: &InvoiceContext, apply_gst: bool) -> Result<Vec<Value>, String
|
||||
.filter(|c| !c.is_empty())
|
||||
.or_else(|| Some(cfg.default_item_code.trim()).filter(|c| !c.is_empty()));
|
||||
|
||||
let code_less = code.is_none();
|
||||
let mut row = Map::new();
|
||||
match code {
|
||||
Some(code) => {
|
||||
@@ -276,8 +277,13 @@ fn item_rows(ctx: &InvoiceContext, apply_gst: bool) -> Result<Vec<Value>, String
|
||||
row.insert("description".into(), json!(descriptions[i]));
|
||||
row.insert("qty".into(), decimal_number(&scaled_decimal(qty_scaled, 3)));
|
||||
row.insert("rate".into(), money(rate_paise));
|
||||
row.insert("uom".into(), json!(cfg.uom_for(&item.unit)));
|
||||
let uom = cfg.uom_for(&item.unit);
|
||||
row.insert("uom".into(), json!(uom));
|
||||
row.insert("conversion_factor".into(), json!(1));
|
||||
if code_less {
|
||||
// Without an item, ERPNext falls back to the stock UOM "Nos" and then refuses a fractional stock qty.
|
||||
row.insert("stock_uom".into(), json!(uom));
|
||||
}
|
||||
set_if(&mut row, "income_account", &cfg.income_account);
|
||||
set_if(&mut row, "cost_center", &cfg.cost_center);
|
||||
if apply_gst {
|
||||
@@ -379,7 +385,10 @@ pub fn build_sales_invoice(ctx: &InvoiceContext) -> Result<BuiltRequest, String>
|
||||
inv.place_of_supply_state_code.trim()
|
||||
};
|
||||
body.insert("place_of_supply".into(), json!(place_of_supply_label(pos)?));
|
||||
body.insert("is_reverse_charge".into(), json!(i32::from(inv.reverse_charge)));
|
||||
// Live finding (India Compliance 15.32): a reverse-charge sales invoice must carry negative tax rows on the
|
||||
// separate "... RCM" accounts, so ERPNext's total would exclude the tax Voiced's total includes. The flag
|
||||
// is therefore never sent as 1; the push reports a warning for such invoices instead.
|
||||
body.insert("is_reverse_charge".into(), json!(0));
|
||||
}
|
||||
|
||||
if totals.discount > 0 {
|
||||
@@ -631,6 +640,7 @@ mod tests {
|
||||
"qty": 1.0,
|
||||
"rate": 7310.0,
|
||||
"uom": "Nos",
|
||||
"stock_uom": "Nos",
|
||||
"conversion_factor": 1,
|
||||
"income_account": "Sales - AC",
|
||||
}],
|
||||
@@ -680,17 +690,18 @@ mod tests {
|
||||
"company_address": "Test Co-Billing",
|
||||
"customer_address": "Client Ltd-Billing",
|
||||
"place_of_supply": "29-Karnataka",
|
||||
"is_reverse_charge": 1,
|
||||
// Reverse charge is never sent as 1 (see build_sales_invoice); the push warns instead.
|
||||
"is_reverse_charge": 0,
|
||||
"apply_discount_on": "Net Total",
|
||||
"discount_amount": 10.0,
|
||||
"items": [
|
||||
{
|
||||
"item_name": "Hours", "description": "Hours", "qty": 1.5, "rate": 1200.0, "uom": "Hour",
|
||||
"conversion_factor": 1, "income_account": "Sales - AC", "gst_hsn_code": "998314",
|
||||
"stock_uom": "Hour", "conversion_factor": 1, "income_account": "Sales - AC", "gst_hsn_code": "998314",
|
||||
},
|
||||
{
|
||||
"item_name": "Fee", "description": "Fee", "qty": 1.0, "rate": 33.33, "uom": "Nos",
|
||||
"conversion_factor": 1, "income_account": "Sales - AC",
|
||||
"stock_uom": "Nos", "conversion_factor": 1, "income_account": "Sales - AC",
|
||||
},
|
||||
],
|
||||
"taxes": [
|
||||
@@ -768,6 +779,26 @@ mod tests {
|
||||
assert_eq!(rows[3]["item_name"], "Other");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_code_less_rows_pin_the_stock_uom() {
|
||||
// Live finding: without an item ERPNext defaults the stock UOM to "Nos" and then refuses a fractional
|
||||
// quantity of hours; an item-coded row keeps the item's own stock UOM.
|
||||
let mut conn = registered();
|
||||
let inv = issue(
|
||||
&mut conn,
|
||||
json!({ "items": [
|
||||
{ "description": "Coded", "mode": "rate", "rate": 100.0, "unit": "hour", "quantity": 1.5 },
|
||||
{ "description": "Not coded", "mode": "rate", "rate": 100.0, "unit": "minute", "quantity": 2.5 },
|
||||
]}),
|
||||
);
|
||||
let codes = [Some("SVC".to_string()), None];
|
||||
let req = build(&inv, &cfg(), false, &codes, false).unwrap();
|
||||
let rows = req.body["items"].as_array().unwrap();
|
||||
assert!(rows[0].get("stock_uom").is_none());
|
||||
assert_eq!((rows[0]["uom"].as_str(), rows[0]["conversion_factor"].as_i64()), (Some("Hour"), Some(1)));
|
||||
assert_eq!((rows[1]["uom"].as_str(), rows[1]["stock_uom"].as_str()), (Some("Minute"), Some("Minute")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_less_rows_need_an_income_account() {
|
||||
let mut conn = registered();
|
||||
|
||||
Reference in New Issue
Block a user