Skip to main content

datasynth_core/models/documents/
document_chain.rs

1//! Document reference chain for tracking document relationships.
2//!
3//! Provides structures for tracking the relationships between documents
4//! in business processes (e.g., PO -> GR -> Invoice -> Payment).
5
6use chrono::{NaiveDate, NaiveDateTime};
7use serde::{Deserialize, Serialize};
8use uuid::Uuid;
9
10/// Type of business document.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum DocumentType {
14    // P2P Documents
15    /// Purchase Requisition
16    PurchaseRequisition,
17    /// Purchase Order
18    PurchaseOrder,
19    /// Goods Receipt
20    GoodsReceipt,
21    /// Vendor Invoice
22    VendorInvoice,
23    /// AP Payment
24    ApPayment,
25    /// Debit Memo (AP)
26    DebitMemo,
27
28    // O2C Documents
29    /// Sales Quote
30    SalesQuote,
31    /// Sales Order
32    SalesOrder,
33    /// Delivery
34    Delivery,
35    /// Customer Invoice
36    CustomerInvoice,
37    /// Customer Receipt
38    CustomerReceipt,
39    /// Credit Memo (AR)
40    CreditMemo,
41
42    // Financial Documents
43    /// Journal Entry
44    JournalEntry,
45    /// Asset Acquisition
46    AssetAcquisition,
47    /// Depreciation Run
48    DepreciationRun,
49    /// Intercompany Document
50    IntercompanyDocument,
51
52    // Other
53    /// General document
54    General,
55}
56
57impl DocumentType {
58    /// Get the document type prefix for ID generation.
59    pub fn prefix(&self) -> &'static str {
60        match self {
61            Self::PurchaseRequisition => "PR",
62            Self::PurchaseOrder => "PO",
63            Self::GoodsReceipt => "GR",
64            Self::VendorInvoice => "VI",
65            Self::ApPayment => "AP",
66            Self::DebitMemo => "DM",
67            Self::SalesQuote => "SQ",
68            Self::SalesOrder => "SO",
69            Self::Delivery => "DL",
70            Self::CustomerInvoice => "CI",
71            Self::CustomerReceipt => "CR",
72            Self::CreditMemo => "CM",
73            Self::JournalEntry => "JE",
74            Self::AssetAcquisition => "AA",
75            Self::DepreciationRun => "DR",
76            Self::IntercompanyDocument => "IC",
77            Self::General => "GN",
78        }
79    }
80
81    /// Check if this document type generates GL entries.
82    pub fn creates_gl_entry(&self) -> bool {
83        !matches!(
84            self,
85            Self::PurchaseRequisition | Self::PurchaseOrder | Self::SalesQuote | Self::SalesOrder
86        )
87    }
88
89    /// Get the business process this document belongs to.
90    pub fn business_process(&self) -> &'static str {
91        match self {
92            Self::PurchaseRequisition
93            | Self::PurchaseOrder
94            | Self::GoodsReceipt
95            | Self::VendorInvoice
96            | Self::ApPayment
97            | Self::DebitMemo => "P2P",
98
99            Self::SalesQuote
100            | Self::SalesOrder
101            | Self::Delivery
102            | Self::CustomerInvoice
103            | Self::CustomerReceipt
104            | Self::CreditMemo => "O2C",
105
106            Self::JournalEntry => "R2R",
107            Self::AssetAcquisition | Self::DepreciationRun => "A2R",
108            Self::IntercompanyDocument => "IC",
109            Self::General => "GEN",
110        }
111    }
112}
113
114/// Type of reference relationship between documents.
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
116#[serde(rename_all = "snake_case")]
117pub enum ReferenceType {
118    /// Follow-on document (normal flow: PO -> GR)
119    FollowOn,
120    /// Payment for invoice
121    Payment,
122    /// Reversal/correction of document
123    Reversal,
124    /// Partial fulfillment (partial GR, partial payment)
125    Partial,
126    /// Credit memo related to invoice
127    CreditMemo,
128    /// Debit memo related to invoice
129    DebitMemo,
130    /// Return related to delivery
131    Return,
132    /// Intercompany matching document
133    IntercompanyMatch,
134    /// Manual reference (user-defined)
135    Manual,
136}
137
138/// Reference between two documents.
139#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct DocumentReference {
141    /// Reference ID
142    pub reference_id: Uuid,
143
144    /// Source document type
145    pub source_doc_type: DocumentType,
146
147    /// Source document ID
148    pub source_doc_id: String,
149
150    /// Target document type
151    pub target_doc_type: DocumentType,
152
153    /// Target document ID
154    pub target_doc_id: String,
155
156    /// Type of reference relationship
157    pub reference_type: ReferenceType,
158
159    /// Company code
160    pub company_code: String,
161
162    /// Date the reference was created
163    pub reference_date: NaiveDate,
164
165    /// Description/notes
166    pub description: Option<String>,
167
168    /// Amount covered by this reference (for partial references)
169    pub reference_amount: Option<rust_decimal::Decimal>,
170}
171
172impl DocumentReference {
173    /// Create a new document reference.
174    pub fn new(
175        source_type: DocumentType,
176        source_id: impl Into<String>,
177        target_type: DocumentType,
178        target_id: impl Into<String>,
179        ref_type: ReferenceType,
180        company_code: impl Into<String>,
181        date: NaiveDate,
182    ) -> Self {
183        Self {
184            reference_id: Uuid::new_v4(),
185            source_doc_type: source_type,
186            source_doc_id: source_id.into(),
187            target_doc_type: target_type,
188            target_doc_id: target_id.into(),
189            reference_type: ref_type,
190            company_code: company_code.into(),
191            reference_date: date,
192            description: None,
193            reference_amount: None,
194        }
195    }
196
197    /// Create a follow-on reference.
198    pub fn follow_on(
199        source_type: DocumentType,
200        source_id: impl Into<String>,
201        target_type: DocumentType,
202        target_id: impl Into<String>,
203        company_code: impl Into<String>,
204        date: NaiveDate,
205    ) -> Self {
206        Self::new(
207            source_type,
208            source_id,
209            target_type,
210            target_id,
211            ReferenceType::FollowOn,
212            company_code,
213            date,
214        )
215    }
216
217    /// Create a payment reference.
218    pub fn payment(
219        invoice_type: DocumentType,
220        invoice_id: impl Into<String>,
221        payment_id: impl Into<String>,
222        company_code: impl Into<String>,
223        date: NaiveDate,
224        amount: rust_decimal::Decimal,
225    ) -> Self {
226        let payment_type = match invoice_type {
227            DocumentType::VendorInvoice => DocumentType::ApPayment,
228            DocumentType::CustomerInvoice => DocumentType::CustomerReceipt,
229            _ => DocumentType::ApPayment,
230        };
231
232        let mut reference = Self::new(
233            invoice_type,
234            invoice_id,
235            payment_type,
236            payment_id,
237            ReferenceType::Payment,
238            company_code,
239            date,
240        );
241        reference.reference_amount = Some(amount);
242        reference
243    }
244
245    /// Set description.
246    pub fn with_description(mut self, desc: impl Into<String>) -> Self {
247        self.description = Some(desc.into());
248        self
249    }
250
251    /// Set reference amount.
252    pub fn with_amount(mut self, amount: rust_decimal::Decimal) -> Self {
253        self.reference_amount = Some(amount);
254        self
255    }
256}
257
258/// Document status in workflow.
259#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
260#[serde(rename_all = "snake_case")]
261pub enum DocumentStatus {
262    /// Draft/not yet released
263    #[default]
264    Draft,
265    /// Submitted for approval
266    Submitted,
267    /// Pending approval
268    PendingApproval,
269    /// Approved
270    Approved,
271    /// Rejected
272    Rejected,
273    /// Released for processing
274    Released,
275    /// Partially processed
276    PartiallyProcessed,
277    /// Fully processed/completed
278    Completed,
279    /// Cancelled/voided
280    Cancelled,
281    /// Posted to GL
282    Posted,
283    /// Cleared (for open items)
284    Cleared,
285}
286
287impl DocumentStatus {
288    /// Check if document can be modified.
289    pub fn is_editable(&self) -> bool {
290        matches!(self, Self::Draft | Self::Rejected)
291    }
292
293    /// Check if document can be cancelled.
294    pub fn can_cancel(&self) -> bool {
295        !matches!(self, Self::Cancelled | Self::Cleared | Self::Completed)
296    }
297
298    /// Check if document needs approval.
299    pub fn needs_approval(&self) -> bool {
300        matches!(self, Self::Submitted | Self::PendingApproval)
301    }
302}
303
304/// Common document header fields.
305#[derive(Debug, Clone, Serialize, Deserialize)]
306pub struct DocumentHeader {
307    /// Unique document ID
308    pub document_id: String,
309
310    /// Document type
311    pub document_type: DocumentType,
312
313    /// Company code
314    pub company_code: String,
315
316    /// Fiscal year
317    pub fiscal_year: u16,
318
319    /// Fiscal period
320    pub fiscal_period: u8,
321
322    /// Document date
323    pub document_date: NaiveDate,
324
325    /// Posting date (if applicable)
326    pub posting_date: Option<NaiveDate>,
327
328    /// Entry date (when document was created)
329    pub entry_date: NaiveDate,
330
331    /// Entry timestamp
332    #[serde(with = "crate::serde_timestamp::naive")]
333    pub entry_timestamp: NaiveDateTime,
334
335    /// Document status
336    pub status: DocumentStatus,
337
338    /// Created by user
339    pub created_by: String,
340
341    /// Last changed by user
342    pub changed_by: Option<String>,
343
344    /// Last change timestamp
345    #[serde(default, with = "crate::serde_timestamp::naive::option")]
346    pub changed_at: Option<NaiveDateTime>,
347
348    /// Employee ID of the creator (bridges user_id ↔ employee_id)
349    ///
350    /// `created_by` stores the user login (e.g. "JSMITH") while
351    /// employee nodes use `employee_id` (e.g. "E-001234"). This
352    /// field stores the employee_id when it is known at generation
353    /// time, allowing the export pipeline to emit
354    /// `DOC_CREATED_BY` edges directly without an expensive lookup.
355    #[serde(default, skip_serializing_if = "Option::is_none")]
356    pub created_by_employee_id: Option<String>,
357
358    /// Currency
359    pub currency: String,
360
361    /// Reference number (external)
362    pub reference: Option<String>,
363
364    /// Header text
365    pub header_text: Option<String>,
366
367    /// Related journal entry ID (if posted to GL)
368    pub journal_entry_id: Option<String>,
369
370    /// References to other documents
371    pub document_references: Vec<DocumentReference>,
372
373    /// Whether this document is part of a fraud scenario.
374    /// Propagated from the corresponding journal entry after anomaly injection.
375    #[serde(default)]
376    pub is_fraud: bool,
377
378    /// Fraud type if applicable (propagated from journal entry).
379    #[serde(default, skip_serializing_if = "Option::is_none")]
380    pub fraud_type: Option<crate::models::FraudType>,
381}
382
383impl DocumentHeader {
384    /// Create a new document header.
385    pub fn new(
386        document_id: impl Into<String>,
387        document_type: DocumentType,
388        company_code: impl Into<String>,
389        fiscal_year: u16,
390        fiscal_period: u8,
391        document_date: NaiveDate,
392        created_by: impl Into<String>,
393    ) -> Self {
394        let now = chrono::Utc::now().naive_utc();
395        Self {
396            document_id: document_id.into(),
397            document_type,
398            company_code: company_code.into(),
399            fiscal_year,
400            fiscal_period,
401            document_date,
402            posting_date: None,
403            entry_date: document_date,
404            entry_timestamp: now,
405            status: DocumentStatus::Draft,
406            created_by: created_by.into(),
407            changed_by: None,
408            changed_at: None,
409            created_by_employee_id: None,
410            currency: "USD".to_string(),
411            reference: None,
412            header_text: None,
413            journal_entry_id: None,
414            document_references: Vec::new(),
415            is_fraud: false,
416            fraud_type: None,
417        }
418    }
419
420    /// Propagate fraud labels from a fraud map (document_id/journal_entry_id -> FraudType).
421    /// Returns true if this document was tagged as fraudulent.
422    pub fn propagate_fraud(
423        &mut self,
424        fraud_map: &std::collections::HashMap<String, crate::models::FraudType>,
425    ) -> bool {
426        if let Some(ft) = fraud_map.get(&self.document_id) {
427            self.is_fraud = true;
428            self.fraud_type = Some(*ft);
429            return true;
430        }
431        if let Some(ref je_id) = self.journal_entry_id {
432            if let Some(ft) = fraud_map.get(je_id) {
433                self.is_fraud = true;
434                self.fraud_type = Some(*ft);
435                return true;
436            }
437        }
438        false
439    }
440
441    /// Set the employee ID of the document creator.
442    pub fn with_created_by_employee_id(mut self, employee_id: impl Into<String>) -> Self {
443        self.created_by_employee_id = Some(employee_id.into());
444        self
445    }
446
447    /// Set posting date.
448    pub fn with_posting_date(mut self, date: NaiveDate) -> Self {
449        self.posting_date = Some(date);
450        self
451    }
452
453    /// Set currency.
454    pub fn with_currency(mut self, currency: impl Into<String>) -> Self {
455        self.currency = currency.into();
456        self
457    }
458
459    /// Set reference.
460    pub fn with_reference(mut self, reference: impl Into<String>) -> Self {
461        self.reference = Some(reference.into());
462        self
463    }
464
465    /// Set header text.
466    pub fn with_header_text(mut self, text: impl Into<String>) -> Self {
467        self.header_text = Some(text.into());
468        self
469    }
470
471    /// Add a document reference.
472    pub fn add_reference(&mut self, reference: DocumentReference) {
473        self.document_references.push(reference);
474    }
475
476    /// Update status and record change.
477    pub fn update_status(&mut self, new_status: DocumentStatus, user: impl Into<String>) {
478        self.status = new_status;
479        self.changed_by = Some(user.into());
480        self.changed_at = Some(chrono::Utc::now().naive_utc());
481    }
482
483    /// Generate a deterministic document ID.
484    pub fn generate_id(doc_type: DocumentType, company_code: &str, sequence: u64) -> String {
485        format!("{}-{}-{:010}", doc_type.prefix(), company_code, sequence)
486    }
487}
488
489/// Document line item common fields.
490#[derive(Debug, Clone, Serialize, Deserialize)]
491pub struct DocumentLineItem {
492    /// Line item number
493    pub line_number: u16,
494
495    /// Material/service ID (if applicable)
496    pub material_id: Option<String>,
497
498    /// Description
499    pub description: String,
500
501    /// Quantity
502    pub quantity: rust_decimal::Decimal,
503
504    /// Unit of measure
505    pub uom: String,
506
507    /// Unit price
508    pub unit_price: rust_decimal::Decimal,
509
510    /// Net amount (quantity * unit_price)
511    pub net_amount: rust_decimal::Decimal,
512
513    /// Tax amount
514    pub tax_amount: rust_decimal::Decimal,
515
516    /// Gross amount (net + tax)
517    pub gross_amount: rust_decimal::Decimal,
518
519    /// GL account (for posting)
520    pub gl_account: Option<String>,
521
522    /// Cost center
523    pub cost_center: Option<String>,
524
525    /// Profit center
526    pub profit_center: Option<String>,
527
528    /// Internal order
529    pub internal_order: Option<String>,
530
531    /// WBS element
532    pub wbs_element: Option<String>,
533
534    /// Delivery date (for scheduling)
535    pub delivery_date: Option<NaiveDate>,
536
537    /// Plant/location
538    pub plant: Option<String>,
539
540    /// Storage location
541    pub storage_location: Option<String>,
542
543    /// Line text
544    pub line_text: Option<String>,
545
546    /// Is this line cancelled?
547    pub is_cancelled: bool,
548}
549
550impl DocumentLineItem {
551    /// Create a new line item.
552    pub fn new(
553        line_number: u16,
554        description: impl Into<String>,
555        quantity: rust_decimal::Decimal,
556        unit_price: rust_decimal::Decimal,
557    ) -> Self {
558        let net_amount = quantity * unit_price;
559        Self {
560            line_number,
561            material_id: None,
562            description: description.into(),
563            quantity,
564            uom: "EA".to_string(),
565            unit_price,
566            net_amount,
567            tax_amount: rust_decimal::Decimal::ZERO,
568            gross_amount: net_amount,
569            gl_account: None,
570            cost_center: None,
571            profit_center: None,
572            internal_order: None,
573            wbs_element: None,
574            delivery_date: None,
575            plant: None,
576            storage_location: None,
577            line_text: None,
578            is_cancelled: false,
579        }
580    }
581
582    /// Set material ID.
583    pub fn with_material(mut self, material_id: impl Into<String>) -> Self {
584        self.material_id = Some(material_id.into());
585        self
586    }
587
588    /// Set GL account.
589    pub fn with_gl_account(mut self, account: impl Into<String>) -> Self {
590        self.gl_account = Some(account.into());
591        self
592    }
593
594    /// Set cost center.
595    pub fn with_cost_center(mut self, cost_center: impl Into<String>) -> Self {
596        self.cost_center = Some(cost_center.into());
597        self
598    }
599
600    /// Set tax amount and recalculate gross.
601    pub fn with_tax(mut self, tax_amount: rust_decimal::Decimal) -> Self {
602        self.tax_amount = tax_amount;
603        self.gross_amount = self.net_amount + tax_amount;
604        self
605    }
606
607    /// Set UOM.
608    pub fn with_uom(mut self, uom: impl Into<String>) -> Self {
609        self.uom = uom.into();
610        self
611    }
612
613    /// Set delivery date.
614    pub fn with_delivery_date(mut self, date: NaiveDate) -> Self {
615        self.delivery_date = Some(date);
616        self
617    }
618
619    /// Recalculate amounts.
620    pub fn recalculate(&mut self) {
621        self.net_amount = self.quantity * self.unit_price;
622        self.gross_amount = self.net_amount + self.tax_amount;
623    }
624}
625
626#[cfg(test)]
627#[allow(clippy::unwrap_used)]
628mod tests {
629    use super::*;
630
631    #[test]
632    fn test_document_type_prefix() {
633        assert_eq!(DocumentType::PurchaseOrder.prefix(), "PO");
634        assert_eq!(DocumentType::VendorInvoice.prefix(), "VI");
635        assert_eq!(DocumentType::CustomerInvoice.prefix(), "CI");
636    }
637
638    #[test]
639    fn test_document_reference() {
640        let reference = DocumentReference::follow_on(
641            DocumentType::PurchaseOrder,
642            "PO-1000-0000000001",
643            DocumentType::GoodsReceipt,
644            "GR-1000-0000000001",
645            "1000",
646            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
647        );
648
649        assert_eq!(reference.reference_type, ReferenceType::FollowOn);
650        assert_eq!(reference.source_doc_type, DocumentType::PurchaseOrder);
651    }
652
653    #[test]
654    fn test_document_header() {
655        let header = DocumentHeader::new(
656            "PO-1000-0000000001",
657            DocumentType::PurchaseOrder,
658            "1000",
659            2024,
660            1,
661            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
662            "JSMITH",
663        )
664        .with_currency("EUR")
665        .with_reference("EXT-REF-123");
666
667        assert_eq!(header.currency, "EUR");
668        assert_eq!(header.reference, Some("EXT-REF-123".to_string()));
669        assert_eq!(header.status, DocumentStatus::Draft);
670    }
671
672    #[test]
673    fn test_document_line_item() {
674        let item = DocumentLineItem::new(
675            1,
676            "Office Supplies",
677            rust_decimal::Decimal::from(10),
678            rust_decimal::Decimal::from(25),
679        )
680        .with_tax(rust_decimal::Decimal::from(25));
681
682        assert_eq!(item.net_amount, rust_decimal::Decimal::from(250));
683        assert_eq!(item.gross_amount, rust_decimal::Decimal::from(275));
684    }
685
686    #[test]
687    fn test_document_status() {
688        assert!(DocumentStatus::Draft.is_editable());
689        assert!(!DocumentStatus::Posted.is_editable());
690        assert!(DocumentStatus::Released.can_cancel());
691        assert!(!DocumentStatus::Cancelled.can_cancel());
692    }
693
694    /// Regression test for issue #104: fraud_map previously only contained the
695    /// "PREFIX:DOC_ID" form (e.g. "GR:PO-2024-000001") but propagate_fraud
696    /// looks up by the bare `document_id` ("PO-2024-000001"). Every lookup
697    /// missed → 0 documents tagged. Fix: orchestrator now also registers the
698    /// bare form by splitting on ":".
699    ///
700    /// This test asserts that when a fraud_map is built from prefixed refs,
701    /// propagate_fraud can find the document via the bare-ID key.
702    #[test]
703    fn test_propagate_fraud_via_bare_document_id() {
704        use crate::models::FraudType;
705
706        let mut header = DocumentHeader::new(
707            "PO-2024-000001",
708            DocumentType::PurchaseOrder,
709            "1000",
710            2024,
711            6,
712            NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
713            "JSMITH",
714        );
715
716        // Build fraud_map the way the orchestrator does — register BOTH the
717        // prefixed form (raw reference) AND the bare form (post-colon).
718        let raw_reference = "GR:PO-2024-000001";
719        let mut fraud_map = std::collections::HashMap::new();
720        fraud_map.insert(raw_reference.to_string(), FraudType::DuplicatePayment);
721        if let Some((_, bare)) = raw_reference.split_once(':') {
722            fraud_map.insert(bare.to_string(), FraudType::DuplicatePayment);
723        }
724
725        assert!(
726            header.propagate_fraud(&fraud_map),
727            "propagate_fraud should find the bare document_id ({}) in fraud_map",
728            header.document_id,
729        );
730        assert!(header.is_fraud);
731        assert_eq!(header.fraud_type, Some(FraudType::DuplicatePayment));
732    }
733
734    /// Regression test: a fraud_map that ONLY has the prefixed form (without
735    /// the bare-form registration) should NOT propagate. This is what the old
736    /// orchestrator did and why #104 was silent.
737    #[test]
738    fn test_propagate_fraud_only_prefixed_form_misses() {
739        use crate::models::FraudType;
740
741        let mut header = DocumentHeader::new(
742            "PAY-2024-000001",
743            DocumentType::ApPayment,
744            "1000",
745            2024,
746            6,
747            NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
748            "JSMITH",
749        );
750        let mut fraud_map = std::collections::HashMap::new();
751        // Only prefixed form — simulates the old (buggy) orchestrator
752        fraud_map.insert(
753            "PAY:PAY-2024-000001".to_string(),
754            FraudType::DuplicatePayment,
755        );
756        assert!(
757            !header.propagate_fraud(&fraud_map),
758            "Prefixed-only fraud_map should NOT match bare document_id — this is the bug we fixed"
759        );
760        assert!(!header.is_fraud);
761    }
762}