# Amazon Configurator — Developer Notes

Built to bring Cydekick's Amazon integration to feature parity with Linnworks'
Amazon Configurator: pick a product type, map its attributes, and actually
**create new Amazon listings** — not just sync price/stock to SKUs that
already exist on Amazon (that's all `SyncProductToAmazon.php` ever did, and
still does — this is genuinely new capability layered alongside it).

Real channel used for all live testing: **Somerset4x4 Amazon**, `channel_id
= 13`, product type `AUTO_PART`. Every claim about Amazon's real schema
behaviour below was verified against the live SP-API for that channel, not
assumed from docs.

---

## The big picture

```
AmazonConfiguratorResource (Filament UI)
    ├─ Basics tab      → name, channel, is_default, is_offer_only, is_visible
    ├─ Category tab     → product_type (search live), browse_node_extended_property_code
    ├─ Attributes tab   → per-attribute mapping (Required | Optional columns)
    ├─ Pricing tab      → price_source_channel_id (really: which Channel Profile)
    ├─ Description tab  → description_template (token editor, same as eBay)
    ├─ Stock tab        → location_ids, qty_cap (existing, built earlier)
    └─ Dispatch tab     → handling_time_days

AmazonClient (SP-API wrapper)
    ├─ searchDefinitionsProductTypes()   → Category tab's live search
    ├─ getProductTypeSchema()            → fetch + parse an attribute schema
    ├─ parseProductTypeSchema()          → the JSON-Schema flattener (the hard part)
    └─ putListingsItem() / updatePrice() / updateQuantity()  → already existed

AmazonBatchCreateJob (new)
    → loads product + configurator, builds the attributes payload from the
      Attributes tab's mappings, calls putListingsItem() per SKU

ManageListingsV2
    → "Create on Amazon" configurator-picker modal, retry actions — mirrors
      the existing eBay flow
```

---

## Database tables

| Table | Purpose |
|---|---|
| `channels_amazon_configurators` | One row per listing template. Extended this session with `is_offer_only`, `is_visible`, `product_type`, `category_path`, `browse_node_extended_property_code`, `description_template`, `price_source_channel_id`, `schema_fetched_at`, `handling_time_days`. Pre-existing: `channel_id`, `name`, `is_default`, `location_ids` (json), `qty_cap`. |
| `channels_amazon_configurator_attributes` | **New table.** One row per Amazon schema attribute for a configurator. See columns below. |
| `channels_amazon_sku_mappings` | Pre-existing (used by `SyncProductToAmazon`). Added `configurator_id` (FK, `nullOnDelete`) and `last_pushed_at` this session. Still accessed via raw `DB::table()` in the old sync job — only new code (`AmazonSkuMapping` model, `AmazonBatchCreateJob`) uses the Eloquent model. |
| `channel_listings` | Generic, already had `configurator_id` — no changes needed. |

### `channels_amazon_configurator_attributes` columns

| Column | Meaning |
|---|---|
| `attribute_code` | Amazon's schema property name, e.g. `item_name`, `bullet_point` |
| `attribute_label` | Human label, from schema `title` or humanised code |
| `attribute_group` | Best-effort grouping (rarely populated — Amazon doesn't consistently supply this) |
| `attribute_type` | `string` \| `number` \| `boolean` \| `enum` \| `unsupported` |
| `enum_options` (json) | Allowed values when `attribute_type = enum` |
| `requirement` | `required` \| `optional` — from the schema's top-level `required` array |
| `mapping_type` | `extended_property` \| `default_value` \| `null` (unmapped) |
| `extended_property_key` | `native:<products_products column>` or `field:<custom_fields.id>` — see "Mapping mechanism" below |
| `default_value` / `default_value_json` | The fixed value(s) when `mapping_type = default_value` |
| `is_array_of_object` | true when Amazon wraps this attribute as `[{value, marketplace_id, ...}]` (almost everything) |
| `requires_language_tag` | true when the wrapper object also needs a `language_tag` key (text fields like title/brand/description — not enums like country_of_origin) |
| `is_repeatable` | true when the attribute can hold more than one entry (e.g. `bullet_point`, up to 10) |
| `max_items` | The cap, when repeatable |
| `is_unsupported` | true when the schema shape can't be flattened at all (see "What's NOT supported" below) — excluded from mapping UI and from `isComplete()`'s required-check |

Legacy note: an earlier version of this table had `extended_property_field_id`
(a bare FK to `custom_fields.id`). It's gone — replaced by
`extended_property_key`, because `custom_fields` turned out to have **zero**
rows for Product in this install, and the real need was mapping to native
columns (title, description, vendor...), which `extended_property_field_id`
couldn't represent at all.

---

## Key files

| File | Purpose |
|---|---|
| `src/Services/AmazonClient.php` | SP-API client. Schema search/fetch/parse methods added this session (see below). |
| `src/Exceptions/AmazonApiException.php` | Thrown by `AmazonClient::request()` on non-2xx — carries `status` + decoded `responseBody` so callers can call `AmazonClient::parseIssues()` for attribute-scoped error messages instead of parsing an exception string. |
| `src/Models/AmazonConfigurator.php` | `isComplete()`, relations to `attributes`, `skuMappings`, `priceSourceChannel`. |
| `src/Models/AmazonConfiguratorAttribute.php` | `resolveValue($product)` — the mapping resolution logic. `NATIVE_PRODUCT_FIELDS` const. |
| `src/Models/AmazonSkuMapping.php` | New Eloquent model for `channels_amazon_sku_mappings` (old sync job still uses raw `DB::table()`). |
| `src/Services/AmazonDescriptionRenderer.php` | Same token vocabulary/pattern as `EbayDescriptionRenderer`. |
| `src/Filament/Resources/AmazonConfiguratorResource.php` | The whole UI + the two static helpers `refreshAttributesFromSchema()` and `automapAttributes()` that the header actions call. |
| `src/Filament/Resources/AmazonConfiguratorResource/Pages/EditAmazonConfigurator.php` | `Width::Full` override so the tabs use the page's full width (Filament's default 2-column top-level grid was leaving the Tabs component only occupying the left half — needed `->columnSpanFull()` on the Tabs component itself in the resource, not just the page width). |
| `src/Jobs/AmazonBatchCreateJob.php` | The listing-creation job. |
| `src/Filament/Pages/ManageListingsV2.php` | Amazon configurator-picker modal + retry actions, mirroring the eBay ones (`openAmazonConfiguratorModal`, `dispatchAmazonCreate`, `retryAmazonCreate`, `bulkRetryAmazonCreate`). |

---

## AmazonClient — the schema pipeline

### The two-step fetch

`getProductTypeDefinition($productType, $requirements)` calls SP-API's
`GET /definitions/2020-09-01/productTypes/{productType}` — but the response
does **not** contain the JSON Schema. It contains
`schema.link.resource`, a URL to an S3-hosted document that must be fetched
with a **second, plain unauthenticated `Http::get()`** — it rejects the LWA
bearer token `AmazonClient::request()` normally attaches, so this fetch
bypasses `request()` entirely (see `fetchSchemaDocument()`).

`getProductTypeSchema()` does both steps and caches the parsed result for 7
days, keyed by `productType.requirements.marketplaceId`.

