@fluentcommerce/ai-skills 0.1.0 → 0.2.0

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.

Potentially problematic release.


This version of @fluentcommerce/ai-skills might be problematic. Click here for more details.

Files changed (31) hide show
  1. package/README.md +10 -16
  2. package/content/cli/skills/fluent-bootstrap/SKILL.md +9 -0
  3. package/content/cli/skills/fluent-cli-reference/SKILL.md +0 -408
  4. package/content/cli/skills/fluent-module-deploy/SKILL.md +21 -127
  5. package/content/dev/agents/fluent-dev/AGENT.md +76 -0
  6. package/content/dev/agents/fluent-dev/agent.json +2 -12
  7. package/content/dev/agents/fluent-dev.md +5 -6
  8. package/content/dev/skills/fluent-build/SKILL.md +16 -38
  9. package/content/dev/skills/fluent-feature-plan/PLAN_TEMPLATE.md +9 -13
  10. package/content/dev/skills/fluent-feature-plan/SKILL.md +8 -14
  11. package/content/dev/skills/fluent-module-scaffold/SKILL.md +9 -78
  12. package/content/dev/skills/fluent-pre-deploy-check/SKILL.md +5 -23
  13. package/content/dev/skills/fluent-retailer-config/SKILL.md +9 -0
  14. package/content/dev/skills/fluent-rule-scaffold/SKILL.md +9 -0
  15. package/content/dev/skills/fluent-scope-decompose/SKILL.md +1 -1
  16. package/content/dev/skills/fluent-settings/SKILL.md +10 -0
  17. package/content/dev/skills/fluent-source-onboard/SKILL.md +9 -0
  18. package/content/dev/skills/fluent-sourcing/SKILL.md +1171 -0
  19. package/content/dev/skills/fluent-use-case-discover/SKILL.md +471 -0
  20. package/content/dev/skills/fluent-use-case-discover/SPEC_TEMPLATE.md +260 -0
  21. package/content/dev/skills/fluent-version-manage/SKILL.md +10 -0
  22. package/content/dev/skills/fluent-workflow-builder/SKILL.md +10 -3
  23. package/content/mcp-extn/agents/fluent-mcp.md +65 -2
  24. package/content/mcp-extn/skills/fluent-mcp-tools/SKILL.md +343 -4
  25. package/docs/CAPABILITY_MAP.md +3 -7
  26. package/docs/USE_CASES.md +4 -3
  27. package/docs/USE_CASES.pdf +0 -0
  28. package/metadata.json +8 -9
  29. package/package.json +2 -6
  30. package/content/dev/skills/fluent-data-module-scaffold/SKILL.md +0 -714
  31. package/content/dev/skills/fluent-workflow-deploy/SKILL.md +0 -267
