1use chrono::NaiveDate;
6use rust_decimal::Decimal;
7use serde::{Deserialize, Serialize};
8
9use super::{DocumentHeader, DocumentLineItem, DocumentStatus, DocumentType};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
13#[serde(rename_all = "snake_case")]
14pub enum SalesOrderType {
15 #[default]
17 Standard,
18 Rush,
20 CashSale,
22 Return,
24 FreeOfCharge,
26 Consignment,
28 Service,
30 CreditMemoRequest,
32 DebitMemoRequest,
34}
35
36impl SalesOrderType {
37 pub fn requires_delivery(&self) -> bool {
39 !matches!(
40 self,
41 Self::Service | Self::CreditMemoRequest | Self::DebitMemoRequest
42 )
43 }
44
45 pub fn creates_revenue(&self) -> bool {
47 !matches!(
48 self,
49 Self::FreeOfCharge | Self::Return | Self::CreditMemoRequest
50 )
51 }
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct SalesOrderItem {
57 #[serde(flatten)]
59 pub base: DocumentLineItem,
60
61 pub item_category: String,
63
64 pub schedule_lines: Vec<ScheduleLine>,
66
67 pub quantity_confirmed: Decimal,
69
70 pub quantity_delivered: Decimal,
72
73 pub quantity_invoiced: Decimal,
75
76 pub is_fully_delivered: bool,
78
79 pub is_fully_invoiced: bool,
81
82 pub rejection_reason: Option<String>,
84
85 pub is_rejected: bool,
87
88 pub route: Option<String>,
90
91 pub shipping_point: Option<String>,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct ScheduleLine {
98 pub schedule_number: u16,
100 pub requested_date: NaiveDate,
102 pub confirmed_date: Option<NaiveDate>,
104 pub quantity: Decimal,
106 pub delivered_quantity: Decimal,
108}
109
110impl SalesOrderItem {
111 #[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(), 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 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 pub fn with_plant(mut self, plant: impl Into<String>) -> Self {
143 self.base.plant = Some(plant.into());
144 self
145 }
146
147 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 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 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 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 pub fn open_quantity_delivery(&self) -> Decimal {
189 (self.base.quantity - self.quantity_delivered).max(Decimal::ZERO)
190 }
191
192 pub fn open_quantity_billing(&self) -> Decimal {
194 (self.quantity_delivered - self.quantity_invoiced).max(Decimal::ZERO)
195 }
196
197 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#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct SalesOrder {
207 pub header: DocumentHeader,
209
210 pub so_type: SalesOrderType,
212
213 pub customer_id: String,
215
216 pub sold_to: Option<String>,
218
219 pub ship_to: Option<String>,
221
222 pub bill_to: Option<String>,
224
225 pub payer: Option<String>,
227
228 pub sales_org: String,
230
231 pub distribution_channel: String,
233
234 pub division: String,
236
237 pub sales_office: Option<String>,
239
240 pub sales_group: Option<String>,
242
243 pub items: Vec<SalesOrderItem>,
245
246 pub total_net_amount: Decimal,
248
249 pub total_tax_amount: Decimal,
251
252 pub total_gross_amount: Decimal,
254
255 pub payment_terms: String,
257
258 pub incoterms: Option<String>,
260
261 pub shipping_condition: Option<String>,
263
264 pub requested_delivery_date: Option<NaiveDate>,
266
267 pub customer_po_number: Option<String>,
269
270 pub is_complete: bool,
272
273 pub credit_status: CreditStatus,
275
276 pub credit_block_reason: Option<String>,
278
279 pub is_delivery_released: bool,
281
282 pub is_billing_released: bool,
284
285 pub quote_id: Option<String>,
287
288 pub contract_id: Option<String>,
290}
291
292#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
294#[serde(rename_all = "snake_case")]
295pub enum CreditStatus {
296 #[default]
298 NotChecked,
299 Passed,
301 Failed,
303 Released,
305}
306
307impl SalesOrder {
308 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 pub fn with_so_type(mut self, so_type: SalesOrderType) -> Self {
362 self.so_type = so_type;
363 self
364 }
365
366 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 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 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 pub fn with_requested_delivery_date(mut self, date: NaiveDate) -> Self {
400 self.requested_delivery_date = Some(date);
401 self
402 }
403
404 pub fn add_item(&mut self, item: SalesOrderItem) {
406 self.items.push(item);
407 self.recalculate_totals();
408 }
409
410 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 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 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 pub fn release_for_delivery(&mut self) {
447 self.is_delivery_released = true;
448 }
449
450 pub fn release_for_billing(&mut self) {
452 self.is_billing_released = true;
453 }
454
455 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 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 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}