use std::fs::File;
use std::io::{BufWriter, Write};
use std::path::Path;
use chrono::NaiveDate;
use datasynth_core::error::SynthResult;
use datasynth_core::models::documents::{Delivery, GoodsReceipt, PurchaseOrder, SalesOrder};
use rust_decimal::Decimal;
use super::sap::SapExportConfig;
fn open_file(cfg: &SapExportConfig, path: &Path) -> SynthResult<BufWriter<File>> {
let file = File::create(path)?;
let mut writer = BufWriter::with_capacity(256 * 1024, file);
let bom = cfg.dialect.bom();
if !bom.is_empty() {
writer.write_all(bom)?;
}
Ok(writer)
}
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)
}
fn write_header<W: Write>(writer: &mut W, delim: char, cols: &[&str]) -> std::io::Result<()> {
for (i, c) in cols.iter().enumerate() {
if i > 0 {
write!(writer, "{delim}")?;
}
write!(writer, "{c}")?;
}
writeln!(writer)
}
fn escape(field: &str) -> String {
if field.contains(',') || field.contains(';') || field.contains('"') || field.contains('\n') {
format!("\"{}\"", field.replace('"', "\"\""))
} else {
field.to_string()
}
}
fn opt_date(cfg: &SapExportConfig, d: Option<NaiveDate>) -> String {
d.map(|x| cfg.format_date(x)).unwrap_or_default()
}
fn dec(cfg: &SapExportConfig, v: &Decimal) -> String {
cfg.format_decimal(v)
}
#[derive(Debug, Clone)]
pub struct SapPoHeader {
pub mandt: String,
pub ebeln: String,
pub bukrs: String,
pub bstyp: String,
pub bsart: String,
pub lifnr: String,
pub ekorg: String,
pub ekgrp: String,
pub aedat: NaiveDate,
pub bedat: NaiveDate,
pub ernam: String,
pub waers: String,
pub zterm: String,
pub inco1: Option<String>,
pub inco2: Option<String>,
pub ihrez: Option<String>,
}
pub trait SapPoExportable {
fn to_sap_ekko(&self, client: &str) -> SapPoHeader;
fn to_sap_ekpo_rows(&self, client: &str) -> Vec<SapPoItem>;
}
#[derive(Debug, Clone)]
pub struct SapPoItem {
pub mandt: String,
pub ebeln: String,
pub ebelp: String,
pub matnr: Option<String>,
pub txz01: String,
pub pstyp: String,
pub knttp: String,
pub werks: Option<String>,
pub lgort: Option<String>,
pub menge: Decimal,
pub meins: String,
pub netpr: Decimal,
pub peinh: u32,
pub netwr: Decimal,
pub webre: bool,
pub wemng: Decimal,
pub remng: Decimal,
pub eindt: Option<NaiveDate>,
pub loekz: bool,
}
impl SapPoExportable for PurchaseOrder {
fn to_sap_ekko(&self, client: &str) -> SapPoHeader {
SapPoHeader {
mandt: client.to_string(),
ebeln: self.header.document_id.clone(),
bukrs: self.header.company_code.clone(),
bstyp: "F".to_string(),
bsart: po_type_to_bsart(&self.po_type),
lifnr: self.vendor_id.clone(),
ekorg: self.purchasing_org.clone(),
ekgrp: self.purchasing_group.clone(),
aedat: self.header.entry_date,
bedat: self.header.document_date,
ernam: self.header.created_by.clone(),
waers: self.header.currency.clone(),
zterm: format!("{:?}", self.payment_terms),
inco1: self.incoterms.clone(),
inco2: self.incoterms_location.clone(),
ihrez: self.header.reference.clone(),
}
}
fn to_sap_ekpo_rows(&self, client: &str) -> Vec<SapPoItem> {
self.items
.iter()
.map(|item| SapPoItem {
mandt: client.to_string(),
ebeln: self.header.document_id.clone(),
ebelp: format!("{:05}", item.base.line_number),
matnr: item.base.material_id.clone(),
txz01: item.base.description.clone(),
pstyp: match item.item_category.as_str() {
"SERVICE" => "9",
"LIMIT" => "B",
_ => "0",
}
.to_string(),
knttp: item.account_assignment_category.clone(),
werks: item.base.plant.clone(),
lgort: item.base.storage_location.clone(),
menge: item.base.quantity,
meins: item.base.uom.clone(),
netpr: item.base.unit_price,
peinh: 1,
netwr: item.base.net_amount,
webre: item.gr_based_iv,
wemng: item.quantity_received,
remng: item.quantity_invoiced,
eindt: item.requested_date,
loekz: self.is_closed,
})
.collect()
}
}
fn po_type_to_bsart(pt: &datasynth_core::documents::PurchaseOrderType) -> String {
use datasynth_core::documents::PurchaseOrderType;
match pt {
PurchaseOrderType::Standard => "NB",
PurchaseOrderType::Framework => "FO",
PurchaseOrderType::Service => "DB",
PurchaseOrderType::StockTransfer => "UB",
PurchaseOrderType::Subcontracting => "LB",
PurchaseOrderType::Consignment => "K",
}
.to_string()
}
pub fn write_ekko(cfg: &SapExportConfig, pos: &[PurchaseOrder], path: &Path) -> SynthResult<()> {
let mut w = open_file(cfg, path)?;
let d = cfg.delimiter();
write_header(
&mut w,
d,
&[
"MANDT", "EBELN", "BUKRS", "BSTYP", "BSART", "LIFNR", "EKORG", "EKGRP", "AEDAT",
"BEDAT", "ERNAM", "WAERS", "ZTERM", "INCO1", "INCO2", "IHREZ",
],
)?;
for po in pos {
let s = po.to_sap_ekko(&cfg.client);
let fields = vec![
s.mandt,
s.ebeln,
s.bukrs,
s.bstyp,
s.bsart,
s.lifnr,
s.ekorg,
s.ekgrp,
cfg.format_date(s.aedat),
cfg.format_date(s.bedat),
s.ernam,
s.waers,
escape(&s.zterm),
s.inco1.unwrap_or_default(),
s.inco2.unwrap_or_default(),
s.ihrez.unwrap_or_default(),
];
write_row(&mut w, d, &fields)?;
}
w.flush()?;
Ok(())
}
pub fn write_ekpo(cfg: &SapExportConfig, pos: &[PurchaseOrder], path: &Path) -> SynthResult<()> {
let mut w = open_file(cfg, path)?;
let d = cfg.delimiter();
write_header(
&mut w,
d,
&[
"MANDT", "EBELN", "EBELP", "MATNR", "TXZ01", "PSTYP", "KNTTP", "WERKS", "LGORT",
"MENGE", "MEINS", "NETPR", "PEINH", "NETWR", "WEBRE", "WEMNG", "REMNG", "EINDT",
"LOEKZ",
],
)?;
for po in pos {
for s in po.to_sap_ekpo_rows(&cfg.client) {
let fields = vec![
s.mandt,
s.ebeln,
s.ebelp,
s.matnr.unwrap_or_default(),
escape(&s.txz01),
s.pstyp,
s.knttp,
s.werks.unwrap_or_default(),
s.lgort.unwrap_or_default(),
dec(cfg, &s.menge),
s.meins,
dec(cfg, &s.netpr),
s.peinh.to_string(),
dec(cfg, &s.netwr),
if s.webre {
"X".to_string()
} else {
String::new()
},
dec(cfg, &s.wemng),
dec(cfg, &s.remng),
opt_date(cfg, s.eindt),
if s.loekz {
"X".to_string()
} else {
String::new()
},
];
write_row(&mut w, d, &fields)?;
}
}
w.flush()?;
Ok(())
}
#[derive(Debug, Clone)]
pub struct SapSoHeader {
pub mandt: String,
pub vbeln: String,
pub auart: String,
pub vkorg: String,
pub vtweg: Option<String>,
pub spart: Option<String>,
pub kunnr: String,
pub audat: NaiveDate,
pub netwr: Decimal,
pub waerk: String,
pub augru: Option<String>,
pub vdatu: Option<NaiveDate>,
pub zterm: String,
pub inco1: Option<String>,
}
#[derive(Debug, Clone)]
pub struct SapSoItem {
pub mandt: String,
pub vbeln: String,
pub posnr: String,
pub matnr: Option<String>,
pub arktx: String,
pub pstyv: String,
pub kwmeng: Decimal,
pub vrkme: String,
pub netpr: Decimal,
pub netwr: Decimal,
pub werks: Option<String>,
pub lgort: Option<String>,
pub edatu: Option<NaiveDate>,
pub abgru: Option<String>,
}
pub trait SapSoExportable {
fn to_sap_vbak(&self, client: &str) -> SapSoHeader;
fn to_sap_vbap_rows(&self, client: &str) -> Vec<SapSoItem>;
}
impl SapSoExportable for SalesOrder {
fn to_sap_vbak(&self, client: &str) -> SapSoHeader {
SapSoHeader {
mandt: client.to_string(),
vbeln: self.header.document_id.clone(),
auart: so_type_to_auart(&self.so_type),
vkorg: self.sales_org.clone(),
vtweg: Some(self.distribution_channel.clone()),
spart: Some(self.division.clone()),
kunnr: self.customer_id.clone(),
audat: self.header.document_date,
netwr: self.total_net_amount,
waerk: self.header.currency.clone(),
augru: None,
vdatu: self.requested_delivery_date,
zterm: format!("{:?}", self.payment_terms),
inco1: self.incoterms.clone(),
}
}
fn to_sap_vbap_rows(&self, client: &str) -> Vec<SapSoItem> {
self.items
.iter()
.map(|item| SapSoItem {
mandt: client.to_string(),
vbeln: self.header.document_id.clone(),
posnr: format!("{:06}", item.base.line_number),
matnr: item.base.material_id.clone(),
arktx: item.base.description.clone(),
pstyv: "TAN".to_string(),
kwmeng: item.base.quantity,
vrkme: item.base.uom.clone(),
netpr: item.base.unit_price,
netwr: item.base.net_amount,
werks: item.base.plant.clone(),
lgort: item.base.storage_location.clone(),
edatu: item.base.delivery_date,
abgru: None,
})
.collect()
}
}
fn so_type_to_auart(so: &datasynth_core::documents::SalesOrderType) -> String {
use datasynth_core::documents::SalesOrderType;
match so {
SalesOrderType::Standard => "OR",
SalesOrderType::Rush => "SO",
SalesOrderType::CashSale => "BV",
SalesOrderType::Return => "RE",
SalesOrderType::FreeOfCharge => "FD",
SalesOrderType::Consignment => "KB",
SalesOrderType::Service => "DS",
SalesOrderType::CreditMemoRequest => "G2",
SalesOrderType::DebitMemoRequest => "L2",
}
.to_string()
}
pub fn write_vbak(cfg: &SapExportConfig, sos: &[SalesOrder], path: &Path) -> SynthResult<()> {
let mut w = open_file(cfg, path)?;
let d = cfg.delimiter();
write_header(
&mut w,
d,
&[
"MANDT", "VBELN", "AUART", "VKORG", "VTWEG", "SPART", "KUNNR", "AUDAT", "NETWR",
"WAERK", "AUGRU", "VDATU", "ZTERM", "INCO1",
],
)?;
for so in sos {
let s = so.to_sap_vbak(&cfg.client);
let fields = vec![
s.mandt,
s.vbeln,
s.auart,
s.vkorg,
s.vtweg.unwrap_or_default(),
s.spart.unwrap_or_default(),
s.kunnr,
cfg.format_date(s.audat),
dec(cfg, &s.netwr),
s.waerk,
s.augru.unwrap_or_default(),
opt_date(cfg, s.vdatu),
escape(&s.zterm),
s.inco1.unwrap_or_default(),
];
write_row(&mut w, d, &fields)?;
}
w.flush()?;
Ok(())
}
pub fn write_vbap(cfg: &SapExportConfig, sos: &[SalesOrder], path: &Path) -> SynthResult<()> {
let mut w = open_file(cfg, path)?;
let d = cfg.delimiter();
write_header(
&mut w,
d,
&[
"MANDT", "VBELN", "POSNR", "MATNR", "ARKTX", "PSTYV", "KWMENG", "VRKME", "NETPR",
"NETWR", "WERKS", "LGORT", "EDATU", "ABGRU",
],
)?;
for so in sos {
for s in so.to_sap_vbap_rows(&cfg.client) {
let fields = vec![
s.mandt,
s.vbeln,
s.posnr,
s.matnr.unwrap_or_default(),
escape(&s.arktx),
s.pstyv,
dec(cfg, &s.kwmeng),
s.vrkme,
dec(cfg, &s.netpr),
dec(cfg, &s.netwr),
s.werks.unwrap_or_default(),
s.lgort.unwrap_or_default(),
opt_date(cfg, s.edatu),
s.abgru.unwrap_or_default(),
];
write_row(&mut w, d, &fields)?;
}
}
w.flush()?;
Ok(())
}
#[derive(Debug, Clone)]
pub struct SapDeliveryHeader {
pub mandt: String,
pub vbeln: String,
pub lfart: String,
pub vstel: Option<String>,
pub route: Option<String>,
pub kunnr: String,
pub wadat: Option<NaiveDate>,
pub wadat_ist: Option<NaiveDate>,
pub waerk: String,
pub sdabw: Option<String>,
pub erdat: NaiveDate,
pub lifsk: Option<String>,
}
#[derive(Debug, Clone)]
pub struct SapDeliveryItem {
pub mandt: String,
pub vbeln: String,
pub posnr: String,
pub matnr: Option<String>,
pub arktx: String,
pub lfimg: Decimal,
pub vrkme: String,
pub werks: Option<String>,
pub lgort: Option<String>,
pub vgbel: Option<String>,
pub vgpos: Option<String>,
pub charg: Option<String>,
}
pub trait SapDeliveryExportable {
fn to_sap_likp(&self, client: &str) -> SapDeliveryHeader;
fn to_sap_lips_rows(&self, client: &str) -> Vec<SapDeliveryItem>;
}
impl SapDeliveryExportable for Delivery {
fn to_sap_likp(&self, client: &str) -> SapDeliveryHeader {
SapDeliveryHeader {
mandt: client.to_string(),
vbeln: self.header.document_id.clone(),
lfart: delivery_type_to_lfart(&self.delivery_type),
vstel: Some(self.shipping_point.clone()),
route: self.route.clone(),
kunnr: self.customer_id.clone(),
wadat: self.delivery_date,
wadat_ist: self.actual_gi_date,
waerk: self.header.currency.clone(),
sdabw: self.carrier.clone(),
erdat: self.header.entry_date,
lifsk: None,
}
}
fn to_sap_lips_rows(&self, client: &str) -> Vec<SapDeliveryItem> {
self.items
.iter()
.map(|item| SapDeliveryItem {
mandt: client.to_string(),
vbeln: self.header.document_id.clone(),
posnr: format!("{:06}", item.base.line_number),
matnr: item.base.material_id.clone(),
arktx: item.base.description.clone(),
lfimg: item.base.quantity,
vrkme: item.base.uom.clone(),
werks: item.base.plant.clone(),
lgort: item.base.storage_location.clone(),
vgbel: item.sales_order_id.clone(),
vgpos: item.so_item.map(|p| format!("{:05}", p)),
charg: item.batch.clone(),
})
.collect()
}
}
fn delivery_type_to_lfart(dt: &datasynth_core::documents::DeliveryType) -> String {
use datasynth_core::documents::DeliveryType;
match dt {
DeliveryType::Outbound => "LF",
DeliveryType::Return => "LR",
DeliveryType::StockTransfer => "UL",
DeliveryType::Replenishment => "NL",
DeliveryType::ConsignmentIssue => "LK",
DeliveryType::ConsignmentReturn => "RL",
}
.to_string()
}
pub fn write_likp(cfg: &SapExportConfig, deliveries: &[Delivery], path: &Path) -> SynthResult<()> {
let mut w = open_file(cfg, path)?;
let d = cfg.delimiter();
write_header(
&mut w,
d,
&[
"MANDT",
"VBELN",
"LFART",
"VSTEL",
"ROUTE",
"KUNNR",
"WADAT",
"WADAT_IST",
"WAERK",
"SDABW",
"ERDAT",
"LIFSK",
],
)?;
for dlv in deliveries {
let s = dlv.to_sap_likp(&cfg.client);
let fields = vec![
s.mandt,
s.vbeln,
s.lfart,
s.vstel.unwrap_or_default(),
s.route.unwrap_or_default(),
s.kunnr,
opt_date(cfg, s.wadat),
opt_date(cfg, s.wadat_ist),
s.waerk,
s.sdabw.unwrap_or_default(),
cfg.format_date(s.erdat),
s.lifsk.unwrap_or_default(),
];
write_row(&mut w, d, &fields)?;
}
w.flush()?;
Ok(())
}
pub fn write_lips(cfg: &SapExportConfig, deliveries: &[Delivery], path: &Path) -> SynthResult<()> {
let mut w = open_file(cfg, path)?;
let d = cfg.delimiter();
write_header(
&mut w,
d,
&[
"MANDT", "VBELN", "POSNR", "MATNR", "ARKTX", "LFIMG", "VRKME", "WERKS", "LGORT",
"VGBEL", "VGPOS", "CHARG",
],
)?;
for dlv in deliveries {
for s in dlv.to_sap_lips_rows(&cfg.client) {
let fields = vec![
s.mandt,
s.vbeln,
s.posnr,
s.matnr.unwrap_or_default(),
escape(&s.arktx),
dec(cfg, &s.lfimg),
s.vrkme,
s.werks.unwrap_or_default(),
s.lgort.unwrap_or_default(),
s.vgbel.unwrap_or_default(),
s.vgpos.unwrap_or_default(),
s.charg.unwrap_or_default(),
];
write_row(&mut w, d, &fields)?;
}
}
w.flush()?;
Ok(())
}
#[derive(Debug, Clone)]
pub struct SapMatDocHeader {
pub mandt: String,
pub mblnr: String,
pub mjahr: u16,
pub vgart: String,
pub budat: NaiveDate,
pub bldat: NaiveDate,
pub xblnr: Option<String>,
pub usnam: String,
pub bktxt: Option<String>,
}
#[derive(Debug, Clone)]
pub struct SapMatDocItem {
pub mandt: String,
pub mblnr: String,
pub mjahr: u16,
pub zeile: String,
pub bwart: String,
pub matnr: Option<String>,
pub werks: Option<String>,
pub lgort: Option<String>,
pub charg: Option<String>,
pub menge: Decimal,
pub meins: String,
pub dmbtr: Decimal,
pub waers: String,
pub shkzg: String,
pub ebeln: Option<String>,
pub ebelp: Option<String>,
}
pub trait SapMatDocExportable {
fn to_sap_mkpf(&self, client: &str) -> SapMatDocHeader;
fn to_sap_mseg_rows(&self, client: &str) -> Vec<SapMatDocItem>;
}
impl SapMatDocExportable for GoodsReceipt {
fn to_sap_mkpf(&self, client: &str) -> SapMatDocHeader {
SapMatDocHeader {
mandt: client.to_string(),
mblnr: self.header.document_id.clone(),
mjahr: self.header.fiscal_year,
vgart: "WE".to_string(),
budat: self
.header
.posting_date
.unwrap_or(self.header.document_date),
bldat: self.header.document_date,
xblnr: self.header.reference.clone(),
usnam: self.header.created_by.clone(),
bktxt: self.header.header_text.clone(),
}
}
fn to_sap_mseg_rows(&self, client: &str) -> Vec<SapMatDocItem> {
let year = self.header.fiscal_year;
self.items
.iter()
.map(|item| SapMatDocItem {
mandt: client.to_string(),
mblnr: self.header.document_id.clone(),
mjahr: year,
zeile: format!("{:04}", item.base.line_number),
bwart: "101".to_string(),
matnr: item.base.material_id.clone(),
werks: item.base.plant.clone(),
lgort: item.base.storage_location.clone(),
charg: item.batch.clone(),
menge: item.base.quantity,
meins: item.base.uom.clone(),
dmbtr: item.base.net_amount,
waers: self.header.currency.clone(),
shkzg: "S".to_string(),
ebeln: item.po_number.clone(),
ebelp: item.po_item.map(|p| format!("{:05}", p)),
})
.collect()
}
}
pub fn write_mkpf(cfg: &SapExportConfig, grs: &[GoodsReceipt], path: &Path) -> SynthResult<()> {
let mut w = open_file(cfg, path)?;
let d = cfg.delimiter();
write_header(
&mut w,
d,
&[
"MANDT", "MBLNR", "MJAHR", "VGART", "BUDAT", "BLDAT", "XBLNR", "USNAM", "BKTXT",
],
)?;
for gr in grs {
let s = gr.to_sap_mkpf(&cfg.client);
let fields = vec![
s.mandt,
s.mblnr,
s.mjahr.to_string(),
s.vgart,
cfg.format_date(s.budat),
cfg.format_date(s.bldat),
s.xblnr.unwrap_or_default(),
s.usnam,
escape(&s.bktxt.unwrap_or_default()),
];
write_row(&mut w, d, &fields)?;
}
w.flush()?;
Ok(())
}
pub fn write_mseg(cfg: &SapExportConfig, grs: &[GoodsReceipt], path: &Path) -> SynthResult<()> {
let mut w = open_file(cfg, path)?;
let d = cfg.delimiter();
write_header(
&mut w,
d,
&[
"MANDT", "MBLNR", "MJAHR", "ZEILE", "BWART", "MATNR", "WERKS", "LGORT", "CHARG",
"MENGE", "MEINS", "DMBTR", "WAERS", "SHKZG", "EBELN", "EBELP",
],
)?;
for gr in grs {
for s in gr.to_sap_mseg_rows(&cfg.client) {
let fields = vec![
s.mandt,
s.mblnr,
s.mjahr.to_string(),
s.zeile,
s.bwart,
s.matnr.unwrap_or_default(),
s.werks.unwrap_or_default(),
s.lgort.unwrap_or_default(),
s.charg.unwrap_or_default(),
dec(cfg, &s.menge),
s.meins,
dec(cfg, &s.dmbtr),
s.waers,
s.shkzg,
s.ebeln.unwrap_or_default(),
s.ebelp.unwrap_or_default(),
];
write_row(&mut w, d, &fields)?;
}
}
w.flush()?;
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::super::sap::SapDialect;
use super::*;
fn cfg() -> SapExportConfig {
SapExportConfig {
dialect: SapDialect::Hana,
..SapExportConfig::default()
}
}
#[test]
fn po_type_mapping_covers_all_variants() {
use datasynth_core::documents::PurchaseOrderType;
for pt in [
PurchaseOrderType::Standard,
PurchaseOrderType::Framework,
PurchaseOrderType::Service,
PurchaseOrderType::StockTransfer,
PurchaseOrderType::Subcontracting,
PurchaseOrderType::Consignment,
] {
let code = po_type_to_bsart(&pt);
assert!(!code.is_empty(), "BSART must be non-empty for {pt:?}");
}
}
#[test]
fn so_type_mapping_covers_all_variants() {
use datasynth_core::documents::SalesOrderType;
for so in [
SalesOrderType::Standard,
SalesOrderType::Rush,
SalesOrderType::CashSale,
SalesOrderType::Return,
SalesOrderType::FreeOfCharge,
SalesOrderType::Consignment,
SalesOrderType::Service,
SalesOrderType::CreditMemoRequest,
SalesOrderType::DebitMemoRequest,
] {
let code = so_type_to_auart(&so);
assert!(!code.is_empty(), "AUART must be non-empty for {so:?}");
}
}
#[test]
fn delivery_type_mapping_covers_all_variants() {
use datasynth_core::documents::DeliveryType;
for dt in [
DeliveryType::Outbound,
DeliveryType::Return,
DeliveryType::StockTransfer,
DeliveryType::Replenishment,
DeliveryType::ConsignmentIssue,
DeliveryType::ConsignmentReturn,
] {
let code = delivery_type_to_lfart(&dt);
assert!(!code.is_empty(), "LFART must be non-empty for {dt:?}");
}
}
#[test]
fn dialect_format_decimal_roundtrips_through_helper() {
let cfg = cfg();
let v = Decimal::new(12345, 2);
assert_eq!(dec(&cfg, &v), "123,45");
}
}