Skip to content

fix(listings): make bulk-wizard category resolve mapping-aware (#1522)#1523

Merged
norbert-kulus-blockydevs merged 2 commits into
mainfrom
1522-bulk-wizard-category-mapping-resolve
Jul 14, 2026
Merged

fix(listings): make bulk-wizard category resolve mapping-aware (#1522)#1523
norbert-kulus-blockydevs merged 2 commits into
mainfrom
1522-bulk-wizard-category-mapping-resolve

Conversation

@norbert-kulus-blockydevs

Copy link
Copy Markdown
Collaborator

What & why

The bulk-offer wizard's Resolve step resolved the destination (Allegro) category by EAN catalogue-match only and ignored the operator's configured PS -> Allegro category mapping. A product whose EAN is not in Allegro's catalogue but whose source category is mapped was blocked in the wizard (empty category picker, review row "needs attention"), even though OfferBuilderService builds the offer correctly server-side using that same mapping. The wizard preview and build-time resolution disagreed.

This makes the batch Resolve step mapping-aware so the two agree.

Approach — mapping-aware resolve (the real fix)

Chosen over the FE-blocker-suppression fallback because the source category ids are already available end-to-end: OL persists them (products.categories, #1034) and the domain Product carries them; they were simply not exposed on the transport. Threading them lets the wizard show the resolved category, mirroring OfferBuilderService, rather than merely suppressing the blocker.

  • CoreCategoryResolutionService.resolveCategoriesBatch keeps EAN catalogue match as the primary path. On an EAN no-match (or a variant with no EAN) it consults the operator's configured per-source-category mapping via the same IMappingConfigService.resolveDestinationCategory (findBySourceCategory) that OfferBuilderService uses, when the item supplies sourceCategoryIds. A hit returns a matched result with method: 'category_mapping' and an empty productCardId (the offer self-links by barcode at build time). Borrows-taxonomy destinations (Erli) are unchanged — they degrade to no-match and resolve server-side at submit.
  • Transport — batch request item gains sourceCategoryIds; EanMatchResult.matched gains an optional method; ProductResponseDto / FE Product expose the persisted source category ids.
  • FE — the Resolve step attaches each row's product.categories, prefers the batch verdict (now covering both EAN and mapping paths), reports category_mapping, and normalizes the empty mapping card id to null.

EAN match stays primary; mapping is the fallback only on no-match / no-ean. No CORE<->Integration boundary crossings; capability-gated, no platformType checks.

How tested

  • Core unit tests (category-resolution.service.spec.ts): EAN hit still wins (mapping not consulted); EAN no-match + source-category mapping -> resolves via category_mapping; no-EAN + mapping -> category_mapping; EAN no-match + no mapping -> stays no-match.
  • API controller test: sourceCategoryIds forwarded to the service and a category_mapping result surfaced.
  • FE (bulk-resolve-step.test.tsx): a mapped-category row (EAN no-match) is no longer blocked and carries resolutionMethod: category_mapping.
  • Scoped gates green: @openlinker/core type-check + tests, @openlinker/api type-check + affected controller specs, @openlinker/web type-check + full test suite (one pre-existing, unrelated settings-page.test.tsx env-default failure). pnpm check:invariants passes.

Closes #1522

🤖 Generated with Claude Code

@norbert-kulus-blockydevs norbert-kulus-blockydevs force-pushed the 1522-bulk-wizard-category-mapping-resolve branch from 77f405b to 55e6099 Compare July 13, 2026 17:32

@piotrswierzy piotrswierzy left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review — fix(listings): make bulk-wizard category resolve mapping-aware (#1522)

Verdict: APPROVE. Correct, well-scoped fix that closes a real preview/build-resolution disagreement by reusing the existing mapping seam rather than duplicating it. No blockers — one non-blocking latent-divergence note and two minor suggestions.

What it does

The bulk-offer wizard's Resolve step resolved the destination category by EAN catalogue-match only, blocking products whose EAN isn't in Allegro's catalogue but whose source category is mapped — even though OfferBuilderService builds those offers fine server-side. This threads the persisted source category ids (products.categories, #1034) FE → API DTO → core, and makes CategoryResolutionService.resolveCategoriesBatch consult the operator's configured per-source-category mapping (resolveDestinationCategory / findBySourceCategory) as a fallback on EAN no-match/no-ean, returning method: 'category_mapping' with an empty product card. EAN match stays primary.

Assessment

  • Architecture — strong. The mapping fallback lives in core, reuses the same IMappingConfigService.resolveDestinationCategory that OfferBuilderService uses (no parallel path), and is capability-gated on isEanCategoryMatcher — no platformType literals. The adapter still receives only { variantId, ean } (category-resolution.service.ts:141-143); sourceCategoryIds is kept a core concern and never leaks across the CORE↔Integration boundary. Borrows-taxonomy destinations (Erli) degrade to no-match and resolve server-side, unchanged. New types live in *.types.ts (BatchCategoryResolveInput/Item, EanMatchMethod), barrel exports updated, shared Logger, no any, explicit types, DTO validated with class-validator.
  • Correctness — verified. The four-way branch (EAN hit ignores mapping / EAN no-match + mapping hit / no-EAN + mapping hit / no-match + no mapping) is sound, and the batch verdict now agrees with build-time resolution for every destination that exists today (see inline note for the one future-facing asymmetry). FE resolvedProductCardId normalization to null on the empty-card path is correct — selectBulkProductCardId (bulk-policy.ts:309) already treats '' as falsy, so this is defensive-but-clearer.
  • Tests — thorough. Core spec covers all four branches and asserts the adapter receives only the EAN shape; controller spec verifies sourceCategoryIds forwarding + category_mapping surfacing; FE spec verifies threading, non-blocked row, and resolutionMethod. AAA, good edge coverage.
  • Migrations / security — none needed. Read-only preview path; input validated at the DTO boundary. Idempotency is not in scope (this touches the pre-flight resolve query, not the bulk submit).

IMPORTANT (non-blocking)

  • category-resolution.service.ts:159 — batch path passes empty opts ({}) to tryCategoryMapping, unlike the single-offer path which threads sourceConnectionId + borrowedTaxonomy. Safe today (owns-only reachability), but a latent divergence if a future destination is both an EanCategoryMatcher and a borrows destination. Recommend documenting the invariant inline. See the line comment for the full trace.

SUGGESTION

  • The productCardId: '' sentinel on the mapping-fallback result relies on empty-string-as-falsy across FE (selectBulkProductCardId) and the normalization in bulk-resolve-step.tsx. It works, but a nullable/omitted card would express "no catalogue card" more directly. Minor; the empty-string convention is already established for EanMatchResult.matched.
  • Two spec files exist for this service (category-resolution.service.spec.ts and __tests__/category-resolution.service.spec.ts) — pre-existing, not introduced here, but worth consolidating in a future cleanup.

Positives

Reused the existing resolution seam instead of a fork; kept the adapter contract EAN-only; capability-gated with no platform literals; preview now provably agrees with OfferBuilderService for current destinations; comprehensive tests across all three layers; clear, honest doc comments on the new interface/types explaining the fallback semantics.

item.sourceCategoryIds &&
item.sourceCategoryIds.length > 0
) {
const mapped = await this.tryCategoryMapping(connectionId, item.sourceCategoryIds, {});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

IMPORTANT (non-blocking) — latent divergence from the single-offer path. This calls tryCategoryMapping(connectionId, item.sourceCategoryIds, {}) with empty opts, whereas OfferBuilderService.resolveCategory (offer-builder.service.ts:298-307) threads sourceConnectionId: masterConnectionId and borrowedTaxonomy into the same resolveDestinationCategory.

I traced this and it is correct for every destination that exists today: the batch mapping fallback only runs past the isEanCategoryMatcher(adapter) gate (Allegro = owns taxonomy), and for an owns destination resolveDestinationCategory resolves entirely via step 1 (findBySourceCategory(destinationConnectionId, sourceCategoryId) — mapping-config.service.ts:156), which consults neither opt. The borrowed-taxonomy step 2 only fires when opts.borrowedTaxonomy is set, and borrows destinations (Erli) aren't EanCategoryMatchers so they degrade to no-match before reaching this loop.

The gap is future-facing: if a destination is ever both an EanCategoryMatcher and a borrows-taxonomy destination, this batch preview would silently skip the source-scoped borrowed reuse (#1045) and diverge from build-time resolution — reintroducing exactly the preview/build mismatch this PR fixes. The transport also can't carry sourceConnectionId, so the batch physically can't replicate the source-scoped fallback. Recommend a one-line comment here documenting why empty opts is safe (owns-only reachability), so the invariant is explicit for the next reader.

The bulk-offer wizard's Resolve step resolved the destination (Allegro)
category by EAN catalogue-match only and ignored the operator's configured
PS -> Allegro category mapping. A product whose EAN is not in Allegro's
catalogue but whose source category IS mapped was blocked in the wizard
(empty picker / "needs attention"), even though OfferBuilderService builds
the offer correctly server-side using that same mapping.

Make the batch resolve mapping-aware so the wizard preview agrees with
build-time resolution:

- Core: CategoryResolutionService.resolveCategoriesBatch now keeps EAN
  catalogue match as the PRIMARY path and, on an EAN no-match (or a
  variant with no EAN), consults the configured per-source-category
  mapping (the same mappingConfig.resolveDestinationCategory
  OfferBuilderService uses) when the item supplies sourceCategoryIds,
  returning a matched result with method="category_mapping" and no
  catalogue card.
- Thread the source category through FE -> API DTO -> core: batch item
  gains sourceCategoryIds; ProductResponseDto/Product transport expose
  the persisted source category ids (#1034); the Resolve step attaches
  each row's product.categories and reports the mapping method.

EAN match remains primary; mapping is the fallback only on no-match/no-ean.

Closes #1522

Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>
…ng fallback

Addresses piotrswierzy's non-blocking review note on #1523: the batch
resolve path calls tryCategoryMapping with empty opts, unlike the
single-offer path which threads sourceConnectionId/borrowedTaxonomy.
Documents why that's safe today and what would need to change if a
future destination is both an EanCategoryMatcher and a borrows-taxonomy
destination.

Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>
@norbert-kulus-blockydevs norbert-kulus-blockydevs force-pushed the 1522-bulk-wizard-category-mapping-resolve branch from 55e6099 to e45eed5 Compare July 14, 2026 09:00
@norbert-kulus-blockydevs

Copy link
Copy Markdown
Collaborator Author

Addressed the review feedback in e45eed5:

  • IMPORTANT (latent-divergence note, category-resolution.service.ts:159) — added an inline comment documenting why passing empty opts to tryCategoryMapping in the batch path is safe today (the loop only runs past the isEanCategoryMatcher gate, and every current matcher owns its taxonomy, so resolveDestinationCategory resolves via step 1 alone), and what would need to change (threading sourceConnectionId/borrowedTaxonomy through the transport) if a future destination is ever both an EanCategoryMatcher and a borrows-taxonomy destination.

The two SUGGESTIONs were left as-is since you framed them as out of scope for this PR:

  • productCardId: '' sentinel — you noted the empty-string convention is already established elsewhere (EanMatchResult.matched), so no change here.
  • Duplicate category-resolution.service.spec.ts files — pre-existing, not introduced by this PR; left for a future cleanup pass.

Also rebased onto latest main (no conflicts) and re-verified @openlinker/core type-check.

@norbert-kulus-blockydevs norbert-kulus-blockydevs merged commit 454a7c6 into main Jul 14, 2026
9 checks passed
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.

[BUG] Listings — bulk-offer wizard category resolution is EAN-only, ignores the PS→Allegro category mapping

2 participants