### The `requirements` parameter — offer-only vs full listing

Confirmed live: `GET .../productTypes/{type}?requirements=LISTING_OFFER_ONLY`
returns a **genuinely smaller schema**, not just a smaller required-list.
For `AUTO_PART`: 90 properties / 6 required (`LISTING`) vs 36 properties / 0
required (`LISTING_OFFER_ONLY`). Catalog-only fields like `item_name`/`brand`
don't even exist in the offer-only schema — makes sense, since offer-only
just attaches your price/stock to an ASIN someone else's listing already
has full catalog data for.

`refreshAttributesFromSchema()` picks `LISTING_OFFER_ONLY` automatically
when `$configurator->is_offer_only` is true. **Toggling the flag does not
auto-refresh** — the user has to click "Refresh Attributes from Amazon"
again afterwards, and the helper text on the toggle says so.

### `parseProductTypeSchema()` — the flattener

Deliberately **not** a general JSON-Schema engine. For each top-level
`properties` entry:

1. Resolve one level of `$ref` into the document's own `$defs` (no external
   `$ref` resolution — `resolveRef()`).
2. Detect Amazon's common wrapper: `array<{value, marketplace_id, ...}>`.
   **The tricky part**: Amazon signals "capped at one entry" with either
   `maxUniqueItems: 1` (most attributes, e.g. `brand`) *or* `maxItems: 1`
   (a minority — confirmed on `variation_theme`, though that one is
   unsupported for an unrelated reason, see below). Both are checked.
3. If capped at one and there's a `value` property in the items object →
   simple mappable scalar/enum, `is_repeatable = false`.
4. If NOT capped at one but there IS a `value` property → **repeatable**
   (e.g. `bullet_point`, `maxUniqueItems: 10`) — still mappable, just via a
   multi-line default value in the UI (see below). `max_items` is recorded.
5. If there's genuinely no `value` key at all (a real structured object,
   e.g. `child_parent_sku_relationship` has `child_relationship_type` +
   `parent_sku`, no generic value) → `unsupported`. This is the actual
   boundary — not "array vs scalar", but "has a plain value key vs doesn't".
6. `allOf`/`oneOf`/`anyOf`/`patternProperties` at the top level → also
   `unsupported` (conditional/cross-field schemas, not attempted).
7. `requires_language_tag` is read from `items.required` containing
   `language_tag` — confirmed some attributes need it (item_name, brand,
   product_description) and some don't (country_of_origin, an enum).

### What's genuinely NOT supported, and why (confirmed against real AUTO_PART/LUGGAGE data)

| Attribute example | Why it's `unsupported` | Fixable later? |
|---|---|---|
| `child_parent_sku_relationship` | No `value` key — real structured object (`child_relationship_type` + `parent_sku`). This is Amazon's variation/parentage system. | Only alongside full variation-listing support (Phase 2 — needs parent/child SKU grouping, per-variant attributes, coordination with `plugins/webkul/products`' `ProductAttributeValue`/`ProductCombination`). |
| `variation_theme` | Has a `value`-shaped wrapper but the inner key is literally named `name`, not `value` — a one-off Amazon inconsistency. **But even if parsed**, it's useless without the rest of variation support, so deliberately left unsupported rather than "fixed" to look complete. | Same as above — don't fix in isolation, it'd be misleading. |
| `bullet_point` (before this session's fix) | Genuinely repeatable, was blanket-marked unsupported by an earlier, cruder version of the parser. | **Fixed** — now correctly detected as repeatable+mappable. |
| `supplier_declared_dg_hz_regulation` | Same shape issue as bullet_point (repeatable, up to 1000!). | **Fixed** alongside bullet_point. |

If you see something in "Not mappable yet" that looks like it should be
plain text/number, dump its raw schema and check for a `value` key inside
`items.properties` — see the tinker snippets under "Debugging recipes"
below.

### Stale rows after refresh

`refreshAttributesFromSchema()` deletes rows whose `attribute_code` is no
longer in the freshly-fetched schema, **but only if `mapping_type IS NULL`**
— a mapped row is left alone even if it falls out of the current schema
(e.g. after toggling `is_offer_only`), rather than silently destroying
configured work. This bit us once already: `bullet_point` lingered as
"Required — Not mappable" on the real configurator after `is_offer_only`
was turned on, purely because it wasn't in the smaller offer-only schema and
the refresh (at the time) never cleaned up unmapped-and-no-longer-relevant
rows. Now it does.

---

## Mapping mechanism (`AmazonConfiguratorAttribute::resolveValue()`)

Three ways to resolve an attribute's value for a product:

1. **`mapping_type = 'extended_property'`, `extended_property_key = 'native:<column>'`**
   — reads a real column off the `Product` model (`name`, `description`,
   `vendor`, `sku`, `barcode`, `weight`, `cost`, `price`, `length`, `width`,
   `height`, `sub_title`, `description_sale` — see
   `AmazonConfiguratorAttribute::NATIVE_PRODUCT_FIELDS`). This is the
   **primary** path — added because `custom_fields` (the schema-driven
   "Field" system in `plugins/webkul/fields`) had **zero rows defined for
   Product** in this install, so the original design (custom-fields-only)
   was a dead end before it started. (First real row added since: see
   "Matching an offer-only listing to an existing ASIN" below.)
2. **`mapping_type = 'extended_property'`, `extended_property_key = 'field:<id>'`**
   — falls back to a real `custom_fields` row, if any ever get defined.
3. **`mapping_type = 'default_value'`** — same fixed value for every product
   using this configurator. For `is_repeatable` attributes, this is a
   **multi-line textarea** (one entry per line, capped at `max_items`) — the
   only option offered for repeatable attributes, since there's no natural
   per-product column holding "a list of bullet points".

The "Map" radio option (labelled "Map" in the UI, was "Extended Property")
is hidden entirely for repeatable attributes — only "Default Value" is
offered for those.

### Automap

`AmazonConfiguratorResource::automapAttributes()` tries native-field alias
matching first (`NATIVE_FIELD_ALIASES` const — e.g. "Item Name"/"Title" →
`native:name`), then falls back to exact name/code match against
`custom_fields`. **Never overwrites an existing mapping** — only touches
rows where `mapping_type IS NULL`. Caught one bad alias during testing:
`merchant_suggested_asin` was wrongly aliased to `sku` (semantically wrong —
that field wants an actual ASIN, not your internal SKU) — removed.

### Matching an offer-only listing to an existing ASIN

For `is_offer_only` configurators there's no `item_name`/`brand` in the
schema (see "requirements parameter" above) — the one field that tells
Amazon which existing catalog ASIN to attach the offer to is
`merchant_suggested_asin`, an **optional**, per-product string attribute.
There's no native `Product` column for this, so it needs a real
`custom_fields` row (option 2 in the mapping mechanism above):

1. Settings → Custom Fields → Create Field: Name `Amazon ASIN`, Code
   `amazon_asin`, Type `Text Input`, Input Type `Text`, Resource
   `ProductResource` (resolves to `Webkul\Inventory\Models\Product` — matches
   the `Product` import in `AmazonConfiguratorResource.php`, confirmed
   correct). Leave Form Settings / Table Settings / Infolist Settings empty —
   all optional; their Repeaters do add one blank required row by default
   that must be deleted (trash icon) before saving, or the form won't submit.
2. On the configurator's Attributes tab, map `merchant_suggested_asin` →
   **Map** → the new `Amazon ASIN` field.
3. Fill in the actual ASIN per product on that product's own Edit page
   (custom fields render there automatically once defined for
   `ProductResource`).

Do **not** use the per-product "Attributes" tab
(`/admin/inventory/products/{id}/attributes`) for this — that's a different
system entirely (variant-defining attributes like Color/Size that generate
SKU combinations; the page's own warning says adding/removing one deletes
and recreates variants). Wrong tool for a scalar value like an ASIN.

### Create-page crash: blank repeater row → `attribute_code` NOT NULL

Hit once in practice: creating a new configurator (channel + name + product
type in one submit) saved the parent `AmazonConfigurator` row successfully,
then Filament's relationship-`Repeater` save step (`required_attributes`/
`optional_attributes`, both bound to the `attributes` relationship) tried to
insert a related `AmazonConfiguratorAttribute` with a blank `attribute_code`,
throwing a raw `QueryException` and aborting the request — leaving the
parent record orphaned (0 attributes, `schema_fetched_at` still null). A
retry with the same name+channel then hit the `channels_amazon_configurators_
channel_id_name_unique` constraint on top, because the orphan was still
there. Root cause in Filament's save-cycle wasn't fully pinned down (would
need a live `dd()` to catch the exact state at fault); fixed defensively
instead via the hook Filament provides for exactly this —
`->mutateRelationshipDataBeforeCreateUsing(fn (array $data): ?array =>
blank($data['attribute_code'] ?? null) ? null : $data)` on both Repeaters —
returning `null` makes Filament skip persisting that item instead of
crashing. Cleanup for an already-orphaned row from before the fix: delete it
directly (`DELETE FROM channels_amazon_configurators WHERE id = ...`), no
attributes/mappings exist yet to cascade.

