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 #[serde(default, skip_serializing_if = "Option::is_none")]
293 pub customer_name: Option<String>,
294}
295
296#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
298#[serde(rename_all = "snake_case")]
299pub enum CreditStatus {
300 #[default]
302 NotChecked,
303 Passed,
305 Failed,
307 Released,
309}
310
311impl SalesOrder {
312 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 pub fn with_so_type(mut self, so_type: SalesOrderType) -> Self {
367 self.so_type = so_type;
368 self
369 }
370
371 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 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 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 pub fn with_requested_delivery_date(mut self, date: NaiveDate) -> Self {
405 self.requested_delivery_date = Some(date);
406 self
407 }
408
409 pub fn add_item(&mut self, item: SalesOrderItem) {
411 self.items.push(item);
412 self.recalculate_totals();
413 }
414
415 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 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 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 pub fn release_for_delivery(&mut self) {
452 self.is_delivery_released = true;
453 }
454
455 pub fn release_for_billing(&mut self) {
457 self.is_billing_released = true;
458 }
459
460 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 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 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}