Skip to content

Add all combination endpoints - #102

Open
Jeremie-Kiwik wants to merge 8 commits into
PrestaShop:devfrom
Jeremie-Kiwik:pr-combinations
Open

Jeremie-Kiwik wants to merge 8 commits into
PrestaShop:devfrom
Jeremie-Kiwik:pr-combinations

Conversation

@Jeremie-Kiwik

@Jeremie-Kiwik Jeremie-Kiwik commented Nov 5, 2025 •

Copy link
Copy Markdown
Contributor
Questions Answers
Description? Add Product Combinations admin API endpoints (CRUD, stock, suppliers, images, searches)
Type? improvement
BC breaks? yes
Deprecations? no
Fixed ticket?
Sponsor company KIWIK
How to test? See below

EDIT 3 (2026-06-24) — Breaking change disclosure

Flagging explicitly that this PR is a BC breaks. Renames/reshapes some routes and fields that were already released (v0.3.0 → v0.7.0). (thanks @mattgoud for pointing this)

Routes renamed:

  • GET /products/{productId}/combination-ids → GET /products/{productId}/combinations/ids
  • POST /products/{productId}/generate-combinations → POST /products/{productId}/combinations

Generate combinations — request body changed:

  • groupedAttributes (array of {attributeGroupId, attributeIds} objects) → groupedAttributeIds (map {attributeGroupId: [attributeIds]})

Generate combinations — response body changed:

  • Previously returned only the newly created IDs: { "newCombinationIds": [1, 2, 3] }
  • Now returns the full combination listing instead, using the same standard paginated envelope as the list endpoint: { "items": [...], "totalItems": N }

Combination IDs list — response shape changed:

  • Previously a single object with a flat array: { "combinationIds": [1, 2, 3] }
  • Now a standard collection of objects: [{ "combinationId": 1 }, { "combinationId": 2 }, ...]

Combinations list — field renames (on top of the envelope change already disclosed in EDIT 2): the DTO now exposes the underlying Core field names directly instead of translating them:

  • name → combinationName
  • attributes → attributesInformation
  • impactOnPriceTaxExcluded → impactOnPrice

Single combination (get/update) — fields no longer exposed compared to the previous released version: impactOnPriceTaxIncluded, impactOnUnitPriceTaxIncluded, ecotaxTaxIncluded, productTaxRate, productPriceTaxExcluded, productEcotaxTaxExcluded, coverThumbnailUrl, and the root-level quantity. This is a scope reduction for this first version (tax-included variants and parent-product price info can be recomputed from the tax-excluded values and the existing Product endpoint); happy to restore any of these if needed.

Delete route changed:

  • PATCH /products/combinations/{combinationId}/images/clears → DELETE /products/combinations/{combinationId}/images

EDIT 2 (2026-05-12)