---

## The Attributes tab UI

Two side-by-side `Repeater`s, **both bound to the same `attributes`
relationship**, differentiated by `modifyQueryUsing`:

```php
Repeater::make('required_attributes')
    ->relationship('attributes', modifyQueryUsing: fn ($q) => $q
        ->where('requirement', 'required')
        ->orderBy('is_unsupported')   // unsupported sorts to the bottom
        ->orderBy('attribute_label'))
```
...and the mirror for `optional_attributes` / `'optional'`.

**This is safe** — verified by reading Filament's actual
`RelationshipRepeater` save logic (`vendor/filament/forms/src/Components/Repeater.php`,
`saveRelationshipsUsing`): the "existing records to diff against for
deletion" set is fetched via the *same* `modifyRelationshipQueryUsing`
closure, so saving the Required repeater only ever considers rows already
matching `requirement = 'required'` — it can never see, and can never
delete, an Optional row. No cross-contamination risk between the two.

Shared per-row schema lives in `AmazonConfiguratorResource::attributeRowSchema()`
(both Repeaters call it) — Hidden fields carry the metadata (`attribute_type`,
`enum_options`, `is_repeatable`, `max_items`...) needed by the visible
fields' `->visible()`/`->options()` closures.

### The "Updating…" indicator

Toggling any Radio/Select in a ~40-90 row Repeater triggers a full Livewire
round-trip (every `->visible()`/`->options()` closure is server-evaluated
PHP, re-run on every interaction) — genuinely a few seconds. There's a
`wire:loading.flex` pill (no `wire:target` — fires on *any* pending request
for this Livewire component, deliberately not trying to guess Filament's
internal property names) fixed at `bottom:32px; right:40px` so it's visible
regardless of scroll position. This makes the wait *visible*, it does not
make it *faster*. A real fix for the speed itself would mean converting the
row-visibility logic to client-side JS (Filament supports `hidden(js:)`/
`visible(js:)` as an alternative to PHP `Get`-closures) — not attempted,
bigger/riskier change.

---

## AmazonBatchCreateJob

Mirrors `EbayBatchCreateJob`'s shape, but Amazon's Listings Items API has
**no bulk-put equivalent** — calls `putListingsItem()` sequentially per SKU
(eBay's job does inventory/offer/publish in batches of 25; this one can't).

### `buildAttributes()` flow, per product

1. For each configurator attribute row: `resolveValue($product)`. Empty +
   required → add to `$missing`, skip. Empty + optional → skip silently.
2. Non-array attributes: value goes in directly.
3. Array attributes, not repeatable: wrapped once —
   `[wrapValue($value, $client, $row->requires_language_tag)]`.
4. Array attributes, repeatable: value is split on newlines, blank lines
   filtered, capped at `max_items`, **each line wrapped separately** — a
   real multi-entry array to Amazon.
5. Description auto-injection: if the seller hasn't manually mapped
   `product_description` (checked against `$mappedCodes`, which is codes
   with `mapping_type IS NOT NULL` — **not** all codes the schema happens to
   define; got this wrong once during the build, `product_description`
   would always look "already mapped" simply because it existed in the
   schema, silently killing the auto-render path), render via
   `AmazonDescriptionRenderer` and inject.
6. `purchasable_offer` / `fulfillment_availability` always injected using
   the **same array literal shapes** `AmazonClient::updatePrice()` /
   `updateQuantity()` already use, so the create payload and the later
   sync-patch (via the old `SyncProductToAmazon` job) stay consistent.
   `fulfillment_availability` also gets `lead_time_to_ship_max_days` when
   `$configurator->handling_time_days` is set (optional — Amazon falls back
   to the seller account's own default handling time otherwise, which is
   also what happens if you leave the Dispatch tab blank).
7. **Missing-required is re-derived at the end**, against the final
   `$attributes` array (`array_diff($missing, array_keys($attributes))`) —
   not right after the per-row loop. Needed because the description
   fallback (step 5) can fill a required attribute *after* the main loop
   already flagged it missing; checking too early caused false rejections.
8. Browse node: best-effort via `$configurator->browse_node_extended_property_code`,
   only injected if the attribute actually exists in `$attributes` already
   (i.e. the schema has a browse-node-shaped attribute for this product
   type at all).

### Error handling

- `AmazonApiException` (thrown by `AmazonClient::request()`) carries the
  decoded body → `AmazonClient::parseIssues()` turns Amazon's `issues`/
  `errors` array into `"attribute_code: message"` lines instead of one
  opaque string.
- 401/403 from the API, or a credential/token exception, **aborts the rest
  of the batch** (`break`) rather than repeating the same failure for every
  remaining SKU.
- 429 gets one contained retry with a 750ms backoff inside
  `AmazonClient::request()` itself.
- Missing-required attributes never even reach the API call — checked
  locally first, written to `last_error` naming the specific codes.
