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