Skip to main content

datasynth_ocpm/generator/
p2p_generator.rs

1//! P2P (Procure-to-Pay) process event generator.
2//!
3//! Generates OCPM events for the complete P2P flow:
4//! Create PO → Approve PO → Release PO → Create GR → Post GR →
5//! Receive Invoice → Verify Invoice → Post Invoice → Execute Payment
6
7use chrono::{DateTime, Utc};
8use rust_decimal::Decimal;
9use uuid::Uuid;
10
11use super::{CaseGenerationResult, OcpmEventGenerator, VariantType};
12use crate::models::{
13    ActivityType, EventObjectRef, ObjectAttributeValue, ObjectRelationship, ObjectType,
14};
15use datasynth_core::models::BusinessProcess;
16
17/// P2P document references for event generation.
18#[derive(Debug, Clone)]
19pub struct P2pDocuments {
20    /// Purchase order number
21    pub po_number: String,
22    /// Purchase order UUID
23    pub po_id: Uuid,
24    /// Goods receipt number
25    pub gr_number: Option<String>,
26    /// Goods receipt UUID
27    pub gr_id: Option<Uuid>,
28    /// Vendor invoice number
29    pub invoice_number: Option<String>,
30    /// Invoice UUID
31    pub invoice_id: Option<Uuid>,
32    /// Payment number
33    pub payment_number: Option<String>,
34    /// Payment UUID
35    pub payment_id: Option<Uuid>,
36    /// Vendor ID
37    pub vendor_id: String,
38    /// Company code
39    pub company_code: String,
40    /// Total amount
41    pub amount: Decimal,
42    /// Currency
43    pub currency: String,
44}
45
46impl P2pDocuments {
47    /// Create new P2P documents.
48    pub fn new(
49        po_number: &str,
50        vendor_id: &str,
51        company_code: &str,
52        amount: Decimal,
53        currency: &str,
54    ) -> Self {
55        Self {
56            po_number: po_number.into(),
57            po_id: Uuid::new_v4(),
58            gr_number: None,
59            gr_id: None,
60            invoice_number: None,
61            invoice_id: None,
62            payment_number: None,
63            payment_id: None,
64            vendor_id: vendor_id.into(),
65            company_code: company_code.into(),
66            amount,
67            currency: currency.into(),
68        }
69    }
70
71    /// Set goods receipt info.
72    pub fn with_goods_receipt(mut self, gr_number: &str) -> Self {
73        self.gr_number = Some(gr_number.into());
74        self.gr_id = Some(Uuid::new_v4());
75        self
76    }
77
78    /// Set invoice info.
79    pub fn with_invoice(mut self, invoice_number: &str) -> Self {
80        self.invoice_number = Some(invoice_number.into());
81        self.invoice_id = Some(Uuid::new_v4());
82        self
83    }
84
85    /// Set payment info.
86    pub fn with_payment(mut self, payment_number: &str) -> Self {
87        self.payment_number = Some(payment_number.into());
88        self.payment_id = Some(Uuid::new_v4());
89        self
90    }
91}
92
93impl OcpmEventGenerator {
94    /// Generate complete P2P process events.
95    pub fn generate_p2p_case(
96        &mut self,
97        documents: &P2pDocuments,
98        start_time: DateTime<Utc>,
99        available_users: &[String],
100    ) -> CaseGenerationResult {
101        let case_id = self.new_case_id();
102        let variant_type = self.select_variant_type();
103
104        let mut events = Vec::new();
105        let mut objects = Vec::new();
106        let mut relationships = Vec::new();
107        let mut current_time = start_time;
108
109        // Create object types
110        let po_type = ObjectType::purchase_order();
111        let gr_type = ObjectType::goods_receipt();
112        let invoice_type = ObjectType::vendor_invoice();
113
114        // Create PO object
115        let po_object = self.create_object(
116            &po_type,
117            &documents.po_number,
118            &documents.company_code,
119            current_time,
120        );
121        objects.push(po_object.clone());
122
123        // Activity: Create PO
124        let create_po = ActivityType::create_po();
125        let resource = self.select_resource(&create_po, available_users);
126        let mut event = self.create_event(
127            &create_po,
128            current_time,
129            &resource,
130            &documents.company_code,
131            case_id,
132        );
133        event = event
134            .with_object(
135                EventObjectRef::created(po_object.object_id, &po_type.type_id)
136                    .with_external_id(&documents.po_number),
137            )
138            .with_document_ref(&documents.po_number);
139        Self::add_event_attribute(
140            &mut event,
141            "amount",
142            ObjectAttributeValue::Decimal(documents.amount),
143        );
144        Self::add_event_attribute(
145            &mut event,
146            "vendor_id",
147            ObjectAttributeValue::String(documents.vendor_id.clone()),
148        );
149        events.push(event);
150
151        // Activity: Approve PO
152        current_time = self.calculate_event_time(current_time, &create_po);
153        current_time += self.generate_inter_activity_delay(30, 480); // 30 min to 8 hours
154
155        let approve_po = ActivityType::approve_po();
156        let resource = self.select_resource(&approve_po, available_users);
157        let mut event = self.create_event(
158            &approve_po,
159            current_time,
160            &resource,
161            &documents.company_code,
162            case_id,
163        );
164        event = event
165            .with_object(
166                EventObjectRef::updated(po_object.object_id, &po_type.type_id)
167                    .with_external_id(&documents.po_number),
168            )
169            .with_document_ref(&documents.po_number);
170        events.push(event);
171
172        // Activity: Release PO
173        current_time = self.calculate_event_time(current_time, &approve_po);
174
175        let release_po = ActivityType::release_po();
176        let resource = self.select_resource(&release_po, available_users);
177        let mut event = self.create_event(
178            &release_po,
179            current_time,
180            &resource,
181            &documents.company_code,
182            case_id,
183        );
184        event = event
185            .with_object(
186                EventObjectRef::updated(po_object.object_id, &po_type.type_id)
187                    .with_external_id(&documents.po_number),
188            )
189            .with_document_ref(&documents.po_number);
190        events.push(event);
191
192        // Skip remaining steps for error paths
193        if matches!(variant_type, VariantType::ErrorPath) {
194            let case_trace = self.create_case_trace(
195                case_id,
196                &events,
197                BusinessProcess::P2P,
198                po_object.object_id,
199                &po_type.type_id,
200                &documents.company_code,
201            );
202            return CaseGenerationResult {
203                events,
204                objects,
205                relationships,
206                case_trace,
207                variant_type,
208            };
209        }
210
211        // Activity: Create GR
212        current_time = self.calculate_event_time(current_time, &release_po);
213        current_time += self.generate_inter_activity_delay(1440, 10080); // 1-7 days
214
215        if let Some(gr_number) = &documents.gr_number {
216            let gr_object =
217                self.create_object(&gr_type, gr_number, &documents.company_code, current_time);
218            objects.push(gr_object.clone());
219
220            // Add relationship: GR references PO
221            relationships.push(ObjectRelationship::new(
222                "references",
223                gr_object.object_id,
224                &gr_type.type_id,
225                po_object.object_id,
226                &po_type.type_id,
227            ));
228
229            let create_gr = ActivityType::create_gr();
230            let resource = self.select_resource(&create_gr, available_users);
231            let mut event = self.create_event(
232                &create_gr,
233                current_time,
234                &resource,
235                &documents.company_code,
236                case_id,
237            );
238            event = event
239                .with_object(
240                    EventObjectRef::created(gr_object.object_id, &gr_type.type_id)
241                        .with_external_id(gr_number),
242                )
243                .with_object(
244                    EventObjectRef::updated(po_object.object_id, &po_type.type_id)
245                        .with_external_id(&documents.po_number),
246                )
247                .with_document_ref(gr_number);
248            events.push(event);
249
250            // Activity: Post GR
251            current_time = self.calculate_event_time(current_time, &create_gr);
252
253            let post_gr = ActivityType::post_gr();
254            let resource = self.select_resource(&post_gr, available_users);
255            let mut event = self.create_event(
256                &post_gr,
257                current_time,
258                &resource,
259                &documents.company_code,
260                case_id,
261            );
262            event = event
263                .with_object(
264                    EventObjectRef::updated(gr_object.object_id, &gr_type.type_id)
265                        .with_external_id(gr_number),
266                )
267                .with_document_ref(gr_number);
268            events.push(event);
269        }
270
271        // Activity: Receive Invoice
272        current_time = self.calculate_event_time(current_time, &ActivityType::post_gr());
273        current_time += self.generate_inter_activity_delay(1440, 20160); // 1-14 days
274
275        if let Some(invoice_number) = &documents.invoice_number {
276            let invoice_object = self.create_object(
277                &invoice_type,
278                invoice_number,
279                &documents.company_code,
280                current_time,
281            );
282            objects.push(invoice_object.clone());
283
284            // Add relationship: Invoice references PO
285            relationships.push(ObjectRelationship::new(
286                "references",
287                invoice_object.object_id,
288                &invoice_type.type_id,
289                po_object.object_id,
290                &po_type.type_id,
291            ));
292
293            let receive_invoice = ActivityType::receive_invoice();
294            let resource = self.select_resource(&receive_invoice, available_users);
295            let mut event = self.create_event(
296                &receive_invoice,
297                current_time,
298                &resource,
299                &documents.company_code,
300                case_id,
301            );
302            event = event
303                .with_object(
304                    EventObjectRef::created(invoice_object.object_id, &invoice_type.type_id)
305                        .with_external_id(invoice_number),
306                )
307                .with_document_ref(invoice_number);
308            Self::add_event_attribute(
309                &mut event,
310                "invoice_amount",
311                ObjectAttributeValue::Decimal(documents.amount),
312            );
313            events.push(event);
314
315            // Skip verify for exception paths sometimes
316            if !matches!(variant_type, VariantType::ExceptionPath)
317                || !self.should_skip_activity(0.3)
318            {
319                // Activity: Verify Invoice (3-way match)
320                current_time = self.calculate_event_time(current_time, &receive_invoice);
321                current_time += self.generate_inter_activity_delay(60, 480);
322
323                let verify_invoice = ActivityType::verify_invoice();
324                let resource = self.select_resource(&verify_invoice, available_users);
325                let mut event = self.create_event(
326                    &verify_invoice,
327                    current_time,
328                    &resource,
329                    &documents.company_code,
330                    case_id,
331                );
332                event = event
333                    .with_object(
334                        EventObjectRef::updated(invoice_object.object_id, &invoice_type.type_id)
335                            .with_external_id(invoice_number),
336                    )
337                    .with_object(
338                        EventObjectRef::read(po_object.object_id, &po_type.type_id)
339                            .with_external_id(&documents.po_number),
340                    );
341
342                if let Some(gr_id) = documents.gr_id {
343                    event = event.with_object(EventObjectRef::read(gr_id, &gr_type.type_id));
344                }
345
346                Self::add_event_attribute(
347                    &mut event,
348                    "match_result",
349                    ObjectAttributeValue::String("matched".into()),
350                );
351                events.push(event);
352            }
353
354            // Activity: Post Invoice
355            current_time = self.calculate_event_time(current_time, &ActivityType::verify_invoice());
356
357            let post_invoice = ActivityType::post_invoice();
358            let resource = self.select_resource(&post_invoice, available_users);
359            let mut event = self.create_event(
360                &post_invoice,
361                current_time,
362                &resource,
363                &documents.company_code,
364                case_id,
365            );
366            event = event
367                .with_object(
368                    EventObjectRef::updated(invoice_object.object_id, &invoice_type.type_id)
369                        .with_external_id(invoice_number),
370                )
371                .with_object(
372                    EventObjectRef::updated(po_object.object_id, &po_type.type_id)
373                        .with_external_id(&documents.po_number),
374                )
375                .with_document_ref(invoice_number);
376            events.push(event);
377
378            // Activity: Execute Payment
379            if documents.payment_number.is_some() {
380                current_time = self.calculate_event_time(current_time, &post_invoice);
381                current_time += self.generate_inter_activity_delay(1440, 43200); // 1-30 days (payment terms)
382
383                let execute_payment = ActivityType::execute_payment();
384                let resource = self.select_resource(&execute_payment, available_users);
385                let mut event = self.create_event(
386                    &execute_payment,
387                    current_time,
388                    &resource,
389                    &documents.company_code,
390                    case_id,
391                );
392                event = event
393                    .with_object(
394                        EventObjectRef::consumed(invoice_object.object_id, &invoice_type.type_id)
395                            .with_external_id(invoice_number),
396                    )
397                    .with_object(
398                        EventObjectRef::consumed(po_object.object_id, &po_type.type_id)
399                            .with_external_id(&documents.po_number),
400                    )
401                    .with_document_ref(documents.payment_number.as_deref().unwrap_or(""));
402                Self::add_event_attribute(
403                    &mut event,
404                    "payment_amount",
405                    ObjectAttributeValue::Decimal(documents.amount),
406                );
407                events.push(event);
408            }
409        }
410
411        let case_trace = self.create_case_trace(
412            case_id,
413            &events,
414            BusinessProcess::P2P,
415            po_object.object_id,
416            &po_type.type_id,
417            &documents.company_code,
418        );
419
420        CaseGenerationResult {
421            events,
422            objects,
423            relationships,
424            case_trace,
425            variant_type,
426        }
427    }
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433
434    #[test]
435    fn test_p2p_case_generation() {
436        let mut generator = OcpmEventGenerator::new(42);
437        let documents = P2pDocuments::new(
438            "PO-000001",
439            "V000001",
440            "1000",
441            Decimal::new(10000, 0),
442            "USD",
443        )
444        .with_goods_receipt("GR-000001")
445        .with_invoice("INV-000001")
446        .with_payment("PAY-000001");
447
448        let result = generator.generate_p2p_case(
449            &documents,
450            Utc::now(),
451            &["user001".into(), "user002".into()],
452        );
453
454        // Should have at least 3 events (create, approve, release)
455        assert!(result.events.len() >= 3);
456        // Should have objects
457        assert!(!result.objects.is_empty());
458        // Should have case trace
459        assert!(!result.case_trace.activity_sequence.is_empty());
460    }
461
462    #[test]
463    fn test_p2p_error_path() {
464        // Use a seed that produces error paths more often
465        let mut generator = OcpmEventGenerator::with_config(
466            123,
467            super::super::OcpmGeneratorConfig {
468                error_path_rate: 1.0,
469                happy_path_rate: 0.0,
470                exception_path_rate: 0.0,
471                ..Default::default()
472            },
473        );
474
475        let documents =
476            P2pDocuments::new("PO-000002", "V000001", "1000", Decimal::new(5000, 0), "USD")
477                .with_goods_receipt("GR-000002")
478                .with_invoice("INV-000002");
479
480        let result = generator.generate_p2p_case(&documents, Utc::now(), &[]);
481
482        // Error path should stop early (only create, approve, release)
483        assert_eq!(result.variant_type, VariantType::ErrorPath);
484        assert_eq!(result.events.len(), 3);
485    }
486}