- **Hit once in practice, now fixed:** `putListingsItem()`'s return value
  was discarded entirely — only a thrown `AmazonApiException` (4xx/5xx) was
  treated as failure. But the Listings Items API is asynchronous: a 2xx
  response only means "accepted for processing" and can still carry
  `status: "INVALID"` or ERROR-severity entries in `issues[]`. Real-world
  result: a listing create action reported success and wrote `status =
  'synced'`, but the product never actually appeared on Amazon, with no
  error anywhere to explain why. Fixed by checking the response body itself
  after the call succeeds — `($response['status'] ?? null) === 'INVALID'`
  or any `issues[]` entry with `strtoupper(severity) === 'ERROR'` now routes
  through the same `writeError()` path a thrown exception does, so it shows
  up identically to an eBay listing error (red text under the product name
  on Manage Listings, driven by `channel_listings.status = 'error'`).
  WARNING/INFO-severity issues on an otherwise-accepted submission are not
  treated as blocking — not surfaced anywhere yet, so a "successful" listing
  can still be carrying non-fatal Amazon warnings silently. Worth revisiting
  if that turns out to matter in practice.

---

## Fixed (2026-09-16) — Manage Listings never showed Amazon products as mapped

`ManageListingsV2::getProductRows()` (and `buildIdQuery()`, used for bulk
select-all) only ever joined `channels_sku_mappings` (Shopify) and
`channels_ebay_sku_mappings` — never `channels_amazon_sku_mappings`. The
`listing_status` CASE statement's `WHEN sm.id IS NULL AND esm.id IS NULL
THEN 'not_mapped'` was therefore blind to Amazon: any Amazon-only product
always evaluated to `not_mapped` regardless of its real
`channel_listings.status`, so the row always rendered the Link/Create
button instead of Edit/Delete/Unlink — even for a product genuinely marked
`listed`. This was visible from the very first ERR3340 screenshot in this
whole investigation ("Not Linked" + Link button despite later create
attempts), just not diagnosed as a UI bug until TF802 hit the identical
symptom. Fixed by joining `channels_amazon_sku_mappings as asm` in both
queries and adding `asm.id` to every place `sm.id`/`esm.id` were already
checked (the status CASE, the `not_mapped`/`not_synced` tab filters, and
the row sort order). `buildIdQuery()` was also missing the eBay join
entirely before this fix — added for the same reason.

---

## Fixed (2026-09-16) — false "listed" status: delayed verification + payload logging

Direct consequence of the TF802 finding below: a `putListingsItem()`
submission can stay completely clean at every check `AmazonBatchCreateJob`
does (immediate response, 3-second follow-up) while never actually
resolving into a live catalog attachment. "No issues yet" is not proof of
success. Two additions:

1. **`AmazonClient::putListingsItem()` now logs the full outgoing
   payload and the raw response** to `storage/logs/laravel.log` (grep for
   `"putListingsItem: sending"` / `"putListingsItem: response"`) — every
   "why isn't this attaching" investigation in this file so far has needed
   to reconstruct the exact payload via `buildAttributes()` reflection
   after the fact; now it's just in the log for the SKU in question.
2. **New `VerifyAmazonListingJob`** (`plugins/webkul/channels/src/Jobs/`)
   — dispatched by `AmazonBatchCreateJob` with a 5-minute delay right after
   a clean create, it does a real `getListingsItem()` check and only
   leaves the listing as `synced`/`listed` if `summaries` or `offers` are
   actually populated. If Amazon returns an ERROR-severity issue by then,
   or after 3 total checks (~35 min window: 5 / 10 / 20 min delays) nothing
   has ever populated, it downgrades the status to `error` with a message
   distinguishing "Amazon rejected it" from "never confirmed, may still
   resolve later — check Seller Central manually." Self-redispatches with
   an incrementing `$attempt` rather than looping/sleeping inline — never
   blocks the create batch itself.

Guards against clobbering a newer state: if `channels_amazon_sku_mappings.status`
is no longer `'synced'` by the time a check runs (user deleted/unlinked it,
a manual retry already produced its own outcome), the job just returns
without touching anything.

**Not yet known**: whether 35 minutes is actually long enough — TF802
itself was still unconfirmed after a full hour, so this may need a longer
final window or more attempts once there's real data on how long Amazon's
processing genuinely takes for this class of submission. Revisit if
`VerifyAmazonListingJob`'s "unconfirmed" errors turn out to resolve fine on
Amazon's side after the fact.

---

## RESOLVED — TF802 (ASIN B0FN4NY32W): same Generic Product Policy block as ERR3340

Initially looked like a *different*, unexplained problem from ERR3340 —
TF802's submission stayed completely clean at every check (`status` never
`INVALID`, `issues: []` immediately and on the 3-second follow-up), yet
`summaries`/`offers` were still empty over an hour later. Confirmed via
`mode=VALIDATION_PREVIEW` (see below) that it's actually the **exact same
root cause**: `identifiers` resolved correctly to `B0FN4NY32W` (the ASIN
match itself works fine), but `issues` showed the identical code 5886
Generic Product Policy rejection ERR3340 hit. TF802's ASIN is also a
restricted generic listing this seller account has never contributed to.
Not fixable via the API — same two options as ERR3340: Seller Central's
"Add a Product" tool, or attach to a different, already-branded ASIN
instead.

**Real lesson**: a policy-level rejection like this can take Amazon's
async processing far longer than the few seconds/minutes checked so far to
surface via a live submission's own response — the *same* rejection showed
up instantly via `VALIDATION_PREVIEW` but took an unknown, possibly much
longer time to appear on a real submission's follow-up checks. This is why
the pre-flight preview check (below) is a real improvement, not just a
convenience — it can catch this class of problem before ever submitting
live, rather than relying on the live submission's async checks to
eventually notice.

**Found and fixed separately, unrelated to the above**: this configurator
(id 3) had `location_ids: []` (Stock tab never configured), so
`AmazonBatchCreateJob` was submitting `fulfillment_availability.quantity =
0` for every product regardless of real stock — TF802 actually has 28
units available (25 at Allmakes4x4, 3 at Somerset4x4). Needs the Stock tab
configured with the correct location(s) before relying on this
configurator for anything real. Confirmed the code already sums quantity
across however many locations are ticked, respecting `qty_cap` — nothing
to fix there, purely a "nothing selected yet" configuration gap.

## Fixed (2026-09-16) — auto-detect ASIN-attach vs new-listing per PRODUCT, not per configurator

Removed `is_offer_only` entirely (column dropped via
`2026_09_16_000001_drop_is_offer_only_from_amazon_configurators`). It was
the wrong shape for this problem — whether a product should attach to an
existing ASIN or create a brand-new catalog listing depends on whether
*that specific product* has a matching `amazon_asin` set, not on a
configurator-wide toggle the user has to pre-decide. Direct motivation:
today's whole ERR3340/TF802 saga, plus the discovery that a large fraction
of real ASINs turn out to be Generic-brand/policy-restricted, made clear
that "attach vs create" is fundamentally a per-product decision.

**New behavior**, entirely in `AmazonBatchCreateJob`'s per-product loop:

```php
$isAttachMode = filled($product->amazon_asin);
$requirements = $isAttachMode ? 'LISTING_OFFER_ONLY' : 'LISTING';
```

`buildAttributes()` takes this as a new trailing `bool $isAttachMode = false`
param — when true, both of its missing-required checks are skipped
entirely (`if (! $isAttachMode && $row->requirement === 'required')`),
mirroring the exact philosophy `AmazonConfigurator::isComplete()` already
used for whole offer-only configurators ("we don't attempt to detect the
offer-only-specific required subset from the schema") — just applied per
product now. `merchant_suggested_asin` needed zero new code: it's already
resolved through the existing generic attribute-mapping mechanism
(`extended_property` → `field:<amazon_asin custom field id>`), so it's
automatically included when set and automatically omitted when not.

**Configurator changes**:
- `AmazonConfiguratorResource`: the "Create offer only configurator"
  toggle and "Offer Only" table column are gone. `refreshAttributesFromSchema()`
  always fetches the full `LISTING` schema now (the superset — includes
  catalog fields AND sales-terms fields like `merchant_suggested_asin`/
  `condition_type`), regardless of how any individual product will end up
  being submitted. Added an info banner on the Attributes tab explaining
  the new per-product behavior.
- `AmazonConfigurator::isComplete()` simplified to `filled($this->product_type)`
  — real enforcement now only happens per-product at actual create time,
  where the error is specific ("Missing required attributes: X, Y") rather
  than a vague upfront configurator-level gate.

**Deliberately not done**: dual-schema tracking (fetching both `LISTING`
and `LISTING_OFFER_ONLY` to know exactly which fields are required under
each mode separately, rather than skipping the check outright for attach
mode). Would be more precise but is real added complexity (new migration
column, schema-fetch refactor) for a check that's now backed up by the
`previewListingsItem()` pre-flight (below) catching real Amazon-side
problems before a live submission regardless.

**Verified functionally** (via `buildAttributes()` reflection, no live
submission): after refreshing configurator id 3's schema to the full 90-
property/6-required set, TF802 (has `amazon_asin`) returns zero missing
attributes with `merchant_suggested_asin` correctly present; the same
product with `isAttachMode` forced `false` correctly flags the 5 unmapped
required catalog fields (`country_of_origin`, `supplier_declared_dg_hz_regulation`,
`item_name`, `brand`, `bullet_point` — the 6th required field,
`product_description`, is covered by the existing auto-injection fallback).

---

## Fixed (2026-09-16) — pre-flight `VALIDATION_PREVIEW` check before every real submission

Amazon's Listings Items API PUT operation supports `mode=VALIDATION_PREVIEW`
as a query param, confirmed real and safe — a genuine dry run that never
touches the live catalog/offer, but still returns real `issues` (found via
the official API docs' query-param reference, cross-checked live). Crucially,
only in this mode can `includedData=identifiers` be requested — it resolves
whatever `merchant_suggested_asin` (or other identifier attribute) would
match to, so you can confirm an ASIN match will actually work *before*
submitting anything real.

This is what definitively solved the TF802 mystery above: previewing the
exact same payload the real job would send returned `identifiers:
[{"asin": "B0FN4NY32W"}]` (proving the match itself works) alongside the
same Generic Product Policy `issues` entry a live submission only revealed
much later.

**New**: `AmazonClient::previewListingsItem($sku, $productType, $attributes,
$requirements)` — identical signature/shape to `putListingsItem()`, just
adds `mode=VALIDATION_PREVIEW` and `includedData=identifiers,issues` to the
query string. `AmazonBatchCreateJob` now calls this immediately before
every real `putListingsItem()` call, using the exact same
attributes/productType/requirements that will be submitted for real. A
blocking (`status: INVALID` + an ERROR-severity issue) preview result
routes through the same `writeError()` path and **skips the real
submission entirely** — no live API call made, no wait for
`VerifyAmazonListingJob` needed for this class of problem. If the preview
call itself throws (network hiccup, etc.), it's logged and swallowed
rather than blocking the real attempt — the live submission's own checks
remain the ultimate safety net regardless.

**Not a complete substitute for `VerifyAmazonListingJob`** — the preview
only validates schema + identifier-matching + immediately-knowable policy
issues; it can't tell you whether `summaries`/`offers` will actually
populate after a real submission, since that's Amazon's asynchronous
fulfillment/publishing step, not something a dry-run can simulate. Both
checks stay in place, covering different failure classes.

---

## Pricing tab

`price_source_channel_id` is the actual stored/used field (job reads
`channels_product_prices` for that channel, same mechanism eBay uses,
ultimately driven by `plugins/webkul/pricing`'s `ChannelFeeProfile` →
`PricingEngine` → `RecalculateProductChannelPrice` chain). The **Select's
options**, though, are built from `ChannelFeeProfile` records (not raw
`Channel` records) so the dropdown shows "Somerset4x4 Ebay_UK — eBay" style
labels and is ready the moment an Amazon profile exists.

**Fixed (2026-09-16)**: `AmazonFeeCalculator` now exists and is registered
in `config('pricing.fee_calculators')` — see "AmazonFeeCalculator —
floor-based referral fee" in `pricing_plugin.md` for the full derivation.
Create a real Amazon `ChannelFeeProfile` in Admin › Pricing › Channel
Profiles to stop the warning banner and start subtracting the actual
referral fee. Scope is FBM (merchant-fulfilled) only — matches how every
Amazon configurator here actually creates listings
(`fulfillment_channel_code: DEFAULT`). FBA pick & pack/storage fees are
still not modelled — genuinely deferred this time, not just theoretically.

---

## Fixed (2026-09-29) — reprices never actually pushed to Amazon (or eBay)

Found via a real screenshot: repricing ERR3340 (product 1) synced Shopify but left
its live eBay listing showing the old price, and separately — Amazon wasn't getting
pushed either. Root cause: `RecalculateProductAllChannels` and
`RecalculateProductChannelPrice` (the two jobs behind every reprice — the "Recalculate"
button, new-product creation, the cost-change observer, `pricing:recalculate`) only ever
dispatched a live push for **Shopify**. eBay/Amazon's price was written to Cydekick's
own `channels_product_prices` table, but nothing told the actual channel about it.

**eBay**: the push mechanism already existed —
`ChannelProductPriceObserver::maybeDispatchEbay()`, used by the stock-quantity and
product-save observers — it just was never called from either pricing job. Fixed by
calling it there too.

**Amazon**: unlike eBay, there was no dispatch mechanism anywhere to reconnect — this
was genuinely new wiring. Added `ChannelProductPriceObserver::maybeDispatchAmazon()`,
same shape as the eBay one, dispatching the existing `SyncProductToAmazon` job (the
same one the manual "Update" button on Channel Listings / Manage Listings already
uses — no new sync logic, just a new automatic trigger for it).

**"Is it actually listed?" check** deliberately mirrors `ManageChannelListings::mount()`'s
own `is_listed` definition for Amazon exactly, rather than inventing a new one — Amazon
has two distinct ways a product ends up live (see that page's own comment, ~line 131):

```php
$isListed = ($mapping->status ?? null) === 'synced' || filled($product->amazon_asin);
```

Checking only `channels_amazon_sku_mappings.status = 'synced'` would miss every product
listed via the ASIN-attach path, which may never get a `synced` mapping row of its own.

Both `maybeDispatchEbay()`/`maybeDispatchAmazon()` are called unconditionally on every
reprice (not gated behind the `$isBulkReprice`/`FeeProfileChange` check Shopify's path
uses) — they're already lean, single-SKU price+qty pushes, unlike Shopify's optional
full sync, so there's no bulk-scale cost to worry about.

**Verified against two real products with opposite states, not synthetic fixtures**:
- `TF802` (id 9084, has `amazon_asin` set, no confirmed `synced` mapping) → reprice
  correctly dispatches `SyncProductToAmazon`.
- `ERR3340` (id 1, has a `channels_amazon_sku_mappings` row but `status = 'error'` —
  the real, still-unresolved "Generic Product Policy" / missing-`merchant_suggested_asin`
  saga documented further down this file — and no `amazon_asin`) → correctly does
  **not** get pushed, since it isn't genuinely listed yet.

Tests: `tests/Unit/Pricing/RecalculatePushesToEbayTest.php`,
`tests/Unit/Pricing/RecalculatePushesToAmazonTest.php`.

---

## Explicitly out of scope (Phase 2 / not attempted)

- **Variation listings** — no Variation Theme picker, no parent/child SKU
  grouping column on `channels_amazon_sku_mappings`, no `child_parent_sku_relationship`
  support. Needs coordination with `plugins/webkul/products`' variant system.
- **Live browse-node browsing** — no confirmed public SP-API endpoint for
  "list browse nodes under category X". Only the extended-property
  mechanism (map a custom field's value to the browse-node attribute)
  ships.
- **"Download Attributes" CSV import/export** (a Linnworks feature) —
  omitted entirely rather than half-built.
- **Amazon fee calculator** — see Pricing tab above.
- Refactoring `SyncProductToAmazon.php` onto the new `AmazonSkuMapping`
  Eloquent model — untouched, still raw `DB::table()`.

---

## RESOLVED — offer-only ASIN attach: two real bugs + one Amazon account restriction (2026-09-15/16)

Real test case throughout: product `ERR3340` (id 1), configurator id 3
("Somerset4x4 Amazon", `is_offer_only = true`, `product_type = AUTO_PART`),
ASIN `B0FN4QMXGP`. Roughly a dozen live `putListingsItem()` submissions
were made against the real channel while diagnosing this across two
sessions — real API traffic throughout, not simulated. One submission that
came back `ACCEPTED` with zero issues silently created a **separate, wrongly
categorized listing** (Motorcycle Parts) not tied to the intended ASIN at
all — this got deleted via `deleteListingsItem()` once spotted, and that
same delete flow is now a proper "Delete from Amazon" button on Manage
Listings (see below), distinct from the local-only "Unlink" action.

### Bug #1 (fixed): `putListingsItem()` never checked its own response

Covered further up this file under "AmazonBatchCreateJob → Error handling"
— the job discarded `putListingsItem()`'s return value entirely, so a 2xx
HTTP response with `status: "INVALID"` in the body (Amazon's async
validation result) was recorded as a silent success. Fixed by inspecting
the response for a blocking status/issue and routing it through the same
`writeError()` path a thrown exception uses.

### Bug #2 (fixed): `requirements` was never sent, and one attempt sent it wrong

`requirements` (`LISTING` / `LISTING_OFFER_ONLY` / `LISTING_PRODUCT_ONLY`)
is a **request BODY field** on `putListingsItem`, sibling to `productType`
and `attributes` — confirmed against Amazon's own PUT operation schema.
`putListingsItem()` never sent it at all, so every submission — offer-only
configurator or not — was validated as a full `LISTING` (product facts +
sales terms), explaining why an offer-only submission demanded all 17 full
catalog fields (item_name, brand, country_of_origin, automotive_fit_type,
etc.) even though its locally-fetched schema said 0 required.

One earlier attempt tried adding `requirements=LISTING_OFFER_ONLY` as a
**query parameter** instead (mirroring how the GET `/definitions` endpoint
takes it) — this made things measurably *worse* (Amazon demanded *more*
fields, not fewer), because the query param is meaningless to this
endpoint and was presumably just ignored while something else about that
particular request shape triggered stricter validation. The fix only
works in the body.

**Fixed**: `AmazonClient::putListingsItem()` now takes a `$requirements`
param (default `'LISTING'`, backwards compatible) and includes it in the
request body. `AmazonBatchCreateJob` passes
`$configurator->is_offer_only ? 'LISTING_OFFER_ONLY' : 'LISTING'`.
Confirmed against the real API: the *exact same* minimal payload
(`merchant_suggested_asin` + `condition_type` + `purchasable_offer` +
`fulfillment_availability`, nothing else) that previously got rejected
demanding all 17 full-catalog fields came back with **zero**
missing-attribute issues once `requirements` was in the body correctly.
This fix is real and generally applicable — keep it regardless of the
ASIN-specific issue below.

### Not a bug: Amazon's Generic Product Policy (account-level restriction)

With the `requirements` fix in place and a genuinely minimal payload, the
*only* remaining issue was:

> **Code 5886**: "You are attempting to contribute to a **restricted
> generic ASIN** on which you have not contributed in the past... You must
> create a new ASIN by following the process as outlined in our **Add a
> product** tool... Amazon's Generic Product Policy."

`B0FN4QMXGP`'s catalog entry has `brand: "Generic"` / `manufacturer:
"Generic"` (confirmed via `getCatalogItem()`) — a generic/unbranded ASIN.
Amazon's policy blocks attaching an offer to a restricted generic ASIN via
the API for a seller who has never contributed to it before; it must go
through Seller Central's "Add a Product" tool instead. This explains every
earlier symptom in this investigation: the original silent "success" was
this exact rejection going unlogged (bug #1), and the full-catalog-data
submission's wrong Motorcycle Parts listing was Amazon creating a *new*
item from the submitted data instead of attaching, since attach was
policy-blocked (the two bugs above combined to obscure this the whole
time). Also surfaced along the way (WARNING severity, not blocking):
Amazon suggests `product_type = OIL_FILTER` is more correct than
`AUTO_PART` for this specific SKU's content.

**Practical path for this specific ASIN**: not fixable via API. Either use
Seller Central's "Add a Product" flow directly, or pick a different,
already-branded ASIN to attach to instead — offer-only attach should now
work correctly (per the bug #2 fix) against any ASIN that isn't
policy-restricted like this one.

### Delete-from-Amazon button (built to clean up the wrong test listing)

`ManageListingsV2::deleteAmazonListing()` + a red trash-icon button on the
Amazon channel's Manage Listings table, gated `$isAmazon`, calling
`AmazonClient::deleteListingsItem()` for real. Deliberately **separate**
from the existing `unlink()` action, which stays local-only across all
three platforms per the `ChannelListingDriver` contract — changing that
shared meaning wasn't asked for and would surprise existing Shopify/eBay
users. A 404 from Amazon (already gone) is treated as success for local
cleanup purposes rather than an error.

### Amazon ASIN custom field (built this session, separate from the above bug)

`merchant_suggested_asin` needs a real per-product value to map to. Since
`Product` has no native ASIN column and `custom_fields` had zero rows
defined for Product before this session (see the mapping-mechanism note
above — now outdated), added:

- A `Field` row (code `amazon_asin`, type `text`, resource `Product`) —
  auto-seeded via migration
  `2026_09_16_000001_seed_amazon_asin_custom_field.php` in
  `plugins/webkul/channels/database/migrations/` (registered in
  `ChannelServiceProvider`'s explicit `->hasMigrations([...])` list — this
  plugin does **not** auto-discover migration files, every one must be
  added to that array or `php artisan migrate` silently ignores it).
  Creating the `Field` row via `Field::firstOrCreate()` alone does **not**
  add the actual database column — that only happens via
  `FieldsColumnManager::createColumn($field)`, normally triggered by
  Filament's `CreateField` page's `afterCreate()` hook, so the migration
  calls it manually. Real column: `products_products.amazon_asin`
  (nullable varchar).
- An editable **ASIN** column on the Amazon channel's Manage Listings table
  (`$isAmazon`-gated, in `manage-listings-v2.blade.php`), saving on blur via
  `ManageListingsV2::updateAmazonAsin()` straight to that column — plus a
  small external-link icon next to it (visible once a value exists) opening
  `https://www.amazon.co.uk/dp/{ASIN}`.

