1use crate::Database;
84use chrono::Utc;
85use serde::{Deserialize, Serialize};
86use serde_json::{Map, Value};
87use stateset_core::{CommerceError, Result};
88use std::collections::HashMap;
89use std::io::{Read, Write};
90
91pub const FORMAT_VERSION: u32 = 1;
93
94pub const PAGE_SIZE: u32 = 500;
99
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102#[non_exhaustive]
103pub struct ExportHeader {
104 pub format_version: u32,
106 pub engine_version: String,
108 pub exported_at: String,
110 pub schema_version: String,
112}
113
114#[derive(Debug, Clone)]
116pub struct ExportOptions {
117 pub domains: Vec<String>,
119 pub page_size: u32,
121 pub pretty: bool,
123 pub schema_version: String,
125}
126
127impl Default for ExportOptions {
128 fn default() -> Self {
129 Self {
130 domains: Vec::new(),
131 page_size: PAGE_SIZE,
132 pretty: false,
133 schema_version: String::new(),
134 }
135 }
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140#[non_exhaustive]
141pub struct ExportReport {
142 pub counts: Vec<(String, usize)>,
144 pub total: usize,
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
150#[serde(rename_all = "snake_case")]
151#[non_exhaustive]
152pub enum ConflictPolicy {
153 #[default]
155 Skip,
156 Fail,
158}
159
160#[derive(Debug, Clone, Default)]
162pub struct ImportOptions {
163 pub domains: Vec<String>,
165 pub on_conflict: ConflictPolicy,
167 pub dry_run: bool,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
173#[non_exhaustive]
174pub struct ImportReport {
175 pub created: Vec<(String, usize)>,
177 pub skipped: Vec<(String, usize)>,
179 pub unsupported_domains: Vec<String>,
181 pub total_created: usize,
183}
184
185#[derive(Debug, Default)]
187struct IdRemap {
188 by_domain: HashMap<&'static str, HashMap<String, String>>,
189}
190
191impl IdRemap {
192 fn record(&mut self, domain: &'static str, old: &str, new: String) {
193 self.by_domain.entry(domain).or_default().insert(old.to_owned(), new);
194 }
195
196 fn lookup(&self, domain: &str, old: &str) -> Option<&String> {
197 self.by_domain.get(domain).and_then(|m| m.get(old))
198 }
199}
200
201type Fetch = fn(&dyn Database, u32, u32) -> Result<Vec<Value>>;
207type Import = fn(&dyn Database, &Value, &mut IdRemap, ConflictPolicy) -> Result<Outcome>;
209
210#[derive(Debug, Clone, Copy, PartialEq, Eq)]
212enum Outcome {
213 Created,
214 Skipped,
215}
216
217struct DomainSpec {
219 name: &'static str,
221 fetch: Fetch,
223 import: Option<Import>,
225}
226
227fn registry() -> Vec<DomainSpec> {
230 vec![
231 DomainSpec { name: "customers", fetch: fetch_customers, import: Some(import_customer) },
232 DomainSpec { name: "products", fetch: fetch_products, import: Some(import_product) },
233 DomainSpec {
234 name: "inventory_items",
235 fetch: fetch_inventory_items,
236 import: Some(import_inventory_item),
237 },
238 DomainSpec { name: "warehouses", fetch: fetch_warehouses, import: Some(import_warehouse) },
239 DomainSpec { name: "suppliers", fetch: fetch_suppliers, import: Some(import_supplier) },
240 DomainSpec {
241 name: "gl_accounts",
242 fetch: fetch_gl_accounts,
243 import: Some(import_gl_account),
244 },
245 DomainSpec { name: "orders", fetch: fetch_orders, import: Some(import_order) },
246 DomainSpec {
247 name: "purchase_orders",
248 fetch: fetch_purchase_orders,
249 import: Some(import_purchase_order),
250 },
251 DomainSpec { name: "gl_periods", fetch: fetch_gl_periods, import: Some(import_gl_period) },
252 DomainSpec {
253 name: "journal_entries",
254 fetch: fetch_journal_entries,
255 import: Some(import_journal_entry),
256 },
257 DomainSpec { name: "returns", fetch: fetch_returns, import: None },
260 DomainSpec { name: "invoices", fetch: fetch_invoices, import: None },
261 DomainSpec { name: "payments", fetch: fetch_payments, import: None },
262 DomainSpec { name: "bills", fetch: fetch_bills, import: None },
263 ]
264}
265
266#[must_use]
268pub fn exportable_domains() -> Vec<&'static str> {
269 registry().into_iter().map(|d| d.name).collect()
270}
271
272#[must_use]
274pub fn importable_domains() -> Vec<&'static str> {
275 registry().into_iter().filter(|d| d.import.is_some()).map(|d| d.name).collect()
276}
277
278fn json_err(err: serde_json::Error) -> CommerceError {
283 CommerceError::ValidationError(format!("json error: {err}"))
284}
285
286fn io_err(err: std::io::Error) -> CommerceError {
287 CommerceError::DatabaseError(format!("export io error: {err}"))
288}
289
290fn to_values<T: Serialize>(items: Vec<T>) -> Result<Vec<Value>> {
291 items.into_iter().map(|item| serde_json::to_value(item).map_err(json_err)).collect()
292}
293
294pub fn export_all<W: Write>(
305 db: &dyn Database,
306 writer: &mut W,
307 options: &ExportOptions,
308) -> Result<ExportReport> {
309 let page_size = options.page_size.clamp(1, 1000);
310 let header = ExportHeader {
311 format_version: FORMAT_VERSION,
312 engine_version: env!("CARGO_PKG_VERSION").to_owned(),
313 exported_at: Utc::now().to_rfc3339(),
314 schema_version: options.schema_version.clone(),
315 };
316
317 let mut out = Vec::new();
318 write!(
319 out,
320 "{{\"format_version\":{},\"engine_version\":{},\"exported_at\":{},\"schema_version\":{},\"domains\":{{",
321 header.format_version,
322 serde_json::to_string(&header.engine_version).map_err(json_err)?,
323 serde_json::to_string(&header.exported_at).map_err(json_err)?,
324 serde_json::to_string(&header.schema_version).map_err(json_err)?,
325 )
326 .map_err(io_err)?;
327 writer.write_all(&out).map_err(io_err)?;
328
329 let mut counts = Vec::new();
330 let mut total = 0_usize;
331 let mut first_domain = true;
332
333 for spec in registry() {
334 if !options.domains.is_empty() && !options.domains.iter().any(|d| d == spec.name) {
335 continue;
336 }
337 if !first_domain {
338 writer.write_all(b",").map_err(io_err)?;
339 }
340 first_domain = false;
341 write!(writer, "{}:[", serde_json::to_string(spec.name).map_err(json_err)?)
342 .map_err(io_err)?;
343
344 let mut domain_count = 0_usize;
345 let mut offset = 0_u32;
346 loop {
347 let page = (spec.fetch)(db, page_size, offset)?;
348 let fetched = u32::try_from(page.len()).unwrap_or(u32::MAX);
349 for record in page {
350 if domain_count > 0 {
351 writer.write_all(b",").map_err(io_err)?;
352 }
353 let encoded = if options.pretty {
354 serde_json::to_vec_pretty(&record)
355 } else {
356 serde_json::to_vec(&record)
357 }
358 .map_err(json_err)?;
359 writer.write_all(&encoded).map_err(io_err)?;
360 domain_count += 1;
361 }
362 if fetched < page_size {
363 break;
364 }
365 offset = offset.saturating_add(page_size);
366 if offset > 10_000_000 {
369 break;
370 }
371 }
372
373 writer.write_all(b"]").map_err(io_err)?;
374 total += domain_count;
375 counts.push((spec.name.to_owned(), domain_count));
376 }
377
378 writer.write_all(b"}}").map_err(io_err)?;
379 writer.flush().map_err(io_err)?;
380
381 Ok(ExportReport { counts, total })
382}
383
384pub fn import_all<R: Read>(
398 db: &dyn Database,
399 reader: &mut R,
400 options: &ImportOptions,
401) -> Result<ImportReport> {
402 let mut buf = String::new();
403 reader
404 .read_to_string(&mut buf)
405 .map_err(|e| CommerceError::DatabaseError(format!("import io error: {e}")))?;
406 let envelope: Value = serde_json::from_str(&buf).map_err(json_err)?;
407
408 let format_version =
409 envelope.get("format_version").and_then(Value::as_u64).ok_or_else(|| {
410 CommerceError::ValidationError("export envelope is missing format_version".to_owned())
411 })?;
412 if format_version != u64::from(FORMAT_VERSION) {
413 return Err(CommerceError::ValidationError(format!(
414 "unsupported export format_version {format_version}; this build reads {FORMAT_VERSION}"
415 )));
416 }
417
418 let domains = envelope.get("domains").and_then(Value::as_object).ok_or_else(|| {
419 CommerceError::ValidationError("export envelope is missing a domains object".to_owned())
420 })?;
421
422 let mut remap = IdRemap::default();
423 let mut created = Vec::new();
424 let mut skipped = Vec::new();
425 let mut total_created = 0_usize;
426
427 let specs = registry();
428 for spec in &specs {
429 let Some(importer) = spec.import else { continue };
430 if !options.domains.is_empty() && !options.domains.iter().any(|d| d == spec.name) {
431 continue;
432 }
433 let Some(records) = domains.get(spec.name).and_then(Value::as_array) else { continue };
434
435 let mut n_created = 0_usize;
436 let mut n_skipped = 0_usize;
437 for record in records {
438 if options.dry_run {
439 n_skipped += 1;
440 continue;
441 }
442 match importer(db, record, &mut remap, options.on_conflict)? {
443 Outcome::Created => n_created += 1,
444 Outcome::Skipped => n_skipped += 1,
445 }
446 }
447 total_created += n_created;
448 created.push((spec.name.to_owned(), n_created));
449 skipped.push((spec.name.to_owned(), n_skipped));
450 }
451
452 let importable = importable_domains();
453 let unsupported_domains = domains
454 .keys()
455 .filter(|k| !importable.contains(&k.as_str()))
456 .filter(|k| domains.get(*k).and_then(Value::as_array).is_some_and(|a| !a.is_empty()))
457 .cloned()
458 .collect();
459
460 Ok(ImportReport { created, skipped, unsupported_domains, total_created })
461}
462
463fn obj(record: &Value) -> Result<&Map<String, Value>> {
468 record
469 .as_object()
470 .ok_or_else(|| CommerceError::ValidationError("export record is not an object".to_owned()))
471}
472
473fn str_field(record: &Value, field: &str) -> Result<String> {
474 obj(record)?
475 .get(field)
476 .and_then(Value::as_str)
477 .map(ToOwned::to_owned)
478 .ok_or_else(|| CommerceError::ValidationError(format!("record is missing '{field}'")))
479}
480
481fn opt_str(record: &Value, field: &str) -> Option<String> {
482 record.get(field).and_then(Value::as_str).map(ToOwned::to_owned)
483}
484
485fn any_field_as<T: for<'de> Deserialize<'de>>(record: &Value, fields: &[&str]) -> Result<T> {
491 let value = fields
492 .iter()
493 .find_map(|field| record.get(*field).filter(|v| !v.is_null()).cloned())
494 .unwrap_or(Value::Null);
495 serde_json::from_value(value).map_err(|e| {
496 CommerceError::ValidationError(format!("field '{}': {e}", fields.join("' / '")))
497 })
498}
499
500fn field_as<T: for<'de> Deserialize<'de>>(record: &Value, field: &str) -> Result<T> {
502 let value = record.get(field).cloned().unwrap_or(Value::Null);
503 serde_json::from_value(value)
504 .map_err(|e| CommerceError::ValidationError(format!("field '{field}': {e}")))
505}
506
507fn record_as<T: for<'de> Deserialize<'de>>(record: &Value) -> Result<T> {
510 serde_json::from_value(record.clone())
511 .map_err(|e| CommerceError::ValidationError(format!("cannot decode record: {e}")))
512}
513
514fn remapped_id(remap: &IdRemap, domain: &str, raw: &str) -> String {
517 remap.lookup(domain, raw).cloned().unwrap_or_else(|| raw.to_owned())
518}
519
520fn parse_uuid(raw: &str, field: &str) -> Result<uuid::Uuid> {
521 raw.parse::<uuid::Uuid>()
522 .map_err(|e| CommerceError::ValidationError(format!("field '{field}' is not a uuid: {e}")))
523}
524
525fn fetch_customers(db: &dyn Database, limit: u32, offset: u32) -> Result<Vec<Value>> {
530 let filter = stateset_core::CustomerFilter {
531 limit: Some(limit),
532 offset: Some(offset),
533 ..Default::default()
534 };
535 to_values(db.customers().list(filter)?)
536}
537
538fn import_customer(
539 db: &dyn Database,
540 record: &Value,
541 remap: &mut IdRemap,
542 policy: ConflictPolicy,
543) -> Result<Outcome> {
544 let old_id = str_field(record, "id")?;
545 let email = str_field(record, "email")?;
546 if let Some(existing) = db.customers().get_by_email(&email)? {
547 if policy == ConflictPolicy::Fail {
548 return Err(CommerceError::ValidationError(format!(
549 "customer with email '{email}' already exists"
550 )));
551 }
552 remap.record("customers", &old_id, existing.id.to_string());
553 return Ok(Outcome::Skipped);
554 }
555 let input = stateset_core::CreateCustomer {
556 email,
557 first_name: str_field(record, "first_name")?,
558 last_name: str_field(record, "last_name")?,
559 phone: opt_str(record, "phone"),
560 accepts_marketing: record.get("accepts_marketing").and_then(Value::as_bool),
561 tags: field_as::<Option<Vec<String>>>(record, "tags")?,
562 metadata: record.get("metadata").cloned().filter(|v| !v.is_null()),
563 };
564 let created = db.customers().create(input)?;
565 remap.record("customers", &old_id, created.id.to_string());
566 Ok(Outcome::Created)
567}
568
569fn fetch_products(db: &dyn Database, limit: u32, offset: u32) -> Result<Vec<Value>> {
570 let filter = stateset_core::ProductFilter {
571 limit: Some(limit),
572 offset: Some(offset),
573 ..Default::default()
574 };
575 to_values(db.products().list(filter)?)
576}
577
578fn import_product(
579 db: &dyn Database,
580 record: &Value,
581 remap: &mut IdRemap,
582 policy: ConflictPolicy,
583) -> Result<Outcome> {
584 let old_id = str_field(record, "id")?;
585 let name = str_field(record, "name")?;
586 let slug = opt_str(record, "slug");
587
588 if let Some(slug) = slug.as_deref() {
589 let existing = db.products().list(stateset_core::ProductFilter {
590 search: Some(slug.to_owned()),
591 limit: Some(1000),
592 ..Default::default()
593 })?;
594 if let Some(found) = existing.iter().find(|p| p.slug == slug) {
595 if policy == ConflictPolicy::Fail {
596 return Err(CommerceError::ValidationError(format!(
597 "product with slug '{slug}' already exists"
598 )));
599 }
600 remap.record("products", &old_id, found.id.to_string());
601 return Ok(Outcome::Skipped);
602 }
603 }
604
605 let variants: Option<Vec<stateset_core::CreateProductVariant>> =
606 match record.get("variants").and_then(Value::as_array) {
607 Some(list) if !list.is_empty() => Some(
608 list.iter()
609 .map(record_as::<stateset_core::CreateProductVariant>)
610 .collect::<Result<Vec<_>>>()?,
611 ),
612 _ => None,
613 };
614
615 let input = stateset_core::CreateProduct {
616 name,
617 slug,
618 description: opt_str(record, "description"),
619 product_type: field_as(record, "product_type")?,
620 attributes: field_as(record, "attributes")?,
621 seo: field_as(record, "seo")?,
622 variants,
623 };
624 let created = db.products().create(input)?;
625 remap.record("products", &old_id, created.id.to_string());
626 Ok(Outcome::Created)
627}
628
629fn fetch_inventory_items(db: &dyn Database, limit: u32, offset: u32) -> Result<Vec<Value>> {
630 let filter = stateset_core::InventoryFilter {
631 limit: Some(limit),
632 offset: Some(offset),
633 ..Default::default()
634 };
635 to_values(db.inventory().list(filter)?)
636}
637
638fn import_inventory_item(
639 db: &dyn Database,
640 record: &Value,
641 _remap: &mut IdRemap,
642 policy: ConflictPolicy,
643) -> Result<Outcome> {
644 let sku = str_field(record, "sku")?;
645 if db.inventory().get_item_by_sku(&sku)?.is_some() {
646 if policy == ConflictPolicy::Fail {
647 return Err(CommerceError::ValidationError(format!(
648 "inventory item with sku '{sku}' already exists"
649 )));
650 }
651 return Ok(Outcome::Skipped);
652 }
653 let input = stateset_core::CreateInventoryItem {
654 sku,
655 name: str_field(record, "name")?,
656 description: opt_str(record, "description"),
657 unit_of_measure: opt_str(record, "unit_of_measure"),
658 initial_quantity: field_as(record, "initial_quantity")?,
659 location_id: record
660 .get("location_id")
661 .and_then(Value::as_i64)
662 .and_then(|v| i32::try_from(v).ok()),
663 reorder_point: field_as(record, "reorder_point")?,
664 safety_stock: field_as(record, "safety_stock")?,
665 };
666 db.inventory().create_item(input)?;
667 Ok(Outcome::Created)
668}
669
670fn fetch_warehouses(db: &dyn Database, limit: u32, offset: u32) -> Result<Vec<Value>> {
671 let filter = stateset_core::WarehouseFilter {
672 limit: Some(limit),
673 offset: Some(offset),
674 ..Default::default()
675 };
676 to_values(db.warehouse().list_warehouses(filter)?)
677}
678
679fn import_warehouse(
680 db: &dyn Database,
681 record: &Value,
682 remap: &mut IdRemap,
683 policy: ConflictPolicy,
684) -> Result<Outcome> {
685 let code = str_field(record, "code")?;
686 let old_id = record.get("id").map(ToString::to_string).unwrap_or_default();
687 if let Some(existing) = db.warehouse().get_warehouse_by_code(&code)? {
688 if policy == ConflictPolicy::Fail {
689 return Err(CommerceError::ValidationError(format!(
690 "warehouse with code '{code}' already exists"
691 )));
692 }
693 remap.record("warehouses", &old_id, existing.id.to_string());
694 return Ok(Outcome::Skipped);
695 }
696 let input = stateset_core::CreateWarehouse {
697 code,
698 name: str_field(record, "name")?,
699 warehouse_type: field_as(record, "warehouse_type")?,
700 address: field_as(record, "address")?,
701 timezone: opt_str(record, "timezone"),
702 };
703 let created = db.warehouse().create_warehouse(input)?;
704 remap.record("warehouses", &old_id, created.id.to_string());
705 Ok(Outcome::Created)
706}
707
708fn fetch_suppliers(db: &dyn Database, limit: u32, offset: u32) -> Result<Vec<Value>> {
709 let filter = stateset_core::SupplierFilter {
710 limit: Some(limit),
711 offset: Some(offset),
712 ..Default::default()
713 };
714 to_values(db.purchase_orders().list_suppliers(filter)?)
715}
716
717fn import_supplier(
718 db: &dyn Database,
719 record: &Value,
720 remap: &mut IdRemap,
721 policy: ConflictPolicy,
722) -> Result<Outcome> {
723 let old_id = str_field(record, "id")?;
724 let code = opt_str(record, "supplier_code");
725 if let Some(code) = code.as_deref() {
726 if let Some(existing) = db.purchase_orders().get_supplier_by_code(code)? {
727 if policy == ConflictPolicy::Fail {
728 return Err(CommerceError::ValidationError(format!(
729 "supplier with code '{code}' already exists"
730 )));
731 }
732 remap.record("suppliers", &old_id, existing.id.to_string());
733 return Ok(Outcome::Skipped);
734 }
735 }
736 let input = stateset_core::CreateSupplier {
737 name: str_field(record, "name")?,
738 supplier_code: code,
739 contact_name: opt_str(record, "contact_name"),
740 email: opt_str(record, "email"),
741 phone: opt_str(record, "phone"),
742 website: opt_str(record, "website"),
743 address: opt_str(record, "address"),
744 city: opt_str(record, "city"),
745 state: opt_str(record, "state"),
746 postal_code: opt_str(record, "postal_code"),
747 country: opt_str(record, "country"),
748 tax_id: opt_str(record, "tax_id"),
749 payment_terms: field_as(record, "payment_terms")?,
750 currency: field_as(record, "currency")?,
751 lead_time_days: record
752 .get("lead_time_days")
753 .and_then(Value::as_i64)
754 .and_then(|v| i32::try_from(v).ok()),
755 minimum_order: field_as(record, "minimum_order")?,
756 notes: opt_str(record, "notes"),
757 };
758 let created = db.purchase_orders().create_supplier(input)?;
759 remap.record("suppliers", &old_id, created.id.to_string());
760 Ok(Outcome::Created)
761}
762
763fn fetch_gl_accounts(db: &dyn Database, limit: u32, offset: u32) -> Result<Vec<Value>> {
764 let filter = stateset_core::GlAccountFilter {
765 limit: Some(limit),
766 offset: Some(offset),
767 ..Default::default()
768 };
769 to_values(db.general_ledger().list_accounts(filter)?)
770}
771
772fn import_gl_account(
773 db: &dyn Database,
774 record: &Value,
775 remap: &mut IdRemap,
776 policy: ConflictPolicy,
777) -> Result<Outcome> {
778 let old_id = str_field(record, "id")?;
779 let account_number = str_field(record, "account_number")?;
780 if let Some(existing) = db.general_ledger().get_account_by_number(&account_number)? {
781 if policy == ConflictPolicy::Fail {
782 return Err(CommerceError::ValidationError(format!(
783 "gl account '{account_number}' already exists"
784 )));
785 }
786 remap.record("gl_accounts", &old_id, existing.id.to_string());
787 return Ok(Outcome::Skipped);
788 }
789 let parent_account_id = match opt_str(record, "parent_account_id") {
791 Some(parent) => {
792 Some(parse_uuid(&remapped_id(remap, "gl_accounts", &parent), "parent_account_id")?)
793 }
794 None => None,
795 };
796 let input = stateset_core::CreateGlAccount {
797 account_number,
798 name: str_field(record, "name")?,
799 description: opt_str(record, "description"),
800 account_type: field_as(record, "account_type")?,
801 account_sub_type: field_as(record, "account_sub_type")?,
802 parent_account_id,
803 is_header: record.get("is_header").and_then(Value::as_bool),
804 is_posting: record.get("is_posting").and_then(Value::as_bool),
805 currency: field_as(record, "currency")?,
806 };
807 let created = db.general_ledger().create_account(input)?;
808 remap.record("gl_accounts", &old_id, created.id.to_string());
809 Ok(Outcome::Created)
810}
811
812fn fetch_orders(db: &dyn Database, limit: u32, offset: u32) -> Result<Vec<Value>> {
813 let filter = stateset_core::OrderFilter {
814 limit: Some(limit),
815 offset: Some(offset),
816 ..Default::default()
817 };
818 to_values(db.orders().list(filter)?)
819}
820
821fn import_order(
822 db: &dyn Database,
823 record: &Value,
824 remap: &mut IdRemap,
825 _policy: ConflictPolicy,
826) -> Result<Outcome> {
827 let raw_customer = str_field(record, "customer_id")?;
828 let customer_id = parse_uuid(&remapped_id(remap, "customers", &raw_customer), "customer_id")?;
829
830 let items = record
831 .get("items")
832 .and_then(Value::as_array)
833 .map(|list| {
834 list.iter()
835 .map(|item| {
836 let raw_product = str_field(item, "product_id")?;
837 Ok(stateset_core::CreateOrderItem {
838 product_id: parse_uuid(
839 &remapped_id(remap, "products", &raw_product),
840 "product_id",
841 )?
842 .into(),
843 variant_id: None,
844 sku: str_field(item, "sku")?,
845 name: str_field(item, "name")?,
846 quantity: item
847 .get("quantity")
848 .and_then(Value::as_i64)
849 .and_then(|q| i32::try_from(q).ok())
850 .unwrap_or(0),
851 unit_price: field_as(item, "unit_price")?,
852 discount: field_as(item, "discount")?,
853 tax_amount: field_as(item, "tax_amount")?,
854 })
855 })
856 .collect::<Result<Vec<_>>>()
857 })
858 .transpose()?
859 .unwrap_or_default();
860
861 let input = stateset_core::CreateOrder {
862 customer_id: customer_id.into(),
863 items,
864 currency: field_as(record, "currency")?,
865 shipping_address: field_as(record, "shipping_address")?,
866 billing_address: field_as(record, "billing_address")?,
867 notes: opt_str(record, "notes"),
868 payment_method: opt_str(record, "payment_method"),
869 shipping_method: opt_str(record, "shipping_method"),
870 };
871 let created = db.orders().create(input)?;
872 if let Ok(old_id) = str_field(record, "id") {
873 remap.record("orders", &old_id, created.id.to_string());
874 }
875 Ok(Outcome::Created)
876}
877
878fn fetch_purchase_orders(db: &dyn Database, limit: u32, offset: u32) -> Result<Vec<Value>> {
879 let filter = stateset_core::PurchaseOrderFilter {
880 limit: Some(limit),
881 offset: Some(offset),
882 ..Default::default()
883 };
884 to_values(db.purchase_orders().list(filter)?)
885}
886
887fn import_purchase_order(
888 db: &dyn Database,
889 record: &Value,
890 remap: &mut IdRemap,
891 _policy: ConflictPolicy,
892) -> Result<Outcome> {
893 let raw_supplier = str_field(record, "supplier_id")?;
894 let supplier_id = parse_uuid(&remapped_id(remap, "suppliers", &raw_supplier), "supplier_id")?;
895
896 let items = record
897 .get("items")
898 .and_then(Value::as_array)
899 .map(|list| {
900 list.iter()
901 .map(|item| {
902 let product_id = match opt_str(item, "product_id") {
903 Some(raw) => Some(
904 parse_uuid(&remapped_id(remap, "products", &raw), "product_id")?.into(),
905 ),
906 None => None,
907 };
908 Ok(stateset_core::CreatePurchaseOrderItem {
909 product_id,
910 sku: str_field(item, "sku")?,
911 name: str_field(item, "name")?,
912 supplier_sku: opt_str(item, "supplier_sku"),
913 quantity: any_field_as(item, &["quantity", "quantity_ordered"])?,
914 unit_of_measure: opt_str(item, "unit_of_measure"),
915 unit_cost: field_as(item, "unit_cost")?,
916 tax_amount: field_as(item, "tax_amount")?,
917 discount_amount: field_as(item, "discount_amount")?,
918 expected_date: field_as(item, "expected_date")?,
919 notes: opt_str(item, "notes"),
920 })
921 })
922 .collect::<Result<Vec<_>>>()
923 })
924 .transpose()?
925 .unwrap_or_default();
926
927 let input = stateset_core::CreatePurchaseOrder {
928 supplier_id,
929 order_date: field_as(record, "order_date")?,
930 expected_date: field_as(record, "expected_date")?,
931 ship_to_address: opt_str(record, "ship_to_address"),
932 ship_to_city: opt_str(record, "ship_to_city"),
933 ship_to_state: opt_str(record, "ship_to_state"),
934 ship_to_postal_code: opt_str(record, "ship_to_postal_code"),
935 ship_to_country: opt_str(record, "ship_to_country"),
936 payment_terms: field_as(record, "payment_terms")?,
937 currency: field_as(record, "currency")?,
938 tax_amount: field_as(record, "tax_amount")?,
939 shipping_cost: field_as(record, "shipping_cost")?,
940 discount_amount: field_as(record, "discount_amount")?,
941 notes: opt_str(record, "notes"),
942 supplier_notes: opt_str(record, "supplier_notes"),
943 items,
944 };
945 db.purchase_orders().create(input)?;
946 Ok(Outcome::Created)
947}
948
949fn fetch_gl_periods(db: &dyn Database, limit: u32, offset: u32) -> Result<Vec<Value>> {
950 let filter = stateset_core::GlPeriodFilter {
951 limit: Some(limit),
952 offset: Some(offset),
953 ..Default::default()
954 };
955 to_values(db.general_ledger().list_periods(filter)?)
956}
957
958fn import_gl_period(
959 db: &dyn Database,
960 record: &Value,
961 remap: &mut IdRemap,
962 policy: ConflictPolicy,
963) -> Result<Outcome> {
964 let old_id = str_field(record, "id")?;
965 let fiscal_year = record.get("fiscal_year").and_then(Value::as_i64).unwrap_or(0);
966 let period_number = record.get("period_number").and_then(Value::as_i64).unwrap_or(0);
967
968 let existing = db.general_ledger().list_periods(stateset_core::GlPeriodFilter {
969 fiscal_year: i32::try_from(fiscal_year).ok(),
970 limit: Some(1000),
971 ..Default::default()
972 })?;
973 if let Some(found) = existing.into_iter().find(|p| i64::from(p.period_number) == period_number)
974 {
975 if policy == ConflictPolicy::Fail {
976 return Err(CommerceError::ValidationError(format!(
977 "gl period {fiscal_year}-{period_number} already exists"
978 )));
979 }
980 remap.record("gl_periods", &old_id, found.id.to_string());
981 return Ok(Outcome::Skipped);
982 }
983
984 let input = stateset_core::CreateGlPeriod {
985 period_name: str_field(record, "period_name")?,
986 fiscal_year: i32::try_from(fiscal_year).unwrap_or_default(),
987 period_number: i32::try_from(period_number).unwrap_or_default(),
988 start_date: field_as(record, "start_date")?,
989 end_date: field_as(record, "end_date")?,
990 };
991 let created = db.general_ledger().create_period(input)?;
992 if opt_str(record, "status").as_deref() == Some("open") {
994 db.general_ledger().open_period(created.id)?;
995 }
996 remap.record("gl_periods", &old_id, created.id.to_string());
997 Ok(Outcome::Created)
998}
999
1000fn fetch_journal_entries(db: &dyn Database, limit: u32, offset: u32) -> Result<Vec<Value>> {
1001 let filter = stateset_core::JournalEntryFilter {
1002 limit: Some(limit),
1003 offset: Some(offset),
1004 ..Default::default()
1005 };
1006 to_values(db.general_ledger().list_journal_entries(filter)?)
1007}
1008
1009fn import_journal_entry(
1010 db: &dyn Database,
1011 record: &Value,
1012 remap: &mut IdRemap,
1013 _policy: ConflictPolicy,
1014) -> Result<Outcome> {
1015 let lines = record
1016 .get("lines")
1017 .and_then(Value::as_array)
1018 .map(|list| {
1019 list.iter()
1020 .map(|line| {
1021 let raw_account = str_field(line, "account_id")?;
1022 Ok(stateset_core::CreateJournalEntryLine {
1023 account_id: parse_uuid(
1024 &remapped_id(remap, "gl_accounts", &raw_account),
1025 "account_id",
1026 )?,
1027 description: opt_str(line, "description"),
1028 debit_amount: field_as(line, "debit_amount")?,
1029 credit_amount: field_as(line, "credit_amount")?,
1030 reference_type: opt_str(line, "reference_type"),
1031 reference_id: match opt_str(line, "reference_id") {
1032 Some(raw) => Some(parse_uuid(&raw, "reference_id")?),
1033 None => None,
1034 },
1035 })
1036 })
1037 .collect::<Result<Vec<_>>>()
1038 })
1039 .transpose()?
1040 .unwrap_or_default();
1041
1042 let input = stateset_core::CreateJournalEntry {
1043 entry_date: field_as(record, "entry_date")?,
1044 entry_type: field_as(record, "entry_type")?,
1045 description: str_field(record, "description")?,
1046 lines,
1047 source_document_type: opt_str(record, "source_document_type"),
1048 source_document_id: match opt_str(record, "source_document_id") {
1049 Some(raw) => Some(parse_uuid(&raw, "source_document_id")?),
1050 None => None,
1051 },
1052 auto_post: Some(false),
1053 };
1054 db.general_ledger().create_journal_entry(input)?;
1055 Ok(Outcome::Created)
1056}
1057
1058fn fetch_returns(db: &dyn Database, limit: u32, offset: u32) -> Result<Vec<Value>> {
1061 let filter = stateset_core::ReturnFilter {
1062 limit: Some(limit),
1063 offset: Some(offset),
1064 ..Default::default()
1065 };
1066 to_values(db.returns().list(filter)?)
1067}
1068
1069fn fetch_invoices(db: &dyn Database, limit: u32, offset: u32) -> Result<Vec<Value>> {
1070 let filter = stateset_core::InvoiceFilter {
1071 limit: Some(limit),
1072 offset: Some(offset),
1073 ..Default::default()
1074 };
1075 to_values(db.invoices().list(filter)?)
1076}
1077
1078fn fetch_payments(db: &dyn Database, limit: u32, offset: u32) -> Result<Vec<Value>> {
1079 let filter = stateset_core::PaymentFilter {
1080 limit: Some(limit),
1081 offset: Some(offset),
1082 ..Default::default()
1083 };
1084 to_values(db.payments().list(filter)?)
1085}
1086
1087fn fetch_bills(db: &dyn Database, limit: u32, offset: u32) -> Result<Vec<Value>> {
1088 let filter = stateset_core::BillFilter {
1089 limit: Some(limit),
1090 offset: Some(offset),
1091 ..Default::default()
1092 };
1093 to_values(db.accounts_payable().list_bills(filter)?)
1094}