Skip to main content

datasynth_output/formats/
sap_master_data.rs

1//! SAP master-data table exporters — v4.3.0b.
2//!
3//! Complements the document-oriented exporter in `formats::sap` by
4//! mapping the DataSynth master-data models (`Vendor`, `Customer`,
5//! `Material`) into the SAP master-data tables that accompany the
6//! classical three-table triple (BKPF / BSEG / ACDOCA):
7//!
8//! | DataSynth model | SAP general table | SAP company-code table |
9//! |-----------------|-------------------|------------------------|
10//! | `Vendor`        | LFA1              | LFB1                   |
11//! | `Customer`      | KNA1              | KNB1                   |
12//! | `Material`      | MARA              | MARD                   |
13//!
14//! LFA1 / KNA1 are cross-client "general data" tables (one row per
15//! master record). LFB1 / KNB1 carry company-code-specific data
16//! (reconciliation account, payment terms, dunning block) — one row
17//! per (vendor|customer, company code). MARA is the client-level
18//! material master; MARD is the storage-location-level stock view.
19//!
20//! All exporters honour the `SapDialect` setting of the parent
21//! `SapExportConfig` (delimiter / decimal separator / date format /
22//! UTF-8 BOM).
23
24use std::fs::File;
25use std::io::{BufWriter, Write};
26use std::path::Path;
27
28use chrono::Datelike;
29use datasynth_core::error::SynthResult;
30use datasynth_core::models::{
31    ChartOfAccounts, CostCenter, Customer, FixedAsset, GLAccount, Material, ProfitCenter, Vendor,
32};
33
34use super::sap::{
35    SapCustomer, SapCustomerExportable, SapExportConfig, SapVendor, SapVendorExportable,
36};
37
38// ===========================================================================
39// LFA1 — Vendor general data (one row per Vendor)
40// ===========================================================================
41
42impl SapVendorExportable for Vendor {
43    fn to_sap_vendor(&self, client: &str) -> SapVendor {
44        SapVendor {
45            mandt: client.to_string(),
46            lifnr: self.vendor_id.clone(),
47            land1: self.country.clone(),
48            name1: self.name.clone(),
49            name2: None,
50            ort01: None,
51            pstlz: None,
52            stras: None,
53            regio: None,
54            spras: language_for_country(&self.country).to_string(),
55            stcd1: self.tax_id.clone(),
56            ktokk: account_group_for_vendor(self),
57        }
58    }
59}
60
61// ===========================================================================
62// KNA1 — Customer general data (one row per Customer)
63// ===========================================================================
64
65impl SapCustomerExportable for Customer {
66    fn to_sap_customer(&self, client: &str) -> SapCustomer {
67        SapCustomer {
68            mandt: client.to_string(),
69            kunnr: self.customer_id.clone(),
70            land1: self.country.clone(),
71            name1: self.name.clone(),
72            name2: None,
73            ort01: None,
74            pstlz: None,
75            stras: None,
76            regio: None,
77            spras: language_for_country(&self.country).to_string(),
78            stcd1: self.tax_id.clone(),
79            ktokd: account_group_for_customer(self),
80        }
81    }
82}
83
84// ===========================================================================
85// LFB1 — Vendor company-code data (one row per (vendor, company code))
86// ===========================================================================
87
88/// SAP LFB1 — vendor company-code view. Carries the reconciliation
89/// account, payment terms, dunning procedure, and withholding-tax flag
90/// that are company-code-specific on a vendor master record.
91#[derive(Debug, Clone)]
92pub struct SapVendorCompanyCode {
93    pub mandt: String,
94    pub lifnr: String,
95    pub bukrs: String,
96    /// Reconciliation GL account (AKONT) — AP control account in the chart.
97    pub akont: String,
98    /// Payment terms key (ZTERM) — "Net30" / "Net60" / "2_10_Net_30" etc.
99    pub zterm: String,
100    /// Dunning procedure (MAHNA) — blank when no dunning applies.
101    pub mahna: Option<String>,
102    /// Withholding tax code (QSSKZ) — populated when withholding is applicable.
103    pub qsskz: Option<String>,
104    /// Payment block reason (ZAHLS) — blank = open, "A"/"B"/... = blocked.
105    pub zahls: Option<String>,
106    /// Vendor sort key (SORTL).
107    pub sortl: Option<String>,
108    /// Account creation date (ERDAT).
109    pub erdat: chrono::NaiveDate,
110}
111
112/// Extension trait for building the LFB1 company-code row from a
113/// foreign-crate `Vendor`. Rust's orphan rule forbids an inherent impl
114/// on `Vendor`, so we expose the method via a trait defined here.
115pub trait SapVendorCompanyCodeExportable {
116    fn to_sap_vendor_company_code(&self, client: &str, company_code: &str) -> SapVendorCompanyCode;
117}
118
119impl SapVendorCompanyCodeExportable for Vendor {
120    fn to_sap_vendor_company_code(&self, client: &str, company_code: &str) -> SapVendorCompanyCode {
121        SapVendorCompanyCode {
122            mandt: client.to_string(),
123            lifnr: self.vendor_id.clone(),
124            bukrs: company_code.to_string(),
125            akont: self
126                .reconciliation_account
127                .clone()
128                .unwrap_or_else(|| "2000".to_string()),
129            zterm: format!("{:?}", self.payment_terms),
130            mahna: None,
131            qsskz: if self.withholding_tax_applicable {
132                Some("W1".to_string())
133            } else {
134                None
135            },
136            zahls: None,
137            sortl: Some(self.vendor_id.clone()),
138            erdat: chrono::Utc::now().date_naive(),
139        }
140    }
141}
142
143// ===========================================================================
144// KNB1 — Customer company-code data (one row per (customer, company code))
145// ===========================================================================
146
147/// SAP KNB1 — customer company-code view. Carries the reconciliation
148/// account, payment terms, dunning level / procedure, and credit block
149/// that are company-code-specific on a customer master record.
150#[derive(Debug, Clone)]
151pub struct SapCustomerCompanyCode {
152    pub mandt: String,
153    pub kunnr: String,
154    pub bukrs: String,
155    /// Reconciliation GL account (AKONT) — AR control account.
156    pub akont: String,
157    /// Payment terms key (ZTERM).
158    pub zterm: String,
159    /// Dunning procedure (MAHNA).
160    pub mahna: Option<String>,
161    /// Current dunning level (MAHNS, 0–4).
162    pub mahns: u8,
163    /// Last dunning run date (MADAT).
164    pub madat: Option<chrono::NaiveDate>,
165    /// Payment block reason (ZAHLS).
166    pub zahls: Option<String>,
167    /// Credit block indicator (CRDBLK) — 1 = blocked, 0 = open.
168    pub crdblk: u8,
169    /// Sort key (SORTL).
170    pub sortl: Option<String>,
171    /// Account creation date (ERDAT).
172    pub erdat: chrono::NaiveDate,
173}
174
175/// Extension trait for building the KNB1 company-code row from a
176/// foreign-crate `Customer`.
177pub trait SapCustomerCompanyCodeExportable {
178    fn to_sap_customer_company_code(
179        &self,
180        client: &str,
181        company_code: &str,
182    ) -> SapCustomerCompanyCode;
183}
184
185impl SapCustomerCompanyCodeExportable for Customer {
186    fn to_sap_customer_company_code(
187        &self,
188        client: &str,
189        company_code: &str,
190    ) -> SapCustomerCompanyCode {
191        SapCustomerCompanyCode {
192            mandt: client.to_string(),
193            kunnr: self.customer_id.clone(),
194            bukrs: company_code.to_string(),
195            akont: self
196                .reconciliation_account
197                .clone()
198                .unwrap_or_else(|| "1100".to_string()),
199            zterm: format!("{:?}", self.payment_terms),
200            mahna: self.dunning_procedure.clone(),
201            mahns: self.dunning_level,
202            madat: self.last_dunning_date,
203            zahls: self.credit_block_reason.clone(),
204            crdblk: if self.credit_blocked { 1 } else { 0 },
205            sortl: Some(self.customer_id.clone()),
206            erdat: chrono::Utc::now().date_naive(),
207        }
208    }
209}
210
211// ===========================================================================
212// MARA — Material general data (one row per Material)
213// ===========================================================================
214
215/// SAP MARA — material master general view. One row per material,
216/// client-wide (cross-plant).
217#[derive(Debug, Clone)]
218pub struct SapMaterial {
219    pub mandt: String,
220    /// Material number (MATNR).
221    pub matnr: String,
222    /// Material type (MTART) — "FERT" (finished), "HALB" (semi-finished),
223    /// "ROH" (raw), "HAWA" (trading goods), "DIEN" (service), etc.
224    pub mtart: String,
225    /// Industry sector (MBRSH) — "C" = chemical, "M" = mechanical engineering,
226    /// "1" = retail, defaults to "M" when unknown.
227    pub mbrsh: String,
228    /// Material group (MATKL).
229    pub matkl: String,
230    /// Base unit of measure (MEINS).
231    pub meins: String,
232    /// Gross weight (BRGEW).
233    pub brgew: Option<rust_decimal::Decimal>,
234    /// Volume (VOLUM).
235    pub volum: Option<rust_decimal::Decimal>,
236    /// Unit of weight (GEWEI).
237    pub gewei: String,
238    /// Unit of volume (VOLEH).
239    pub voleh: String,
240    /// Old material number (BISMT) — blank by default.
241    pub bismt: Option<String>,
242    /// Creation date (ERSDA).
243    pub ersda: chrono::NaiveDate,
244    /// Created by user (ERNAM).
245    pub ernam: String,
246}
247
248/// Extension trait for building MARA rows from a foreign-crate `Material`.
249pub trait SapMaterialExportable {
250    fn to_sap_material(&self, client: &str) -> SapMaterial;
251}
252
253impl SapMaterialExportable for Material {
254    fn to_sap_material(&self, client: &str) -> SapMaterial {
255        SapMaterial {
256            mandt: client.to_string(),
257            matnr: self.material_id.clone(),
258            mtart: material_type_to_mtart(&self.material_type),
259            mbrsh: "M".to_string(),
260            matkl: material_group_to_matkl(&self.material_group),
261            // UnitOfMeasure has a `code` String field (e.g. "EA", "KG", "L")
262            // that maps directly to the SAP MEINS column.
263            meins: self.base_uom.code.to_uppercase(),
264            brgew: self.weight_kg,
265            volum: self.volume_m3,
266            gewei: "KG".to_string(),
267            voleh: "M3".to_string(),
268            bismt: None,
269            ersda: chrono::Utc::now().date_naive(),
270            ernam: "SYSTEM".to_string(),
271        }
272    }
273}
274
275// ===========================================================================
276// MARD — Material storage location data (one row per (material, plant, storage location))
277// ===========================================================================
278
279/// SAP MARD — storage-location view. One row per (material, plant,
280/// storage location); carries the period stock total that subledger
281/// inventory reports pull from.
282#[derive(Debug, Clone)]
283pub struct SapMaterialStorage {
284    pub mandt: String,
285    pub matnr: String,
286    /// Plant (WERKS).
287    pub werks: String,
288    /// Storage location (LGORT).
289    pub lgort: String,
290    /// Total unrestricted-use stock (LABST).
291    pub labst: rust_decimal::Decimal,
292    /// Stock in quality inspection (INSME).
293    pub insme: rust_decimal::Decimal,
294    /// Blocked stock (SPEME).
295    pub speme: rust_decimal::Decimal,
296    /// Safety stock (EISLO).
297    pub eislo: rust_decimal::Decimal,
298    /// Reorder point (MINBE).
299    pub minbe: rust_decimal::Decimal,
300}
301
302/// Extension trait for building MARD storage-location rows from a
303/// foreign-crate `Material`.
304pub trait SapMaterialStorageExportable {
305    fn to_sap_material_storage_rows(
306        &self,
307        client: &str,
308        storage_location: &str,
309        stock_by_plant: &[(String, rust_decimal::Decimal)],
310    ) -> Vec<SapMaterialStorage>;
311}
312
313impl SapMaterialStorageExportable for Material {
314    fn to_sap_material_storage_rows(
315        &self,
316        client: &str,
317        storage_location: &str,
318        stock_by_plant: &[(String, rust_decimal::Decimal)],
319    ) -> Vec<SapMaterialStorage> {
320        stock_by_plant
321            .iter()
322            .map(|(plant, qty)| SapMaterialStorage {
323                mandt: client.to_string(),
324                matnr: self.material_id.clone(),
325                werks: plant.clone(),
326                lgort: storage_location.to_string(),
327                labst: *qty,
328                insme: rust_decimal::Decimal::ZERO,
329                speme: rust_decimal::Decimal::ZERO,
330                eislo: self.safety_stock,
331                minbe: self.reorder_point,
332            })
333            .collect()
334    }
335}
336
337// ===========================================================================
338// File writers
339// ===========================================================================
340
341/// Shared helper — opens a file, writes the dialect BOM, returns the buffered writer.
342fn open_master_file(cfg: &SapExportConfig, path: &Path) -> SynthResult<BufWriter<File>> {
343    let file = File::create(path)?;
344    let mut writer = BufWriter::with_capacity(256 * 1024, file);
345    let bom = cfg.dialect.bom();
346    if !bom.is_empty() {
347        writer.write_all(bom)?;
348    }
349    Ok(writer)
350}
351
352fn write_row<W: Write>(writer: &mut W, delim: char, fields: &[String]) -> std::io::Result<()> {
353    for (i, f) in fields.iter().enumerate() {
354        if i > 0 {
355            write!(writer, "{delim}")?;
356        }
357        write!(writer, "{f}")?;
358    }
359    writeln!(writer)
360}
361
362fn write_header<W: Write>(writer: &mut W, delim: char, cols: &[&str]) -> std::io::Result<()> {
363    for (i, c) in cols.iter().enumerate() {
364        if i > 0 {
365            write!(writer, "{delim}")?;
366        }
367        write!(writer, "{c}")?;
368    }
369    writeln!(writer)
370}
371
372fn escape(field: &str) -> String {
373    if field.contains(',') || field.contains(';') || field.contains('"') || field.contains('\n') {
374        format!("\"{}\"", field.replace('"', "\"\""))
375    } else {
376        field.to_string()
377    }
378}
379
380/// Write LFA1 (vendor general data). One row per vendor.
381pub fn write_lfa1(cfg: &SapExportConfig, vendors: &[Vendor], path: &Path) -> SynthResult<()> {
382    let mut writer = open_master_file(cfg, path)?;
383    let delim = cfg.delimiter();
384    write_header(
385        &mut writer,
386        delim,
387        &[
388            "MANDT", "LIFNR", "LAND1", "NAME1", "NAME2", "ORT01", "PSTLZ", "STRAS", "REGIO",
389            "SPRAS", "STCD1", "KTOKK",
390        ],
391    )?;
392    for v in vendors {
393        let s = v.to_sap_vendor(&cfg.client);
394        let fields: Vec<String> = vec![
395            s.mandt,
396            s.lifnr,
397            s.land1,
398            escape(&s.name1),
399            escape(&s.name2.unwrap_or_default()),
400            escape(&s.ort01.unwrap_or_default()),
401            s.pstlz.unwrap_or_default(),
402            escape(&s.stras.unwrap_or_default()),
403            s.regio.unwrap_or_default(),
404            s.spras,
405            s.stcd1.unwrap_or_default(),
406            s.ktokk,
407        ];
408        write_row(&mut writer, delim, &fields)?;
409    }
410    writer.flush()?;
411    Ok(())
412}
413
414/// Write LFB1 (vendor company-code data). Requires a list of company
415/// codes to emit the per-vendor-per-company rows.
416pub fn write_lfb1(
417    cfg: &SapExportConfig,
418    vendors: &[Vendor],
419    company_codes: &[String],
420    path: &Path,
421) -> SynthResult<()> {
422    let mut writer = open_master_file(cfg, path)?;
423    let delim = cfg.delimiter();
424    write_header(
425        &mut writer,
426        delim,
427        &[
428            "MANDT", "LIFNR", "BUKRS", "AKONT", "ZTERM", "MAHNA", "QSSKZ", "ZAHLS", "SORTL",
429            "ERDAT",
430        ],
431    )?;
432    for v in vendors {
433        for company in company_codes {
434            let s = v.to_sap_vendor_company_code(&cfg.client, company);
435            let fields: Vec<String> = vec![
436                s.mandt,
437                s.lifnr,
438                s.bukrs,
439                s.akont,
440                s.zterm,
441                s.mahna.unwrap_or_default(),
442                s.qsskz.unwrap_or_default(),
443                s.zahls.unwrap_or_default(),
444                s.sortl.unwrap_or_default(),
445                cfg.format_date(s.erdat),
446            ];
447            write_row(&mut writer, delim, &fields)?;
448        }
449    }
450    writer.flush()?;
451    Ok(())
452}
453
454/// Write KNA1 (customer general data).
455pub fn write_kna1(cfg: &SapExportConfig, customers: &[Customer], path: &Path) -> SynthResult<()> {
456    let mut writer = open_master_file(cfg, path)?;
457    let delim = cfg.delimiter();
458    write_header(
459        &mut writer,
460        delim,
461        &[
462            "MANDT", "KUNNR", "LAND1", "NAME1", "NAME2", "ORT01", "PSTLZ", "STRAS", "REGIO",
463            "SPRAS", "STCD1", "KTOKD",
464        ],
465    )?;
466    for c in customers {
467        let s = c.to_sap_customer(&cfg.client);
468        let fields: Vec<String> = vec![
469            s.mandt,
470            s.kunnr,
471            s.land1,
472            escape(&s.name1),
473            escape(&s.name2.unwrap_or_default()),
474            escape(&s.ort01.unwrap_or_default()),
475            s.pstlz.unwrap_or_default(),
476            escape(&s.stras.unwrap_or_default()),
477            s.regio.unwrap_or_default(),
478            s.spras,
479            s.stcd1.unwrap_or_default(),
480            s.ktokd,
481        ];
482        write_row(&mut writer, delim, &fields)?;
483    }
484    writer.flush()?;
485    Ok(())
486}
487
488/// Write KNB1 (customer company-code data).
489pub fn write_knb1(
490    cfg: &SapExportConfig,
491    customers: &[Customer],
492    company_codes: &[String],
493    path: &Path,
494) -> SynthResult<()> {
495    let mut writer = open_master_file(cfg, path)?;
496    let delim = cfg.delimiter();
497    write_header(
498        &mut writer,
499        delim,
500        &[
501            "MANDT", "KUNNR", "BUKRS", "AKONT", "ZTERM", "MAHNA", "MAHNS", "MADAT", "ZAHLS",
502            "CRDBLK", "SORTL", "ERDAT",
503        ],
504    )?;
505    for c in customers {
506        for company in company_codes {
507            let s = c.to_sap_customer_company_code(&cfg.client, company);
508            let fields: Vec<String> = vec![
509                s.mandt,
510                s.kunnr,
511                s.bukrs,
512                s.akont,
513                s.zterm,
514                s.mahna.unwrap_or_default(),
515                s.mahns.to_string(),
516                s.madat.map(|d| cfg.format_date(d)).unwrap_or_default(),
517                s.zahls.unwrap_or_default(),
518                s.crdblk.to_string(),
519                s.sortl.unwrap_or_default(),
520                cfg.format_date(s.erdat),
521            ];
522            write_row(&mut writer, delim, &fields)?;
523        }
524    }
525    writer.flush()?;
526    Ok(())
527}
528
529/// Write MARA (material general data).
530pub fn write_mara(cfg: &SapExportConfig, materials: &[Material], path: &Path) -> SynthResult<()> {
531    let mut writer = open_master_file(cfg, path)?;
532    let delim = cfg.delimiter();
533    write_header(
534        &mut writer,
535        delim,
536        &[
537            "MANDT", "MATNR", "MTART", "MBRSH", "MATKL", "MEINS", "BRGEW", "VOLUM", "GEWEI",
538            "VOLEH", "BISMT", "ERSDA", "ERNAM",
539        ],
540    )?;
541    for m in materials {
542        let s = m.to_sap_material(&cfg.client);
543        let fields: Vec<String> = vec![
544            s.mandt,
545            s.matnr,
546            s.mtart,
547            s.mbrsh,
548            s.matkl,
549            s.meins,
550            s.brgew
551                .as_ref()
552                .map(|d| cfg.format_decimal(d))
553                .unwrap_or_default(),
554            s.volum
555                .as_ref()
556                .map(|d| cfg.format_decimal(d))
557                .unwrap_or_default(),
558            s.gewei,
559            s.voleh,
560            s.bismt.unwrap_or_default(),
561            cfg.format_date(s.ersda),
562            s.ernam,
563        ];
564        write_row(&mut writer, delim, &fields)?;
565    }
566    writer.flush()?;
567    Ok(())
568}
569
570/// Write MARD (material storage-location data). Uses a synthetic
571/// `"0001"` storage location when none is supplied — SAP's default.
572pub fn write_mard(cfg: &SapExportConfig, materials: &[Material], path: &Path) -> SynthResult<()> {
573    let mut writer = open_master_file(cfg, path)?;
574    let delim = cfg.delimiter();
575    write_header(
576        &mut writer,
577        delim,
578        &[
579            "MANDT", "MATNR", "WERKS", "LGORT", "LABST", "INSME", "SPEME", "EISLO", "MINBE",
580        ],
581    )?;
582    for m in materials {
583        // Each listed plant emits a MARD row with zero stock — the CLI can
584        // overlay real stock from `InventoryPosition` data if needed.
585        let stock: Vec<(String, rust_decimal::Decimal)> = m
586            .plants
587            .iter()
588            .map(|p| (p.clone(), rust_decimal::Decimal::ZERO))
589            .collect();
590        for s in m.to_sap_material_storage_rows(&cfg.client, "0001", &stock) {
591            let fields: Vec<String> = vec![
592                s.mandt,
593                s.matnr,
594                s.werks,
595                s.lgort,
596                cfg.format_decimal(&s.labst),
597                cfg.format_decimal(&s.insme),
598                cfg.format_decimal(&s.speme),
599                cfg.format_decimal(&s.eislo),
600                cfg.format_decimal(&s.minbe),
601            ];
602            write_row(&mut writer, delim, &fields)?;
603        }
604    }
605    writer.flush()?;
606    Ok(())
607}
608
609// ===========================================================================
610// Internal helpers
611// ===========================================================================
612
613/// Map a DataSynth `MaterialGroup` to the SAP MATKL column (4-char codes).
614fn material_group_to_matkl(g: &datasynth_core::models::MaterialGroup) -> String {
615    use datasynth_core::models::MaterialGroup;
616    match g {
617        MaterialGroup::Electronics => "ELEC",
618        MaterialGroup::Mechanical => "MECH",
619        MaterialGroup::Chemicals | MaterialGroup::Chemical => "CHEM",
620        MaterialGroup::OfficeSupplies => "OFFC",
621        MaterialGroup::ItEquipment => "ITEQ",
622        MaterialGroup::Furniture => "FURN",
623        MaterialGroup::PackagingMaterials => "PACK",
624        MaterialGroup::SafetyEquipment => "SAFE",
625        MaterialGroup::Tools => "TOOL",
626        MaterialGroup::Services => "SERV",
627        MaterialGroup::Consumables => "CONS",
628        MaterialGroup::FinishedGoods => "FINI",
629    }
630    .to_string()
631}
632
633/// Map a DataSynth `MaterialType` to the SAP MTART code.
634fn material_type_to_mtart(mt: &datasynth_core::models::MaterialType) -> String {
635    use datasynth_core::models::MaterialType;
636    match mt {
637        MaterialType::RawMaterial => "ROH",
638        MaterialType::SemiFinished => "HALB",
639        MaterialType::FinishedGood => "FERT",
640        MaterialType::TradingGood => "HAWA",
641        MaterialType::OperatingSupplies => "HIBE",
642        MaterialType::SparePart => "ERSA",
643        MaterialType::Packaging => "VERP",
644        MaterialType::Service => "DIEN",
645    }
646    .to_string()
647}
648
649/// Pick a default SAP KTOKK vendor account group based on vendor type.
650fn account_group_for_vendor(v: &Vendor) -> String {
651    use datasynth_core::models::VendorType;
652    match v.vendor_type {
653        VendorType::Supplier => "LIEF",
654        VendorType::ServiceProvider | VendorType::ProfessionalServices => "SERV",
655        VendorType::Technology => "TECH",
656        VendorType::Logistics => "LOGI",
657        VendorType::Contractor => "CONT",
658        VendorType::RealEstate => "REST",
659        VendorType::Financial => "FINA",
660        VendorType::Utility => "UTIL",
661        VendorType::EmployeeReimbursement => "EMPL",
662    }
663    .to_string()
664}
665
666/// Pick a default SAP KTOKD customer account group based on customer type.
667fn account_group_for_customer(c: &Customer) -> String {
668    use datasynth_core::models::CustomerType;
669    match c.customer_type {
670        CustomerType::Corporate => "KUNA",
671        CustomerType::SmallBusiness => "KUN1",
672        CustomerType::Consumer => "CPDB",
673        CustomerType::Government => "GOVT",
674        CustomerType::NonProfit => "NPRF",
675        CustomerType::Intercompany => "INTR",
676        CustomerType::Distributor => "DIST",
677    }
678    .to_string()
679}
680
681/// Minimal ISO-country → SAP-language (SPRAS) map. Falls back to "E"
682/// (English) for anything not explicitly listed.
683fn language_for_country(iso: &str) -> &'static str {
684    match iso {
685        "DE" | "AT" | "CH" => "D",
686        "FR" | "BE" | "LU" => "F",
687        "ES" | "MX" | "AR" | "CO" | "CL" => "S",
688        "IT" => "I",
689        "PT" | "BR" => "P",
690        "CN" => "1",
691        "JP" => "J",
692        "RU" => "R",
693        "PL" => "L",
694        _ => "E",
695    }
696}
697
698#[allow(dead_code)]
699fn today_ymd() -> String {
700    let now = chrono::Utc::now().date_naive();
701    format!("{:04}{:02}{:02}", now.year(), now.month(), now.day())
702}
703
704// ===========================================================================
705// v4.3.0c — Asset / Cost-center / GL-account masters
706// ===========================================================================
707
708/// SAP ANLA — asset master general data (one row per FixedAsset).
709#[derive(Debug, Clone)]
710pub struct SapAsset {
711    pub mandt: String,
712    pub bukrs: String,
713    /// Main asset number (ANLN1).
714    pub anln1: String,
715    /// Asset sub-number (ANLN2) — "0000" for the main record.
716    pub anln2: String,
717    /// Asset class (ANLKL).
718    pub anlkl: String,
719    /// Description (TXT50).
720    pub txt50: String,
721    /// Capitalisation date (AKTIV).
722    pub aktiv: Option<chrono::NaiveDate>,
723    /// Acquisition date (ZUGDT).
724    pub zugdt: chrono::NaiveDate,
725    /// Deactivation / retirement date (DEAKT).
726    pub deakt: Option<chrono::NaiveDate>,
727    /// Cost centre (KOSTL).
728    pub kostl: Option<String>,
729    /// Serial number (SERNR).
730    pub sernr: Option<String>,
731    /// Manufacturer (HERST).
732    pub herst: Option<String>,
733    /// Creation date (ERDAT).
734    pub erdat: chrono::NaiveDate,
735    /// Created by (ERNAM).
736    pub ernam: String,
737}
738
739/// Extension trait for mapping `FixedAsset` → SAP ANLA.
740pub trait SapAssetExportable {
741    fn to_sap_asset(&self, client: &str) -> SapAsset;
742}
743
744impl SapAssetExportable for FixedAsset {
745    fn to_sap_asset(&self, client: &str) -> SapAsset {
746        SapAsset {
747            mandt: client.to_string(),
748            bukrs: self.company_code.clone(),
749            anln1: self.asset_id.clone(),
750            anln2: format!("{:04}", self.sub_number),
751            anlkl: asset_class_to_anlkl(&self.asset_class),
752            txt50: self.description.clone(),
753            aktiv: self.capitalized_date,
754            zugdt: self.acquisition_date,
755            deakt: self.disposal_date,
756            kostl: self.cost_center.clone(),
757            sernr: self.serial_number.clone(),
758            herst: self.manufacturer.clone(),
759            erdat: self.acquisition_date,
760            ernam: "SYSTEM".to_string(),
761        }
762    }
763}
764
765/// Write ANLA (fixed-asset master).
766pub fn write_anla(cfg: &SapExportConfig, assets: &[FixedAsset], path: &Path) -> SynthResult<()> {
767    let mut writer = open_master_file(cfg, path)?;
768    let delim = cfg.delimiter();
769    write_header(
770        &mut writer,
771        delim,
772        &[
773            "MANDT", "BUKRS", "ANLN1", "ANLN2", "ANLKL", "TXT50", "AKTIV", "ZUGDT", "DEAKT",
774            "KOSTL", "SERNR", "HERST", "ERDAT", "ERNAM",
775        ],
776    )?;
777    for a in assets {
778        let s = a.to_sap_asset(&cfg.client);
779        let fields: Vec<String> = vec![
780            s.mandt,
781            s.bukrs,
782            s.anln1,
783            s.anln2,
784            s.anlkl,
785            escape(&s.txt50),
786            s.aktiv.map(|d| cfg.format_date(d)).unwrap_or_default(),
787            cfg.format_date(s.zugdt),
788            s.deakt.map(|d| cfg.format_date(d)).unwrap_or_default(),
789            s.kostl.unwrap_or_default(),
790            s.sernr.unwrap_or_default(),
791            escape(&s.herst.unwrap_or_default()),
792            cfg.format_date(s.erdat),
793            s.ernam,
794        ];
795        write_row(&mut writer, delim, &fields)?;
796    }
797    writer.flush()?;
798    Ok(())
799}
800
801// ===========================================================================
802// CSKS — Cost center master
803// ===========================================================================
804
805/// SAP CSKS — cost-centre master. One row per (controlling area, cost
806/// centre, validity period). DataSynth emits one row per cost centre
807/// using the company code as the controlling area (1:1 mapping in
808/// small demos; enterprises typically split).
809#[derive(Debug, Clone)]
810pub struct SapCostCenter {
811    pub mandt: String,
812    /// Controlling area (KOKRS) — defaults to the company code.
813    pub kokrs: String,
814    /// Cost-centre ID (KOSTL).
815    pub kostl: String,
816    /// Valid-from (DATBI … DATAB in SAP; the validity-to is stored in DATBI
817    /// which we leave as 9999-12-31 for "open ended").
818    pub datbi: chrono::NaiveDate,
819    pub datab: chrono::NaiveDate,
820    /// Name / description (KTEXT — via CSKT table in real SAP, flattened
821    /// here for analytics convenience).
822    pub ktext: String,
823    /// Category — `1` = overhead, `F` = production, etc. (KOSAR).
824    pub kosar: String,
825    /// Responsible person (VERAK_USER).
826    pub verak_user: Option<String>,
827    /// Blocked-for-actuals flag (BKZKP).
828    pub bkzkp: bool,
829}
830
831/// Extension trait for mapping `CostCenter` → SAP CSKS.
832pub trait SapCostCenterExportable {
833    fn to_sap_cost_center(&self, client: &str) -> SapCostCenter;
834}
835
836impl SapCostCenterExportable for CostCenter {
837    fn to_sap_cost_center(&self, client: &str) -> SapCostCenter {
838        SapCostCenter {
839            mandt: client.to_string(),
840            kokrs: self.company_code.clone(),
841            kostl: self.id.clone(),
842            datbi: chrono::NaiveDate::from_ymd_opt(9999, 12, 31)
843                .expect("9999-12-31 is a valid date"),
844            datab: chrono::NaiveDate::from_ymd_opt(2000, 1, 1).expect("2000-01-01 is a valid date"),
845            ktext: self.name.clone(),
846            kosar: cost_center_category_to_kosar(&self.category),
847            verak_user: self.responsible_person.clone(),
848            bkzkp: !self.is_active,
849        }
850    }
851}
852
853/// Write CSKS (cost-centre master).
854pub fn write_csks(
855    cfg: &SapExportConfig,
856    cost_centers: &[CostCenter],
857    path: &Path,
858) -> SynthResult<()> {
859    let mut writer = open_master_file(cfg, path)?;
860    let delim = cfg.delimiter();
861    write_header(
862        &mut writer,
863        delim,
864        &[
865            "MANDT",
866            "KOKRS",
867            "KOSTL",
868            "DATBI",
869            "DATAB",
870            "KTEXT",
871            "KOSAR",
872            "VERAK_USER",
873            "BKZKP",
874        ],
875    )?;
876    for cc in cost_centers {
877        let s = cc.to_sap_cost_center(&cfg.client);
878        let fields: Vec<String> = vec![
879            s.mandt,
880            s.kokrs,
881            s.kostl,
882            cfg.format_date(s.datbi),
883            cfg.format_date(s.datab),
884            escape(&s.ktext),
885            s.kosar,
886            s.verak_user.unwrap_or_default(),
887            if s.bkzkp {
888                "X".to_string()
889            } else {
890                String::new()
891            },
892        ];
893        write_row(&mut writer, delim, &fields)?;
894    }
895    writer.flush()?;
896    Ok(())
897}
898
899// ===========================================================================
900// CEPC — Profit centre master (v5.1)
901// ===========================================================================
902
903/// SAP CEPC — profit-centre master.  One row per (controlling area,
904/// profit centre, validity period).  DataSynth emits one row per
905/// profit centre using the company code as the controlling area
906/// (1:1 mapping in small demos; enterprises typically split).
907///
908/// CEPC is the canonical SAP table for profit-centre master data in
909/// the CO-PCA (Profit Centre Accounting) module.  The `PRCTR` key
910/// joins to `BSEG.PRCTR` / `ACDOCA.PRCTR` on every line item that
911/// carries a profit-centre attribution.
912#[derive(Debug, Clone)]
913pub struct SapProfitCenter {
914    pub mandt: String,
915    /// Controlling area (KOKRS) — defaults to the company code.
916    pub kokrs: String,
917    /// Profit centre ID (PRCTR).
918    pub prctr: String,
919    /// Valid-to (DATBI) — `9999-12-31` for "open ended".
920    pub datbi: chrono::NaiveDate,
921    /// Valid-from (DATAB).
922    pub datab: chrono::NaiveDate,
923    /// Description / name (KTEXT — flattened from CEPCT in real SAP).
924    pub ktext: String,
925    /// Responsible person (VERAK_USER).
926    pub verak_user: Option<String>,
927    /// Lock indicator (LOKKZ): `X` when the centre is inactive.
928    pub lokkz: bool,
929    /// Department / segment code (ABTEI) — propagated from the
930    /// `ProfitCenter.segment_code` so consumers can group by IFRS 8
931    /// reportable segment without joining a sidecar.
932    pub abtei: Option<String>,
933    /// Hierarchy node (HIE_KIND) — encodes whether this is a top-level
934    /// node ("S" for summary) or a leaf ("D" for detail).
935    pub hie_kind: String,
936}
937
938/// Extension trait for mapping `ProfitCenter` → SAP CEPC.
939pub trait SapProfitCenterExportable {
940    fn to_sap_profit_center(&self, client: &str) -> SapProfitCenter;
941}
942
943impl SapProfitCenterExportable for ProfitCenter {
944    fn to_sap_profit_center(&self, client: &str) -> SapProfitCenter {
945        SapProfitCenter {
946            mandt: client.to_string(),
947            kokrs: self.company_code.clone(),
948            prctr: self.id.clone(),
949            datbi: chrono::NaiveDate::from_ymd_opt(9999, 12, 31)
950                .expect("9999-12-31 is a valid date"),
951            datab: chrono::NaiveDate::from_ymd_opt(2000, 1, 1).expect("2000-01-01 is a valid date"),
952            ktext: self.name.clone(),
953            verak_user: self.responsible_person.clone(),
954            lokkz: !self.is_active,
955            abtei: self.segment_code.clone(),
956            hie_kind: if self.level == 1 { "S" } else { "D" }.to_string(),
957        }
958    }
959}
960
961/// Write CEPC (profit-centre master).
962///
963/// v5.1: closes the v5.0.1 documentation-only mitigation of Gap 6.
964/// CEPC is now a first-class master-data table alongside CSKS — the
965/// CLI's `output.sap.tables` config no longer warns when `cepc` is
966/// requested.
967pub fn write_cepc(
968    cfg: &SapExportConfig,
969    profit_centers: &[ProfitCenter],
970    path: &Path,
971) -> SynthResult<()> {
972    let mut writer = open_master_file(cfg, path)?;
973    let delim = cfg.delimiter();
974    write_header(
975        &mut writer,
976        delim,
977        &[
978            "MANDT",
979            "KOKRS",
980            "PRCTR",
981            "DATBI",
982            "DATAB",
983            "KTEXT",
984            "VERAK_USER",
985            "LOKKZ",
986            "ABTEI",
987            "HIE_KIND",
988        ],
989    )?;
990    for pc in profit_centers {
991        let s = pc.to_sap_profit_center(&cfg.client);
992        let fields: Vec<String> = vec![
993            s.mandt,
994            s.kokrs,
995            s.prctr,
996            cfg.format_date(s.datbi),
997            cfg.format_date(s.datab),
998            escape(&s.ktext),
999            s.verak_user.unwrap_or_default(),
1000            if s.lokkz {
1001                "X".to_string()
1002            } else {
1003                String::new()
1004            },
1005            s.abtei.unwrap_or_default(),
1006            s.hie_kind,
1007        ];
1008        write_row(&mut writer, delim, &fields)?;
1009    }
1010    writer.flush()?;
1011    Ok(())
1012}
1013
1014// ===========================================================================
1015// SKA1 / SKB1 — GL account masters
1016// ===========================================================================
1017
1018/// SAP SKA1 — chart-of-accounts-level GL account (client-wide).
1019#[derive(Debug, Clone)]
1020pub struct SapGlAccountGeneral {
1021    pub mandt: String,
1022    /// Chart of accounts code (KTOPL).
1023    pub ktopl: String,
1024    /// GL account number (SAKNR).
1025    pub saknr: String,
1026    /// Account group (KTOKS).
1027    pub ktoks: String,
1028    /// Balance sheet (1) or P&L (2) (XBILK).
1029    pub xbilk: u8,
1030    /// P&L statement account type (GVTYP) — blank for balance-sheet accounts.
1031    pub gvtyp: Option<String>,
1032    /// Creation date (ERDAT).
1033    pub erdat: chrono::NaiveDate,
1034    /// Created by (ERNAM).
1035    pub ernam: String,
1036}
1037
1038/// SAP SKB1 — company-code-level GL account data.
1039#[derive(Debug, Clone)]
1040pub struct SapGlAccountCompanyCode {
1041    pub mandt: String,
1042    pub bukrs: String,
1043    pub saknr: String,
1044    /// Account currency (WAERS).
1045    pub waers: String,
1046    /// Open-item management flag (XOPVW).
1047    pub xopvw: bool,
1048    /// Line-item display flag (XKRES).
1049    pub xkres: bool,
1050    /// Posting blocked flag (XSPEB).
1051    pub xspeb: bool,
1052    /// Tax category (MWSKZ).
1053    pub mwskz: Option<String>,
1054    /// Reconciliation account type (MITKZ) — "D" (debtor), "K" (vendor),
1055    /// "A" (asset), blank for regular GL accounts.
1056    pub mitkz: Option<String>,
1057}
1058
1059/// Extension trait for mapping `GLAccount` → SAP SKA1.
1060pub trait SapGlAccountExportable {
1061    fn to_sap_gl_general(&self, client: &str, ktopl: &str) -> SapGlAccountGeneral;
1062    fn to_sap_gl_company_code(
1063        &self,
1064        client: &str,
1065        company_code: &str,
1066        currency: &str,
1067    ) -> SapGlAccountCompanyCode;
1068}
1069
1070impl SapGlAccountExportable for GLAccount {
1071    fn to_sap_gl_general(&self, client: &str, ktopl: &str) -> SapGlAccountGeneral {
1072        use datasynth_core::models::AccountType;
1073        let xbilk = matches!(
1074            self.account_type,
1075            AccountType::Asset | AccountType::Liability | AccountType::Equity
1076        );
1077        SapGlAccountGeneral {
1078            mandt: client.to_string(),
1079            ktopl: ktopl.to_string(),
1080            saknr: self.account_number.clone(),
1081            ktoks: self.account_group.clone(),
1082            xbilk: if xbilk { 1 } else { 2 },
1083            gvtyp: if !xbilk { Some("H".to_string()) } else { None },
1084            erdat: chrono::Utc::now().date_naive(),
1085            ernam: "SYSTEM".to_string(),
1086        }
1087    }
1088
1089    fn to_sap_gl_company_code(
1090        &self,
1091        client: &str,
1092        company_code: &str,
1093        currency: &str,
1094    ) -> SapGlAccountCompanyCode {
1095        use datasynth_core::models::AccountType;
1096        let mitkz = if self.is_control_account {
1097            match self.account_type {
1098                AccountType::Asset => Some("D".to_string()), // AR control
1099                AccountType::Liability => Some("K".to_string()), // AP control
1100                _ => None,
1101            }
1102        } else {
1103            None
1104        };
1105        SapGlAccountCompanyCode {
1106            mandt: client.to_string(),
1107            bukrs: company_code.to_string(),
1108            saknr: self.account_number.clone(),
1109            waers: currency.to_string(),
1110            xopvw: self.is_control_account || self.is_suspense_account,
1111            xkres: self.is_postable,
1112            xspeb: self.is_blocked,
1113            mwskz: None,
1114            mitkz,
1115        }
1116    }
1117}
1118
1119/// Write SKA1 (chart-of-accounts-level GL master).
1120///
1121/// Takes a `ChartOfAccounts` rather than a `&[GLAccount]` so the
1122/// `KTOPL` column (chart-of-accounts code) is available — SAP separates
1123/// the chart of accounts (country/group-specific, like INT/SKR04/CAUS)
1124/// from the individual account rows.
1125pub fn write_ska1(cfg: &SapExportConfig, coa: &ChartOfAccounts, path: &Path) -> SynthResult<()> {
1126    let mut writer = open_master_file(cfg, path)?;
1127    let delim = cfg.delimiter();
1128    write_header(
1129        &mut writer,
1130        delim,
1131        &[
1132            "MANDT", "KTOPL", "SAKNR", "KTOKS", "XBILK", "GVTYP", "ERDAT", "ERNAM",
1133        ],
1134    )?;
1135    let ktopl = coa.coa_id.as_str();
1136    for acct in &coa.accounts {
1137        let s = acct.to_sap_gl_general(&cfg.client, ktopl);
1138        let fields: Vec<String> = vec![
1139            s.mandt,
1140            s.ktopl,
1141            s.saknr,
1142            s.ktoks,
1143            s.xbilk.to_string(),
1144            s.gvtyp.unwrap_or_default(),
1145            cfg.format_date(s.erdat),
1146            s.ernam,
1147        ];
1148        write_row(&mut writer, delim, &fields)?;
1149    }
1150    writer.flush()?;
1151    Ok(())
1152}
1153
1154/// Write SKB1 (company-code-level GL master) — one row per (account, company).
1155pub fn write_skb1(
1156    cfg: &SapExportConfig,
1157    coa: &ChartOfAccounts,
1158    company_codes: &[String],
1159    path: &Path,
1160) -> SynthResult<()> {
1161    let mut writer = open_master_file(cfg, path)?;
1162    let delim = cfg.delimiter();
1163    write_header(
1164        &mut writer,
1165        delim,
1166        &[
1167            "MANDT", "BUKRS", "SAKNR", "WAERS", "XOPVW", "XKRES", "XSPEB", "MWSKZ", "MITKZ",
1168        ],
1169    )?;
1170    for acct in &coa.accounts {
1171        for company in company_codes {
1172            let s = acct.to_sap_gl_company_code(&cfg.client, company, &cfg.local_currency);
1173            let fields: Vec<String> = vec![
1174                s.mandt,
1175                s.bukrs,
1176                s.saknr,
1177                s.waers,
1178                if s.xopvw {
1179                    "X".to_string()
1180                } else {
1181                    String::new()
1182                },
1183                if s.xkres {
1184                    "X".to_string()
1185                } else {
1186                    String::new()
1187                },
1188                if s.xspeb {
1189                    "X".to_string()
1190                } else {
1191                    String::new()
1192                },
1193                s.mwskz.unwrap_or_default(),
1194                s.mitkz.unwrap_or_default(),
1195            ];
1196            write_row(&mut writer, delim, &fields)?;
1197        }
1198    }
1199    writer.flush()?;
1200    Ok(())
1201}
1202
1203// ===========================================================================
1204// Helper mappings for v4.3.0c
1205// ===========================================================================
1206
1207/// Map `FixedAsset.asset_class` → SAP ANLKL asset-class code (4-digit).
1208fn asset_class_to_anlkl(class: &datasynth_core::models::AssetClass) -> String {
1209    use datasynth_core::models::AssetClass;
1210    match class {
1211        AssetClass::Buildings | AssetClass::BuildingImprovements => "1000",
1212        AssetClass::Land => "1100",
1213        AssetClass::MachineryEquipment | AssetClass::Machinery => "2000",
1214        AssetClass::ComputerHardware | AssetClass::ItEquipment => "5000",
1215        AssetClass::FurnitureFixtures | AssetClass::Furniture => "4000",
1216        AssetClass::Vehicles => "3000",
1217        AssetClass::LeaseholdImprovements => "4500",
1218        AssetClass::Intangibles | AssetClass::Software => "7000",
1219        AssetClass::ConstructionInProgress => "8000",
1220        AssetClass::LowValueAssets => "9000",
1221    }
1222    .to_string()
1223}
1224
1225/// Map `CostCenter.category` → SAP KOSAR cost-centre category code.
1226fn cost_center_category_to_kosar(category: &datasynth_core::models::CostCenterCategory) -> String {
1227    use datasynth_core::models::CostCenterCategory;
1228    match category {
1229        CostCenterCategory::Production => "F",
1230        CostCenterCategory::Administration => "H",
1231        CostCenterCategory::Sales => "V",
1232        CostCenterCategory::RAndD => "E",
1233        CostCenterCategory::Corporate => "1",
1234    }
1235    .to_string()
1236}
1237
1238#[cfg(test)]
1239mod tests {
1240    use super::super::sap::SapDialect;
1241    use super::*;
1242    use datasynth_core::models::{CustomerType, MaterialType, VendorType};
1243    use tempfile::TempDir;
1244
1245    fn sample_vendor() -> Vendor {
1246        let mut v = Vendor::new("V-0001", "Müller GmbH", VendorType::Supplier);
1247        v.country = "DE".to_string();
1248        v.tax_id = Some("DE123456789".to_string());
1249        v.reconciliation_account = Some("2000".to_string());
1250        v.withholding_tax_applicable = true;
1251        v
1252    }
1253
1254    fn sample_customer() -> Customer {
1255        let mut c = Customer::new("C-0001", "Retail Corp", CustomerType::Corporate);
1256        c.country = "US".to_string();
1257        c.reconciliation_account = Some("1100".to_string());
1258        c.dunning_level = 2;
1259        c.credit_blocked = true;
1260        c.credit_block_reason = Some("A".to_string());
1261        c
1262    }
1263
1264    fn sample_material() -> Material {
1265        let mut m = Material::new("MAT-0001", "Steel coil 1.5mm", MaterialType::RawMaterial);
1266        m.weight_kg = Some(rust_decimal::Decimal::new(15, 0));
1267        m.plants = vec!["PLNT01".to_string(), "PLNT02".to_string()];
1268        m
1269    }
1270
1271    #[test]
1272    fn lfa1_row_round_trip_maps_core_fields() {
1273        let v = sample_vendor();
1274        let row = v.to_sap_vendor("100");
1275        assert_eq!(row.mandt, "100");
1276        assert_eq!(row.lifnr, "V-0001");
1277        assert_eq!(row.land1, "DE");
1278        assert_eq!(row.name1, "Müller GmbH");
1279        assert_eq!(row.spras, "D", "DE country must map to SPRAS=D (German)");
1280        assert_eq!(row.stcd1.as_deref(), Some("DE123456789"));
1281        assert_eq!(row.ktokk, "LIEF");
1282    }
1283
1284    #[test]
1285    fn kna1_row_round_trip_maps_core_fields() {
1286        let c = sample_customer();
1287        let row = c.to_sap_customer("100");
1288        assert_eq!(row.kunnr, "C-0001");
1289        assert_eq!(row.land1, "US");
1290        assert_eq!(row.spras, "E");
1291        assert_eq!(row.ktokd, "KUNA");
1292    }
1293
1294    #[test]
1295    fn lfb1_row_carries_reconciliation_account_and_withholding() {
1296        let v = sample_vendor();
1297        let row = v.to_sap_vendor_company_code("100", "C001");
1298        assert_eq!(row.bukrs, "C001");
1299        assert_eq!(row.akont, "2000");
1300        assert_eq!(
1301            row.qsskz.as_deref(),
1302            Some("W1"),
1303            "withholding-applicable vendor must emit a QSSKZ code"
1304        );
1305    }
1306
1307    #[test]
1308    fn knb1_row_carries_dunning_and_credit_block() {
1309        let c = sample_customer();
1310        let row = c.to_sap_customer_company_code("100", "C001");
1311        assert_eq!(row.bukrs, "C001");
1312        assert_eq!(row.akont, "1100");
1313        assert_eq!(row.mahns, 2);
1314        assert_eq!(row.crdblk, 1, "credit-blocked customer must emit CRDBLK=1");
1315        assert_eq!(row.zahls.as_deref(), Some("A"));
1316    }
1317
1318    #[test]
1319    fn mara_row_maps_material_type_to_mtart() {
1320        let m = sample_material();
1321        let row = m.to_sap_material("100");
1322        assert_eq!(row.matnr, "MAT-0001");
1323        assert_eq!(row.mtart, "ROH", "raw-material type must map to MTART=ROH");
1324    }
1325
1326    #[test]
1327    fn mard_row_emitted_per_plant() {
1328        let m = sample_material();
1329        let stock: Vec<(String, rust_decimal::Decimal)> = vec![
1330            ("PLNT01".to_string(), rust_decimal::Decimal::new(100, 0)),
1331            ("PLNT02".to_string(), rust_decimal::Decimal::new(50, 0)),
1332        ];
1333        let rows = m.to_sap_material_storage_rows("100", "0001", &stock);
1334        assert_eq!(rows.len(), 2);
1335        assert_eq!(rows[0].werks, "PLNT01");
1336        assert_eq!(rows[0].labst, rust_decimal::Decimal::new(100, 0));
1337        assert_eq!(rows[1].werks, "PLNT02");
1338    }
1339
1340    #[test]
1341    fn cepc_emits_one_row_per_profit_center_with_segment_propagated() {
1342        use datasynth_core::models::{ProfitCenter, ProfitCenterCategory};
1343        let tmp = TempDir::new().unwrap();
1344        let cfg = SapExportConfig::default();
1345        let pcs = vec![
1346            ProfitCenter::top_level("PC-EMEA", "EMEA", "C001", ProfitCenterCategory::Region)
1347                .with_segment("SEG-EMEA"),
1348            ProfitCenter::sub_unit(
1349                "PC-EMEA-DACH",
1350                "DACH",
1351                "PC-EMEA",
1352                "C001",
1353                ProfitCenterCategory::Region,
1354            )
1355            .with_segment("SEG-EMEA"),
1356        ];
1357        let path = tmp.path().join("cepc.csv");
1358        write_cepc(&cfg, &pcs, &path).unwrap();
1359
1360        let text = std::fs::read_to_string(&path).unwrap();
1361        let lines: Vec<&str> = text.lines().collect();
1362        assert_eq!(lines.len(), 3, "header + 2 profit-centre rows");
1363        assert!(lines[0].starts_with("MANDT"));
1364        assert!(lines[0].contains("PRCTR"));
1365        assert!(lines[0].contains("ABTEI"));
1366        assert!(lines[0].contains("HIE_KIND"));
1367
1368        // First data row is the level-1 segment node (HIE_KIND=S).
1369        assert!(lines[1].contains("PC-EMEA"));
1370        assert!(lines[1].contains("SEG-EMEA"));
1371        assert!(
1372            lines[1].ends_with(",S") || lines[1].contains(",S,") || lines[1].contains("\tS"),
1373            "level-1 should map to HIE_KIND=S, got: {}",
1374            lines[1]
1375        );
1376
1377        // Second data row is the level-2 sub-unit (HIE_KIND=D), shares segment.
1378        assert!(lines[2].contains("PC-EMEA-DACH"));
1379        assert!(lines[2].contains("SEG-EMEA"));
1380    }
1381
1382    #[test]
1383    fn master_files_written_with_hana_dialect_use_semicolon_and_bom() {
1384        let tmp = TempDir::new().unwrap();
1385        let cfg = SapExportConfig {
1386            dialect: SapDialect::Hana,
1387            ..SapExportConfig::default()
1388        };
1389        let vendors = vec![sample_vendor()];
1390        let lfa1_path = tmp.path().join("lfa1.csv");
1391        write_lfa1(&cfg, &vendors, &lfa1_path).unwrap();
1392
1393        let bytes = std::fs::read(&lfa1_path).unwrap();
1394        assert_eq!(
1395            &bytes[..3],
1396            [0xEF, 0xBB, 0xBF],
1397            "Hana dialect must prefix master-data files with a UTF-8 BOM"
1398        );
1399        let text = std::str::from_utf8(&bytes[3..]).unwrap();
1400        assert!(text.lines().next().unwrap().contains(';'));
1401    }
1402}