use chrono::NaiveDate;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs::File;
use std::io::{BufWriter, Write};
use std::path::Path;
use datasynth_core::error::SynthResult;
use datasynth_core::models::{AcdocaFactory, JournalEntry};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SapTableType {
Bkpf,
Bseg,
Acdoca,
Lfa1,
Kna1,
Mara,
Csks,
Cepc,
}
impl SapTableType {
pub fn table_name(&self) -> &'static str {
match self {
SapTableType::Bkpf => "BKPF",
SapTableType::Bseg => "BSEG",
SapTableType::Acdoca => "ACDOCA",
SapTableType::Lfa1 => "LFA1",
SapTableType::Kna1 => "KNA1",
SapTableType::Mara => "MARA",
SapTableType::Csks => "CSKS",
SapTableType::Cepc => "CEPC",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BkpfEntry {
pub mandt: String,
pub bukrs: String,
pub belnr: String,
pub gjahr: u16,
pub blart: String,
pub bldat: NaiveDate,
pub budat: NaiveDate,
pub monat: u8,
pub cpudt: NaiveDate,
pub cputm: String,
pub usnam: String,
pub tcode: String,
pub xblnr: Option<String>,
pub bktxt: Option<String>,
pub waers: String,
pub kursf: Decimal,
pub bvorg: Option<String>,
pub stblg: Option<String>,
pub stgrd: Option<String>,
}
impl Default for BkpfEntry {
fn default() -> Self {
Self {
mandt: "100".to_string(),
bukrs: String::new(),
belnr: String::new(),
gjahr: 0,
blart: "SA".to_string(),
bldat: NaiveDate::from_ymd_opt(2000, 1, 1).expect("valid default date"),
budat: NaiveDate::from_ymd_opt(2000, 1, 1).expect("valid default date"),
monat: 1,
cpudt: NaiveDate::from_ymd_opt(2000, 1, 1).expect("valid default date"),
cputm: "000000".to_string(),
usnam: String::new(),
tcode: "FB01".to_string(),
xblnr: None,
bktxt: None,
waers: "USD".to_string(),
kursf: Decimal::ONE,
bvorg: None,
stblg: None,
stgrd: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SapDialect {
#[default]
Classic,
Hana,
}
impl SapDialect {
pub fn delimiter(&self) -> char {
match self {
Self::Classic => ',',
Self::Hana => ';',
}
}
pub fn decimal_separator(&self) -> char {
match self {
Self::Classic => '.',
Self::Hana => ',',
}
}
pub fn bom(&self) -> &'static [u8] {
match self {
Self::Classic => &[],
Self::Hana => &[0xEF, 0xBB, 0xBF],
}
}
pub fn format_date(&self, date: NaiveDate) -> String {
match self {
Self::Classic => date.format("%Y%m%d").to_string(),
Self::Hana => date.format("%Y-%m-%d").to_string(),
}
}
pub fn format_decimal(&self, value: &Decimal) -> String {
match self {
Self::Classic => value.to_string(),
Self::Hana => value.to_string().replace('.', ","),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SapExportConfig {
pub client: String,
pub ledger: String,
pub source_system: String,
pub local_currency: String,
pub group_currency: Option<String>,
pub tables: Vec<SapTableType>,
pub include_extension_fields: bool,
pub dialect: SapDialect,
pub use_sap_date_format: bool,
}
impl Default for SapExportConfig {
fn default() -> Self {
Self {
client: "100".to_string(),
ledger: "0L".to_string(),
source_system: "SYNTH".to_string(),
local_currency: "USD".to_string(),
group_currency: None,
tables: vec![SapTableType::Bkpf, SapTableType::Bseg, SapTableType::Acdoca],
include_extension_fields: true,
dialect: SapDialect::Classic,
use_sap_date_format: true,
}
}
}
impl SapExportConfig {
pub fn format_date(&self, date: NaiveDate) -> String {
match self.dialect {
SapDialect::Classic if self.use_sap_date_format => date.format("%Y%m%d").to_string(),
SapDialect::Classic => date.format("%Y-%m-%d").to_string(),
SapDialect::Hana => date.format("%Y-%m-%d").to_string(),
}
}
pub fn format_decimal(&self, value: &Decimal) -> String {
self.dialect.format_decimal(value)
}
pub fn delimiter(&self) -> char {
self.dialect.delimiter()
}
}
pub struct SapExporter {
config: SapExportConfig,
acdoca_factory: AcdocaFactory,
document_counter: HashMap<String, u64>, }
impl SapExporter {
pub fn new(config: SapExportConfig) -> Self {
let mut acdoca_factory = AcdocaFactory::new(&config.ledger, &config.source_system)
.with_local_currency(&config.local_currency)
.with_client(&config.client);
if let Some(ref group_currency) = config.group_currency {
acdoca_factory = acdoca_factory.with_group_currency(group_currency);
}
Self {
config,
acdoca_factory,
document_counter: HashMap::new(),
}
}
fn next_document_number(&mut self, company_code: &str) -> String {
let counter = self
.document_counter
.entry(company_code.to_string())
.or_insert(0);
*counter += 1;
format!("{:010}", *counter)
}
pub fn to_bkpf(&self, je: &JournalEntry, document_number: &str) -> BkpfEntry {
BkpfEntry {
mandt: self.config.client.clone(),
bukrs: je.header.company_code.clone(),
belnr: document_number.to_string(),
gjahr: je.header.fiscal_year,
blart: je.header.document_type.clone(),
bldat: je.header.document_date,
budat: je.header.posting_date,
monat: je.header.fiscal_period,
cpudt: je.header.created_at.date_naive(),
cputm: je.header.created_at.format("%H%M%S").to_string(),
usnam: je.header.created_by.clone(),
tcode: self.get_transaction_code(je),
xblnr: je.header.reference.clone(),
bktxt: je.header.header_text.clone(),
waers: je.header.currency.clone(),
kursf: je.header.exchange_rate,
bvorg: None,
stblg: None,
stgrd: None,
}
}
fn get_transaction_code(&self, je: &JournalEntry) -> String {
match je.header.document_type.as_str() {
"SA" => "FB01".to_string(), "RE" => "MIRO".to_string(), "RV" => "VF01".to_string(), "KZ" => "F110".to_string(), "DZ" => "F28".to_string(), "AB" => "ABZON".to_string(), "AA" => "ABSO1".to_string(), _ => "FB01".to_string(),
}
}
pub fn export_to_files(
&mut self,
entries: &[JournalEntry],
output_dir: &Path,
) -> SynthResult<HashMap<SapTableType, String>> {
let mut output_files = HashMap::new();
std::fs::create_dir_all(output_dir)?;
let tables = self.config.tables.clone();
for table_type in tables {
let filename = format!("{}.csv", table_type.table_name().to_lowercase());
let filepath = output_dir.join(&filename);
match table_type {
SapTableType::Bkpf => self.export_bkpf(entries, &filepath)?,
SapTableType::Bseg => self.export_bseg(entries, &filepath)?,
SapTableType::Acdoca => self.export_acdoca(entries, &filepath)?,
_ => {
continue;
}
}
output_files.insert(table_type, filepath.to_string_lossy().to_string());
}
Ok(output_files)
}
fn open_sap_file(&self, filepath: &Path) -> SynthResult<BufWriter<File>> {
let file = File::create(filepath)?;
let mut writer = BufWriter::with_capacity(256 * 1024, file);
let bom = self.config.dialect.bom();
if !bom.is_empty() {
writer.write_all(bom)?;
}
Ok(writer)
}
fn export_bkpf(&mut self, entries: &[JournalEntry], filepath: &Path) -> SynthResult<()> {
let mut writer = self.open_sap_file(filepath)?;
let delim = self.config.delimiter();
write_header(
&mut writer,
delim,
&[
"MANDT", "BUKRS", "BELNR", "GJAHR", "BLART", "BLDAT", "BUDAT", "MONAT", "CPUDT",
"CPUTM", "USNAM", "TCODE", "XBLNR", "BKTXT", "WAERS", "KURSF",
],
)?;
for je in entries {
let doc_num = self.next_document_number(&je.header.company_code);
let bkpf = self.to_bkpf(je, &doc_num);
let fields: Vec<String> = vec![
bkpf.mandt,
bkpf.bukrs,
bkpf.belnr,
bkpf.gjahr.to_string(),
bkpf.blart,
self.config.format_date(bkpf.bldat),
self.config.format_date(bkpf.budat),
bkpf.monat.to_string(),
self.config.format_date(bkpf.cpudt),
bkpf.cputm,
bkpf.usnam,
bkpf.tcode,
bkpf.xblnr.unwrap_or_default(),
escape_csv_field(bkpf.bktxt.as_deref().unwrap_or("")),
bkpf.waers,
self.config.format_decimal(&bkpf.kursf),
];
write_row(&mut writer, delim, &fields)?;
}
writer.flush()?;
Ok(())
}
fn export_bseg(&mut self, entries: &[JournalEntry], filepath: &Path) -> SynthResult<()> {
let mut writer = self.open_sap_file(filepath)?;
let delim = self.config.delimiter();
write_header(
&mut writer,
delim,
&[
"MANDT", "BUKRS", "BELNR", "GJAHR", "BUZEI", "BSCHL", "HKONT", "WRBTR", "SHKZG",
"DMBTR", "WAERS", "KOSTL", "PRCTR", "SGTXT", "ZUONR", "MWSKZ",
],
)?;
self.document_counter.clear();
for je in entries {
let doc_num = self.next_document_number(&je.header.company_code);
let bseg_entries = self.acdoca_factory.to_bseg_entries(je, &doc_num);
for bseg in bseg_entries {
let fields: Vec<String> = vec![
bseg.mandt,
bseg.bukrs,
bseg.belnr,
bseg.gjahr.to_string(),
bseg.buzei.to_string(),
bseg.bschl,
bseg.hkont,
self.config.format_decimal(&bseg.wrbtr),
bseg.shkzg,
self.config.format_decimal(&bseg.dmbtr),
bseg.waers,
bseg.kostl.unwrap_or_default(),
bseg.prctr.unwrap_or_default(),
escape_csv_field(bseg.sgtxt.as_deref().unwrap_or("")),
bseg.zuonr.unwrap_or_default(),
bseg.mwskz.unwrap_or_default(),
];
write_row(&mut writer, delim, &fields)?;
}
}
writer.flush()?;
Ok(())
}
fn export_acdoca(&mut self, entries: &[JournalEntry], filepath: &Path) -> SynthResult<()> {
let mut writer = self.open_sap_file(filepath)?;
let delim = self.config.delimiter();
let mut header_cols: Vec<&'static str> = vec![
"RLDNR", "RBUKRS", "GJAHR", "BELNR", "DOCLN", "BLART", "BUDAT", "BLDAT", "CPUDT",
"CPUTM", "USNAM", "POPER", "RACCT", "RCNTR", "PRCTR", "WSL", "RWCUR", "HSL", "RHCUR",
"DRCRK", "BSCHL", "SGTXT", "ZUONR", "AWSYS", "AWTYP", "AWKEY",
];
if self.config.include_extension_fields {
header_cols.extend_from_slice(&[
"ZSIM_BATCH_ID",
"ZSIM_IS_FRAUD",
"ZSIM_FRAUD_TYPE",
"ZSIM_BUSINESS_PROCESS",
"ZSIM_CONTROL_IDS",
"ZSIM_SOX_RELEVANT",
"ZSIM_SOD_VIOLATION",
]);
}
write_header(&mut writer, delim, &header_cols)?;
self.document_counter.clear();
for je in entries {
let doc_num = self.next_document_number(&je.header.company_code);
let acdoca_entries = self.acdoca_factory.from_journal_entry(je, &doc_num);
for entry in acdoca_entries {
let mut fields: Vec<String> = vec![
entry.rldnr,
entry.rbukrs,
entry.gjahr.to_string(),
entry.belnr,
entry.docln.to_string(),
entry.blart,
self.config.format_date(entry.budat),
self.config.format_date(entry.bldat),
self.config.format_date(entry.cpudt),
entry.cputm,
entry.usnam,
entry.poper.to_string(),
entry.racct,
entry.rcntr.unwrap_or_default(),
entry.prctr.unwrap_or_default(),
self.config.format_decimal(&entry.wsl),
entry.rwcur,
self.config.format_decimal(&entry.hsl),
entry.rhcur,
entry.drcrk,
entry.bschl,
escape_csv_field(entry.sgtxt.as_deref().unwrap_or("")),
entry.zuonr.unwrap_or_default(),
entry.awsys,
entry.awtyp,
entry.awkey,
];
if self.config.include_extension_fields {
fields.push(
entry
.sim_batch_id
.map(|u| u.to_string())
.unwrap_or_default(),
);
fields.push(entry.sim_is_fraud.to_string());
fields.push(entry.sim_fraud_type.unwrap_or_default());
fields.push(entry.sim_business_process.unwrap_or_default());
fields.push(entry.sim_control_ids.unwrap_or_default());
fields.push(entry.sim_sox_relevant.to_string());
fields.push(entry.sim_sod_violation.to_string());
}
write_row(&mut writer, delim, &fields)?;
}
}
writer.flush()?;
Ok(())
}
pub fn export_vendor_master<V: SapVendorExportable>(
&self,
vendors: &[V],
filepath: &Path,
) -> SynthResult<()> {
let mut writer = self.open_sap_file(filepath)?;
let delim = self.config.delimiter();
write_header(
&mut writer,
delim,
&[
"MANDT", "LIFNR", "LAND1", "NAME1", "NAME2", "ORT01", "PSTLZ", "STRAS", "REGIO",
"SPRAS", "STCD1", "KTOKK",
],
)?;
for vendor in vendors {
let v = vendor.to_sap_vendor(&self.config.client);
let fields: Vec<String> = vec![
v.mandt,
v.lifnr,
v.land1,
escape_csv_field(&v.name1),
escape_csv_field(&v.name2.unwrap_or_default()),
escape_csv_field(&v.ort01.unwrap_or_default()),
v.pstlz.unwrap_or_default(),
escape_csv_field(&v.stras.unwrap_or_default()),
v.regio.unwrap_or_default(),
v.spras,
v.stcd1.unwrap_or_default(),
v.ktokk,
];
write_row(&mut writer, delim, &fields)?;
}
writer.flush()?;
Ok(())
}
pub fn export_customer_master<C: SapCustomerExportable>(
&self,
customers: &[C],
filepath: &Path,
) -> SynthResult<()> {
let mut writer = self.open_sap_file(filepath)?;
let delim = self.config.delimiter();
write_header(
&mut writer,
delim,
&[
"MANDT", "KUNNR", "LAND1", "NAME1", "NAME2", "ORT01", "PSTLZ", "STRAS", "REGIO",
"SPRAS", "STCD1", "KTOKD",
],
)?;
for customer in customers {
let c = customer.to_sap_customer(&self.config.client);
let fields: Vec<String> = vec![
c.mandt,
c.kunnr,
c.land1,
escape_csv_field(&c.name1),
escape_csv_field(&c.name2.unwrap_or_default()),
escape_csv_field(&c.ort01.unwrap_or_default()),
c.pstlz.unwrap_or_default(),
escape_csv_field(&c.stras.unwrap_or_default()),
c.regio.unwrap_or_default(),
c.spras,
c.stcd1.unwrap_or_default(),
c.ktokd,
];
write_row(&mut writer, delim, &fields)?;
}
writer.flush()?;
Ok(())
}
}
fn write_row_plain<W: Write>(writer: &mut W, delim: char, fields: &[&str]) -> std::io::Result<()> {
for (i, f) in fields.iter().enumerate() {
if i > 0 {
write!(writer, "{delim}")?;
}
write!(writer, "{f}")?;
}
writeln!(writer)
}
fn write_header<W: Write>(writer: &mut W, delim: char, cols: &[&str]) -> std::io::Result<()> {
write_row_plain(writer, delim, cols)
}
fn write_row<W: Write>(writer: &mut W, delim: char, fields: &[String]) -> std::io::Result<()> {
for (i, f) in fields.iter().enumerate() {
if i > 0 {
write!(writer, "{delim}")?;
}
write!(writer, "{f}")?;
}
writeln!(writer)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SapVendor {
pub mandt: String,
pub lifnr: String,
pub land1: String,
pub name1: String,
pub name2: Option<String>,
pub ort01: Option<String>,
pub pstlz: Option<String>,
pub stras: Option<String>,
pub regio: Option<String>,
pub spras: String,
pub stcd1: Option<String>,
pub ktokk: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SapCustomer {
pub mandt: String,
pub kunnr: String,
pub land1: String,
pub name1: String,
pub name2: Option<String>,
pub ort01: Option<String>,
pub pstlz: Option<String>,
pub stras: Option<String>,
pub regio: Option<String>,
pub spras: String,
pub stcd1: Option<String>,
pub ktokd: String,
}
pub trait SapVendorExportable {
fn to_sap_vendor(&self, client: &str) -> SapVendor;
}
pub trait SapCustomerExportable {
fn to_sap_customer(&self, client: &str) -> SapCustomer;
}
fn escape_csv_field(field: &str) -> String {
if field.contains(',') || field.contains(';') || field.contains('"') || field.contains('\n') {
format!("\"{}\"", field.replace('"', "\"\""))
} else {
field.to_string()
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::test_helpers::create_test_je;
use tempfile::TempDir;
#[test]
fn test_sap_exporter_creates_files() {
let temp_dir = TempDir::new().unwrap();
let config = SapExportConfig::default();
let mut exporter = SapExporter::new(config);
let entries = vec![create_test_je()];
let result = exporter.export_to_files(&entries, temp_dir.path());
assert!(result.is_ok());
let files = result.unwrap();
assert!(files.contains_key(&SapTableType::Bkpf));
assert!(files.contains_key(&SapTableType::Bseg));
assert!(files.contains_key(&SapTableType::Acdoca));
assert!(temp_dir.path().join("bkpf.csv").exists());
assert!(temp_dir.path().join("bseg.csv").exists());
assert!(temp_dir.path().join("acdoca.csv").exists());
}
#[test]
fn test_bkpf_conversion() {
let config = SapExportConfig::default();
let exporter = SapExporter::new(config);
let je = create_test_je();
let bkpf = exporter.to_bkpf(&je, "0000000001");
assert_eq!(bkpf.bukrs, "1000");
assert_eq!(bkpf.belnr, "0000000001");
assert_eq!(bkpf.gjahr, je.header.fiscal_year);
}
#[test]
fn test_document_number_generation() {
let config = SapExportConfig::default();
let mut exporter = SapExporter::new(config);
let num1 = exporter.next_document_number("1000");
let num2 = exporter.next_document_number("1000");
let num3 = exporter.next_document_number("2000");
assert_eq!(num1, "0000000001");
assert_eq!(num2, "0000000002");
assert_eq!(num3, "0000000001"); }
#[test]
fn classic_dialect_uses_comma_no_bom_yyyymmdd_dot_decimal() {
let temp_dir = TempDir::new().unwrap();
let config = SapExportConfig {
dialect: SapDialect::Classic,
use_sap_date_format: true,
..SapExportConfig::default()
};
let mut exporter = SapExporter::new(config);
exporter
.export_to_files(&[create_test_je()], temp_dir.path())
.unwrap();
let bkpf = std::fs::read(temp_dir.path().join("bkpf.csv")).unwrap();
assert_ne!(&bkpf[..3], [0xEF, 0xBB, 0xBF]);
let head = std::str::from_utf8(&bkpf).unwrap().lines().next().unwrap();
assert!(head.contains(','), "Classic header must use comma: {head}");
assert!(!head.contains(';'), "Classic header must not use semicolon");
let body = std::str::from_utf8(&bkpf).unwrap().lines().nth(1).unwrap();
let has_yyyymmdd = body
.split(',')
.any(|f| f.len() == 8 && f.chars().all(|c| c.is_ascii_digit()));
assert!(
has_yyyymmdd,
"Classic body must contain YYYYMMDD date: {body}"
);
}
#[test]
fn hana_dialect_uses_semicolon_utf8_bom_iso_date_comma_decimal() {
let temp_dir = TempDir::new().unwrap();
let config = SapExportConfig {
dialect: SapDialect::Hana,
..SapExportConfig::default()
};
let mut exporter = SapExporter::new(config);
exporter
.export_to_files(&[create_test_je()], temp_dir.path())
.unwrap();
let bkpf = std::fs::read(temp_dir.path().join("bkpf.csv")).unwrap();
assert_eq!(
&bkpf[..3],
[0xEF, 0xBB, 0xBF],
"Hana dialect must prefix files with a UTF-8 BOM"
);
let text = std::str::from_utf8(&bkpf[3..]).unwrap();
let head = text.lines().next().unwrap();
assert!(head.contains(';'), "Hana header must use semicolon: {head}");
assert!(!head.contains(','), "Hana header must not use comma");
let body = text.lines().nth(1).unwrap();
assert!(
body.split(';')
.any(|f| { f.len() == 10 && f.chars().filter(|c| *c == '-').count() == 2 }),
"Hana body must contain a YYYY-MM-DD date: {body}"
);
}
#[test]
fn dialect_format_decimal_uses_comma_for_hana_dot_for_classic() {
let d = Decimal::new(12345, 2); assert_eq!(SapDialect::Classic.format_decimal(&d), "123.45");
assert_eq!(SapDialect::Hana.format_decimal(&d), "123,45");
}
#[test]
fn escape_csv_field_quotes_fields_containing_either_delimiter() {
assert_eq!(escape_csv_field("Müller, GmbH"), "\"Müller, GmbH\"");
assert_eq!(escape_csv_field("Müller; GmbH"), "\"Müller; GmbH\"");
assert_eq!(escape_csv_field("Straight"), "Straight");
}
}