---

## Debugging recipes (tinker, against the real channel)

Fetch and inspect a raw attribute schema directly (bypassing the parser) to
check why something is/isn't mappable:

```php
$channel = \Webkul\Channel\Models\Channel::find(13);
$client = new \Webkul\Channel\Services\AmazonClient($channel);
$definition = $client->getProductTypeDefinition('AUTO_PART'); // or 'LISTING_OFFER_ONLY'
$resource = $definition['schema']['link']['resource'];
$raw = \Illuminate\Support\Facades\Http::get($resource)->json();
$p = $raw['properties']['SOME_ATTRIBUTE_CODE'];
echo json_encode($p, JSON_PRETTY_PRINT);
```

Check what the parser actually produces:

```php
$attrs = $client->getProductTypeSchema('AUTO_PART', 'LISTING');
foreach ($attrs as $a) {
    if ($a['code'] === 'SOME_ATTRIBUTE_CODE') { print_r($a); }
}
```

Re-sync a real configurator's attribute rows after a parser fix (safe —
never touches existing mappings, deletes only unmapped+no-longer-relevant
rows):

```php
$c = \Webkul\Channel\Models\AmazonConfigurator::find(1);
\Webkul\Channel\Filament\Resources\AmazonConfiguratorResource::refreshAttributesFromSchema($c);
```

**Never call `putListingsItem()` against the real channel during testing** —
it creates/overwrites a real live Amazon listing. Everything above (search,
schema fetch, `refreshAttributesFromSchema`, `automapAttributes`) is
read/local-write only and safe to run against the real channel; only the
actual `AmazonBatchCreateJob` run (or manually calling `putListingsItem`)
touches Amazon's live catalog.

**Use `previewListingsItem()` instead of `putListingsItem()` for testing
payloads** — same signature, adds `mode=VALIDATION_PREVIEW`, genuinely
safe (confirmed real, never touches the live catalog), and tells you both
whether an ASIN match will resolve (`includedData=identifiers`) and
whether Amazon will reject it (`issues`) — this is what actually solved
the TF802 investigation, after several *live* submissions hadn't. Prefer
this over a real `putListingsItem()` call for any future "will this
payload work" question:

```php
$channel = \Webkul\Channel\Models\Channel::find(14);
$client  = new \Webkul\Channel\Services\AmazonClient($channel);
$result  = $client->previewListingsItem('SOME_SKU', 'AUTO_PART', $attributes, 'LISTING_OFFER_ONLY');
echo json_encode($result, JSON_PRETTY_PRINT); // check result['identifiers'] and result['issues']
```

### Checking what Amazon actually did, after the fact (all read-only, safe)

Check whether a SKU genuinely exists on Amazon's side, regardless of what
the local DB says (this is what caught the false-`synced` bug — the DB said
`synced`, this said `404 SKU not found`):

```php
$channel = \Webkul\Channel\Models\Channel::find(14); // local id — was 13 in the original dev env, differs per install
$client = new \Webkul\Channel\Services\AmazonClient($channel);
$client->getListingsItem('ERR3340'); // throws AmazonApiException(404) if Amazon has no record of it at all
```

Look up an ASIN's real catalog data, including its **actual registered
product type** — useful for checking a product-type mismatch theory.
`getCatalogItem()` as currently written doesn't request `includedData`, so
it silently omits `productTypes` from the response unless you call
`request()` directly with it added (reflection needed — `request()` is
private):

```php
$channel = \Webkul\Channel\Models\Channel::find(14);
$client  = new \Webkul\Channel\Services\AmazonClient($channel);
$method  = new \ReflectionMethod($client, 'request');
$method->setAccessible(true);
$result = $method->invoke($client, 'GET', '/catalog/2022-04-01/items/B0FN4QMXGP', [
    'marketplaceIds' => $client->marketplaceId(),
    'includedData'   => 'productTypes,summaries', // getCatalogItem() only ever sends marketplaceIds
], null);
echo json_encode($result, JSON_PRETTY_PRINT);
```

Inspect the exact payload `AmazonBatchCreateJob` would submit, without
submitting it — `buildAttributes()` is private, so reflection again:

```php
$product      = \Webkul\Inventory\Models\Product::find(1);
$configurator = \Webkul\Channel\Models\AmazonConfigurator::with('attributes')->find(3);
$client       = new \Webkul\Channel\Services\AmazonClient($configurator->channel);
$renderer     = new \Webkul\Channel\Services\AmazonDescriptionRenderer;
$attributeRows = $configurator->attributes->where('is_unsupported', false);
$mappedCodes   = $attributeRows->whereNotNull('mapping_type')->pluck('attribute_code')->all();

$job    = new \Webkul\Channel\Jobs\AmazonBatchCreateJob([1], 14, 3);
$method = new \ReflectionMethod($job, 'buildAttributes');
$method->setAccessible(true);
[$attributes, $missing] = $method->invoke($job, $attributeRows, $product, $configurator, $renderer, 8.38, 5, $client, $mappedCodes);
echo json_encode($attributes, JSON_PRETTY_PRINT);
```

**Windows/tinker gotcha**: `php artisan tinker --execute="<code>"` can't pipe
a heredoc via `php://stdin` on Windows (`RequirePass` in psysh chokes on
it). Instead write the snippet to a real `.php` file and `include` it:
`php artisan tinker --execute="include 'C:/path/to/script.php';"`.

Always `php artisan optimize:clear` after touching `AmazonClient.php` — the
7-day schema cache and config/view caches can otherwise mask changes.

---

### Repeatable attributes now mappable + Bullet Points / Country of Origin custom fields

Investigating why a full-catalog create attempt for ERR3340 failed with
missing `country_of_origin` and `bullet_point` led to two related fixes:

**1. Repeatable attributes (e.g. `bullet_point`) can now be mapped, not just
given a Default Value.** The Attributes tab's "Map"/"Default Value" radio
previously hard-coded `is_repeatable` attributes to Default Value only
(`AmazonConfiguratorResource.php` mapping-form fields). Removed that
restriction — `buildAttributes()` in `AmazonBatchCreateJob.php` never
actually cared where the value came from, it just calls
`$row->resolveValue($product)` and splits the result into lines regardless
of `mapping_type`, so this was a pure UI limitation.

