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    pub entry_timestamp: NaiveDateTime,
333
334    /// Document status
335    pub status: DocumentStatus,
336
337    /// Created by user
338    pub created_by: String,
339
340    /// Last changed by user
341    pub changed_by: Option<String>,
342
343    /// Last change timestamp
344    pub changed_at: Option<NaiveDateTime>,
345
346    /// Employee ID of the creator (bridges user_id ↔ employee_id)
347    ///
348    /// `created_by` stores the user login (e.g. "JSMITH") while
349    /// employee nodes use `employee_id` (e.g. "E-001234"). This
350    /// field stores the employee_id when it is known at generation
351    /// time, allowing the export pipeline to emit
352    /// `DOC_CREATED_BY` edges directly without an expensive lookup.
353    #[serde(default, skip_serializing_if = "Option::is_none")]
354    pub created_by_employee_id: Option<String>,
355
356    /// Currency
357    pub currency: String,
358
359    /// Reference number (external)
360    pub reference: Option<String>,
361
362    /// Header text
363    pub header_text: Option<String>,
364
365    /// Related journal entry ID (if posted to GL)
366    pub journal_entry_id: Option<String>,
367
368    /// References to other documents
369    pub document_references: Vec<DocumentReference>,
370}
371
372impl DocumentHeader {
373    /// Create a new document header.
374    pub fn new(
375        document_id: impl Into<String>,
376        document_type: DocumentType,
377        company_code: impl Into<String>,
378        fiscal_year: u16,
379        fiscal_period: u8,
380        document_date: NaiveDate,
381        created_by: impl Into<String>,
382    ) -> Self {
383        let now = chrono::Utc::now().naive_utc();
384        Self {
385            document_id: document_id.into(),
386            document_type,
387            company_code: company_code.into(),
388            fiscal_year,
389            fiscal_period,
390            document_date,
391            posting_date: None,
392            entry_date: document_date,
393            entry_timestamp: now,
394            status: DocumentStatus::Draft,
395            created_by: created_by.into(),
396            changed_by: None,
397            changed_at: None,
398            created_by_employee_id: None,
399            currency: "USD".to_string(),
400            reference: None,
401            header_text: None,
402            journal_entry_id: None,
403            document_references: Vec::new(),
404        }
405    }
406
407    /// Set the employee ID of the document creator.
408    pub fn with_created_by_employee_id(mut self, employee_id: impl Into<String>) -> Self {
409        self.created_by_employee_id = Some(employee_id.into());
410        self
411    }
412
413    /// Set posting date.
414    pub fn with_posting_date(mut self, date: NaiveDate) -> Self {
415        self.posting_date = Some(date);
416        self
417    }
418
419    /// Set currency.
420    pub fn with_currency(mut self, currency: impl Into<String>) -> Self {
421        self.currency = currency.into();
422        self
423    }
424
425    /// Set reference.
426    pub fn with_reference(mut self, reference: impl Into<String>) -> Self {
427        self.reference = Some(reference.into());
428        self
429    }
430
431    /// Set header text.
432    pub fn with_header_text(mut self, text: impl Into<String>) -> Self {
433        self.header_text = Some(text.into());
434        self
435    }
436
437    /// Add a document reference.
438    pub fn add_reference(&mut self, reference: DocumentReference) {
439        self.document_references.push(reference);
440    }
441
442    /// Update status and record change.
443    pub fn update_status(&mut self, new_status: DocumentStatus, user: impl Into<String>) {
444        self.status = new_status;
445        self.changed_by = Some(user.into());
446        self.changed_at = Some(chrono::Utc::now().naive_utc());
447    }
448
449    /// Generate a deterministic document ID.
450    pub fn generate_id(doc_type: DocumentType, company_code: &str, sequence: u64) -> String {
451        format!("{}-{}-{:010}", doc_type.prefix(), company_code, sequence)
452    }
453}
454
455/// Document line item common fields.
456#[derive(Debug, Clone, Serialize, Deserialize)]
457pub struct DocumentLineItem {
458    /// Line item number
459    pub line_number: u16,
460
461    /// Material/service ID (if applicable)
462    pub material_id: Option<String>,
463
464    /// Description
465    pub description: String,
466
467    /// Quantity
468    pub quantity: rust_decimal::Decimal,
469
470    /// Unit of measure
471    pub uom: String,
472
473    /// Unit price
474    pub unit_price: rust_decimal::Decimal,
475
476    /// Net amount (quantity * unit_price)
477    pub net_amount: rust_decimal::Decimal,
478
479    /// Tax amount
480    pub tax_amount: rust_decimal::Decimal,
481
482    /// Gross amount (net + tax)
483    pub gross_amount: rust_decimal::Decimal,
484
485    /// GL account (for posting)
486    pub gl_account: Option<String>,
487
488    /// Cost center
489    pub cost_center: Option<String>,
490
491    /// Profit center
492    pub profit_center: Option<String>,
493
494    /// Internal order
495    pub internal_order: Option<String>,
496
497    /// WBS element
498    pub wbs_element: Option<String>,
499
500    /// Delivery date (for scheduling)
501    pub delivery_date: Option<NaiveDate>,
502
503    /// Plant/location
504    pub plant: Option<String>,
505
506    /// Storage location
507    pub storage_location: Option<String>,
508
509    /// Line text
510    pub line_text: Option<String>,
511
512    /// Is this line cancelled?
513    pub is_cancelled: bool,
514}
515
516impl DocumentLineItem {
517    /// Create a new line item.
518    pub fn new(
519        line_number: u16,
520        description: impl Into<String>,
521        quantity: rust_decimal::Decimal,
522        unit_price: rust_decimal::Decimal,
523    ) -> Self {
524        let net_amount = quantity * unit_price;
525        Self {
526            line_number,
527            material_id: None,
528            description: description.into(),
529            quantity,
530            uom: "EA".to_string(),
531            unit_price,
532            net_amount,
533            tax_amount: rust_decimal::Decimal::ZERO,
534            gross_amount: net_amount,
535            gl_account: None,
536            cost_center: None,
537            profit_center: None,
538            internal_order: None,
539            wbs_element: None,
540            delivery_date: None,
541            plant: None,
542            storage_location: None,
543            line_text: None,
544            is_cancelled: false,
545        }
546    }
547
548    /// Set material ID.
549    pub fn with_material(mut self, material_id: impl Into<String>) -> Self {
550        self.material_id = Some(material_id.into());
551        self
552    }
553
554    /// Set GL account.
555    pub fn with_gl_account(mut self, account: impl Into<String>) -> Self {
556        self.gl_account = Some(account.into());
557        self
558    }
559
560    /// Set cost center.
561    pub fn with_cost_center(mut self, cost_center: impl Into<String>) -> Self {
562        self.cost_center = Some(cost_center.into());
563        self
564    }
565
566    /// Set tax amount and recalculate gross.
567    pub fn with_tax(mut self, tax_amount: rust_decimal::Decimal) -> Self {
568        self.tax_amount = tax_amount;
569        self.gross_amount = self.net_amount + tax_amount;
570        self
571    }
572
573    /// Set UOM.
574    pub fn with_uom(mut self, uom: impl Into<String>) -> Self {
575        self.uom = uom.into();
576        self
577    }
578
579    /// Set delivery date.
580    pub fn with_delivery_date(mut self, date: NaiveDate) -> Self {
581        self.delivery_date = Some(date);
582        self
583    }
584
585    /// Recalculate amounts.
586    pub fn recalculate(&mut self) {
587        self.net_amount = self.quantity * self.unit_price;
588        self.gross_amount = self.net_amount + self.tax_amount;
589    }
590}
591
592#[cfg(test)]
593#[allow(clippy::unwrap_used)]
594mod tests {
595    use super::*;
596
597    #[test]
598    fn test_document_type_prefix() {
599        assert_eq!(DocumentType::PurchaseOrder.prefix(), "PO");
600        assert_eq!(DocumentType::VendorInvoice.prefix(), "VI");
601        assert_eq!(DocumentType::CustomerInvoice.prefix(), "CI");
602    }
603
604    #[test]
605    fn test_document_reference() {
606        let reference = DocumentReference::follow_on(
607            DocumentType::PurchaseOrder,
608            "PO-1000-0000000001",
609            DocumentType::GoodsReceipt,
610            "GR-1000-0000000001",
611            "1000",
612            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
613        );
614
615        assert_eq!(reference.reference_type, ReferenceType::FollowOn);
616        assert_eq!(reference.source_doc_type, DocumentType::PurchaseOrder);
617    }
618
619    #[test]
620    fn test_document_header() {
621        let header = DocumentHeader::new(
622            "PO-1000-0000000001",
623            DocumentType::PurchaseOrder,
624            "1000",
625            2024,
626            1,
627            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
628            "JSMITH",
629        )
630        .with_currency("EUR")
631        .with_reference("EXT-REF-123");
632
633        assert_eq!(header.currency, "EUR");
634        assert_eq!(header.reference, Some("EXT-REF-123".to_string()));
635        assert_eq!(header.status, DocumentStatus::Draft);
636    }
637
638    #[test]
639    fn test_document_line_item() {
640        let item = DocumentLineItem::new(
641            1,
642            "Office Supplies",
643            rust_decimal::Decimal::from(10),
644            rust_decimal::Decimal::from(25),
645        )
646        .with_tax(rust_decimal::Decimal::from(25));
647
648        assert_eq!(item.net_amount, rust_decimal::Decimal::from(250));
649        assert_eq!(item.gross_amount, rust_decimal::Decimal::from(275));
650    }
651
652    #[test]
653    fn test_document_status() {
654        assert!(DocumentStatus::Draft.is_editable());
655        assert!(!DocumentStatus::Posted.is_editable());
656        assert!(DocumentStatus::Released.can_cancel());
657        assert!(!DocumentStatus::Cancelled.can_cancel());
658    }
659}