@fluentcommerce/fc-connect-sdk 0.1.54 → 0.1.55
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/dist/cjs/clients/fluent-client.js +13 -6
- package/dist/cjs/utils/pagination-helpers.js +38 -2
- package/dist/cjs/versori/fluent-versori-client.js +11 -5
- package/dist/esm/clients/fluent-client.js +13 -6
- package/dist/esm/utils/pagination-helpers.js +38 -2
- package/dist/esm/versori/fluent-versori-client.js +11 -5
- package/dist/tsconfig.esm.tsbuildinfo +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/tsconfig.types.tsbuildinfo +1 -1
- package/docs/00-START-HERE/EXPORT-VALIDATION.md +158 -158
- package/docs/00-START-HERE/cli-analyze-source-structure-guide.md +655 -655
- package/docs/00-START-HERE/cli-documentation-index.md +202 -202
- package/docs/00-START-HERE/cli-quick-reference.md +252 -252
- package/docs/00-START-HERE/decision-tree.md +552 -552
- package/docs/00-START-HERE/getting-started.md +1070 -1070
- package/docs/00-START-HERE/mapper-quick-decision-guide.md +235 -235
- package/docs/00-START-HERE/readme.md +237 -237
- package/docs/00-START-HERE/retailerid-configuration.md +404 -404
- package/docs/00-START-HERE/sdk-philosophy.md +794 -794
- package/docs/00-START-HERE/troubleshooting-quick-reference.md +1086 -1086
- package/docs/01-TEMPLATES/faq.md +686 -686
- package/docs/01-TEMPLATES/patterns/pattern-templates-guide.md +68 -68
- package/docs/01-TEMPLATES/patterns/patterns-csv-schema-validation-and-rejection-report.md +233 -233
- package/docs/01-TEMPLATES/patterns/patterns-custom-resolvers.md +407 -407
- package/docs/01-TEMPLATES/patterns/patterns-error-handling-retry.md +511 -511
- package/docs/01-TEMPLATES/patterns/patterns-field-mapping-universal.md +701 -701
- package/docs/01-TEMPLATES/patterns/patterns-large-file-splitting.md +1430 -1430
- package/docs/01-TEMPLATES/patterns/patterns-master-data-etl.md +2399 -2399
- package/docs/01-TEMPLATES/patterns/patterns-pagination-streaming.md +447 -447
- package/docs/01-TEMPLATES/patterns/patterns-state-duplicate-prevention.md +385 -385
- package/docs/01-TEMPLATES/readme.md +957 -957
- package/docs/01-TEMPLATES/standalone/standalone-asn-inbound-processing.md +1209 -1209
- package/docs/01-TEMPLATES/standalone/standalone-graphql-query-export.md +1140 -1140
- package/docs/01-TEMPLATES/standalone/standalone-graphql-to-parquet-partitioned-s3.md +432 -432
- package/docs/01-TEMPLATES/standalone/standalone-multi-channel-inventory-sync.md +1185 -1185
- package/docs/01-TEMPLATES/standalone/standalone-multi-source-aggregation.md +1462 -1462
- package/docs/01-TEMPLATES/standalone/standalone-s3-csv-batch-api.md +1390 -1390
- package/docs/01-TEMPLATES/standalone/standalone-s3-csv-inventory-to-batch.md +330 -330
- package/docs/01-TEMPLATES/standalone/standalone-scripts-guide.md +87 -87
- package/docs/01-TEMPLATES/standalone/standalone-sftp-xml-graphql.md +1444 -1444
- package/docs/01-TEMPLATES/standalone/standalone-webhook-payload-processing.md +688 -688
- package/docs/01-TEMPLATES/versori/business-examples/business-examples-dropship-order-routing.md +193 -193
- package/docs/01-TEMPLATES/versori/business-examples/business-examples-graphql-parquet-extraction.md +518 -518
- package/docs/01-TEMPLATES/versori/business-examples/business-examples-inter-location-transfers.md +2162 -2162
- package/docs/01-TEMPLATES/versori/business-examples/business-examples-pre-order-allocation.md +2226 -2226
- package/docs/01-TEMPLATES/versori/business-examples/business-scenarios-guide.md +87 -87
- package/docs/01-TEMPLATES/versori/patterns/versori-patterns-connection-validation-pattern.md +656 -656
- package/docs/01-TEMPLATES/versori/patterns/versori-patterns-dual-workflow-connector.md +835 -835
- package/docs/01-TEMPLATES/versori/patterns/versori-patterns-guide.md +108 -108
- package/docs/01-TEMPLATES/versori/patterns/versori-patterns-kv-state-management.md +1533 -1533
- package/docs/01-TEMPLATES/versori/patterns/versori-patterns-xml-response-patterns.md +1160 -1160
- package/docs/01-TEMPLATES/versori/versori-platform-guide.md +201 -201
- package/docs/01-TEMPLATES/versori/webhooks/template-webhook-asn-purchase-order.md +1906 -1906
- package/docs/01-TEMPLATES/versori/webhooks/template-webhook-dropship-routing.md +1074 -1074
- package/docs/01-TEMPLATES/versori/webhooks/template-webhook-flash-sale-reserve.md +1395 -1395
- package/docs/01-TEMPLATES/versori/webhooks/template-webhook-generic-xml-order.md +888 -888
- package/docs/01-TEMPLATES/versori/webhooks/template-webhook-payment-gateway-integration.md +2478 -2478
- package/docs/01-TEMPLATES/versori/webhooks/template-webhook-rma-returns-comprehensive.md +2240 -2240
- package/docs/01-TEMPLATES/versori/webhooks/template-webhook-xml-order-ingestion.md +2029 -2029
- package/docs/01-TEMPLATES/versori/webhooks/webhook-templates-guide.md +140 -140
- package/docs/01-TEMPLATES/versori/workflows/_examples/sample-data/inventory-mapping.json +20 -20
- package/docs/01-TEMPLATES/versori/workflows/_examples/sample-data/products_2025-01-22.csv +11 -11
- package/docs/01-TEMPLATES/versori/workflows/_examples/sample-data/sample-data-guide.md +34 -34
- package/docs/01-TEMPLATES/versori/workflows/_examples/workflow-examples-guide.md +36 -36
- package/docs/01-TEMPLATES/versori/workflows/extraction/extraction-modes-guide.md +1038 -1038
- package/docs/01-TEMPLATES/versori/workflows/extraction/extraction-workflows-guide.md +138 -138
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/graphql-extraction-guide.md +63 -63
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-fulfillments-to-sftp-csv.md +2062 -2062
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-fulfillments-to-sftp-xml.md +2294 -2294
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-inventory-positions-to-s3-csv.md +2461 -2461
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-inventory-positions-to-sftp-xml.md +2529 -2529
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-inventory-quantities-to-s3-csv.md +2464 -2464
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-inventory-quantities-to-s3-json.md +1959 -1959
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-orders-to-s3-csv.md +1953 -1953
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-orders-to-sftp-xml.md +2541 -2541
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-products-to-s3-json.md +2384 -2384
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-products-to-sftp-xml.md +2445 -2445
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-virtual-positions-to-s3-csv.md +2355 -2355
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-virtual-positions-to-s3-json.md +2042 -2042
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-virtual-positions-to-sftp-xml.md +2726 -2726
- package/docs/01-TEMPLATES/versori/workflows/ingestion/batch-api/batch-api-guide.md +206 -206
- package/docs/01-TEMPLATES/versori/workflows/ingestion/batch-api/template-ingestion-cycle-count-reconciliation.md +2030 -2030
- package/docs/01-TEMPLATES/versori/workflows/ingestion/batch-api/template-ingestion-multi-channel-inventory-sync.md +1882 -1882
- package/docs/01-TEMPLATES/versori/workflows/ingestion/batch-api/template-ingestion-s3-csv-inventory-batch.md +2827 -2827
- package/docs/01-TEMPLATES/versori/workflows/ingestion/batch-api/template-ingestion-s3-json-inventory-batch.md +1952 -1952
- package/docs/01-TEMPLATES/versori/workflows/ingestion/batch-api/template-ingestion-s3-xml-inventory-batch.md +3289 -3289
- package/docs/01-TEMPLATES/versori/workflows/ingestion/batch-api/template-ingestion-sftp-csv-inventory-batch.md +3064 -3064
- package/docs/01-TEMPLATES/versori/workflows/ingestion/batch-api/template-ingestion-sftp-json-inventory-batch.md +3238 -3238
- package/docs/01-TEMPLATES/versori/workflows/ingestion/batch-api/template-ingestion-sftp-xml-inventory-batch.md +2977 -2977
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/event-api-guide.md +321 -321
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/template-ingestion-payload-json-order-cancel-event.md +959 -959
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/template-ingestion-payload-xml-order-cancel-event.md +1170 -1170
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/template-ingestion-s3-csv-product-event.md +2312 -2312
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/template-ingestion-s3-json-product-event.md +2999 -2999
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/template-ingestion-s3-parquet-product-event.md +2836 -2836
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/template-ingestion-s3-xml-product-event.md +2395 -2395
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/template-ingestion-sftp-csv-product-event.md +2295 -2295
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/template-ingestion-sftp-json-product-event.md +2602 -2602
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/template-ingestion-sftp-parquet-product-event.md +2589 -2589
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/template-ingestion-sftp-xml-product-event.md +3578 -3578
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/graphql-mutations-guide.md +93 -93
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-payload-json-order-update-graphql.md +1260 -1260
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-payload-xml-order-update-graphql.md +1472 -1472
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-s3-csv-control-graphql.md +2417 -2417
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-s3-csv-location-graphql.md +2811 -2811
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-s3-csv-price-graphql.md +2619 -2619
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-s3-json-location-graphql.md +2807 -2807
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-s3-xml-location-graphql.md +2373 -2373
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-sftp-csv-control-graphql.md +2740 -2740
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-sftp-csv-location-graphql.md +2760 -2760
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-sftp-json-location-graphql.md +1710 -1710
- package/docs/01-TEMPLATES/versori/workflows/ingestion/ingestion-workflows-guide.md +136 -136
- package/docs/01-TEMPLATES/versori/workflows/rubix-webhooks/rubix-webhooks-guide.md +520 -520
- package/docs/01-TEMPLATES/versori/workflows/rubix-webhooks/template-webhook-rubix-fulfilment-to-sftp-xml-inline.md +1418 -1418
- package/docs/01-TEMPLATES/versori/workflows/rubix-webhooks/template-webhook-rubix-fulfilment-to-sftp-xml-universal-mapper.md +1785 -1785
- package/docs/01-TEMPLATES/versori/workflows/rubix-webhooks/template-webhook-rubix-order-attribute-update.md +824 -824
- package/docs/01-TEMPLATES/versori/workflows/workflows-overview-guide.md +646 -646
- package/docs/02-CORE-GUIDES/advanced-services/advanced-services-batch-archival.md +724 -724
- package/docs/02-CORE-GUIDES/advanced-services/advanced-services-job-tracker.md +627 -627
- package/docs/02-CORE-GUIDES/advanced-services/advanced-services-partial-batch-recovery.md +561 -561
- package/docs/02-CORE-GUIDES/advanced-services/advanced-services-quick-reference.md +367 -367
- package/docs/02-CORE-GUIDES/advanced-services/advanced-services-readme.md +407 -407
- package/docs/02-CORE-GUIDES/advanced-services/readme.md +49 -49
- package/docs/02-CORE-GUIDES/api-reference/api-reference-quick-reference.md +548 -548
- package/docs/02-CORE-GUIDES/api-reference/event-api-input-output-reference.md +702 -1171
- package/docs/02-CORE-GUIDES/api-reference/examples/client-initialization.ts +286 -286
- package/docs/02-CORE-GUIDES/api-reference/graphql-error-classification.md +337 -337
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-01-client-api.md +399 -520
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-03-authentication.md +199 -199
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-04-graphql-mapping.md +925 -925
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-05-services.md +1198 -1198
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-06-data-sources.md +1083 -1083
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-07-parsers.md +1097 -1097
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-08-pagination.md +513 -513
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-08-types.md +545 -597
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-09-error-handling.md +527 -527
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-09-webhook-validation.md +514 -514
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-10-extraction.md +557 -557
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-10-utilities.md +412 -412
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-11-cli-tools.md +423 -423
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-11-error-handling.md +716 -716
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-12-analyze-source-structure.md +518 -518
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-12-partial-responses.md +212 -212
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-12-testing.md +300 -300
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-13-resolver-builder.md +322 -322
- package/docs/02-CORE-GUIDES/api-reference/readme.md +279 -279
- package/docs/02-CORE-GUIDES/auto-pagination/auto-pagination-quick-reference.md +351 -351
- package/docs/02-CORE-GUIDES/auto-pagination/auto-pagination-readme.md +277 -277
- package/docs/02-CORE-GUIDES/auto-pagination/examples/auto-pagination-readme.md +178 -178
- package/docs/02-CORE-GUIDES/auto-pagination/examples/common-patterns.ts +351 -351
- package/docs/02-CORE-GUIDES/auto-pagination/examples/paginate-products.ts +384 -384
- package/docs/02-CORE-GUIDES/auto-pagination/examples/paginate-virtual-positions.ts +308 -308
- package/docs/02-CORE-GUIDES/auto-pagination/modules/auto-pagination-01-foundations.md +470 -470
- package/docs/02-CORE-GUIDES/auto-pagination/modules/auto-pagination-02-quick-start.md +713 -713
- package/docs/02-CORE-GUIDES/auto-pagination/modules/auto-pagination-03-configuration.md +754 -754
- package/docs/02-CORE-GUIDES/auto-pagination/modules/auto-pagination-04-advanced-patterns.md +732 -732
- package/docs/02-CORE-GUIDES/auto-pagination/modules/auto-pagination-05-sdk-integration.md +847 -847
- package/docs/02-CORE-GUIDES/auto-pagination/modules/auto-pagination-06-troubleshooting.md +359 -359
- package/docs/02-CORE-GUIDES/auto-pagination/modules/auto-pagination-07-api-reference.md +462 -462
- package/docs/02-CORE-GUIDES/auto-pagination/readme.md +54 -54
- package/docs/02-CORE-GUIDES/data-sources/data-sources-file-operations-error-handling.md +1487 -1487
- package/docs/02-CORE-GUIDES/data-sources/data-sources-quick-reference.md +836 -836
- package/docs/02-CORE-GUIDES/data-sources/data-sources-readme.md +276 -276
- package/docs/02-CORE-GUIDES/data-sources/data-sources-sftp-credential-access-security.md +553 -553
- package/docs/02-CORE-GUIDES/data-sources/examples/common-patterns.ts +409 -409
- package/docs/02-CORE-GUIDES/data-sources/examples/data-sources-readme.md +178 -178
- package/docs/02-CORE-GUIDES/data-sources/examples/s3-operations.ts +308 -308
- package/docs/02-CORE-GUIDES/data-sources/examples/sftp-operations.ts +371 -371
- package/docs/02-CORE-GUIDES/data-sources/modules/data-sources-01-foundations.md +735 -735
- package/docs/02-CORE-GUIDES/data-sources/modules/data-sources-02-s3-operations.md +1302 -1302
- package/docs/02-CORE-GUIDES/data-sources/modules/data-sources-03-sftp-operations.md +1379 -1379
- package/docs/02-CORE-GUIDES/data-sources/modules/data-sources-04-file-patterns.md +941 -941
- package/docs/02-CORE-GUIDES/data-sources/modules/data-sources-05-advanced-topics.md +813 -813
- package/docs/02-CORE-GUIDES/data-sources/modules/data-sources-06-integration-patterns.md +486 -486
- package/docs/02-CORE-GUIDES/data-sources/modules/data-sources-07-troubleshooting.md +387 -387
- package/docs/02-CORE-GUIDES/data-sources/modules/data-sources-08-api-reference.md +417 -417
- package/docs/02-CORE-GUIDES/data-sources/readme.md +77 -77
- package/docs/02-CORE-GUIDES/error-handling-guide.md +936 -936
- package/docs/02-CORE-GUIDES/extraction/examples/02-core-guides-extraction-readme.md +116 -116
- package/docs/02-CORE-GUIDES/extraction/examples/common-patterns.ts +428 -428
- package/docs/02-CORE-GUIDES/extraction/examples/extract-inventory-basic.ts +187 -187
- package/docs/02-CORE-GUIDES/extraction/extraction-quick-reference.md +596 -596
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-01-foundations.md +514 -514
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-02-basic-extraction.md +823 -823
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-03-parquet-processing.md +507 -507
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-04-data-enrichment.md +546 -546
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-05-transformation.md +494 -494
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-06-export-formats.md +458 -458
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-06-performance.md +138 -138
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-07-api-reference.md +148 -148
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-07-optimization.md +692 -692
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-08-extraction-orchestrator.md +1008 -1008
- package/docs/02-CORE-GUIDES/extraction/readme.md +151 -151
- package/docs/02-CORE-GUIDES/ingestion/examples/_simple-kv-store.ts +40 -40
- package/docs/02-CORE-GUIDES/ingestion/examples/error-recovery.ts +728 -728
- package/docs/02-CORE-GUIDES/ingestion/examples/event-driven.ts +501 -501
- package/docs/02-CORE-GUIDES/ingestion/examples/local-file-ingestion.ts +88 -88
- package/docs/02-CORE-GUIDES/ingestion/examples/parquet-ingestion.ts +117 -117
- package/docs/02-CORE-GUIDES/ingestion/examples/performance-optimized.ts +647 -647
- package/docs/02-CORE-GUIDES/ingestion/examples/s3-csv-ingestion.ts +169 -169
- package/docs/02-CORE-GUIDES/ingestion/examples/sftp-csv-ingestion.ts +134 -134
- package/docs/02-CORE-GUIDES/ingestion/ingestion-quick-reference.md +546 -546
- package/docs/02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-01-introduction.md +626 -626
- package/docs/02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-02-quick-start.md +658 -658
- package/docs/02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-03-data-sources.md +1052 -1052
- package/docs/02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-04-field-mapping.md +763 -763
- package/docs/02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-05-advanced-parsers.md +676 -676
- package/docs/02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-06-batch-api.md +1295 -1295
- package/docs/02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-07-api-reference.md +138 -138
- package/docs/02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-07-state-management.md +1037 -1037
- package/docs/02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-08-performance-optimization.md +1349 -1349
- package/docs/02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-09-best-practices.md +1893 -1893
- package/docs/02-CORE-GUIDES/ingestion/readme.md +160 -160
- package/docs/02-CORE-GUIDES/logging-guide.md +585 -585
- package/docs/02-CORE-GUIDES/mapping/error-handling-patterns.md +401 -401
- package/docs/02-CORE-GUIDES/mapping/examples/02-core-guides-mapping-readme.md +128 -128
- package/docs/02-CORE-GUIDES/mapping/examples/common-patterns.ts +273 -273
- package/docs/02-CORE-GUIDES/mapping/examples/csv-location-ingestion.json +36 -36
- package/docs/02-CORE-GUIDES/mapping/examples/csv-mapping.ts +242 -242
- package/docs/02-CORE-GUIDES/mapping/examples/graphql-to-parquet-extraction.json +36 -36
- package/docs/02-CORE-GUIDES/mapping/examples/json-mapping.ts +213 -213
- package/docs/02-CORE-GUIDES/mapping/examples/json-product-to-mutation.json +48 -48
- package/docs/02-CORE-GUIDES/mapping/examples/xml-mapping.ts +291 -291
- package/docs/02-CORE-GUIDES/mapping/examples/xml-order-to-mutation.json +45 -45
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/graphql-mutation-mapping-quick-reference.md +463 -463
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/graphql-mutation-mapping-readme.md +227 -227
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-01-introduction.md +222 -222
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-02-quick-start.md +351 -351
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-03-schema-validation.md +569 -569
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-04-mapping-patterns.md +471 -471
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-05-configuration-reference.md +611 -611
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-06-advanced-xpath.md +148 -148
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-06-path-syntax.md +464 -464
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-07-api-reference.md +94 -94
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-07-array-handling.md +307 -307
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-08-custom-resolvers.md +544 -544
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-09-advanced-patterns.md +427 -427
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-10-hooks-and-variables.md +336 -336
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-11-error-handling.md +488 -488
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-12-arguments-vs-nodes.md +383 -383
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-13-best-practices.md +477 -477
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/readme.md +62 -62
- package/docs/02-CORE-GUIDES/mapping/mapping-format-decision-tree.md +480 -480
- package/docs/02-CORE-GUIDES/mapping/mapping-graphql-alias-batching-guide.md +820 -820
- package/docs/02-CORE-GUIDES/mapping/mapping-javascript-objects.md +2369 -2369
- package/docs/02-CORE-GUIDES/mapping/mapping-mapper-comparison-guide.md +682 -682
- package/docs/02-CORE-GUIDES/mapping/modules/02-core-guides-mapping-07-api-reference.md +1327 -1327
- package/docs/02-CORE-GUIDES/mapping/modules/02-core-guides-mapping-08-error-handling.md +1142 -1142
- package/docs/02-CORE-GUIDES/mapping/modules/mapping-04-use-cases.md +891 -891
- package/docs/02-CORE-GUIDES/mapping/modules/mapping-06-helpers-resolvers.md +1126 -1126
- package/docs/02-CORE-GUIDES/mapping/modules/mapping-06-sdk-resolvers.md +199 -199
- package/docs/02-CORE-GUIDES/mapping/modules/mapping-07-api-reference.md +1319 -1319
- package/docs/02-CORE-GUIDES/mapping/readme.md +178 -178
- package/docs/02-CORE-GUIDES/mapping/resolver-registration.md +410 -410
- package/docs/02-CORE-GUIDES/mapping/resolvers/examples/common-patterns.ts +226 -226
- package/docs/02-CORE-GUIDES/mapping/resolvers/examples/custom-resolvers.ts +227 -227
- package/docs/02-CORE-GUIDES/mapping/resolvers/examples/sdk-resolvers-usage.ts +203 -203
- package/docs/02-CORE-GUIDES/mapping/resolvers/mapping-resolvers-readme.md +274 -274
- package/docs/02-CORE-GUIDES/mapping/resolvers/mapping-resolvers-resolver-api-reference.md +679 -679
- package/docs/02-CORE-GUIDES/mapping/resolvers/mapping-resolvers-resolver-cookbook.md +826 -826
- package/docs/02-CORE-GUIDES/mapping/resolvers/mapping-resolvers-resolver-guide.md +1330 -1330
- package/docs/02-CORE-GUIDES/mapping/resolvers/mapping-resolvers-resolver-helpers-reference.md +1437 -1437
- package/docs/02-CORE-GUIDES/mapping/resolvers/mapping-resolvers-resolver-parameters-reference.md +553 -553
- package/docs/02-CORE-GUIDES/mapping/resolvers/mapping-resolvers-resolver-troubleshooting.md +854 -854
- package/docs/02-CORE-GUIDES/mapping/resolvers/readme.md +75 -75
- package/docs/02-CORE-GUIDES/parsers/examples/02-core-guides-parsers-readme.md +161 -161
- package/docs/02-CORE-GUIDES/parsers/examples/csv-parser-examples.ts +110 -110
- package/docs/02-CORE-GUIDES/parsers/examples/json-parser-examples.ts +33 -33
- package/docs/02-CORE-GUIDES/parsers/examples/parquet-parser-examples.ts +47 -47
- package/docs/02-CORE-GUIDES/parsers/examples/xml-parser-examples.ts +38 -38
- package/docs/02-CORE-GUIDES/parsers/modules/02-core-guides-parsers-01-foundations.md +355 -355
- package/docs/02-CORE-GUIDES/parsers/modules/02-core-guides-parsers-02-csv-parser.md +772 -772
- package/docs/02-CORE-GUIDES/parsers/modules/02-core-guides-parsers-03-json-parser.md +789 -789
- package/docs/02-CORE-GUIDES/parsers/modules/02-core-guides-parsers-04-xml-parser.md +857 -857
- package/docs/02-CORE-GUIDES/parsers/modules/02-core-guides-parsers-05-parquet-parser.md +603 -603
- package/docs/02-CORE-GUIDES/parsers/modules/02-core-guides-parsers-06-integration-patterns.md +702 -702
- package/docs/02-CORE-GUIDES/parsers/modules/02-core-guides-parsers-06-streaming.md +121 -121
- package/docs/02-CORE-GUIDES/parsers/modules/02-core-guides-parsers-07-api-reference.md +89 -89
- package/docs/02-CORE-GUIDES/parsers/modules/02-core-guides-parsers-07-troubleshooting.md +727 -727
- package/docs/02-CORE-GUIDES/parsers/parsers-quick-reference.md +482 -482
- package/docs/02-CORE-GUIDES/parsers/parsers-readme.md +258 -258
- package/docs/02-CORE-GUIDES/parsers/readme.md +65 -65
- package/docs/02-CORE-GUIDES/readme.md +194 -194
- package/docs/02-CORE-GUIDES/webhook-validation/examples/basic-validation.ts +108 -108
- package/docs/02-CORE-GUIDES/webhook-validation/examples/common-patterns.ts +316 -316
- package/docs/02-CORE-GUIDES/webhook-validation/examples/webhook-validation-readme.md +61 -61
- package/docs/02-CORE-GUIDES/webhook-validation/modules/webhook-validation-01-foundations.md +440 -440
- package/docs/02-CORE-GUIDES/webhook-validation/modules/webhook-validation-02-quick-start.md +525 -525
- package/docs/02-CORE-GUIDES/webhook-validation/modules/webhook-validation-03-versori-integration.md +741 -741
- package/docs/02-CORE-GUIDES/webhook-validation/modules/webhook-validation-04-platform-integration.md +629 -629
- package/docs/02-CORE-GUIDES/webhook-validation/modules/webhook-validation-05-configuration.md +535 -535
- package/docs/02-CORE-GUIDES/webhook-validation/modules/webhook-validation-06-error-handling.md +611 -611
- package/docs/02-CORE-GUIDES/webhook-validation/modules/webhook-validation-06-troubleshooting.md +124 -124
- package/docs/02-CORE-GUIDES/webhook-validation/modules/webhook-validation-07-api-reference.md +511 -511
- package/docs/02-CORE-GUIDES/webhook-validation/modules/webhook-validation-08-rubix-webhooks.md +590 -590
- package/docs/02-CORE-GUIDES/webhook-validation/modules/webhook-validation-09-rubix-event-vs-http-call.md +432 -432
- package/docs/02-CORE-GUIDES/webhook-validation/readme.md +239 -239
- package/docs/02-CORE-GUIDES/webhook-validation/webhook-validation-quick-reference.md +392 -392
- package/docs/03-PATTERN-GUIDES/connector-scenarios/connector-scenarios-quick-reference.md +498 -498
- package/docs/03-PATTERN-GUIDES/connector-scenarios/connector-scenarios-readme.md +313 -313
- package/docs/03-PATTERN-GUIDES/connector-scenarios/examples/common-patterns.ts +612 -612
- package/docs/03-PATTERN-GUIDES/connector-scenarios/examples/connector-scenarios-readme.md +253 -253
- package/docs/03-PATTERN-GUIDES/connector-scenarios/modules/connector-scenarios-01-foundations.md +452 -452
- package/docs/03-PATTERN-GUIDES/connector-scenarios/modules/connector-scenarios-02-simple-scenarios.md +681 -681
- package/docs/03-PATTERN-GUIDES/connector-scenarios/modules/connector-scenarios-03-intermediate-scenarios.md +637 -637
- package/docs/03-PATTERN-GUIDES/connector-scenarios/modules/connector-scenarios-04-advanced-scenarios.md +650 -650
- package/docs/03-PATTERN-GUIDES/connector-scenarios/modules/connector-scenarios-05-bidirectional-sync.md +233 -233
- package/docs/03-PATTERN-GUIDES/connector-scenarios/modules/connector-scenarios-06-production-patterns.md +442 -442
- package/docs/03-PATTERN-GUIDES/connector-scenarios/modules/connector-scenarios-07-reference.md +445 -445
- package/docs/03-PATTERN-GUIDES/connector-scenarios/readme.md +31 -31
- package/docs/03-PATTERN-GUIDES/enterprise-integration-patterns.md +1528 -1528
- package/docs/03-PATTERN-GUIDES/error-handling/comprehensive-error-handling-guide.md +1437 -1437
- package/docs/03-PATTERN-GUIDES/error-handling/error-handling-quick-reference.md +390 -390
- package/docs/03-PATTERN-GUIDES/error-handling/examples/common-patterns.ts +438 -438
- package/docs/03-PATTERN-GUIDES/error-handling/modules/error-handling-01-foundations.md +362 -362
- package/docs/03-PATTERN-GUIDES/error-handling/modules/error-handling-02-error-types.md +850 -850
- package/docs/03-PATTERN-GUIDES/error-handling/modules/error-handling-03-utf8-handling.md +456 -456
- package/docs/03-PATTERN-GUIDES/error-handling/modules/error-handling-04-error-scenarios.md +658 -658
- package/docs/03-PATTERN-GUIDES/error-handling/modules/error-handling-05-calling-patterns.md +671 -671
- package/docs/03-PATTERN-GUIDES/error-handling/modules/error-handling-06-retry-strategies.md +1034 -1034
- package/docs/03-PATTERN-GUIDES/error-handling/modules/error-handling-07-monitoring.md +653 -653
- package/docs/03-PATTERN-GUIDES/error-handling/modules/error-handling-08-api-reference.md +847 -847
- package/docs/03-PATTERN-GUIDES/error-handling/readme.md +36 -36
- package/docs/03-PATTERN-GUIDES/examples/__tests__/readme.md +40 -40
- package/docs/03-PATTERN-GUIDES/examples/__tests__/resolver-examples.test.js +282 -282
- package/docs/03-PATTERN-GUIDES/examples/test-data/03-pattern-guides-readme.md +110 -110
- package/docs/03-PATTERN-GUIDES/examples/test-data/canonical-inventory.json +123 -123
- package/docs/03-PATTERN-GUIDES/examples/test-data/canonical-order.json +171 -171
- package/docs/03-PATTERN-GUIDES/examples/test-data/readme.md +28 -28
- package/docs/03-PATTERN-GUIDES/extraction/extraction-readme.md +15 -15
- package/docs/03-PATTERN-GUIDES/extraction/readme.md +25 -25
- package/docs/03-PATTERN-GUIDES/file-operations/examples/common-patterns.ts +407 -407
- package/docs/03-PATTERN-GUIDES/file-operations/examples/file-operations-readme.md +142 -142
- package/docs/03-PATTERN-GUIDES/file-operations/file-operations-quick-reference.md +462 -462
- package/docs/03-PATTERN-GUIDES/file-operations/file-operations-readme.md +379 -379
- package/docs/03-PATTERN-GUIDES/file-operations/modules/file-operations-01-foundations.md +430 -430
- package/docs/03-PATTERN-GUIDES/file-operations/modules/file-operations-02-quick-start.md +484 -484
- package/docs/03-PATTERN-GUIDES/file-operations/modules/file-operations-03-s3-operations.md +507 -507
- package/docs/03-PATTERN-GUIDES/file-operations/modules/file-operations-04-sftp-operations.md +963 -963
- package/docs/03-PATTERN-GUIDES/file-operations/modules/file-operations-05-streaming-performance.md +503 -503
- package/docs/03-PATTERN-GUIDES/file-operations/modules/file-operations-06-archive-patterns.md +386 -386
- package/docs/03-PATTERN-GUIDES/file-operations/modules/file-operations-06-error-handling.md +117 -117
- package/docs/03-PATTERN-GUIDES/file-operations/modules/file-operations-07-api-reference.md +78 -78
- package/docs/03-PATTERN-GUIDES/file-operations/modules/file-operations-07-testing-troubleshooting.md +567 -567
- package/docs/03-PATTERN-GUIDES/file-operations/modules/file-operations-08-api-reference.md +1055 -1055
- package/docs/03-PATTERN-GUIDES/file-operations/readme.md +32 -32
- package/docs/03-PATTERN-GUIDES/ingestion/ingestion-readme.md +15 -15
- package/docs/03-PATTERN-GUIDES/ingestion/readme.md +25 -25
- package/docs/03-PATTERN-GUIDES/integration-patterns/examples/batch-processing.ts +130 -130
- package/docs/03-PATTERN-GUIDES/integration-patterns/examples/common-patterns.ts +360 -360
- package/docs/03-PATTERN-GUIDES/integration-patterns/examples/delta-sync.ts +130 -130
- package/docs/03-PATTERN-GUIDES/integration-patterns/examples/integration-patterns-readme.md +100 -100
- package/docs/03-PATTERN-GUIDES/integration-patterns/examples/real-time-webhook.ts +398 -398
- package/docs/03-PATTERN-GUIDES/integration-patterns/integration-patterns-quick-reference.md +962 -962
- package/docs/03-PATTERN-GUIDES/integration-patterns/integration-patterns-readme.md +134 -134
- package/docs/03-PATTERN-GUIDES/integration-patterns/modules/integration-patterns-01-real-time-processing.md +991 -991
- package/docs/03-PATTERN-GUIDES/integration-patterns/modules/integration-patterns-02-batch-processing.md +1547 -1547
- package/docs/03-PATTERN-GUIDES/integration-patterns/modules/integration-patterns-03-delta-sync.md +1108 -1108
- package/docs/03-PATTERN-GUIDES/integration-patterns/modules/integration-patterns-04-webhook-patterns.md +1181 -1181
- package/docs/03-PATTERN-GUIDES/integration-patterns/modules/integration-patterns-05-error-handling.md +1061 -1061
- package/docs/03-PATTERN-GUIDES/integration-patterns/modules/integration-patterns-06-advanced-integration-services.md +1547 -1547
- package/docs/03-PATTERN-GUIDES/integration-patterns/modules/integration-patterns-06-performance.md +109 -109
- package/docs/03-PATTERN-GUIDES/integration-patterns/modules/integration-patterns-07-api-reference.md +34 -34
- package/docs/03-PATTERN-GUIDES/integration-patterns/readme.md +30 -30
- package/docs/03-PATTERN-GUIDES/logging-minimal-mode.md +128 -128
- package/docs/03-PATTERN-GUIDES/multiple-connections/examples/common-patterns.ts +380 -380
- package/docs/03-PATTERN-GUIDES/multiple-connections/examples/multiple-connections-readme.md +139 -139
- package/docs/03-PATTERN-GUIDES/multiple-connections/examples/parallel-root-connections.ts +149 -149
- package/docs/03-PATTERN-GUIDES/multiple-connections/examples/real-world-scenarios.ts +405 -405
- package/docs/03-PATTERN-GUIDES/multiple-connections/modules/multiple-connections-01-foundations.md +378 -378
- package/docs/03-PATTERN-GUIDES/multiple-connections/modules/multiple-connections-02-quick-start.md +566 -566
- package/docs/03-PATTERN-GUIDES/multiple-connections/modules/multiple-connections-03-targeting-connections.md +659 -659
- package/docs/03-PATTERN-GUIDES/multiple-connections/modules/multiple-connections-04-parallel-queries.md +656 -656
- package/docs/03-PATTERN-GUIDES/multiple-connections/modules/multiple-connections-05-best-practices.md +624 -624
- package/docs/03-PATTERN-GUIDES/multiple-connections/modules/multiple-connections-06-api-reference.md +824 -824
- package/docs/03-PATTERN-GUIDES/multiple-connections/modules/multiple-connections-06-versori.md +119 -119
- package/docs/03-PATTERN-GUIDES/multiple-connections/modules/multiple-connections-07-api-reference.md +87 -87
- package/docs/03-PATTERN-GUIDES/multiple-connections/multiple-connections-quick-reference.md +353 -353
- package/docs/03-PATTERN-GUIDES/multiple-connections/multiple-connections-readme.md +270 -270
- package/docs/03-PATTERN-GUIDES/multiple-connections/readme.md +30 -30
- package/docs/03-PATTERN-GUIDES/pagination/pagination-readme.md +14 -14
- package/docs/03-PATTERN-GUIDES/pagination/readme.md +24 -24
- package/docs/03-PATTERN-GUIDES/parquet/examples/common-patterns.ts +180 -180
- package/docs/03-PATTERN-GUIDES/parquet/examples/read-parquet.ts +48 -48
- package/docs/03-PATTERN-GUIDES/parquet/examples/write-parquet.ts +65 -65
- package/docs/03-PATTERN-GUIDES/parquet/modules/03-pattern-guides-parquet-01-introduction.md +393 -393
- package/docs/03-PATTERN-GUIDES/parquet/modules/03-pattern-guides-parquet-02-quick-start.md +572 -572
- package/docs/03-PATTERN-GUIDES/parquet/modules/03-pattern-guides-parquet-03-reading-parquet.md +525 -525
- package/docs/03-PATTERN-GUIDES/parquet/modules/03-pattern-guides-parquet-04-writing-parquet.md +554 -554
- package/docs/03-PATTERN-GUIDES/parquet/modules/03-pattern-guides-parquet-05-graphql-extraction.md +405 -405
- package/docs/03-PATTERN-GUIDES/parquet/modules/03-pattern-guides-parquet-06-performance.md +104 -104
- package/docs/03-PATTERN-GUIDES/parquet/modules/03-pattern-guides-parquet-06-s3-integration.md +511 -511
- package/docs/03-PATTERN-GUIDES/parquet/modules/03-pattern-guides-parquet-07-api-reference.md +90 -90
- package/docs/03-PATTERN-GUIDES/parquet/modules/03-pattern-guides-parquet-07-performance-optimization.md +525 -525
- package/docs/03-PATTERN-GUIDES/parquet/modules/03-pattern-guides-parquet-08-best-practices.md +712 -712
- package/docs/03-PATTERN-GUIDES/parquet/parquet-quick-reference.md +683 -683
- package/docs/03-PATTERN-GUIDES/parquet/parquet-readme.md +248 -248
- package/docs/03-PATTERN-GUIDES/parquet/readme.md +32 -32
- package/docs/03-PATTERN-GUIDES/parsers/parsers-readme.md +12 -12
- package/docs/03-PATTERN-GUIDES/parsers/readme.md +24 -24
- package/docs/03-PATTERN-GUIDES/readme.md +159 -159
- package/docs/03-PATTERN-GUIDES/webhooks/readme.md +24 -24
- package/docs/03-PATTERN-GUIDES/webhooks/webhooks-readme.md +8 -8
- package/docs/04-REFERENCE/architecture/architecture-01-overview.md +427 -427
- package/docs/04-REFERENCE/architecture/architecture-02-client-architecture.md +424 -424
- package/docs/04-REFERENCE/architecture/architecture-03-data-flow.md +690 -690
- package/docs/04-REFERENCE/architecture/architecture-04-service-layer.md +834 -834
- package/docs/04-REFERENCE/architecture/architecture-05-integration-architecture.md +655 -655
- package/docs/04-REFERENCE/architecture/architecture-06-state-management.md +653 -653
- package/docs/04-REFERENCE/architecture/architecture-adding-new-data-sources.md +686 -686
- package/docs/04-REFERENCE/architecture/readme.md +279 -279
- package/docs/04-REFERENCE/platforms/deno/readme.md +117 -117
- package/docs/04-REFERENCE/platforms/nodejs/readme.md +146 -146
- package/docs/04-REFERENCE/platforms/readme.md +135 -135
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-01-introduction.md +398 -398
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-02-quick-start.md +560 -560
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-03-authentication.md +757 -757
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-04-workflows.md +2476 -2476
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-05-connections.md +1167 -1167
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-06-kv-storage.md +990 -990
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-06-state-management.md +121 -121
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-07-api-reference.md +68 -68
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-07-deployment.md +731 -731
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-08-best-practices.md +1111 -1111
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-09-signature-reference.md +766 -766
- package/docs/04-REFERENCE/platforms/versori/platforms-versori-readme.md +299 -299
- package/docs/04-REFERENCE/platforms/versori/platforms-versori-s3-sftp-configuration-guide.md +1425 -1425
- package/docs/04-REFERENCE/platforms/versori/platforms-versori-webhook-api-key-security.md +816 -816
- package/docs/04-REFERENCE/platforms/versori/platforms-versori-webhook-connection-security.md +681 -681
- package/docs/04-REFERENCE/platforms/versori/platforms-versori-workflow-task-types.md +708 -708
- package/docs/04-REFERENCE/platforms/versori/readme.md +108 -108
- package/docs/04-REFERENCE/readme.md +148 -148
- package/docs/04-REFERENCE/resolver-signature/examples/advanced-resolvers.ts +482 -482
- package/docs/04-REFERENCE/resolver-signature/examples/async-resolvers.ts +496 -496
- package/docs/04-REFERENCE/resolver-signature/examples/basic-resolvers.ts +343 -343
- package/docs/04-REFERENCE/resolver-signature/examples/resolver-signature-readme.md +188 -188
- package/docs/04-REFERENCE/resolver-signature/examples/testing-resolvers.ts +463 -463
- package/docs/04-REFERENCE/resolver-signature/modules/resolver-signature-01-foundations.md +286 -286
- package/docs/04-REFERENCE/resolver-signature/modules/resolver-signature-02-parameter-reference.md +643 -643
- package/docs/04-REFERENCE/resolver-signature/modules/resolver-signature-03-basic-examples.md +521 -521
- package/docs/04-REFERENCE/resolver-signature/modules/resolver-signature-04-advanced-patterns.md +739 -739
- package/docs/04-REFERENCE/resolver-signature/modules/resolver-signature-05-sdk-resolvers.md +531 -531
- package/docs/04-REFERENCE/resolver-signature/modules/resolver-signature-06-migration-guide.md +650 -650
- package/docs/04-REFERENCE/resolver-signature/modules/resolver-signature-06-testing.md +125 -125
- package/docs/04-REFERENCE/resolver-signature/modules/resolver-signature-07-api-reference.md +794 -794
- package/docs/04-REFERENCE/resolver-signature/readme.md +64 -64
- package/docs/04-REFERENCE/resolver-signature/resolver-signature-quick-reference.md +270 -270
- package/docs/04-REFERENCE/resolver-signature/resolver-signature-readme.md +351 -351
- package/docs/04-REFERENCE/schema/fluent-commerce-schema.json +764 -764
- package/docs/04-REFERENCE/schema/readme.md +141 -141
- package/docs/04-REFERENCE/testing/examples/04-reference-testing-readme.md +158 -158
- package/docs/04-REFERENCE/testing/examples/fluent-testing.ts +62 -62
- package/docs/04-REFERENCE/testing/examples/health-check.ts +155 -155
- package/docs/04-REFERENCE/testing/examples/integration-test.ts +119 -119
- package/docs/04-REFERENCE/testing/examples/performance-test.ts +183 -183
- package/docs/04-REFERENCE/testing/examples/s3-testing.ts +127 -127
- package/docs/04-REFERENCE/testing/modules/04-reference-testing-01-foundations.md +267 -267
- package/docs/04-REFERENCE/testing/modules/04-reference-testing-02-s3-testing.md +599 -599
- package/docs/04-REFERENCE/testing/modules/04-reference-testing-03-fluent-testing.md +589 -589
- package/docs/04-REFERENCE/testing/modules/04-reference-testing-04-integration-testing.md +699 -699
- package/docs/04-REFERENCE/testing/modules/04-reference-testing-05-debugging.md +478 -478
- package/docs/04-REFERENCE/testing/modules/04-reference-testing-06-cicd-integration.md +463 -463
- package/docs/04-REFERENCE/testing/modules/04-reference-testing-06-preflight-validation.md +131 -131
- package/docs/04-REFERENCE/testing/modules/04-reference-testing-07-best-practices.md +499 -499
- package/docs/04-REFERENCE/testing/modules/04-reference-testing-07-coverage-ci.md +165 -165
- package/docs/04-REFERENCE/testing/modules/04-reference-testing-08-api-reference.md +634 -634
- package/docs/04-REFERENCE/testing/readme.md +86 -86
- package/docs/04-REFERENCE/testing/testing-quick-reference.md +667 -667
- package/docs/04-REFERENCE/testing/testing-readme.md +286 -286
- package/docs/04-REFERENCE/troubleshooting/readme.md +144 -144
- package/docs/04-REFERENCE/troubleshooting/troubleshooting-deno-sftp-compatibility.md +392 -392
- package/docs/template-loading-matrix.md +242 -242
- package/package.json +5 -3
- package/docs/02-CORE-GUIDES/api-reference/cli-profile-integration.md +0 -377
|
@@ -1,1882 +1,1882 @@
|
|
|
1
|
-
---
|
|
2
|
-
template_id: tpl-ingest-multi-channel-inventory-sync
|
|
3
|
-
canonical_filename: template-ingestion-multi-channel-inventory-sync.md
|
|
4
|
-
sdk_version: latest
|
|
5
|
-
runtime: versori
|
|
6
|
-
direction: ingestion
|
|
7
|
-
source: multi-channel-rest-s3
|
|
8
|
-
destination: fluent-batch-api
|
|
9
|
-
entity: inventory
|
|
10
|
-
format: json-csv
|
|
11
|
-
logging: versori
|
|
12
|
-
status: stable
|
|
13
|
-
---
|
|
14
|
-
|
|
15
|
-
# Template: Ingestion - Multi-Channel Inventory Sync (Scheduled)
|
|
16
|
-
|
|
17
|
-
## STEP 1: Understand This Template
|
|
18
|
-
|
|
19
|
-
**What This Template Does:**
|
|
20
|
-
|
|
21
|
-
- Scheduled Versori workflow for multi-channel inventory aggregation and sync
|
|
22
|
-
- Fetches inventory from multiple sources in parallel (REST API, S3 CSV, Fluent GraphQL)
|
|
23
|
-
- Calculates channel-specific ATP (Available To Promise) with configurable buffers and caps
|
|
24
|
-
- Performs delta detection using VersoriKVAdapter to sync only changed inventory records
|
|
25
|
-
- Handles partial channel failures gracefully with Promise.allSettled
|
|
26
|
-
- Rate-limits external REST API calls with exponential backoff retry logic
|
|
27
|
-
- Sends consolidated inventory updates to Fluent Commerce Batch API with chunking
|
|
28
|
-
- Tracks job lifecycle with JobTracker for operational monitoring
|
|
29
|
-
- Supports scheduled (cron) and ad-hoc (webhook) triggers
|
|
30
|
-
|
|
31
|
-
**Key SDK Components:**
|
|
32
|
-
|
|
33
|
-
- `createClient()` - Universal client factory (auto-detects Versori context)
|
|
34
|
-
- `S3DataSource` - S3 file operations with retry logic
|
|
35
|
-
- `CSVParserService` - CSV parsing with validation
|
|
36
|
-
- `UniversalMapper` - Field transformation with SDK resolvers
|
|
37
|
-
- `StateService` + `VersoriKVAdapter` - Delta detection state management
|
|
38
|
-
- `JobTracker` - Job lifecycle tracking - `FluentClient.createJob()` - Create Batch API job
|
|
39
|
-
- `FluentClient.sendBatch()` - Send inventory chunks (fire-and-forget)
|
|
40
|
-
- `FluentClient.graphql()` - Query current Fluent inventory state with auto-pagination
|
|
41
|
-
- Native Versori `log` - Use `log` from context
|
|
42
|
-
**Entity Type:**
|
|
43
|
-
|
|
44
|
-
- **InventoryQuantity** - Fluent entity for inventory positions and quantities
|
|
45
|
-
- **EntityType: 'INVENTORY'** - Used in Batch API `sendBatch()` call
|
|
46
|
-
- **Batch API Method** - Uses `createJob()` and `sendBatch()` (not Event API)
|
|
47
|
-
|
|
48
|
-
**Critical Patterns:**
|
|
49
|
-
|
|
50
|
-
- **Multi-source aggregation**: Use `Promise.allSettled()` for parallel channel fetching
|
|
51
|
-
- **Graceful degradation**: Continue processing even if one channel fails
|
|
52
|
-
- **Delta detection**: StateService tracks previous state to detect changes
|
|
53
|
-
- **Progress Logging**: Enhanced logging with context (sample SKUs, locations)
|
|
54
|
-
- **JobTracker Progress Updates**: Periodic progress updates during batch processing
|
|
55
|
-
- **ATP calculation**: `ATP = (onHand - reserved) - buffer` with oversell protection
|
|
56
|
-
- **Delta detection**: Use `StateService` + `VersoriKVAdapter` to track previous ATP values
|
|
57
|
-
- **Rate limiting**: Enforce minimum interval between channel API requests
|
|
58
|
-
- **Safe configuration**: External JSON mapping file (not inline TypeScript)
|
|
59
|
-
- **BPP Configuration**: Use `'skip'` (delta detection already filters changes)
|
|
60
|
-
- **Fire-and-forget batches**: Batch submission is asynchronous (no polling needed)
|
|
61
|
-
|
|
62
|
-
**When to Use This Template:**
|
|
63
|
-
|
|
64
|
-
- ✅ Multiple inventory sources (3+ channels) requiring aggregation
|
|
65
|
-
- ✅ Channel-specific ATP calculations with buffers and caps
|
|
66
|
-
- ✅ Delta detection needed (only sync changed records)
|
|
67
|
-
- ✅ Partial channel failures shouldn't block entire sync
|
|
68
|
-
- ✅ External APIs require rate limiting and retry logic
|
|
69
|
-
- ✅ Scheduled batch processing (every 15 minutes, hourly, etc.)
|
|
70
|
-
|
|
71
|
-
**When NOT to Use:**
|
|
72
|
-
|
|
73
|
-
- ❌ Single inventory source (use simpler single-source templates)
|
|
74
|
-
- ❌ Real-time sync required (this is designed for scheduled batch processing)
|
|
75
|
-
- ❌ No ATP calculations needed (use direct field mapping)
|
|
76
|
-
- ❌ Don't need delta detection (full snapshots every time)
|
|
77
|
-
- ❌ Products, Locations, Customers (use Event API templates)
|
|
78
|
-
|
|
79
|
-
---
|
|
80
|
-
|
|
81
|
-
## STEP 2: AI Prompt
|
|
82
|
-
|
|
83
|
-
**Copy this prompt to generate the complete implementation:**
|
|
84
|
-
|
|
85
|
-
```
|
|
86
|
-
Create a Versori scheduled workflow for multi-channel inventory aggregation to Fluent Commerce Batch API.
|
|
87
|
-
|
|
88
|
-
REQUIREMENTS:
|
|
89
|
-
1. Runtime: Versori Platform (scheduled workflow)
|
|
90
|
-
2. Sources: Multiple channels (REST API, S3 CSV, Fluent GraphQL)
|
|
91
|
-
3. Destination: Fluent Commerce Batch API (InventoryQuantity entity)
|
|
92
|
-
4. Entity: InventoryQuantity (EntityType: 'INVENTORY')
|
|
93
|
-
|
|
94
|
-
KEY FEATURES:
|
|
95
|
-
1. **Multi-channel fetching** (parallel):
|
|
96
|
-
- **Channel A**: REST API with rate limiting (120 RPM) and retry logic
|
|
97
|
-
- **Channel B**: S3 CSV file with CSVParserService
|
|
98
|
-
- **Fluent GraphQL**: Current inventory state via auto-pagination
|
|
99
|
-
2. **ATP (Available To Promise) calculation**:
|
|
100
|
-
- Formula: `ATP = (onHand - reserved) - buffer`
|
|
101
|
-
- Channel-specific buffers (configurable per channel)
|
|
102
|
-
- Aggregate ATP across channels with deduplication by SKU + location
|
|
103
|
-
- Support oversell protection (ensure ATP ≥ 0)
|
|
104
|
-
3. **Delta detection**:
|
|
105
|
-
- Use `StateService` + `VersoriKVAdapter` to track previous ATP values
|
|
106
|
-
- Only send changed records to Batch API
|
|
107
|
-
- Store state with 7-day TTL in Versori KV
|
|
108
|
-
4. **Graceful degradation**:
|
|
109
|
-
- Use `Promise.allSettled()` for channel fetching
|
|
110
|
-
- Continue processing even if one channel fails
|
|
111
|
-
- Log channel failures but don't block entire sync
|
|
112
|
-
5. **Batch API**:
|
|
113
|
-
- Entity: `InventoryQuantity`
|
|
114
|
-
- EntityType: `'INVENTORY'`
|
|
115
|
-
- Action: `'UPSERT'`
|
|
116
|
-
- BPP: `'skip'` (delta detection already filters)
|
|
117
|
-
- Batch size: 500 records per chunk
|
|
118
|
-
- Poll batches until complete with exponential backoff
|
|
119
|
-
6. **Job tracking**:
|
|
120
|
-
- Use `JobTracker` with Versori KV storage
|
|
121
|
-
- Track status transitions: `fetching_channels` → `aggregating` → `delta_detection` → `creating_batch_job` → `sending_batches` → `polling_batches` → `updating_delta_state`
|
|
122
|
-
7. **Modular architecture**:
|
|
123
|
-
- Separate service files (ATP calculator, channel connectors, batch dispatcher)
|
|
124
|
-
- External JSON mapping config
|
|
125
|
-
- Clean separation of concerns
|
|
126
|
-
|
|
127
|
-
CRITICAL REQUIREMENTS:
|
|
128
|
-
1. Modular architecture: Separate service files (ATP calculator, channel connectors, batch dispatcher)
|
|
129
|
-
2. External JSON mapping config: Use `with { type: 'json' }` import syntax
|
|
130
|
-
3. Native logging: Use log from context (LoggingService removed - use native log)
|
|
131
|
-
4. Graceful degradation: Promise.allSettled for parallel channel fetching
|
|
132
|
-
5. Delta detection: Track previous ATP values in Versori KV
|
|
133
|
-
6. BPP: Set to 'skip' (delta already filters changes)
|
|
134
|
-
7. Rate limiting: Enforce minimum interval between Channel A requests
|
|
135
|
-
8. Job tracking: Use JobTracker for lifecycle management
|
|
136
|
-
|
|
137
|
-
SDK METHODS TO USE:
|
|
138
|
-
- createClient(ctx) - Pass entire Versori context, auto-detects platform
|
|
139
|
-
- new S3DataSource(config, log) - S3 file operations
|
|
140
|
-
- new CSVParserService() - Parse CSV files
|
|
141
|
-
- await client.graphql({ query, variables, pagination }) - Fluent GraphQL extraction with auto-pagination
|
|
142
|
-
- new VersoriKVAdapter(openKv(':project:')) - Versori KV storage adapter
|
|
143
|
-
- new StateService(kvAdapter) - Delta state management (takes KV adapter, not logger)
|
|
144
|
-
- new JobTracker(kv, log) - Job lifecycle tracking
|
|
145
|
-
- await client.createJob({ name, retailerId, meta: { preprocessing: 'skip' } }) - Create Batch API job
|
|
146
|
-
- await client.sendBatch(jobId, { action, entityType, source, event, entities }) - Send batch chunk (fire-and-forget)
|
|
147
|
-
|
|
148
|
-
FORBIDDEN PATTERNS:
|
|
149
|
-
- ❌ LoggingService (removed - use native log on Versori)
|
|
150
|
-
- ❌ Don't use monolithic index.ts (extract services into separate files)
|
|
151
|
-
- ❌ Don't use inline mapping config (use external JSON file)
|
|
152
|
-
- ❌ Don't fail entire sync if one channel fails (use Promise.allSettled)
|
|
153
|
-
- ❌ Don't send all records (use delta detection to filter unchanged)
|
|
154
|
-
- ❌ Don't use BPP with deltas (set preprocessing: 'skip')
|
|
155
|
-
- ❌ Don't forget rate limiting for external APIs
|
|
156
|
-
- ❌ Don't forget to call dispose() on data sources in finally block
|
|
157
|
-
|
|
158
|
-
GENERATE:
|
|
159
|
-
1. package.json with dependencies
|
|
160
|
-
2. index.ts (workflow entry point with scheduled/adhoc/status triggers)
|
|
161
|
-
3. src/workflows/multi-channel-sync.workflow.ts (main orchestration logic)
|
|
162
|
-
4. src/services/atp-calculator.service.ts (ATP calculation and aggregation)
|
|
163
|
-
5. src/services/channel-a-connector.service.ts (REST API with rate limiting)
|
|
164
|
-
6. src/services/channel-b-connector.service.ts (S3 CSV fetching)
|
|
165
|
-
7. src/services/batch-processor.service.ts (Batch API submission)
|
|
166
|
-
8. src/services/batch-logger.service.ts (SFTP log file writing)
|
|
167
|
-
9. src/types/multi-channel.types.ts (TypeScript interfaces)
|
|
168
|
-
10. config/multi-channel.mapping.json (mapping configuration - external JSON file)
|
|
169
|
-
|
|
170
|
-
NOTE: Use external JSON files for mapping configuration (not TypeScript .config files)
|
|
171
|
-
|
|
172
|
-
Ensure all code is production-ready with proper error handling, graceful degradation, and rate limiting. Use modular architecture with separate service files for each concern.
|
|
173
|
-
```
|
|
174
|
-
|
|
175
|
-
---
|
|
176
|
-
|
|
177
|
-
## What You'll Build
|
|
178
|
-
|
|
179
|
-
### Versori Workflows Structure
|
|
180
|
-
|
|
181
|
-
**Key Concept**: Versori workflows are organized by **trigger type** at the first level, then by **specific workflow** with descriptive file names.
|
|
182
|
-
|
|
183
|
-
**Trigger Types:**
|
|
184
|
-
- **`schedule()`** → Time-based triggers (cron expressions) - NOT exposed as HTTP endpoints
|
|
185
|
-
- **`webhook()`** → HTTP-based triggers (event-driven) - Creates HTTP endpoints
|
|
186
|
-
- **`workflow()`** → Durable workflows (advanced, rarely used)
|
|
187
|
-
|
|
188
|
-
**Execution Steps (chained to triggers):**
|
|
189
|
-
- **`http()`** → External API calls (chained from schedule/webhook)
|
|
190
|
-
- **`fn()`** → Internal processing (chained from schedule/webhook)
|
|
191
|
-
|
|
192
|
-
### Recommended Project Structure
|
|
193
|
-
|
|
194
|
-
```
|
|
195
|
-
inventory-batch-sync/
|
|
196
|
-
├── index.ts # Entry point - exports all workflows
|
|
197
|
-
└── src/
|
|
198
|
-
├── workflows/
|
|
199
|
-
│ ├── scheduled/
|
|
200
|
-
│ │ └── daily-inventory-sync.ts # Scheduled: Daily inventory sync
|
|
201
|
-
│ │
|
|
202
|
-
│ └── webhook/
|
|
203
|
-
│ ├── adhoc-inventory-sync.ts # Webhook: Manual trigger
|
|
204
|
-
│ └── job-status-check.ts # Webhook: Status query
|
|
205
|
-
│
|
|
206
|
-
├── services/
|
|
207
|
-
│ └── inventory-sync.service.ts # Shared orchestration logic (reusable)
|
|
208
|
-
│
|
|
209
|
-
└── types/
|
|
210
|
-
└── inventory.types.ts # Shared type definitions
|
|
211
|
-
```
|
|
212
|
-
|
|
213
|
-
**Benefits:**
|
|
214
|
-
- ✅ Clear trigger separation (`scheduled/` vs `webhook/`)
|
|
215
|
-
- ✅ Descriptive file names (easy to browse and understand)
|
|
216
|
-
- ✅ Scalable (add new workflows without cluttering)
|
|
217
|
-
- ✅ Reusable code in `services/` (DRY principle)
|
|
218
|
-
- ✅ Easy to modify individual workflows without affecting others
|
|
219
|
-
|
|
220
|
-
---
|
|
221
|
-
|
|
222
|
-
## Workflow Files
|
|
223
|
-
|
|
224
|
-
### 1. Scheduled Workflows (`src/workflows/scheduled/`)
|
|
225
|
-
|
|
226
|
-
All time-based triggers that run automatically on cron schedules.
|
|
227
|
-
|
|
228
|
-
#### `src/workflows/scheduled/daily-inventory-sync.ts`
|
|
229
|
-
|
|
230
|
-
**Purpose**: Automatic Daily inventory sync
|
|
231
|
-
**Trigger**: Cron schedule (`0 2 * * *`)
|
|
232
|
-
**Exposed as Endpoint**: ❌ NO - Runs automatically
|
|
233
|
-
|
|
234
|
-
```typescript
|
|
235
|
-
import { schedule, http } from '@versori/run';
|
|
236
|
-
import { JobTracker } from '@fluentcommerce/fc-connect-sdk';
|
|
237
|
-
import { runIngestion } from '../../services/inventory-sync.service.ts';
|
|
238
|
-
|
|
239
|
-
/**
|
|
240
|
-
* Scheduled Workflow: Daily Inventory Sync
|
|
241
|
-
*
|
|
242
|
-
* Runs automatically daily at 2 AM UTC
|
|
243
|
-
* NOT exposed as HTTP endpoint - Versori executes on schedule
|
|
244
|
-
*
|
|
245
|
-
* Uses shared service: inventory-sync.service.ts
|
|
246
|
-
*/
|
|
247
|
-
export const daily_inventory_sync = schedule(
|
|
248
|
-
'inventory-batch-scheduled',
|
|
249
|
-
'0 2 * * *' // Daily at 2 AM UTC
|
|
250
|
-
).then(
|
|
251
|
-
http('run-inventory-batch', { connection: 'fluent_commerce' }, async ctx => {
|
|
252
|
-
const { log, openKv } = ctx;
|
|
253
|
-
const jobId = `inventory-batch-${Date.now()}`;
|
|
254
|
-
const tracker = new JobTracker(openKv(':project:'), log);
|
|
255
|
-
|
|
256
|
-
await tracker.createJob(jobId, { triggeredBy: 'schedule', stage: 'initialization' });
|
|
257
|
-
await tracker.updateJob(jobId, { status: 'processing' });
|
|
258
|
-
|
|
259
|
-
try {
|
|
260
|
-
// Reuse shared orchestration logic
|
|
261
|
-
const result = await runIngestion(ctx, jobId, tracker);
|
|
262
|
-
await tracker.markCompleted(jobId, result);
|
|
263
|
-
return { success: true, jobId, ...result };
|
|
264
|
-
} catch (e: any) {
|
|
265
|
-
await tracker.markFailed(jobId, e);
|
|
266
|
-
return { success: false, jobId, error: e?.message };
|
|
267
|
-
}
|
|
268
|
-
})
|
|
269
|
-
);
|
|
270
|
-
```
|
|
271
|
-
|
|
272
|
-
---
|
|
273
|
-
|
|
274
|
-
### 2. Webhook Workflows (`src/workflows/webhook/`)
|
|
275
|
-
|
|
276
|
-
All HTTP-based triggers that create webhook endpoints.
|
|
277
|
-
|
|
278
|
-
#### `src/workflows/webhook/adhoc-inventory-sync.ts`
|
|
279
|
-
|
|
280
|
-
**Purpose**: Manual inventory sync trigger (on-demand)
|
|
281
|
-
**Trigger**: HTTP POST
|
|
282
|
-
**Endpoint**: `POST https://{workspace}.versori.run/inventory-batch-adhoc`
|
|
283
|
-
**Use Cases**: Testing, priority processing, ad-hoc runs
|
|
284
|
-
|
|
285
|
-
```typescript
|
|
286
|
-
import { webhook, http } from '@versori/run';
|
|
287
|
-
import { JobTracker } from '@fluentcommerce/fc-connect-sdk';
|
|
288
|
-
import { runIngestion } from '../../services/inventory-sync.service.ts';
|
|
289
|
-
|
|
290
|
-
/**
|
|
291
|
-
* Webhook: Manual Inventory Sync Trigger
|
|
292
|
-
*
|
|
293
|
-
* Endpoint: POST https://{workspace}.versori.run/inventory-batch-adhoc
|
|
294
|
-
* Request body (optional): { filePattern: "urgent_*.xml", maxFiles: 5 }
|
|
295
|
-
*
|
|
296
|
-
* Pattern: webhook().then(http()) - needs Fluent API access
|
|
297
|
-
* Uses shared service: inventory-sync.service.ts
|
|
298
|
-
*/
|
|
299
|
-
export const adhoc_inventory_sync = webhook('inventory-batch-adhoc', {
|
|
300
|
-
response: { mode: 'sync' },
|
|
301
|
-
connection: 'inventory-batch-adhoc', // Versori validates API key
|
|
302
|
-
}).then(
|
|
303
|
-
http('run-inventory-batch-adhoc', { connection: 'fluent_commerce' }, async ctx => {
|
|
304
|
-
const { log, openKv, data } = ctx;
|
|
305
|
-
const jobId = `inventory-batch-adhoc-${Date.now()}`;
|
|
306
|
-
const tracker = new JobTracker(openKv(':project:'), log);
|
|
307
|
-
|
|
308
|
-
await tracker.createJob(jobId, {
|
|
309
|
-
triggeredBy: 'manual',
|
|
310
|
-
stage: 'initialization',
|
|
311
|
-
options: data // Optional: filePattern, maxFiles, etc.
|
|
312
|
-
});
|
|
313
|
-
await tracker.updateJob(jobId, { status: 'processing' });
|
|
314
|
-
|
|
315
|
-
try {
|
|
316
|
-
// Same orchestration logic as scheduled workflow
|
|
317
|
-
const result = await runIngestion(ctx, jobId, tracker);
|
|
318
|
-
await tracker.markCompleted(jobId, result);
|
|
319
|
-
return { success: true, jobId, ...result };
|
|
320
|
-
} catch (e: any) {
|
|
321
|
-
await tracker.markFailed(jobId, e);
|
|
322
|
-
return { success: false, jobId, error: e?.message };
|
|
323
|
-
}
|
|
324
|
-
})
|
|
325
|
-
);
|
|
326
|
-
```
|
|
327
|
-
|
|
328
|
-
#### `src/workflows/webhook/job-status-check.ts`
|
|
329
|
-
|
|
330
|
-
**Purpose**: Query job status
|
|
331
|
-
**Trigger**: HTTP POST
|
|
332
|
-
**Endpoint**: `POST https://{workspace}.versori.run/inventory-batch-job-status`
|
|
333
|
-
**Request body**: `{ jobId: "inventory-batch-1234567890" }`
|
|
334
|
-
|
|
335
|
-
```typescript
|
|
336
|
-
import { webhook, fn } from '@versori/run';
|
|
337
|
-
import { JobTracker } from '@fluentcommerce/fc-connect-sdk';
|
|
338
|
-
|
|
339
|
-
/**
|
|
340
|
-
* Webhook: Job Status Check
|
|
341
|
-
*
|
|
342
|
-
* Endpoint: POST https://{workspace}.versori.run/inventory-batch-job-status
|
|
343
|
-
* Request body: { jobId: "inventory-batch-1234567890" }
|
|
344
|
-
*
|
|
345
|
-
* Pattern: webhook().then(fn()) - no external API needed, only KV storage
|
|
346
|
-
* Lightweight: Only queries KV store, no Fluent API calls
|
|
347
|
-
*/
|
|
348
|
-
export const jobStatusCheck = webhook('inventory-batch-job-status', {
|
|
349
|
-
response: { mode: 'sync' },
|
|
350
|
-
connection: 'inventory-batch-job-status',
|
|
351
|
-
}).then(
|
|
352
|
-
fn('status', async ctx => {
|
|
353
|
-
const { data, log, openKv } = ctx;
|
|
354
|
-
const jobId = data?.jobId as string;
|
|
355
|
-
|
|
356
|
-
if (!jobId) {
|
|
357
|
-
return { success: false, error: 'jobId required' };
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
const tracker = new JobTracker(openKv(':project:'), log);
|
|
361
|
-
const status = await tracker.getJob(jobId);
|
|
362
|
-
|
|
363
|
-
return status
|
|
364
|
-
? { success: true, jobId, ...status }
|
|
365
|
-
: { success: false, error: 'Job not found', jobId };
|
|
366
|
-
})
|
|
367
|
-
);
|
|
368
|
-
```
|
|
369
|
-
|
|
370
|
-
---
|
|
371
|
-
|
|
372
|
-
### 3. Entry Point (`index.ts`)
|
|
373
|
-
|
|
374
|
-
**Purpose**: Register all workflows with Versori platform
|
|
375
|
-
|
|
376
|
-
```typescript
|
|
377
|
-
/**
|
|
378
|
-
* Entry Point - Registers all workflows with Versori platform
|
|
379
|
-
*
|
|
380
|
-
* Versori automatically discovers and registers exported workflows
|
|
381
|
-
*
|
|
382
|
-
* File Structure:
|
|
383
|
-
* - src/workflows/scheduled/ → Time-based triggers (cron)
|
|
384
|
-
* - src/workflows/webhook/ → HTTP-based triggers (webhooks)
|
|
385
|
-
*/
|
|
386
|
-
|
|
387
|
-
// Import scheduled workflows
|
|
388
|
-
import { daily_inventory_sync } from './src/workflows/scheduled/daily-inventory-sync';
|
|
389
|
-
|
|
390
|
-
// Import webhook workflows
|
|
391
|
-
import { adhoc_inventory_sync } from './src/workflows/webhook/adhoc-inventory-sync';
|
|
392
|
-
import { jobStatusCheck } from './src/workflows/webhook/job-status-check';
|
|
393
|
-
|
|
394
|
-
// Register all workflows
|
|
395
|
-
export {
|
|
396
|
-
// Scheduled (time-based triggers)
|
|
397
|
-
daily_inventory_sync,
|
|
398
|
-
|
|
399
|
-
// Webhooks (HTTP-based triggers)
|
|
400
|
-
adhoc_inventory_sync,
|
|
401
|
-
jobStatusCheck,
|
|
402
|
-
};
|
|
403
|
-
```
|
|
404
|
-
|
|
405
|
-
**What Gets Exposed:**
|
|
406
|
-
|
|
407
|
-
- ✅ `adhoc_inventory_sync` → `https://{workspace}.versori.run/inventory-batch-adhoc`
|
|
408
|
-
- ✅ `jobStatusCheck` → `https://{workspace}.versori.run/inventory-batch-job-status`
|
|
409
|
-
- ❌ `daily_inventory_sync` → NOT exposed (runs automatically on cron)
|
|
410
|
-
|
|
411
|
-
---
|
|
412
|
-
|
|
413
|
-
### Adding New Workflows
|
|
414
|
-
|
|
415
|
-
**To add a scheduled workflow:**
|
|
416
|
-
1. Create file in `src/workflows/scheduled/` with descriptive name (e.g., `hourly-delta-sync.ts`)
|
|
417
|
-
2. Export the workflow from the file
|
|
418
|
-
3. Import and re-export in `index.ts`
|
|
419
|
-
|
|
420
|
-
**To add a webhook workflow:**
|
|
421
|
-
1. Create file in `src/workflows/webhook/` with descriptive name (e.g., `error-notification.ts`)
|
|
422
|
-
2. Export the workflow from the file
|
|
423
|
-
3. Import and re-export in `index.ts`
|
|
424
|
-
|
|
425
|
-
**Example - Adding hourly delta sync:**
|
|
426
|
-
|
|
427
|
-
```typescript
|
|
428
|
-
// src/workflows/scheduled/hourly-delta-sync.ts
|
|
429
|
-
export const hourlyDeltaSync = schedule(
|
|
430
|
-
'inventory-delta-hourly',
|
|
431
|
-
'0 * * * *' // Every hour
|
|
432
|
-
).then(
|
|
433
|
-
http('run-delta-sync', { connection: 'fluent_commerce' }, async ctx => {
|
|
434
|
-
// Delta sync logic (skip BPP)
|
|
435
|
-
const result = await runIngestion(ctx, jobId, tracker, { bppEnabled: false });
|
|
436
|
-
return result;
|
|
437
|
-
})
|
|
438
|
-
);
|
|
439
|
-
|
|
440
|
-
// index.ts (add to imports and exports)
|
|
441
|
-
import { hourlyDeltaSync } from './src/workflows/scheduled/hourly-delta-sync';
|
|
442
|
-
export { daily_inventory_sync, hourlyDeltaSync, ... };
|
|
443
|
-
```
|
|
444
|
-
|
|
445
|
-
---
|
|
446
|
-
## Complete Modular Implementation
|
|
447
|
-
|
|
448
|
-
### File: `package.json`
|
|
449
|
-
|
|
450
|
-
```json
|
|
451
|
-
{
|
|
452
|
-
"name": "multi-channel-inventory-sync",
|
|
453
|
-
"version": "1.0.0",
|
|
454
|
-
"description": "Multi-Channel Inventory Aggregation to Fluent Commerce Batch API",
|
|
455
|
-
"type": "module",
|
|
456
|
-
"versori": {
|
|
457
|
-
"workflows": "./index.ts"
|
|
458
|
-
},
|
|
459
|
-
"scripts": {
|
|
460
|
-
"lint": "eslint . --ext .ts",
|
|
461
|
-
"typecheck": "tsc --noEmit"
|
|
462
|
-
},
|
|
463
|
-
"dependencies": {
|
|
464
|
-
"@fluentcommerce/fc-connect-sdk": "^0.1.39",
|
|
465
|
-
"@versori/run": "latest"
|
|
466
|
-
},
|
|
467
|
-
"devDependencies": {
|
|
468
|
-
"@types/node": "^20.0.0",
|
|
469
|
-
"typescript": "^5.0.0"
|
|
470
|
-
},
|
|
471
|
-
"engines": {
|
|
472
|
-
"node": ">=18.0.0"
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
```
|
|
476
|
-
|
|
477
|
-
---
|
|
478
|
-
|
|
479
|
-
### File: `index.ts`
|
|
480
|
-
|
|
481
|
-
```typescript
|
|
482
|
-
import { schedule, webhook, http, fn } from '@versori/run';
|
|
483
|
-
import { processMultiChannelSync } from './src/workflows/multi-channel-sync.workflow';
|
|
484
|
-
|
|
485
|
-
/**
|
|
486
|
-
* Scheduled workflow: Multi-channel inventory sync every 15 minutes
|
|
487
|
-
*
|
|
488
|
-
* Processing: Parallel channel fetching with graceful degradation
|
|
489
|
-
* BPP: Disabled (preprocessing: 'skip') - delta detection already filters
|
|
490
|
-
* State Management: VersoriKVAdapter + JobTracker prevent duplicates
|
|
491
|
-
*/
|
|
492
|
-
export const scheduledMultiChannelSync = schedule(
|
|
493
|
-
'multi-channel-sync',
|
|
494
|
-
'*/15 * * * *' // Every 15 minutes
|
|
495
|
-
).then(
|
|
496
|
-
http('run-sync', { connection: 'fluent_commerce' }, async ctx => {
|
|
497
|
-
// ctx contains: fetch, connections, log, activation, openKv
|
|
498
|
-
// Pass entire context to workflow
|
|
499
|
-
return await processMultiChannelSync(ctx);
|
|
500
|
-
})
|
|
501
|
-
);
|
|
502
|
-
|
|
503
|
-
/**
|
|
504
|
-
* Manual trigger endpoint for testing and ad-hoc runs
|
|
505
|
-
*/
|
|
506
|
-
export const adhocMultiChannelSync = webhook('multi-channel-sync-adhoc', {
|
|
507
|
-
response: { mode: 'sync' },
|
|
508
|
-
connection: 'multi-channel-sync-adhoc',
|
|
509
|
-
}).then(
|
|
510
|
-
http('run-sync-adhoc', { connection: 'fluent_commerce' }, async ctx => {
|
|
511
|
-
return await processMultiChannelSync(ctx);
|
|
512
|
-
})
|
|
513
|
-
);
|
|
514
|
-
|
|
515
|
-
/**
|
|
516
|
-
* Job status check endpoint
|
|
517
|
-
*/
|
|
518
|
-
export const multiChannelSyncJobStatus = webhook('multi-channel-sync-job-status', {
|
|
519
|
-
response: { mode: 'sync' },
|
|
520
|
-
connection: 'multi-channel-sync-job-status',
|
|
521
|
-
}).then(
|
|
522
|
-
fn('status', async ctx => {
|
|
523
|
-
const { data, log, openKv } = ctx;
|
|
524
|
-
const jobId = data?.jobId as string;
|
|
525
|
-
if (!jobId) return { success: false, error: 'jobId required' };
|
|
526
|
-
const { JobTracker } = await import('@fluentcommerce/fc-connect-sdk');
|
|
527
|
-
const tracker = new JobTracker(openKv(':project:'), log);
|
|
528
|
-
const status = await tracker.getJob(jobId);
|
|
529
|
-
return status
|
|
530
|
-
? { success: true, jobId, ...status }
|
|
531
|
-
: { success: false, error: 'Job not found', jobId };
|
|
532
|
-
})
|
|
533
|
-
);
|
|
534
|
-
```
|
|
535
|
-
|
|
536
|
-
---
|
|
537
|
-
|
|
538
|
-
### File: `src/types/multi-channel.types.ts`
|
|
539
|
-
|
|
540
|
-
```typescript
|
|
541
|
-
/**
|
|
542
|
-
* Type definitions for multi-channel inventory sync
|
|
543
|
-
*/
|
|
544
|
-
export interface ChannelInventoryRecord {
|
|
545
|
-
sku: string;
|
|
546
|
-
location: string;
|
|
547
|
-
channel: string;
|
|
548
|
-
onHand: number;
|
|
549
|
-
reserved: number;
|
|
550
|
-
buffer: number;
|
|
551
|
-
lastUpdated?: string;
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
export interface AggregatedInventory {
|
|
555
|
-
sku: string;
|
|
556
|
-
location: string;
|
|
557
|
-
totalOnHand: number;
|
|
558
|
-
totalReserved: number;
|
|
559
|
-
atp: number;
|
|
560
|
-
channels: {
|
|
561
|
-
[channel: string]: {
|
|
562
|
-
allocated: number;
|
|
563
|
-
buffer: number;
|
|
564
|
-
max?: number;
|
|
565
|
-
};
|
|
566
|
-
};
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
export interface SyncState {
|
|
570
|
-
[sku: string]: {
|
|
571
|
-
[location: string]: number; // ATP value
|
|
572
|
-
};
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
export interface SyncStats {
|
|
576
|
-
totalRecords: number;
|
|
577
|
-
channelARecords: number;
|
|
578
|
-
channelBRecords: number;
|
|
579
|
-
fluentRecords: number;
|
|
580
|
-
aggregatedSkus: number;
|
|
581
|
-
changedRecords: number;
|
|
582
|
-
batchesSent: number;
|
|
583
|
-
successCount: number;
|
|
584
|
-
errorCount: number;
|
|
585
|
-
duration: number;
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
export interface BatchDetail {
|
|
589
|
-
batchId: string;
|
|
590
|
-
recordCount: number;
|
|
591
|
-
timestamp: string;
|
|
592
|
-
status: 'SENT' | 'FAILED';
|
|
593
|
-
error?: string;
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
export interface BatchResult {
|
|
597
|
-
totalSent: number;
|
|
598
|
-
batchCount: number;
|
|
599
|
-
batches: BatchDetail[];
|
|
600
|
-
errors: Array<{ batchId: string; error: string }>;
|
|
601
|
-
}
|
|
602
|
-
```
|
|
603
|
-
|
|
604
|
-
---
|
|
605
|
-
|
|
606
|
-
### File: `src/config/multi-channel.mapping.json`
|
|
607
|
-
|
|
608
|
-
```json
|
|
609
|
-
{
|
|
610
|
-
"name": "multi-channel-inventory",
|
|
611
|
-
"version": "1.0.0",
|
|
612
|
-
"description": "Normalize channel payloads to aggregation schema",
|
|
613
|
-
"fields": {
|
|
614
|
-
"locationRef": {
|
|
615
|
-
"source": "locationRef",
|
|
616
|
-
"required": true,
|
|
617
|
-
"resolver": "sdk.trim",
|
|
618
|
-
"comment": "Location reference"
|
|
619
|
-
},
|
|
620
|
-
"skuRef": {
|
|
621
|
-
"source": "skuRef",
|
|
622
|
-
"required": true,
|
|
623
|
-
"resolver": "sdk.trim",
|
|
624
|
-
"comment": "SKU reference"
|
|
625
|
-
},
|
|
626
|
-
"onHand": {
|
|
627
|
-
"source": "onHand",
|
|
628
|
-
"resolver": "sdk.number",
|
|
629
|
-
"comment": "On-hand quantity"
|
|
630
|
-
},
|
|
631
|
-
"reserved": {
|
|
632
|
-
"source": "reserved",
|
|
633
|
-
"resolver": "sdk.number",
|
|
634
|
-
"comment": "Reserved quantity"
|
|
635
|
-
},
|
|
636
|
-
"buffer": {
|
|
637
|
-
"source": "buffer",
|
|
638
|
-
"resolver": "sdk.number",
|
|
639
|
-
"comment": "Safety buffer"
|
|
640
|
-
},
|
|
641
|
-
"channel": {
|
|
642
|
-
"source": "channel",
|
|
643
|
-
"defaultValue": "UNKNOWN",
|
|
644
|
-
"comment": "Channel identifier"
|
|
645
|
-
}
|
|
646
|
-
}
|
|
647
|
-
}
|
|
648
|
-
```
|
|
649
|
-
|
|
650
|
-
> **✅ PRODUCTION STANDARD:** Use external JSON files for mapping configuration (not TypeScript objects)
|
|
651
|
-
|
|
652
|
-
---
|
|
653
|
-
|
|
654
|
-
### File: `src/services/atp-calculator.service.ts`
|
|
655
|
-
|
|
656
|
-
```typescript
|
|
657
|
-
import type { ChannelInventoryRecord, AggregatedInventory } from '../types/multi-channel.types';
|
|
658
|
-
|
|
659
|
-
/**
|
|
660
|
-
* Service for calculating ATP (Available To Promise) across channels
|
|
661
|
-
*/
|
|
662
|
-
export class ATPCalculatorService {
|
|
663
|
-
private readonly oversellProtection: boolean;
|
|
664
|
-
|
|
665
|
-
constructor(
|
|
666
|
-
oversellProtection = true
|
|
667
|
-
) {
|
|
668
|
-
this.oversellProtection = oversellProtection;
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
/**
|
|
672
|
-
* Calculate base ATP for a single record
|
|
673
|
-
* Formula: ATP = (onHand - reserved) - buffer
|
|
674
|
-
*/
|
|
675
|
-
calculateBaseATP(onHand: number, reserved: number, buffer: number): number {
|
|
676
|
-
const available = Math.max(0, onHand - reserved);
|
|
677
|
-
const atp = available - buffer;
|
|
678
|
-
return this.oversellProtection ? Math.max(0, atp) : atp;
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
/**
|
|
682
|
-
* Aggregate inventory across channels
|
|
683
|
-
* Deduplicates by SKU + location and calculates consolidated ATP
|
|
684
|
-
*/
|
|
685
|
-
aggregateChannelInventory(records: ChannelInventoryRecord[]): Map<string, AggregatedInventory> {
|
|
686
|
-
const aggregated = new Map<string, AggregatedInventory>();
|
|
687
|
-
|
|
688
|
-
for (const record of records) {
|
|
689
|
-
const key = `${record.sku}:${record.location}`;
|
|
690
|
-
const existing = aggregated.get(key);
|
|
691
|
-
|
|
692
|
-
if (existing) {
|
|
693
|
-
// Aggregate across channels
|
|
694
|
-
existing.totalOnHand += record.onHand;
|
|
695
|
-
existing.totalReserved += record.reserved;
|
|
696
|
-
existing.channels[record.channel] = {
|
|
697
|
-
allocated: record.onHand - record.reserved - record.buffer,
|
|
698
|
-
buffer: record.buffer,
|
|
699
|
-
};
|
|
700
|
-
} else {
|
|
701
|
-
// First record for this SKU+location
|
|
702
|
-
aggregated.set(key, {
|
|
703
|
-
sku: record.sku,
|
|
704
|
-
location: record.location,
|
|
705
|
-
totalOnHand: record.onHand,
|
|
706
|
-
totalReserved: record.reserved,
|
|
707
|
-
atp: 0,
|
|
708
|
-
channels: {
|
|
709
|
-
[record.channel]: {
|
|
710
|
-
allocated: record.onHand - record.reserved - record.buffer,
|
|
711
|
-
buffer: record.buffer,
|
|
712
|
-
},
|
|
713
|
-
},
|
|
714
|
-
});
|
|
715
|
-
}
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
// Calculate final ATP for each aggregated record
|
|
719
|
-
for (const [, agg] of aggregated) {
|
|
720
|
-
const totalBuffer = Math.max(...Object.values(agg.channels).map(c => c.buffer));
|
|
721
|
-
agg.atp = this.calculateBaseATP(agg.totalOnHand, agg.totalReserved, totalBuffer);
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
return aggregated;
|
|
726
|
-
}
|
|
727
|
-
}
|
|
728
|
-
```
|
|
729
|
-
|
|
730
|
-
---
|
|
731
|
-
|
|
732
|
-
### File: `src/services/channel-a-connector.service.ts`
|
|
733
|
-
|
|
734
|
-
```typescript
|
|
735
|
-
|
|
736
|
-
/**
|
|
737
|
-
* Service for fetching inventory from Channel A REST API
|
|
738
|
-
* Includes rate limiting and exponential backoff retry logic
|
|
739
|
-
*/
|
|
740
|
-
export class ChannelAConnectorService {
|
|
741
|
-
private readonly url: string;
|
|
742
|
-
private readonly apiKey: string;
|
|
743
|
-
private readonly rateLimitRpm: number;
|
|
744
|
-
private readonly logger; // ✅ Versori native log - TypeScript infers type
|
|
745
|
-
private lastRequest = 0;
|
|
746
|
-
|
|
747
|
-
constructor(url: string, apiKey: string, rateLimitRpm: number, logger) { // ✅ Versori native log - TypeScript infers type
|
|
748
|
-
this.url = url;
|
|
749
|
-
this.apiKey = apiKey;
|
|
750
|
-
this.rateLimitRpm = rateLimitRpm;
|
|
751
|
-
this.logger = logger;
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
/**
|
|
755
|
-
* Fetch inventory from Channel A with rate limiting
|
|
756
|
-
*/
|
|
757
|
-
async fetchInventory(): Promise<any[]> {
|
|
758
|
-
await this.enforceRateLimit();
|
|
759
|
-
|
|
760
|
-
const response = await this.fetchWithRetry(this.url, {
|
|
761
|
-
method: 'GET',
|
|
762
|
-
headers: {
|
|
763
|
-
'Content-Type': 'application/json',
|
|
764
|
-
Authorization: `Bearer ${this.apiKey}`,
|
|
765
|
-
},
|
|
766
|
-
});
|
|
767
|
-
|
|
768
|
-
if (!response.ok) {
|
|
769
|
-
throw new Error(`Channel A API error: ${response.status} ${response.statusText}`);
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
const data = await response.json();
|
|
773
|
-
return data.inventory || [];
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
/**
|
|
777
|
-
* Enforce rate limit (minimum interval between requests)
|
|
778
|
-
*/
|
|
779
|
-
private async enforceRateLimit(): Promise<void> {
|
|
780
|
-
const minInterval = 60000 / this.rateLimitRpm;
|
|
781
|
-
const now = Date.now();
|
|
782
|
-
const elapsed = now - this.lastRequest;
|
|
783
|
-
|
|
784
|
-
if (elapsed < minInterval) {
|
|
785
|
-
const wait = minInterval - elapsed;
|
|
786
|
-
await new Promise(resolve => setTimeout(resolve, wait));
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
this.lastRequest = Date.now();
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
/**
|
|
793
|
-
* Fetch with exponential backoff retry
|
|
794
|
-
*/
|
|
795
|
-
private async fetchWithRetry(
|
|
796
|
-
url: string,
|
|
797
|
-
options: RequestInit,
|
|
798
|
-
maxRetries = 3
|
|
799
|
-
): Promise<Response> {
|
|
800
|
-
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
801
|
-
try {
|
|
802
|
-
const response = await fetch(url, options);
|
|
803
|
-
|
|
804
|
-
if (response.status === 429 || response.status >= 500) {
|
|
805
|
-
if (attempt < maxRetries) {
|
|
806
|
-
const backoff = Math.pow(2, attempt) * 1000;
|
|
807
|
-
`[ChannelA] Error ${response.status}, retrying in ${backoff}ms (attempt ${attempt}/${maxRetries})`
|
|
808
|
-
);
|
|
809
|
-
await new Promise(resolve => setTimeout(resolve, backoff));
|
|
810
|
-
continue;
|
|
811
|
-
}
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
return response;
|
|
815
|
-
} catch (error) {
|
|
816
|
-
if (attempt === maxRetries) throw error;
|
|
817
|
-
const backoff = Math.pow(2, attempt) * 1000;
|
|
818
|
-
`[ChannelA] Fetch error, retrying in ${backoff}ms (attempt ${attempt}/${maxRetries}):`,
|
|
819
|
-
error
|
|
820
|
-
);
|
|
821
|
-
await new Promise(resolve => setTimeout(resolve, backoff));
|
|
822
|
-
}
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
throw new Error('Channel A: Max retries exceeded');
|
|
826
|
-
}
|
|
827
|
-
}
|
|
828
|
-
```
|
|
829
|
-
|
|
830
|
-
---
|
|
831
|
-
|
|
832
|
-
### File: `src/services/channel-b-connector.service.ts`
|
|
833
|
-
|
|
834
|
-
```typescript
|
|
835
|
-
import { S3DataSource, CSVParserService } from '@fluentcommerce/fc-connect-sdk';
|
|
836
|
-
|
|
837
|
-
/**
|
|
838
|
-
* Service for fetching inventory from Channel B S3 CSV
|
|
839
|
-
*/
|
|
840
|
-
export class ChannelBConnectorService {
|
|
841
|
-
private readonly s3: S3DataSource;
|
|
842
|
-
private readonly csv: CSVParserService;
|
|
843
|
-
private readonly bucket: string;
|
|
844
|
-
private readonly key: string;
|
|
845
|
-
private readonly logger; // ✅ Versori native log - TypeScript infers type
|
|
846
|
-
|
|
847
|
-
constructor(s3Config: any, bucket: string, key: string, logger) { // ✅ Versori native log - TypeScript infers type
|
|
848
|
-
this.s3 = new S3DataSource(s3Config, logger);
|
|
849
|
-
this.csv = new CSVParserService();
|
|
850
|
-
this.bucket = bucket;
|
|
851
|
-
this.key = key;
|
|
852
|
-
}
|
|
853
|
-
|
|
854
|
-
/**
|
|
855
|
-
* Fetch inventory from S3 CSV file
|
|
856
|
-
*/
|
|
857
|
-
async fetchInventory(): Promise<any[]> {
|
|
858
|
-
try {
|
|
859
|
-
const csvContent = (await this.s3.downloadFile(`${this.bucket}/${this.key}`, {
|
|
860
|
-
encoding: 'utf8',
|
|
861
|
-
})) as string;
|
|
862
|
-
|
|
863
|
-
const records = await this.csv.parse(csvContent, {
|
|
864
|
-
columns: true,
|
|
865
|
-
skip_empty_lines: true,
|
|
866
|
-
trim: true,
|
|
867
|
-
});
|
|
868
|
-
|
|
869
|
-
return records || [];
|
|
870
|
-
} catch (error) {
|
|
871
|
-
throw error;
|
|
872
|
-
}
|
|
873
|
-
// Note: S3DataSource doesn't require explicit disposal (unlike SFTP)
|
|
874
|
-
}
|
|
875
|
-
}
|
|
876
|
-
```
|
|
877
|
-
|
|
878
|
-
---
|
|
879
|
-
|
|
880
|
-
### File: `src/services/batch-processor.service.ts`
|
|
881
|
-
|
|
882
|
-
```typescript
|
|
883
|
-
import type { FluentClient } from '@fluentcommerce/fc-connect-sdk';
|
|
884
|
-
import { JobTracker } from '@fluentcommerce/fc-connect-sdk';
|
|
885
|
-
import { BatchResult, BatchDetail } from '../types/multi-channel.types';
|
|
886
|
-
|
|
887
|
-
/**
|
|
888
|
-
* Service for sending records to Fluent Batch API
|
|
889
|
-
*
|
|
890
|
-
* ✅ PRODUCTION ENHANCEMENT: Optional logger for detailed progress tracking
|
|
891
|
-
*/
|
|
892
|
-
export class BatchProcessorService {
|
|
893
|
-
constructor(
|
|
894
|
-
private client: FluentClient,
|
|
895
|
-
private jobTracker: JobTracker,
|
|
896
|
-
private log?: any // ✅ Optional logger for progress tracking
|
|
897
|
-
) {}
|
|
898
|
-
|
|
899
|
-
/**
|
|
900
|
-
* Send inventory records to Batch API with chunking
|
|
901
|
-
*/
|
|
902
|
-
async sendInventoryBatches(
|
|
903
|
-
jobId: string,
|
|
904
|
-
records: any[],
|
|
905
|
-
batchSize: number = 500
|
|
906
|
-
): Promise<BatchResult> {
|
|
907
|
-
const result: BatchResult = {
|
|
908
|
-
totalSent: 0,
|
|
909
|
-
batchCount: 0,
|
|
910
|
-
batches: [],
|
|
911
|
-
errors: [],
|
|
912
|
-
};
|
|
913
|
-
|
|
914
|
-
// Chunk records into batches
|
|
915
|
-
const chunks: any[][] = [];
|
|
916
|
-
for (let i = 0; i < records.length; i += batchSize) {
|
|
917
|
-
chunks.push(records.slice(i, i + batchSize));
|
|
918
|
-
}
|
|
919
|
-
|
|
920
|
-
const totalBatches = chunks.length;
|
|
921
|
-
|
|
922
|
-
// ✅ PRODUCTION ENHANCEMENT: Log batch sending start
|
|
923
|
-
if (this.log) {
|
|
924
|
-
this.log.info('📤 Starting batch sending', {
|
|
925
|
-
jobId,
|
|
926
|
-
totalRecords: records.length,
|
|
927
|
-
batchSize,
|
|
928
|
-
totalBatches,
|
|
929
|
-
processingMode: 'sequential (one at a time)',
|
|
930
|
-
});
|
|
931
|
-
}
|
|
932
|
-
|
|
933
|
-
// Send each batch
|
|
934
|
-
for (let i = 0; i < chunks.length; i++) {
|
|
935
|
-
const batchNumber = i + 1;
|
|
936
|
-
|
|
937
|
-
// ✅ PRODUCTION ENHANCEMENT: Log progress every 10 batches
|
|
938
|
-
if (this.log && batchNumber % 10 === 0) {
|
|
939
|
-
this.log.info(`📤 Sending batch ${batchNumber}/${totalBatches}`, {
|
|
940
|
-
jobId,
|
|
941
|
-
batchNumber,
|
|
942
|
-
totalBatches,
|
|
943
|
-
recordsInBatch: chunks[i].length,
|
|
944
|
-
totalSentSoFar: result.totalSent,
|
|
945
|
-
progress: `${((batchNumber / totalBatches) * 100).toFixed(1)}%`,
|
|
946
|
-
});
|
|
947
|
-
}
|
|
948
|
-
|
|
949
|
-
try {
|
|
950
|
-
const batch = await this.client.sendBatch(jobId, {
|
|
951
|
-
action: 'UPSERT',
|
|
952
|
-
entityType: 'INVENTORY',
|
|
953
|
-
source: 'MULTI_CHANNEL',
|
|
954
|
-
event: 'MULTI_CHANNEL_SYNC',
|
|
955
|
-
entities: chunks[i],
|
|
956
|
-
});
|
|
957
|
-
|
|
958
|
-
result.totalSent += chunks[i].length;
|
|
959
|
-
result.batchCount++;
|
|
960
|
-
result.batches.push({
|
|
961
|
-
batchId: batch.id,
|
|
962
|
-
recordCount: chunks[i].length,
|
|
963
|
-
timestamp: new Date().toISOString(),
|
|
964
|
-
status: 'SENT',
|
|
965
|
-
});
|
|
966
|
-
|
|
967
|
-
// ✅ No logging here - workflow handles it
|
|
968
|
-
|
|
969
|
-
// Update job tracker
|
|
970
|
-
await this.jobTracker.updateJob(jobId, {
|
|
971
|
-
details: {
|
|
972
|
-
batchesSent: result.batchCount,
|
|
973
|
-
recordsProcessed: result.totalSent,
|
|
974
|
-
},
|
|
975
|
-
});
|
|
976
|
-
} catch (error: any) {
|
|
977
|
-
result.errors.push({
|
|
978
|
-
batchId: `batch-${batchNumber}`,
|
|
979
|
-
error: error.message,
|
|
980
|
-
});
|
|
981
|
-
result.batches.push({
|
|
982
|
-
batchId: `batch-${batchNumber}`,
|
|
983
|
-
recordCount: chunks[i].length,
|
|
984
|
-
timestamp: new Date().toISOString(),
|
|
985
|
-
status: 'FAILED',
|
|
986
|
-
error: error.message,
|
|
987
|
-
});
|
|
988
|
-
}
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
// ✅ PRODUCTION ENHANCEMENT: Log completion
|
|
992
|
-
if (this.log) {
|
|
993
|
-
this.log.info('✅ Sequential batch sending completed', {
|
|
994
|
-
jobId,
|
|
995
|
-
totalBatches,
|
|
996
|
-
batchesSent: result.batchCount,
|
|
997
|
-
batchesFailed: result.errors.length,
|
|
998
|
-
totalRecordsSent: result.totalSent,
|
|
999
|
-
});
|
|
1000
|
-
}
|
|
1001
|
-
|
|
1002
|
-
return result;
|
|
1003
|
-
}
|
|
1004
|
-
}
|
|
1005
|
-
```
|
|
1006
|
-
|
|
1007
|
-
---
|
|
1008
|
-
|
|
1009
|
-
### File: `src/services/batch-logger.service.ts`
|
|
1010
|
-
|
|
1011
|
-
```typescript
|
|
1012
|
-
import { Buffer } from 'node:buffer';
|
|
1013
|
-
import { S3DataSource } from '@fluentcommerce/fc-connect-sdk';
|
|
1014
|
-
|
|
1015
|
-
/**
|
|
1016
|
-
* Service for writing batch processing logs to S3
|
|
1017
|
-
*/
|
|
1018
|
-
export class BatchLoggerService {
|
|
1019
|
-
constructor(private s3: S3DataSource) {
|
|
1020
|
-
// ✅ No logger - workflow handles logging with Versori native log
|
|
1021
|
-
}
|
|
1022
|
-
|
|
1023
|
-
/**
|
|
1024
|
-
* Write batch processing log to S3
|
|
1025
|
-
*/
|
|
1026
|
-
async writeBatchLog(
|
|
1027
|
-
logData: any,
|
|
1028
|
-
bucket: string,
|
|
1029
|
-
keyPrefix: string,
|
|
1030
|
-
format: 'json' | 'text' = 'json'
|
|
1031
|
-
): Promise<void> {
|
|
1032
|
-
try {
|
|
1033
|
-
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
1034
|
-
const logFileName = `${keyPrefix}${timestamp}.${format === 'json' ? 'json' : 'log'}`;
|
|
1035
|
-
const logKey = `${bucket}/${logFileName}`;
|
|
1036
|
-
|
|
1037
|
-
const logContent = this.formatLogContent(logData, format);
|
|
1038
|
-
|
|
1039
|
-
await this.s3.uploadFile(logKey, Buffer.from(logContent, 'utf8'), {
|
|
1040
|
-
encoding: 'utf8',
|
|
1041
|
-
contentType: format === 'json' ? 'application/json' : 'text/plain',
|
|
1042
|
-
});
|
|
1043
|
-
|
|
1044
|
-
// ✅ No logging here - workflow handles it
|
|
1045
|
-
} catch (error: any) {
|
|
1046
|
-
// ✅ No logging here - workflow handles it
|
|
1047
|
-
// Don't throw - logging failure shouldn't stop workflow
|
|
1048
|
-
}
|
|
1049
|
-
}
|
|
1050
|
-
|
|
1051
|
-
private formatLogContent(logData: any, format: 'json' | 'text'): string {
|
|
1052
|
-
if (format === 'json') {
|
|
1053
|
-
return JSON.stringify(logData, null, 2);
|
|
1054
|
-
}
|
|
1055
|
-
|
|
1056
|
-
return `Multi-Channel Sync Log
|
|
1057
|
-
======================
|
|
1058
|
-
Timestamp: ${logData.timestamp}
|
|
1059
|
-
Job ID: ${logData.jobId}
|
|
1060
|
-
|
|
1061
|
-
Channel Summary:
|
|
1062
|
-
Channel A: ${logData.channels.channelA || 0} records
|
|
1063
|
-
Channel B: ${logData.channels.channelB || 0} records
|
|
1064
|
-
Fluent: ${logData.channels.fluent || 0} records
|
|
1065
|
-
|
|
1066
|
-
Aggregation:
|
|
1067
|
-
Total Records: ${logData.aggregated}
|
|
1068
|
-
Changed Records: ${logData.changed}
|
|
1069
|
-
Unchanged: ${logData.unchanged}
|
|
1070
|
-
|
|
1071
|
-
Batches:
|
|
1072
|
-
${logData.batches
|
|
1073
|
-
.map(
|
|
1074
|
-
(b: any, i: number) =>
|
|
1075
|
-
` [${i + 1}] ${b.batchId} | ${b.recordCount} records | ${b.status}${b.error ? ` | Error: ${b.error}` : ''}`
|
|
1076
|
-
)
|
|
1077
|
-
.join('\n')}
|
|
1078
|
-
|
|
1079
|
-
Summary:
|
|
1080
|
-
Total Batches: ${logData.summary.totalBatches}
|
|
1081
|
-
Successful: ${logData.summary.success}
|
|
1082
|
-
Failed: ${logData.summary.failed}
|
|
1083
|
-
Duration: ${logData.summary.duration}ms
|
|
1084
|
-
|
|
1085
|
-
Status: ${logData.status}
|
|
1086
|
-
`;
|
|
1087
|
-
}
|
|
1088
|
-
}
|
|
1089
|
-
```
|
|
1090
|
-
|
|
1091
|
-
---
|
|
1092
|
-
|
|
1093
|
-
### File: `src/workflows/multi-channel-sync.workflow.ts`
|
|
1094
|
-
|
|
1095
|
-
```typescript
|
|
1096
|
-
import { Buffer } from 'node:buffer';
|
|
1097
|
-
import {
|
|
1098
|
-
createClient,
|
|
1099
|
-
StateService,
|
|
1100
|
-
VersoriKVAdapter,
|
|
1101
|
-
JobTracker,
|
|
1102
|
-
} from '@fluentcommerce/fc-connect-sdk';
|
|
1103
|
-
import type { ChannelInventoryRecord, SyncStats } from '../types/multi-channel.types';
|
|
1104
|
-
import { ATPCalculatorService } from '../services/atp-calculator.service';
|
|
1105
|
-
import { ChannelAConnectorService } from '../services/channel-a-connector.service';
|
|
1106
|
-
import { ChannelBConnectorService } from '../services/channel-b-connector.service';
|
|
1107
|
-
import { BatchProcessorService } from '../services/batch-processor.service';
|
|
1108
|
-
import { BatchLoggerService } from '../services/batch-logger.service';
|
|
1109
|
-
|
|
1110
|
-
const INVENTORY_QUERY = `
|
|
1111
|
-
query GetInventory($retailerId: ID!, $first: Int!, $after: String) {
|
|
1112
|
-
inventoryPositions(retailerId: $retailerId, first: $first, after: $after) {
|
|
1113
|
-
edges {
|
|
1114
|
-
node {
|
|
1115
|
-
id
|
|
1116
|
-
ref
|
|
1117
|
-
productRef
|
|
1118
|
-
locationRef
|
|
1119
|
-
onHand
|
|
1120
|
-
reservedQuantity
|
|
1121
|
-
status
|
|
1122
|
-
type
|
|
1123
|
-
}
|
|
1124
|
-
cursor
|
|
1125
|
-
}
|
|
1126
|
-
pageInfo {
|
|
1127
|
-
hasNextPage
|
|
1128
|
-
endCursor
|
|
1129
|
-
}
|
|
1130
|
-
}
|
|
1131
|
-
}
|
|
1132
|
-
`;
|
|
1133
|
-
|
|
1134
|
-
/**
|
|
1135
|
-
* Fetch current inventory state from Fluent GraphQL
|
|
1136
|
-
*/
|
|
1137
|
-
async function fetchFluentInventory(
|
|
1138
|
-
client: any,
|
|
1139
|
-
retailerId: string,
|
|
1140
|
-
pageSize: number,
|
|
1141
|
-
maxRecords: number,
|
|
1142
|
-
log: any
|
|
1143
|
-
): Promise<any[]> {
|
|
1144
|
-
try {
|
|
1145
|
-
const result = await client.graphql({
|
|
1146
|
-
query: INVENTORY_QUERY,
|
|
1147
|
-
variables: { retailerId, first: pageSize },
|
|
1148
|
-
pagination: { maxRecords },
|
|
1149
|
-
});
|
|
1150
|
-
|
|
1151
|
-
const edges = result.data?.inventoryPositions?.edges || [];
|
|
1152
|
-
const records = edges.map((e: any) => ({
|
|
1153
|
-
productRef: e.node.productRef,
|
|
1154
|
-
locationRef: e.node.locationRef,
|
|
1155
|
-
onHand: Number(e.node.onHand || 0),
|
|
1156
|
-
reserved: Number(e.node.reservedQuantity || 0),
|
|
1157
|
-
}));
|
|
1158
|
-
|
|
1159
|
-
log.info(`[Fluent] Fetched ${records.length} records from GraphQL`);
|
|
1160
|
-
return records;
|
|
1161
|
-
} catch (error) {
|
|
1162
|
-
// ✅ Enhanced error logging: Extract all error details for visibility
|
|
1163
|
-
const errorDetails = {
|
|
1164
|
-
message: error instanceof Error ? error.message : String(error),
|
|
1165
|
-
stack: error instanceof Error ? error.stack : undefined,
|
|
1166
|
-
errorType: error instanceof Error ? error.constructor.name : 'Error',
|
|
1167
|
-
};
|
|
1168
|
-
log.error('[Fluent] GraphQL fetch error:', errorDetails);
|
|
1169
|
-
throw error;
|
|
1170
|
-
}
|
|
1171
|
-
}
|
|
1172
|
-
|
|
1173
|
-
/**
|
|
1174
|
-
* Main multi-channel sync workflow orchestrator
|
|
1175
|
-
* Coordinates channel fetching, aggregation, delta detection, and batch submission
|
|
1176
|
-
*
|
|
1177
|
-
* @param ctx - Versori context object containing fetch, connections, log, activation, openKv
|
|
1178
|
-
*/
|
|
1179
|
-
export async function processMultiChannelSync(ctx: any) {
|
|
1180
|
-
const { log, openKv, activation } = ctx;
|
|
1181
|
-
const startTime = Date.now();
|
|
1182
|
-
|
|
1183
|
-
log.info('[MultiChannelSync] Starting sync workflow');
|
|
1184
|
-
|
|
1185
|
-
try {
|
|
1186
|
-
// ========================================
|
|
1187
|
-
// CLIENT INITIALIZATION
|
|
1188
|
-
// ========================================
|
|
1189
|
-
const client = await createClient(ctx);
|
|
1190
|
-
|
|
1191
|
-
// ========================================
|
|
1192
|
-
// CONFIGURATION
|
|
1193
|
-
// ========================================
|
|
1194
|
-
const config = {
|
|
1195
|
-
retailerId: activation?.getVariable('retailerId'),
|
|
1196
|
-
jobName: activation?.getVariable('jobName') || 'Multi-Channel Inventory Sync',
|
|
1197
|
-
batchSize: parseInt(activation?.getVariable('batchSize') || '500', 10),
|
|
1198
|
-
maxRecords: parseInt(activation?.getVariable('maxRecords') || '50000', 10),
|
|
1199
|
-
defaultBuffer: parseInt(activation?.getVariable('defaultBuffer') || '5', 10),
|
|
1200
|
-
oversellProtection: activation?.getVariable('oversellProtection') !== 'false',
|
|
1201
|
-
useBpp: activation?.getVariable('useBpp') || 'skip',
|
|
1202
|
-
enableDelta: activation?.getVariable('enableDelta') !== 'false',
|
|
1203
|
-
deltaStateKey: activation?.getVariable('deltaStateKey') || 'sync:delta:state',
|
|
1204
|
-
channelAEnabled: activation?.getVariable('channelAEnabled') === 'true',
|
|
1205
|
-
channelAUrl: activation?.getVariable('channelAUrl'),
|
|
1206
|
-
channelAKey: activation?.getVariable('channelAKey'),
|
|
1207
|
-
channelABuffer: parseInt(activation?.getVariable('channelABuffer') || '5', 10),
|
|
1208
|
-
channelARateLimitRpm: parseInt(activation?.getVariable('channelARateLimitRpm') || '120', 10),
|
|
1209
|
-
channelBEnabled: activation?.getVariable('channelBEnabled') === 'true',
|
|
1210
|
-
channelBBucket: activation?.getVariable('channelBBucket'),
|
|
1211
|
-
channelBKey: activation?.getVariable('channelBKey'),
|
|
1212
|
-
fluentEnabled: activation?.getVariable('fluentEnabled') === 'true',
|
|
1213
|
-
fluentPageSize: parseInt(activation?.getVariable('fluentPageSize') || '200', 10),
|
|
1214
|
-
};
|
|
1215
|
-
|
|
1216
|
-
// ========================================
|
|
1217
|
-
// SERVICE INITIALIZATION
|
|
1218
|
-
// ========================================
|
|
1219
|
-
const kv = new VersoriKVAdapter(openKv(':project:'));
|
|
1220
|
-
const jobTracker = new JobTracker(openKv(':project:'), log);
|
|
1221
|
-
const stateService = new StateService(log);
|
|
1222
|
-
|
|
1223
|
-
const atpCalc = new ATPCalculatorService(config.oversellProtection);
|
|
1224
|
-
// ✅ PRODUCTION ENHANCEMENT: Pass log to BatchProcessorService for detailed progress tracking
|
|
1225
|
-
const batchProcessor = new BatchProcessorService(client, jobTracker, log);
|
|
1226
|
-
|
|
1227
|
-
const jobId = `multi-channel-sync-${Date.now()}`;
|
|
1228
|
-
await jobTracker.createJob(jobId, {
|
|
1229
|
-
triggeredBy: 'schedule',
|
|
1230
|
-
stage: 'initialization',
|
|
1231
|
-
details: { config },
|
|
1232
|
-
});
|
|
1233
|
-
|
|
1234
|
-
const stats: SyncStats = {
|
|
1235
|
-
totalRecords: 0,
|
|
1236
|
-
channelARecords: 0,
|
|
1237
|
-
channelBRecords: 0,
|
|
1238
|
-
fluentRecords: 0,
|
|
1239
|
-
aggregatedSkus: 0,
|
|
1240
|
-
changedRecords: 0,
|
|
1241
|
-
batchesSent: 0,
|
|
1242
|
-
successCount: 0,
|
|
1243
|
-
errorCount: 0,
|
|
1244
|
-
duration: 0,
|
|
1245
|
-
};
|
|
1246
|
-
|
|
1247
|
-
await jobTracker.updateJob(jobId, { status: 'fetching_channels' });
|
|
1248
|
-
|
|
1249
|
-
// ========================================
|
|
1250
|
-
// CHANNEL FETCHING (PARALLEL WITH GRACEFUL DEGRADATION)
|
|
1251
|
-
// ========================================
|
|
1252
|
-
const allRecords: ChannelInventoryRecord[] = [];
|
|
1253
|
-
|
|
1254
|
-
// Fetch Channel A (REST API)
|
|
1255
|
-
if (config.channelAEnabled && config.channelAUrl && config.channelAKey) {
|
|
1256
|
-
log.info('[MultiChannelSync] Fetching from Channel A...');
|
|
1257
|
-
const channelA = new ChannelAConnectorService(
|
|
1258
|
-
config.channelAUrl,
|
|
1259
|
-
config.channelAKey,
|
|
1260
|
-
config.channelARateLimitRpm,
|
|
1261
|
-
log
|
|
1262
|
-
);
|
|
1263
|
-
|
|
1264
|
-
try {
|
|
1265
|
-
const records = await channelA.fetchInventory();
|
|
1266
|
-
const mapped = records.map(r => ({
|
|
1267
|
-
sku: r.product_id,
|
|
1268
|
-
location: r.warehouse_code,
|
|
1269
|
-
channel: 'A',
|
|
1270
|
-
onHand: r.quantity_available,
|
|
1271
|
-
reserved: r.quantity_reserved,
|
|
1272
|
-
buffer: config.channelABuffer,
|
|
1273
|
-
lastUpdated: r.updated_at,
|
|
1274
|
-
}));
|
|
1275
|
-
|
|
1276
|
-
allRecords.push(...mapped);
|
|
1277
|
-
stats.channelARecords = mapped.length;
|
|
1278
|
-
log.info(`[MultiChannelSync] Channel A: ${mapped.length} records`);
|
|
1279
|
-
} catch (error) {
|
|
1280
|
-
// ✅ Enhanced error logging: Extract all error details for visibility
|
|
1281
|
-
const errorDetails = {
|
|
1282
|
-
message: error instanceof Error ? error.message : String(error),
|
|
1283
|
-
stack: error instanceof Error ? error.stack : undefined,
|
|
1284
|
-
errorType: error instanceof Error ? error.constructor.name : 'Error',
|
|
1285
|
-
};
|
|
1286
|
-
log.error('[MultiChannelSync] Channel A fetch failed (continuing):', errorDetails);
|
|
1287
|
-
stats.errorCount++;
|
|
1288
|
-
}
|
|
1289
|
-
}
|
|
1290
|
-
|
|
1291
|
-
// Fetch Channel B (S3 CSV)
|
|
1292
|
-
if (config.channelBEnabled && config.channelBBucket && config.channelBKey) {
|
|
1293
|
-
log.info('[MultiChannelSync] Fetching from Channel B...');
|
|
1294
|
-
const s3Config = {
|
|
1295
|
-
type: 'S3_CSV',
|
|
1296
|
-
connectionId: 'channel-b-s3',
|
|
1297
|
-
name: 'Channel B S3',
|
|
1298
|
-
s3Config: {
|
|
1299
|
-
bucket: config.channelBBucket,
|
|
1300
|
-
region: activation?.getVariable('awsRegion') || 'us-east-1',
|
|
1301
|
-
accessKeyId: activation?.getVariable('awsAccessKeyId'),
|
|
1302
|
-
secretAccessKey: activation?.getVariable('awsSecretAccessKey'),
|
|
1303
|
-
},
|
|
1304
|
-
};
|
|
1305
|
-
|
|
1306
|
-
const channelB = new ChannelBConnectorService(
|
|
1307
|
-
s3Config,
|
|
1308
|
-
config.channelBBucket,
|
|
1309
|
-
config.channelBKey,
|
|
1310
|
-
log
|
|
1311
|
-
);
|
|
1312
|
-
|
|
1313
|
-
try {
|
|
1314
|
-
const records = await channelB.fetchInventory();
|
|
1315
|
-
const mapped = records.map((r: any) => ({
|
|
1316
|
-
sku: r.sku,
|
|
1317
|
-
location: r.location,
|
|
1318
|
-
channel: 'B',
|
|
1319
|
-
onHand: parseInt(r.qty, 10),
|
|
1320
|
-
reserved: parseInt(r.reserved, 10),
|
|
1321
|
-
buffer: config.defaultBuffer,
|
|
1322
|
-
}));
|
|
1323
|
-
|
|
1324
|
-
allRecords.push(...mapped);
|
|
1325
|
-
stats.channelBRecords = mapped.length;
|
|
1326
|
-
log.info(`[MultiChannelSync] Channel B: ${mapped.length} records`);
|
|
1327
|
-
} catch (error) {
|
|
1328
|
-
// ✅ Enhanced error logging: Extract all error details for visibility
|
|
1329
|
-
const errorDetails = {
|
|
1330
|
-
message: error instanceof Error ? error.message : String(error),
|
|
1331
|
-
stack: error instanceof Error ? error.stack : undefined,
|
|
1332
|
-
errorType: error instanceof Error ? error.constructor.name : 'Error',
|
|
1333
|
-
};
|
|
1334
|
-
log.error('[MultiChannelSync] Channel B fetch failed (continuing):', errorDetails);
|
|
1335
|
-
stats.errorCount++;
|
|
1336
|
-
}
|
|
1337
|
-
}
|
|
1338
|
-
|
|
1339
|
-
// Fetch Fluent GraphQL (current state)
|
|
1340
|
-
if (config.fluentEnabled) {
|
|
1341
|
-
log.info('[MultiChannelSync] Fetching from Fluent GraphQL...');
|
|
1342
|
-
try {
|
|
1343
|
-
const records = await fetchFluentInventory(
|
|
1344
|
-
client,
|
|
1345
|
-
config.retailerId!,
|
|
1346
|
-
config.fluentPageSize,
|
|
1347
|
-
config.maxRecords,
|
|
1348
|
-
log
|
|
1349
|
-
);
|
|
1350
|
-
|
|
1351
|
-
const mapped = records.map(r => ({
|
|
1352
|
-
sku: r.productRef,
|
|
1353
|
-
location: r.locationRef,
|
|
1354
|
-
channel: 'FLUENT',
|
|
1355
|
-
onHand: r.onHand,
|
|
1356
|
-
reserved: r.reserved,
|
|
1357
|
-
buffer: 0,
|
|
1358
|
-
}));
|
|
1359
|
-
|
|
1360
|
-
allRecords.push(...mapped);
|
|
1361
|
-
stats.fluentRecords = mapped.length;
|
|
1362
|
-
log.info(`[MultiChannelSync] Fluent: ${mapped.length} records`);
|
|
1363
|
-
} catch (error) {
|
|
1364
|
-
// ✅ Enhanced error logging: Extract all error details for visibility
|
|
1365
|
-
const errorDetails = {
|
|
1366
|
-
message: error instanceof Error ? error.message : String(error),
|
|
1367
|
-
stack: error instanceof Error ? error.stack : undefined,
|
|
1368
|
-
errorType: error instanceof Error ? error.constructor.name : 'Error',
|
|
1369
|
-
};
|
|
1370
|
-
log.error('[MultiChannelSync] Fluent fetch failed (continuing):', errorDetails);
|
|
1371
|
-
stats.errorCount++;
|
|
1372
|
-
}
|
|
1373
|
-
}
|
|
1374
|
-
|
|
1375
|
-
stats.totalRecords = allRecords.length;
|
|
1376
|
-
|
|
1377
|
-
if (allRecords.length === 0) {
|
|
1378
|
-
log.warn('[MultiChannelSync] No inventory records fetched from any channel');
|
|
1379
|
-
await jobTracker.markCompleted(jobId, { message: 'No data', stats });
|
|
1380
|
-
return { success: false, message: 'No data', stats };
|
|
1381
|
-
}
|
|
1382
|
-
|
|
1383
|
-
// ========================================
|
|
1384
|
-
// AGGREGATION
|
|
1385
|
-
// ========================================
|
|
1386
|
-
await jobTracker.updateJob(jobId, { status: 'aggregating' });
|
|
1387
|
-
|
|
1388
|
-
log.info('[MultiChannelSync] Aggregating inventory across channels...');
|
|
1389
|
-
const aggregated = atpCalc.aggregateChannelInventory(allRecords);
|
|
1390
|
-
stats.aggregatedSkus = aggregated.size;
|
|
1391
|
-
|
|
1392
|
-
const finalInventory = Array.from(aggregated.values()).map(agg => ({
|
|
1393
|
-
skuRef: agg.sku,
|
|
1394
|
-
locationRef: agg.location,
|
|
1395
|
-
qty: agg.atp,
|
|
1396
|
-
type: 'AVAILABLE',
|
|
1397
|
-
status: 'ACTIVE',
|
|
1398
|
-
expectedOn: new Date().toISOString().split('T')[0],
|
|
1399
|
-
}));
|
|
1400
|
-
|
|
1401
|
-
log.info(`[MultiChannelSync] Aggregated ${stats.aggregatedSkus} unique SKU/location combinations`);
|
|
1402
|
-
|
|
1403
|
-
// ========================================
|
|
1404
|
-
// DELTA DETECTION
|
|
1405
|
-
// ========================================
|
|
1406
|
-
let recordsToSend = finalInventory;
|
|
1407
|
-
|
|
1408
|
-
if (config.enableDelta) {
|
|
1409
|
-
await jobTracker.updateJob(jobId, { status: 'delta_detection' });
|
|
1410
|
-
log.info('[MultiChannelSync] Checking for changes (delta detection)...');
|
|
1411
|
-
|
|
1412
|
-
const prevState = ((await stateService.getState(config.deltaStateKey)) as any) || {};
|
|
1413
|
-
const changedRecords = [];
|
|
1414
|
-
|
|
1415
|
-
for (const record of finalInventory) {
|
|
1416
|
-
const prevQty = prevState[record.skuRef]?.[record.locationRef];
|
|
1417
|
-
if (prevQty === undefined || prevQty !== record.qty) {
|
|
1418
|
-
changedRecords.push(record);
|
|
1419
|
-
}
|
|
1420
|
-
}
|
|
1421
|
-
|
|
1422
|
-
recordsToSend = changedRecords;
|
|
1423
|
-
stats.changedRecords = changedRecords.length;
|
|
1424
|
-
log.info(
|
|
1425
|
-
`[MultiChannelSync] Delta detection: ${changedRecords.length} changed records (${finalInventory.length} total)`
|
|
1426
|
-
);
|
|
1427
|
-
} else {
|
|
1428
|
-
stats.changedRecords = finalInventory.length;
|
|
1429
|
-
}
|
|
1430
|
-
|
|
1431
|
-
if (recordsToSend.length === 0) {
|
|
1432
|
-
log.info('[MultiChannelSync] No changes detected, skipping batch send');
|
|
1433
|
-
stats.duration = Date.now() - startTime;
|
|
1434
|
-
await jobTracker.markCompleted(jobId, { message: 'No changes', stats });
|
|
1435
|
-
return { success: true, message: 'No changes', stats };
|
|
1436
|
-
}
|
|
1437
|
-
|
|
1438
|
-
// ========================================
|
|
1439
|
-
// BATCH API SUBMISSION
|
|
1440
|
-
// ========================================
|
|
1441
|
-
await jobTracker.updateJob(jobId, { status: 'creating_batch_job' });
|
|
1442
|
-
|
|
1443
|
-
log.info('[MultiChannelSync] Creating Batch API job...');
|
|
1444
|
-
const job = await client.createJob({
|
|
1445
|
-
name: config.jobName,
|
|
1446
|
-
retailerId: config.retailerId!,
|
|
1447
|
-
meta: {
|
|
1448
|
-
preprocessing: config.useBpp,
|
|
1449
|
-
},
|
|
1450
|
-
});
|
|
1451
|
-
|
|
1452
|
-
log.info(`[MultiChannelSync] Job created: ${job.id}`);
|
|
1453
|
-
|
|
1454
|
-
await jobTracker.updateJob(jobId, { status: 'sending_batches' });
|
|
1455
|
-
|
|
1456
|
-
// ? Enhanced: Extract context for progress logging
|
|
1457
|
-
const uniqueLocations = [...new Set(recordsToSend.map((r: any) => r.locationRef))];
|
|
1458
|
-
const sampleSKUs = recordsToSend.slice(0, 5).map((r: any) => r.skuRef);
|
|
1459
|
-
const estimatedBatches = Math.ceil(recordsToSend.length / config.batchSize);
|
|
1460
|
-
|
|
1461
|
-
// ? Enhanced: Start logging with context
|
|
1462
|
-
log.info(`[BatchProcessor] Sending batches for multi-channel sync`, {
|
|
1463
|
-
totalRecords: recordsToSend.length,
|
|
1464
|
-
estimatedBatches,
|
|
1465
|
-
batchSize: config.batchSize,
|
|
1466
|
-
locations: uniqueLocations.join(', '),
|
|
1467
|
-
sampleSKUs: sampleSKUs.join(', '),
|
|
1468
|
-
jobId: job.id
|
|
1469
|
-
});
|
|
1470
|
-
|
|
1471
|
-
const batchResults = await batchProcessor.sendInventoryBatches(
|
|
1472
|
-
job.id,
|
|
1473
|
-
recordsToSend,
|
|
1474
|
-
config.batchSize
|
|
1475
|
-
);
|
|
1476
|
-
|
|
1477
|
-
// ✅ Logging handled in workflow with Versori native log
|
|
1478
|
-
log.info(`[BatchProcessor] Sent ${batchResults.batchCount} batches`, {
|
|
1479
|
-
jobId: job.id,
|
|
1480
|
-
totalRecords: batchResults.totalSent,
|
|
1481
|
-
successfulBatches: batchResults.batches.filter(b => b.status === 'SENT').length,
|
|
1482
|
-
failedBatches: batchResults.batches.filter(b => b.status === 'FAILED').length,
|
|
1483
|
-
});
|
|
1484
|
-
|
|
1485
|
-
// ? Enhanced: Completion logging with summary
|
|
1486
|
-
log.info(`[BatchProcessor] Batch submission completed for multi-channel sync`, {
|
|
1487
|
-
totalBatches: batchResults.batchCount,
|
|
1488
|
-
totalRecords: batchResults.totalSent,
|
|
1489
|
-
successfulBatches: batchResults.batches.filter(b => b.status === 'SENT').length,
|
|
1490
|
-
failedBatches: batchResults.errors.length,
|
|
1491
|
-
jobId: job.id
|
|
1492
|
-
});
|
|
1493
|
-
|
|
1494
|
-
if (batchResults.errors.length > 0) {
|
|
1495
|
-
log.warn(`[BatchProcessor] ${batchResults.errors.length} batches failed`, {
|
|
1496
|
-
jobId: job.id,
|
|
1497
|
-
errors: batchResults.errors.slice(0, 5), // Log first 5 errors
|
|
1498
|
-
});
|
|
1499
|
-
}
|
|
1500
|
-
|
|
1501
|
-
stats.batchesSent = batchResults.batchCount;
|
|
1502
|
-
stats.successCount = batchResults.batches.filter(b => b.status === 'SENT').length;
|
|
1503
|
-
stats.errorCount = batchResults.errors.length;
|
|
1504
|
-
|
|
1505
|
-
// ========================================
|
|
1506
|
-
// DELTA STATE UPDATE
|
|
1507
|
-
// ========================================
|
|
1508
|
-
if (config.enableDelta) {
|
|
1509
|
-
await jobTracker.updateJob(jobId, { status: 'updating_delta_state' });
|
|
1510
|
-
log.info('[MultiChannelSync] Updating delta state...');
|
|
1511
|
-
|
|
1512
|
-
const newState: any = {};
|
|
1513
|
-
for (const record of finalInventory) {
|
|
1514
|
-
if (!newState[record.skuRef]) {
|
|
1515
|
-
newState[record.skuRef] = {};
|
|
1516
|
-
}
|
|
1517
|
-
newState[record.skuRef][record.locationRef] = record.qty;
|
|
1518
|
-
}
|
|
1519
|
-
|
|
1520
|
-
await stateService.setState(config.deltaStateKey, newState, {
|
|
1521
|
-
ttlSeconds: 7 * 24 * 60 * 60, // 7 days
|
|
1522
|
-
});
|
|
1523
|
-
|
|
1524
|
-
log.info('[MultiChannelSync] Delta state updated');
|
|
1525
|
-
}
|
|
1526
|
-
|
|
1527
|
-
stats.duration = Date.now() - startTime;
|
|
1528
|
-
|
|
1529
|
-
log.info('[MultiChannelSync] Sync completed', { stats });
|
|
1530
|
-
|
|
1531
|
-
await jobTracker.markCompleted(jobId, {
|
|
1532
|
-
stats,
|
|
1533
|
-
jobId: job.id,
|
|
1534
|
-
});
|
|
1535
|
-
|
|
1536
|
-
return {
|
|
1537
|
-
success: stats.errorCount === 0,
|
|
1538
|
-
jobId: job.id,
|
|
1539
|
-
stats,
|
|
1540
|
-
};
|
|
1541
|
-
} catch (error: any) {
|
|
1542
|
-
// ✅ Enhanced error logging: Extract all error details for visibility
|
|
1543
|
-
const errorDetails = {
|
|
1544
|
-
message: error?.message || 'Unknown error',
|
|
1545
|
-
stack: error?.stack,
|
|
1546
|
-
fileName: error?.fileName,
|
|
1547
|
-
lineNumber: error?.lineNumber,
|
|
1548
|
-
originalError: error?.context?.originalError?.message,
|
|
1549
|
-
errorType: error?.name || 'Error',
|
|
1550
|
-
};
|
|
1551
|
-
log.error('[MultiChannelSync] Fatal error:', errorDetails);
|
|
1552
|
-
return { success: false, error: error.message, duration: Date.now() - startTime };
|
|
1553
|
-
}
|
|
1554
|
-
}
|
|
1555
|
-
```
|
|
1556
|
-
|
|
1557
|
-
---
|
|
1558
|
-
|
|
1559
|
-
## Versori Activation Variables
|
|
1560
|
-
|
|
1561
|
-
```bash
|
|
1562
|
-
# Required Variables
|
|
1563
|
-
retailerId=your-retailer-id
|
|
1564
|
-
|
|
1565
|
-
# Sync Configuration
|
|
1566
|
-
jobName=Multi-Channel Inventory Sync
|
|
1567
|
-
batchSize=500
|
|
1568
|
-
maxRecords=50000
|
|
1569
|
-
defaultBuffer=5
|
|
1570
|
-
oversellProtection=true
|
|
1571
|
-
useBpp=skip
|
|
1572
|
-
|
|
1573
|
-
# Delta Detection
|
|
1574
|
-
enableDelta=true
|
|
1575
|
-
deltaStateKey=sync:delta:state
|
|
1576
|
-
|
|
1577
|
-
# Channel A (REST API)
|
|
1578
|
-
channelAEnabled=true
|
|
1579
|
-
channelAUrl=https://api.channel-a.example.com/inventory
|
|
1580
|
-
channelAKey=your-api-key
|
|
1581
|
-
channelABuffer=5
|
|
1582
|
-
channelARateLimitRpm=120
|
|
1583
|
-
|
|
1584
|
-
# Channel B (S3 CSV)
|
|
1585
|
-
channelBEnabled=true
|
|
1586
|
-
channelBBucket=channel-b-inventory
|
|
1587
|
-
channelBKey=inventory/current.csv
|
|
1588
|
-
|
|
1589
|
-
# AWS Credentials (for Channel B S3)
|
|
1590
|
-
awsRegion=us-east-1
|
|
1591
|
-
awsAccessKeyId=AKIAXXXXXXXXXXXX
|
|
1592
|
-
awsSecretAccessKey=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
|
1593
|
-
|
|
1594
|
-
# Fluent GraphQL (current state comparison)
|
|
1595
|
-
fluentEnabled=true
|
|
1596
|
-
fluentPageSize=200
|
|
1597
|
-
```
|
|
1598
|
-
|
|
1599
|
-
---
|
|
1600
|
-
|
|
1601
|
-
## Sample Channel Data
|
|
1602
|
-
|
|
1603
|
-
### Channel A (REST API Response)
|
|
1604
|
-
|
|
1605
|
-
```json
|
|
1606
|
-
{
|
|
1607
|
-
"inventory": [
|
|
1608
|
-
{
|
|
1609
|
-
"product_id": "SKU-12345",
|
|
1610
|
-
"warehouse_code": "LOC001",
|
|
1611
|
-
"quantity_available": 100,
|
|
1612
|
-
"quantity_reserved": 20,
|
|
1613
|
-
"updated_at": "2025-01-25T10:00:00Z"
|
|
1614
|
-
}
|
|
1615
|
-
]
|
|
1616
|
-
}
|
|
1617
|
-
```
|
|
1618
|
-
|
|
1619
|
-
### Channel B (S3 CSV)
|
|
1620
|
-
|
|
1621
|
-
```csv
|
|
1622
|
-
sku,location,qty,reserved
|
|
1623
|
-
SKU-12345,LOC001,95,15
|
|
1624
|
-
SKU-67890,LOC002,75,5
|
|
1625
|
-
SKU-11111,LOC001,200,0
|
|
1626
|
-
```
|
|
1627
|
-
|
|
1628
|
-
### ATP Calculation Example
|
|
1629
|
-
|
|
1630
|
-
```typescript
|
|
1631
|
-
// Channel A: SKU-12345 at LOC001
|
|
1632
|
-
onHand = 100
|
|
1633
|
-
reserved = 20
|
|
1634
|
-
buffer = 5
|
|
1635
|
-
ATP = (100 - 20) - 5 = 75
|
|
1636
|
-
|
|
1637
|
-
// Channel B: SKU-12345 at LOC001
|
|
1638
|
-
onHand = 95
|
|
1639
|
-
reserved = 15
|
|
1640
|
-
buffer = 5
|
|
1641
|
-
ATP = (95 - 15) - 5 = 75
|
|
1642
|
-
|
|
1643
|
-
// Aggregated: SKU-12345 at LOC001
|
|
1644
|
-
totalOnHand = 100 + 95 = 195
|
|
1645
|
-
totalReserved = 20 + 15 = 35
|
|
1646
|
-
maxBuffer = max(5, 5) = 5
|
|
1647
|
-
finalATP = (195 - 35) - 5 = 155
|
|
1648
|
-
```
|
|
1649
|
-
|
|
1650
|
-
---
|
|
1651
|
-
|
|
1652
|
-
## Deployment
|
|
1653
|
-
|
|
1654
|
-
```bash
|
|
1655
|
-
# Install dependencies
|
|
1656
|
-
npm install
|
|
1657
|
-
|
|
1658
|
-
# Validate configuration
|
|
1659
|
-
npm run lint
|
|
1660
|
-
|
|
1661
|
-
# Deploy to Versori
|
|
1662
|
-
versori deploy
|
|
1663
|
-
|
|
1664
|
-
# View logs
|
|
1665
|
-
versori logs multi-channel-inventory-sync
|
|
1666
|
-
|
|
1667
|
-
# Trigger manual sync
|
|
1668
|
-
versori run adhocMultiChannelSync
|
|
1669
|
-
```
|
|
1670
|
-
|
|
1671
|
-
---
|
|
1672
|
-
|
|
1673
|
-
## Testing
|
|
1674
|
-
|
|
1675
|
-
### Test Scheduled Sync
|
|
1676
|
-
|
|
1677
|
-
Upload test CSV files to S3/SFTP for each channel and wait for the scheduled run.
|
|
1678
|
-
|
|
1679
|
-
**Check logs:**
|
|
1680
|
-
|
|
1681
|
-
```
|
|
1682
|
-
[STEP 1/8] Initializing job tracking
|
|
1683
|
-
[STEP 2/8] Initializing Fluent Commerce client and data sources
|
|
1684
|
-
[STEP 3/8] Discovering files across channels
|
|
1685
|
-
[CHANNEL 1/3] Processing channel: CHANNEL_A
|
|
1686
|
-
[FILE 1/1] Processing file: channel-a-inventory_20250124.csv
|
|
1687
|
-
[STEP 4/8] Downloading and parsing: channel-a-inventory_20250124.csv
|
|
1688
|
-
[STEP 5/8] Transforming 5000 inventory records from channel-a-inventory_20250124.csv
|
|
1689
|
-
[STEP 6/8] Creating batch job and sending 5 batches to Fluent Commerce
|
|
1690
|
-
[STEP 7/8] Archiving file: channel-a-inventory_20250124.csv
|
|
1691
|
-
[STEP 8/8] Completing job and calculating totals
|
|
1692
|
-
```
|
|
1693
|
-
|
|
1694
|
-
### Test Ad hoc Sync
|
|
1695
|
-
|
|
1696
|
-
```bash
|
|
1697
|
-
# Sync all channels
|
|
1698
|
-
curl -X POST https://api.versori.com/webhooks/multi-channel-sync-adhoc \
|
|
1699
|
-
-H "Content-Type: application/json" \
|
|
1700
|
-
-d '{}'
|
|
1701
|
-
|
|
1702
|
-
# Sync specific channel
|
|
1703
|
-
curl -X POST https://api.versori.com/webhooks/multi-channel-sync-adhoc \
|
|
1704
|
-
-H "Content-Type: application/json" \
|
|
1705
|
-
-d '{
|
|
1706
|
-
"channelId": "CHANNEL_A"
|
|
1707
|
-
}'
|
|
1708
|
-
|
|
1709
|
-
# Sync with specific pattern
|
|
1710
|
-
curl -X POST https://api.versori.com/webhooks/multi-channel-sync-adhoc \
|
|
1711
|
-
-H "Content-Type: application/json" \
|
|
1712
|
-
-d '{
|
|
1713
|
-
"filePattern": "urgent_*.csv",
|
|
1714
|
-
"channelId": "CHANNEL_B"
|
|
1715
|
-
}'
|
|
1716
|
-
```
|
|
1717
|
-
|
|
1718
|
-
### Test Job Status Query
|
|
1719
|
-
|
|
1720
|
-
```bash
|
|
1721
|
-
curl -X POST https://api.versori.com/webhooks/multi-channel-sync-job-status \
|
|
1722
|
-
-H "Content-Type: application/json" \
|
|
1723
|
-
-d '{
|
|
1724
|
-
"jobId": "ADHOC_MULTI_20251024_183045_abc123"
|
|
1725
|
-
}'
|
|
1726
|
-
```
|
|
1727
|
-
|
|
1728
|
-
### Verify Batch Jobs in Fluent
|
|
1729
|
-
|
|
1730
|
-
After processing, check the Batch job status for each channel in Fluent Commerce:
|
|
1731
|
-
|
|
1732
|
-
```bash
|
|
1733
|
-
# Query job status via GraphQL
|
|
1734
|
-
curl -X POST https://your-fluent-instance.com/graphql \
|
|
1735
|
-
-H "Authorization: Bearer YOUR_TOKEN" \
|
|
1736
|
-
-H "Content-Type: application/json" \
|
|
1737
|
-
-d '{
|
|
1738
|
-
"query": "query { job(id: \"job-123456\") { id status recordCount processedCount } }"
|
|
1739
|
-
}'
|
|
1740
|
-
```
|
|
1741
|
-
|
|
1742
|
-
---
|
|
1743
|
-
|
|
1744
|
-
## Monitoring
|
|
1745
|
-
|
|
1746
|
-
### Success Response
|
|
1747
|
-
|
|
1748
|
-
```json
|
|
1749
|
-
{
|
|
1750
|
-
"success": true,
|
|
1751
|
-
"channelsProcessed": 3,
|
|
1752
|
-
"channelsFailed": 0,
|
|
1753
|
-
"filesProcessed": 3,
|
|
1754
|
-
"filesSkipped": 0,
|
|
1755
|
-
"filesFailed": 0,
|
|
1756
|
-
"results": [
|
|
1757
|
-
{
|
|
1758
|
-
"channel": "CHANNEL_A",
|
|
1759
|
-
"file": "channel-a-inventory_2025-01-22.csv",
|
|
1760
|
-
"success": true,
|
|
1761
|
-
"recordCount": 5000,
|
|
1762
|
-
"batchCount": 5,
|
|
1763
|
-
"jobId": "job-123456",
|
|
1764
|
-
"duration": 12345
|
|
1765
|
-
},
|
|
1766
|
-
{
|
|
1767
|
-
"channel": "CHANNEL_B",
|
|
1768
|
-
"file": "channel-b-inventory_2025-01-22.csv",
|
|
1769
|
-
"success": true,
|
|
1770
|
-
"recordCount": 3000,
|
|
1771
|
-
"batchCount": 3,
|
|
1772
|
-
"jobId": "job-123457",
|
|
1773
|
-
"duration": 9876
|
|
1774
|
-
},
|
|
1775
|
-
{
|
|
1776
|
-
"channel": "CHANNEL_C",
|
|
1777
|
-
"file": "channel-c-inventory_2025-01-22.csv",
|
|
1778
|
-
"success": true,
|
|
1779
|
-
"recordCount": 2000,
|
|
1780
|
-
"batchCount": 2,
|
|
1781
|
-
"jobId": "job-123458",
|
|
1782
|
-
"duration": 8765
|
|
1783
|
-
}
|
|
1784
|
-
],
|
|
1785
|
-
"duration": 13456
|
|
1786
|
-
}
|
|
1787
|
-
```
|
|
1788
|
-
|
|
1789
|
-
### Partial Success Response
|
|
1790
|
-
|
|
1791
|
-
```json
|
|
1792
|
-
{
|
|
1793
|
-
"success": true,
|
|
1794
|
-
"channelsProcessed": 2,
|
|
1795
|
-
"channelsFailed": 1,
|
|
1796
|
-
"filesProcessed": 2,
|
|
1797
|
-
"filesSkipped": 0,
|
|
1798
|
-
"filesFailed": 1,
|
|
1799
|
-
"results": [
|
|
1800
|
-
{
|
|
1801
|
-
"channel": "CHANNEL_A",
|
|
1802
|
-
"file": "channel-a-inventory_2025-01-22.csv",
|
|
1803
|
-
"success": true,
|
|
1804
|
-
"recordCount": 5000,
|
|
1805
|
-
"batchCount": 5,
|
|
1806
|
-
"jobId": "job-123456",
|
|
1807
|
-
"duration": 12345
|
|
1808
|
-
},
|
|
1809
|
-
{
|
|
1810
|
-
"channel": "CHANNEL_B",
|
|
1811
|
-
"file": "channel-b-inventory_2025-01-22.csv",
|
|
1812
|
-
"success": false,
|
|
1813
|
-
"error": "CSV parse error: Invalid structure"
|
|
1814
|
-
}
|
|
1815
|
-
],
|
|
1816
|
-
"duration": 13456
|
|
1817
|
-
}
|
|
1818
|
-
```
|
|
1819
|
-
|
|
1820
|
-
### Error Response
|
|
1821
|
-
|
|
1822
|
-
```json
|
|
1823
|
-
{
|
|
1824
|
-
"success": false,
|
|
1825
|
-
"channelsProcessed": 0,
|
|
1826
|
-
"channelsFailed": 3,
|
|
1827
|
-
"filesProcessed": 0,
|
|
1828
|
-
"filesFailed": 3,
|
|
1829
|
-
"results": [
|
|
1830
|
-
{
|
|
1831
|
-
"channel": "CHANNEL_A",
|
|
1832
|
-
"file": "channel-a-inventory_2025-01-22.csv",
|
|
1833
|
-
"success": false,
|
|
1834
|
-
"error": "Data source connection failed"
|
|
1835
|
-
}
|
|
1836
|
-
],
|
|
1837
|
-
"duration": 876
|
|
1838
|
-
}
|
|
1839
|
-
```
|
|
1840
|
-
|
|
1841
|
-
### Monitoring Metrics
|
|
1842
|
-
|
|
1843
|
-
Monitor these metrics via Versori logs filtered by `jobId` or `stage`:
|
|
1844
|
-
|
|
1845
|
-
- **Channels Processed** - Total channels successfully processed
|
|
1846
|
-
- **Files Processed** - Total files successfully processed across all channels
|
|
1847
|
-
- **Batch Jobs Created** - Total Batch jobs created in Fluent Commerce (one per channel)
|
|
1848
|
-
- **Processing Duration** - Time taken for complete multi-channel sync
|
|
1849
|
-
- **Channel Failures** - Channels that failed (check individual channel errors)
|
|
1850
|
-
|
|
1851
|
-
Use the status webhook for dashboards and automated monitoring.
|
|
1852
|
-
|
|
1853
|
-
---
|
|
1854
|
-
|
|
1855
|
-
- 🎯 **TRUE modular architecture** - Separate service files with clear responsibilities
|
|
1856
|
-
- 🎯 **Graceful degradation** - Use `Promise.allSettled()` for partial channel failures
|
|
1857
|
-
- 🎯 **Delta detection** - Only sync changed records (reduces API load by 90%+)
|
|
1858
|
-
- 🎯 **External JSON mapping** - Use `with { type: 'json' }` import syntax
|
|
1859
|
-
- 🎯 **ATP calculation** - `ATP = (onHand - reserved) - buffer` with oversell protection
|
|
1860
|
-
- 🎯 **Rate limiting** - Enforce minimum interval between Channel A requests
|
|
1861
|
-
- 🎯 **Skip BPP** - Set `preprocessing: 'skip'` when using delta detection
|
|
1862
|
-
- 🎯 **Job tracking** - Use `JobTracker` for lifecycle management
|
|
1863
|
-
- 🎯 **Native logging** - Use `log` from context on Versori platform
|
|
1864
|
-
- 🎯 **EntityType: INVENTORY** - Correct entity type for inventory records
|
|
1865
|
-
- 🎯 **Error handling** - Log channel failures but don't block entire sync
|
|
1866
|
-
|
|
1867
|
-
---
|
|
1868
|
-
|
|
1869
|
-
## Related Documentation
|
|
1870
|
-
|
|
1871
|
-
- **Single-source batch ingestion**: [SFTP XML Inventory Batch Template](./template-ingestion-sftp-xml-inventory-batch.md) - Simpler pattern for single data sources (GOLD STANDARD)
|
|
1872
|
-
- [Batch API Guide](../../../../../02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-06-batch-api.md) - Complete Batch API patterns and BPP documentation
|
|
1873
|
-
- [State Management](../../../../../02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-07-state-management.md) - VersoriKVAdapter and StateService usage
|
|
1874
|
-
- [Job Tracker](../../../../../02-CORE-GUIDES/advanced-services/advanced-services-job-tracker.md) - Job lifecycle tracking
|
|
1875
|
-
- [Universal Mapping](../../../../../02-CORE-GUIDES/advanced-services/advanced-services-readme.md) - Field transformation guide
|
|
1876
|
-
- [Error Handling Patterns](../../../../../03-PATTERN-GUIDES/error-handling/error-handling-readme.md) - Retry logic and exponential backoff
|
|
1877
|
-
- [File Operations](../../../../../03-PATTERN-GUIDES/file-operations/file-operations-readme.md) - KV state management patterns
|
|
1878
|
-
- [GraphQL Extraction](../../../../../02-CORE-GUIDES/advanced-services/advanced-services-readme.md) - Auto-pagination for Fluent inventory queries
|
|
1879
|
-
|
|
1880
|
-
---
|
|
1881
|
-
|
|
1882
|
-
[→ Back to Versori Scheduled Workflows](../../../../../02-CORE-GUIDES/advanced-services/advanced-services-readme.md) | [Versori Platform Guide →](../../../../../02-CORE-GUIDES/advanced-services/advanced-services-readme.md)
|
|
1
|
+
---
|
|
2
|
+
template_id: tpl-ingest-multi-channel-inventory-sync
|
|
3
|
+
canonical_filename: template-ingestion-multi-channel-inventory-sync.md
|
|
4
|
+
sdk_version: latest
|
|
5
|
+
runtime: versori
|
|
6
|
+
direction: ingestion
|
|
7
|
+
source: multi-channel-rest-s3
|
|
8
|
+
destination: fluent-batch-api
|
|
9
|
+
entity: inventory
|
|
10
|
+
format: json-csv
|
|
11
|
+
logging: versori
|
|
12
|
+
status: stable
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
# Template: Ingestion - Multi-Channel Inventory Sync (Scheduled)
|
|
16
|
+
|
|
17
|
+
## STEP 1: Understand This Template
|
|
18
|
+
|
|
19
|
+
**What This Template Does:**
|
|
20
|
+
|
|
21
|
+
- Scheduled Versori workflow for multi-channel inventory aggregation and sync
|
|
22
|
+
- Fetches inventory from multiple sources in parallel (REST API, S3 CSV, Fluent GraphQL)
|
|
23
|
+
- Calculates channel-specific ATP (Available To Promise) with configurable buffers and caps
|
|
24
|
+
- Performs delta detection using VersoriKVAdapter to sync only changed inventory records
|
|
25
|
+
- Handles partial channel failures gracefully with Promise.allSettled
|
|
26
|
+
- Rate-limits external REST API calls with exponential backoff retry logic
|
|
27
|
+
- Sends consolidated inventory updates to Fluent Commerce Batch API with chunking
|
|
28
|
+
- Tracks job lifecycle with JobTracker for operational monitoring
|
|
29
|
+
- Supports scheduled (cron) and ad-hoc (webhook) triggers
|
|
30
|
+
|
|
31
|
+
**Key SDK Components:**
|
|
32
|
+
|
|
33
|
+
- `createClient()` - Universal client factory (auto-detects Versori context)
|
|
34
|
+
- `S3DataSource` - S3 file operations with retry logic
|
|
35
|
+
- `CSVParserService` - CSV parsing with validation
|
|
36
|
+
- `UniversalMapper` - Field transformation with SDK resolvers
|
|
37
|
+
- `StateService` + `VersoriKVAdapter` - Delta detection state management
|
|
38
|
+
- `JobTracker` - Job lifecycle tracking - `FluentClient.createJob()` - Create Batch API job
|
|
39
|
+
- `FluentClient.sendBatch()` - Send inventory chunks (fire-and-forget)
|
|
40
|
+
- `FluentClient.graphql()` - Query current Fluent inventory state with auto-pagination
|
|
41
|
+
- Native Versori `log` - Use `log` from context
|
|
42
|
+
**Entity Type:**
|
|
43
|
+
|
|
44
|
+
- **InventoryQuantity** - Fluent entity for inventory positions and quantities
|
|
45
|
+
- **EntityType: 'INVENTORY'** - Used in Batch API `sendBatch()` call
|
|
46
|
+
- **Batch API Method** - Uses `createJob()` and `sendBatch()` (not Event API)
|
|
47
|
+
|
|
48
|
+
**Critical Patterns:**
|
|
49
|
+
|
|
50
|
+
- **Multi-source aggregation**: Use `Promise.allSettled()` for parallel channel fetching
|
|
51
|
+
- **Graceful degradation**: Continue processing even if one channel fails
|
|
52
|
+
- **Delta detection**: StateService tracks previous state to detect changes
|
|
53
|
+
- **Progress Logging**: Enhanced logging with context (sample SKUs, locations)
|
|
54
|
+
- **JobTracker Progress Updates**: Periodic progress updates during batch processing
|
|
55
|
+
- **ATP calculation**: `ATP = (onHand - reserved) - buffer` with oversell protection
|
|
56
|
+
- **Delta detection**: Use `StateService` + `VersoriKVAdapter` to track previous ATP values
|
|
57
|
+
- **Rate limiting**: Enforce minimum interval between channel API requests
|
|
58
|
+
- **Safe configuration**: External JSON mapping file (not inline TypeScript)
|
|
59
|
+
- **BPP Configuration**: Use `'skip'` (delta detection already filters changes)
|
|
60
|
+
- **Fire-and-forget batches**: Batch submission is asynchronous (no polling needed)
|
|
61
|
+
|
|
62
|
+
**When to Use This Template:**
|
|
63
|
+
|
|
64
|
+
- ✅ Multiple inventory sources (3+ channels) requiring aggregation
|
|
65
|
+
- ✅ Channel-specific ATP calculations with buffers and caps
|
|
66
|
+
- ✅ Delta detection needed (only sync changed records)
|
|
67
|
+
- ✅ Partial channel failures shouldn't block entire sync
|
|
68
|
+
- ✅ External APIs require rate limiting and retry logic
|
|
69
|
+
- ✅ Scheduled batch processing (every 15 minutes, hourly, etc.)
|
|
70
|
+
|
|
71
|
+
**When NOT to Use:**
|
|
72
|
+
|
|
73
|
+
- ❌ Single inventory source (use simpler single-source templates)
|
|
74
|
+
- ❌ Real-time sync required (this is designed for scheduled batch processing)
|
|
75
|
+
- ❌ No ATP calculations needed (use direct field mapping)
|
|
76
|
+
- ❌ Don't need delta detection (full snapshots every time)
|
|
77
|
+
- ❌ Products, Locations, Customers (use Event API templates)
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## STEP 2: AI Prompt
|
|
82
|
+
|
|
83
|
+
**Copy this prompt to generate the complete implementation:**
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
Create a Versori scheduled workflow for multi-channel inventory aggregation to Fluent Commerce Batch API.
|
|
87
|
+
|
|
88
|
+
REQUIREMENTS:
|
|
89
|
+
1. Runtime: Versori Platform (scheduled workflow)
|
|
90
|
+
2. Sources: Multiple channels (REST API, S3 CSV, Fluent GraphQL)
|
|
91
|
+
3. Destination: Fluent Commerce Batch API (InventoryQuantity entity)
|
|
92
|
+
4. Entity: InventoryQuantity (EntityType: 'INVENTORY')
|
|
93
|
+
|
|
94
|
+
KEY FEATURES:
|
|
95
|
+
1. **Multi-channel fetching** (parallel):
|
|
96
|
+
- **Channel A**: REST API with rate limiting (120 RPM) and retry logic
|
|
97
|
+
- **Channel B**: S3 CSV file with CSVParserService
|
|
98
|
+
- **Fluent GraphQL**: Current inventory state via auto-pagination
|
|
99
|
+
2. **ATP (Available To Promise) calculation**:
|
|
100
|
+
- Formula: `ATP = (onHand - reserved) - buffer`
|
|
101
|
+
- Channel-specific buffers (configurable per channel)
|
|
102
|
+
- Aggregate ATP across channels with deduplication by SKU + location
|
|
103
|
+
- Support oversell protection (ensure ATP ≥ 0)
|
|
104
|
+
3. **Delta detection**:
|
|
105
|
+
- Use `StateService` + `VersoriKVAdapter` to track previous ATP values
|
|
106
|
+
- Only send changed records to Batch API
|
|
107
|
+
- Store state with 7-day TTL in Versori KV
|
|
108
|
+
4. **Graceful degradation**:
|
|
109
|
+
- Use `Promise.allSettled()` for channel fetching
|
|
110
|
+
- Continue processing even if one channel fails
|
|
111
|
+
- Log channel failures but don't block entire sync
|
|
112
|
+
5. **Batch API**:
|
|
113
|
+
- Entity: `InventoryQuantity`
|
|
114
|
+
- EntityType: `'INVENTORY'`
|
|
115
|
+
- Action: `'UPSERT'`
|
|
116
|
+
- BPP: `'skip'` (delta detection already filters)
|
|
117
|
+
- Batch size: 500 records per chunk
|
|
118
|
+
- Poll batches until complete with exponential backoff
|
|
119
|
+
6. **Job tracking**:
|
|
120
|
+
- Use `JobTracker` with Versori KV storage
|
|
121
|
+
- Track status transitions: `fetching_channels` → `aggregating` → `delta_detection` → `creating_batch_job` → `sending_batches` → `polling_batches` → `updating_delta_state`
|
|
122
|
+
7. **Modular architecture**:
|
|
123
|
+
- Separate service files (ATP calculator, channel connectors, batch dispatcher)
|
|
124
|
+
- External JSON mapping config
|
|
125
|
+
- Clean separation of concerns
|
|
126
|
+
|
|
127
|
+
CRITICAL REQUIREMENTS:
|
|
128
|
+
1. Modular architecture: Separate service files (ATP calculator, channel connectors, batch dispatcher)
|
|
129
|
+
2. External JSON mapping config: Use `with { type: 'json' }` import syntax
|
|
130
|
+
3. Native logging: Use log from context (LoggingService removed - use native log)
|
|
131
|
+
4. Graceful degradation: Promise.allSettled for parallel channel fetching
|
|
132
|
+
5. Delta detection: Track previous ATP values in Versori KV
|
|
133
|
+
6. BPP: Set to 'skip' (delta already filters changes)
|
|
134
|
+
7. Rate limiting: Enforce minimum interval between Channel A requests
|
|
135
|
+
8. Job tracking: Use JobTracker for lifecycle management
|
|
136
|
+
|
|
137
|
+
SDK METHODS TO USE:
|
|
138
|
+
- createClient(ctx) - Pass entire Versori context, auto-detects platform
|
|
139
|
+
- new S3DataSource(config, log) - S3 file operations
|
|
140
|
+
- new CSVParserService() - Parse CSV files
|
|
141
|
+
- await client.graphql({ query, variables, pagination }) - Fluent GraphQL extraction with auto-pagination
|
|
142
|
+
- new VersoriKVAdapter(openKv(':project:')) - Versori KV storage adapter
|
|
143
|
+
- new StateService(kvAdapter) - Delta state management (takes KV adapter, not logger)
|
|
144
|
+
- new JobTracker(kv, log) - Job lifecycle tracking
|
|
145
|
+
- await client.createJob({ name, retailerId, meta: { preprocessing: 'skip' } }) - Create Batch API job
|
|
146
|
+
- await client.sendBatch(jobId, { action, entityType, source, event, entities }) - Send batch chunk (fire-and-forget)
|
|
147
|
+
|
|
148
|
+
FORBIDDEN PATTERNS:
|
|
149
|
+
- ❌ LoggingService (removed - use native log on Versori)
|
|
150
|
+
- ❌ Don't use monolithic index.ts (extract services into separate files)
|
|
151
|
+
- ❌ Don't use inline mapping config (use external JSON file)
|
|
152
|
+
- ❌ Don't fail entire sync if one channel fails (use Promise.allSettled)
|
|
153
|
+
- ❌ Don't send all records (use delta detection to filter unchanged)
|
|
154
|
+
- ❌ Don't use BPP with deltas (set preprocessing: 'skip')
|
|
155
|
+
- ❌ Don't forget rate limiting for external APIs
|
|
156
|
+
- ❌ Don't forget to call dispose() on data sources in finally block
|
|
157
|
+
|
|
158
|
+
GENERATE:
|
|
159
|
+
1. package.json with dependencies
|
|
160
|
+
2. index.ts (workflow entry point with scheduled/adhoc/status triggers)
|
|
161
|
+
3. src/workflows/multi-channel-sync.workflow.ts (main orchestration logic)
|
|
162
|
+
4. src/services/atp-calculator.service.ts (ATP calculation and aggregation)
|
|
163
|
+
5. src/services/channel-a-connector.service.ts (REST API with rate limiting)
|
|
164
|
+
6. src/services/channel-b-connector.service.ts (S3 CSV fetching)
|
|
165
|
+
7. src/services/batch-processor.service.ts (Batch API submission)
|
|
166
|
+
8. src/services/batch-logger.service.ts (SFTP log file writing)
|
|
167
|
+
9. src/types/multi-channel.types.ts (TypeScript interfaces)
|
|
168
|
+
10. config/multi-channel.mapping.json (mapping configuration - external JSON file)
|
|
169
|
+
|
|
170
|
+
NOTE: Use external JSON files for mapping configuration (not TypeScript .config files)
|
|
171
|
+
|
|
172
|
+
Ensure all code is production-ready with proper error handling, graceful degradation, and rate limiting. Use modular architecture with separate service files for each concern.
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
## What You'll Build
|
|
178
|
+
|
|
179
|
+
### Versori Workflows Structure
|
|
180
|
+
|
|
181
|
+
**Key Concept**: Versori workflows are organized by **trigger type** at the first level, then by **specific workflow** with descriptive file names.
|
|
182
|
+
|
|
183
|
+
**Trigger Types:**
|
|
184
|
+
- **`schedule()`** → Time-based triggers (cron expressions) - NOT exposed as HTTP endpoints
|
|
185
|
+
- **`webhook()`** → HTTP-based triggers (event-driven) - Creates HTTP endpoints
|
|
186
|
+
- **`workflow()`** → Durable workflows (advanced, rarely used)
|
|
187
|
+
|
|
188
|
+
**Execution Steps (chained to triggers):**
|
|
189
|
+
- **`http()`** → External API calls (chained from schedule/webhook)
|
|
190
|
+
- **`fn()`** → Internal processing (chained from schedule/webhook)
|
|
191
|
+
|
|
192
|
+
### Recommended Project Structure
|
|
193
|
+
|
|
194
|
+
```
|
|
195
|
+
inventory-batch-sync/
|
|
196
|
+
├── index.ts # Entry point - exports all workflows
|
|
197
|
+
└── src/
|
|
198
|
+
├── workflows/
|
|
199
|
+
│ ├── scheduled/
|
|
200
|
+
│ │ └── daily-inventory-sync.ts # Scheduled: Daily inventory sync
|
|
201
|
+
│ │
|
|
202
|
+
│ └── webhook/
|
|
203
|
+
│ ├── adhoc-inventory-sync.ts # Webhook: Manual trigger
|
|
204
|
+
│ └── job-status-check.ts # Webhook: Status query
|
|
205
|
+
│
|
|
206
|
+
├── services/
|
|
207
|
+
│ └── inventory-sync.service.ts # Shared orchestration logic (reusable)
|
|
208
|
+
│
|
|
209
|
+
└── types/
|
|
210
|
+
└── inventory.types.ts # Shared type definitions
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
**Benefits:**
|
|
214
|
+
- ✅ Clear trigger separation (`scheduled/` vs `webhook/`)
|
|
215
|
+
- ✅ Descriptive file names (easy to browse and understand)
|
|
216
|
+
- ✅ Scalable (add new workflows without cluttering)
|
|
217
|
+
- ✅ Reusable code in `services/` (DRY principle)
|
|
218
|
+
- ✅ Easy to modify individual workflows without affecting others
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
222
|
+
## Workflow Files
|
|
223
|
+
|
|
224
|
+
### 1. Scheduled Workflows (`src/workflows/scheduled/`)
|
|
225
|
+
|
|
226
|
+
All time-based triggers that run automatically on cron schedules.
|
|
227
|
+
|
|
228
|
+
#### `src/workflows/scheduled/daily-inventory-sync.ts`
|
|
229
|
+
|
|
230
|
+
**Purpose**: Automatic Daily inventory sync
|
|
231
|
+
**Trigger**: Cron schedule (`0 2 * * *`)
|
|
232
|
+
**Exposed as Endpoint**: ❌ NO - Runs automatically
|
|
233
|
+
|
|
234
|
+
```typescript
|
|
235
|
+
import { schedule, http } from '@versori/run';
|
|
236
|
+
import { JobTracker } from '@fluentcommerce/fc-connect-sdk';
|
|
237
|
+
import { runIngestion } from '../../services/inventory-sync.service.ts';
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Scheduled Workflow: Daily Inventory Sync
|
|
241
|
+
*
|
|
242
|
+
* Runs automatically daily at 2 AM UTC
|
|
243
|
+
* NOT exposed as HTTP endpoint - Versori executes on schedule
|
|
244
|
+
*
|
|
245
|
+
* Uses shared service: inventory-sync.service.ts
|
|
246
|
+
*/
|
|
247
|
+
export const daily_inventory_sync = schedule(
|
|
248
|
+
'inventory-batch-scheduled',
|
|
249
|
+
'0 2 * * *' // Daily at 2 AM UTC
|
|
250
|
+
).then(
|
|
251
|
+
http('run-inventory-batch', { connection: 'fluent_commerce' }, async ctx => {
|
|
252
|
+
const { log, openKv } = ctx;
|
|
253
|
+
const jobId = `inventory-batch-${Date.now()}`;
|
|
254
|
+
const tracker = new JobTracker(openKv(':project:'), log);
|
|
255
|
+
|
|
256
|
+
await tracker.createJob(jobId, { triggeredBy: 'schedule', stage: 'initialization' });
|
|
257
|
+
await tracker.updateJob(jobId, { status: 'processing' });
|
|
258
|
+
|
|
259
|
+
try {
|
|
260
|
+
// Reuse shared orchestration logic
|
|
261
|
+
const result = await runIngestion(ctx, jobId, tracker);
|
|
262
|
+
await tracker.markCompleted(jobId, result);
|
|
263
|
+
return { success: true, jobId, ...result };
|
|
264
|
+
} catch (e: any) {
|
|
265
|
+
await tracker.markFailed(jobId, e);
|
|
266
|
+
return { success: false, jobId, error: e?.message };
|
|
267
|
+
}
|
|
268
|
+
})
|
|
269
|
+
);
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
---
|
|
273
|
+
|
|
274
|
+
### 2. Webhook Workflows (`src/workflows/webhook/`)
|
|
275
|
+
|
|
276
|
+
All HTTP-based triggers that create webhook endpoints.
|
|
277
|
+
|
|
278
|
+
#### `src/workflows/webhook/adhoc-inventory-sync.ts`
|
|
279
|
+
|
|
280
|
+
**Purpose**: Manual inventory sync trigger (on-demand)
|
|
281
|
+
**Trigger**: HTTP POST
|
|
282
|
+
**Endpoint**: `POST https://{workspace}.versori.run/inventory-batch-adhoc`
|
|
283
|
+
**Use Cases**: Testing, priority processing, ad-hoc runs
|
|
284
|
+
|
|
285
|
+
```typescript
|
|
286
|
+
import { webhook, http } from '@versori/run';
|
|
287
|
+
import { JobTracker } from '@fluentcommerce/fc-connect-sdk';
|
|
288
|
+
import { runIngestion } from '../../services/inventory-sync.service.ts';
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Webhook: Manual Inventory Sync Trigger
|
|
292
|
+
*
|
|
293
|
+
* Endpoint: POST https://{workspace}.versori.run/inventory-batch-adhoc
|
|
294
|
+
* Request body (optional): { filePattern: "urgent_*.xml", maxFiles: 5 }
|
|
295
|
+
*
|
|
296
|
+
* Pattern: webhook().then(http()) - needs Fluent API access
|
|
297
|
+
* Uses shared service: inventory-sync.service.ts
|
|
298
|
+
*/
|
|
299
|
+
export const adhoc_inventory_sync = webhook('inventory-batch-adhoc', {
|
|
300
|
+
response: { mode: 'sync' },
|
|
301
|
+
connection: 'inventory-batch-adhoc', // Versori validates API key
|
|
302
|
+
}).then(
|
|
303
|
+
http('run-inventory-batch-adhoc', { connection: 'fluent_commerce' }, async ctx => {
|
|
304
|
+
const { log, openKv, data } = ctx;
|
|
305
|
+
const jobId = `inventory-batch-adhoc-${Date.now()}`;
|
|
306
|
+
const tracker = new JobTracker(openKv(':project:'), log);
|
|
307
|
+
|
|
308
|
+
await tracker.createJob(jobId, {
|
|
309
|
+
triggeredBy: 'manual',
|
|
310
|
+
stage: 'initialization',
|
|
311
|
+
options: data // Optional: filePattern, maxFiles, etc.
|
|
312
|
+
});
|
|
313
|
+
await tracker.updateJob(jobId, { status: 'processing' });
|
|
314
|
+
|
|
315
|
+
try {
|
|
316
|
+
// Same orchestration logic as scheduled workflow
|
|
317
|
+
const result = await runIngestion(ctx, jobId, tracker);
|
|
318
|
+
await tracker.markCompleted(jobId, result);
|
|
319
|
+
return { success: true, jobId, ...result };
|
|
320
|
+
} catch (e: any) {
|
|
321
|
+
await tracker.markFailed(jobId, e);
|
|
322
|
+
return { success: false, jobId, error: e?.message };
|
|
323
|
+
}
|
|
324
|
+
})
|
|
325
|
+
);
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
#### `src/workflows/webhook/job-status-check.ts`
|
|
329
|
+
|
|
330
|
+
**Purpose**: Query job status
|
|
331
|
+
**Trigger**: HTTP POST
|
|
332
|
+
**Endpoint**: `POST https://{workspace}.versori.run/inventory-batch-job-status`
|
|
333
|
+
**Request body**: `{ jobId: "inventory-batch-1234567890" }`
|
|
334
|
+
|
|
335
|
+
```typescript
|
|
336
|
+
import { webhook, fn } from '@versori/run';
|
|
337
|
+
import { JobTracker } from '@fluentcommerce/fc-connect-sdk';
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Webhook: Job Status Check
|
|
341
|
+
*
|
|
342
|
+
* Endpoint: POST https://{workspace}.versori.run/inventory-batch-job-status
|
|
343
|
+
* Request body: { jobId: "inventory-batch-1234567890" }
|
|
344
|
+
*
|
|
345
|
+
* Pattern: webhook().then(fn()) - no external API needed, only KV storage
|
|
346
|
+
* Lightweight: Only queries KV store, no Fluent API calls
|
|
347
|
+
*/
|
|
348
|
+
export const jobStatusCheck = webhook('inventory-batch-job-status', {
|
|
349
|
+
response: { mode: 'sync' },
|
|
350
|
+
connection: 'inventory-batch-job-status',
|
|
351
|
+
}).then(
|
|
352
|
+
fn('status', async ctx => {
|
|
353
|
+
const { data, log, openKv } = ctx;
|
|
354
|
+
const jobId = data?.jobId as string;
|
|
355
|
+
|
|
356
|
+
if (!jobId) {
|
|
357
|
+
return { success: false, error: 'jobId required' };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const tracker = new JobTracker(openKv(':project:'), log);
|
|
361
|
+
const status = await tracker.getJob(jobId);
|
|
362
|
+
|
|
363
|
+
return status
|
|
364
|
+
? { success: true, jobId, ...status }
|
|
365
|
+
: { success: false, error: 'Job not found', jobId };
|
|
366
|
+
})
|
|
367
|
+
);
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
---
|
|
371
|
+
|
|
372
|
+
### 3. Entry Point (`index.ts`)
|
|
373
|
+
|
|
374
|
+
**Purpose**: Register all workflows with Versori platform
|
|
375
|
+
|
|
376
|
+
```typescript
|
|
377
|
+
/**
|
|
378
|
+
* Entry Point - Registers all workflows with Versori platform
|
|
379
|
+
*
|
|
380
|
+
* Versori automatically discovers and registers exported workflows
|
|
381
|
+
*
|
|
382
|
+
* File Structure:
|
|
383
|
+
* - src/workflows/scheduled/ → Time-based triggers (cron)
|
|
384
|
+
* - src/workflows/webhook/ → HTTP-based triggers (webhooks)
|
|
385
|
+
*/
|
|
386
|
+
|
|
387
|
+
// Import scheduled workflows
|
|
388
|
+
import { daily_inventory_sync } from './src/workflows/scheduled/daily-inventory-sync';
|
|
389
|
+
|
|
390
|
+
// Import webhook workflows
|
|
391
|
+
import { adhoc_inventory_sync } from './src/workflows/webhook/adhoc-inventory-sync';
|
|
392
|
+
import { jobStatusCheck } from './src/workflows/webhook/job-status-check';
|
|
393
|
+
|
|
394
|
+
// Register all workflows
|
|
395
|
+
export {
|
|
396
|
+
// Scheduled (time-based triggers)
|
|
397
|
+
daily_inventory_sync,
|
|
398
|
+
|
|
399
|
+
// Webhooks (HTTP-based triggers)
|
|
400
|
+
adhoc_inventory_sync,
|
|
401
|
+
jobStatusCheck,
|
|
402
|
+
};
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
**What Gets Exposed:**
|
|
406
|
+
|
|
407
|
+
- ✅ `adhoc_inventory_sync` → `https://{workspace}.versori.run/inventory-batch-adhoc`
|
|
408
|
+
- ✅ `jobStatusCheck` → `https://{workspace}.versori.run/inventory-batch-job-status`
|
|
409
|
+
- ❌ `daily_inventory_sync` → NOT exposed (runs automatically on cron)
|
|
410
|
+
|
|
411
|
+
---
|
|
412
|
+
|
|
413
|
+
### Adding New Workflows
|
|
414
|
+
|
|
415
|
+
**To add a scheduled workflow:**
|
|
416
|
+
1. Create file in `src/workflows/scheduled/` with descriptive name (e.g., `hourly-delta-sync.ts`)
|
|
417
|
+
2. Export the workflow from the file
|
|
418
|
+
3. Import and re-export in `index.ts`
|
|
419
|
+
|
|
420
|
+
**To add a webhook workflow:**
|
|
421
|
+
1. Create file in `src/workflows/webhook/` with descriptive name (e.g., `error-notification.ts`)
|
|
422
|
+
2. Export the workflow from the file
|
|
423
|
+
3. Import and re-export in `index.ts`
|
|
424
|
+
|
|
425
|
+
**Example - Adding hourly delta sync:**
|
|
426
|
+
|
|
427
|
+
```typescript
|
|
428
|
+
// src/workflows/scheduled/hourly-delta-sync.ts
|
|
429
|
+
export const hourlyDeltaSync = schedule(
|
|
430
|
+
'inventory-delta-hourly',
|
|
431
|
+
'0 * * * *' // Every hour
|
|
432
|
+
).then(
|
|
433
|
+
http('run-delta-sync', { connection: 'fluent_commerce' }, async ctx => {
|
|
434
|
+
// Delta sync logic (skip BPP)
|
|
435
|
+
const result = await runIngestion(ctx, jobId, tracker, { bppEnabled: false });
|
|
436
|
+
return result;
|
|
437
|
+
})
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
// index.ts (add to imports and exports)
|
|
441
|
+
import { hourlyDeltaSync } from './src/workflows/scheduled/hourly-delta-sync';
|
|
442
|
+
export { daily_inventory_sync, hourlyDeltaSync, ... };
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
---
|
|
446
|
+
## Complete Modular Implementation
|
|
447
|
+
|
|
448
|
+
### File: `package.json`
|
|
449
|
+
|
|
450
|
+
```json
|
|
451
|
+
{
|
|
452
|
+
"name": "multi-channel-inventory-sync",
|
|
453
|
+
"version": "1.0.0",
|
|
454
|
+
"description": "Multi-Channel Inventory Aggregation to Fluent Commerce Batch API",
|
|
455
|
+
"type": "module",
|
|
456
|
+
"versori": {
|
|
457
|
+
"workflows": "./index.ts"
|
|
458
|
+
},
|
|
459
|
+
"scripts": {
|
|
460
|
+
"lint": "eslint . --ext .ts",
|
|
461
|
+
"typecheck": "tsc --noEmit"
|
|
462
|
+
},
|
|
463
|
+
"dependencies": {
|
|
464
|
+
"@fluentcommerce/fc-connect-sdk": "^0.1.39",
|
|
465
|
+
"@versori/run": "latest"
|
|
466
|
+
},
|
|
467
|
+
"devDependencies": {
|
|
468
|
+
"@types/node": "^20.0.0",
|
|
469
|
+
"typescript": "^5.0.0"
|
|
470
|
+
},
|
|
471
|
+
"engines": {
|
|
472
|
+
"node": ">=18.0.0"
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
---
|
|
478
|
+
|
|
479
|
+
### File: `index.ts`
|
|
480
|
+
|
|
481
|
+
```typescript
|
|
482
|
+
import { schedule, webhook, http, fn } from '@versori/run';
|
|
483
|
+
import { processMultiChannelSync } from './src/workflows/multi-channel-sync.workflow';
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Scheduled workflow: Multi-channel inventory sync every 15 minutes
|
|
487
|
+
*
|
|
488
|
+
* Processing: Parallel channel fetching with graceful degradation
|
|
489
|
+
* BPP: Disabled (preprocessing: 'skip') - delta detection already filters
|
|
490
|
+
* State Management: VersoriKVAdapter + JobTracker prevent duplicates
|
|
491
|
+
*/
|
|
492
|
+
export const scheduledMultiChannelSync = schedule(
|
|
493
|
+
'multi-channel-sync',
|
|
494
|
+
'*/15 * * * *' // Every 15 minutes
|
|
495
|
+
).then(
|
|
496
|
+
http('run-sync', { connection: 'fluent_commerce' }, async ctx => {
|
|
497
|
+
// ctx contains: fetch, connections, log, activation, openKv
|
|
498
|
+
// Pass entire context to workflow
|
|
499
|
+
return await processMultiChannelSync(ctx);
|
|
500
|
+
})
|
|
501
|
+
);
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Manual trigger endpoint for testing and ad-hoc runs
|
|
505
|
+
*/
|
|
506
|
+
export const adhocMultiChannelSync = webhook('multi-channel-sync-adhoc', {
|
|
507
|
+
response: { mode: 'sync' },
|
|
508
|
+
connection: 'multi-channel-sync-adhoc',
|
|
509
|
+
}).then(
|
|
510
|
+
http('run-sync-adhoc', { connection: 'fluent_commerce' }, async ctx => {
|
|
511
|
+
return await processMultiChannelSync(ctx);
|
|
512
|
+
})
|
|
513
|
+
);
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Job status check endpoint
|
|
517
|
+
*/
|
|
518
|
+
export const multiChannelSyncJobStatus = webhook('multi-channel-sync-job-status', {
|
|
519
|
+
response: { mode: 'sync' },
|
|
520
|
+
connection: 'multi-channel-sync-job-status',
|
|
521
|
+
}).then(
|
|
522
|
+
fn('status', async ctx => {
|
|
523
|
+
const { data, log, openKv } = ctx;
|
|
524
|
+
const jobId = data?.jobId as string;
|
|
525
|
+
if (!jobId) return { success: false, error: 'jobId required' };
|
|
526
|
+
const { JobTracker } = await import('@fluentcommerce/fc-connect-sdk');
|
|
527
|
+
const tracker = new JobTracker(openKv(':project:'), log);
|
|
528
|
+
const status = await tracker.getJob(jobId);
|
|
529
|
+
return status
|
|
530
|
+
? { success: true, jobId, ...status }
|
|
531
|
+
: { success: false, error: 'Job not found', jobId };
|
|
532
|
+
})
|
|
533
|
+
);
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
---
|
|
537
|
+
|
|
538
|
+
### File: `src/types/multi-channel.types.ts`
|
|
539
|
+
|
|
540
|
+
```typescript
|
|
541
|
+
/**
|
|
542
|
+
* Type definitions for multi-channel inventory sync
|
|
543
|
+
*/
|
|
544
|
+
export interface ChannelInventoryRecord {
|
|
545
|
+
sku: string;
|
|
546
|
+
location: string;
|
|
547
|
+
channel: string;
|
|
548
|
+
onHand: number;
|
|
549
|
+
reserved: number;
|
|
550
|
+
buffer: number;
|
|
551
|
+
lastUpdated?: string;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
export interface AggregatedInventory {
|
|
555
|
+
sku: string;
|
|
556
|
+
location: string;
|
|
557
|
+
totalOnHand: number;
|
|
558
|
+
totalReserved: number;
|
|
559
|
+
atp: number;
|
|
560
|
+
channels: {
|
|
561
|
+
[channel: string]: {
|
|
562
|
+
allocated: number;
|
|
563
|
+
buffer: number;
|
|
564
|
+
max?: number;
|
|
565
|
+
};
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
export interface SyncState {
|
|
570
|
+
[sku: string]: {
|
|
571
|
+
[location: string]: number; // ATP value
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
export interface SyncStats {
|
|
576
|
+
totalRecords: number;
|
|
577
|
+
channelARecords: number;
|
|
578
|
+
channelBRecords: number;
|
|
579
|
+
fluentRecords: number;
|
|
580
|
+
aggregatedSkus: number;
|
|
581
|
+
changedRecords: number;
|
|
582
|
+
batchesSent: number;
|
|
583
|
+
successCount: number;
|
|
584
|
+
errorCount: number;
|
|
585
|
+
duration: number;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
export interface BatchDetail {
|
|
589
|
+
batchId: string;
|
|
590
|
+
recordCount: number;
|
|
591
|
+
timestamp: string;
|
|
592
|
+
status: 'SENT' | 'FAILED';
|
|
593
|
+
error?: string;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
export interface BatchResult {
|
|
597
|
+
totalSent: number;
|
|
598
|
+
batchCount: number;
|
|
599
|
+
batches: BatchDetail[];
|
|
600
|
+
errors: Array<{ batchId: string; error: string }>;
|
|
601
|
+
}
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
---
|
|
605
|
+
|
|
606
|
+
### File: `src/config/multi-channel.mapping.json`
|
|
607
|
+
|
|
608
|
+
```json
|
|
609
|
+
{
|
|
610
|
+
"name": "multi-channel-inventory",
|
|
611
|
+
"version": "1.0.0",
|
|
612
|
+
"description": "Normalize channel payloads to aggregation schema",
|
|
613
|
+
"fields": {
|
|
614
|
+
"locationRef": {
|
|
615
|
+
"source": "locationRef",
|
|
616
|
+
"required": true,
|
|
617
|
+
"resolver": "sdk.trim",
|
|
618
|
+
"comment": "Location reference"
|
|
619
|
+
},
|
|
620
|
+
"skuRef": {
|
|
621
|
+
"source": "skuRef",
|
|
622
|
+
"required": true,
|
|
623
|
+
"resolver": "sdk.trim",
|
|
624
|
+
"comment": "SKU reference"
|
|
625
|
+
},
|
|
626
|
+
"onHand": {
|
|
627
|
+
"source": "onHand",
|
|
628
|
+
"resolver": "sdk.number",
|
|
629
|
+
"comment": "On-hand quantity"
|
|
630
|
+
},
|
|
631
|
+
"reserved": {
|
|
632
|
+
"source": "reserved",
|
|
633
|
+
"resolver": "sdk.number",
|
|
634
|
+
"comment": "Reserved quantity"
|
|
635
|
+
},
|
|
636
|
+
"buffer": {
|
|
637
|
+
"source": "buffer",
|
|
638
|
+
"resolver": "sdk.number",
|
|
639
|
+
"comment": "Safety buffer"
|
|
640
|
+
},
|
|
641
|
+
"channel": {
|
|
642
|
+
"source": "channel",
|
|
643
|
+
"defaultValue": "UNKNOWN",
|
|
644
|
+
"comment": "Channel identifier"
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
```
|
|
649
|
+
|
|
650
|
+
> **✅ PRODUCTION STANDARD:** Use external JSON files for mapping configuration (not TypeScript objects)
|
|
651
|
+
|
|
652
|
+
---
|
|
653
|
+
|
|
654
|
+
### File: `src/services/atp-calculator.service.ts`
|
|
655
|
+
|
|
656
|
+
```typescript
|
|
657
|
+
import type { ChannelInventoryRecord, AggregatedInventory } from '../types/multi-channel.types';
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Service for calculating ATP (Available To Promise) across channels
|
|
661
|
+
*/
|
|
662
|
+
export class ATPCalculatorService {
|
|
663
|
+
private readonly oversellProtection: boolean;
|
|
664
|
+
|
|
665
|
+
constructor(
|
|
666
|
+
oversellProtection = true
|
|
667
|
+
) {
|
|
668
|
+
this.oversellProtection = oversellProtection;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Calculate base ATP for a single record
|
|
673
|
+
* Formula: ATP = (onHand - reserved) - buffer
|
|
674
|
+
*/
|
|
675
|
+
calculateBaseATP(onHand: number, reserved: number, buffer: number): number {
|
|
676
|
+
const available = Math.max(0, onHand - reserved);
|
|
677
|
+
const atp = available - buffer;
|
|
678
|
+
return this.oversellProtection ? Math.max(0, atp) : atp;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Aggregate inventory across channels
|
|
683
|
+
* Deduplicates by SKU + location and calculates consolidated ATP
|
|
684
|
+
*/
|
|
685
|
+
aggregateChannelInventory(records: ChannelInventoryRecord[]): Map<string, AggregatedInventory> {
|
|
686
|
+
const aggregated = new Map<string, AggregatedInventory>();
|
|
687
|
+
|
|
688
|
+
for (const record of records) {
|
|
689
|
+
const key = `${record.sku}:${record.location}`;
|
|
690
|
+
const existing = aggregated.get(key);
|
|
691
|
+
|
|
692
|
+
if (existing) {
|
|
693
|
+
// Aggregate across channels
|
|
694
|
+
existing.totalOnHand += record.onHand;
|
|
695
|
+
existing.totalReserved += record.reserved;
|
|
696
|
+
existing.channels[record.channel] = {
|
|
697
|
+
allocated: record.onHand - record.reserved - record.buffer,
|
|
698
|
+
buffer: record.buffer,
|
|
699
|
+
};
|
|
700
|
+
} else {
|
|
701
|
+
// First record for this SKU+location
|
|
702
|
+
aggregated.set(key, {
|
|
703
|
+
sku: record.sku,
|
|
704
|
+
location: record.location,
|
|
705
|
+
totalOnHand: record.onHand,
|
|
706
|
+
totalReserved: record.reserved,
|
|
707
|
+
atp: 0,
|
|
708
|
+
channels: {
|
|
709
|
+
[record.channel]: {
|
|
710
|
+
allocated: record.onHand - record.reserved - record.buffer,
|
|
711
|
+
buffer: record.buffer,
|
|
712
|
+
},
|
|
713
|
+
},
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Calculate final ATP for each aggregated record
|
|
719
|
+
for (const [, agg] of aggregated) {
|
|
720
|
+
const totalBuffer = Math.max(...Object.values(agg.channels).map(c => c.buffer));
|
|
721
|
+
agg.atp = this.calculateBaseATP(agg.totalOnHand, agg.totalReserved, totalBuffer);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
return aggregated;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
```
|
|
729
|
+
|
|
730
|
+
---
|
|
731
|
+
|
|
732
|
+
### File: `src/services/channel-a-connector.service.ts`
|
|
733
|
+
|
|
734
|
+
```typescript
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Service for fetching inventory from Channel A REST API
|
|
738
|
+
* Includes rate limiting and exponential backoff retry logic
|
|
739
|
+
*/
|
|
740
|
+
export class ChannelAConnectorService {
|
|
741
|
+
private readonly url: string;
|
|
742
|
+
private readonly apiKey: string;
|
|
743
|
+
private readonly rateLimitRpm: number;
|
|
744
|
+
private readonly logger; // ✅ Versori native log - TypeScript infers type
|
|
745
|
+
private lastRequest = 0;
|
|
746
|
+
|
|
747
|
+
constructor(url: string, apiKey: string, rateLimitRpm: number, logger) { // ✅ Versori native log - TypeScript infers type
|
|
748
|
+
this.url = url;
|
|
749
|
+
this.apiKey = apiKey;
|
|
750
|
+
this.rateLimitRpm = rateLimitRpm;
|
|
751
|
+
this.logger = logger;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Fetch inventory from Channel A with rate limiting
|
|
756
|
+
*/
|
|
757
|
+
async fetchInventory(): Promise<any[]> {
|
|
758
|
+
await this.enforceRateLimit();
|
|
759
|
+
|
|
760
|
+
const response = await this.fetchWithRetry(this.url, {
|
|
761
|
+
method: 'GET',
|
|
762
|
+
headers: {
|
|
763
|
+
'Content-Type': 'application/json',
|
|
764
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
765
|
+
},
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
if (!response.ok) {
|
|
769
|
+
throw new Error(`Channel A API error: ${response.status} ${response.statusText}`);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const data = await response.json();
|
|
773
|
+
return data.inventory || [];
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Enforce rate limit (minimum interval between requests)
|
|
778
|
+
*/
|
|
779
|
+
private async enforceRateLimit(): Promise<void> {
|
|
780
|
+
const minInterval = 60000 / this.rateLimitRpm;
|
|
781
|
+
const now = Date.now();
|
|
782
|
+
const elapsed = now - this.lastRequest;
|
|
783
|
+
|
|
784
|
+
if (elapsed < minInterval) {
|
|
785
|
+
const wait = minInterval - elapsed;
|
|
786
|
+
await new Promise(resolve => setTimeout(resolve, wait));
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
this.lastRequest = Date.now();
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Fetch with exponential backoff retry
|
|
794
|
+
*/
|
|
795
|
+
private async fetchWithRetry(
|
|
796
|
+
url: string,
|
|
797
|
+
options: RequestInit,
|
|
798
|
+
maxRetries = 3
|
|
799
|
+
): Promise<Response> {
|
|
800
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
801
|
+
try {
|
|
802
|
+
const response = await fetch(url, options);
|
|
803
|
+
|
|
804
|
+
if (response.status === 429 || response.status >= 500) {
|
|
805
|
+
if (attempt < maxRetries) {
|
|
806
|
+
const backoff = Math.pow(2, attempt) * 1000;
|
|
807
|
+
`[ChannelA] Error ${response.status}, retrying in ${backoff}ms (attempt ${attempt}/${maxRetries})`
|
|
808
|
+
);
|
|
809
|
+
await new Promise(resolve => setTimeout(resolve, backoff));
|
|
810
|
+
continue;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
return response;
|
|
815
|
+
} catch (error) {
|
|
816
|
+
if (attempt === maxRetries) throw error;
|
|
817
|
+
const backoff = Math.pow(2, attempt) * 1000;
|
|
818
|
+
`[ChannelA] Fetch error, retrying in ${backoff}ms (attempt ${attempt}/${maxRetries}):`,
|
|
819
|
+
error
|
|
820
|
+
);
|
|
821
|
+
await new Promise(resolve => setTimeout(resolve, backoff));
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
throw new Error('Channel A: Max retries exceeded');
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
```
|
|
829
|
+
|
|
830
|
+
---
|
|
831
|
+
|
|
832
|
+
### File: `src/services/channel-b-connector.service.ts`
|
|
833
|
+
|
|
834
|
+
```typescript
|
|
835
|
+
import { S3DataSource, CSVParserService } from '@fluentcommerce/fc-connect-sdk';
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Service for fetching inventory from Channel B S3 CSV
|
|
839
|
+
*/
|
|
840
|
+
export class ChannelBConnectorService {
|
|
841
|
+
private readonly s3: S3DataSource;
|
|
842
|
+
private readonly csv: CSVParserService;
|
|
843
|
+
private readonly bucket: string;
|
|
844
|
+
private readonly key: string;
|
|
845
|
+
private readonly logger; // ✅ Versori native log - TypeScript infers type
|
|
846
|
+
|
|
847
|
+
constructor(s3Config: any, bucket: string, key: string, logger) { // ✅ Versori native log - TypeScript infers type
|
|
848
|
+
this.s3 = new S3DataSource(s3Config, logger);
|
|
849
|
+
this.csv = new CSVParserService();
|
|
850
|
+
this.bucket = bucket;
|
|
851
|
+
this.key = key;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* Fetch inventory from S3 CSV file
|
|
856
|
+
*/
|
|
857
|
+
async fetchInventory(): Promise<any[]> {
|
|
858
|
+
try {
|
|
859
|
+
const csvContent = (await this.s3.downloadFile(`${this.bucket}/${this.key}`, {
|
|
860
|
+
encoding: 'utf8',
|
|
861
|
+
})) as string;
|
|
862
|
+
|
|
863
|
+
const records = await this.csv.parse(csvContent, {
|
|
864
|
+
columns: true,
|
|
865
|
+
skip_empty_lines: true,
|
|
866
|
+
trim: true,
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
return records || [];
|
|
870
|
+
} catch (error) {
|
|
871
|
+
throw error;
|
|
872
|
+
}
|
|
873
|
+
// Note: S3DataSource doesn't require explicit disposal (unlike SFTP)
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
```
|
|
877
|
+
|
|
878
|
+
---
|
|
879
|
+
|
|
880
|
+
### File: `src/services/batch-processor.service.ts`
|
|
881
|
+
|
|
882
|
+
```typescript
|
|
883
|
+
import type { FluentClient } from '@fluentcommerce/fc-connect-sdk';
|
|
884
|
+
import { JobTracker } from '@fluentcommerce/fc-connect-sdk';
|
|
885
|
+
import { BatchResult, BatchDetail } from '../types/multi-channel.types';
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* Service for sending records to Fluent Batch API
|
|
889
|
+
*
|
|
890
|
+
* ✅ PRODUCTION ENHANCEMENT: Optional logger for detailed progress tracking
|
|
891
|
+
*/
|
|
892
|
+
export class BatchProcessorService {
|
|
893
|
+
constructor(
|
|
894
|
+
private client: FluentClient,
|
|
895
|
+
private jobTracker: JobTracker,
|
|
896
|
+
private log?: any // ✅ Optional logger for progress tracking
|
|
897
|
+
) {}
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* Send inventory records to Batch API with chunking
|
|
901
|
+
*/
|
|
902
|
+
async sendInventoryBatches(
|
|
903
|
+
jobId: string,
|
|
904
|
+
records: any[],
|
|
905
|
+
batchSize: number = 500
|
|
906
|
+
): Promise<BatchResult> {
|
|
907
|
+
const result: BatchResult = {
|
|
908
|
+
totalSent: 0,
|
|
909
|
+
batchCount: 0,
|
|
910
|
+
batches: [],
|
|
911
|
+
errors: [],
|
|
912
|
+
};
|
|
913
|
+
|
|
914
|
+
// Chunk records into batches
|
|
915
|
+
const chunks: any[][] = [];
|
|
916
|
+
for (let i = 0; i < records.length; i += batchSize) {
|
|
917
|
+
chunks.push(records.slice(i, i + batchSize));
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
const totalBatches = chunks.length;
|
|
921
|
+
|
|
922
|
+
// ✅ PRODUCTION ENHANCEMENT: Log batch sending start
|
|
923
|
+
if (this.log) {
|
|
924
|
+
this.log.info('📤 Starting batch sending', {
|
|
925
|
+
jobId,
|
|
926
|
+
totalRecords: records.length,
|
|
927
|
+
batchSize,
|
|
928
|
+
totalBatches,
|
|
929
|
+
processingMode: 'sequential (one at a time)',
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// Send each batch
|
|
934
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
935
|
+
const batchNumber = i + 1;
|
|
936
|
+
|
|
937
|
+
// ✅ PRODUCTION ENHANCEMENT: Log progress every 10 batches
|
|
938
|
+
if (this.log && batchNumber % 10 === 0) {
|
|
939
|
+
this.log.info(`📤 Sending batch ${batchNumber}/${totalBatches}`, {
|
|
940
|
+
jobId,
|
|
941
|
+
batchNumber,
|
|
942
|
+
totalBatches,
|
|
943
|
+
recordsInBatch: chunks[i].length,
|
|
944
|
+
totalSentSoFar: result.totalSent,
|
|
945
|
+
progress: `${((batchNumber / totalBatches) * 100).toFixed(1)}%`,
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
try {
|
|
950
|
+
const batch = await this.client.sendBatch(jobId, {
|
|
951
|
+
action: 'UPSERT',
|
|
952
|
+
entityType: 'INVENTORY',
|
|
953
|
+
source: 'MULTI_CHANNEL',
|
|
954
|
+
event: 'MULTI_CHANNEL_SYNC',
|
|
955
|
+
entities: chunks[i],
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
result.totalSent += chunks[i].length;
|
|
959
|
+
result.batchCount++;
|
|
960
|
+
result.batches.push({
|
|
961
|
+
batchId: batch.id,
|
|
962
|
+
recordCount: chunks[i].length,
|
|
963
|
+
timestamp: new Date().toISOString(),
|
|
964
|
+
status: 'SENT',
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
// ✅ No logging here - workflow handles it
|
|
968
|
+
|
|
969
|
+
// Update job tracker
|
|
970
|
+
await this.jobTracker.updateJob(jobId, {
|
|
971
|
+
details: {
|
|
972
|
+
batchesSent: result.batchCount,
|
|
973
|
+
recordsProcessed: result.totalSent,
|
|
974
|
+
},
|
|
975
|
+
});
|
|
976
|
+
} catch (error: any) {
|
|
977
|
+
result.errors.push({
|
|
978
|
+
batchId: `batch-${batchNumber}`,
|
|
979
|
+
error: error.message,
|
|
980
|
+
});
|
|
981
|
+
result.batches.push({
|
|
982
|
+
batchId: `batch-${batchNumber}`,
|
|
983
|
+
recordCount: chunks[i].length,
|
|
984
|
+
timestamp: new Date().toISOString(),
|
|
985
|
+
status: 'FAILED',
|
|
986
|
+
error: error.message,
|
|
987
|
+
});
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// ✅ PRODUCTION ENHANCEMENT: Log completion
|
|
992
|
+
if (this.log) {
|
|
993
|
+
this.log.info('✅ Sequential batch sending completed', {
|
|
994
|
+
jobId,
|
|
995
|
+
totalBatches,
|
|
996
|
+
batchesSent: result.batchCount,
|
|
997
|
+
batchesFailed: result.errors.length,
|
|
998
|
+
totalRecordsSent: result.totalSent,
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
return result;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
```
|
|
1006
|
+
|
|
1007
|
+
---
|
|
1008
|
+
|
|
1009
|
+
### File: `src/services/batch-logger.service.ts`
|
|
1010
|
+
|
|
1011
|
+
```typescript
|
|
1012
|
+
import { Buffer } from 'node:buffer';
|
|
1013
|
+
import { S3DataSource } from '@fluentcommerce/fc-connect-sdk';
|
|
1014
|
+
|
|
1015
|
+
/**
|
|
1016
|
+
* Service for writing batch processing logs to S3
|
|
1017
|
+
*/
|
|
1018
|
+
export class BatchLoggerService {
|
|
1019
|
+
constructor(private s3: S3DataSource) {
|
|
1020
|
+
// ✅ No logger - workflow handles logging with Versori native log
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/**
|
|
1024
|
+
* Write batch processing log to S3
|
|
1025
|
+
*/
|
|
1026
|
+
async writeBatchLog(
|
|
1027
|
+
logData: any,
|
|
1028
|
+
bucket: string,
|
|
1029
|
+
keyPrefix: string,
|
|
1030
|
+
format: 'json' | 'text' = 'json'
|
|
1031
|
+
): Promise<void> {
|
|
1032
|
+
try {
|
|
1033
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
1034
|
+
const logFileName = `${keyPrefix}${timestamp}.${format === 'json' ? 'json' : 'log'}`;
|
|
1035
|
+
const logKey = `${bucket}/${logFileName}`;
|
|
1036
|
+
|
|
1037
|
+
const logContent = this.formatLogContent(logData, format);
|
|
1038
|
+
|
|
1039
|
+
await this.s3.uploadFile(logKey, Buffer.from(logContent, 'utf8'), {
|
|
1040
|
+
encoding: 'utf8',
|
|
1041
|
+
contentType: format === 'json' ? 'application/json' : 'text/plain',
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
// ✅ No logging here - workflow handles it
|
|
1045
|
+
} catch (error: any) {
|
|
1046
|
+
// ✅ No logging here - workflow handles it
|
|
1047
|
+
// Don't throw - logging failure shouldn't stop workflow
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
private formatLogContent(logData: any, format: 'json' | 'text'): string {
|
|
1052
|
+
if (format === 'json') {
|
|
1053
|
+
return JSON.stringify(logData, null, 2);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
return `Multi-Channel Sync Log
|
|
1057
|
+
======================
|
|
1058
|
+
Timestamp: ${logData.timestamp}
|
|
1059
|
+
Job ID: ${logData.jobId}
|
|
1060
|
+
|
|
1061
|
+
Channel Summary:
|
|
1062
|
+
Channel A: ${logData.channels.channelA || 0} records
|
|
1063
|
+
Channel B: ${logData.channels.channelB || 0} records
|
|
1064
|
+
Fluent: ${logData.channels.fluent || 0} records
|
|
1065
|
+
|
|
1066
|
+
Aggregation:
|
|
1067
|
+
Total Records: ${logData.aggregated}
|
|
1068
|
+
Changed Records: ${logData.changed}
|
|
1069
|
+
Unchanged: ${logData.unchanged}
|
|
1070
|
+
|
|
1071
|
+
Batches:
|
|
1072
|
+
${logData.batches
|
|
1073
|
+
.map(
|
|
1074
|
+
(b: any, i: number) =>
|
|
1075
|
+
` [${i + 1}] ${b.batchId} | ${b.recordCount} records | ${b.status}${b.error ? ` | Error: ${b.error}` : ''}`
|
|
1076
|
+
)
|
|
1077
|
+
.join('\n')}
|
|
1078
|
+
|
|
1079
|
+
Summary:
|
|
1080
|
+
Total Batches: ${logData.summary.totalBatches}
|
|
1081
|
+
Successful: ${logData.summary.success}
|
|
1082
|
+
Failed: ${logData.summary.failed}
|
|
1083
|
+
Duration: ${logData.summary.duration}ms
|
|
1084
|
+
|
|
1085
|
+
Status: ${logData.status}
|
|
1086
|
+
`;
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
```
|
|
1090
|
+
|
|
1091
|
+
---
|
|
1092
|
+
|
|
1093
|
+
### File: `src/workflows/multi-channel-sync.workflow.ts`
|
|
1094
|
+
|
|
1095
|
+
```typescript
|
|
1096
|
+
import { Buffer } from 'node:buffer';
|
|
1097
|
+
import {
|
|
1098
|
+
createClient,
|
|
1099
|
+
StateService,
|
|
1100
|
+
VersoriKVAdapter,
|
|
1101
|
+
JobTracker,
|
|
1102
|
+
} from '@fluentcommerce/fc-connect-sdk';
|
|
1103
|
+
import type { ChannelInventoryRecord, SyncStats } from '../types/multi-channel.types';
|
|
1104
|
+
import { ATPCalculatorService } from '../services/atp-calculator.service';
|
|
1105
|
+
import { ChannelAConnectorService } from '../services/channel-a-connector.service';
|
|
1106
|
+
import { ChannelBConnectorService } from '../services/channel-b-connector.service';
|
|
1107
|
+
import { BatchProcessorService } from '../services/batch-processor.service';
|
|
1108
|
+
import { BatchLoggerService } from '../services/batch-logger.service';
|
|
1109
|
+
|
|
1110
|
+
const INVENTORY_QUERY = `
|
|
1111
|
+
query GetInventory($retailerId: ID!, $first: Int!, $after: String) {
|
|
1112
|
+
inventoryPositions(retailerId: $retailerId, first: $first, after: $after) {
|
|
1113
|
+
edges {
|
|
1114
|
+
node {
|
|
1115
|
+
id
|
|
1116
|
+
ref
|
|
1117
|
+
productRef
|
|
1118
|
+
locationRef
|
|
1119
|
+
onHand
|
|
1120
|
+
reservedQuantity
|
|
1121
|
+
status
|
|
1122
|
+
type
|
|
1123
|
+
}
|
|
1124
|
+
cursor
|
|
1125
|
+
}
|
|
1126
|
+
pageInfo {
|
|
1127
|
+
hasNextPage
|
|
1128
|
+
endCursor
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
`;
|
|
1133
|
+
|
|
1134
|
+
/**
|
|
1135
|
+
* Fetch current inventory state from Fluent GraphQL
|
|
1136
|
+
*/
|
|
1137
|
+
async function fetchFluentInventory(
|
|
1138
|
+
client: any,
|
|
1139
|
+
retailerId: string,
|
|
1140
|
+
pageSize: number,
|
|
1141
|
+
maxRecords: number,
|
|
1142
|
+
log: any
|
|
1143
|
+
): Promise<any[]> {
|
|
1144
|
+
try {
|
|
1145
|
+
const result = await client.graphql({
|
|
1146
|
+
query: INVENTORY_QUERY,
|
|
1147
|
+
variables: { retailerId, first: pageSize },
|
|
1148
|
+
pagination: { maxRecords },
|
|
1149
|
+
});
|
|
1150
|
+
|
|
1151
|
+
const edges = result.data?.inventoryPositions?.edges || [];
|
|
1152
|
+
const records = edges.map((e: any) => ({
|
|
1153
|
+
productRef: e.node.productRef,
|
|
1154
|
+
locationRef: e.node.locationRef,
|
|
1155
|
+
onHand: Number(e.node.onHand || 0),
|
|
1156
|
+
reserved: Number(e.node.reservedQuantity || 0),
|
|
1157
|
+
}));
|
|
1158
|
+
|
|
1159
|
+
log.info(`[Fluent] Fetched ${records.length} records from GraphQL`);
|
|
1160
|
+
return records;
|
|
1161
|
+
} catch (error) {
|
|
1162
|
+
// ✅ Enhanced error logging: Extract all error details for visibility
|
|
1163
|
+
const errorDetails = {
|
|
1164
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1165
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
1166
|
+
errorType: error instanceof Error ? error.constructor.name : 'Error',
|
|
1167
|
+
};
|
|
1168
|
+
log.error('[Fluent] GraphQL fetch error:', errorDetails);
|
|
1169
|
+
throw error;
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/**
|
|
1174
|
+
* Main multi-channel sync workflow orchestrator
|
|
1175
|
+
* Coordinates channel fetching, aggregation, delta detection, and batch submission
|
|
1176
|
+
*
|
|
1177
|
+
* @param ctx - Versori context object containing fetch, connections, log, activation, openKv
|
|
1178
|
+
*/
|
|
1179
|
+
export async function processMultiChannelSync(ctx: any) {
|
|
1180
|
+
const { log, openKv, activation } = ctx;
|
|
1181
|
+
const startTime = Date.now();
|
|
1182
|
+
|
|
1183
|
+
log.info('[MultiChannelSync] Starting sync workflow');
|
|
1184
|
+
|
|
1185
|
+
try {
|
|
1186
|
+
// ========================================
|
|
1187
|
+
// CLIENT INITIALIZATION
|
|
1188
|
+
// ========================================
|
|
1189
|
+
const client = await createClient(ctx);
|
|
1190
|
+
|
|
1191
|
+
// ========================================
|
|
1192
|
+
// CONFIGURATION
|
|
1193
|
+
// ========================================
|
|
1194
|
+
const config = {
|
|
1195
|
+
retailerId: activation?.getVariable('retailerId'),
|
|
1196
|
+
jobName: activation?.getVariable('jobName') || 'Multi-Channel Inventory Sync',
|
|
1197
|
+
batchSize: parseInt(activation?.getVariable('batchSize') || '500', 10),
|
|
1198
|
+
maxRecords: parseInt(activation?.getVariable('maxRecords') || '50000', 10),
|
|
1199
|
+
defaultBuffer: parseInt(activation?.getVariable('defaultBuffer') || '5', 10),
|
|
1200
|
+
oversellProtection: activation?.getVariable('oversellProtection') !== 'false',
|
|
1201
|
+
useBpp: activation?.getVariable('useBpp') || 'skip',
|
|
1202
|
+
enableDelta: activation?.getVariable('enableDelta') !== 'false',
|
|
1203
|
+
deltaStateKey: activation?.getVariable('deltaStateKey') || 'sync:delta:state',
|
|
1204
|
+
channelAEnabled: activation?.getVariable('channelAEnabled') === 'true',
|
|
1205
|
+
channelAUrl: activation?.getVariable('channelAUrl'),
|
|
1206
|
+
channelAKey: activation?.getVariable('channelAKey'),
|
|
1207
|
+
channelABuffer: parseInt(activation?.getVariable('channelABuffer') || '5', 10),
|
|
1208
|
+
channelARateLimitRpm: parseInt(activation?.getVariable('channelARateLimitRpm') || '120', 10),
|
|
1209
|
+
channelBEnabled: activation?.getVariable('channelBEnabled') === 'true',
|
|
1210
|
+
channelBBucket: activation?.getVariable('channelBBucket'),
|
|
1211
|
+
channelBKey: activation?.getVariable('channelBKey'),
|
|
1212
|
+
fluentEnabled: activation?.getVariable('fluentEnabled') === 'true',
|
|
1213
|
+
fluentPageSize: parseInt(activation?.getVariable('fluentPageSize') || '200', 10),
|
|
1214
|
+
};
|
|
1215
|
+
|
|
1216
|
+
// ========================================
|
|
1217
|
+
// SERVICE INITIALIZATION
|
|
1218
|
+
// ========================================
|
|
1219
|
+
const kv = new VersoriKVAdapter(openKv(':project:'));
|
|
1220
|
+
const jobTracker = new JobTracker(openKv(':project:'), log);
|
|
1221
|
+
const stateService = new StateService(log);
|
|
1222
|
+
|
|
1223
|
+
const atpCalc = new ATPCalculatorService(config.oversellProtection);
|
|
1224
|
+
// ✅ PRODUCTION ENHANCEMENT: Pass log to BatchProcessorService for detailed progress tracking
|
|
1225
|
+
const batchProcessor = new BatchProcessorService(client, jobTracker, log);
|
|
1226
|
+
|
|
1227
|
+
const jobId = `multi-channel-sync-${Date.now()}`;
|
|
1228
|
+
await jobTracker.createJob(jobId, {
|
|
1229
|
+
triggeredBy: 'schedule',
|
|
1230
|
+
stage: 'initialization',
|
|
1231
|
+
details: { config },
|
|
1232
|
+
});
|
|
1233
|
+
|
|
1234
|
+
const stats: SyncStats = {
|
|
1235
|
+
totalRecords: 0,
|
|
1236
|
+
channelARecords: 0,
|
|
1237
|
+
channelBRecords: 0,
|
|
1238
|
+
fluentRecords: 0,
|
|
1239
|
+
aggregatedSkus: 0,
|
|
1240
|
+
changedRecords: 0,
|
|
1241
|
+
batchesSent: 0,
|
|
1242
|
+
successCount: 0,
|
|
1243
|
+
errorCount: 0,
|
|
1244
|
+
duration: 0,
|
|
1245
|
+
};
|
|
1246
|
+
|
|
1247
|
+
await jobTracker.updateJob(jobId, { status: 'fetching_channels' });
|
|
1248
|
+
|
|
1249
|
+
// ========================================
|
|
1250
|
+
// CHANNEL FETCHING (PARALLEL WITH GRACEFUL DEGRADATION)
|
|
1251
|
+
// ========================================
|
|
1252
|
+
const allRecords: ChannelInventoryRecord[] = [];
|
|
1253
|
+
|
|
1254
|
+
// Fetch Channel A (REST API)
|
|
1255
|
+
if (config.channelAEnabled && config.channelAUrl && config.channelAKey) {
|
|
1256
|
+
log.info('[MultiChannelSync] Fetching from Channel A...');
|
|
1257
|
+
const channelA = new ChannelAConnectorService(
|
|
1258
|
+
config.channelAUrl,
|
|
1259
|
+
config.channelAKey,
|
|
1260
|
+
config.channelARateLimitRpm,
|
|
1261
|
+
log
|
|
1262
|
+
);
|
|
1263
|
+
|
|
1264
|
+
try {
|
|
1265
|
+
const records = await channelA.fetchInventory();
|
|
1266
|
+
const mapped = records.map(r => ({
|
|
1267
|
+
sku: r.product_id,
|
|
1268
|
+
location: r.warehouse_code,
|
|
1269
|
+
channel: 'A',
|
|
1270
|
+
onHand: r.quantity_available,
|
|
1271
|
+
reserved: r.quantity_reserved,
|
|
1272
|
+
buffer: config.channelABuffer,
|
|
1273
|
+
lastUpdated: r.updated_at,
|
|
1274
|
+
}));
|
|
1275
|
+
|
|
1276
|
+
allRecords.push(...mapped);
|
|
1277
|
+
stats.channelARecords = mapped.length;
|
|
1278
|
+
log.info(`[MultiChannelSync] Channel A: ${mapped.length} records`);
|
|
1279
|
+
} catch (error) {
|
|
1280
|
+
// ✅ Enhanced error logging: Extract all error details for visibility
|
|
1281
|
+
const errorDetails = {
|
|
1282
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1283
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
1284
|
+
errorType: error instanceof Error ? error.constructor.name : 'Error',
|
|
1285
|
+
};
|
|
1286
|
+
log.error('[MultiChannelSync] Channel A fetch failed (continuing):', errorDetails);
|
|
1287
|
+
stats.errorCount++;
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
// Fetch Channel B (S3 CSV)
|
|
1292
|
+
if (config.channelBEnabled && config.channelBBucket && config.channelBKey) {
|
|
1293
|
+
log.info('[MultiChannelSync] Fetching from Channel B...');
|
|
1294
|
+
const s3Config = {
|
|
1295
|
+
type: 'S3_CSV',
|
|
1296
|
+
connectionId: 'channel-b-s3',
|
|
1297
|
+
name: 'Channel B S3',
|
|
1298
|
+
s3Config: {
|
|
1299
|
+
bucket: config.channelBBucket,
|
|
1300
|
+
region: activation?.getVariable('awsRegion') || 'us-east-1',
|
|
1301
|
+
accessKeyId: activation?.getVariable('awsAccessKeyId'),
|
|
1302
|
+
secretAccessKey: activation?.getVariable('awsSecretAccessKey'),
|
|
1303
|
+
},
|
|
1304
|
+
};
|
|
1305
|
+
|
|
1306
|
+
const channelB = new ChannelBConnectorService(
|
|
1307
|
+
s3Config,
|
|
1308
|
+
config.channelBBucket,
|
|
1309
|
+
config.channelBKey,
|
|
1310
|
+
log
|
|
1311
|
+
);
|
|
1312
|
+
|
|
1313
|
+
try {
|
|
1314
|
+
const records = await channelB.fetchInventory();
|
|
1315
|
+
const mapped = records.map((r: any) => ({
|
|
1316
|
+
sku: r.sku,
|
|
1317
|
+
location: r.location,
|
|
1318
|
+
channel: 'B',
|
|
1319
|
+
onHand: parseInt(r.qty, 10),
|
|
1320
|
+
reserved: parseInt(r.reserved, 10),
|
|
1321
|
+
buffer: config.defaultBuffer,
|
|
1322
|
+
}));
|
|
1323
|
+
|
|
1324
|
+
allRecords.push(...mapped);
|
|
1325
|
+
stats.channelBRecords = mapped.length;
|
|
1326
|
+
log.info(`[MultiChannelSync] Channel B: ${mapped.length} records`);
|
|
1327
|
+
} catch (error) {
|
|
1328
|
+
// ✅ Enhanced error logging: Extract all error details for visibility
|
|
1329
|
+
const errorDetails = {
|
|
1330
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1331
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
1332
|
+
errorType: error instanceof Error ? error.constructor.name : 'Error',
|
|
1333
|
+
};
|
|
1334
|
+
log.error('[MultiChannelSync] Channel B fetch failed (continuing):', errorDetails);
|
|
1335
|
+
stats.errorCount++;
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
// Fetch Fluent GraphQL (current state)
|
|
1340
|
+
if (config.fluentEnabled) {
|
|
1341
|
+
log.info('[MultiChannelSync] Fetching from Fluent GraphQL...');
|
|
1342
|
+
try {
|
|
1343
|
+
const records = await fetchFluentInventory(
|
|
1344
|
+
client,
|
|
1345
|
+
config.retailerId!,
|
|
1346
|
+
config.fluentPageSize,
|
|
1347
|
+
config.maxRecords,
|
|
1348
|
+
log
|
|
1349
|
+
);
|
|
1350
|
+
|
|
1351
|
+
const mapped = records.map(r => ({
|
|
1352
|
+
sku: r.productRef,
|
|
1353
|
+
location: r.locationRef,
|
|
1354
|
+
channel: 'FLUENT',
|
|
1355
|
+
onHand: r.onHand,
|
|
1356
|
+
reserved: r.reserved,
|
|
1357
|
+
buffer: 0,
|
|
1358
|
+
}));
|
|
1359
|
+
|
|
1360
|
+
allRecords.push(...mapped);
|
|
1361
|
+
stats.fluentRecords = mapped.length;
|
|
1362
|
+
log.info(`[MultiChannelSync] Fluent: ${mapped.length} records`);
|
|
1363
|
+
} catch (error) {
|
|
1364
|
+
// ✅ Enhanced error logging: Extract all error details for visibility
|
|
1365
|
+
const errorDetails = {
|
|
1366
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1367
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
1368
|
+
errorType: error instanceof Error ? error.constructor.name : 'Error',
|
|
1369
|
+
};
|
|
1370
|
+
log.error('[MultiChannelSync] Fluent fetch failed (continuing):', errorDetails);
|
|
1371
|
+
stats.errorCount++;
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
stats.totalRecords = allRecords.length;
|
|
1376
|
+
|
|
1377
|
+
if (allRecords.length === 0) {
|
|
1378
|
+
log.warn('[MultiChannelSync] No inventory records fetched from any channel');
|
|
1379
|
+
await jobTracker.markCompleted(jobId, { message: 'No data', stats });
|
|
1380
|
+
return { success: false, message: 'No data', stats };
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
// ========================================
|
|
1384
|
+
// AGGREGATION
|
|
1385
|
+
// ========================================
|
|
1386
|
+
await jobTracker.updateJob(jobId, { status: 'aggregating' });
|
|
1387
|
+
|
|
1388
|
+
log.info('[MultiChannelSync] Aggregating inventory across channels...');
|
|
1389
|
+
const aggregated = atpCalc.aggregateChannelInventory(allRecords);
|
|
1390
|
+
stats.aggregatedSkus = aggregated.size;
|
|
1391
|
+
|
|
1392
|
+
const finalInventory = Array.from(aggregated.values()).map(agg => ({
|
|
1393
|
+
skuRef: agg.sku,
|
|
1394
|
+
locationRef: agg.location,
|
|
1395
|
+
qty: agg.atp,
|
|
1396
|
+
type: 'AVAILABLE',
|
|
1397
|
+
status: 'ACTIVE',
|
|
1398
|
+
expectedOn: new Date().toISOString().split('T')[0],
|
|
1399
|
+
}));
|
|
1400
|
+
|
|
1401
|
+
log.info(`[MultiChannelSync] Aggregated ${stats.aggregatedSkus} unique SKU/location combinations`);
|
|
1402
|
+
|
|
1403
|
+
// ========================================
|
|
1404
|
+
// DELTA DETECTION
|
|
1405
|
+
// ========================================
|
|
1406
|
+
let recordsToSend = finalInventory;
|
|
1407
|
+
|
|
1408
|
+
if (config.enableDelta) {
|
|
1409
|
+
await jobTracker.updateJob(jobId, { status: 'delta_detection' });
|
|
1410
|
+
log.info('[MultiChannelSync] Checking for changes (delta detection)...');
|
|
1411
|
+
|
|
1412
|
+
const prevState = ((await stateService.getState(config.deltaStateKey)) as any) || {};
|
|
1413
|
+
const changedRecords = [];
|
|
1414
|
+
|
|
1415
|
+
for (const record of finalInventory) {
|
|
1416
|
+
const prevQty = prevState[record.skuRef]?.[record.locationRef];
|
|
1417
|
+
if (prevQty === undefined || prevQty !== record.qty) {
|
|
1418
|
+
changedRecords.push(record);
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
recordsToSend = changedRecords;
|
|
1423
|
+
stats.changedRecords = changedRecords.length;
|
|
1424
|
+
log.info(
|
|
1425
|
+
`[MultiChannelSync] Delta detection: ${changedRecords.length} changed records (${finalInventory.length} total)`
|
|
1426
|
+
);
|
|
1427
|
+
} else {
|
|
1428
|
+
stats.changedRecords = finalInventory.length;
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
if (recordsToSend.length === 0) {
|
|
1432
|
+
log.info('[MultiChannelSync] No changes detected, skipping batch send');
|
|
1433
|
+
stats.duration = Date.now() - startTime;
|
|
1434
|
+
await jobTracker.markCompleted(jobId, { message: 'No changes', stats });
|
|
1435
|
+
return { success: true, message: 'No changes', stats };
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
// ========================================
|
|
1439
|
+
// BATCH API SUBMISSION
|
|
1440
|
+
// ========================================
|
|
1441
|
+
await jobTracker.updateJob(jobId, { status: 'creating_batch_job' });
|
|
1442
|
+
|
|
1443
|
+
log.info('[MultiChannelSync] Creating Batch API job...');
|
|
1444
|
+
const job = await client.createJob({
|
|
1445
|
+
name: config.jobName,
|
|
1446
|
+
retailerId: config.retailerId!,
|
|
1447
|
+
meta: {
|
|
1448
|
+
preprocessing: config.useBpp,
|
|
1449
|
+
},
|
|
1450
|
+
});
|
|
1451
|
+
|
|
1452
|
+
log.info(`[MultiChannelSync] Job created: ${job.id}`);
|
|
1453
|
+
|
|
1454
|
+
await jobTracker.updateJob(jobId, { status: 'sending_batches' });
|
|
1455
|
+
|
|
1456
|
+
// ? Enhanced: Extract context for progress logging
|
|
1457
|
+
const uniqueLocations = [...new Set(recordsToSend.map((r: any) => r.locationRef))];
|
|
1458
|
+
const sampleSKUs = recordsToSend.slice(0, 5).map((r: any) => r.skuRef);
|
|
1459
|
+
const estimatedBatches = Math.ceil(recordsToSend.length / config.batchSize);
|
|
1460
|
+
|
|
1461
|
+
// ? Enhanced: Start logging with context
|
|
1462
|
+
log.info(`[BatchProcessor] Sending batches for multi-channel sync`, {
|
|
1463
|
+
totalRecords: recordsToSend.length,
|
|
1464
|
+
estimatedBatches,
|
|
1465
|
+
batchSize: config.batchSize,
|
|
1466
|
+
locations: uniqueLocations.join(', '),
|
|
1467
|
+
sampleSKUs: sampleSKUs.join(', '),
|
|
1468
|
+
jobId: job.id
|
|
1469
|
+
});
|
|
1470
|
+
|
|
1471
|
+
const batchResults = await batchProcessor.sendInventoryBatches(
|
|
1472
|
+
job.id,
|
|
1473
|
+
recordsToSend,
|
|
1474
|
+
config.batchSize
|
|
1475
|
+
);
|
|
1476
|
+
|
|
1477
|
+
// ✅ Logging handled in workflow with Versori native log
|
|
1478
|
+
log.info(`[BatchProcessor] Sent ${batchResults.batchCount} batches`, {
|
|
1479
|
+
jobId: job.id,
|
|
1480
|
+
totalRecords: batchResults.totalSent,
|
|
1481
|
+
successfulBatches: batchResults.batches.filter(b => b.status === 'SENT').length,
|
|
1482
|
+
failedBatches: batchResults.batches.filter(b => b.status === 'FAILED').length,
|
|
1483
|
+
});
|
|
1484
|
+
|
|
1485
|
+
// ? Enhanced: Completion logging with summary
|
|
1486
|
+
log.info(`[BatchProcessor] Batch submission completed for multi-channel sync`, {
|
|
1487
|
+
totalBatches: batchResults.batchCount,
|
|
1488
|
+
totalRecords: batchResults.totalSent,
|
|
1489
|
+
successfulBatches: batchResults.batches.filter(b => b.status === 'SENT').length,
|
|
1490
|
+
failedBatches: batchResults.errors.length,
|
|
1491
|
+
jobId: job.id
|
|
1492
|
+
});
|
|
1493
|
+
|
|
1494
|
+
if (batchResults.errors.length > 0) {
|
|
1495
|
+
log.warn(`[BatchProcessor] ${batchResults.errors.length} batches failed`, {
|
|
1496
|
+
jobId: job.id,
|
|
1497
|
+
errors: batchResults.errors.slice(0, 5), // Log first 5 errors
|
|
1498
|
+
});
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
stats.batchesSent = batchResults.batchCount;
|
|
1502
|
+
stats.successCount = batchResults.batches.filter(b => b.status === 'SENT').length;
|
|
1503
|
+
stats.errorCount = batchResults.errors.length;
|
|
1504
|
+
|
|
1505
|
+
// ========================================
|
|
1506
|
+
// DELTA STATE UPDATE
|
|
1507
|
+
// ========================================
|
|
1508
|
+
if (config.enableDelta) {
|
|
1509
|
+
await jobTracker.updateJob(jobId, { status: 'updating_delta_state' });
|
|
1510
|
+
log.info('[MultiChannelSync] Updating delta state...');
|
|
1511
|
+
|
|
1512
|
+
const newState: any = {};
|
|
1513
|
+
for (const record of finalInventory) {
|
|
1514
|
+
if (!newState[record.skuRef]) {
|
|
1515
|
+
newState[record.skuRef] = {};
|
|
1516
|
+
}
|
|
1517
|
+
newState[record.skuRef][record.locationRef] = record.qty;
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
await stateService.setState(config.deltaStateKey, newState, {
|
|
1521
|
+
ttlSeconds: 7 * 24 * 60 * 60, // 7 days
|
|
1522
|
+
});
|
|
1523
|
+
|
|
1524
|
+
log.info('[MultiChannelSync] Delta state updated');
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
stats.duration = Date.now() - startTime;
|
|
1528
|
+
|
|
1529
|
+
log.info('[MultiChannelSync] Sync completed', { stats });
|
|
1530
|
+
|
|
1531
|
+
await jobTracker.markCompleted(jobId, {
|
|
1532
|
+
stats,
|
|
1533
|
+
jobId: job.id,
|
|
1534
|
+
});
|
|
1535
|
+
|
|
1536
|
+
return {
|
|
1537
|
+
success: stats.errorCount === 0,
|
|
1538
|
+
jobId: job.id,
|
|
1539
|
+
stats,
|
|
1540
|
+
};
|
|
1541
|
+
} catch (error: any) {
|
|
1542
|
+
// ✅ Enhanced error logging: Extract all error details for visibility
|
|
1543
|
+
const errorDetails = {
|
|
1544
|
+
message: error?.message || 'Unknown error',
|
|
1545
|
+
stack: error?.stack,
|
|
1546
|
+
fileName: error?.fileName,
|
|
1547
|
+
lineNumber: error?.lineNumber,
|
|
1548
|
+
originalError: error?.context?.originalError?.message,
|
|
1549
|
+
errorType: error?.name || 'Error',
|
|
1550
|
+
};
|
|
1551
|
+
log.error('[MultiChannelSync] Fatal error:', errorDetails);
|
|
1552
|
+
return { success: false, error: error.message, duration: Date.now() - startTime };
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
```
|
|
1556
|
+
|
|
1557
|
+
---
|
|
1558
|
+
|
|
1559
|
+
## Versori Activation Variables
|
|
1560
|
+
|
|
1561
|
+
```bash
|
|
1562
|
+
# Required Variables
|
|
1563
|
+
retailerId=your-retailer-id
|
|
1564
|
+
|
|
1565
|
+
# Sync Configuration
|
|
1566
|
+
jobName=Multi-Channel Inventory Sync
|
|
1567
|
+
batchSize=500
|
|
1568
|
+
maxRecords=50000
|
|
1569
|
+
defaultBuffer=5
|
|
1570
|
+
oversellProtection=true
|
|
1571
|
+
useBpp=skip
|
|
1572
|
+
|
|
1573
|
+
# Delta Detection
|
|
1574
|
+
enableDelta=true
|
|
1575
|
+
deltaStateKey=sync:delta:state
|
|
1576
|
+
|
|
1577
|
+
# Channel A (REST API)
|
|
1578
|
+
channelAEnabled=true
|
|
1579
|
+
channelAUrl=https://api.channel-a.example.com/inventory
|
|
1580
|
+
channelAKey=your-api-key
|
|
1581
|
+
channelABuffer=5
|
|
1582
|
+
channelARateLimitRpm=120
|
|
1583
|
+
|
|
1584
|
+
# Channel B (S3 CSV)
|
|
1585
|
+
channelBEnabled=true
|
|
1586
|
+
channelBBucket=channel-b-inventory
|
|
1587
|
+
channelBKey=inventory/current.csv
|
|
1588
|
+
|
|
1589
|
+
# AWS Credentials (for Channel B S3)
|
|
1590
|
+
awsRegion=us-east-1
|
|
1591
|
+
awsAccessKeyId=AKIAXXXXXXXXXXXX
|
|
1592
|
+
awsSecretAccessKey=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
|
1593
|
+
|
|
1594
|
+
# Fluent GraphQL (current state comparison)
|
|
1595
|
+
fluentEnabled=true
|
|
1596
|
+
fluentPageSize=200
|
|
1597
|
+
```
|
|
1598
|
+
|
|
1599
|
+
---
|
|
1600
|
+
|
|
1601
|
+
## Sample Channel Data
|
|
1602
|
+
|
|
1603
|
+
### Channel A (REST API Response)
|
|
1604
|
+
|
|
1605
|
+
```json
|
|
1606
|
+
{
|
|
1607
|
+
"inventory": [
|
|
1608
|
+
{
|
|
1609
|
+
"product_id": "SKU-12345",
|
|
1610
|
+
"warehouse_code": "LOC001",
|
|
1611
|
+
"quantity_available": 100,
|
|
1612
|
+
"quantity_reserved": 20,
|
|
1613
|
+
"updated_at": "2025-01-25T10:00:00Z"
|
|
1614
|
+
}
|
|
1615
|
+
]
|
|
1616
|
+
}
|
|
1617
|
+
```
|
|
1618
|
+
|
|
1619
|
+
### Channel B (S3 CSV)
|
|
1620
|
+
|
|
1621
|
+
```csv
|
|
1622
|
+
sku,location,qty,reserved
|
|
1623
|
+
SKU-12345,LOC001,95,15
|
|
1624
|
+
SKU-67890,LOC002,75,5
|
|
1625
|
+
SKU-11111,LOC001,200,0
|
|
1626
|
+
```
|
|
1627
|
+
|
|
1628
|
+
### ATP Calculation Example
|
|
1629
|
+
|
|
1630
|
+
```typescript
|
|
1631
|
+
// Channel A: SKU-12345 at LOC001
|
|
1632
|
+
onHand = 100
|
|
1633
|
+
reserved = 20
|
|
1634
|
+
buffer = 5
|
|
1635
|
+
ATP = (100 - 20) - 5 = 75
|
|
1636
|
+
|
|
1637
|
+
// Channel B: SKU-12345 at LOC001
|
|
1638
|
+
onHand = 95
|
|
1639
|
+
reserved = 15
|
|
1640
|
+
buffer = 5
|
|
1641
|
+
ATP = (95 - 15) - 5 = 75
|
|
1642
|
+
|
|
1643
|
+
// Aggregated: SKU-12345 at LOC001
|
|
1644
|
+
totalOnHand = 100 + 95 = 195
|
|
1645
|
+
totalReserved = 20 + 15 = 35
|
|
1646
|
+
maxBuffer = max(5, 5) = 5
|
|
1647
|
+
finalATP = (195 - 35) - 5 = 155
|
|
1648
|
+
```
|
|
1649
|
+
|
|
1650
|
+
---
|
|
1651
|
+
|
|
1652
|
+
## Deployment
|
|
1653
|
+
|
|
1654
|
+
```bash
|
|
1655
|
+
# Install dependencies
|
|
1656
|
+
npm install
|
|
1657
|
+
|
|
1658
|
+
# Validate configuration
|
|
1659
|
+
npm run lint
|
|
1660
|
+
|
|
1661
|
+
# Deploy to Versori
|
|
1662
|
+
versori deploy
|
|
1663
|
+
|
|
1664
|
+
# View logs
|
|
1665
|
+
versori logs multi-channel-inventory-sync
|
|
1666
|
+
|
|
1667
|
+
# Trigger manual sync
|
|
1668
|
+
versori run adhocMultiChannelSync
|
|
1669
|
+
```
|
|
1670
|
+
|
|
1671
|
+
---
|
|
1672
|
+
|
|
1673
|
+
## Testing
|
|
1674
|
+
|
|
1675
|
+
### Test Scheduled Sync
|
|
1676
|
+
|
|
1677
|
+
Upload test CSV files to S3/SFTP for each channel and wait for the scheduled run.
|
|
1678
|
+
|
|
1679
|
+
**Check logs:**
|
|
1680
|
+
|
|
1681
|
+
```
|
|
1682
|
+
[STEP 1/8] Initializing job tracking
|
|
1683
|
+
[STEP 2/8] Initializing Fluent Commerce client and data sources
|
|
1684
|
+
[STEP 3/8] Discovering files across channels
|
|
1685
|
+
[CHANNEL 1/3] Processing channel: CHANNEL_A
|
|
1686
|
+
[FILE 1/1] Processing file: channel-a-inventory_20250124.csv
|
|
1687
|
+
[STEP 4/8] Downloading and parsing: channel-a-inventory_20250124.csv
|
|
1688
|
+
[STEP 5/8] Transforming 5000 inventory records from channel-a-inventory_20250124.csv
|
|
1689
|
+
[STEP 6/8] Creating batch job and sending 5 batches to Fluent Commerce
|
|
1690
|
+
[STEP 7/8] Archiving file: channel-a-inventory_20250124.csv
|
|
1691
|
+
[STEP 8/8] Completing job and calculating totals
|
|
1692
|
+
```
|
|
1693
|
+
|
|
1694
|
+
### Test Ad hoc Sync
|
|
1695
|
+
|
|
1696
|
+
```bash
|
|
1697
|
+
# Sync all channels
|
|
1698
|
+
curl -X POST https://api.versori.com/webhooks/multi-channel-sync-adhoc \
|
|
1699
|
+
-H "Content-Type: application/json" \
|
|
1700
|
+
-d '{}'
|
|
1701
|
+
|
|
1702
|
+
# Sync specific channel
|
|
1703
|
+
curl -X POST https://api.versori.com/webhooks/multi-channel-sync-adhoc \
|
|
1704
|
+
-H "Content-Type: application/json" \
|
|
1705
|
+
-d '{
|
|
1706
|
+
"channelId": "CHANNEL_A"
|
|
1707
|
+
}'
|
|
1708
|
+
|
|
1709
|
+
# Sync with specific pattern
|
|
1710
|
+
curl -X POST https://api.versori.com/webhooks/multi-channel-sync-adhoc \
|
|
1711
|
+
-H "Content-Type: application/json" \
|
|
1712
|
+
-d '{
|
|
1713
|
+
"filePattern": "urgent_*.csv",
|
|
1714
|
+
"channelId": "CHANNEL_B"
|
|
1715
|
+
}'
|
|
1716
|
+
```
|
|
1717
|
+
|
|
1718
|
+
### Test Job Status Query
|
|
1719
|
+
|
|
1720
|
+
```bash
|
|
1721
|
+
curl -X POST https://api.versori.com/webhooks/multi-channel-sync-job-status \
|
|
1722
|
+
-H "Content-Type: application/json" \
|
|
1723
|
+
-d '{
|
|
1724
|
+
"jobId": "ADHOC_MULTI_20251024_183045_abc123"
|
|
1725
|
+
}'
|
|
1726
|
+
```
|
|
1727
|
+
|
|
1728
|
+
### Verify Batch Jobs in Fluent
|
|
1729
|
+
|
|
1730
|
+
After processing, check the Batch job status for each channel in Fluent Commerce:
|
|
1731
|
+
|
|
1732
|
+
```bash
|
|
1733
|
+
# Query job status via GraphQL
|
|
1734
|
+
curl -X POST https://your-fluent-instance.com/graphql \
|
|
1735
|
+
-H "Authorization: Bearer YOUR_TOKEN" \
|
|
1736
|
+
-H "Content-Type: application/json" \
|
|
1737
|
+
-d '{
|
|
1738
|
+
"query": "query { job(id: \"job-123456\") { id status recordCount processedCount } }"
|
|
1739
|
+
}'
|
|
1740
|
+
```
|
|
1741
|
+
|
|
1742
|
+
---
|
|
1743
|
+
|
|
1744
|
+
## Monitoring
|
|
1745
|
+
|
|
1746
|
+
### Success Response
|
|
1747
|
+
|
|
1748
|
+
```json
|
|
1749
|
+
{
|
|
1750
|
+
"success": true,
|
|
1751
|
+
"channelsProcessed": 3,
|
|
1752
|
+
"channelsFailed": 0,
|
|
1753
|
+
"filesProcessed": 3,
|
|
1754
|
+
"filesSkipped": 0,
|
|
1755
|
+
"filesFailed": 0,
|
|
1756
|
+
"results": [
|
|
1757
|
+
{
|
|
1758
|
+
"channel": "CHANNEL_A",
|
|
1759
|
+
"file": "channel-a-inventory_2025-01-22.csv",
|
|
1760
|
+
"success": true,
|
|
1761
|
+
"recordCount": 5000,
|
|
1762
|
+
"batchCount": 5,
|
|
1763
|
+
"jobId": "job-123456",
|
|
1764
|
+
"duration": 12345
|
|
1765
|
+
},
|
|
1766
|
+
{
|
|
1767
|
+
"channel": "CHANNEL_B",
|
|
1768
|
+
"file": "channel-b-inventory_2025-01-22.csv",
|
|
1769
|
+
"success": true,
|
|
1770
|
+
"recordCount": 3000,
|
|
1771
|
+
"batchCount": 3,
|
|
1772
|
+
"jobId": "job-123457",
|
|
1773
|
+
"duration": 9876
|
|
1774
|
+
},
|
|
1775
|
+
{
|
|
1776
|
+
"channel": "CHANNEL_C",
|
|
1777
|
+
"file": "channel-c-inventory_2025-01-22.csv",
|
|
1778
|
+
"success": true,
|
|
1779
|
+
"recordCount": 2000,
|
|
1780
|
+
"batchCount": 2,
|
|
1781
|
+
"jobId": "job-123458",
|
|
1782
|
+
"duration": 8765
|
|
1783
|
+
}
|
|
1784
|
+
],
|
|
1785
|
+
"duration": 13456
|
|
1786
|
+
}
|
|
1787
|
+
```
|
|
1788
|
+
|
|
1789
|
+
### Partial Success Response
|
|
1790
|
+
|
|
1791
|
+
```json
|
|
1792
|
+
{
|
|
1793
|
+
"success": true,
|
|
1794
|
+
"channelsProcessed": 2,
|
|
1795
|
+
"channelsFailed": 1,
|
|
1796
|
+
"filesProcessed": 2,
|
|
1797
|
+
"filesSkipped": 0,
|
|
1798
|
+
"filesFailed": 1,
|
|
1799
|
+
"results": [
|
|
1800
|
+
{
|
|
1801
|
+
"channel": "CHANNEL_A",
|
|
1802
|
+
"file": "channel-a-inventory_2025-01-22.csv",
|
|
1803
|
+
"success": true,
|
|
1804
|
+
"recordCount": 5000,
|
|
1805
|
+
"batchCount": 5,
|
|
1806
|
+
"jobId": "job-123456",
|
|
1807
|
+
"duration": 12345
|
|
1808
|
+
},
|
|
1809
|
+
{
|
|
1810
|
+
"channel": "CHANNEL_B",
|
|
1811
|
+
"file": "channel-b-inventory_2025-01-22.csv",
|
|
1812
|
+
"success": false,
|
|
1813
|
+
"error": "CSV parse error: Invalid structure"
|
|
1814
|
+
}
|
|
1815
|
+
],
|
|
1816
|
+
"duration": 13456
|
|
1817
|
+
}
|
|
1818
|
+
```
|
|
1819
|
+
|
|
1820
|
+
### Error Response
|
|
1821
|
+
|
|
1822
|
+
```json
|
|
1823
|
+
{
|
|
1824
|
+
"success": false,
|
|
1825
|
+
"channelsProcessed": 0,
|
|
1826
|
+
"channelsFailed": 3,
|
|
1827
|
+
"filesProcessed": 0,
|
|
1828
|
+
"filesFailed": 3,
|
|
1829
|
+
"results": [
|
|
1830
|
+
{
|
|
1831
|
+
"channel": "CHANNEL_A",
|
|
1832
|
+
"file": "channel-a-inventory_2025-01-22.csv",
|
|
1833
|
+
"success": false,
|
|
1834
|
+
"error": "Data source connection failed"
|
|
1835
|
+
}
|
|
1836
|
+
],
|
|
1837
|
+
"duration": 876
|
|
1838
|
+
}
|
|
1839
|
+
```
|
|
1840
|
+
|
|
1841
|
+
### Monitoring Metrics
|
|
1842
|
+
|
|
1843
|
+
Monitor these metrics via Versori logs filtered by `jobId` or `stage`:
|
|
1844
|
+
|
|
1845
|
+
- **Channels Processed** - Total channels successfully processed
|
|
1846
|
+
- **Files Processed** - Total files successfully processed across all channels
|
|
1847
|
+
- **Batch Jobs Created** - Total Batch jobs created in Fluent Commerce (one per channel)
|
|
1848
|
+
- **Processing Duration** - Time taken for complete multi-channel sync
|
|
1849
|
+
- **Channel Failures** - Channels that failed (check individual channel errors)
|
|
1850
|
+
|
|
1851
|
+
Use the status webhook for dashboards and automated monitoring.
|
|
1852
|
+
|
|
1853
|
+
---
|
|
1854
|
+
|
|
1855
|
+
- 🎯 **TRUE modular architecture** - Separate service files with clear responsibilities
|
|
1856
|
+
- 🎯 **Graceful degradation** - Use `Promise.allSettled()` for partial channel failures
|
|
1857
|
+
- 🎯 **Delta detection** - Only sync changed records (reduces API load by 90%+)
|
|
1858
|
+
- 🎯 **External JSON mapping** - Use `with { type: 'json' }` import syntax
|
|
1859
|
+
- 🎯 **ATP calculation** - `ATP = (onHand - reserved) - buffer` with oversell protection
|
|
1860
|
+
- 🎯 **Rate limiting** - Enforce minimum interval between Channel A requests
|
|
1861
|
+
- 🎯 **Skip BPP** - Set `preprocessing: 'skip'` when using delta detection
|
|
1862
|
+
- 🎯 **Job tracking** - Use `JobTracker` for lifecycle management
|
|
1863
|
+
- 🎯 **Native logging** - Use `log` from context on Versori platform
|
|
1864
|
+
- 🎯 **EntityType: INVENTORY** - Correct entity type for inventory records
|
|
1865
|
+
- 🎯 **Error handling** - Log channel failures but don't block entire sync
|
|
1866
|
+
|
|
1867
|
+
---
|
|
1868
|
+
|
|
1869
|
+
## Related Documentation
|
|
1870
|
+
|
|
1871
|
+
- **Single-source batch ingestion**: [SFTP XML Inventory Batch Template](./template-ingestion-sftp-xml-inventory-batch.md) - Simpler pattern for single data sources (GOLD STANDARD)
|
|
1872
|
+
- [Batch API Guide](../../../../../02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-06-batch-api.md) - Complete Batch API patterns and BPP documentation
|
|
1873
|
+
- [State Management](../../../../../02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-07-state-management.md) - VersoriKVAdapter and StateService usage
|
|
1874
|
+
- [Job Tracker](../../../../../02-CORE-GUIDES/advanced-services/advanced-services-job-tracker.md) - Job lifecycle tracking
|
|
1875
|
+
- [Universal Mapping](../../../../../02-CORE-GUIDES/advanced-services/advanced-services-readme.md) - Field transformation guide
|
|
1876
|
+
- [Error Handling Patterns](../../../../../03-PATTERN-GUIDES/error-handling/error-handling-readme.md) - Retry logic and exponential backoff
|
|
1877
|
+
- [File Operations](../../../../../03-PATTERN-GUIDES/file-operations/file-operations-readme.md) - KV state management patterns
|
|
1878
|
+
- [GraphQL Extraction](../../../../../02-CORE-GUIDES/advanced-services/advanced-services-readme.md) - Auto-pagination for Fluent inventory queries
|
|
1879
|
+
|
|
1880
|
+
---
|
|
1881
|
+
|
|
1882
|
+
[→ Back to Versori Scheduled Workflows](../../../../../02-CORE-GUIDES/advanced-services/advanced-services-readme.md) | [Versori Platform Guide →](../../../../../02-CORE-GUIDES/advanced-services/advanced-services-readme.md)
|