I ran the AI Context analysis tool (#188) and
applied the main proposals it raised:

  • ProductCombinationsList: replaced the CQRSGet wrapper DTO (productId / combinations /
    totalCombinationsCount) with a proper CQRSPaginate operation returning a standard paginated
    envelope (items / totalItems). Fixed URI variable resolution by declaring explicit
    uriVariables: ['productId' => new Link(identifiers: ['productId'])] and adding a hidden
    $productId property on the DTO — without this, API Platform auto-generates the uriVariables
    map from the identifier: true field (combinationId), which doesn't match the {productId}
    path segment and causes a 404.
  • ProductCombinationIdList: removed the erroneous identifier: true on $combinationId
    (same URI-variable mismatch issue as above).
  • ProductCombinationStock: removed dead $quantity write-only property (output: false
    endpoint, unused).
  • ProductCombinationSuppliers: added missing $combinationSuppliers write property with
    @NotBlank validation and the corresponding CQRSCommandMapping entry.
  • ProductCombinationsAssociationSearch: moved exceptionToStatus from the operation level
    to the #[ApiResource] class level (correct placement per convention).
  • GenerateCombinationsSerializer: added missing declare(strict_types=1).
  • Tests: added a stock delta movement before asserting stock-movements, so the assertion is
    always meaningful rather than conditional on pre-existing data.

The How-to-test section below has been updated to reflect the new paginated response format for
step 4.

EDIT

Changed endpoints to match the new ADR convention
#109

Endpoints summary

GET /products/combinations/{combinationId}
PATCH /products/combinations/{combinationId}
DELETE /products/combinations/{combinationId}

POST /products/{productId}/combinations
GET /products/{productId}/combinations/ids
GET /products/{productId}/combinations
DELETE /products/{productId}/combinations/bulk-delete

PATCH /products/combinations/{combinationId}/stocks
GET /products/combinations/{combinationId}/stock-movements

PATCH /products/combinations/{combinationId}/suppliers
GET /products/combinations/{combinationId}/suppliers

PATCH /products/combinations/{combinationId}/images
DELETE /products/combinations/{combinationId}/images

GET /products/{productId}/combinations/search
GET /products/combinations/associations/search

How to test

Create an API Client with these scopes: product_read & product_write
Request an access token

0. Create a product

  • Method: POST
  • URI: /admin-api/products
  • Body:
{
  "productType": "combinations",
  "names": {
    "fr-FR": "Test combinated product"
  }
}

Get the product ID in the response. We will use it as {productId} for other calls

1. Generate product combinations

(GenerateProductCombinationsCommand command)

Will add 4 combinations, with:

  • attribute group 'Size' (id_attribute_group = 1)

    • Size M (id_attribute = 2)
    • Size L (id_attribute = 3)
  • attribute group 'Color' (id_attribute_group = 2)

    • Color Red (id_attribute = 10)
    • Color Blue (id_attribute = 14)
  • Method: POST

  • URI: /admin-api/products/{productId}/combinations

  • Body:

{
  "groupedAttributeIds": {
    "1": [2, 3],
    "2": [10, 14]
  }
}
  • Response:
    HTTP Code: 201
    HTTP Body: standard paginated envelope — items containing 4 combinations, and totalItems

Please note the 4 combinationId, we will use them below. Let's call them {combinationId_1}, ..., {combinationId_4}, in ascending order.

You should see the combinations in back office too.

2. Get combinations IDs

(GetCombinationIds command)

  • Method: GET
  • URI: /admin-api/products/{productId}/combinations/ids
  • Response:
    HTTP Code: 200
    HTTP Body: array of { "combinationId": number } with our 4 Ids

3. Update a combination

(UpdateCombinationCommand command)

  • Method: PATCH
  • URI: /admin-api/products/combinations/{combinationId_1}
  • Body (example):
{
  "default": true,
  "reference": "REF-001",
  "gtin": "3519690900332",
  "isbn": "978-3-16-148410-0",
  "mpn": "MPN-123",
  "upc": "72527273070",
  "impactOnWeight": 0.15,
  "impactOnPrice": 1.99,
  "ecoTax": 0.2,
  "impactOnUnitPrice": 0.3,
  "wholesalePrice": 12.5,
  "minimalQuantity": 2,
  "lowStockThreshold": 5,
  "availableDate": "2025-12-31T00:00:00+00:00",
  "availableNowLabels": {
    "fr-FR": "En stock",
    "en-GB": "In stock"
  },
  "availableLaterLabels": {
    "fr-FR": "Bientôt",
    "en-GB": "Later"
  }
}
  • Response:
    HTTP Code: 200
    HTTP Body: updated combination

4. Get all combinations (paginated list)

(GetEditableCombinationsList command)

  • Method: GET
  • URI: /admin-api/products/{productId}/combinations
  • Optional query params: limit, offset, orderBy, orderWay
  • Response:
    HTTP Code: 200
    HTTP Body (standard paginated envelope):
{
  "items": [
    {
      "combinationId": 42,
      "combinationName": "Size: M - Color: Red",
      "reference": "REF-001",
      "default": true,
      "impactOnPrice": 1.99,
      "quantity": 0,
      "imageUrl": "",
      "ecoTax": 0.2,
      "attributesInformation": [
        { "attributeGroupId": 1, "attributeGroupName": "Size", "attributeId": 2, "attributeName": "M" },
        { "attributeGroupId": 2, "attributeGroupName": "Color", "attributeId": 10, "attributeName": "Red" }
      ]
    }
  ],
  "totalItems": 4
}

Pagination example: /admin-api/products/{productId}/combinations?limit=2&offset=0

5. Get single combination detail

(GetCombinationForEditing command)

  • Method: GET
  • URI: /admin-api/products/combinations/{combinationId_1}
  • Response:
    HTTP Code: 200
    HTTP Body: full combination details (pricing, labels, dates, identifiers)

6. Delete a single combination

(DeleteCombinationCommand command)

Let's delete the 'L - size' / 'Red - color' combination:

  • Method: DELETE
  • URI: /admin-api/products/combinations/{combinationId_3}
  • Response:
    HTTP Code: 204
    HTTP Body: none

7. Bulk delete combinations

(BulkDeleteCombinationCommand command)

Let's delete now all the blue combinations:
(change {combinationId_x} in the body with real values)

  • Method: DELETE
  • URI: /admin-api/products/{productId}/combinations/bulk-delete
  • Body:
{
  "combinationIds": [{combinationId_2}, {combinationId_4}]
}
  • Response:
    HTTP Code: 204
    HTTP Body: none

8. Update combination stock

(UpdateCombinationStockAvailableCommand command)

Fixed quantity

  • Method: PATCH
  • URI: /admin-api/products/combinations/{combinationId_1}/stocks
  • Body (fixed quantity):
{
  "location": "somewhere",
  "fixedQuantity": 42
}
  • Response:
    HTTP Code: 204
    HTTP Body: none
  • Validation error (if both fixed and delta provided):
    HTTP Code: 422

On back-office, you should see a quantity of 42 for the combination.

Delta quantity

  • Method: PATCH
  • URI: /admin-api/products/combinations/{combinationId_1}/stocks
  • Body (delta quantity):
{
  "deltaQuantity": -10
}
  • Response:
    Same as fixed quantity

On back-office, you should see a quantity of 32 for the combination.

9. Get stock movements

(GetCombinationStockMovements command)

  • Method: GET
  • URI: /admin-api/products/combinations/{combinationId_1}/stock-movements?limit=5
  • Response:
    HTTP Code: 200
    HTTP Body: array of movements (type, dates, ids, deltaQuantity, employeeName)

10. Update suppliers for a combination

(UpdateCombinationSuppliersCommand command)

Before testing, suppliers must be linked to the product.
The endpoint to associate suppliers with a product has not been implemented yet. It must be done manually from the back office:

  • go to the product
  • "Options" tab
  • Check the 2 suppliers "Accessories supplier" + "Fashion supplier"
    • save
  • (This will also create a line in DB for the product's combinations, with default values and price = 0)

Here we go: update information.

  • Method: PATCH
  • URI: /admin-api/products/combinations/{combinationId_1}/suppliers
  • Body (example):
{
  "combinationSuppliers": [
    {
      "supplier_id": 1,
      "currency_id": 1,
      "reference": "SUP-REF-001",
      "price_tax_excluded": "10.50"
    },
    {
      "supplier_id": 2,
      "currency_id": 1,
      "reference": "SUP-REF-002",
      "price_tax_excluded": "20.00"
    }
  ]
}
  • Response:
    HTTP Code: 204
    HTTP Body: none

Use the GET endpoint below to verify the updated suppliers list.

11. Get suppliers associated to a combination

(GetCombinationSuppliers command)

  • Method: GET
  • URI: /admin-api/products/combinations/{combinationId_1}/suppliers
  • Response:
    HTTP Code: 200
    HTTP Body: array of suppliers (productSupplierId, productId, supplierId, supplierName, reference, priceTaxExcluded, currencyId, combinationId)

12. Associate images to a combination

(SetCombinationImagesCommand command)

First add 3 random images to the product {productId}, on the back office. We will then add 2 of them to the combination

Get the associated id_image in DB. We will use them as {imageId_1}, {imageId_2} and {imageId_3}.

If you don't have access to the DB, reload the page then inspect the DOM of the image to get the data-id

You will have something like:

<div class="dz-preview is-cover dz-complete dz-image-preview" data-id="27">

Your id is 27

  • Method: PATCH
  • URI: /admin-api/products/combinations/{combinationId_1}/images
  • Body:
    (replace {imageId_x} with the real values)
{
  "imageIds": [{imageId_1}, {imageId_3}]
}
  • Response:
    HTTP Code: 200
    HTTP Body: updated combination (at least combinationId, imageIds)

On back-office, you should see that 2 images are linked to the combination (with a black border around the images)

13. Remove all images from a combination

(RemoveAllCombinationImagesCommand command)

  • Method: DELETE
  • URI: /admin-api/products/combinations/{combinationId}/images
  • Response:
    HTTP Code: 204
    HTTP Body: none

On back-office, images are no more linked to the combination (no black border around them)

14. Search combinations (scoped to a product)

(SearchProductCombinations command)
Will search combinations with for example, a given string in attributes

  • Method: GET
  • URI: /admin-api/products/{productId}/combinations/search?phrase=rouge&limit=5

Note: The search strings are localized. So if you have an English PS, please test search?phrase=red instead, like this:
/admin-api/products/{productId}/combinations/search?phrase=red&limit=5

  • Response:
    HTTP Code: 200
    HTTP Body:
{
  "productId": {productId},
  "combinations": [
    { "combinationId": x, "combinationName": "xxxx" }
  ]
}

If you search for a non-existent string, combinations should be empty.

15. Search combinations for association (global search)

(SearchCombinationsForAssociation command)

Important note: this search DOES NOT search in attribute names; it searches product/combination name and references (like ref, ean13, upc, mpn, isbn, supplier_reference...)

  • Method: GET
  • URI: /admin-api/products/combinations/associations/search?phrase=REF&limit=5
  • Response:
    HTTP Code: 200
    HTTP Body: array of { productId, combinationId, name, reference, imageUrl }
    Or an empty array if nothing is found

@jf-viguier

Copy link
Copy Markdown
Contributor

nice PR ! whaou ! 15 endpoints

@kpodemski

Copy link
Copy Markdown
Contributor

Hello @Jeremie-Kiwik

Thank you for this PR. There are a few errors in the integration tests, looks like some typos in the paths, could you take a look?

@ps-jarvis ps-jarvis moved this from Ready for review to Waiting for author in PR Dashboard Nov 11, 2025
@Jeremie-Kiwik

Copy link
Copy Markdown
Contributor Author

@kpodemski : ah sorry, I thought those were internal errors in the deployment of the test environments. I didn’t really look into it any further.
The unit tests are now OK

@nicosomb

Copy link
Copy Markdown
Contributor

@Jeremie-Kiwik there is a conflict on your PR.

@Jeremie-Kiwik
Jeremie-Kiwik force-pushed the pr-combinations branch 2 times, most recently from b23ee33 to 7aefe3d Compare November 24, 2025 14:58
@kpodemski

Copy link
Copy Markdown
Contributor

Hello @Jeremie-Kiwik

Thanks for fixing the conflicts, the last step is making sure to have the CI 🟢. Thank you 🙏🏻

@kpodemski kpodemski added the Invalid This doesn't seem right label Jan 23, 2026
@kpodemski

Copy link
Copy Markdown
Contributor

Hello @Jeremie-Kiwik

Just a quick heads-up: reviews on pending Admin API PRs will start in the coming days.

We first took some time to clarify and unify the Admin API contribution rules and ADR expectations. With that work done, the team will now review existing PRs based on those updates.

Please note that, based on the updated standards described here:
https://devdocs.prestashop-project.org/9/admin-api/contribute-to-core-api/

some aspects of this PR currently do not meet the requirements. For this reason, I've added the Invalid label for now.

Someone from the team will take a closer look at the PR and provide concrete suggestions on how it can be adjusted to align with the new guidelines.

Thanks for your patience. Feedback will follow directly on the PR.

@TomasEm

TomasEm commented Jan 30, 2026

Copy link
Copy Markdown

Hi,
please, forgive me my lack of knowledge of push request procedure.
Can I ask for timeline of this PR to be integrated in main release? As it is important, I can wait, but not for a long time.
thanks

@nicosomb

nicosomb commented May 7, 2026

Copy link
Copy Markdown
Contributor

@Jeremie-Kiwik you sill have conflict, sorry. Could you fix it please?

@Jeremie-Kiwik

Copy link
Copy Markdown
Contributor Author

@nicosomb : done. I also ran your new IA context to dig deeper and find some flaws. It should be robust / clean, now.

@Quetzacoalt91

Quetzacoalt91 commented May 15, 2026 •

Copy link
Copy Markdown
Member

@Jeremie-Kiwik the PR can't be reviewed in the current state, a merge probably went wrong and many commit from the original branch are now in your history.

Can you please rebase?

@Jeremie-Kiwik

Copy link
Copy Markdown
Contributor Author

@Quetzacoalt91 is it better now?

@Quetzacoalt91

Copy link
Copy Markdown
Member

Yep that's better, thanks

@Quetzacoalt91 Quetzacoalt91 removed Waiting for author Invalid This doesn't seem right labels May 15, 2026

@mattgoud mattgoud left a comment

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.

thanks for the PR @Jeremie-Kiwik 🙏 reviewing as part of the sheriff rotation. solid work overall — the 15 CQRS command/query mappings all check out, the requirements: ['productId' => '\d+'] added to Product.php are exactly what's needed to disambiguate /products/combinations/... from /products/{productId}, every new resource has its \d+ requirements, scopes/strict_types/exception mappings are consistent, the tests use restoreAllTables(), and the validation unit test is a nice touch. a few things before merge:

1. this renames endpoints that already shipped (v0.3.0 → v0.7.0) — please flag it
the PR restructures released routes:

  • GET /products/{productId}/combination-ids → /products/{productId}/combinations/ids
  • POST /products/{productId}/generate-combinations → POST /products/{productId}/combinations
  • generate body: groupedAttributes (array of objects) → groupedAttributeIds (map)

that's a breaking change to the public API surface. it's targeting dev and the module is pre-1.0 / the Admin API is still experimental, so it's allowed — but the description has BC breaks: no and there's no CHANGELOG / migration note. could you flag the BC break in the description and add a note for consumers? and it'd be good to get a maintainer (cc the API team) to explicitly sign off on the rename rather than slip it in silently.

2. /images/clears — rename before it ships
the URI /products/combinations/{combinationId}/images/clears reads oddly ("clears" plural) and uses PATCH for a destructive clear-all. cheaper to get right now than after release — consider DELETE .../images or .../images/clear. (see inline)

3. test lost the product_read scope
createApiClient(['product_write']) dropped the product_read the old setup pre-created. not a failure (tokens auto-provision per scope), just a perf/consistency regression — worth restoring both.

nice PR — main ask is just making the BC story explicit. 👍

(verified non-issues while reviewing: the BulkProductCombinations mapping is fine — NormalizationMapper is additive so combinationIds passes through by name; the ?? [] in the generate serializer can't silently generate nothing since NotBlank catches it; the bootstrap require_once is a legit PHPUnit load-order workaround.)

#[ApiProperty(openapiContext: ['type' => 'object', 'additionalProperties' => ['type' => 'array', 'items' => ['type' => 'integer']], 'example' => ['1' => [2, 3], '2' => [10, 14]]])]
#[Assert\NotBlank(groups: ['Create'])]
#[Assert\Type('array', groups: ['Create'])]
public array $groupedAttributeIds;

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 breaking change vs the released endpoint: the old GenerateCombinations took groupedAttributes as an array of {attributeGroupId, attributeIds} objects, this now takes groupedAttributeIds as a map. fine for a pre-1.0 / experimental API, but please flag the BC break in the PR description + a migration note, since it shipped in v0.3.0–v0.7.0.

#[ApiResource(
operations: [
new CQRSPartialUpdate(
uriTemplate: '/products/combinations/{combinationId}/images/clears',

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.

/images/clears is an awkward name ("clears" plural) and PATCH is a weak verb for a destructive clear-all. since this hasn't shipped yet, cheaper to fix now — DELETE /products/combinations/{combinationId}/images (or .../images/clear) would read better.

self::$attributeData[$attribute->name[1]] = (int) $attribute->id;
}
}
self::createApiClient(['product_write']);

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 dropped the product_read scope the old setup pre-created — the suite still passes (tokens auto-provision per requested scope) but it re-creates an API client on the first read call. worth restoring ['product_write', 'product_read'] for the pre-warm optimization.

@mattgoud mattgoud added the Need AI review Trigger: Request an AI pre-review from Claude label Jun 19, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🤖 Claude AI Pre-Review — Automated analysis. Does not replace human review.

📋 Summary of changes

This PR adds a comprehensive suite of combination endpoints: single-combination GET/PATCH/DELETE, paginated list, ID collection, generate (POST), bulk-delete, stock update + stock movements, suppliers read/write, image set + image clear, and two search operations. It replaces five older files (Combination.php, CombinationList.php, CombinationIdList.php, GenerateCombinations.php, and the partial test) with eleven new resource classes and a substantially rewritten integration test. The GenerateCombinationsSerializer normalizer is also updated to match the new groupedAttributeIds request shape.

⏱️ Estimated review time

60–90 minutes — large surface (15+ files), multiple operation types, non-trivial test rewrites.

🎯 Scope

  • Exposed operations: GET / POST / PATCH / DELETE / list / collection / bulk-delete / search
  • CQRS entity: Combination (Product\Combination domain)
  • Integration test: yes — full rewrite of ProductCombinationEndpointTest.php
🧱 API Platform / CQRS architecture compliance

🔴 Hard Blocker — Custom normalizer still present

src/ApiPlatform/Normalizer/GenerateCombinationsSerializer.php is a custom DenormalizerInterface living inside the module. CONTEXT.md: "Don't write custom normalizers or custom processors. CI enforces this." The PR modifies (rather than introduces) this file, but CI will still reject the PR. The normalizer coerces JSON object string-keys to int for groupedAttributeIds; this needs either a Core-side fix or explicit sign-off from maintainers.


🔴 Likely Bug — ApiResourceMapping on CQRSGet is a no-op

ProductCombinationSearch.php:

new CQRSGet(
    CQRSQueryMapping: [ '[phrase]' => '[searchPhrase]', ... ],
    ApiResourceMapping: [
        '[productCombinations]' => '[combinations]',  // problem
    ],
),

Per CONTEXT.md, ApiResourceMapping is only processed by PaginatedList / CQRSPaginate. On a CQRSGet it is silently ignored, so the productCombinations → combinations rename never happens. $combinations would always be the default empty []. The rename must move into CQRSQueryMapping.


🟠 limit/offset not wired in CQRSQueryMapping

ProductCombinationIdList.php and ProductCombinationStockMovement.php declare limit/offset as QueryParameter for OpenAPI, but do not include them in CQRSQueryMapping. Without '[limit]' => '[limit]' entries the values are never forwarded to the CQRS query constructor. testGetCombinationIds asserts limit=1 returns one item — please confirm this test actually passes; if it does there is an implicit pass-through not documented in CONTEXT.md.


🟠 Shared QUERY_MAPPING may omit imageIds from response

ProductCombinationImages and ProductCombinationImagesClear reuse ProductCombination::QUERY_MAPPING. That constant maps ~15 fields that do not exist as properties on the images DTOs (silently skipped), and it does not contain an entry for imageIds. After a successful PATCH /images, the imageIds field in the response may be empty unless GetCombinationForEditing exposes it at the top level with the exact same name. Please verify with a real request.


🟡 $availableDate typed as ?string instead of DateTimeImmutable

ProductCombination.php — CONTEXT.md: "Dates — DateTimeImmutable is allowed" (preferred over string). Use ?DateTimeImmutable.


🟡 $priceTaxExcluded typed as string in ProductCombinationSuppliers

CONTEXT.md: "Decimals — use DecimalNumber, never float". Every other price field in the module uses DecimalNumber. Should be ?DecimalNumber.


🟡 ProductCombinationSuppliers — missing #[ApiProperty(identifier: true)]

URI template /products/combinations/{combinationId}/suppliers contains {combinationId}. CONTEXT.md: "The DTO property exposed as the identifier must match the URI parameter and be marked #[ApiProperty(identifier: true)]". ProductCombinationImages and ProductCombinationStock both annotate $combinationId correctly. ProductCombinationSuppliers only has a nullable ?int $combinationId read-model property with no identifier annotation.


🟡 /images/clears URI — verb form

CONTEXT.md: URIs are "plural, lowercase, kebab-case" resource nouns. clears reads as a verb. Consider /images/clear (noun sense) or /images/reset.


🟡 Non-standard response envelope from GenerateProductCombinations

CQRSPaginate returns { "items": [...], "totalItems": N }. The generate endpoint returns { "combinations": [...], "totalCombinationsCount": N }. The test itself hedges with $list['combinations'] ?? $list['items'] ?? [], flagging the inconsistency. Consumers should not need to special-case the two endpoints.


🟡 Breaking field renames vs. the old list endpoint

ProductCombinationsList renames several API fields compared to the deleted CombinationList: name → combinationName, attributes → attributesInformation, impactOnPriceTaxExcluded → impactOnPrice, and productId is now hidden. Please confirm whether the old endpoints were considered stable before treating these as non-breaking.

💡 Improvement suggestions

Test independence vs. @depends chains — CONTEXT.md requires: "Chain CRUD test methods with @depends so each step builds on the previous one's created entity." Every test method creates its own product. Consider grouping the get → patch → delete single-combination tests into a @depends chain.

DatabaseDump::restoreAllTables() is overly broad — CONTEXT.md calls for restoreTables([...]) with explicit table names.

Missing LanguageResetter — ProductCombination has #[LocalizedValue] on $availableNowLabels / $availableLaterLabels. CONTEXT.md: "Include LanguageResetter only if the entity has localized fields."

fr-FR locale missing from PATCH test data — The default test environment installs fr-FR. testPartialUpdateCombination sends availableNowLabels only with en-US.

Weak assertions — testGetCombinationForEditing and testPartialUpdateCombination only call assertArrayHasKey('combinationId', ...). CONTEXT.md: test "asserts all fields". Assert reference === 'REF-UPDATED' and a representative set of scalar fields.

Orphan mapping entry — GenerateProductCombinations::CQRSQueryMapping has '[productId]' => '[productId]'. GetEditableCombinationsList result has no top-level productId; $productId is already set via the [_context][uriVariables] entry. Remove to reduce noise.

✅ Pre-review checklist

URI & routing

  • URI is plural, lowercase, kebab-case
  • Identifier uses domain name + Id suffix
  • Sub-resources follow parent path
  • Bulk operation URI uses bulk- prefix and plural Ids parameter
  • /images/clears — clears is a verb, not a resource noun

Operations & scopes

  • Correct operation attribute per HTTP method
  • Scope format: product_read / product_write, singular form

API Resource properties

  • All properties strictly typed, scalars/arrays only
  • $availableDate typed as ?string instead of ?DateTimeImmutable
  • $priceTaxExcluded typed as string instead of DecimalNumber (ProductCombinationSuppliers)
  • Naming conventions respected (default not isDefault, no active, no localized prefix)
  • #[ApiProperty(identifier: true)] present on most DTOs
  • ProductCombinationSuppliers — no #[ApiProperty(identifier: true)] for {combinationId}
  • #[LocalizedValue] on $availableNowLabels / $availableLaterLabels

CQRS mapping

  • QUERY_MAPPING direction: QueryResult field → API field
  • CQRSCommandMapping direction: API field → Command parameter
  • CQRSQuery present on CQRSCreate and CQRSPartialUpdate operations that return state
  • ProductCombinationSearch — ApiResourceMapping on CQRSGet is a no-op; $combinations never populated
  • No SerializedName — mappings only

Forbidden practices (CI-enforced)

  • Hard blocker: GenerateCombinationsSerializer custom normalizer still present in module
  • No Value Objects in properties (other than allowed DecimalNumber)

Exception handling & validation

  • ConstraintException → 422, NotFoundException → 404
  • Correct validationContext groups on Create / Update operations

Multi-shop

  • [_context][shopConstraint] passed to CQRS commands/queries where appropriate
  • No shopIds DTO property (combinations are not directly shop-associated)

Listing field alignment (ProductCombinationsList)

  • DTO properties match EditableCombinationForListing fields
  • ApiResourceMapping covers isDefault → default rename
  • No orphan DTO properties identified

Integration test

  • Extends ApiTestCase
  • @depends chain absent — each test is self-contained (violates CONTEXT.md convention)
  • testGetCombinationForEditing and testPartialUpdateCombination assert only identifier, not all fields
  • testInvalid* / assertValidationErrors present for generate, bulk-delete, suppliers, images
  • getProtectedEndpoints() lists all 15 new URIs
  • DatabaseDump::restoreAllTables() instead of specific restoreTables([...])
  • Missing LanguageResetter despite localized fields on the combination DTO
  • declare(strict_types=1) present in test file

@github-actions github-actions Bot added AI reviewed Status: Claude AI has already pre-reviewed this PR and removed Need AI review Trigger: Request an AI pre-review from Claude labels Jun 19, 2026
@Jeremie-Kiwik

Copy link
Copy Markdown
Contributor Author

@mattgoud hello, I didn't forget you. I'll do the changes this week, sorry for the delay 🙏.
I'll make a 1st commit for your required changes, then others for the Claude recommandations. It would be easier to review this way

@Jeremie-Kiwik

Copy link
Copy Markdown
Contributor Author

@mattgoud: it should be ok now!

@PrestaEdit

Copy link
Copy Markdown
Contributor

Heads-up for cross-referencing: a subset of the combination endpoints proposed here has since been merged into dev via #121 — namely the read/generate ones:

  • GET single combination (GetCombinationForEditing)
  • GET combination IDs list (GetCombinationIds)
  • GET editable combinations list (GetEditableCombinationsList)
  • Generate combinations (GenerateProductCombinationsCommand)

The write side of this PR is not in dev yet and is still the valuable part: UpdateCombinationCommand, DeleteCombinationCommand, BulkDeleteCombinationCommand, SetCombinationImagesCommand, RemoveAllCombinationImagesCommand, UpdateCombinationStockAvailableCommand, UpdateCombinationSuppliersCommand, GetCombinationStockMovements, GetCombinationSuppliers, SearchProductCombinations, SearchCombinationsForAssociation. Might be worth rebasing to drop the already-merged overlap and focus the PR on the remaining endpoints.

@jolelievre

Copy link
Copy Markdown
Contributor

Heads up on a small overlap: #410 (centralization of the pending Product-domain endpoints) now exposes UpdateCombinationStockAvailableCommand as PUT /products/combinations/{combinationId}/stock — it was needed there so the stock-movement integration tests could build their fixtures through the API alone.

Everything else in this PR (combination update/delete/bulk-delete, images, suppliers, stock movements, etc.) remains uncovered and very much wanted. When rebasing, you can drop the UpdateCombinationStockAvailableCommand endpoint and align with the conventions applied in #410 if that helps:

  • singular stock segment (a combination has a single stock), with a matching keyword exception in ApiResourceUriTemplateRector
  • the write operation returns the updated stock state through CQRSQuery: GetCombinationForEditing
  • integration test fixtures created through the API alone (attribute group → attributes → product → generate-combinations), whole-structure assertEquals per entity

Thanks for the contribution!

FIX phpstan: remove custom provider + normalize query params

Rewrite unit tests without Symfony\Component\Validator (only available in the global PS vendor, not in the module test env)

Remove custom processors + more robust code (use Claude AI Pre-Review recommandations)

End of Claude proposal changes

Last Claude review
… standardize the generate-combinations response envelope
@Jeremie-Kiwik

Copy link
Copy Markdown
Contributor Author

OK, not an easy fix 😅

As #121 is already merged, a chunk of what this PR originally added is redundant, and #410 (still open) is about to cover another piece of it. Trimmed the PR down accordingly, per PrestaEdit's and @jolelievre's comments above (with Claude's support, I must say)

Here we go:

Removed — already merged in dev via #121

  • GET /products/combinations/{combinationId} (Combination.php) - removed
  • GET /products/{productId}/combination-ids (CombinationIdList.php) - removed
  • GET /products/{productId}/combinations paginated list (CombinationList.php) - removed
  • POST /products/{productId}/generate-combinations (GenerateCombinations.php + GenerateCombinationsSerializer.php) - removed

Removed — per #410's design decision

#410 explicitly states SearchProductCombinations and SearchCombinationsForAssociation are "intentionally NOT exposed: they duplicate existing endpoints", superseding earlier attempts (#352, #286). Aligning with that:

  • GET /products/{productId}/combinations/search (ProductCombinationSearch.php) — removed
  • GET /products/combinations/associations/search (ProductCombinationsAssociationSearch.php) — removed

Removed — overlaps with #410 (still open)

#410 exposes PUT /products/combinations/{combinationId}/stock (UpdateCombinationStockAvailableCommand) as part of its own scope. Per @jolelievre's comment, dropping the equivalent endpoint here now rather than fighting over it once #410 merges:

  • PATCH /products/combinations/{combinationId}/stocks (ProductCombinationStock.php) — removed

Kept — still not covered anywhere else

AKA: the remaining endpoints

  • PATCH / DELETE /products/combinations/{combinationId} — (merged my code in Combination.php instead of my ProductCombination.php class)
  • DELETE /products/{productId}/combinations/bulk-delete (BulkProductCombinations.php)
  • PATCH / DELETE /products/combinations/{combinationId}/images (ProductCombinationImages.php, ProductCombinationImagesClear.php)
  • GET / PATCH /products/combinations/{combinationId}/suppliers (ProductCombinationSuppliers.php)
  • GET /products/combinations/{combinationId}/stock-movements (ProductCombinationStockMovement.php)

Also

  • Fixed a stale doc reference (.ai/Component/CONTEXT.md "Sub-resource" example still pointed at the now-removed ProductCombination.php).

@mattgoud mattgoud left a comment

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.

thanks for the trim @Jeremie-Kiwik 🙏 and sorry for the silence on my side, i owed you a reply since june.

first, closing my old review: all 3 points are done ✅ BC break disclosed in the description (and mostly moot now that those routes went out with the prune), /images/clears is now DELETE .../images, and product_read is back in the test setup.

the reduced scope looks right to me: what's left is exactly the part neither #121 nor #410 covers.

on CI: the red PHPStan (9.0.3) is an infra flake, the prestashop/statsforecast zip came down corrupted during composer install. a re-run should clear it, nothing to fix on your side.

a few things on the new state:

1. combinationSuppliers payload is snake_case
the request body exposes supplier_id / currency_id / price_tax_excluded / product_supplier_id. that's UpdateCombinationSuppliersCommand's internal array shape leaking into the public API, everything else we expose is camelCase. the mapper handles this with index placeholders, SearchAlias.php:110-122 is the precedent. (see inline)

2. no validation on the inner supplier items (returns 500)
#[Assert\NotBlank] only guards the outer array. setCombinationSuppliers() reads $productSupplier['supplier_id'] unguarded and hands it to CombinationSupplierAssociation::__construct(int $combinationId, int $supplierId, ...), so {"combinationSuppliers":[{"reference":"x"}]} gives undefined-key + TypeError = 500 instead of 422. needs a nested Assert\All/Assert\Collection or a real nested DTO. (see inline)

3. merge ProductCombinationImagesClear into ProductCombinationImages
both operate on /products/combinations/{combinationId}/images, and the convention here is one class per resource carrying several operations: ProductCategory.php does exactly that (CQRSCreate + CQRSDelete on the same URI), ProductImage.php has 3. drops a file. (see inline)

4. nit: stock-movements param defaults are strings
schema: ['type' => 'integer', 'default' => '5'], the default should be an int, otherwise the generated openapi schema contradicts its own type. (see inline)

rest reads clean, mappings all check out, and the test rework (mutated-tables restore + real stock delta before asserting movements) is a nice improvement 👍

],
CQRSCommandMapping: [
'[_context][uriVariables][combinationId]' => '[combinationId]',
'[combinationSuppliers]' => '[combinationSuppliers]',

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.

the payload keys here come straight from UpdateCombinationSuppliersCommand's raw array (supplier_id, currency_id, reference, price_tax_excluded, product_supplier_id), so an internal shape ends up in the public API while everything else we expose is camelCase.

the mapper supports index placeholders, so you can keep the DTO camelCase and translate on the way in:

CQRSCommandMapping: [
    '[_context][uriVariables][combinationId]' => '[combinationId]',
    '[combinationSuppliers][@index][supplierId]' => '[combinationSuppliers][@index][supplier_id]',
    '[combinationSuppliers][@index][currencyId]' => '[combinationSuppliers][@index][currency_id]',
    '[combinationSuppliers][@index][reference]' => '[combinationSuppliers][@index][reference]',
    '[combinationSuppliers][@index][priceTaxExcluded]' => '[combinationSuppliers][@index][price_tax_excluded]',
    '[combinationSuppliers][@index][productSupplierId]' => '[combinationSuppliers][@index][product_supplier_id]',
],

src/ApiPlatform/Resources/SearchAlias/SearchAlias.php:110-122 is the in-repo precedent for this. the openapi schema and the how-to-test body below would need the same rename.

],
])]
#[Assert\NotBlank(groups: ['Update'])]
public ?array $combinationSuppliers = null;

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.

