Skip to content

[RFC] feat(core): Add partitionKey to StockLevel for sub-location stock tracking#4628

Open
niko91i wants to merge 9 commits into
vendurehq:masterfrom
niko91i:feat/stock-location-hierarchy
Open

[RFC] feat(core): Add partitionKey to StockLevel for sub-location stock tracking#4628
niko91i wants to merge 9 commits into
vendurehq:masterfrom
niko91i:feat/stock-location-hierarchy

Conversation

@niko91i
Copy link
Copy Markdown
Contributor

@niko91i niko91i commented Apr 4, 2026

Context

This PR is a working proof-of-concept related to a discussion on Discord:
https://discord.com/channels/1100672177260478564/1488690570296692909

The current StockLevel entity enforces a unique constraint on [productVariantId, stockLocationId],
meaning only one stock record can exist per variant per location. This forces use cases like batch/lot
tracking, serial number management, or expiration-based stock rotation to create one StockLocation
per batch — polluting a table designed for physical locations.

At scale (e.g. 10,000 vendors × 30 products × 6 batches/year = 1.8M rows/year), the stock_location
table becomes unmanageable for its original purpose.

Proposed solution

Add an optional partitionKey column to StockLevel, allowing multiple stock records for the same
variant+location pair, each identified by a unique key.

  • partitionKey defaults to '' (empty string) — existing behavior is 100% unchanged
  • The unique index becomes [productVariantId, stockLocationId, partitionKey]
  • LocationWithQuantity gains optional partitionKey and stockLevelId fields
  • StockLevelService methods accept an optional partitionKey parameter
  • StockMovementService propagates partitionKey through all allocation/sale/release/cancellation flows

Changes

  • stock-level.entity.ts — New partitionKey column (default '') + updated unique index
  • stock-location-strategy.ts — Optional stockLevelId and partitionKey on LocationWithQuantity
  • stock-level.service.ts — Optional partitionKey parameter on getStockLevel, updateStockOnHand, updateStockAllocated
  • stock-movement.service.ts — Propagate partitionKey through 5 call sites
  • GraphQL schema — partitionKey on StockLevel type and StockLevelInput

What does NOT change

  • StockLocation entity — untouched
  • StockLocationStrategy interface — method signatures unchanged
  • DefaultStockLocationStrategy / MultiChannelStockLocationStrategy — no modifications
  • All existing applications without partitionKey work identically

Test plan

  • 822 existing unit tests pass (zero regression)
  • 11 new unit tests for StockLevelService with/without partitionKey
  • 5 new E2E tests verifying partition creation, coexistence, and targeted updates
  • 59 existing stock E2E tests pass (stock-control, stock-location, multi-location)

@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 4, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
vendure-storybook Ready Ready Preview, Comment Apr 6, 2026 11:56am

Request Review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 4, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Added a partitionKey field across stock-related GraphQL types, inputs, entities, services, schema, and tests. GraphQL types/inputs (StockLevel, StockLevelInput, StockMovement and implementing types) and generated typings now include partitionKey. The StockLevel entity gained a partitionKey column (default '') and its unique index was updated to include it. StockMovement entities persist partitionKey. StockLevelService and related service methods were extended to accept and propagate an optional partitionKey. New unit and E2E tests validate partitioned stock behavior.

Suggested reviewers

  • michaelbromley
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title '[RFC] feat(core): Add partitionKey to StockLevel for sub-location stock tracking' is clear, specific, and directly summarizes the main feature being added—support for multiple stock records per location via an optional partitionKey field.
Description check ✅ Passed The PR description comprehensively covers context, proposed solution, changes, test coverage, and backward compatibility. However, it does not fully match the template structure with labeled sections for 'Breaking changes' and 'Screenshots'.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/core/src/service/services/stock-level.service.ts (1)

37-62: ⚠️ Potential issue | 🟠 Major

Confirm and document: omitting partitionKey matches any partition, not the default.

The concern is valid. When partitionKey is undefined, the where clause doesn't include partitionKey, so findOne() returns the first matching record regardless of partition. With multiple partitions (e.g., 'BATCH-001' and ''), this produces non-deterministic results.

The test "returns existing StockLevel without partitionKey filter" (line 70) documents this behavior, but the JSDoc (lines 32-36) only explains the case when partitionKey is provided, leaving the omitted case undefined.