**Separator convention**: Default Value is a real Filament Textarea, so it
still splits on a genuine line break (`\n`). A *mapped* source field is
typically a plain single-line column (native or custom) that may also be
populated via CSV import, where a real embedded newline doesn't reliably
survive — so mapped repeatable attributes split on `|` (pipe) instead. This
branches on `$row->mapping_type === 'extended_property'` in
`AmazonBatchCreateJob::buildAttributes()`. The Source Field dropdown shows a
helper note explaining this only when `is_repeatable` is true.

**2. Two new empty custom fields seeded on Product**, same pattern as
`amazon_asin` (`FieldsColumnManager::createColumn()` after
`Field::firstOrCreate()`, migration
`2026_09_16_000002_seed_bullet_points_and_country_of_origin_custom_fields.php`,
registered in `ChannelServiceProvider`'s `->hasMigrations([...])` array):

- `bullet_points` → `products_products.bullet_points` (single-line text,
  `|`-separated for multiple bullets)
- `country_of_origin` → `products_products.country_of_origin` (single-line
  text, e.g. `CN`, `GB`, `TH`)

Somerset4x4 Amazon (configurator id 3) now maps `bullet_point` →
`field:2` (Bullet Points) and `country_of_origin` → `field:3` (Country of
Origin) — both `extended_property` mappings, replacing an interim mapping
of `bullet_point` to the native Subtitle field.

**Country of Origin data already exists, just wasn't bridged anywhere**:
`allmakes_psp_results.psp_country_origin` (added by an earlier PSP
migration, but never added to `PspResult::$fillable`/`$casts`, and never
copied onto the product) has real data for ~80% of PSP-linked products
(16,505 of 20,508 rows) — e.g. ERR3340 = `CN`, TF802 = `TH`. These two were
backfilled manually onto `products_products.country_of_origin` as a
one-off; there is **no automatic sync** from PSP scrape results to this new
field yet — if PSP data changes, the product's `country_of_origin` won't
follow unless something is built to bridge it (a job, or a step inside the
existing PSP scrape pipeline). Worth doing if COO needs to stay current
across the whole catalog rather than just these two test SKUs.

**3. Import/export support**, per
`z_notes/Claude_help/adding_importer_columns.md`'s checklist, done for both
new fields together:

- `ProcessProductImportJob.php` — `$map` + attrs block (queue path)
- `ImportManager.php` — `COLUMN_MAP` + `buildAttributes()` (synchronous path)
- `Product::$fillable` (`Webkul\Product\Models\Product`) — both added;
  without this the import silently no-ops on these two columns exactly like
  the `track_inventory` incident the doc describes
- `product-import.blade.php` — new **Custom Fields** group (purple,
  `sheet-group-custom`/`sheet-cell-custom`) in the Supported Columns
  reference, kept visually distinct from core Product Columns rather than
  merged into that list, per request
- `ExportManager.php` — new `CUSTOM_COLUMNS` const (kept separate from
  `PRODUCT_COLUMNS` on purpose, same reasoning as above), query select list,
  `getProductColumnValue()`
- `ExportProducts.php` — the `startExport()` column-validation filter had
  to be widened to check `CUSTOM_COLUMNS` too, or these two would be
  silently stripped before the query even runs (the export equivalent of
  the `$fillable` trap)
- `product-export.blade.php` — new **Custom Fields** checkbox group
  (matching the Channel Prices/Tax pattern, including its own select-all
  toggle `toggleCustomColumns()`)

Queue worker restarted after `ProcessProductImportJob.php` and
`AmazonBatchCreateJob.php` changes (both are job classes — see the
recurring stale-worker gotcha elsewhere in these notes).

### VerifyAmazonListingJob bug: "summaries OR offers" wrongly counted a catalog-only attach as confirmed

Real case that caught it: **TF381 / ASIN B01BKLFF8O**. Cydekick showed
status `synced`/"Listed"; Seller Central showed **"Missing Offer"** on the
same SKU. `getListingsItem('TF381', ['summaries','offers','issues'])`
confirmed both were telling the truth from their own vantage point:
`summaries` populated (catalog attachment genuinely `DISCOVERABLE`,
correct title/image), `issues: []`, but **`offers: []`** — no sellable
offer exists at all.

The bug: `VerifyAmazonListingJob`'s success check was
`if (! empty($summaries) || ! empty($offers))` — treating catalog content
alone as sufficient proof of "confirmed live." It isn't: a catalog entry
with no offer isn't something a customer can buy, which is exactly what
Seller Central's "Missing Offer" status means. The `||` let this specific
case slip through as a false success on the very first check, immediately
after `AmazonBatchCreateJob`'s create — the mapping never even entered the
retry cascade this job exists to provide.

Fixed to require `offers` specifically (`summaries` alone is no longer
sufficient), with a more specific give-up message distinguishing "catalog
live but still no offer after all retries" from "nothing attached at
all." Re-verified against the real TF381 case: with the fix in place, a
fresh dispatch correctly saw `offers: []` and scheduled attempt 2 for
~10 minutes later instead of wrongly declaring success — exactly the
retry-then-give-up behavior the job was originally meant to provide.

**Follow-up: fields also added to the Product Edit form and View page,
matching layout.** Initially only wired into the Amazon Configurator's
mapping dropdown — the user wanted `bullet_points`, `country_of_origin`,
and `amazon_asin` actually visible/editable on the standard product
Edit/View pages too, in the same layout as their neighbours (not a
generic custom-fields dump). Added as plain `TextInput`/`TextEntry`s:

- Edit form (`Webkul\Product\Filament\Resources\ProductResource::form()`):
  `bullet_points` next to Sub Title in General; `country_of_origin` +
  `amazon_asin` next to Barcode in Settings.
- View infolist (same class's `infolist()`): same three fields, same
  section placement, plain text (no fancy bulleted-list rendering — match
  the Edit form's raw pipe-separated text exactly, don't reformat it).
- `amazon_asin` had never been added to `Product::$fillable` (all prior
  writes went through raw `DB::table()` updates specifically to avoid
  needing to touch it) — now added, since the new form field saves via
  normal Eloquent mass assignment and would otherwise silently no-op.

**Gotcha hit: the Inventory cluster's `ProductResource` overrides both
`form()` and `infolist()`, so edits to the base class don't automatically
reach it.** Confirmed the same problem twice in one afternoon (see the
CSV-template route earlier in these notes):
- `infolist()`: deletes the base "General" section entirely (replaced by a
  header widget above the tabs) — `bullet_points` there was dead on
  arrival; re-added inside the override directly. `country_of_origin` /
  `amazon_asin` survive fine since they're in the untouched right-hand
  "Settings" section.
- `form()`: **also already auto-renders every registered custom `Field`**
  (any `Field` row with `customizable_type = Product`) into a generic
  catch-all "Additional" section via `HasCustomFields`/`CustomFields::make()`
  — meaning `bullet_points`/`country_of_origin`/`amazon_asin` were already
  technically editable there, just dumped in an unstyled generic section
  rather than placed sensibly. Excluded all three from that auto-render
  (`getCustomFormFields(exclude: [...])`) once they got proper hand-placed
  homes, to avoid the field appearing twice on the Inventory Edit page.
  Sales/Purchases/Invoices product resources don't call this auto-render
  at all, so adding the fields to the base form was enough for them with
  no duplication risk.