NotBlank only guards the outer array, nothing validates the items. UpdateCombinationSuppliersCommand::setCombinationSuppliers() reads the keys unguarded:

new CombinationSupplierAssociation(
    $this->combinationId->getValue(),
    $productSupplier['supplier_id'],
    ...

and the VO signature is __construct(int $combinationId, int $supplierId, ?int $productSupplierId = null). so PATCH {"combinationSuppliers": [{"reference": "x"}]} gives an undefined-key warning then a TypeError, i.e. a 500 where the API should answer 422.

an Assert\All + Assert\Collection on the required keys (or a proper nested DTO) would cover it. worth a test case too.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

OK, I implemented it. Note: I had to duplicate snake + camel keys, as after normalization both are present (same behavior than in src/ApiPlatform/Resources/SearchAlias/SearchAlias.php)

The result would be:

    #[Assert\All(constraints: [
        new Assert\Collection(
            fields: [
                'supplierId' => [new Assert\NotBlank(), new Assert\Type('integer')],
                // Add supplier_id because after normalization both supplierId and supplier_id are present
                'supplier_id' => new Assert\Optional(new Assert\Type('integer')),

(Another option would be to add a allowExtraFields: true, but this would accept any unknown value, so I prefer to be deterministic)

Is it OK for you like this ?


#[ApiResource(
operations: [
new CQRSDelete(

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 operates on the same URI as ProductCombinationImages (/products/combinations/{combinationId}/images), so it can live in that class as a second operation rather than in its own file. output: false and the exception map are per-operation, so nothing is lost.

precedent: ProductCategory.php carries CQRSCreate + CQRSDelete on /products/{productId}/categories, and ProductImage.php groups get/update/delete on /products/images/{imageId}.

parameters: [
'limit' => new QueryParameter(
key: 'limit',
schema: ['type' => 'integer', 'default' => '5'],

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.

nit: 'default' => '5' (and '0' just below) are strings in a schema declared 'type' => 'integer', so the generated openapi contradicts itself. 'default' => 5 / 'default' => 0.

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

Labels

Admin API Contributions AI reviewed Status: Claude AI has already pre-reviewed this PR Waiting for author

Projects

Status: Waiting for author

Development

Successfully merging this pull request may close these issues.