All production calls in stock-movement.service.ts explicitly pass partitionKey, so this edge case isn't hit in practice. However, the API allows omitting it, which creates ambiguity.

The suggested fix is appropriate: defaulting to '' (the entity's default value) makes behavior deterministic and prevents unexpected partition matches. Since no production code omits partitionKey, this change is safe.

Alternatively, if the "match any" behavior is intentional for backward compatibility, update the JSDoc to explicitly document it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/service/services/stock-level.service.ts` around lines 37 -
62, getStockLevel currently omits partitionKey from the findOne where clause
when partitionKey is undefined, causing non-deterministic cross-partition
matches; change getStockLevel in stock-level.service.ts so that when
partitionKey is omitted it defaults to the entity default (e.g. '') and is
included in both the findOne and save payloads (ensure the new StockLevel(...)
creation also uses the same default), and update the JSDoc for getStockLevel to
state that an omitted partitionKey will be treated as the empty-string partition
rather than "match any".
🧹 Nitpick comments (6)
packages/core/src/config/catalog/stock-location-strategy.ts (1)

35-52: Clarify precedence when both stockLevelId and partitionKey are supplied.

The interface now permits both selectors; documenting conflict/precedence rules in this contract would prevent divergent custom strategy implementations.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/config/catalog/stock-location-strategy.ts` around lines 35
- 52, Update the documentation for the StockLocationStrategy contract to specify
the precedence rule when both selectors are provided: state whether stockLevelId
takes precedence over partitionKey (or vice versa), and describe the expected
behavior (e.g., if stockLevelId is present, implementations must use that
specific StockLevel and ignore partitionKey; if stockLevelId is absent but
partitionKey present, implementations must target the matching partition).
Reference the existing fields stockLevelId and partitionKey and make the rule
explicit so custom strategies implementers have a single authoritative
precedence to follow.
packages/core/src/api/schema/admin-api/product.api.graphql (1)

132-136: Add a short schema description for partitionKey semantics.

A brief docstring here would help API consumers understand default-partition behavior when partitionKey is omitted or empty.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/api/schema/admin-api/product.api.graphql` around lines 132
- 136, Add a short GraphQL schema description (docstring) to the partitionKey
field on the StockLevelInput input type so API consumers understand its
semantics: state that when partitionKey is omitted or empty the system uses the
default partition, and document any effects (e.g., scope of stock levels or
tenant partitioning) and expected format (if any). Update the input
StockLevelInput definition to include this brief description next to the
partitionKey field to make default-partition behavior explicit.
packages/core/e2e/stock-partition-key.e2e-spec.ts (1)

173-175: Consider a stronger assertion for verifying unchanged default stock level.

The assertion toBeGreaterThanOrEqual(0) doesn't verify that the default level's stockOnHand remained unchanged. Consider capturing the initial value and asserting equality:

💡 Suggested improvement
+    // Capture initial default stock level before the update
+    const { productVariant: initialVariant } = await adminClient.query(getStockLevelsForVariantDocument, {
+        id: variantId,
+    });
+    const initialDefaultStock = initialVariant?.stockLevels.find(sl => sl.partitionKey === '')?.stockOnHand;
+
     await adminClient.query(setStockLevelWithPartitionDocument, {
         // ... mutation input
     });

     // ... after fetching updated levels
-    expect(defaultLevel?.stockOnHand).toBeGreaterThanOrEqual(0); // Unchanged
+    expect(defaultLevel?.stockOnHand).toBe(initialDefaultStock); // Actually unchanged
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/e2e/stock-partition-key.e2e-spec.ts` around lines 173 - 175,
The test currently asserts defaultLevel?.stockOnHand >= 0 which doesn't ensure
it stayed unchanged; before performing the stock update capture the initial
value (e.g., const initialDefaultStock = defaultLevel?.stockOnHand) and after
the update assert equality
(expect(defaultLevel?.stockOnHand).toBe(initialDefaultStock)). Update the
assertions that reference defaultLevel in the test (symbols: defaultLevel,
batch1, batch2) so the default level comparison uses the captured initial value
rather than a >= 0 check.
packages/core/src/service/services/stock-level.service.ts (2)

132-151: Asymmetric behavior: updateStockAllocatedForLocation silently ignores missing partitions.

Unlike updateStockOnHandForLocation which creates a new record when the partition doesn't exist, this method silently does nothing. This asymmetry could mask bugs where an allocation strategy targets a non-existent partition.

Consider either:

  1. Throwing an error when the partition doesn't exist (fail-fast)
  2. Creating the record like updateStockOnHandForLocation does (consistent behavior)
  3. Documenting this as intentional behavior
💡 Option 1: Throw error for missing partition
     const stockLevel = await this.connection.getRepository(ctx, StockLevel).findOne({
         where: {
             productVariantId,
             stockLocationId,
-            ...(partitionKey != null ? { partitionKey } : {}),
+            partitionKey: partitionKey ?? '',
         },
     });
-    if (stockLevel) {
+    if (!stockLevel) {
+        throw new Error(
+            `StockLevel not found for productVariantId=${productVariantId}, ` +
+            `stockLocationId=${stockLocationId}, partitionKey='${partitionKey ?? ''}'`
+        );
+    }
+    await this.connection
+        .getRepository(ctx, StockLevel)
+        .update(stockLevel.id, { stockAllocated: stockLevel.stockAllocated + change });
-        await this.connection
-            .getRepository(ctx, StockLevel)
-            .update(stockLevel.id, { stockAllocated: stockLevel.stockAllocated + change });
-    }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/service/services/stock-level.service.ts` around lines 132 -
151, The method updateStockAllocatedForLocation currently returns early when no
StockLevel is found, causing asymmetric behavior with
updateStockOnHandForLocation; update updateStockAllocatedForLocation to mirror
updateStockOnHandForLocation: if no StockLevel exists for the given
productVariantId, stockLocationId and optional partitionKey, create a new
StockLevel record (using the same fields/population logic as
updateStockOnHandForLocation) and set stockAllocated to the passed change (or
initialize appropriately), otherwise update the existing
StockLevel.stockAllocated by adding change; refer to
updateStockAllocatedForLocation, updateStockOnHandForLocation, and the
StockLevel entity when implementing.

95-125: Same ambiguity issue in updateStockOnHandForLocation.

When partitionKey is omitted, this method may find and update an arbitrary partitioned record instead of the default one.

Additionally, the control flow could be simplified:

♻️ Suggested refactor for clearer control flow
     const stockLevel = await this.connection.getRepository(ctx, StockLevel).findOne({
         where: {
             productVariantId,
             stockLocationId,
-            ...(partitionKey != null ? { partitionKey } : {}),
+            partitionKey: partitionKey ?? '',
         },
     });
-    if (!stockLevel) {
+    if (stockLevel) {
+        await this.connection
+            .getRepository(ctx, StockLevel)
+            .update(stockLevel.id, { stockOnHand: stockLevel.stockOnHand + change });
+    } else {
         await this.connection.getRepository(ctx, StockLevel).save(
             new StockLevel({
                 productVariantId,
                 stockLocationId,
                 stockOnHand: change,
                 stockAllocated: 0,
-                ...(partitionKey != null ? { partitionKey } : {}),
+                partitionKey: partitionKey ?? '',
             }),
         );
     }
-    if (stockLevel) {
-        await this.connection
-            .getRepository(ctx, StockLevel)
-            .update(stockLevel.id, { stockOnHand: stockLevel.stockOnHand + change });
-    }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/service/services/stock-level.service.ts` around lines 95 -
125, The updateStockOnHandForLocation method can match an arbitrary partition
when partitionKey is omitted; make partitionKey explicit by normalizing it at
the top of updateStockOnHandForLocation (e.g., const pk = partitionKey ??
DEFAULT_PARTITION or null) and use pk in the findOne, save/create, and update
calls so the lookup and mutation always target the same partition; also simplify
control flow to: find by productVariantId/stockLocationId/pk, if found update
via repository.update(stockLevel.id, { stockOnHand: stockLevel.stockOnHand +
change }) else create/save a new StockLevel with stockOnHand = change and
partitionKey = pk (or use repository.upsert if available) so there is no
ambiguity between partitions.
packages/core/src/service/services/stock-level.service.spec.ts (1)

149-154: This asymmetry is intentional and validated by tests, but documentation should clarify the design rationale.

The test correctly validates that updateStockAllocatedForLocation silently does nothing when the partitionKey doesn't exist. This behavior differs intentionally from updateStockOnHandForLocation, which creates a new partition (test at line 122–128 explicitly validates this).

The design makes sense: receiving stock to a non-existent partition is valid (new batch arrival), while allocating to a non-existent partition likely indicates a bug in allocation logic. However, both methods have identical documentation ("only the matching partitioned StockLevel will be updated"), which obscures this intentional difference. Consider expanding the docstring for updateStockAllocatedForLocation to clarify that it does not create partitions, making the behavior explicit to callers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/service/services/stock-level.service.spec.ts` around lines
149 - 154, Update the docstring for updateStockAllocatedForLocation to
explicitly state that unlike updateStockOnHandForLocation it will not create a
new partition when the provided partitionKey does not exist and will instead be
a no-op; mention the rationale that allocating to a non-existent partition is
treated as a logic error while receiving stock may create partitions, and
reference both method names (updateStockAllocatedForLocation and
updateStockOnHandForLocation) in the comment so callers understand the
intentional asymmetry validated by tests.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/core/src/service/services/stock-level.service.spec.ts`:
- Around line 70-75: The test for getStockLevel in stock-level.service.spec.ts
is non-deterministic because omitting partitionKey allows any matching
StockLevel to be returned; update the test to be deterministic by providing an
explicit partitionKey when calling service.getStockLevel(ctx, variantId,
locationId, partitionKey) (use the same partitionKey used when creating the
expected StockLevel in the test setup), or alternatively change the assertion to
not assume id === 1 (e.g., assert the returned record matches the expected
partitionKey and variant/location instead of a specific id); target the
getStockLevel call and its expectations to ensure the mock and assertions align.

---

Outside diff comments:
In `@packages/core/src/service/services/stock-level.service.ts`:
- Around line 37-62: getStockLevel currently omits partitionKey from the findOne
where clause when partitionKey is undefined, causing non-deterministic
cross-partition matches; change getStockLevel in stock-level.service.ts so that
when partitionKey is omitted it defaults to the entity default (e.g. '') and is
included in both the findOne and save payloads (ensure the new StockLevel(...)
creation also uses the same default), and update the JSDoc for getStockLevel to
state that an omitted partitionKey will be treated as the empty-string partition
rather than "match any".

---

Nitpick comments:
In `@packages/core/e2e/stock-partition-key.e2e-spec.ts`:
- Around line 173-175: The test currently asserts defaultLevel?.stockOnHand >= 0
which doesn't ensure it stayed unchanged; before performing the stock update
capture the initial value (e.g., const initialDefaultStock =
defaultLevel?.stockOnHand) and after the update assert equality
(expect(defaultLevel?.stockOnHand).toBe(initialDefaultStock)). Update the
assertions that reference defaultLevel in the test (symbols: defaultLevel,
batch1, batch2) so the default level comparison uses the captured initial value
rather than a >= 0 check.

In `@packages/core/src/api/schema/admin-api/product.api.graphql`:
- Around line 132-136: Add a short GraphQL schema description (docstring) to the
partitionKey field on the StockLevelInput input type so API consumers understand
its semantics: state that when partitionKey is omitted or empty the system uses
the default partition, and document any effects (e.g., scope of stock levels or
tenant partitioning) and expected format (if any). Update the input
StockLevelInput definition to include this brief description next to the
partitionKey field to make default-partition behavior explicit.

In `@packages/core/src/config/catalog/stock-location-strategy.ts`:
- Around line 35-52: Update the documentation for the StockLocationStrategy
contract to specify the precedence rule when both selectors are provided: state
whether stockLevelId takes precedence over partitionKey (or vice versa), and
describe the expected behavior (e.g., if stockLevelId is present,
implementations must use that specific StockLevel and ignore partitionKey; if
stockLevelId is absent but partitionKey present, implementations must target the
matching partition). Reference the existing fields stockLevelId and partitionKey
and make the rule explicit so custom strategies implementers have a single
authoritative precedence to follow.

In `@packages/core/src/service/services/stock-level.service.spec.ts`:
- Around line 149-154: Update the docstring for updateStockAllocatedForLocation
to explicitly state that unlike updateStockOnHandForLocation it will not create
a new partition when the provided partitionKey does not exist and will instead
be a no-op; mention the rationale that allocating to a non-existent partition is
treated as a logic error while receiving stock may create partitions, and
reference both method names (updateStockAllocatedForLocation and
updateStockOnHandForLocation) in the comment so callers understand the
intentional asymmetry validated by tests.

In `@packages/core/src/service/services/stock-level.service.ts`:
- Around line 132-151: The method updateStockAllocatedForLocation currently
returns early when no StockLevel is found, causing asymmetric behavior with
updateStockOnHandForLocation; update updateStockAllocatedForLocation to mirror
updateStockOnHandForLocation: if no StockLevel exists for the given
productVariantId, stockLocationId and optional partitionKey, create a new
StockLevel record (using the same fields/population logic as
updateStockOnHandForLocation) and set stockAllocated to the passed change (or
initialize appropriately), otherwise update the existing
StockLevel.stockAllocated by adding change; refer to
updateStockAllocatedForLocation, updateStockOnHandForLocation, and the
StockLevel entity when implementing.
- Around line 95-125: The updateStockOnHandForLocation method can match an
arbitrary partition when partitionKey is omitted; make partitionKey explicit by
normalizing it at the top of updateStockOnHandForLocation (e.g., const pk =
partitionKey ?? DEFAULT_PARTITION or null) and use pk in the findOne,
save/create, and update calls so the lookup and mutation always target the same
partition; also simplify control flow to: find by
productVariantId/stockLocationId/pk, if found update via
repository.update(stockLevel.id, { stockOnHand: stockLevel.stockOnHand + change
}) else create/save a new StockLevel with stockOnHand = change and partitionKey
= pk (or use repository.upsert if available) so there is no ambiguity between
partitions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c51b6ba3-59cd-4e68-bdb8-9a093d49b578

📥 Commits

Reviewing files that changed from the base of the PR and between e63387a and 4c120cb.

📒 Files selected for processing (13)
  • packages/admin-ui/src/lib/core/src/common/generated-types.ts
  • packages/asset-server-plugin/e2e/graphql/graphql-env-admin.d.ts
  • packages/common/src/generated-types.ts
  • packages/core/e2e/graphql/graphql-env-admin.d.ts
  • packages/core/e2e/stock-partition-key.e2e-spec.ts
  • packages/core/src/api/schema/admin-api/product.api.graphql
  • packages/core/src/api/schema/admin-api/stock-level.type.graphql
  • packages/core/src/config/catalog/stock-location-strategy.ts
  • packages/core/src/entity/stock-level/stock-level.entity.ts
  • packages/core/src/service/services/stock-level.service.spec.ts
  • packages/core/src/service/services/stock-level.service.ts
  • packages/core/src/service/services/stock-movement.service.ts
  • schema-admin.json

Comment thread packages/core/src/service/services/stock-level.service.spec.ts Outdated
  - Default partitionKey to '' instead of omitting from where clause,
    preventing non-deterministic cross-partition matches
  - Add JSDoc documenting default partition behavior, asymmetric
    creation semantics, and stockLevelId/partitionKey precedence rule
  - Add GraphQL description to StockLevelInput.partitionKey field
  - Fix non-deterministic unit test assertion
  - Capture initial stock values in E2E test for precise comparison
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/core/src/config/catalog/stock-location-strategy.ts`:
- Around line 35-60: The documented precedence where stockLevelId overrides
partitionKey is not implemented; update the service layer to honor it by (1)
extending StockLevelService methods (getStockLevel,
updateStockOnHandForLocation, updateStockAllocatedForLocation) to accept an
optional stockLevelId parameter and use it to fetch/update the exact StockLevel
when provided, (2) modifying StockMovementService
allocation/sale/release/cancellation flows to propagate
LocationWithQuantity.stockLevelId (prefer stockLevelId over partitionKey when
both present), and (3) adjusting related type signatures/interfaces to include
stockLevelId so custom StockLocationStrategy values are not ignored;
alternatively, if you prefer the simpler route, remove stockLevelId from
LocationWithQuantity and update the JSDoc to reflect partitionKey-only support.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 162fad51-8708-4925-a720-337ff0c109fd

📥 Commits

Reviewing files that changed from the base of the PR and between 4c120cb and e1f0cb4.

📒 Files selected for processing (5)
  • packages/core/e2e/stock-partition-key.e2e-spec.ts
  • packages/core/src/api/schema/admin-api/product.api.graphql
  • packages/core/src/config/catalog/stock-location-strategy.ts
  • packages/core/src/service/services/stock-level.service.spec.ts
  • packages/core/src/service/services/stock-level.service.ts
✅ Files skipped from review due to trivial changes (2)
  • packages/core/src/service/services/stock-level.service.spec.ts
  • packages/core/e2e/stock-partition-key.e2e-spec.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/core/src/service/services/stock-level.service.ts

Comment thread packages/core/src/config/catalog/stock-location-strategy.ts Outdated
  The documented precedence rule (stockLevelId > partitionKey) was not
  implemented in the service layer. Rather than adding complexity,
  remove stockLevelId entirely — partitionKey alone is sufficient to
  identify a specific StockLevel partition.
  StockMovement records now track which stock partition they affect,
  enabling per-batch/lot movement history and full traceability.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/core/src/service/services/stock-movement.service.ts (1)

175-191: Consider normalizing partitionKey once per loop iteration for DRY/consistency.

This reduces duplication and prevents accidental divergence between entity value and service-call argument in future edits.

Refactor pattern (example)
for (const saleLocation of saleLocations) {
+    const partitionKey = saleLocation.partitionKey ?? '';
     const sale = new Sale({
         productVariant,
         quantity: lineRow.quantity * -1,
         orderLine,
         stockLocation: saleLocation.location,
-        partitionKey: saleLocation.partitionKey ?? '',
+        partitionKey,
     });
     sales.push(sale);

     if (this.trackInventoryForVariant(productVariant, globalTrackInventory)) {
         await this.stockLevelService.updateStockAllocatedForLocation(
             ctx,
             orderLine.productVariantId,
             saleLocation.location.id,
             -saleLocation.quantity,
-            saleLocation.partitionKey,
+            partitionKey,
         );
         await this.stockLevelService.updateStockOnHandForLocation(
             ctx,
             orderLine.productVariantId,
             saleLocation.location.id,
             -saleLocation.quantity,
-            saleLocation.partitionKey,
+            partitionKey,
         );
     }
}

Also applies to: 232-255, 296-312, 348-363

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/service/services/stock-movement.service.ts` around lines
175 - 191, The loop constructs an Allocation and then calls
updateStockAllocatedForLocation passing allocationLocation.partitionKey
directly, which duplicates the partitionKey expression and risks divergence;
normalize partitionKey at the top of the iteration (e.g., const partitionKey =
allocationLocation.partitionKey ?? '') and use that variable for the Allocation
constructor (Allocation's partitionKey) and for the
updateStockAllocatedForLocation call; apply the same change for the other
similar blocks referenced (around the Allocation creation and calls to
this.trackInventoryForVariant and
this.stockLevelService.updateStockAllocatedForLocation).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/core/src/service/services/stock-movement.service.ts`:
- Around line 175-191: The loop constructs an Allocation and then calls
updateStockAllocatedForLocation passing allocationLocation.partitionKey
directly, which duplicates the partitionKey expression and risks divergence;
normalize partitionKey at the top of the iteration (e.g., const partitionKey =
allocationLocation.partitionKey ?? '') and use that variable for the Allocation
constructor (Allocation's partitionKey) and for the
updateStockAllocatedForLocation call; apply the same change for the other
similar blocks referenced (around the Allocation creation and calls to
this.trackInventoryForVariant and
this.stockLevelService.updateStockAllocatedForLocation).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: d4416ea3-4bf8-4542-9665-df0fa5b7544c

📥 Commits

Reviewing files that changed from the base of the PR and between d9883c0 and 46c13c8.

📒 Files selected for processing (3)
  • packages/core/src/api/schema/admin-api/stock-movement.type.graphql
  • packages/core/src/entity/stock-movement/stock-movement.entity.ts
  • packages/core/src/service/services/stock-movement.service.ts

@niko91i
Copy link
Copy Markdown
Contributor Author

niko91i commented Apr 4, 2026

Tested this PR against a production marketplace codebase via patch-package. Batch/lot tracking with multiple StockLevels per variant+location works as expected. Also added partitionKey propagation to StockMovement entities for full traceability.

quantity: delta,
stockLocation: { id: input.stockLocationId },
productVariant: { id: productVariantId },
partitionKey: input.partitionKey ?? '',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a bit nitpicky (sorry) because i do understand that once the default partitionkey exists it generally wont change, but i'd really like to see some form of constant like DEFAULT_STOCK_LOCATION_PARTITION_KEY (or something shorter) because the current default of "" is being reused throughout many different files, comments, services, tests, .. and having a named constant makes it easier to understand unfamiliar parts of code intuitively

other than that really cool PR! 🙏

…eview feedback

  Replace hardcoded empty-string defaults with a named constant as suggested by @DanielBiegler.
  Exported from @vendure/core for use in custom StockLocationStrategy implementations.
@niko91i
Copy link
Copy Markdown
Contributor Author

niko91i commented Apr 5, 2026

Thanks for the review @DanielBiegler ! I'm already using this pattern in production via patch-package for a marketplace with batch/lot tracking. Before I build further on it, is this approach likely to be accepted (with potential refinements), or would you recommend a different direction?

@DanielBiegler
Copy link
Copy Markdown
Contributor

is this approach likely to be accepted

Can't say, gotta wait for a comment from the maintainers. I've not looked into this topic deeply, so I don't have informed opinions specifically there, but the pr looks generally well made from my short glance!

I think a research section comparing how other ecom frameworks deal with this task, can help.

Generally I'd say keeping track of batches/lots sounds reasonable to support because Vendure aims to be enterprisy/B2B, where this kind of stuff happens throughout different types of businesses

  The MultiChannelStockLocationStrategy.forAllocation() previously used
  find() to get a single StockLevel per location, ignoring other partitions.
  Now uses filter() to iterate over all partitions at each location,
  allocating from each until the requested quantity is fulfilled.

  Updated BaseStockLocationStrategy.getLocationsBasedOnAllocations() to
  group by locationId+partitionKey for correct sale/release/cancellation.

  Added 8 unit tests covering multi-partition allocation, partition-aware
  sale/release/cancellation, and single-partition backward compatibility.
@Ryrahul
Copy link
Copy Markdown
Contributor

Ryrahul commented Apr 6, 2026

Nice work on this PR — the problem is well-defined, the entity/service plumbing is clean, and the test coverage is nice. Just wanted to flag one issue

BaseStockLocationStrategy.getLocationsBasedOnAllocations reads Allocation records (which now correctly store partitionKey) but:

  • Does NOT include partitionKey in the returned LocationWithQuantity
  • Groups only by stockLocationId

This causes allocations from different partitions at the same location to get merged.

Since forSale, forRelease, and forCancellation all delegate to this method, the partition context is lost across the order lifecycle.

Impact

In a normal order lifecycle:

  • Checkout allocates from the correct partition (e.g. BATCH-001)
  • Fulfillment / cancellation / release default to the default partition

Result:

  • Allocated partition gets stuck
  • Default partition receives incorrect (phantom) adjustments
  • Inventory becomes inconsistent

Extensibility Issue

  • getLocationsBasedOnAllocations is private
  • Custom strategies cannot override it

To fix behavior, users would need to reimplement:

  • forSale
  • forRelease
  • forCancellation

This is unnecessarily complex and error-prone.

Suggested Fix

Update getLocationsBasedOnAllocations to:

  • Group by stockLocationId and partitionKey
  • Include partitionKey in LocationWithQuantity

This ensures partition context is preserved across the full order lifecycle.

Minor Issue

In StockLocationService.deleteStockLocation:

  • Stock transfers are matched without partitionKey

Risk:
Different batches/partitions may get merged incorrectly during deletion.

I’m not a maintainer, so probably its best to wait for a maintainer’s input on this

  StockLocationService.delete() now matches and creates StockLevel records
  by partitionKey when transferring stock to another location, preventing
  partition merge and preserving batch traceability.
@Ryrahul
Copy link
Copy Markdown
Contributor

Ryrahul commented Apr 6, 2026

Worth noting the dashboard doesn't query or send partitionkey yet - admins would see
duplicate rows per location with no way to tell them apart. Probably fine as a follow-up
just flagging it.

@michaelbromley
Copy link
Copy Markdown
Member

Hi @niko91i, thanks for taking the time to share your solution here.

I've not got time for a deep-dive review on this right now, as it is quite a complex topic touching many parts of the inventory & order flow.

I just wanted to let you know it's on my radar and might be something we are interested in if it aligns with our overall strategy in terms of what features we want to work on in the coming months.

Sorry I can't be more specific than that right now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants