Date: 2026-05-28 Status: Design — pending implementation
Wheel compatibility today has only a create path. Once a WheelCompatibility row is written, it is immutable: there is no way to fix a mistake, no way to improve manually-entered data with later PDF-extracted data, and no way to link a car to a wheel compatibility that already exists for a different car (the typical AT flow today only works because the AT UI calls a series of GETs and an undocumented assignment step).
The immediate driver is data quality. PDF capture (DE-only) produces highly reliable data; manual capture (AT) is unreliable. When a DE user uploads a PDF whose typeApprovalNumber collides with an existing AT-manual row, today's create call rejects with DuplicateRetailAdException or hits a DB UNIQUE constraint on wheel_compatibility.type_approval_number. The DE PDF data — which is canonical — is lost.
The broader problem is that wheel compatibility needs a real update story going forward: AT users will continue to make manual entries that occasionally need correction, and the system should let them fix mistakes without inventing a workaround.
Add update and assignment endpoints so that:
- Existing wheel compatibility rows can be updated with corrected or improved data.
- A car can be linked to a pre-existing wheel compatibility row without re-entering the data.
- PDF-sourced data is treated as canonical and cannot be overwritten by manual data.
- The DE HSN/TSN-based fan-out chain continues to propagate the best available data to matching cars.
- No retroactive re-upgrade of historical AT-manual rows.
- No new authorization model. All three endpoints reuse the existing
Publisherrole and country check (retailWheelSetConfigurationPort.isCountryAllowed). - No partial-field updates. Update is a full overwrite of mutable fields.
- No bulk operations.
- No exposure of
dataSourceas an explicit field in any request body. It is derived server-side.
New enum in com.auto1.api.retailwheel.wheelcompatibility.domain:
public enum DataSource {
PDF,
MANUAL
}The existing record/class adds a required DataSource dataSource field. Derivation rule applied at every insert and update:
incomingSource = (fileSourceUrl is non-blank) ? PDF : MANUAL
The derivation lives inside the use cases. CreateWheelCompatibilityCommand and the new update command do not accept dataSource from callers.
Existing rows are backfilled with the same derivation:
data_source = CASE WHEN file_source_url IS NOT NULL THEN 'PDF' ELSE 'MANUAL' ENDAny row where file_source_url is null was created via manual entry (the PDF extraction job always populates it). Rows where file_source_url is non-null came from a PDF extraction.
New file under src/main/resources/db/migration/<next-version>/V<ts>__add_data_source_to_wheel_compatibility.sql:
ALTER TABLE retail_wheel.wheel_compatibility
ADD COLUMN data_source VARCHAR(16);
UPDATE retail_wheel.wheel_compatibility
SET data_source = CASE
WHEN file_source_url IS NOT NULL THEN 'PDF'
ELSE 'MANUAL'
END;
ALTER TABLE retail_wheel.wheel_compatibility
ALTER COLUMN data_source SET NOT NULL;
COMMENT ON COLUMN retail_wheel.wheel_compatibility.data_source IS
'Origin of the wheel compatibility data. PDF = extracted from an uploaded document (DE flow). MANUAL = entered by hand (AT flow, and rare DE fallback).';Add:
@Enumerated(EnumType.STRING)
@Column(name = "data_source", nullable = false)
private DataSource dataSource;Map dataSource both directions. No custom logic — straight enum-to-string.
The existing WheelCompatibilityCreated event is renamed to CarTypeCreated. The event's semantic is now correctly aligned with what it represents: the moment a CarType row is written, making the (typeApprovalNumber, hsn, tsn) tuple available for HSN/TSN-based fan-out to matching DE cars.
This event has no external consumers (verified via grep across the service repo). All publishers and subscribers are within retail-wheel-service. The rename is a single PR with no dual-publish or deprecation period required.
Files renamed (paths under wheelcompatibility/):
| Old | New |
|---|---|
port/out/PublishWheelCompatibilityCreatedPort.java |
port/out/PublishCarTypeCreatedPort.java |
infrastructure/adapter/out/event/WheelCompatibilityCreatedMessage.java |
infrastructure/adapter/out/event/CarTypeCreatedMessage.java |
infrastructure/adapter/out/event/WheelCompatibilityEventPublisherAdapter.java |
infrastructure/adapter/out/event/CarTypeEventPublisherAdapter.java |
infrastructure/adapter/in/event/WheelCompatibilityCreatedEventListener.java |
infrastructure/adapter/in/event/CarTypeCreatedEventListener.java |
The @MessageType string on the message class is updated. Payload is unchanged — still carries typeApprovalNumber only.
Both create and update use the same rule for deciding when to save a CarType row and publish the CarTypeCreated event. The two actions are atomic siblings: either both happen or neither does.
Save CarType + publish event if all of the following hold:
- CarType missing.
loadCarTypePort.loadByTypeApprovalNumber(...)returns empty. - Incoming data is PDF. Derived from
fileSourceUrlpresence. - Uploader's car is DE.
"DE".equals(car.retailCountry()). - Uploader's car has HSN and TSN. Both
car.hsn()andcar.tsn()are non-null and non-blank.
If any condition fails, skip both. The compat row is still written (insert or update); only the CarType propagation is gated.
In create, condition 1 is essentially always true (the compat row is being created fresh, so no CarType should exist for that typeApprovalNumber; if one does it is a data inconsistency). In update, condition 1 selects exactly the headline scenario: a DE user upgrading an AT-originated wheel compatibility (which had no CarType) with PDF data.
The gate logic is implemented as a small helper used by both use cases. Suggested shape:
// In a domain or use-case-level helper
boolean shouldPublishCarTypeCreated(Car uploader, DataSource incoming, boolean carTypeAlreadyExists) {
return !carTypeAlreadyExists
&& incoming == DataSource.PDF
&& "DE".equals(uploader.retailCountry())
&& StringUtils.isNotBlank(uploader.hsn())
&& StringUtils.isNotBlank(uploader.tsn());
}This also fixes a latent bug in the current create flow: today, the DE branch saves CarType unconditionally without checking HSN/TSN presence. If a DE car has null HSN/TSN, the save either throws a constraint violation or inserts an unmatchable row. With the gate applied to both create and update, this case is handled consistently.
Endpoint: POST /wheel-compatibilities (unchanged path; OpenAPI spec already defines it)
Use case: CreateWheelCompatibilityUseCase (existing)
- Compute
incomingSource = (command.fileSourceUrl is non-blank) ? PDF : MANUAL. - Set
dataSourceon the newWheelCompatibilityrecord. - Replace the existing
if (GERMANY.equals(car.retailCountry()))block with a call to the four-condition gate. If the gate returns true → save CarType + publishCarTypeCreated. If false → skip both.
- Country check via
retailWheelSetConfigurationPort.isCountryAllowed. DuplicateRetailAdExceptionwhen the uploader's car already has anywheelCompatibilityId. The UI must call GET first and route to update or assign when this is the case; create remains strict.- DB-level
UNIQUEconstraint ontype_approval_numberstill rejects duplicate-tan creates with whatever exception mapping is in place today.
Endpoint: PUT /wheel-compatibilities/{typeApprovalNumber}
Use case: UpdateWheelCompatibilityUseCase (new)
URL: typeApprovalNumber path parameter.
Body fields: retailAdId, tpms, wheelSetSpecs, dataSourceUrl, fileSourceUrl.
Update operates on the WheelCompatibility row's data only — it does not modify any car-side state (no link is set, no link is changed). retailAdId is required in the body solely to provide the uploader's car for the four-condition CarType gate (which needs the car's country and HSN/TSN). Linking the uploader's car to the updated row is a separate call to the assign endpoint.
This design choice was considered against an alternative where update does not run the gate at all and the UI is responsible for triggering CarType propagation. That alternative was rejected because the four-condition gate is the only path by which a DE-PDF upgrade of an AT-MANUAL row propagates to other matching DE cars, and making that propagation depend on correct UI orchestration is fragile.
Update's command shape:
public record UpdateWheelCompatibilityCommand(
@NotNull UUID retailAdId,
Tpms tpms,
@Valid List<@NotNull @Valid WheelSetSpec> wheelSetSpecs,
String dataSourceUrl,
String fileSourceUrl
) {}typeApprovalNumber is taken from the URL path, not the body.
1. Load existing WheelCompatibility by typeApprovalNumber.
- Not found → 404 (WheelCompatibilityNotFoundException).
2. Load uploader's car by retailAdId; apply country check.
- Not found / country not allowed → 4xx (existing exception classes).
3. Compute incomingSource from fileSourceUrl.
4. Apply data-source rule:
- existing.dataSource == PDF && incomingSource == MANUAL → reject with new
DataSourceConflictException → 409.
- Otherwise → proceed.
5. Overwrite tpms, wheelSetSpecs, dataSourceUrl, fileSourceUrl, dataSource.
- id, typeApprovalNumber unchanged.
- Save via SaveWheelCompatibilityPort.save(...) (Hibernate merges on existing id).
6. Apply the four-condition gate.
- If true → save CarType from uploader's HSN/TSN, publish CarTypeCreated.
- If false → done.
7. Return the updated WheelCompatibility.
200 OK with the updated WheelCompatibility body (same shape as create's 201 response).
| Existing source | Incoming source | Outcome |
|---|---|---|
| MANUAL | MANUAL | Allowed (correction) |
| MANUAL | Allowed (upgrade — primary scenario) | |
| Allowed (re-upload) | ||
| MANUAL | Rejected (409, DataSourceConflictException) |
WheelCompatibilityNotFoundException(exists) for unknown typeApprovalNumber → 404.DataSourceConflictException(new) for the MANUAL-over-PDF case → 409.InvalidRetailAdException(exists) for unknown car / forbidden country → 4xx.DuplicateHsnTsnException(exists) if the CarType save inside the gate collides with another typeApprovalNumber's(hsn, tsn)row → 409.
Endpoint: POST /cars/{retailAdId}/wheel-compatibility
Use case: AssignExistingWheelCompatibilityToCarUseCase (new)
Path / verb to be matched to the existing OpenAPI conventions during implementation. The semantic is "link this car to this existing typeApprovalNumber."
URL: retailAdId path parameter.
Body: { "typeApprovalNumber": "..." }.
1. Load car by retailAdId; apply country check.
- Not found → 404.
- Country not allowed → 4xx.
2. Load target WheelCompatibility by typeApprovalNumber.
- Not found → 404.
3. Apply car-link rules based on car.wheelCompatibilityId state:
a) null → link: saveCarPort.assignWheelCompatibility(retailAdId, target.id). Return 200.
b) equals target.id → no-op. Return 200.
c) differs from target.id → look up the currently-linked compat:
- If lookup empty (orphan id) → treat as null, link, return 200.
- If current.dataSource == PDF → reject with CarLinkConflictException → 409.
- If current.dataSource == MANUAL → relink: saveCarPort.assignWheelCompatibility(retailAdId, target.id). Return 200.
Assign does not save a CarType, does not publish CarTypeCreated. It is purely a car-side operation. Cars linked to a wheel compatibility row automatically see its current data via the shared id.
| Car's current link | Target | Outcome |
|---|---|---|
| none | any | Allowed |
| equals target.id | any | No-op (200) |
| different id, currently MANUAL-sourced | any | Allowed (relink) |
| different id, currently PDF-sourced | any | Rejected (409, CarLinkConflictException) |
| different id, orphan (compat not found) | any | Allowed (treated as none) |
CarNotFoundException(exists) for unknown retailAdId → 404.WheelCompatibilityNotFoundException(exists) for unknown typeApprovalNumber → 404.InvalidRetailAdException(exists) for forbidden country → 4xx.CarLinkConflictException(new) for relink-off-PDF → 409.
This is out of scope for the backend but informs why the verbs are split this way:
1. UI receives user intent to capture wheel compatibility for car X.
2. UI calls GET /wheel-compatibilities/{typeApprovalNumber}.
3a. 404 → UI calls POST /wheel-compatibilities (create + auto-link uploader's car).
3b. 200 → UI shows the existing entry to the user. User chooses:
- "Fix the data" → UI calls PUT /wheel-compatibilities/{tan} (update), then
POST /cars/{retailAdId}/wheel-compatibility (assign) if the user's car is not already linked.
- "Use as-is" → UI calls POST /cars/{retailAdId}/wheel-compatibility (assign).
The two-call flow on the "fix the data" branch is a deliberate trade-off: it keeps update's contract focused on the data and makes the user's intent explicit at each step.
All three use cases live in com.auto1.api.retailwheel.wheelcompatibility.usecase and are annotated with @UseCase.
Ports used:
| Use case | Outbound ports |
|---|---|
| Create (modified) | LoadCarPort, RetailWheelSetConfigurationPort, SaveWheelCompatibilityPort, SaveCarPort, LoadCarTypePort (new dependency for the gate), SaveCarTypePort, PublishCarTypeCreatedPort (renamed) |
| Update (new) | LoadCarPort, LoadWheelCompatibilityPort, LoadCarTypePort, RetailWheelSetConfigurationPort, SaveWheelCompatibilityPort, SaveCarTypePort, PublishCarTypeCreatedPort |
| Assign (new) | LoadCarPort, LoadWheelCompatibilityPort, RetailWheelSetConfigurationPort, SaveCarPort |
No new outbound ports are needed. SaveWheelCompatibilityPort.save(...) already supports both insert and update via Hibernate merge by id.
REST adapter additions: new controller methods (or a new controller) wired to the generated OpenAPI interfaces. Update the OpenAPI spec in the api-specification repo accordingly.
WheelCompatibilityValidator does not need new rules. dataSource is derived, not user-supplied. The same typeApprovalNumber format validation applies to update (when typeApprovalNumber appears in the URL); enforce it in the controller or use case.
JpaWheelCompatibility carries @ChangelogObject. Verify during implementation that field-level diffs are captured automatically on update via save(...). If not, add explicit changelog wiring inside the update use case. The changelog should capture who made the change — verify actor propagation from the security context reaches the changelog framework.
CreateWheelCompatibilityUseCaseTest — new scenarios added to existing test class:
- Fresh PDF upload creates row with
dataSource = PDF; gate fires; CarType saved; event published. - Fresh manual upload creates row with
dataSource = MANUAL; gate skips; no CarType save; no event. - DE uploader with missing HSN → gate skips even though source is PDF; no CarType save (latent-bug fix).
- DE uploader with missing TSN → gate skips; no CarType save.
UpdateWheelCompatibilityUseCaseTest (new) — scenarios:
- Update existing MANUAL row with MANUAL payload → row rewritten; gate skips (not PDF); no CarType save.
- Update existing MANUAL row with PDF payload from DE car with HSN+TSN → row rewritten with
dataSource = PDF; CarType saved;CarTypeCreatedpublished. - Update existing MANUAL row with PDF payload from DE car with no HSN → row rewritten; gate skips; no CarType save.
- Update existing MANUAL row with PDF payload from AT car → row rewritten; gate skips; no CarType save.
- Update existing PDF row with PDF payload → row rewritten (re-upload allowed); gate skips (CarType already exists); no extra CarType save.
- Update existing PDF row with MANUAL payload →
DataSourceConflictException; row unchanged; no CarType save. - Update for unknown typeApprovalNumber →
WheelCompatibilityNotFoundException. - Update with non-allowed country →
InvalidRetailAdException. - Update payload's HSN/TSN do not affect CarType — only the uploader car's HSN/TSN do (regression guard).
AssignExistingWheelCompatibilityToCarUseCaseTest (new) — scenarios:
- Car with no current link → assigned to target; saved.
- Car already linked to target → no-op; no save.
- Car linked to a MANUAL-sourced different compat → relinked to target.
- Car linked to a PDF-sourced different compat →
CarLinkConflictException; no save. - Car linked to an orphan id (compat not found) → treated as none; assigned.
- Unknown retailAdId →
CarNotFoundException. - Unknown typeApprovalNumber →
WheelCompatibilityNotFoundException. - Non-allowed country →
InvalidRetailAdException.
End-to-end happy paths over the REST API for each new endpoint:
- Update: AT-originated row is upgraded by a DE-PDF call; row is overwritten with same id, CarType is created, event is published, and a previously-matching DE car (with the same HSN/TSN) becomes linked after the listener runs.
- Update rejection: PDF-over-PDF or MANUAL-over-PDF return the expected HTTP status.
- Assign: car with no link is linked. Car linked to a PDF cannot be relinked.
SpecificationDsl and Specification already exist in the test infrastructure. Add:
given().aWheelCompatibility(typeApprovalNumber).withSource(DataSource.PDF).exists()and equivalents for MANUAL.given().aCarType(typeApprovalNumber).withHsnTsn("AAA", "BBB").exists().- Builder defaults match canonical examples (see
hexagonal-testing.md).
- Single PR, single deploy.
- Flyway migration runs on startup; backfill is a single UPDATE so it is fast even for production volumes.
- No backwards-compatibility shim required: new endpoints are additive; the existing create endpoint's response shape adds the
dataSourcefield but callers that ignore unknown fields are unaffected. If callers strict-validate the response shape, the OpenAPI spec change must be coordinated with them.
- PDF-over-PDF behaviour. Early in the design conversation we agreed "PDF cannot overwrite PDF" (treat two PDFs for the same typeApprovalNumber as a conflict). Later, as the update story broadened, the rule effectively shifted to "manual cannot overwrite PDF," which by symmetry permits PDF-over-PDF as a re-upload. The current spec assumes the broader rule. If PDF-over-PDF should still be rejected (e.g. as protection against accidental re-uploads from a wrong PDF), update's data-source rule needs an extra branch:
existing.dataSource == PDF && incomingSource == PDF→ reject withDataSourceConflictException. The test scenario "Update existing PDF row with PDF payload" would invert accordingly.
- If the changelog framework does not auto-capture diffs on update, add explicit wiring.
- If actor propagation to the changelog is broken, fix as a separate ticket — does not block this design.
- Consider adding a
DELETE /cars/{retailAdId}/wheel-compatibilityendpoint to unlink a car. Out of scope here but a natural complement to assign.