Skip to main content

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    /// Customer display name (denormalized, DS-011)
292    #[serde(default, skip_serializing_if = "Option::is_none")]
293    pub customer_name: Option<String>,
294}
295
296/// Credit check status.
297#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
298#[serde(rename_all = "snake_case")]
299pub enum CreditStatus {
300    /// Not checked
301    #[default]
302    NotChecked,
303    /// Passed
304    Passed,
305    /// Failed - blocked
306    Failed,
307    /// Manually released
308    Released,
309}
310
311impl SalesOrder {
312    /// Create a new sales order.
313    pub fn new(
314        so_id: impl Into<String>,
315        company_code: impl Into<String>,
316        customer_id: impl Into<String>,
317        fiscal_year: u16,
318        fiscal_period: u8,
319        document_date: NaiveDate,
320        created_by: impl Into<String>,
321    ) -> Self {
322        let header = DocumentHeader::new(
323            so_id,
324            DocumentType::SalesOrder,
325            company_code,
326            fiscal_year,
327            fiscal_period,
328            document_date,
329            created_by,
330        );
331
332        Self {
333            header,
334            so_type: SalesOrderType::Standard,
335            customer_id: customer_id.into(),
336            sold_to: None,
337            ship_to: None,
338            bill_to: None,
339            payer: None,
340            sales_org: "1000".to_string(),
341            distribution_channel: "10".to_string(),
342            division: "00".to_string(),
343            sales_office: None,
344            sales_group: None,
345            items: Vec::new(),
346            total_net_amount: Decimal::ZERO,
347            total_tax_amount: Decimal::ZERO,
348            total_gross_amount: Decimal::ZERO,
349            payment_terms: "NET30".to_string(),
350            incoterms: None,
351            shipping_condition: None,
352            requested_delivery_date: None,
353            customer_po_number: None,
354            is_complete: false,
355            credit_status: CreditStatus::NotChecked,
356            credit_block_reason: None,
357            is_delivery_released: false,
358            is_billing_released: false,
359            quote_id: None,
360            contract_id: None,
361            customer_name: None,
362        }
363    }
364
365    /// Set SO type.
366    pub fn with_so_type(mut self, so_type: SalesOrderType) -> Self {
367        self.so_type = so_type;
368        self
369    }
370
371    /// Set sales organization.
372    pub fn with_sales_org(
373        mut self,
374        sales_org: impl Into<String>,
375        dist_channel: impl Into<String>,
376        division: impl Into<String>,
377    ) -> Self {
378        self.sales_org = sales_org.into();
379        self.distribution_channel = dist_channel.into();
380        self.division = division.into();
381        self
382    }
383
384    /// Set partner functions.
385    pub fn with_partners(
386        mut self,
387        sold_to: impl Into<String>,
388        ship_to: impl Into<String>,
389        bill_to: impl Into<String>,
390    ) -> Self {
391        self.sold_to = Some(sold_to.into());
392        self.ship_to = Some(ship_to.into());
393        self.bill_to = Some(bill_to.into());
394        self
395    }
396
397    /// Set customer PO.
398    pub fn with_customer_po(mut self, po_number: impl Into<String>) -> Self {
399        self.customer_po_number = Some(po_number.into());
400        self
401    }
402
403    /// Set requested delivery date.
404    pub fn with_requested_delivery_date(mut self, date: NaiveDate) -> Self {
405        self.requested_delivery_date = Some(date);
406        self
407    }
408
409    /// Add a line item.
410    pub fn add_item(&mut self, item: SalesOrderItem) {
411        self.items.push(item);
412        self.recalculate_totals();
413    }
414
415    /// Recalculate totals.
416    pub fn recalculate_totals(&mut self) {
417        self.total_net_amount = self
418            .items
419            .iter()
420            .filter(|i| !i.is_rejected)
421            .map(|i| i.base.net_amount)
422            .sum();
423        self.total_tax_amount = self
424            .items
425            .iter()
426            .filter(|i| !i.is_rejected)
427            .map(|i| i.base.tax_amount)
428            .sum();
429        self.total_gross_amount = self.total_net_amount + self.total_tax_amount;
430    }
431
432    /// Perform credit check.
433    pub fn check_credit(&mut self, passed: bool, block_reason: Option<String>) {
434        if passed {
435            self.credit_status = CreditStatus::Passed;
436            self.credit_block_reason = None;
437        } else {
438            self.credit_status = CreditStatus::Failed;
439            self.credit_block_reason = block_reason;
440        }
441    }
442
443    /// Release credit block.
444    pub fn release_credit_block(&mut self, user: impl Into<String>) {
445        self.credit_status = CreditStatus::Released;
446        self.credit_block_reason = None;
447        self.header.update_status(DocumentStatus::Released, user);
448    }
449
450    /// Release for delivery.
451    pub fn release_for_delivery(&mut self) {
452        self.is_delivery_released = true;
453    }
454
455    /// Release for billing.
456    pub fn release_for_billing(&mut self) {
457        self.is_billing_released = true;
458    }
459
460    /// Check if order is complete.
461    pub fn check_complete(&mut self) {
462        self.is_complete = self
463            .items
464            .iter()
465            .all(|i| i.is_rejected || i.is_fully_invoiced);
466    }
467
468    /// Get total open delivery value.
469    pub fn open_delivery_value(&self) -> Decimal {
470        self.items
471            .iter()
472            .filter(|i| !i.is_rejected)
473            .map(|i| i.open_quantity_delivery() * i.base.unit_price)
474            .sum()
475    }
476
477    /// Get total open billing value.
478    pub fn open_billing_value(&self) -> Decimal {
479        self.items
480            .iter()
481            .filter(|i| !i.is_rejected)
482            .map(|i| i.open_quantity_billing() * i.base.unit_price)
483            .sum()
484    }
485}
486
487#[cfg(test)]
488#[allow(clippy::unwrap_used)]
489mod tests {
490    use super::*;
491
492    #[test]
493    fn test_sales_order_creation() {
494        let so = SalesOrder::new(
495            "SO-1000-0000000001",
496            "1000",
497            "C-000001",
498            2024,
499            1,
500            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
501            "JSMITH",
502        );
503
504        assert_eq!(so.customer_id, "C-000001");
505        assert_eq!(so.header.status, DocumentStatus::Draft);
506    }
507
508    #[test]
509    fn test_sales_order_items() {
510        let mut so = SalesOrder::new(
511            "SO-1000-0000000001",
512            "1000",
513            "C-000001",
514            2024,
515            1,
516            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
517            "JSMITH",
518        );
519
520        let mut item = SalesOrderItem::new(1, "Product A", Decimal::from(10), Decimal::from(100));
521        item.add_schedule_line(
522            NaiveDate::from_ymd_opt(2024, 1, 20).unwrap(),
523            Decimal::from(10),
524        );
525
526        so.add_item(item);
527
528        assert_eq!(so.total_net_amount, Decimal::from(1000));
529        assert_eq!(so.items[0].schedule_lines.len(), 1);
530    }
531
532    #[test]
533    fn test_delivery_tracking() {
534        let mut item = SalesOrderItem::new(1, "Product A", Decimal::from(100), Decimal::from(10));
535
536        assert_eq!(item.open_quantity_delivery(), Decimal::from(100));
537
538        item.record_delivery(Decimal::from(60));
539        assert_eq!(item.open_quantity_delivery(), Decimal::from(40));
540        assert!(!item.is_fully_delivered);
541
542        item.record_delivery(Decimal::from(40));
543        assert!(item.is_fully_delivered);
544    }
545}