@@ -0,0 +1,1171 @@
1
+ ---
2
+ name: fluent-sourcing
3
+ description: "Comprehensive reference for the Fluent Commerce Responsive Sourcing Framework. Covers sourcing profiles, strategies, conditions, criteria, scoring algorithms, permutation search, settings schema, workflow integration, custom extensions, and operational GraphQL patterns. Triggers on \"sourcing\", \"sourcing profile\", \"sourcing strategy\", \"sourcing condition\", \"sourcing criteria\", \"fulfilment allocation\", \"location scoring\", \"order sourcing\", \"where to fulfil\"."
4
+ user-invocable: true
5
+ allowed-tools: Bash, Read, Write, Edit, Glob, Grep
6
+ argument-hint: "[--profile <PROFILE>] [--retailer <RETAILER_REF>] [--operation query|create|debug]"
7
+ ---
8
+
9
+ # Fluent Sourcing Framework Reference
10
+
11
+ Expert reference for the Fluent Commerce Responsive Sourcing Framework (v2.1+). Covers sourcing profiles, strategies, conditions, criteria, scoring algorithms, permutation search, settings, workflow integration, custom extensions, and operational GraphQL patterns.
12
+
13
+ **Source:** `util-sourcing-2.1.0.jar` decompiled analysis + Fluent Commerce documentation.
14
+
15
+ ## Ownership Boundary
16
+
17
+ | Task | This Skill | Chain To |
18
+ |------|-----------|----------|
19
+ | Understand sourcing concepts, scoring, algorithms | Yes | — |
20
+ | Generate sourcing profile JSON configurations | Yes | — |
21
+ | Debug why a location was selected/excluded | Yes | `/fluent-trace` for event-level forensics |
22
+ | Build custom sourcing condition/criterion Java code | Yes (skeleton) | `/fluent-rule-scaffold` for full module wiring |
23
+ | Create/modify workflows that invoke sourcing | No | `/fluent-workflow-builder` |
24
+ | Deploy sourcing-related settings | No | `/fluent-settings` |
25
+ | Query live sourcing profiles via MCP | No | `/fluent-mcp-tools` |
26
+ | Trace sourcing event execution | No | `/fluent-trace` |
27
+ | Configure retailer locations/networks | No | `/fluent-retailer-config` |
28
+ | Run E2E sourcing tests | No | `/fluent-e2e-test` |
29
+
30
+ ## When to Use
31
+
32
+ - **"How does sourcing work?"** — Conceptual Architecture section
33
+ - **"What sourcing conditions/criteria are available?"** — Conditions and Criteria sections
34
+ - **"Why was this location selected/excluded?"** — Debugging & Troubleshooting section
35
+ - **"How do I configure a sourcing profile?"** — Sourcing Profiles section
36
+ - **"How do I add custom sourcing logic?"** — Custom Extension Guide section
37
+ - **"What settings control sourcing?"** — Settings Reference section
38
+ - **"How does sourcing connect to workflows?"** — Workflow Integration section
39
+ - **"How does the scoring algorithm work?"** — Scoring & Normalization subsection
40
+
41
+ ## Conceptual Architecture
42
+
43
+ ### Evaluation Flow
44
+
45
+ ```mermaid
46
+ graph TD
47
+ Start[Order Sourcing Request] --> Load[Load Sourcing Profile]
48
+ Load --> Strategies[Evaluate Strategies by Priority]
49
+ Strategies --> Condition{All Conditions Met?}
50
+ Condition -- No --> NextStrategy[Next Strategy]
51
+ NextStrategy --> Strategies
52
+ Condition -- Yes --> Type{Primary or Fallback?}
53
+ Type -- Primary --> MustFull[Must Source ALL Items]
54
+ Type -- Fallback --> CanPartial[Can Source PARTIAL Items]
55
+ MustFull --> Criteria[Execute Criteria Chain]
56
+ CanPartial --> Criteria
57
+ Criteria --> Exclusion[Exclusion Criteria: Remove Locations score = -1.0]
58
+ Exclusion --> Scoring[Sorter Criteria: Score Remaining Locations]
59
+ Scoring --> Normalize[Normalize Scores to 0.0 - 1.0]
60
+ Normalize --> Sort[Sort by Final Score DESC]
61
+ Sort --> Split{Items Need Split?}
62
+ Split -- Yes maxSplit > 1 --> Permutation[Permutation Search]
63
+ Split -- No single location --> Select[Select Top Location]
64
+ Permutation --> Plan[Build Fulfilment Plan]
65
+ Select --> Plan
66
+ Plan --> End[Return FulfilmentPlan]
67
+ ```
68
+
69
+ ### Entity Model
70
+
71
+ | Entity | Parent | Description | Key Fields |
72
+ |--------|--------|-------------|------------|
73
+ | **SourcingProfile** | — | Root container for sourcing configuration | `ref`, `status`, `catalogueRef`, `networkRef`, `maxSplit`, `virtualCatalogueRef` |
74
+ | **SourcingStrategy** | SourcingProfile | A prioritized rule definition with conditions and criteria | `ref`, `priority`, `status`, `type` (PRIMARY/FALLBACK) |
75
+ | **SourcingCondition** | SourcingStrategy | Boolean gatekeeper — determines IF a strategy applies | `name`, `type`, `params` |
76
+ | **SourcingCriterion** | SourcingStrategy | Numeric scorer/filter — EXCLUDES or RANKS a location | `name`, `type`, `params` |
77
+
78
+ ### Profile Lifecycle
79
+
80
+ Profiles follow a versioned lifecycle:
81
+
82
+ | Status | Description | Can Be Evaluated? |
83
+ |--------|-------------|-------------------|
84
+ | `DRAFT` | Under construction | No |
85
+ | `ACTIVE` | Live — used by workflow rules | Yes |
86
+ | `INACTIVE` | Archived — no longer evaluated | No |
87
+
88
+ Only **one version** of a profile (by ref) can be `ACTIVE` at a time. Activating a new version automatically deactivates the previous one.
89
+
90
+ ### SourcingContext Model
91
+
92
+ The `SourcingContext` is the runtime data object passed to conditions and criteria. It contains:
93
+
94
+ | Path | Type | Description |
95
+ |------|------|-------------|
96
+ | `order` | Order | The full order entity |
97
+ | `order.totalPrice` | Float | Order total value |
98
+ | `order.attributes[]` | List | Order-level custom attributes |
99
+ | `fulfilmentChoice` | Object | Selected fulfilment option |
100
+ | `fulfilmentChoice.type` | String | e.g., `HD`, `CC`, `SFS` |
101
+ | `fulfilmentChoice.deliveryAddress` | Address | Delivery destination (lat/lng for distance) |
102
+ | `items[]` | List | Order items to source |
103
+ | `items[].productRef` | String | Product reference |
104
+ | `items[].requestedQuantity` | Integer | Quantity needed |
105
+ | `locations[]` | List | Candidate locations from network |
106
+ | `locations[].ref` | String | Location reference |
107
+ | `locations[].type` | String | e.g., `WAREHOUSE`, `STORE` |
108
+ | `locations[].address` | Address | Location coordinates |
109
+ | `locations[].attributes[]` | List | Location custom attributes |
110
+ | `inventoryPositions[]` | List | Stock data per location per product |
111
+
112
+ Conditions use JSON path expressions to navigate this model. For example, `fulfilmentChoice.type` extracts the delivery type, `order.attributes[?(@.name=='priority')].value` extracts a custom attribute.
113
+
114
+ ## Sourcing Profiles
115
+
116
+ ### Profile Structure
117
+
118
+ A Sourcing Profile is configured as a GraphQL entity and loaded at runtime by the `CreateFulfilmentWithSourcingProfile` workflow rule.
119
+
120
+ ```json
121
+ {
122
+ "ref": "SOURCING_PROFILE_HD",
123
+ "status": "ACTIVE",
124
+ "retailerId": 1,
125
+ "catalogueRef": "DEFAULT:1",
126
+ "virtualCatalogueRef": "FC:DEFAULT:1",
127
+ "networkRef": "HD_NETWORK",
128
+ "maxSplit": 2,
129
+ "sourcingStrategies": [
130
+ {
131
+ "ref": "STRATEGY_HD_STANDARD",
132
+ "priority": 10,
133
+ "status": "ACTIVE",
134
+ "type": "PRIMARY",
135
+ "sourcingConditions": [],
136
+ "sourcingCriteria": []
137
+ }
138
+ ]
139
+ }
140
+ ```
141
+
142
+ **Field Reference:**
143
+
144
+ | Field | Required | Description |
145
+ |-------|----------|-------------|
146
+ | `ref` | Yes | Unique identifier for the profile |
147
+ | `status` | Yes | `DRAFT`, `ACTIVE`, or `INACTIVE` |
148
+ | `retailerId` | Yes | Owning retailer ID |
149
+ | `catalogueRef` | No | Product catalogue for inventory lookup |
150
+ | `virtualCatalogueRef` | No | Virtual catalogue (overrides catalogueRef) |
151
+ | `networkRef` | No | Default network for location candidates |
152
+ | `maxSplit` | No | Maximum number of fulfilment locations (default: 1) |
153
+ | `sourcingStrategies` | Yes | Ordered list of strategies |
154
+
155
+ ### Create a Sourcing Profile
156
+
157
+ See the **GraphQL Operations** section for the full `createSourcingProfile` mutation.
158
+
159
+ ### Activate a Sourcing Profile
160
+
161
+ Activation is typically done by updating the profile status to `ACTIVE`. See **GraphQL Operations > Update & Activate**.
162
+
163
+ ## Sourcing Strategies
164
+
165
+ ### Primary vs Fallback
166
+
167
+ | Aspect | Primary | Fallback |
168
+ |--------|---------|----------|
169
+ | **Type** | `PRIMARY` | `FALLBACK` |
170
+ | **Requirement** | Must source ALL order items | Can source PARTIAL items |
171
+ | **Evaluation** | Tried first (lower priority number = higher priority) | Tried only if no Primary strategy can source all items |
172
+ | **Result** | Single fulfilment plan for entire order | May produce multiple partial fulfilments |
173
+ | **Use Case** | Standard fulfilment from one location | Split shipment, partial availability |
174
+
175
+ ### Strategy Configuration
176
+
177
+ | Field | Type | Description |
178
+ |-------|------|-------------|
179
+ | `ref` | String | Unique strategy identifier |
180
+ | `priority` | Integer | Evaluation order (lower = first). Must be unique within a profile. |
181
+ | `status` | String | `ACTIVE` or `INACTIVE` |
182
+ | `type` | String | `PRIMARY` or `FALLBACK` |
183
+ | `sourcingConditions` | Array | Conditions that must ALL pass for this strategy to apply |
184
+ | `sourcingCriteria` | Array | Criteria chain for filtering and ranking locations |
185
+
186
+ ### Multi-Strategy Example
187
+
188
+ ```json
189
+ {
190
+ "ref": "SOURCING_PROFILE_MULTI",
191
+ "maxSplit": 3,
192
+ "sourcingStrategies": [
193
+ {
194
+ "ref": "HD_EXPRESS",
195
+ "priority": 10,
196
+ "status": "ACTIVE",
197
+ "type": "PRIMARY",
198
+ "sourcingConditions": [
199
+ {
200
+ "name": "IsExpress",
201
+ "type": "fc.sourcing.condition.path",
202
+ "params": {
203
+ "path": "fulfilmentChoice.type",
204
+ "operator": "equals",
205
+ "value": "HD_EXPRESS"
206
+ }
207
+ }
208
+ ],
209
+ "sourcingCriteria": [
210
+ {
211
+ "name": "NearbyOnly",
212
+ "type": "fc.sourcing.criterion.locationDistanceExclusion",
213
+ "params": { "value": 25.0, "valueUnit": "KILOMETRES" }
214
+ },
215
+ {
216
+ "name": "ClosestFirst",
217
+ "type": "fc.sourcing.criterion.locationDistance"
218
+ }
219
+ ]
220
+ },
221
+ {
222
+ "ref": "HD_STANDARD",
223
+ "priority": 20,
224
+ "status": "ACTIVE",
225
+ "type": "PRIMARY",
226
+ "sourcingConditions": [
227
+ {
228
+ "name": "IsHD",
229
+ "type": "fc.sourcing.condition.path",
230
+ "params": {
231
+ "path": "fulfilmentChoice.type",
232
+ "operator": "in",
233
+ "value": ["HD", "HD_EXPRESS"]
234
+ }
235
+ }
236
+ ],
237
+ "sourcingCriteria": [
238
+ {
239
+ "name": "HasStock",
240
+ "type": "fc.sourcing.criterion.inventoryAvailabilityExclusion"
241
+ },
242
+ {
243
+ "name": "ByDistance",
244
+ "type": "fc.sourcing.criterion.locationDistance"
245
+ },
246
+ {
247
+ "name": "ByCapacity",
248
+ "type": "fc.sourcing.criterion.locationDailyCapacity"
249
+ }
250
+ ]
251
+ },
252
+ {
253
+ "ref": "HD_FALLBACK_SPLIT",
254
+ "priority": 100,
255
+ "status": "ACTIVE",
256
+ "type": "FALLBACK",
257
+ "sourcingConditions": [],
258
+ "sourcingCriteria": [
259
+ {
260
+ "name": "HasStock",
261
+ "type": "fc.sourcing.criterion.inventoryAvailabilityExclusion"
262
+ },
263
+ {
264
+ "name": "ByStock",
265
+ "type": "fc.sourcing.criterion.inventoryAvailability"
266
+ }
267
+ ]
268
+ }
269
+ ]
270
+ }
271
+ ```
272
+
273
+ ## Sourcing Conditions
274
+
275
+ ### Condition Model
276
+
277
+ Conditions are boolean gates on a strategy. **ALL conditions must pass** (AND logic) for the strategy to be evaluated. If any condition fails, the engine skips to the next strategy.
278
+
279
+ There is one built-in condition type: `fc.sourcing.condition.path`.
280
+
281
+ **Class:** `com.fluentcommerce.util.sourcing.condition.DefaultSourcingCondition`
282
+
283
+ This condition extracts a value from the `SourcingContext` using a JSON path expression, then applies an operator against an expected value.
284
+
285
+ ### Operators
286
+
287
+ (Source: `SourcingConditionOperatorRegistry.java`)
288
+
289
+ | Operator | Description | Value Type | Example |
290
+ |----------|-------------|------------|---------|
291
+ | `equals` | Exact match (case-sensitive) | Any | `"value": "HD"` |
292
+ | `not_equals` | Not equal | Any | `"value": "CC"` |
293
+ | `greater_than` | Numeric greater than | Number | `"value": 100.0` |
294
+ | `greater_than_or_equals` | Numeric >= | Number | `"value": 50` |
295
+ | `less_than` | Numeric less than | Number | `"value": 10` |
296
+ | `less_than_or_equals` | Numeric <= | Number | `"value": 5` |
297
+ | `in` | Value is in list | Array | `"value": ["HD", "CC"]` |
298
+ | `not_in` | Value is not in list | Array | `"value": ["PICKUP"]` |
299
+ | `between` | Numeric range (inclusive) | Array[2] | `"value": [10, 100]` |
300
+ | `exists` | Path exists (non-null) | Boolean | `"value": true` |
301
+
302
+ > **Important:** Operators are **case-sensitive, lowercase**. Using `EQUALS` or `Equals` will not match.
303
+
304
+ ### conditionScope
305
+
306
+ When the JSON path resolves to an **array** (e.g., `items[].productRef`), the `conditionScope` parameter controls how the condition evaluates:
307
+
308
+ | Scope | Behavior |
309
+ |-------|----------|
310
+ | `ALL` | Every element in the array must match |
311
+ | `ANY` | At least one element must match |
312
+ | `NONE` | No element may match |
313
+
314
+ If `conditionScope` is omitted on a non-array path, it is ignored.
315
+
316
+ ### Condition Examples
317
+
318
+ **Simple equality:**
319
+ ```json
320
+ {
321
+ "name": "IsHomeDelivery",
322
+ "type": "fc.sourcing.condition.path",
323
+ "params": {
324
+ "path": "fulfilmentChoice.type",
325
+ "operator": "equals",
326
+ "value": "HD"
327
+ }
328
+ }
329
+ ```
330
+
331
+ **Numeric range:**
332
+ ```json
333
+ {
334
+ "name": "HighValueOrder",
335
+ "type": "fc.sourcing.condition.path",
336
+ "params": {
337
+ "path": "order.totalPrice",
338
+ "operator": "greater_than",
339
+ "value": 200.0
340
+ }
341
+ }
342
+ ```
343
+
344
+ **List membership:**
345
+ ```json
346
+ {
347
+ "name": "IsDeliveryType",
348
+ "type": "fc.sourcing.condition.path",
349
+ "params": {
350
+ "path": "fulfilmentChoice.type",
351
+ "operator": "in",
352
+ "value": ["HD", "HD_EXPRESS", "SFS"]
353
+ }
354
+ }
355
+ ```
356
+
357
+ **Custom attribute with exists:**
358
+ ```json
359
+ {
360
+ "name": "HasPriority",
361
+ "type": "fc.sourcing.condition.path",
362
+ "params": {
363
+ "path": "order.attributes[?(@.name=='priority')].value",
364
+ "operator": "exists",
365
+ "value": true
366
+ }
367
+ }
368
+ ```
369
+
370
+ ### Custom Conditions
371
+
372
+ To add custom condition logic beyond `fc.sourcing.condition.path`, see the **Custom Extension Guide** section below. Custom conditions implement the `SourcingCondition` interface and are registered in the `SourcingConditionTypeRegistry`.
373
+
374
+ ## Sourcing Criteria
375
+
376
+ ### Criteria Model
377
+
378
+ Criteria filter and rank candidate locations. They execute **in order** within a strategy, building up a refined and scored list.
379
+
380
+ Each criterion returns a `float`:
381
+ - **`-1.0`** — Exclude the location (removed from candidates)
382
+ - **`0.0+`** — A score (higher = better)
383
+
384
+ Criteria are classified by tag:
385
+ - **Exclusion** — Returns `-1.0` to remove locations that fail a hard constraint
386
+ - **Sorter** — Returns a numeric score to rank locations
387
+
388
+ ### Built-in Criteria Reference
389
+
390
+ (Source: `SourcingCriteriaTypeRegistry.java`)
391
+
392
+ | Type | Tag | Description | Key Params |
393
+ |------|-----|-------------|------------|
394
+ | `fc.sourcing.criterion.locationDistanceExclusion` | Exclusion | Exclude locations beyond a distance threshold | `value` (max distance), `valueUnit` (`KILOMETRES` or `MILES`) |
395
+ | `fc.sourcing.criterion.locationTypeExclusion` | Exclusion | Exclude locations of specified types | `value` (list of type strings, e.g., `["STORE"]`) |
396
+ | `fc.sourcing.criterion.locationNetworkExclusion` | Exclusion | Exclude locations not in the specified network | `value` (network ref string) |
397
+ | `fc.sourcing.criterion.inventoryAvailabilityExclusion` | Exclusion | Exclude locations with insufficient stock for all order items | *(none)* |
398
+ | `fc.sourcing.criterion.locationDistance` | Sorter | Score by distance from delivery address (closest = highest score) | *(none)* |
399
+ | `fc.sourcing.criterion.locationDistanceBanded` | Sorter | Score by distance using configurable bands | `bands` (list of `{from, to, score}`) |
400
+ | `fc.sourcing.criterion.locationDailyCapacity` | Sorter | Score by remaining daily fulfilment capacity | *(none)* |
401
+ | `fc.sourcing.criterion.networkPriority` | Sorter | Score by the location's network priority field | *(none)* |
402
+ | `fc.sourcing.criterion.inventoryAvailability` | Sorter | Score by stock quantity (more stock = higher score) | *(none)* |
403
+ | `fc.sourcing.criterion.inventoryAvailabilityBanded` | Sorter | Score by stock quantity using configurable bands | `bands` (list of `{from, to, score}`) |
404
+ | `fc.sourcing.criterion.orderValue` | Sorter | Score by order value handling capability | *(none)* |
405
+
406
+ > **Note on Parameter Names:** In `util-sourcing-2.1.0`, the JSON parsing logic uses generic keys like `"value"` and `"valueUnit"` (via `params.get("value")`). Newer versions may use descriptive names like `maxDistance`, `distanceUnit`, `excludedTypes`. Always check your specific version.
407
+
408
+ ### Scoring and Normalization
409
+
410
+ (Source: `SourcingCriteriaUtils.java`, `BaseSourcingCriterion.normalize()`)
411
+
412
+ **Step 1: Raw Scoring**
413
+ Each criterion calculates a raw score for every candidate location.
414
+
415
+ **Step 2: Normalization**
416
+ Raw scores are normalized to a `0.0` – `1.0` range across all candidates for that criterion:
417
+
418
+ ```
419
+ normalizedScore = (rawScore - minScore) / (maxScore - minScore)
420
+ ```
421
+
422
+ If all scores are equal (`maxScore == minScore`), all locations get `1.0`.
423
+
424
+ **Step 3: Distance Inversion**
425
+ `LocationDistanceCriterion` overrides normalization — it **inverts** the score so that shorter distance = higher score:
426
+
427
+ ```
428
+ normalizedScore = 1.0 - ((distance - minDistance) / (maxDistance - minDistance))
429
+ ```
430
+
431
+ **Step 4: Aggregation**
432
+ After all criteria run, each location has a list of normalized scores. These are combined (typically averaged) to produce a final composite score. Locations are sorted by final score descending.
433
+
434
+ **Worked Example:**
435
+
436
+ Three locations scored by `locationDistance` (raw = km from delivery address):
437
+ | Location | Raw Distance | After Normalization | After Inversion |
438
+ |----------|-------------|--------------------|-----------------|
439
+ | Store A | 5 km | (5-5)/(50-5) = 0.0 | 1.0 - 0.0 = **1.0** |
440
+ | Store B | 20 km | (20-5)/(50-5) = 0.33 | 1.0 - 0.33 = **0.67** |
441
+ | Store C | 50 km | (50-5)/(50-5) = 1.0 | 1.0 - 1.0 = **0.0** |
442
+
443
+ Result: Store A (closest) wins with score 1.0.
444
+
445
+ ### Banding Pattern
446
+
447
+ Banded criteria use configurable ranges instead of continuous scoring:
448
+
449
+ ```json
450
+ {
451
+ "name": "DistanceBands",
452
+ "type": "fc.sourcing.criterion.locationDistanceBanded",
453
+ "params": {
454
+ "bands": [
455
+ { "from": 0, "to": 10, "score": 1.0 },
456
+ { "from": 10, "to": 50, "score": 0.5 },
457
+ { "from": 50, "to": 100, "score": 0.1 }
458
+ ]
459
+ }
460
+ }
461
+ ```
462
+
463
+ Locations falling outside all bands receive a score of `0.0`. The `inventoryAvailabilityBanded` criterion uses the same pattern with stock quantity thresholds.
464
+
465
+ ### Exclusion Pattern
466
+
467
+ Exclusion criteria are hard filters — they return `-1.0` for locations that fail the constraint, immediately removing them from the candidate pool. Exclusions should always be placed **before** sorters in the criteria chain to reduce the scoring workload.
468
+
469
+ ```json
470
+ {
471
+ "name": "ExcludeFarLocations",
472
+ "type": "fc.sourcing.criterion.locationDistanceExclusion",
473
+ "params": {
474
+ "value": 100.0,
475
+ "valueUnit": "KILOMETRES"
476
+ }
477
+ }
478
+ ```
479
+
480
+ ### Custom Criteria
481
+
482
+ To add custom scoring or filtering logic, see the **Custom Extension Guide** section below. Custom criteria extend `BaseSourcingCriterion` and are registered in the `SourcingCriteriaTypeRegistry`.
483
+
484
+ ## Permutation Search
485
+
486
+ ### Algorithm Overview
487
+
488
+ (Source: `SourcingUtils.findPlanForAllItems()`)
489
+
490
+ When `maxSplit > 1`, the engine performs a **permutation search** to find the optimal combination of locations:
491
+
492
+ 1. **Start with split count = 1** — Try to source all items from a single location
493
+ 2. **Increment split count** — If no single location works, try 2-location combinations, then 3, etc.
494
+ 3. **Stop at maxSplit** — Never exceed the configured maximum
495
+ 4. **Fewer splits always wins** — A 1-location plan is always preferred over a 2-location plan, regardless of scores
496
+
497
+ Within each split level, the engine:
498
+ - Takes the top-scored locations from the criteria chain
499
+ - Tests all permutations to find combinations that can collectively source all items
500
+ - Selects the combination with the highest aggregate score
501
+
502
+ ### maxSplit Behavior
503
+
504
+ | maxSplit | Behavior |
505
+ |----------|----------|
506
+ | `1` (default) | Single location must have all items. No split. |
507
+ | `2` | Try 1 location first, then 2-location combos |
508
+ | `3+` | Try 1, then 2, ..., up to N. First successful split level wins. |
509
+ | Not set | Defaults to 1 |
510
+
511
+ > **Performance Note:** Permutation search is combinatorial. With 100 candidate locations and `maxSplit=3`, the search space is large. Use exclusion criteria aggressively to reduce the candidate pool before permutation.
512
+
513
+ ## Settings Reference
514
+
515
+ ### Sourcing Settings Keys
516
+
517
+ (Source: `SourcingConditionTypeRegistry.java`, `SourcingCriteriaTypeRegistry.java`)
518
+
519
+ | Setting Key | Scope | Purpose |
520
+ |-------------|-------|---------|
521
+ | `fc.rubix.order.sourcing.conditions` | GLOBAL | Registry of built-in sourcing condition types |
522
+ | `fc.rubix.order.sourcing.conditions.custom` | ACCOUNT / RETAILER | Registry of custom sourcing condition types |
523
+ | `fc.rubix.order.sourcing.criteria` | GLOBAL | Registry of built-in sourcing criteria types |
524
+ | `fc.rubix.order.sourcing.criteria.custom` | ACCOUNT / RETAILER | Registry of custom sourcing criteria types |
525
+
526
+ ### Querying Settings
527
+
528
+ Use MCP tools to inspect current sourcing settings:
529
+
530
+ ```
531
+ Tool: graphql.query
532
+ Query: { settings(context: "RETAILER", contextId: <RETAILER_ID>) { edges { node { name value } } } }
533
+ Filter: name starts with "fc.rubix.order.sourcing"
534
+ ```
535
+
536
+ ### Custom Schema Registration
537
+
538
+ To register a custom condition or criterion type, create a setting at the `ACCOUNT` or `RETAILER` scope with the appropriate key. The value is a JSON array of type definitions:
539
+
540
+ ```json
541
+ {
542
+ "name": "fc.rubix.order.sourcing.criteria.custom",
543
+ "context": "ACCOUNT",
544
+ "contextId": 1,
545
+ "value": "[{\"name\":\"Custom Carrier Preference\",\"type\":\"ext.sourcing.criterion.preferredCarrier\",\"class\":\"com.example.PreferredCarrierCriterion\"}]"
546
+ }
547
+ ```
548
+
549
+ ## Workflow Integration
550
+
551
+ ### Workflow Rules
552
+
553
+ | Rule | Class | Purpose |
554
+ |------|-------|---------|
555
+ | `CreateFulfilmentWithSourcingProfile` | `com.fluentcommerce.rule.sourcing.CreateFulfilmentWithSourcingProfile` | Main rule — loads profile, runs evaluation, creates fulfilment |
556
+ | `CreatePartialFulfilmentWithSourcingProfile` | `com.fluentcommerce.rule.sourcing.CreatePartialFulfilmentWithSourcingProfile` | Handles partial sourcing from FALLBACK strategies |
557
+ | `CreateRejectedFulfilment` | `com.fluentcommerce.rule.sourcing.CreateRejectedFulfilment` | Creates a rejected fulfilment when no strategy can source |
558
+
559
+ ### Typical Workflow Pattern
560
+
561
+ ```mermaid
562
+ sequenceDiagram
563
+ participant WF as Order Workflow
564
+ participant Rule as CreateFulfilmentWithSourcingProfile
565
+ participant SP as SourcingProfile
566
+ participant Ctx as SourcingContext
567
+ participant Loc as Location Candidates
568
+
569
+ WF->>Rule: Event: SourceOrder (status: BOOKED)
570
+ Rule->>SP: Load profile by ref from rule props
571
+ Rule->>Ctx: Build SourcingContext (order, items, fulfilmentChoice)
572
+ Rule->>Loc: Query candidate locations from network
573
+ SP->>Ctx: Evaluate strategies (priority order)
574
+ Note over Ctx: For each strategy:<br/>1. Check all conditions<br/>2. Run criteria chain<br/>3. Score & rank locations
575
+ Ctx-->>Rule: FulfilmentPlan (locations + items)
576
+ alt Plan found
577
+ Rule->>WF: Create FULFILMENT entity
578
+ else No plan
579
+ Rule->>WF: Trigger CreateRejectedFulfilment
580
+ end
581
+ ```
582
+
583
+ ### Rule Configuration
584
+
585
+ The sourcing rule is configured in a workflow ruleset with the profile ref as a property:
586
+
587
+ ```json
588
+ {
589
+ "name": "RULESET::SOURCE_ORDER",
590
+ "description": "Source the order using the responsive sourcing profile",
591
+ "type": "ORDER",
592
+ "eventType": "NORMAL",
593
+ "rules": [
594
+ {
595
+ "name": "{{fluent.rule.sourcing.CreateFulfilmentWithSourcingProfile}}",
596
+ "props": {
597
+ "sourcingProfileRef": "SOURCING_PROFILE_HD",
598
+ "eventName": "FulfilmentCreated"
599
+ }
600
+ }
601
+ ],
602
+ "triggers": [
603
+ {
604
+ "status": "BOOKED"
605
+ }
606
+ ],
607
+ "userActions": []
608
+ }
609
+ ```
610
+
611
+ ## GraphQL Operations
612
+
613
+ ### Query Profile with Full Strategy Tree
614
+
615
+ ```graphql
616
+ query GetSourcingProfile($ref: String!) {
617
+ sourcingProfile(ref: $ref) {
618
+ id
619
+ ref
620
+ status
621
+ catalogueRef
622
+ virtualCatalogueRef
623
+ networkRef
624
+ maxSplit
625
+ sourcingStrategies {
626
+ edges {
627
+ node {
628
+ id
629
+ ref
630
+ priority
631
+ status
632
+ type
633
+ sourcingConditions {
634
+ edges {
635
+ node {
636
+ id
637
+ name
638
+ type
639
+ params
640
+ }
641
+ }
642
+ }
643
+ sourcingCriteria {
644
+ edges {
645
+ node {
646
+ id
647
+ name
648
+ type
649
+ params
650
+ }
651
+ }
652
+ }
653
+ }
654
+ }
655
+ }
656
+ }
657
+ }
658
+ ```
659
+
660
+ ### Create Complete Profile
661
+
662
+ ```graphql
663
+ mutation CreateSourcingProfile($input: CreateSourcingProfileInput!) {
664
+ createSourcingProfile(input: $input) {
665
+ id
666
+ ref
667
+ status
668
+ }
669
+ }
670
+ ```
671
+
672
+ **Input variable structure:**
673
+ ```json
674
+ {
675
+ "input": {
676
+ "ref": "SOURCING_PROFILE_HD",
677
+ "retailerId": 1,
678
+ "status": "DRAFT",
679
+ "catalogueRef": "DEFAULT:1",
680
+ "networkRef": "HD_NETWORK",
681
+ "maxSplit": 2,
682
+ "sourcingStrategies": [
683
+ {
684
+ "ref": "STRATEGY_HD_STANDARD",
685
+ "priority": 10,
686
+ "status": "ACTIVE",
687
+ "type": "PRIMARY",
688
+ "sourcingConditions": [
689
+ {
690
+ "name": "IsHD",
691
+ "type": "fc.sourcing.condition.path",
692
+ "params": "{\"path\":\"fulfilmentChoice.type\",\"operator\":\"equals\",\"value\":\"HD\"}"
693
+ }
694
+ ],
695
+ "sourcingCriteria": [
696
+ {
697
+ "name": "HasStock",
698
+ "type": "fc.sourcing.criterion.inventoryAvailabilityExclusion",
699
+ "params": "{}"
700
+ },
701
+ {
702
+ "name": "ByDistance",
703
+ "type": "fc.sourcing.criterion.locationDistance",
704
+ "params": "{}"
705
+ }
706
+ ]
707
+ }
708
+ ]
709
+ }
710
+ }
711
+ ```
712
+
713
+ > **Note:** The `params` field in GraphQL mutations is a **JSON string**, not a JSON object. Stringify the params before sending.
714
+
715
+ ### Update & Activate
716
+
717
+ ```graphql
718
+ mutation UpdateSourcingProfile($input: UpdateSourcingProfileInput!) {
719
+ updateSourcingProfile(input: $input) {
720
+ id
721
+ ref
722
+ status
723
+ }
724
+ }
725
+ ```
726
+
727
+ To activate a profile:
728
+ ```json
729
+ {
730
+ "input": {
731
+ "id": "<PROFILE_ID>",
732
+ "status": "ACTIVE"
733
+ }
734
+ }
735
+ ```
736
+
737
+ ### Required Permissions
738
+
739
+ | Operation | Permission |
740
+ |-----------|-----------|
741
+ | Query profiles | `SOURCING_PROFILE:READ` |
742
+ | Create profile | `SOURCING_PROFILE:CREATE` |
743
+ | Update profile | `SOURCING_PROFILE:UPDATE` |
744
+ | Delete profile | `SOURCING_PROFILE:DELETE` |
745
+
746
+ ## Common Use Cases
747
+
748
+ ### 1. Region-Specific Sourcing
749
+
750
+ Different strategies for different delivery regions:
751
+
752
+ ```json
753
+ {
754
+ "sourcingStrategies": [
755
+ {
756
+ "ref": "METRO_EXPRESS",
757
+ "priority": 10,
758
+ "type": "PRIMARY",
759
+ "sourcingConditions": [
760
+ {
761
+ "name": "MetroZone",
762
+ "type": "fc.sourcing.condition.path",
763
+ "params": {
764
+ "path": "fulfilmentChoice.deliveryAddress.attributes[?(@.name=='zone')].value",
765
+ "operator": "equals",
766
+ "value": "METRO"
767
+ }
768
+ }
769
+ ],
770
+ "sourcingCriteria": [
771
+ { "name": "Within10km", "type": "fc.sourcing.criterion.locationDistanceExclusion", "params": { "value": 10.0, "valueUnit": "KILOMETRES" } },
772
+ { "name": "ByCapacity", "type": "fc.sourcing.criterion.locationDailyCapacity" }
773
+ ]
774
+ },
775
+ {
776
+ "ref": "REGIONAL_STANDARD",
777
+ "priority": 20,
778
+ "type": "PRIMARY",
779
+ "sourcingConditions": [],
780
+ "sourcingCriteria": [
781
+ { "name": "HasStock", "type": "fc.sourcing.criterion.inventoryAvailabilityExclusion" },
782
+ { "name": "ByDistance", "type": "fc.sourcing.criterion.locationDistance" }
783
+ ]
784
+ }
785
+ ]
786
+ }
787
+ ```
788
+
789
+ ### 2. Ship-from-Store Priority
790
+
791
+ Prefer stores over warehouses when stock is available:
792
+
793
+ ```json
794
+ {
795
+ "sourcingCriteria": [
796
+ { "name": "HasStock", "type": "fc.sourcing.criterion.inventoryAvailabilityExclusion" },
797
+ { "name": "ExcludeWarehouses", "type": "fc.sourcing.criterion.locationTypeExclusion", "params": { "value": ["WAREHOUSE"] } },
798
+ { "name": "ByDistance", "type": "fc.sourcing.criterion.locationDistance" }
799
+ ]
800
+ }
801
+ ```
802
+
803
+ Note: This excludes warehouses entirely. For a softer approach, use `networkPriority` with stores in a higher-priority network.
804
+
805
+ ### 3. Seasonal Capacity Management
806
+
807
+ Use banded criteria to prefer locations with high remaining capacity during peak season:
808
+
809
+ ```json
810
+ {
811
+ "sourcingCriteria": [
812
+ { "name": "HasStock", "type": "fc.sourcing.criterion.inventoryAvailabilityExclusion" },
813
+ {
814
+ "name": "CapacityBands",
815
+ "type": "fc.sourcing.criterion.inventoryAvailabilityBanded",
816
+ "params": {
817
+ "bands": [
818
+ { "from": 100, "to": 999999, "score": 1.0 },
819
+ { "from": 50, "to": 99, "score": 0.7 },
820
+ { "from": 10, "to": 49, "score": 0.3 },
821
+ { "from": 0, "to": 9, "score": 0.0 }
822
+ ]
823
+ }
824
+ },
825
+ { "name": "ByDistance", "type": "fc.sourcing.criterion.locationDistance" }
826
+ ]
827
+ }
828
+ ```
829
+
830
+ ### 4. Click-and-Collect (Same Store)
831
+
832
+ For CC orders, source from the selected pickup location:
833
+
834
+ ```json
835
+ {
836
+ "sourcingStrategies": [
837
+ {
838
+ "ref": "CC_PICKUP",
839
+ "priority": 10,
840
+ "type": "PRIMARY",
841
+ "sourcingConditions": [
842
+ {
843
+ "name": "IsCC",
844
+ "type": "fc.sourcing.condition.path",
845
+ "params": { "path": "fulfilmentChoice.type", "operator": "equals", "value": "CC" }
846
+ }
847
+ ],
848
+ "sourcingCriteria": [
849
+ { "name": "HasStock", "type": "fc.sourcing.criterion.inventoryAvailabilityExclusion" },
850
+ {
851
+ "name": "PickupStoreOnly",
852
+ "type": "fc.sourcing.criterion.locationNetworkExclusion",
853
+ "params": { "value": "CC_NETWORK" }
854
+ }
855
+ ]
856
+ }
857
+ ]
858
+ }
859
+ ```
860
+
861
+ ## Debugging and Troubleshooting
862
+
863
+ ### Common Failures
864
+
865
+ | Symptom | Probable Cause | Diagnosis |
866
+ |---------|----------------|-----------|
867
+ | All strategies skipped | Conditions don't match context | Check operator case (must be lowercase), verify JSON path resolves, check `conditionScope` for array paths |
868
+ | All locations excluded | Exclusion criteria too aggressive | Check `locationDistanceExclusion` threshold, verify inventory data exists, check location types |
869
+ | Wrong location selected | Scoring/normalization issue | Review criteria order, check if distance inversion is being applied correctly |
870
+ | `ClassNotFoundException` | Custom type not registered | Verify setting `fc.rubix.order.sourcing.criteria.custom` exists at correct scope |
871
+ | Parameter ignored | Wrong JSON key name | Check if source expects `"value"` vs descriptive name for your version |
872
+ | Profile not loaded | Wrong ref in rule props | Verify `sourcingProfileRef` in workflow ruleset matches profile `ref` exactly |
873
+ | Partial fulfilment not created | No FALLBACK strategy | Add a FALLBACK strategy for split scenarios |
874
+
875
+ ### Diagnosis Patterns
876
+
877
+ **1. Verify profile is active and loadable:**
878
+ ```
879
+ Tool: graphql.query
880
+ Query: { sourcingProfile(ref: "SOURCING_PROFILE_HD") { id ref status maxSplit } }
881
+ ```
882
+
883
+ **2. Check strategy conditions match runtime context:**
884
+ ```
885
+ Tool: event.flowInspect
886
+ entityType: ORDER
887
+ entityId: <ORDER_ID>
888
+ eventName: SourceOrder
889
+ compact: false
890
+ ```
891
+
892
+ Review the flow inspection output for condition evaluation results.
893
+
894
+ **3. Verify inventory data exists for candidate locations:**
895
+ ```
896
+ Tool: graphql.query
897
+ Query: {
898
+ inventoryPositions(
899
+ catalogue: { ref: "DEFAULT:1" }
900
+ productRef: "<PRODUCT_REF>"
901
+ ) {
902
+ edges { node { locationRef quantity status } }
903
+ }
904
+ }
905
+ ```
906
+
907
+ **4. Check settings for custom type registration:**
908
+ ```
909
+ Tool: graphql.query
910
+ Query: {
911
+ settings(
912
+ context: "ACCOUNT"
913
+ contextId: 1
914
+ name: "fc.rubix.order.sourcing.criteria.custom"
915
+ ) { edges { node { name value } } }
916
+ }
917
+ ```
918
+
919
+ ## Key Java Classes
920
+
921
+ (Source: decompiled `util-sourcing-2.1.0.jar`)
922
+
923
+ | Class | Package | Purpose |
924
+ |-------|---------|---------|
925
+ | `SourcingUtils` | `com.fluentcommerce.util.sourcing` | Main orchestrator — `findPlanBasedOnStrategies()`, `findPlanForAllItems()` |
926
+ | `SourcingContextUtils` | `com.fluentcommerce.util.sourcing` | Builds and manages the `SourcingContext` runtime object |
927
+ | `SourcingConditionUtils` | `com.fluentcommerce.util.sourcing.condition` | Evaluates conditions against context using operator registry |
928
+ | `SourcingCriteriaUtils` | `com.fluentcommerce.util.sourcing.criteria` | Executes criteria chain, normalization, aggregation |
929
+ | `SourcingConditionTypeRegistry` | `com.fluentcommerce.util.sourcing.condition` | Maps condition type strings to implementing classes |
930
+ | `SourcingCriteriaTypeRegistry` | `com.fluentcommerce.util.sourcing.criteria` | Maps criterion type strings to implementing classes |
931
+ | `LocationUtils` | `com.fluentcommerce.util.sourcing` | Haversine distance calculation, 10-minute location cache |
932
+ | `OrderUtils` | `com.fluentcommerce.util.sourcing` | Order item extraction and quantity calculation |
933
+
934
+ ### Extension Points
935
+
936
+ | Interface / Base Class | Implement For |
937
+ |----------------------|---------------|
938
+ | `SourcingCondition` | Custom condition type (boolean gate) |
939
+ | `BaseSourcingCriterion` | Custom criterion type (scorer/filter) |
940
+ | `SourcingConditionOperator` | Custom comparison operator |
941
+
942
+ ## Custom Extension Guide
943
+
944
+ ### Building a Custom Condition
945
+
946
+ **Step 1: Implement `SourcingCondition` interface**
947
+
948
+ ```java
949
+ package com.example.sourcing.condition;
950
+
951
+ import com.fluentcommerce.util.sourcing.condition.SourcingCondition;
952
+ import com.fluentcommerce.util.sourcing.model.SourcingContext;
953
+ import com.fasterxml.jackson.databind.JsonNode;
954
+
955
+ public class GeofenceCondition implements SourcingCondition {
956
+
957
+ private double minLat;
958
+ private double maxLat;
959
+ private double minLng;
960
+ private double maxLng;
961
+
962
+ @Override
963
+ public void init(JsonNode params) {
964
+ this.minLat = params.get("minLat").asDouble();
965
+ this.maxLat = params.get("maxLat").asDouble();
966
+ this.minLng = params.get("minLng").asDouble();
967
+ this.maxLng = params.get("maxLng").asDouble();
968
+ }
969
+
970
+ @Override
971
+ public boolean evaluate(SourcingContext context) {
972
+ double lat = context.getFulfilmentChoice()
973
+ .getDeliveryAddress().getLatitude();
974
+ double lng = context.getFulfilmentChoice()
975
+ .getDeliveryAddress().getLongitude();
976
+
977
+ return lat >= minLat && lat <= maxLat
978
+ && lng >= minLng && lng <= maxLng;
979
+ }
980
+ }
981
+ ```
982
+
983
+ **Step 2: Register in `SourcingConditionTypeRegistry`**
984
+
985
+ Add to your module's initialization:
986
+ ```java
987
+ SourcingConditionTypeRegistry.register(
988
+ "ext.sourcing.condition.geofence",
989
+ GeofenceCondition.class
990
+ );
991
+ ```
992
+
993
+ **Step 3: Create the setting**
994
+
995
+ Register at ACCOUNT or RETAILER scope:
996
+ ```json
997
+ {
998
+ "name": "fc.rubix.order.sourcing.conditions.custom",
999
+ "context": "ACCOUNT",
1000
+ "contextId": 1,
1001
+ "value": "[{\"name\":\"Geofence\",\"type\":\"ext.sourcing.condition.geofence\",\"class\":\"com.example.sourcing.condition.GeofenceCondition\"}]"
1002
+ }
1003
+ ```
1004
+
1005
+ **Step 4: Use in a strategy**
1006
+
1007
+ ```json
1008
+ {
1009
+ "name": "InSydneyMetro",
1010
+ "type": "ext.sourcing.condition.geofence",
1011
+ "params": {
1012
+ "minLat": -34.0,
1013
+ "maxLat": -33.5,
1014
+ "minLng": 150.8,
1015
+ "maxLng": 151.4
1016
+ }
1017
+ }
1018
+ ```
1019
+
1020
+ ### Building a Custom Criterion
1021
+
1022
+ **Step 1: Extend `BaseSourcingCriterion`**
1023
+
1024
+ ```java
1025
+ package com.example.sourcing.criterion;
1026
+
1027
+ import com.fluentcommerce.util.sourcing.criterion.BaseSourcingCriterion;
1028
+ import com.fluentcommerce.util.sourcing.criterion.annotation.SourcingCriterionInfo;
1029
+ import com.fluentcommerce.util.sourcing.criterion.annotation.SourcingCriterionParam;
1030
+ import com.fluentcommerce.util.sourcing.criterion.annotation.SourcingCriterionParamSelectComponentOption;
1031
+ import com.fluentcommerce.util.sourcing.model.CriterionContext;
1032
+ import com.fasterxml.jackson.databind.JsonNode;
1033
+
1034
+ @SourcingCriterionInfo(
1035
+ name = "Preferred Carrier",
1036
+ type = "ext.sourcing.criterion.preferredCarrier",
1037
+ tags = {"Sorter"},
1038
+ description = "Boost locations that support the customer's preferred carrier"
1039
+ )
1040
+ public class PreferredCarrierCriterion extends BaseSourcingCriterion {
1041
+
1042
+ @SourcingCriterionParam(
1043
+ name = "carrierAttributeName",
1044
+ component = "input",
1045
+ description = "Name of the location attribute holding supported carriers"
1046
+ )
1047
+ private String carrierAttributeName;
1048
+
1049
+ @SourcingCriterionParam(
1050
+ name = "boostFactor",
1051
+ component = "select",
1052
+ description = "Score multiplier when carrier matches"
1053
+ )
1054
+ @SourcingCriterionParamSelectComponentOption(name = "High", value = "2.0")
1055
+ @SourcingCriterionParamSelectComponentOption(name = "Medium", value = "1.5")
1056
+ @SourcingCriterionParamSelectComponentOption(name = "Low", value = "1.2")
1057
+ private double boostFactor;
1058
+
1059
+ @Override
1060
+ public void parseParams(JsonNode params) {
1061
+ this.carrierAttributeName = params.has("carrierAttributeName")
1062
+ ? params.get("carrierAttributeName").asText()
1063
+ : "supportedCarriers";
1064
+ this.boostFactor = params.has("boostFactor")
1065
+ ? params.get("boostFactor").asDouble()
1066
+ : 1.5;
1067
+ }
1068
+
1069
+ @Override
1070
+ public float apply(CriterionContext context) {
1071
+ // Get customer's preferred carrier from order attributes
1072
+ String preferredCarrier = getOrderAttribute(
1073
+ context, "preferredCarrier");
1074
+
1075
+ if (preferredCarrier == null) {
1076
+ return 1.0f; // No preference — neutral score
1077
+ }
1078
+
1079
+ // Check if location supports this carrier
1080
+ String supported = getLocationAttribute(
1081
+ context, carrierAttributeName);
1082
+
1083
+ if (supported != null && supported.contains(preferredCarrier)) {
1084
+ return (float) boostFactor; // Boosted score
1085
+ }
1086
+
1087
+ return 1.0f; // Base score
1088
+ }
1089
+ }
1090
+ ```
1091
+
1092
+ **Annotation Reference:**
1093
+
1094
+ | Annotation | Purpose |
1095
+ |-----------|---------|
1096
+ | `@SourcingCriterionInfo` | Declares the criterion name, type key, tags (Exclusion/Sorter), and description |
1097
+ | `@SourcingCriterionParam` | Declares a configurable parameter with name, UI component type, and description |
1098
+ | `@SourcingCriterionParamSelectComponentOption` | For `component="select"` params — defines dropdown options with name/value pairs |
1099
+
1100
+ **Step 2: Register in `SourcingCriteriaTypeRegistry`**
1101
+
1102
+ ```java
1103
+ SourcingCriteriaTypeRegistry.register(
1104
+ "ext.sourcing.criterion.preferredCarrier",
1105
+ PreferredCarrierCriterion.class
1106
+ );
1107
+ ```
1108
+
1109
+ **Step 3: Create the setting**
1110
+
1111
+ ```json
1112
+ {
1113
+ "name": "fc.rubix.order.sourcing.criteria.custom",
1114
+ "context": "ACCOUNT",
1115
+ "contextId": 1,
1116
+ "value": "[{\"name\":\"Preferred Carrier\",\"type\":\"ext.sourcing.criterion.preferredCarrier\",\"class\":\"com.example.sourcing.criterion.PreferredCarrierCriterion\"}]"
1117
+ }
1118
+ ```
1119
+
1120
+ **Step 4: Use in a strategy**
1121
+
1122
+ ```json
1123
+ {
1124
+ "name": "CarrierBoost",
1125
+ "type": "ext.sourcing.criterion.preferredCarrier",
1126
+ "params": {
1127
+ "carrierAttributeName": "supportedCarriers",
1128
+ "boostFactor": 1.5
1129
+ }
1130
+ }
1131
+ ```
1132
+
1133
+ ### Testing Custom Extensions
1134
+
1135
+ 1. **Unit test** — Mock `SourcingContext` and `CriterionContext`, verify scores
1136
+ 2. **Integration test** — Deploy to sandbox, create a test profile with the custom type, run `/fluent-e2e-test`
1137
+ 3. **Verify registration** — Query the custom settings to confirm type is registered:
1138
+ ```
1139
+ Tool: graphql.query
1140
+ Query: { settings(name: "fc.rubix.order.sourcing.criteria.custom", context: "ACCOUNT", contextId: 1) { edges { node { value } } } }
1141
+ ```
1142
+
1143
+ ## Integration with Other Skills
1144
+
1145
+ | Task | Skill | How |
1146
+ |------|-------|-----|
1147
+ | Build workflow that triggers sourcing | `/fluent-workflow-builder` | Add `CreateFulfilmentWithSourcingProfile` to a ruleset |
1148
+ | Scaffold custom criterion Java class | `/fluent-rule-scaffold` | Generate boilerplate extending `BaseSourcingCriterion` |
1149
+ | Deploy sourcing settings | `/fluent-settings` | Create/update `fc.rubix.order.sourcing.*` keys |
1150
+ | Query live profiles via MCP | `/fluent-mcp-tools` | Use `graphql.query` with the GraphQL patterns above |
1151
+ | Trace sourcing event execution | `/fluent-trace` | Inspect `SourceOrder` event flow and rule outputs |
1152
+ | Configure locations/networks | `/fluent-retailer-config` | Set up the location network referenced by profiles |
1153
+ | Create test orders for sourcing | `/fluent-test-data` | Generate orders with specific fulfilment choices |
1154
+ | Run E2E sourcing flow | `/fluent-e2e-test` | Create order → source → verify fulfilment plan |
1155
+ | Analyze existing sourcing workflows | `/fluent-workflow-analyzer` | Map status graph and ruleset connections |
1156
+
1157
+ ## Tips
1158
+
1159
+ - **Only one ACTIVE version** of a profile ref can exist. Activating a new version deactivates the old one.
1160
+ - **Conditions use AND logic** — ALL conditions in a strategy must pass. For OR logic, create separate strategies.
1161
+ - **Exclusion score is `-1.0`** — Any criterion returning `-1.0` permanently removes that location from consideration for the current strategy.
1162
+ - **Normalization scope is per-criterion** — Each criterion normalizes independently across all remaining candidates, not across all criteria.
1163
+ - **Location cache TTL is 10 minutes** — `LocationUtils` caches location data. Changes to location attributes may take up to 10 minutes to be reflected in sourcing.
1164
+ - **maxSplit increases computation** — Permutation search is combinatorial. Use exclusion criteria to reduce candidate count before permutation.
1165
+ - **Operators are lowercase** — `equals`, not `EQUALS` or `Equals`. This is the most common configuration error.
1166
+ - **params is a JSON string in mutations** — When creating profiles via GraphQL, stringify the params object.
1167
+ - **Order criteria carefully** — Exclusions first (reduce pool), then sorters (rank remaining). This improves both performance and correctness.
1168
+ - **networkRef on profile is the default** — Individual strategies can override with `locationNetworkExclusion` criterion.
1169
+ - **`virtualCatalogueRef` overrides `catalogueRef`** — If both are set, the virtual catalogue is used for inventory lookups.
1170
+ - **Use `exists` operator for optional attributes** — Check if an attribute exists before comparing its value in a separate condition.
1171
+ - **Test with `/fluent-trace`** — After configuring a profile, send a test order event and use flow inspection to verify the sourcing decision path.