datasynth_core/models/documents/
sales_order.rs

1//! Sales Order document model.
2//!
3//! Represents sales orders in the O2C (Order-to-Cash) process flow.
4
5use chrono::NaiveDate;
6use rust_decimal::Decimal;
7use serde::{Deserialize, Serialize};
8
9use super::{DocumentHeader, DocumentLineItem, DocumentStatus, DocumentType};
10
11/// Sales Order type.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
13#[serde(rename_all = "snake_case")]
14pub enum SalesOrderType {
15    /// Standard sales order
16    #[default]
17    Standard,
18    /// Rush order
19    Rush,
20    /// Cash sale
21    CashSale,
22    /// Return order
23    Return,
24    /// Free of charge delivery
25    FreeOfCharge,
26    /// Consignment order
27    Consignment,
28    /// Service order
29    Service,
30    /// Credit memo request
31    CreditMemoRequest,
32    /// Debit memo request
33    DebitMemoRequest,
34}
35
36impl SalesOrderType {
37    /// Check if this order type requires delivery.
38    pub fn requires_delivery(&self) -> bool {
39        !matches!(
40            self,
41            Self::Service | Self::CreditMemoRequest | Self::DebitMemoRequest
42        )
43    }
44
45    /// Check if this creates revenue.
46    pub fn creates_revenue(&self) -> bool {
47        !matches!(
48            self,
49            Self::FreeOfCharge | Self::Return | Self::CreditMemoRequest
50        )
51    }
52}
53
54/// Sales Order item.
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct SalesOrderItem {
57    /// Base line item fields
58    #[serde(flatten)]
59    pub base: DocumentLineItem,
60
61    /// Item category
62    pub item_category: String,
63
64    /// Schedule line (delivery schedule)
65    pub schedule_lines: Vec<ScheduleLine>,
66
67    /// Quantity confirmed
68    pub quantity_confirmed: Decimal,
69
70    /// Quantity delivered
71    pub quantity_delivered: Decimal,
72
73    /// Quantity invoiced
74    pub quantity_invoiced: Decimal,
75
76    /// Is this line fully delivered?
77    pub is_fully_delivered: bool,
78
79    /// Is this line fully invoiced?
80    pub is_fully_invoiced: bool,
81
82    /// Rejection reason (if rejected)
83    pub rejection_reason: Option<String>,
84
85    /// Is this line rejected?
86    pub is_rejected: bool,
87
88    /// Route
89    pub route: Option<String>,
90
91    /// Shipping point
92    pub shipping_point: Option<String>,
93}
94
95/// Schedule line for delivery.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct ScheduleLine {
98    /// Schedule line number
99    pub schedule_number: u16,
100    /// Requested delivery date
101    pub requested_date: NaiveDate,
102    /// Confirmed delivery date
103    pub confirmed_date: Option<NaiveDate>,
104    /// Scheduled quantity
105    pub quantity: Decimal,
106    /// Delivered quantity
107    pub delivered_quantity: Decimal,
108}
109
110impl SalesOrderItem {
111    /// Create a new sales order item.
112    #[allow(clippy::too_many_arguments)]
113    pub fn new(
114        line_number: u16,
115        description: impl Into<String>,
116        quantity: Decimal,
117        unit_price: Decimal,
118    ) -> Self {
119        Self {
120            base: DocumentLineItem::new(line_number, description, quantity, unit_price),
121            item_category: "TAN".to_string(), // Standard item
122            schedule_lines: Vec::new(),
123            quantity_confirmed: Decimal::ZERO,
124            quantity_delivered: Decimal::ZERO,
125            quantity_invoiced: Decimal::ZERO,
126            is_fully_delivered: false,
127            is_fully_invoiced: false,
128            rejection_reason: None,
129            is_rejected: false,
130            route: None,
131            shipping_point: None,
132        }
133    }
134
135    /// Set material.
136    pub fn with_material(mut self, material_id: impl Into<String>) -> Self {
137        self.base = self.base.with_material(material_id);
138        self
139    }
140
141    /// Set plant.
142    pub fn with_plant(mut self, plant: impl Into<String>) -> Self {
143        self.base.plant = Some(plant.into());
144        self
145    }
146
147    /// Add a schedule line.
148    pub fn add_schedule_line(&mut self, requested_date: NaiveDate, quantity: Decimal) {
149        let schedule_number = (self.schedule_lines.len() + 1) as u16;
150        self.schedule_lines.push(ScheduleLine {
151            schedule_number,
152            requested_date,
153            confirmed_date: None,
154            quantity,
155            delivered_quantity: Decimal::ZERO,
156        });
157    }
158
159    /// Confirm the schedule line.
160    pub fn confirm_schedule(&mut self, schedule_number: u16, confirmed_date: NaiveDate) {
161        if let Some(line) = self
162            .schedule_lines
163            .iter_mut()
164            .find(|l| l.schedule_number == schedule_number)
165        {
166            line.confirmed_date = Some(confirmed_date);
167            self.quantity_confirmed += line.quantity;
168        }
169    }
170
171    /// Record delivery.
172    pub fn record_delivery(&mut self, quantity: Decimal) {
173        self.quantity_delivered += quantity;
174        if self.quantity_delivered >= self.base.quantity {
175            self.is_fully_delivered = true;
176        }
177    }
178
179    /// Record invoice.
180    pub fn record_invoice(&mut self, quantity: Decimal) {
181        self.quantity_invoiced += quantity;
182        if self.quantity_invoiced >= self.base.quantity {
183            self.is_fully_invoiced = true;
184        }
185    }
186
187    /// Open quantity for delivery.
188    pub fn open_quantity_delivery(&self) -> Decimal {
189        (self.base.quantity - self.quantity_delivered).max(Decimal::ZERO)
190    }
191
192    /// Open quantity for billing.
193    pub fn open_quantity_billing(&self) -> Decimal {
194        (self.quantity_delivered - self.quantity_invoiced).max(Decimal::ZERO)
195    }
196
197    /// Reject the line.
198    pub fn reject(&mut self, reason: impl Into<String>) {
199        self.is_rejected = true;
200        self.rejection_reason = Some(reason.into());
201    }
202}
203
204/// Sales Order document.
205#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct SalesOrder {
207    /// Document header
208    pub header: DocumentHeader,
209
210    /// SO type
211    pub so_type: SalesOrderType,
212
213    /// Customer ID
214    pub customer_id: String,
215
216    /// Sold-to party (if different from customer)
217    pub sold_to: Option<String>,
218
219    /// Ship-to party
220    pub ship_to: Option<String>,
221
222    /// Bill-to party
223    pub bill_to: Option<String>,
224
225    /// Payer
226    pub payer: Option<String>,
227
228    /// Sales organization
229    pub sales_org: String,
230
231    /// Distribution channel
232    pub distribution_channel: String,
233
234    /// Division
235    pub division: String,
236
237    /// Sales office
238    pub sales_office: Option<String>,
239
240    /// Sales group
241    pub sales_group: Option<String>,
242
243    /// Line items
244    pub items: Vec<SalesOrderItem>,
245
246    /// Total net amount
247    pub total_net_amount: Decimal,
248
249    /// Total tax amount
250    pub total_tax_amount: Decimal,
251
252    /// Total gross amount
253    pub total_gross_amount: Decimal,
254
255    /// Payment terms
256    pub payment_terms: String,
257
258    /// Incoterms
259    pub incoterms: Option<String>,
260
261    /// Shipping condition
262    pub shipping_condition: Option<String>,
263
264    /// Requested delivery date
265    pub requested_delivery_date: Option<NaiveDate>,
266
267    /// Customer PO number
268    pub customer_po_number: Option<String>,
269
270    /// Is this order complete?
271    pub is_complete: bool,
272
273    /// Credit status
274    pub credit_status: CreditStatus,
275
276    /// Credit block reason
277    pub credit_block_reason: Option<String>,
278
279    /// Is order released for delivery?
280    pub is_delivery_released: bool,
281
282    /// Is order released for billing?
283    pub is_billing_released: bool,
284
285    /// Related quote (if from quote)
286    pub quote_id: Option<String>,
287
288    /// Contract reference
289    pub contract_id: Option<String>,
290}
291
292/// Credit check status.
293#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
294#[serde(rename_all = "snake_case")]
295pub enum CreditStatus {
296    /// Not checked
297    #[default]
298    NotChecked,
299    /// Passed
300    Passed,
301    /// Failed - blocked
302    Failed,
303    /// Manually released
304    Released,
305}
306
307impl SalesOrder {
308    /// Create a new sales order.
309    pub fn new(
310        so_id: impl Into<String>,
311        company_code: impl Into<String>,
312        customer_id: impl Into<String>,
313        fiscal_year: u16,
314        fiscal_period: u8,
315        document_date: NaiveDate,
316        created_by: impl Into<String>,
317    ) -> Self {
318        let header = DocumentHeader::new(
319            so_id,
320            DocumentType::SalesOrder,
321            company_code,
322            fiscal_year,
323            fiscal_period,
324            document_date,
325            created_by,
326        );
327
328        Self {
329            header,
330            so_type: SalesOrderType::Standard,
331            customer_id: customer_id.into(),
332            sold_to: None,
333            ship_to: None,
334            bill_to: None,
335            payer: None,
336            sales_org: "1000".to_string(),
337            distribution_channel: "10".to_string(),
338            division: "00".to_string(),
339            sales_office: None,
340            sales_group: None,
341            items: Vec::new(),
342            total_net_amount: Decimal::ZERO,
343            total_tax_amount: Decimal::ZERO,
344            total_gross_amount: Decimal::ZERO,
345            payment_terms: "NET30".to_string(),
346            incoterms: None,
347            shipping_condition: None,
348            requested_delivery_date: None,
349            customer_po_number: None,
350            is_complete: false,
351            credit_status: CreditStatus::NotChecked,
352            credit_block_reason: None,
353            is_delivery_released: false,
354            is_billing_released: false,
355            quote_id: None,
356            contract_id: None,
357        }
358    }
359
360    /// Set SO type.
361    pub fn with_so_type(mut self, so_type: SalesOrderType) -> Self {
362        self.so_type = so_type;
363        self
364    }
365
366    /// Set sales organization.
367    pub fn with_sales_org(
368        mut self,
369        sales_org: impl Into<String>,
370        dist_channel: impl Into<String>,
371        division: impl Into<String>,
372    ) -> Self {
373        self.sales_org = sales_org.into();
374        self.distribution_channel = dist_channel.into();
375        self.division = division.into();
376        self
377    }
378
379    /// Set partner functions.
380    pub fn with_partners(
381        mut self,
382        sold_to: impl Into<String>,
383        ship_to: impl Into<String>,
384        bill_to: impl Into<String>,
385    ) -> Self {
386        self.sold_to = Some(sold_to.into());
387        self.ship_to = Some(ship_to.into());
388        self.bill_to = Some(bill_to.into());
389        self
390    }
391
392    /// Set customer PO.
393    pub fn with_customer_po(mut self, po_number: impl Into<String>) -> Self {
394        self.customer_po_number = Some(po_number.into());
395        self
396    }
397
398    /// Set requested delivery date.
399    pub fn with_requested_delivery_date(mut self, date: NaiveDate) -> Self {
400        self.requested_delivery_date = Some(date);
401        self
402    }
403
404    /// Add a line item.
405    pub fn add_item(&mut self, item: SalesOrderItem) {
406        self.items.push(item);
407        self.recalculate_totals();
408    }
409
410    /// Recalculate totals.
411    pub fn recalculate_totals(&mut self) {
412        self.total_net_amount = self
413            .items
414            .iter()
415            .filter(|i| !i.is_rejected)
416            .map(|i| i.base.net_amount)
417            .sum();
418        self.total_tax_amount = self
419            .items
420            .iter()
421            .filter(|i| !i.is_rejected)
422            .map(|i| i.base.tax_amount)
423            .sum();
424        self.total_gross_amount = self.total_net_amount + self.total_tax_amount;
425    }
426
427    /// Perform credit check.
428    pub fn check_credit(&mut self, passed: bool, block_reason: Option<String>) {
429        if passed {
430            self.credit_status = CreditStatus::Passed;
431            self.credit_block_reason = None;
432        } else {
433            self.credit_status = CreditStatus::Failed;
434            self.credit_block_reason = block_reason;
435        }
436    }
437
438    /// Release credit block.
439    pub fn release_credit_block(&mut self, user: impl Into<String>) {
440        self.credit_status = CreditStatus::Released;
441        self.credit_block_reason = None;
442        self.header.update_status(DocumentStatus::Released, user);
443    }
444
445    /// Release for delivery.
446    pub fn release_for_delivery(&mut self) {
447        self.is_delivery_released = true;
448    }
449
450    /// Release for billing.
451    pub fn release_for_billing(&mut self) {
452        self.is_billing_released = true;
453    }
454
455    /// Check if order is complete.
456    pub fn check_complete(&mut self) {
457        self.is_complete = self
458            .items
459            .iter()
460            .all(|i| i.is_rejected || i.is_fully_invoiced);
461    }
462
463    /// Get total open delivery value.
464    pub fn open_delivery_value(&self) -> Decimal {
465        self.items
466            .iter()
467            .filter(|i| !i.is_rejected)
468            .map(|i| i.open_quantity_delivery() * i.base.unit_price)
469            .sum()
470    }
471
472    /// Get total open billing value.
473    pub fn open_billing_value(&self) -> Decimal {
474        self.items
475            .iter()
476            .filter(|i| !i.is_rejected)
477            .map(|i| i.open_quantity_billing() * i.base.unit_price)
478            .sum()
479    }
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485
486    #[test]
487    fn test_sales_order_creation() {
488        let so = SalesOrder::new(
489            "SO-1000-0000000001",
490            "1000",
491            "C-000001",
492            2024,
493            1,
494            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
495            "JSMITH",
496        );
497
498        assert_eq!(so.customer_id, "C-000001");
499        assert_eq!(so.header.status, DocumentStatus::Draft);
500    }
501
502    #[test]
503    fn test_sales_order_items() {
504        let mut so = SalesOrder::new(
505            "SO-1000-0000000001",
506            "1000",
507            "C-000001",
508            2024,
509            1,
510            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
511            "JSMITH",
512        );
513
514        let mut item = SalesOrderItem::new(1, "Product A", Decimal::from(10), Decimal::from(100));
515        item.add_schedule_line(
516            NaiveDate::from_ymd_opt(2024, 1, 20).unwrap(),
517            Decimal::from(10),
518        );
519
520        so.add_item(item);
521
522        assert_eq!(so.total_net_amount, Decimal::from(1000));
523        assert_eq!(so.items[0].schedule_lines.len(), 1);
524    }
525
526    #[test]
527    fn test_delivery_tracking() {
528        let mut item = SalesOrderItem::new(1, "Product A", Decimal::from(100), Decimal::from(10));
529
530        assert_eq!(item.open_quantity_delivery(), Decimal::from(100));
531
532        item.record_delivery(Decimal::from(60));
533        assert_eq!(item.open_quantity_delivery(), Decimal::from(40));
534        assert!(!item.is_fully_delivered);
535
536        item.record_delivery(Decimal::from(40));
537        assert!(item.is_fully_delivered);
538    }
539}