1use 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
38impl 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
61impl 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#[derive(Debug, Clone)]
92pub struct SapVendorCompanyCode {
93 pub mandt: String,
94 pub lifnr: String,
95 pub bukrs: String,
96 pub akont: String,
98 pub zterm: String,
100 pub mahna: Option<String>,
102 pub qsskz: Option<String>,
104 pub zahls: Option<String>,
106 pub sortl: Option<String>,
108 pub erdat: chrono::NaiveDate,
110}
111
112pub 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#[derive(Debug, Clone)]
151pub struct SapCustomerCompanyCode {
152 pub mandt: String,
153 pub kunnr: String,
154 pub bukrs: String,
155 pub akont: String,
157 pub zterm: String,
159 pub mahna: Option<String>,
161 pub mahns: u8,
163 pub madat: Option<chrono::NaiveDate>,
165 pub zahls: Option<String>,
167 pub crdblk: u8,
169 pub sortl: Option<String>,
171 pub erdat: chrono::NaiveDate,
173}
174
175pub 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#[derive(Debug, Clone)]
218pub struct SapMaterial {
219 pub mandt: String,
220 pub matnr: String,
222 pub mtart: String,
225 pub mbrsh: String,
228 pub matkl: String,
230 pub meins: String,
232 pub brgew: Option<rust_decimal::Decimal>,
234 pub volum: Option<rust_decimal::Decimal>,
236 pub gewei: String,
238 pub voleh: String,
240 pub bismt: Option<String>,
242 pub ersda: chrono::NaiveDate,
244 pub ernam: String,
246}
247
248pub 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 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#[derive(Debug, Clone)]
283pub struct SapMaterialStorage {
284 pub mandt: String,
285 pub matnr: String,
286 pub werks: String,
288 pub lgort: String,
290 pub labst: rust_decimal::Decimal,
292 pub insme: rust_decimal::Decimal,
294 pub speme: rust_decimal::Decimal,
296 pub eislo: rust_decimal::Decimal,
298 pub minbe: rust_decimal::Decimal,
300}
301
302pub 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
337fn 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
380pub 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
414pub 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
454pub 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
488pub 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
529pub 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
570pub 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 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
609fn 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
633fn 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
649fn 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
666fn 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
681fn 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#[derive(Debug, Clone)]
710pub struct SapAsset {
711 pub mandt: String,
712 pub bukrs: String,
713 pub anln1: String,
715 pub anln2: String,
717 pub anlkl: String,
719 pub txt50: String,
721 pub aktiv: Option<chrono::NaiveDate>,
723 pub zugdt: chrono::NaiveDate,
725 pub deakt: Option<chrono::NaiveDate>,
727 pub kostl: Option<String>,
729 pub sernr: Option<String>,
731 pub herst: Option<String>,
733 pub erdat: chrono::NaiveDate,
735 pub ernam: String,
737}
738
739pub 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
765pub 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#[derive(Debug, Clone)]
810pub struct SapCostCenter {
811 pub mandt: String,
812 pub kokrs: String,
814 pub kostl: String,
816 pub datbi: chrono::NaiveDate,
819 pub datab: chrono::NaiveDate,
820 pub ktext: String,
823 pub kosar: String,
825 pub verak_user: Option<String>,
827 pub bkzkp: bool,
829}
830
831pub 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
853pub 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#[derive(Debug, Clone)]
913pub struct SapProfitCenter {
914 pub mandt: String,
915 pub kokrs: String,
917 pub prctr: String,
919 pub datbi: chrono::NaiveDate,
921 pub datab: chrono::NaiveDate,
923 pub ktext: String,
925 pub verak_user: Option<String>,
927 pub lokkz: bool,
929 pub abtei: Option<String>,
933 pub hie_kind: String,
936}
937
938pub 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
961pub 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#[derive(Debug, Clone)]
1020pub struct SapGlAccountGeneral {
1021 pub mandt: String,
1022 pub ktopl: String,
1024 pub saknr: String,
1026 pub ktoks: String,
1028 pub xbilk: u8,
1030 pub gvtyp: Option<String>,
1032 pub erdat: chrono::NaiveDate,
1034 pub ernam: String,
1036}
1037
1038#[derive(Debug, Clone)]
1040pub struct SapGlAccountCompanyCode {
1041 pub mandt: String,
1042 pub bukrs: String,
1043 pub saknr: String,
1044 pub waers: String,
1046 pub xopvw: bool,
1048 pub xkres: bool,
1050 pub xspeb: bool,
1052 pub mwskz: Option<String>,
1054 pub mitkz: Option<String>,
1057}
1058
1059pub 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()), AccountType::Liability => Some("K".to_string()), _ => 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
1119pub 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
1154pub 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
1203fn 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
1225fn 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 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 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}