diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 60bac381..a443c218 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "24d812389629d56f5750b2b021089c1fcf3d1a330b0bd440683aa54219b60303", + "compatibility_surface_sha256": "29d49a8d8cf10c3415c7d487dfb7419e69ea581f035ec1b3d82788f2ea25f25e", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 35cf39f0..a97b36e5 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -219,7 +219,7 @@ }, { "path": "src-tauri/src/commands.rs", - "sha256": "116c0ab468896bff2c98db3aaada794d5378ef8e32934bcad737c27fb592a5e7" + "sha256": "d12c341e5f5fecd6e41436293699bd8f318f0ae2127ccd7ede29e0af2db9d19e" }, { "path": "src-tauri/src/db/encrypted.rs", @@ -303,7 +303,7 @@ }, { "path": "src-tauri/src/lib.rs", - "sha256": "33bdd1cf9cf4a1e945db9b7eaec0f037b1122e5bafd013c208254b9ad3817196" + "sha256": "62c950aacaff7b1b431b65f32e14ead421d87a0bd3cd44a580912adfb5bf3730" }, { "path": "src-tauri/src/sync/coordinator.rs", @@ -339,7 +339,7 @@ }, { "path": "src-tauri/src/tally/runtime.rs", - "sha256": "2d2ce6d7d59b4b7e7286ca0f583f87604fa86cd1a1eed1c5353f7ae487152b55" + "sha256": "1d2ae890a240344ad9a2a59b4624be2726d13c11e5e529a0b084e355f865deec" }, { "path": "src-tauri/src/tally/serial_queue.rs", @@ -359,7 +359,7 @@ }, { "path": "src/OutstandingsScreen.tsx", - "sha256": "df11bdbbbfb4f62b7738cd30ea69a2ce5b15eb8a5b5567358e67d6d04569bb90" + "sha256": "5fbb3bc7a47487cf5a5d9f5bc527c5a7e01940589fd519c4a07c1e6c6f094cf3" }, { "path": "src/TallyReadinessFlow.tsx", @@ -379,7 +379,7 @@ }, { "path": "src/styles.css", - "sha256": "ff23d59914a48f3f3621499a8121fc63ee93f7b18ad4ae787d7f1d6e04b81281" + "sha256": "b488d5305557b4c356e4d2e16971d387724e9be17ede1d8c05213f64c9661146" }, { "path": "src/tally-company-selection.ts", @@ -434,5 +434,5 @@ "sha256": "a27f294ee15e407b69fdfc73609e8708ac0509b6e6a8872daef5451fde61a8db" } ], - "manifest_sha256": "24d812389629d56f5750b2b021089c1fcf3d1a330b0bd440683aa54219b60303" + "manifest_sha256": "29d49a8d8cf10c3415c7d487dfb7419e69ea581f035ec1b3d82788f2ea25f25e" } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index f6effd95..6447c907 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -9,6 +9,9 @@ use crate::db::tally_mirror::{ WriteFixtureEnrollmentInput, WriteFixtureEnrollmentStatus, }; use crate::gst::{GstDraftRequest, GstReturnDraft}; +use crate::reports::bulk_party_statement::{ + bulk_party_statement_party_count, write_bulk_party_statements, +}; use crate::reports::party_statement::{build_party_statement, PartyStatementError}; use crate::reports::party_statement_pdf::render_party_statement_pdf; use crate::reports::party_statement_xlsx::render_party_statement_xlsx; @@ -2918,6 +2921,100 @@ pub enum PartyStatementFormat { Pdf, } +#[derive(Debug, Deserialize)] +pub struct ExportBulkPartyStatementsRequest { + pub company: String, + pub as_of_yyyymmdd: String, + pub format: PartyStatementFormat, + /// Chosen by the native folder picker. The command still checks that it + /// exists and is a directory before any statement name is joined to it. + pub destination: String, + /// These are complete statement-source rows from the finished local read, + /// not the dashboard's display projections. + pub open_bills: Vec, + pub unallocated_by_party: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct PreviewBulkPartyStatementsRequest { + /// The same complete rows the export command will consume. This command + /// performs no I/O or Tally read; it only makes the pending scope explicit. + pub open_bills: Vec, + pub unallocated_by_party: Vec, +} + +#[derive(Debug, Serialize)] +pub struct BulkPartyStatementsPreview { + pub party_count: usize, +} + +/// Lets the operator choose where the whole statement batch will be written. +#[tauri::command] +pub async fn select_party_statement_destination() -> Result, String> { + tokio::task::spawn_blocking(|| { + Ok(rfd::FileDialog::new() + .set_title("Choose a folder for party statements") + .pick_folder() + .map(|path| path.to_string_lossy().into_owned())) + }) + .await + .map_err(|_| "Bridge could not open the statement destination picker.".to_string())? +} + +/// Counts the unique, non-zero parties that the bulk writer will process. +/// Kept beside the writer's source conversion so the confirmation cannot use +/// a separately implemented frontend approximation of the export scope. +#[tauri::command] +pub async fn preview_bulk_party_statements( + request: PreviewBulkPartyStatementsRequest, +) -> Result { + let open_bills = request + .open_bills + .into_iter() + .map(into_open_bill_row) + .collect::, _>>()?; + Ok(BulkPartyStatementsPreview { + party_count: bulk_party_statement_party_count(&open_bills, &request.unallocated_by_party), + }) +} + +/// Writes a separate statement for every party in the completed source rows. +/// A failed party remains visible in the returned result and manifest while +/// the remaining parties continue, so an operator cannot mistake a partial +/// batch for a complete send-ready set. +#[tauri::command] +pub async fn export_bulk_party_statements( + request: ExportBulkPartyStatementsRequest, +) -> Result { + let open_bills = request + .open_bills + .into_iter() + .map(into_open_bill_row) + .collect::, _>>()?; + let destination = std::path::PathBuf::from(request.destination); + + match request.format { + PartyStatementFormat::Xlsx => write_bulk_party_statements( + &destination, + &request.company, + &request.as_of_yyyymmdd, + "xlsx", + &open_bills, + &request.unallocated_by_party, + |statement| render_party_statement_xlsx(statement).map_err(|error| error.to_string()), + ), + PartyStatementFormat::Pdf => write_bulk_party_statements( + &destination, + &request.company, + &request.as_of_yyyymmdd, + "pdf", + &open_bills, + &request.unallocated_by_party, + |statement| render_party_statement_pdf(statement).map_err(|error| error.to_string()), + ), + } +} + /// Builds one party's aged-bills statement in the requested format and writes /// it to the user's Downloads folder. /// diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 12e97901..ad69e4e4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -90,6 +90,9 @@ pub fn run() { commands::save_report_download, commands::reveal_exported_file, commands::export_party_statement, + commands::select_party_statement_destination, + commands::preview_bulk_party_statements, + commands::export_bulk_party_statements, commands::fetch_tally_outstandings_all_companies, commands::detect_tally_base_currency, commands::tally_persisted_company_profiles, diff --git a/src-tauri/src/reports/bulk_party_statement.rs b/src-tauri/src/reports/bulk_party_statement.rs new file mode 100644 index 00000000..69399e1d --- /dev/null +++ b/src-tauri/src/reports/bulk_party_statement.rs @@ -0,0 +1,426 @@ +//! Writes one statement file per party from an already-complete local result. +//! +//! This module deliberately has no Tally transport dependency. The caller +//! supplies the rows obtained during the completed outstandings read. + +use std::collections::BTreeSet; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use serde::Serialize; + +use super::party_statement::{build_party_statement, PartyStatement}; +use crate::tally::{ExposureDirection, OpenBillRow, UnallocatedParty}; + +#[derive(Debug, Clone, Serialize)] +pub struct BulkPartyStatementResult { + pub destination: String, + pub manifest_path: String, + pub written: Vec, + pub failures: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct WrittenStatement { + pub party: String, + pub file_name: String, + pub receivable_amount: String, + pub payable_amount: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct StatementFailure { + pub party: String, + pub error: String, +} + +#[derive(Serialize)] +struct StatementManifest<'a> { + company: &'a str, + as_of_yyyymmdd: &'a str, + format: &'a str, + written: &'a [WrittenStatement], + failures: &'a [StatementFailure], +} + +/// Produces a separate file for every party represented in the complete rows. +/// +/// Per-party failures are retained and reported after every remaining party is +/// attempted. That makes a partially completed batch explicit, while still +/// giving the operator usable files for parties whose statements rendered. +pub fn write_bulk_party_statements( + destination: &Path, + company: &str, + as_of_yyyymmdd: &str, + format: &str, + open_bills: &[OpenBillRow], + unallocated_by_party: &[UnallocatedParty], + render: impl Fn(&PartyStatement) -> Result, String>, +) -> Result { + if !destination.is_dir() { + return Err("Bridge could not use that statement destination folder.".to_string()); + } + + let parties = statement_parties(open_bills, unallocated_by_party); + let mut written = Vec::with_capacity(parties.len()); + let mut failures = Vec::new(); + for party in parties { + let statement = match build_party_statement( + company, + as_of_yyyymmdd, + &party, + open_bills, + unallocated_by_party, + ) { + Ok(statement) => statement, + Err(error) => { + failures.push(StatementFailure { + party, + error: error.to_string(), + }); + continue; + } + }; + let (receivable_amount, payable_amount) = match statement_directional_totals(&statement) { + Ok(totals) => totals, + Err(error) => { + failures.push(StatementFailure { party, error }); + continue; + } + }; + let bytes = match render(&statement) { + Ok(bytes) => bytes, + Err(error) => { + failures.push(StatementFailure { party, error }); + continue; + } + }; + let stem = format!( + "statement-{}-{as_of_yyyymmdd}", + safe_party_slug(&statement.party) + ); + match write_unique_file(destination, &stem, format, &bytes) { + Ok(path) => written.push(WrittenStatement { + party, + file_name: file_name(&path)?, + receivable_amount, + payable_amount, + }), + Err(error) => failures.push(StatementFailure { party, error }), + } + } + + let manifest = StatementManifest { + company, + as_of_yyyymmdd, + format, + written: &written, + failures: &failures, + }; + let manifest_bytes = serde_json::to_vec_pretty(&manifest) + .map_err(|error| format!("Bridge could not build the statement manifest: {error}"))?; + let manifest_path = write_unique_file( + destination, + &format!("statement-manifest-{as_of_yyyymmdd}"), + "json", + &manifest_bytes, + )?; + + Ok(BulkPartyStatementResult { + destination: destination.to_string_lossy().into_owned(), + manifest_path: manifest_path.to_string_lossy().into_owned(), + written, + failures, + }) +} + +fn statement_directional_totals(statement: &PartyStatement) -> Result<(String, String), String> { + let mut receivable = bridge_tally_core::ExactDecimal::zero(); + let mut payable = bridge_tally_core::ExactDecimal::zero(); + for bill in &statement.bills { + let total = match bill.kind { + "receivable" => &mut receivable, + "payable" => &mut payable, + _ => return Err("Bridge found an unknown statement direction.".to_string()), + }; + *total = total + .checked_add(&bill.amount) + .map_err(|_| "Bridge could not total a statement direction exactly.".to_string())?; + } + if !statement.unallocated.is_zero() { + let total = match statement.unallocated_direction { + Some(ExposureDirection::Receivable) => &mut receivable, + Some(ExposureDirection::Payable) => &mut payable, + None => { + return Err("Bridge found an unallocated amount without a direction.".to_string()) + } + }; + *total = total + .checked_add(&statement.unallocated) + .map_err(|_| "Bridge could not total a statement direction exactly.".to_string())?; + } + Ok(( + receivable.as_str().to_string(), + payable.as_str().to_string(), + )) +} + +fn statement_parties( + open_bills: &[OpenBillRow], + unallocated_by_party: &[UnallocatedParty], +) -> BTreeSet { + open_bills + .iter() + .filter(|row| !row.amount.is_zero()) + .map(|row| row.party.clone()) + .chain( + unallocated_by_party + .iter() + .filter(|entry| !entry.amount.is_zero()) + .map(|entry| entry.party.clone()), + ) + .collect() +} + +/// Counts the non-zero, exact-name parties that a bulk statement would cover. +/// This is shared by the operator preview and the writer so their scopes +/// cannot diverge. +pub fn bulk_party_statement_party_count( + open_bills: &[OpenBillRow], + unallocated_by_party: &[UnallocatedParty], +) -> usize { + statement_parties(open_bills, unallocated_by_party).len() +} + +/// Converts arbitrary ledger text to a portable ASCII filename component. +/// Separators, controls, Windows-reserved punctuation, leading dots, trailing +/// spaces, and non-ASCII characters all become collapsed hyphens. +fn safe_party_slug(party: &str) -> String { + let mut slug = String::with_capacity(party.len()); + let mut previous_was_dash = false; + for ch in party.chars() { + if ch.is_ascii_alphanumeric() { + slug.push(ch.to_ascii_lowercase()); + previous_was_dash = false; + } else if !previous_was_dash { + slug.push('-'); + previous_was_dash = true; + } + } + let trimmed = slug.trim_matches('-'); + if trimmed.is_empty() { + "party".to_string() + } else { + trimmed.chars().take(120).collect() + } +} + +/// Creates a previously unused filename. `create_new` closes the race between +/// candidate selection and writing, so neither a same-run slug collision nor +/// a pre-existing file can be silently overwritten. +fn write_unique_file( + destination: &Path, + stem: &str, + extension: &str, + bytes: &[u8], +) -> Result { + if Path::new(stem).components().count() != 1 { + return Err("Bridge could not build a safe statement filename.".to_string()); + } + for sequence in 1..=10_000_u32 { + let suffix = if sequence == 1 { + String::new() + } else { + format!("-{sequence}") + }; + let path = destination.join(format!("{stem}{suffix}.{extension}")); + let mut file = match OpenOptions::new().write(true).create_new(true).open(&path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => { + return Err(format!( + "Bridge could not create {}: {error}", + path.display() + )) + } + }; + if let Err(error) = file.write_all(bytes) { + drop(file); + let _ = fs::remove_file(&path); + return Err(format!( + "Bridge could not finish writing {}: {error}", + path.display() + )); + } + return Ok(path); + } + Err("Bridge could not find an unused statement filename after 10,000 attempts.".to_string()) +} + +fn file_name(path: &Path) -> Result { + path.file_name() + .and_then(|name| name.to_str()) + .map(str::to_owned) + .ok_or_else(|| "Bridge could not represent the statement filename.".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tally::ExposureDirection; + use bridge_tally_core::ExactDecimal; + + fn bill(party: &str, amount: &str) -> OpenBillRow { + OpenBillRow { + party: party.to_string(), + reference: "SYNTHETIC-1".to_string(), + bill_date: "20260101".to_string(), + due_date: "20260201".to_string(), + amount: ExactDecimal::parse(amount).expect("synthetic decimal"), + age_days: Some(40), + kind: "receivable", + } + } + + #[test] + fn traversal_like_party_name_cannot_escape_the_selected_directory() { + let destination = tempfile::tempdir().expect("temporary destination"); + let result = write_bulk_party_statements( + destination.path(), + "Synthetic Books Pvt Ltd", + "20260808", + "xlsx", + &[bill("../../etc/passwd", "15.00")], + &[], + |_| Ok(b"synthetic workbook".to_vec()), + ) + .expect("statement batch succeeds"); + + assert_eq!(result.written.len(), 1); + let file = destination.path().join(&result.written[0].file_name); + assert!(file.starts_with(destination.path())); + assert!(file.is_file()); + assert_eq!( + result.written[0].file_name, + "statement-etc-passwd-20260808.xlsx" + ); + } + + #[test] + fn renderer_failure_is_recorded_in_result_and_manifest_while_other_parties_write() { + let destination = tempfile::tempdir().expect("temporary destination"); + let result = write_bulk_party_statements( + destination.path(), + "Synthetic Books Pvt Ltd", + "20260808", + "pdf", + &[bill("Good Party", "10.00"), bill("Broken Party", "20.00")], + &[], + |statement| { + if statement.party == "Broken Party" { + Err("synthetic renderer failure".to_string()) + } else { + Ok(b"synthetic PDF".to_vec()) + } + }, + ) + .expect("partial batch result is returned"); + + assert_eq!(result.written.len(), 1); + assert_eq!(result.written[0].party, "Good Party"); + assert_eq!(result.written[0].receivable_amount, "10"); + assert_eq!(result.written[0].payable_amount, "0"); + assert_eq!(result.failures.len(), 1); + assert_eq!(result.failures[0].party, "Broken Party"); + let manifest = fs::read_to_string(&result.manifest_path).expect("manifest is written"); + assert!(manifest.contains("20260808")); + assert!(manifest.contains("Synthetic Books Pvt Ltd")); + assert!(manifest.contains("\"receivable_amount\": \"10\"")); + assert!(manifest.contains("\"payable_amount\": \"0\"")); + assert!(!manifest.contains("\"amount\":")); + assert!(manifest.contains("Broken Party")); + assert!(manifest.contains("synthetic renderer failure")); + } + + #[test] + fn manifest_totals_keep_receivable_and_payable_directions_separate() { + let destination = tempfile::tempdir().expect("temporary destination"); + let mut payable_bill = bill("Mixed Party", "4.00"); + payable_bill.kind = "payable"; + let unallocated = [UnallocatedParty { + party: "Mixed Party".to_string(), + amount: ExactDecimal::parse("3.00").expect("synthetic decimal"), + direction: ExposureDirection::Payable, + }]; + + let result = write_bulk_party_statements( + destination.path(), + "Synthetic Books Pvt Ltd", + "20260808", + "pdf", + &[bill("Mixed Party", "10.00"), payable_bill], + &unallocated, + |_| Ok(b"synthetic PDF".to_vec()), + ) + .expect("mixed statement writes"); + + assert_eq!(result.written[0].receivable_amount, "10"); + assert_eq!(result.written[0].payable_amount, "7"); + } + + #[test] + fn colliding_safe_names_are_written_to_distinct_files() { + let destination = tempfile::tempdir().expect("temporary destination"); + let result = write_bulk_party_statements( + destination.path(), + "Synthetic Books Pvt Ltd", + "20260808", + "xlsx", + &[bill("A/B", "10.00"), bill("A:B", "20.00")], + &[], + |_| Ok(b"synthetic workbook".to_vec()), + ) + .expect("statements write"); + + assert_eq!(result.written.len(), 2); + assert_ne!(result.written[0].file_name, result.written[1].file_name); + assert!(result + .written + .iter() + .all(|entry| destination.path().join(&entry.file_name).is_file())); + } + + #[test] + fn party_count_deduplicates_nonzero_bill_and_unallocated_parties() { + let unallocated = vec![ + UnallocatedParty { + party: "Bill and On Account".to_string(), + amount: ExactDecimal::parse("25.00").expect("synthetic decimal"), + direction: ExposureDirection::Receivable, + }, + UnallocatedParty { + party: "On Account Only".to_string(), + amount: ExactDecimal::parse("10.00").expect("synthetic decimal"), + direction: ExposureDirection::Receivable, + }, + UnallocatedParty { + party: "Zero Balance".to_string(), + amount: ExactDecimal::zero(), + direction: ExposureDirection::Receivable, + }, + ]; + + assert_eq!( + bulk_party_statement_party_count( + &[ + bill("Bill and On Account", "15.00"), + bill("Bill Only", "5.00"), + bill("Zero Balance", "0"), + ], + &unallocated, + ), + 3 + ); + } +} diff --git a/src-tauri/src/reports/mod.rs b/src-tauri/src/reports/mod.rs index 90db2dd2..096442e8 100644 --- a/src-tauri/src/reports/mod.rs +++ b/src-tauri/src/reports/mod.rs @@ -1,6 +1,7 @@ //! Report exports built from data Bridge already holds after //! `fetch_tally_outstandings` -- no module here issues a Tally request. +pub mod bulk_party_statement; pub mod party_statement; pub mod party_statement_pdf; pub mod party_statement_xlsx; diff --git a/src-tauri/src/reports/party_statement_pdf.rs b/src-tauri/src/reports/party_statement_pdf.rs index 637cbccc..050be7e0 100644 --- a/src-tauri/src/reports/party_statement_pdf.rs +++ b/src-tauri/src/reports/party_statement_pdf.rs @@ -42,6 +42,28 @@ struct PdfLine { bold: bool, } +/// Allocates indirect PDF object references without making later document +/// layouts depend on hand-maintained numeric offsets. +#[derive(Debug)] +struct PdfObjectAllocator { + next_id: i32, +} + +impl PdfObjectAllocator { + fn new() -> Self { + Self { next_id: 1 } + } + + fn allocate(&mut self) -> Result { + let id = self.next_id; + self.next_id = self + .next_id + .checked_add(1) + .ok_or(PartyStatementPdfError::TooManyPages)?; + Ok(Ref::new(id)) + } +} + impl PdfLine { fn body(text: impl Into) -> Self { Self { @@ -72,21 +94,18 @@ pub fn render_party_statement_pdf( let page_count_i32 = i32::try_from(page_count).map_err(|_| PartyStatementPdfError::TooManyPages)?; - let catalog_id = Ref::new(1); - let pages_id = Ref::new(2); - let regular_font_id = Ref::new(3); - let bold_font_id = Ref::new(4); + let mut object_ids = PdfObjectAllocator::new(); + let catalog_id = object_ids.allocate()?; + let pages_id = object_ids.allocate()?; + let regular_font_id = object_ids.allocate()?; + let bold_font_id = object_ids.allocate()?; let regular_font = Name(b"F1"); let bold_font = Name(b"F2"); let mut pdf = Pdf::new(); pdf.catalog(catalog_id).pages(pages_id); let page_ids: Vec<_> = (0..page_count) - .map(|index| { - i32::try_from(index) - .map(|index| Ref::new(10 + index * 2)) - .map_err(|_| PartyStatementPdfError::TooManyPages) - }) + .map(|_| object_ids.allocate()) .collect::>()?; pdf.pages(pages_id) .kids(page_ids.iter().copied()) @@ -98,9 +117,7 @@ pub fn render_party_statement_pdf( for (page_index, page_lines) in lines.chunks(BODY_LINES_PER_PAGE).enumerate() { let page_id = page_ids[page_index]; - let content_id = i32::try_from(page_index) - .map(|index| Ref::new(11 + index * 2)) - .map_err(|_| PartyStatementPdfError::TooManyPages)?; + let content_id = object_ids.allocate()?; { let mut page = pdf.page(page_id); page.parent(pages_id) @@ -341,6 +358,21 @@ mod tests { use std::io::{Cursor, Read}; use zip::ZipArchive; + #[test] + fn pdf_object_allocator_issues_unique_sequential_references() { + let mut allocator = PdfObjectAllocator::new(); + let ids = (0..8) + .map(|_| { + allocator + .allocate() + .expect("eight PDF references fit") + .get() + }) + .collect::>(); + + assert_eq!(ids, (1..=8).collect::>()); + } + fn bill(reference: &str, amount: &str, age_days: u32) -> OpenBillRow { OpenBillRow { party: "Synthetic Party".to_string(), diff --git a/src-tauri/src/tally/runtime.rs b/src-tauri/src/tally/runtime.rs index 09444c73..909e557f 100644 --- a/src-tauri/src/tally/runtime.rs +++ b/src-tauri/src/tally/runtime.rs @@ -127,19 +127,21 @@ pub enum OutstandingsLoadResult { /// the bills would be short by 96% with nothing to indicate it. #[serde(skip_serializing_if = "Option::is_none")] unallocated_total: Option, - /// Per-party unallocated exposure, largest first. + /// Complete per-party unallocated exposure, largest first. The + /// frontend applies its display limit locally so the same data can + /// also power complete statement exports without a duplicate payload. /// /// On a book where most balances carry no bill reference, the ageing /// buckets describe a rounding error and this list is the actual /// answer -- so it is surfaced rather than collapsed into the single /// total above. Empty when the path cannot establish it. #[serde(skip_serializing_if = "Vec::is_empty")] - unallocated_by_party: Vec, - /// Every open bill the native reports returned, so the UI can answer - /// "why does this party owe so much" without a second Tally request -- - /// these rows are already in hand. + statement_unallocated_by_party: Vec, + /// Every open bill the native reports returned. The frontend applies + /// its display limit locally; this uncapped source is also what the + /// complete statement export consumes. #[serde(skip_serializing_if = "Vec::is_empty")] - open_bills: Vec, + statement_open_bills: Vec, }, Partial { reason_code: String, @@ -153,7 +155,7 @@ pub enum OutstandingsLoadResult { /// `BILLOVERDUE` column; where no credit period exists the two dates coincide. const MISSING_BILL_REFERENCE_LABEL: &str = "No reference reported"; -fn open_bill_rows( +fn all_open_bill_rows( receivable: &[bridge_tally_protocol::native_outstandings::NativeBillRow], payable: &[bridge_tally_protocol::native_outstandings::NativeBillRow], as_of: &TallyDate, @@ -194,16 +196,15 @@ fn open_bill_rows( .then_with(|| left.party.cmp(&right.party)) .then_with(|| left.reference.cmp(&right.reference)) }); - rows.truncate(MAX_OPEN_BILL_ROWS); rows } -/// Ranks parties by unallocated exposure, largest first. +/// Returns every unallocated party ranked by exposure, largest first. /// /// Zero residuals are dropped rather than listed: a party whose ledger agrees /// exactly with its bills has nothing unallocated, and showing it as a zero row /// buries the parties that do. -fn top_unallocated_parties( +fn all_unallocated_parties( residuals: &[bridge_tally_protocol::native_outstandings::PartyResidual], ) -> Vec { let mut ranked = residuals @@ -227,7 +228,6 @@ fn top_unallocated_parties( .cmp_magnitude(&left.amount) .then_with(|| left.party.cmp(&right.party)) }); - ranked.truncate(10); ranked } @@ -251,21 +251,12 @@ pub struct OpenBillRow { pub kind: &'static str, } -/// Caps how many bill rows cross into the UI. -/// -/// Every row is already parsed, so this bounds only the serialized payload and -/// what the screen must render. A book past this many OPEN bills is well -/// outside anything measured, and silently truncating would be worse than -/// disclosing it -- see `open_bills_truncated`. -const MAX_OPEN_BILL_ROWS: usize = 2_000; - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ExposureDirection { Receivable, Payable, } - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum OutstandingsAgeingAnchor { @@ -1419,14 +1410,17 @@ impl TallyRuntime { return Ok(partial_result(reason_code)); } + let statement_open_bills = + all_open_bill_rows(&receivable_rows, &payable_rows, &as_of); + let statement_unallocated_by_party = all_unallocated_parties(&result.residuals); Ok(OutstandingsLoadResult::Complete { report: Box::new(result.report), currency_assertion, ageing_anchor: OutstandingsAgeingAnchor::DueDate, synced_at_unix_ms: chrono::Utc::now().timestamp_millis(), unallocated_total: Some(result.residual_total), - unallocated_by_party: top_unallocated_parties(&result.residuals), - open_bills: open_bill_rows(&receivable_rows, &payable_rows, &as_of), + statement_unallocated_by_party, + statement_open_bills, }) } }, @@ -1864,8 +1858,8 @@ impl TallyRuntime { // remainder, so it must stay absent rather // than be reported as zero. unallocated_total: None, - unallocated_by_party: Vec::new(), - open_bills: Vec::new(), + statement_unallocated_by_party: Vec::new(), + statement_open_bills: Vec::new(), }), ScanResult::Partial(partial) => { Ok(partial_result(&partial.reason_code)) @@ -2210,7 +2204,7 @@ mod tests { &TallyDate::parse("20260817").expect("capture as-of"), ) .expect("captured validation-book rows parse"); - let rows = open_bill_rows( + let rows = all_open_bill_rows( &receivable, &[], &TallyDate::parse("20260817").expect("capture as-of"), @@ -2268,7 +2262,7 @@ mod tests { &as_of, ) .expect("paired empty BILLREF values remain parseable"); - let rows = open_bill_rows(&parsed, &[], &as_of); + let rows = all_open_bill_rows(&parsed, &[], &as_of); assert_eq!(rows.len(), 2, "empty identities must not collapse rows"); let total = rows @@ -2395,6 +2389,93 @@ mod tests { } } + #[test] + fn future_due_bill_from_raw_native_bytes_reaches_the_statement_source() { + let books_from = TallyDate::parse("20260401").expect("synthetic book start"); + let as_of = TallyDate::parse("20260731").expect("synthetic as-of date"); + let bills_xml = "\ + 1-Jul-26FUTURE-1Synthetic Party\ + -100.001-Aug-260\ + "; + let ledger_xml = "
1
"; + let receivable = parse_native_bill_rows(bills_xml, &books_from, &as_of) + .expect("a future due date in raw native bytes must parse"); + let ledgers = parse_native_ledger_snapshot(ledger_xml) + .expect("the synthetic raw ledger response must parse"); + let computed = compute_native_outstandings( + "Synthetic Company", + &receivable, + &[], + NativeMasterSnapshot { + ledgers: &ledgers, + groups: &[], + }, + AgeingAnchor::DueDate, + &as_of, + bills_xml.len() + ledger_xml.len(), + ) + .expect("a future-due bill must not abort the native computation"); + assert_eq!(computed.report.receivable_total.as_str(), "100"); + assert_eq!(computed.report.top_parties[0].oldest_bill_age_days, None); + assert_eq!(computed.report.ageing.days_0_30.as_str(), "100"); + assert_eq!(computed.report.ageing.days_31_60, ExactDecimal::zero()); + assert_eq!(computed.report.ageing.days_61_90, ExactDecimal::zero()); + assert_eq!(computed.report.ageing.days_90_plus, ExactDecimal::zero()); + assert_eq!(computed.report.open_receivable_bill_count, 1); + + let statement_rows = all_open_bill_rows(&receivable, &[], &as_of); + assert_eq!( + statement_rows.len(), + 1, + "a future-due bill must remain available to the statement source" + ); + assert_eq!(statement_rows[0].amount.as_str(), "100.00"); + assert_eq!(statement_rows[0].age_days, None); + let statement = crate::reports::party_statement::build_party_statement( + "Synthetic Company", + as_of.as_str(), + "Synthetic Party", + &statement_rows, + &[], + ) + .expect("the future-due bill must build a party statement"); + assert_eq!(statement.bills.len(), 1); + assert_eq!(statement.bills[0].reference, "FUTURE-1"); + assert_eq!(statement.bills[0].age_days, None); + assert_eq!(statement.bills[0].bucket, None); + assert_eq!(statement.bill_total.as_str(), "100"); + + let destination = tempfile::tempdir().expect("synthetic destination"); + let bulk = crate::reports::bulk_party_statement::write_bulk_party_statements( + destination.path(), + "Synthetic Company", + as_of.as_str(), + "xlsx", + &statement_rows, + &[], + |party_statement| { + crate::reports::party_statement_xlsx::render_party_statement_xlsx(party_statement) + .map_err(|error| error.to_string()) + }, + ) + .expect("a bulk run must write the future-due party statement"); + assert_eq!(bulk.written.len(), 1); + let workbook = std::fs::File::open(destination.path().join(&bulk.written[0].file_name)) + .expect("the bulk statement file exists"); + let mut archive = zip::ZipArchive::new(workbook).expect("bulk output is an XLSX archive"); + let mut workbook_text = String::new(); + for entry_name in ["xl/worksheets/sheet1.xml", "xl/sharedStrings.xml"] { + let mut entry = archive + .by_name(entry_name) + .expect("the XLSX statement entry exists"); + std::io::Read::read_to_string(&mut entry, &mut workbook_text) + .expect("the workbook XML is readable"); + } + assert!(workbook_text.contains("FUTURE-1")); + assert!(workbook_text.contains("Not due")); + assert!(workbook_text.contains("Unaged")); + } + #[cfg(feature = "voucher-scan")] #[test] fn outstandings_read_failures_are_partial_and_deadlines_recommend_restart() { diff --git a/src/OutstandingsScreen.tsx b/src/OutstandingsScreen.tsx index 234930b1..1ed4e169 100644 --- a/src/OutstandingsScreen.tsx +++ b/src/OutstandingsScreen.tsx @@ -59,6 +59,12 @@ type OpenBill = { kind: "receivable" | "payable"; }; +// These limits bound only what the dashboard renders. The Tauri result keeps +// complete statement-source rows so an export is never silently narrowed by a +// presentation cap. +const DISPLAY_OPEN_BILL_ROW_LIMIT = 2_000; +const DISPLAY_UNALLOCATED_PARTY_LIMIT = 10; + type LoadResult = | { state: "complete"; @@ -69,11 +75,10 @@ type LoadResult = // Absent when the read path cannot establish it. Absent is not zero and // must never render as zero. unallocated_total?: string; - unallocated_by_party?: Array<{ party: string; amount: string }>; - // Absent on the voucher-scan path, which reads no per-bill detail. - // Absent must never render as "no open bills" -- it means the - // party row cannot be expanded at all. - open_bills?: Array; + // Complete, uncapped rows. The dashboard applies its own display caps; + // statement exports always use these complete sources. + statement_open_bills?: Array; + statement_unallocated_by_party?: Array<{ party: string; amount: string }>; } | { state: "partial"; reason_code: string; synced_at_unix_ms: number }; @@ -85,8 +90,14 @@ export function OutstandingsScreen({ config, company, onChangeSetup, onViewAllCl const [loading, setLoading] = React.useState(false); const [inrAssertedCompanyGuid, setInrAssertedCompanyGuid] = React.useState(null); const [view, setView] = React.useState<"ageing" | "unallocated">("ageing"); - const [exportNotice, setExportNotice] = React.useState<{ message: string; path?: string } | null>(null); + const [exportNotice, setExportNotice] = React.useState<{ + message: string; + path?: string; + location?: string; + failures?: Array<{ party: string; error: string }>; + } | null>(null); const [expandedParty, setExpandedParty] = React.useState(null); + const [bulkStatementExporting, setBulkStatementExporting] = React.useState(false); const [partySort, setPartySort] = React.useState(null); const [currencyCheck, setCurrencyCheck] = React.useState<"idle" | "checking" | "inr" | "undetermined">("idle"); const [, refreshClock] = React.useReducer((value) => value + 1, 0); @@ -178,12 +189,16 @@ export function OutstandingsScreen({ config, company, onChangeSetup, onViewAllCl // some renders and not others, which React rejects outright with "Rendered // more hooks than during the previous render" -- and the whole screen blanks. const inrCompleteResult = isInrCompleteResult(result) ? result : null; - // null when open_bills is absent entirely (voucher-scan path) -- distinct - // from a party simply having no rows in a present-but-partial array. - const openBillsByParty = React.useMemo( - () => billsByParty(inrCompleteResult?.open_bills), + const displayOpenBills = React.useMemo( + () => inrCompleteResult?.statement_open_bills?.slice(0, DISPLAY_OPEN_BILL_ROW_LIMIT), [inrCompleteResult], ); + // null when statement rows are absent entirely (voucher-scan path) -- + // distinct from a party simply having no rows in the display projection. + const openBillsByParty = React.useMemo( + () => billsByParty(displayOpenBills), + [displayOpenBills], + ); // On a book where most balances carry no bill reference, the ageing panel // can describe a rounding error against total exposure. Default to @@ -233,7 +248,8 @@ export function OutstandingsScreen({ config, company, onChangeSetup, onViewAllCl const composition = report ? exposureComposition(report, completeResult?.unallocated_total) : null; - const unallocatedParties = completeResult?.unallocated_by_party ?? []; + const unallocatedParties = completeResult?.statement_unallocated_by_party + ?.slice(0, DISPLAY_UNALLOCATED_PARTY_LIMIT) ?? []; const largestUnallocated = Math.max(...unallocatedParties.map((entry) => amountOf(entry.amount)), 0); const largestExposure = report ? Math.max(...report.top_parties.map((party) => amountOf(party.outstanding_total)), 0) @@ -262,6 +278,38 @@ export function OutstandingsScreen({ config, company, onChangeSetup, onViewAllCl const ageingDisclosure = report && completeResult?.unallocated_total === undefined && outstandingsAgeingDisclosure(report.has_unaged_receivable); const unsupportedCurrencyAssertion = result?.state === "complete" && !completeResult; + const batchStatementRowsAvailable = completeResult !== null + && (completeResult.statement_open_bills !== undefined + || completeResult.statement_unallocated_by_party !== undefined); + const exportAllPartyStatements = async (format: "xlsx" | "pdf") => { + if (!completeResult) return; + try { + const destination = await invoke("select_party_statement_destination"); + if (!destination) return; + const preview = await previewBulkPartyStatements(completeResult); + if (preview.party_count === 0) { + setExportNotice({ message: "No parties with outstanding balances are available for statements." }); + return; + } + const label = format === "xlsx" ? "Excel" : "PDF"; + const confirmed = window.confirm( + `Create ${preview.party_count} ${label} statement${preview.party_count === 1 ? "" : "s"} in:\n${destination}\n\nThe dashboard shows only its largest parties. This batch includes every party with a non-zero outstanding balance.`, + ); + if (!confirmed) return; + setBulkStatementExporting(true); + const batch = await exportBulkPartyStatements(completeResult, destination, format); + setExportNotice({ + message: `${batch.written.length} ${label} statement${batch.written.length === 1 ? "" : "s"} written`, + path: batch.manifest_path, + location: batch.destination, + failures: batch.failures, + }); + } catch (cause) { + setExportNotice({ message: operatorMessage(cause) }); + } finally { + setBulkStatementExporting(false); + } + }; return (
@@ -302,6 +350,28 @@ export function OutstandingsScreen({ config, company, onChangeSetup, onViewAllCl Export )} + {batchStatementRowsAvailable && ( + <> + + + + )} {!outstandingsUnavailable && ( + {exportNotice.failures && exportNotice.failures.length > 0 && ( +
    + {exportNotice.failures.map((failure) => ( +
  • {failure.party}: {failure.error}
  • + ))} +
+ )}
)} {error &&
Read failed{error}
} @@ -553,9 +632,9 @@ export function OutstandingsScreen({ config, company, onChangeSetup, onViewAllCl } /// Builds one party's statement in the selected format via the Rust command -/// and writes it to Downloads. Sends the `open_bills`/`unallocated_by_party` -/// rows this screen already holds from `fetch_tally_outstandings` -- Bridge -/// never reads Tally a second time to produce a statement. +/// and writes it to Downloads. Sends the complete statement source rows this +/// screen already holds from `fetch_tally_outstandings` -- Bridge never reads +/// Tally a second time to produce a statement. async function exportPartyStatement( result: InrCompleteResult, party: string, @@ -567,8 +646,48 @@ async function exportPartyStatement( as_of_yyyymmdd: result.report.as_of_yyyymmdd, party, format, - open_bills: result.open_bills ?? [], - unallocated_by_party: result.unallocated_by_party ?? [], + open_bills: result.statement_open_bills ?? [], + unallocated_by_party: result.statement_unallocated_by_party ?? [], + }, + }); +} + +type BulkPartyStatementResult = { + destination: string; + manifest_path: string; + written: Array<{ party: string; file_name: string; amount: string }>; + failures: Array<{ party: string; error: string }>; +}; + +type BulkPartyStatementsPreview = { party_count: number }; + +/// Uses the complete statement-source rows returned by the finished read. The +/// dashboard's top-ten and drill-down projections are intentionally not used: +/// a batch must not silently omit a party beyond a display cap. +async function exportBulkPartyStatements( + result: InrCompleteResult, + destination: string, + format: "xlsx" | "pdf", +) { + return invoke("export_bulk_party_statements", { + request: { + company: result.report.company_name, + as_of_yyyymmdd: result.report.as_of_yyyymmdd, + destination, + format, + open_bills: result.statement_open_bills ?? [], + unallocated_by_party: result.statement_unallocated_by_party ?? [], + }, + }); +} + +/// Uses the same complete source rows and backend counting rule as the writer, +/// so the confirmation names the exact scope before any files are created. +async function previewBulkPartyStatements(result: InrCompleteResult) { + return invoke("preview_bulk_party_statements", { + request: { + open_bills: result.statement_open_bills ?? [], + unallocated_by_party: result.statement_unallocated_by_party ?? [], }, }); } @@ -581,7 +700,11 @@ async function exportPartyStatement( /// because a figure that needs a caveat on screen needs it in the file too -- /// the file is what gets forwarded. async function exportCsv(result: InrCompleteResult) { - const csv = reportToCsv(result.report, result.unallocated_total, result.unallocated_by_party); + const csv = reportToCsv( + result.report, + result.unallocated_total, + result.statement_unallocated_by_party, + ); const slug = result.report.company_name.replace(/[^a-z0-9]+/gi, "-").toLowerCase(); // A BOM so Excel reads UTF-8 party names instead of mojibake -- Indian // ledger names routinely carry non-ASCII characters. @@ -697,7 +820,7 @@ function ageingRows(report: Report) { } /// Groups open bills by exact party name for the drill-down. Returns null -/// when `open_bills` is absent entirely (the voucher-scan path never sends +/// when statement rows are absent entirely (the voucher-scan path never sends /// it) -- that null is what tells a row it must not be expandable at all, /// distinct from a present array that simply has no rows for this party. function billsByParty(openBills: Array | undefined): Map> | null { diff --git a/src/styles.css b/src/styles.css index 3a0eb1c3..ac9b9e27 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1982,6 +1982,7 @@ summary:focus-visible, .outstandings-export-notice { display: flex; + flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 16px; @@ -2001,6 +2002,13 @@ summary:focus-visible, text-decoration: underline; } +.outstandings-export-failures { + flex-basis: 100%; + margin: 0; + padding-left: 18px; + color: #8f2e27; +} + .view-switch { display: inline-flex; gap: 2px;