1use chrono::NaiveDate;
11use datasynth_core::utils::seeded_rng;
12use rand::prelude::*;
13use rand_chacha::ChaCha8Rng;
14use rust_decimal::Decimal;
15use serde::{Deserialize, Serialize};
16
17use datasynth_core::models::documents::{GoodsReceipt, Payment, PurchaseOrder, VendorInvoice};
18use datasynth_core::{AnomalyType, FraudType, LabeledAnomaly, ProcessIssueType};
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
22pub enum DocumentFlowAnomalyType {
23 QuantityMismatch,
25 PriceMismatch,
27 InvoiceWithoutPO,
29 GoodsReceivedNotBilled,
31 PaymentWithoutInvoice,
33 DuplicateInvoice,
35 InvoiceBeforeReceipt,
37 EarlyPayment,
39}
40
41#[derive(Debug, Clone)]
43pub struct DocumentFlowAnomalyResult {
44 pub anomaly_type: DocumentFlowAnomalyType,
46 pub description: String,
48 pub original_value: Option<String>,
50 pub modified_value: Option<String>,
52 pub document_ids: Vec<String>,
54 pub severity: u8,
56}
57
58impl DocumentFlowAnomalyResult {
59 pub fn to_labeled_anomaly(
61 &self,
62 anomaly_id: &str,
63 document_id: &str,
64 company_code: &str,
65 date: NaiveDate,
66 ) -> LabeledAnomaly {
67 let anomaly_type = match self.anomaly_type {
69 DocumentFlowAnomalyType::QuantityMismatch => {
71 AnomalyType::Fraud(FraudType::InvoiceManipulation)
72 }
73 DocumentFlowAnomalyType::PriceMismatch => {
74 AnomalyType::Fraud(FraudType::InvoiceManipulation)
75 }
76 DocumentFlowAnomalyType::InvoiceWithoutPO => {
78 AnomalyType::ProcessIssue(ProcessIssueType::MissingDocumentation)
79 }
80 DocumentFlowAnomalyType::GoodsReceivedNotBilled => {
82 AnomalyType::Fraud(FraudType::AssetMisappropriation)
83 }
84 DocumentFlowAnomalyType::PaymentWithoutInvoice => {
86 AnomalyType::Fraud(FraudType::UnauthorizedApproval)
87 }
88 DocumentFlowAnomalyType::DuplicateInvoice => {
90 AnomalyType::Fraud(FraudType::DuplicatePayment)
91 }
92 DocumentFlowAnomalyType::InvoiceBeforeReceipt => {
94 AnomalyType::ProcessIssue(ProcessIssueType::MissingDocumentation)
95 }
96 DocumentFlowAnomalyType::EarlyPayment => {
98 AnomalyType::ProcessIssue(ProcessIssueType::SkippedApproval)
99 }
100 };
101
102 LabeledAnomaly::new(
103 anomaly_id.to_string(),
104 anomaly_type,
105 document_id.to_string(),
106 "DocumentFlow".to_string(),
107 company_code.to_string(),
108 date,
109 )
110 .with_description(&self.description)
111 }
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct DocumentFlowAnomalyConfig {
117 pub quantity_mismatch_rate: f64,
119 pub price_mismatch_rate: f64,
121 pub maverick_buying_rate: f64,
123 pub unbilled_receipt_rate: f64,
125 pub unauthorized_payment_rate: f64,
127 pub duplicate_invoice_rate: f64,
129 pub early_invoice_rate: f64,
131 pub early_payment_rate: f64,
133 pub max_quantity_variance: f64,
135 pub max_price_variance: f64,
137}
138
139impl Default for DocumentFlowAnomalyConfig {
140 fn default() -> Self {
141 Self {
142 quantity_mismatch_rate: 0.02, price_mismatch_rate: 0.015, maverick_buying_rate: 0.01, unbilled_receipt_rate: 0.005, unauthorized_payment_rate: 0.002, duplicate_invoice_rate: 0.008, early_invoice_rate: 0.01, early_payment_rate: 0.005, max_quantity_variance: 0.25, max_price_variance: 0.15, }
153 }
154}
155
156pub struct DocumentFlowAnomalyInjector {
158 config: DocumentFlowAnomalyConfig,
159 rng: ChaCha8Rng,
160 results: Vec<DocumentFlowAnomalyResult>,
161}
162
163impl DocumentFlowAnomalyInjector {
164 pub fn new(config: DocumentFlowAnomalyConfig, seed: u64) -> Self {
166 Self {
167 config,
168 rng: seeded_rng(seed, 0),
169 results: Vec::new(),
170 }
171 }
172
173 pub fn with_seed(seed: u64) -> Self {
175 Self::new(DocumentFlowAnomalyConfig::default(), seed)
176 }
177
178 pub fn get_results(&self) -> &[DocumentFlowAnomalyResult] {
180 &self.results
181 }
182
183 pub fn clear_results(&mut self) {
185 self.results.clear();
186 }
187
188 pub fn maybe_inject_quantity_mismatch(
192 &mut self,
193 gr: &mut GoodsReceipt,
194 po: &PurchaseOrder,
195 ) -> bool {
196 if self.rng.random::<f64>() >= self.config.quantity_mismatch_rate {
197 return false;
198 }
199
200 if let Some(gr_item) = gr.items.first_mut() {
202 let original_qty = gr_item.base.quantity;
203
204 let variance = if self.rng.random::<bool>() {
206 Decimal::from_f64_retain(
208 1.0 + self.rng.random::<f64>() * self.config.max_quantity_variance,
209 )
210 .unwrap_or(Decimal::ONE)
211 } else {
212 Decimal::from_f64_retain(
214 1.0 - self.rng.random::<f64>() * self.config.max_quantity_variance,
215 )
216 .unwrap_or(Decimal::ONE)
217 };
218
219 gr_item.base.quantity = (original_qty * variance).round_dp(2);
220
221 let result = DocumentFlowAnomalyResult {
222 anomaly_type: DocumentFlowAnomalyType::QuantityMismatch,
223 description: format!(
224 "GR quantity {} doesn't match PO, expected based on PO line",
225 gr_item.base.quantity
226 ),
227 original_value: Some(original_qty.to_string()),
228 modified_value: Some(gr_item.base.quantity.to_string()),
229 document_ids: vec![gr.header.document_id.clone(), po.header.document_id.clone()],
230 severity: if variance > Decimal::from_f64_retain(1.1).expect("valid f64 to decimal")
231 {
232 4
233 } else {
234 3
235 },
236 };
237
238 self.results.push(result);
239 true
240 } else {
241 false
242 }
243 }
244
245 pub fn maybe_inject_price_mismatch(
249 &mut self,
250 invoice: &mut VendorInvoice,
251 po: &PurchaseOrder,
252 ) -> bool {
253 if self.rng.random::<f64>() >= self.config.price_mismatch_rate {
254 return false;
255 }
256
257 if let Some(inv_item) = invoice.items.first_mut() {
259 let original_price = inv_item.base.unit_price;
260
261 let variance = if self.rng.random::<f64>() < 0.8 {
263 Decimal::from_f64_retain(
265 1.0 + self.rng.random::<f64>() * self.config.max_price_variance,
266 )
267 .unwrap_or(Decimal::ONE)
268 } else {
269 Decimal::from_f64_retain(
271 1.0 - self.rng.random::<f64>() * self.config.max_price_variance * 0.5,
272 )
273 .unwrap_or(Decimal::ONE)
274 };
275
276 inv_item.base.unit_price = (original_price * variance).round_dp(2);
277
278 let result = DocumentFlowAnomalyResult {
279 anomaly_type: DocumentFlowAnomalyType::PriceMismatch,
280 description: format!(
281 "Invoice price {} doesn't match PO agreed price",
282 inv_item.base.unit_price
283 ),
284 original_value: Some(original_price.to_string()),
285 modified_value: Some(inv_item.base.unit_price.to_string()),
286 document_ids: vec![
287 invoice.header.document_id.clone(),
288 po.header.document_id.clone(),
289 ],
290 severity: if variance > Decimal::from_f64_retain(1.1).expect("valid f64 to decimal")
291 {
292 4
293 } else {
294 3
295 },
296 };
297
298 self.results.push(result);
299 true
300 } else {
301 false
302 }
303 }
304
305 pub fn inject_maverick_buying(&mut self, invoice: &mut VendorInvoice) -> bool {
309 if self.rng.random::<f64>() >= self.config.maverick_buying_rate {
310 return false;
311 }
312
313 if invoice.purchase_order_id.is_none() {
315 return false;
316 }
317
318 let original_po = invoice.purchase_order_id.take();
319
320 let result = DocumentFlowAnomalyResult {
321 anomaly_type: DocumentFlowAnomalyType::InvoiceWithoutPO,
322 description: "Invoice submitted without purchase order (maverick buying)".to_string(),
323 original_value: original_po,
324 modified_value: None,
325 document_ids: vec![invoice.header.document_id.clone()],
326 severity: 4, };
328
329 self.results.push(result);
330 true
331 }
332
333 pub fn create_early_invoice_anomaly(
337 &mut self,
338 invoice: &VendorInvoice,
339 gr: &GoodsReceipt,
340 ) -> Option<DocumentFlowAnomalyResult> {
341 if self.rng.random::<f64>() >= self.config.early_invoice_rate {
342 return None;
343 }
344
345 if invoice.invoice_date < gr.header.document_date {
347 let result = DocumentFlowAnomalyResult {
348 anomaly_type: DocumentFlowAnomalyType::InvoiceBeforeReceipt,
349 description: format!(
350 "Invoice dated {} before goods receipt dated {}",
351 invoice.invoice_date, gr.header.document_date
352 ),
353 original_value: Some(gr.header.document_date.to_string()),
354 modified_value: Some(invoice.invoice_date.to_string()),
355 document_ids: vec![
356 invoice.header.document_id.clone(),
357 gr.header.document_id.clone(),
358 ],
359 severity: 3,
360 };
361
362 self.results.push(result.clone());
363 return Some(result);
364 }
365
366 None
367 }
368
369 pub fn check_unauthorized_payment(
371 &mut self,
372 payment: &Payment,
373 has_valid_invoice: bool,
374 ) -> Option<DocumentFlowAnomalyResult> {
375 if has_valid_invoice {
376 return None;
377 }
378
379 if self.rng.random::<f64>() >= self.config.unauthorized_payment_rate {
380 return None;
381 }
382
383 let result = DocumentFlowAnomalyResult {
384 anomaly_type: DocumentFlowAnomalyType::PaymentWithoutInvoice,
385 description: "Payment issued without valid approved invoice".to_string(),
386 original_value: None,
387 modified_value: None,
388 document_ids: vec![payment.header.document_id.clone()],
389 severity: 5, };
391
392 self.results.push(result.clone());
393 Some(result)
394 }
395
396 pub fn get_statistics(&self) -> DocumentFlowAnomalyStats {
398 let mut stats = DocumentFlowAnomalyStats::default();
399
400 for result in &self.results {
401 match result.anomaly_type {
402 DocumentFlowAnomalyType::QuantityMismatch => stats.quantity_mismatches += 1,
403 DocumentFlowAnomalyType::PriceMismatch => stats.price_mismatches += 1,
404 DocumentFlowAnomalyType::InvoiceWithoutPO => stats.maverick_buying += 1,
405 DocumentFlowAnomalyType::GoodsReceivedNotBilled => stats.unbilled_receipts += 1,
406 DocumentFlowAnomalyType::PaymentWithoutInvoice => stats.unauthorized_payments += 1,
407 DocumentFlowAnomalyType::DuplicateInvoice => stats.duplicate_invoices += 1,
408 DocumentFlowAnomalyType::InvoiceBeforeReceipt => stats.early_invoices += 1,
409 DocumentFlowAnomalyType::EarlyPayment => stats.early_payments += 1,
410 }
411 }
412
413 stats.total = self.results.len();
414 stats
415 }
416}
417
418#[derive(Debug, Clone, Default)]
420pub struct DocumentFlowAnomalyStats {
421 pub total: usize,
422 pub quantity_mismatches: usize,
423 pub price_mismatches: usize,
424 pub maverick_buying: usize,
425 pub unbilled_receipts: usize,
426 pub unauthorized_payments: usize,
427 pub duplicate_invoices: usize,
428 pub early_invoices: usize,
429 pub early_payments: usize,
430}
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435 use datasynth_core::models::documents::{
436 GoodsReceiptItem, PurchaseOrderItem, VendorInvoiceItem,
437 };
438 use rust_decimal_macros::dec;
439
440 fn create_test_po() -> PurchaseOrder {
441 let mut po = PurchaseOrder::new(
442 "PO-001",
443 "1000",
444 "VEND001",
445 2024,
446 1,
447 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
448 "USER001",
449 );
450 po.add_item(PurchaseOrderItem::new(
451 1,
452 "Test Item",
453 dec!(100),
454 dec!(10.00),
455 ));
456 po
457 }
458
459 fn create_test_gr(_po_id: &str) -> GoodsReceipt {
460 let mut gr = GoodsReceipt::new(
461 "GR-001",
462 "1000",
463 "PLANT01",
464 "STOR01",
465 2024,
466 1,
467 NaiveDate::from_ymd_opt(2024, 1, 20).unwrap(),
468 "USER001",
469 );
470 gr.add_item(GoodsReceiptItem::new(
471 1,
472 "Test Item",
473 dec!(100),
474 dec!(10.00),
475 ));
476 gr
477 }
478
479 fn create_test_invoice(po_id: Option<&str>) -> VendorInvoice {
480 let mut inv = VendorInvoice::new(
481 "VI-001",
482 "1000",
483 "VEND001",
484 "INV-001",
485 2024,
486 1,
487 NaiveDate::from_ymd_opt(2024, 1, 25).unwrap(),
488 "USER001",
489 );
490 inv.purchase_order_id = po_id.map(|s| s.to_string());
491 inv.add_item(VendorInvoiceItem::new(
492 1,
493 "Test Item",
494 dec!(100),
495 dec!(10.00),
496 ));
497 inv
498 }
499
500 #[test]
501 fn test_quantity_mismatch_injection() {
502 let config = DocumentFlowAnomalyConfig {
504 quantity_mismatch_rate: 1.0, ..Default::default()
506 };
507
508 let mut injector = DocumentFlowAnomalyInjector::new(config, 42);
509 let po = create_test_po();
510 let mut gr = create_test_gr(&po.header.document_id);
511
512 let original_qty = gr.items[0].base.quantity;
513 let injected = injector.maybe_inject_quantity_mismatch(&mut gr, &po);
514
515 assert!(injected);
516 assert_ne!(gr.items[0].base.quantity, original_qty);
517 assert_eq!(injector.get_results().len(), 1);
518 assert_eq!(
519 injector.get_results()[0].anomaly_type,
520 DocumentFlowAnomalyType::QuantityMismatch
521 );
522 }
523
524 #[test]
525 fn test_maverick_buying_injection() {
526 let config = DocumentFlowAnomalyConfig {
527 maverick_buying_rate: 1.0, ..Default::default()
529 };
530
531 let mut injector = DocumentFlowAnomalyInjector::new(config, 42);
532 let mut invoice = create_test_invoice(Some("PO-001"));
533
534 assert!(invoice.purchase_order_id.is_some());
535 let injected = injector.inject_maverick_buying(&mut invoice);
536
537 assert!(injected);
538 assert!(invoice.purchase_order_id.is_none());
539 assert_eq!(
540 injector.get_results()[0].anomaly_type,
541 DocumentFlowAnomalyType::InvoiceWithoutPO
542 );
543 }
544
545 #[test]
546 fn test_statistics() {
547 let config = DocumentFlowAnomalyConfig {
548 quantity_mismatch_rate: 1.0,
549 maverick_buying_rate: 1.0,
550 ..Default::default()
551 };
552
553 let mut injector = DocumentFlowAnomalyInjector::new(config, 42);
554
555 let po = create_test_po();
557 let mut gr = create_test_gr(&po.header.document_id);
558 injector.maybe_inject_quantity_mismatch(&mut gr, &po);
559
560 let mut invoice = create_test_invoice(Some("PO-001"));
562 injector.inject_maverick_buying(&mut invoice);
563
564 let stats = injector.get_statistics();
565 assert_eq!(stats.total, 2);
566 assert_eq!(stats.quantity_mismatches, 1);
567 assert_eq!(stats.maverick_buying, 1);
568 }
569
570 #[test]
571 fn test_labeled_anomaly_conversion() {
572 let result = DocumentFlowAnomalyResult {
573 anomaly_type: DocumentFlowAnomalyType::QuantityMismatch,
574 description: "Test mismatch".to_string(),
575 original_value: Some("100".to_string()),
576 modified_value: Some("120".to_string()),
577 document_ids: vec!["DOC-001".to_string()],
578 severity: 3,
579 };
580
581 let labeled = result.to_labeled_anomaly(
582 "ANO-001",
583 "DOC-001",
584 "1000",
585 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
586 );
587
588 assert_eq!(labeled.document_id, "DOC-001");
589 assert_eq!(labeled.company_code, "1000");
590 assert!(matches!(
592 labeled.anomaly_type,
593 AnomalyType::Fraud(FraudType::InvoiceManipulation)
594 ));
595 }
596}