Skip to main content

datasynth_core/models/
acdoca.rs

1//! SAP HANA ACDOCA/BSEG compatible event log structures.
2//!
3//! This module defines data structures compatible with SAP S/4HANA's
4//! Universal Journal (ACDOCA) and legacy document segment table (BSEG).
5//! These formats are essential for testing real-time analytics and
6//! process mining tools that work with SAP data.
7
8use chrono::NaiveDate;
9use rust_decimal::Decimal;
10use serde::{Deserialize, Serialize};
11use uuid::Uuid;
12
13use super::journal_entry::JournalEntry;
14
15/// SAP HANA ACDOCA-compatible universal journal entry line.
16///
17/// This represents the flattened, denormalized structure used in S/4HANA's
18/// Universal Journal. Each record corresponds to a line item with all
19/// dimensional attributes denormalized for analytics performance.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct AcdocaEntry {
22    // === Primary Keys ===
23    /// Ledger (0L = Leading Ledger, 2L = Local GAAP, etc.)
24    pub rldnr: String,
25    /// Company Code
26    pub rbukrs: String,
27    /// Fiscal Year
28    pub gjahr: u16,
29    /// Accounting Document Number
30    pub belnr: String,
31    /// Line Item Number (6 digits, zero-padded)
32    pub docln: String,
33
34    // === Document Control ===
35    /// Document Type
36    pub blart: String,
37    /// Posting Date
38    pub budat: NaiveDate,
39    /// Document Date
40    pub bldat: NaiveDate,
41    /// Entry Date
42    pub cpudt: NaiveDate,
43    /// Entry Time (HHMMSS format)
44    pub cputm: String,
45    /// User Name
46    pub usnam: String,
47    /// Fiscal Period
48    pub poper: u8,
49    /// Reversal Reason
50    pub stgrd: Option<String>,
51    /// Reference Document Number
52    pub xblnr: Option<String>,
53    /// Document Header Text
54    pub bktxt: Option<String>,
55
56    // === Account Assignment ===
57    /// GL Account
58    pub racct: String,
59    /// Cost Center
60    pub rcntr: Option<String>,
61    /// Profit Center
62    pub prctr: Option<String>,
63    /// Segment
64    pub segment: Option<String>,
65    /// Functional Area
66    pub rfarea: Option<String>,
67    /// Business Area
68    pub rbusa: Option<String>,
69    /// Project (WBS Element)
70    pub ps_psp_pnr: Option<String>,
71    /// Internal Order
72    pub aufnr: Option<String>,
73    /// Sales Order
74    pub kdauf: Option<String>,
75    /// Sales Order Item
76    pub kdpos: Option<String>,
77
78    // === Partner Fields (Intercompany) ===
79    /// Partner Company Code
80    pub pbukrs: Option<String>,
81    /// Partner Profit Center
82    pub pprctr: Option<String>,
83    /// Partner Segment
84    pub psegment: Option<String>,
85    /// Trading Partner
86    pub rassc: Option<String>,
87
88    // === Amounts ===
89    /// Amount in Transaction Currency
90    pub wsl: Decimal,
91    /// Transaction Currency
92    pub rwcur: String,
93    /// Amount in Local Currency
94    pub hsl: Decimal,
95    /// Local Currency (Company Code Currency)
96    pub rhcur: String,
97    /// Amount in Group Currency
98    pub ksl: Option<Decimal>,
99    /// Group Currency
100    pub rkcur: Option<String>,
101    /// Amount in Global Currency
102    pub osl: Option<Decimal>,
103    /// Global Currency
104    pub rocur: Option<String>,
105
106    // === Quantities ===
107    /// Quantity
108    pub msl: Option<Decimal>,
109    /// Unit of Measure
110    pub runit: Option<String>,
111
112    // === Text Fields ===
113    /// Line Item Text
114    pub sgtxt: Option<String>,
115    /// Assignment
116    pub zuonr: Option<String>,
117
118    // === Source Document Reference ===
119    /// Source System
120    pub awsys: String,
121    /// Reference Transaction Type
122    pub awtyp: String,
123    /// Reference Key
124    pub awkey: String,
125    /// Reference Item
126    pub awitem: Option<String>,
127    /// Source Document Type
128    pub aworg: Option<String>,
129
130    // === Tax ===
131    /// Tax Code
132    pub mwskz: Option<String>,
133    /// Tax Jurisdiction
134    pub txjcd: Option<String>,
135    /// Tax Base Amount
136    pub hwbas: Option<Decimal>,
137
138    // === Control Flags ===
139    /// Reversal Flag
140    pub xstov: bool,
141    /// Statistical Flag
142    pub xsauf: bool,
143    /// Debit/Credit Indicator (S = Debit, H = Credit)
144    pub drcrk: String,
145    /// Posting Key
146    pub bschl: String,
147
148    // === Asset Accounting ===
149    /// Asset Number
150    pub anln1: Option<String>,
151    /// Asset Sub-Number
152    pub anln2: Option<String>,
153    /// Asset Transaction Type
154    pub anbwa: Option<String>,
155
156    // === Vendor/Customer ===
157    /// Vendor Number
158    pub lifnr: Option<String>,
159    /// Customer Number
160    pub kunnr: Option<String>,
161
162    // === Extension Fields (Simulation Metadata) ===
163    /// Simulation batch ID for traceability
164    #[serde(rename = "ZSIM_BATCH_ID")]
165    pub sim_batch_id: Option<Uuid>,
166    /// Is fraud indicator
167    #[serde(rename = "ZSIM_IS_FRAUD")]
168    pub sim_is_fraud: bool,
169    /// Fraud type code
170    #[serde(rename = "ZSIM_FRAUD_TYPE")]
171    pub sim_fraud_type: Option<String>,
172    /// Business process for process mining
173    #[serde(rename = "ZSIM_BUSINESS_PROCESS")]
174    pub sim_business_process: Option<String>,
175    /// User persona classification
176    #[serde(rename = "ZSIM_USER_PERSONA")]
177    pub sim_user_persona: Option<String>,
178    /// Original journal entry UUID
179    #[serde(rename = "ZSIM_JE_UUID")]
180    pub sim_je_uuid: Option<Uuid>,
181
182    // === Internal Controls / SOX Compliance Fields ===
183    /// Comma-separated list of applicable control IDs
184    #[serde(rename = "ZSIM_CONTROL_IDS")]
185    pub sim_control_ids: Option<String>,
186    /// SOX relevance indicator
187    #[serde(rename = "ZSIM_SOX_RELEVANT")]
188    pub sim_sox_relevant: bool,
189    /// Control status (Effective, Exception, NotTested, Remediated)
190    #[serde(rename = "ZSIM_CONTROL_STATUS")]
191    pub sim_control_status: Option<String>,
192    /// SoD violation indicator
193    #[serde(rename = "ZSIM_SOD_VIOLATION")]
194    pub sim_sod_violation: bool,
195    /// SoD conflict type if violation occurred
196    #[serde(rename = "ZSIM_SOD_CONFLICT")]
197    pub sim_sod_conflict: Option<String>,
198}
199
200impl Default for AcdocaEntry {
201    fn default() -> Self {
202        Self {
203            rldnr: "0L".to_string(),
204            rbukrs: String::new(),
205            gjahr: 0,
206            belnr: String::new(),
207            docln: String::new(),
208            blart: "SA".to_string(),
209            budat: NaiveDate::from_ymd_opt(2000, 1, 1).unwrap(),
210            bldat: NaiveDate::from_ymd_opt(2000, 1, 1).unwrap(),
211            cpudt: NaiveDate::from_ymd_opt(2000, 1, 1).unwrap(),
212            cputm: "000000".to_string(),
213            usnam: String::new(),
214            poper: 1,
215            stgrd: None,
216            xblnr: None,
217            bktxt: None,
218            racct: String::new(),
219            rcntr: None,
220            prctr: None,
221            segment: None,
222            rfarea: None,
223            rbusa: None,
224            ps_psp_pnr: None,
225            aufnr: None,
226            kdauf: None,
227            kdpos: None,
228            pbukrs: None,
229            pprctr: None,
230            psegment: None,
231            rassc: None,
232            wsl: Decimal::ZERO,
233            rwcur: "USD".to_string(),
234            hsl: Decimal::ZERO,
235            rhcur: "USD".to_string(),
236            ksl: None,
237            rkcur: None,
238            osl: None,
239            rocur: None,
240            msl: None,
241            runit: None,
242            sgtxt: None,
243            zuonr: None,
244            awsys: "SYNTH".to_string(),
245            awtyp: "BKPF".to_string(),
246            awkey: String::new(),
247            awitem: None,
248            aworg: None,
249            mwskz: None,
250            txjcd: None,
251            hwbas: None,
252            xstov: false,
253            xsauf: false,
254            drcrk: "S".to_string(),
255            bschl: "40".to_string(),
256            anln1: None,
257            anln2: None,
258            anbwa: None,
259            lifnr: None,
260            kunnr: None,
261            sim_batch_id: None,
262            sim_is_fraud: false,
263            sim_fraud_type: None,
264            sim_business_process: None,
265            sim_user_persona: None,
266            sim_je_uuid: None,
267            // Internal Controls / SOX fields
268            sim_control_ids: None,
269            sim_sox_relevant: false,
270            sim_control_status: None,
271            sim_sod_violation: false,
272            sim_sod_conflict: None,
273        }
274    }
275}
276
277/// SAP BSEG-compatible document segment structure.
278///
279/// Traditional line item table format used in ECC/R3 systems before S/4HANA.
280/// Maintained for backward compatibility with legacy analytics tools.
281#[derive(Debug, Clone, Serialize, Deserialize)]
282pub struct BsegEntry {
283    /// Client (SAP system client)
284    pub mandt: String,
285    /// Company Code
286    pub bukrs: String,
287    /// Document Number
288    pub belnr: String,
289    /// Fiscal Year
290    pub gjahr: u16,
291    /// Line Item Number
292    pub buzei: String,
293
294    // === Account Fields ===
295    /// Posting Key
296    pub bschl: String,
297    /// GL Account
298    pub hkont: String,
299    /// Special GL Indicator
300    pub umskz: Option<String>,
301
302    // === Amount Fields ===
303    /// Amount in Document Currency
304    pub wrbtr: Decimal,
305    /// Debit/Credit Indicator (S = Debit, H = Credit)
306    pub shkzg: String,
307    /// Amount in Local Currency
308    pub dmbtr: Decimal,
309    /// Local Currency
310    pub waers: String,
311    /// Tax Amount
312    pub wmwst: Option<Decimal>,
313
314    // === Additional Assignment ===
315    /// Cost Center
316    pub kostl: Option<String>,
317    /// Profit Center
318    pub prctr: Option<String>,
319    /// Asset Number
320    pub anln1: Option<String>,
321    /// Asset Sub-Number
322    pub anln2: Option<String>,
323    /// Vendor Number
324    pub lifnr: Option<String>,
325    /// Customer Number
326    pub kunnr: Option<String>,
327
328    // === Text and Reference ===
329    /// Line Item Text
330    pub sgtxt: Option<String>,
331    /// Assignment
332    pub zuonr: Option<String>,
333    /// Tax Code
334    pub mwskz: Option<String>,
335}
336
337impl Default for BsegEntry {
338    fn default() -> Self {
339        Self {
340            mandt: "100".to_string(),
341            bukrs: String::new(),
342            belnr: String::new(),
343            gjahr: 0,
344            buzei: String::new(),
345            bschl: "40".to_string(),
346            hkont: String::new(),
347            umskz: None,
348            wrbtr: Decimal::ZERO,
349            shkzg: "S".to_string(),
350            dmbtr: Decimal::ZERO,
351            waers: "USD".to_string(),
352            wmwst: None,
353            kostl: None,
354            prctr: None,
355            anln1: None,
356            anln2: None,
357            lifnr: None,
358            kunnr: None,
359            sgtxt: None,
360            zuonr: None,
361            mwskz: None,
362        }
363    }
364}
365
366/// Factory for creating ACDOCA entries from journal entries.
367///
368/// Handles the conversion from internal journal entry format to
369/// SAP HANA ACDOCA-compatible records.
370#[derive(Debug, Clone)]
371pub struct AcdocaFactory {
372    /// Ledger identifier
373    ledger: String,
374    /// Source system identifier
375    source_system: String,
376    /// Local currency (company code currency)
377    local_currency: String,
378    /// Group currency (for consolidation)
379    group_currency: Option<String>,
380    /// SAP client number
381    client: String,
382}
383
384impl AcdocaFactory {
385    /// Create a new ACDOCA factory.
386    pub fn new(ledger: &str, source_system: &str) -> Self {
387        Self {
388            ledger: ledger.to_string(),
389            source_system: source_system.to_string(),
390            local_currency: "USD".to_string(),
391            group_currency: None,
392            client: "100".to_string(),
393        }
394    }
395
396    /// Set the local currency.
397    pub fn with_local_currency(mut self, currency: &str) -> Self {
398        self.local_currency = currency.to_string();
399        self
400    }
401
402    /// Set the group currency.
403    pub fn with_group_currency(mut self, currency: &str) -> Self {
404        self.group_currency = Some(currency.to_string());
405        self
406    }
407
408    /// Set the SAP client.
409    pub fn with_client(mut self, client: &str) -> Self {
410        self.client = client.to_string();
411        self
412    }
413
414    /// Convert a JournalEntry into ACDOCA entries.
415    pub fn from_journal_entry(&self, je: &JournalEntry, document_number: &str) -> Vec<AcdocaEntry> {
416        let created_at = je.header.created_at;
417
418        je.lines
419            .iter()
420            .map(|line| {
421                // Determine if debit or credit
422                let is_debit = line.debit_amount > Decimal::ZERO;
423                let amount = if is_debit {
424                    line.debit_amount
425                } else {
426                    line.credit_amount
427                };
428
429                // Signed amount (positive for debit, negative for credit)
430                let wsl = if is_debit { amount } else { -amount };
431                let hsl = wsl * je.header.exchange_rate;
432
433                // Posting key
434                let bschl = if is_debit { "40" } else { "50" };
435                let drcrk = if is_debit { "S" } else { "H" };
436
437                AcdocaEntry {
438                    rldnr: self.ledger.clone(),
439                    rbukrs: je.header.company_code.clone(),
440                    gjahr: je.header.fiscal_year,
441                    belnr: document_number.to_string(),
442                    docln: format!("{:06}", line.line_number),
443                    blart: je.header.document_type.clone(),
444                    budat: je.header.posting_date,
445                    bldat: je.header.document_date,
446                    cpudt: created_at.date_naive(),
447                    cputm: created_at.format("%H%M%S").to_string(),
448                    usnam: je.header.created_by.clone(),
449                    poper: je.header.fiscal_period,
450                    stgrd: None,
451                    xblnr: je.header.reference.clone(),
452                    bktxt: je.header.header_text.clone(),
453                    racct: line.gl_account.clone(),
454                    rcntr: line.cost_center.clone(),
455                    prctr: line.profit_center.clone(),
456                    segment: line.segment.clone(),
457                    rfarea: line.functional_area.clone(),
458                    rbusa: None,
459                    ps_psp_pnr: None,
460                    aufnr: None,
461                    kdauf: None,
462                    kdpos: None,
463                    pbukrs: None,
464                    pprctr: None,
465                    psegment: None,
466                    rassc: line.trading_partner.clone(),
467                    wsl,
468                    rwcur: je.header.currency.clone(),
469                    hsl,
470                    rhcur: self.local_currency.clone(),
471                    ksl: self.group_currency.as_ref().map(|_| hsl),
472                    rkcur: self.group_currency.clone(),
473                    osl: None,
474                    rocur: None,
475                    msl: line.quantity,
476                    runit: line.unit_of_measure.clone(),
477                    sgtxt: line.line_text.clone(),
478                    zuonr: line.assignment.clone(),
479                    awsys: self.source_system.clone(),
480                    awtyp: "BKPF".to_string(),
481                    awkey: format!(
482                        "{}{}{}",
483                        je.header.company_code, document_number, je.header.fiscal_year
484                    ),
485                    awitem: Some(format!("{:06}", line.line_number)),
486                    aworg: None,
487                    mwskz: line.tax_code.clone(),
488                    txjcd: None,
489                    hwbas: line.tax_amount,
490                    xstov: matches!(
491                        je.header.source,
492                        super::journal_entry::TransactionSource::Reversal
493                    ),
494                    xsauf: matches!(
495                        je.header.source,
496                        super::journal_entry::TransactionSource::Statistical
497                    ),
498                    drcrk: drcrk.to_string(),
499                    bschl: bschl.to_string(),
500                    anln1: None,
501                    anln2: None,
502                    anbwa: None,
503                    lifnr: None,
504                    kunnr: None,
505                    sim_batch_id: je.header.batch_id,
506                    sim_is_fraud: je.header.is_fraud,
507                    sim_fraud_type: je.header.fraud_type.map(|ft| format!("{:?}", ft)),
508                    sim_business_process: je.header.business_process.map(|bp| format!("{:?}", bp)),
509                    sim_user_persona: Some(je.header.user_persona.clone()),
510                    sim_je_uuid: Some(je.header.document_id),
511                    sim_control_ids: if je.header.control_ids.is_empty() {
512                        None
513                    } else {
514                        Some(je.header.control_ids.join(","))
515                    },
516                    sim_sox_relevant: je.header.sox_relevant,
517                    sim_control_status: Some(je.header.control_status.to_string()),
518                    sim_sod_violation: je.header.sod_violation,
519                    sim_sod_conflict: je.header.sod_conflict_type.map(|t| t.to_string()),
520                }
521            })
522            .collect()
523    }
524
525    /// Convert a JournalEntry into BSEG entries.
526    pub fn to_bseg_entries(&self, je: &JournalEntry, document_number: &str) -> Vec<BsegEntry> {
527        je.lines
528            .iter()
529            .map(|line| {
530                let is_debit = line.debit_amount > Decimal::ZERO;
531                let amount = if is_debit {
532                    line.debit_amount
533                } else {
534                    line.credit_amount
535                };
536
537                BsegEntry {
538                    mandt: self.client.clone(),
539                    bukrs: je.header.company_code.clone(),
540                    belnr: document_number.to_string(),
541                    gjahr: je.header.fiscal_year,
542                    buzei: format!("{:03}", line.line_number),
543                    bschl: if is_debit { "40" } else { "50" }.to_string(),
544                    hkont: line.gl_account.clone(),
545                    umskz: None,
546                    wrbtr: amount,
547                    shkzg: if is_debit { "S" } else { "H" }.to_string(),
548                    dmbtr: line.local_amount.abs(),
549                    waers: je.header.currency.clone(),
550                    wmwst: line.tax_amount,
551                    kostl: line.cost_center.clone(),
552                    prctr: line.profit_center.clone(),
553                    anln1: None,
554                    anln2: None,
555                    lifnr: None,
556                    kunnr: None,
557                    sgtxt: line.line_text.clone(),
558                    zuonr: line.assignment.clone(),
559                    mwskz: line.tax_code.clone(),
560                }
561            })
562            .collect()
563    }
564}
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569    use crate::models::journal_entry::{JournalEntryHeader, JournalEntryLine};
570
571    #[test]
572    fn test_acdoca_factory_conversion() {
573        let header = JournalEntryHeader::new(
574            "1000".to_string(),
575            NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
576        );
577        let mut je = JournalEntry::new(header);
578
579        je.add_line(JournalEntryLine::debit(
580            je.header.document_id,
581            1,
582            "100000".to_string(),
583            Decimal::from(5000),
584        ));
585        je.add_line(JournalEntryLine::credit(
586            je.header.document_id,
587            2,
588            "200000".to_string(),
589            Decimal::from(5000),
590        ));
591
592        let factory = AcdocaFactory::new("0L", "SYNTH");
593        let acdoca_entries = factory.from_journal_entry(&je, "0000000001");
594
595        assert_eq!(acdoca_entries.len(), 2);
596        assert_eq!(acdoca_entries[0].racct, "100000");
597        assert_eq!(acdoca_entries[0].drcrk, "S");
598        assert_eq!(acdoca_entries[0].wsl, Decimal::from(5000));
599        assert_eq!(acdoca_entries[1].racct, "200000");
600        assert_eq!(acdoca_entries[1].drcrk, "H");
601        assert_eq!(acdoca_entries[1].wsl, Decimal::from(-5000));
602    }
603}