@miravia_test/miravia-publish-skill 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -486,10 +486,10 @@ class PublishModule:
486
486
  logger.debug("Could not fetch category attributes: %s", e)
487
487
  definitions = []
488
488
 
489
- # Check EAN code - mandatory for nail polish category
489
+ # Check EAN code via the unified rule (API is_mandatory / postToInactive)
490
490
  skus = (product_payload.get("Skus") or {}).get("Sku") or []
491
491
  has_ean = any(sku.get("ean_code") for sku in skus)
492
- ean_mandatory = self._is_category_attribute_mandatory(definitions, "ean_code")
492
+ ean_mandatory = self._field_is_required("ean_code", definitions)
493
493
  if ean_mandatory and not has_ean:
494
494
  post_to_inactive_missing.append(
495
495
  "EAN code is required for this category but not filled. "
@@ -660,15 +660,30 @@ class PublishModule:
660
660
  ) or ""
661
661
  unit_count_val = getattr(self.args, "unit_count", None)
662
662
 
663
- # Also check payload attributes as fallback
663
+ # Also check payload as fallback. In manual mode SKU-level PPU fields
664
+ # (unit_count_type / Unit_Count) are auto-extracted out of Attributes and
665
+ # merged into each SKU, so the product-level Attributes dict is empty for
666
+ # them — we must also scan the SKU objects or the value is "lost" and PPU
667
+ # is wrongly reported as missing (Nail Polish 62198445 bug).
668
+ payload_skus = (product_payload.get("Skus") or {}).get("Sku") or []
669
+
670
+ def _first_present(keys: tuple) -> Any:
671
+ for src in [attributes] + list(payload_skus):
672
+ if not isinstance(src, dict):
673
+ continue
674
+ for key in keys:
675
+ val = src.get(key)
676
+ if val is not None and str(val).strip() != "":
677
+ return val
678
+ return None
679
+
664
680
  if not unit_type:
665
681
  unit_type = (
666
- attributes.get("unit_count_type")
667
- or attributes.get("standard_unit_type")
682
+ _first_present(("unit_count_type", "standard_unit_type", "Unit_Count_Type"))
668
683
  or ""
669
684
  )
670
685
  if unit_count_val is None:
671
- unit_count_val = attributes.get("unit_count")
686
+ unit_count_val = _first_present(("unit_count", "Unit_Count"))
672
687
 
673
688
  unit_count_str = str(unit_count_val).strip() if unit_count_val is not None else ""
674
689
 
@@ -786,23 +801,35 @@ class PublishModule:
786
801
  *,
787
802
  has_dbs_permission: bool = False,
788
803
  ) -> Optional[Dict[str, Any]]:
789
- """Check if package exceeds DBM limits and auto-switch to DBS.
790
-
791
- DBM limits:
792
- - Weight <= 40KG
793
- - L+W+H <= 300cm
794
- - Volumetric weight (L*W*H/6000) <= 40
795
-
796
- If any limit is exceeded and delivery_method is DBM:
797
- - Seller has DBS permission → auto-switch to DBS
798
- - Seller has no DBS permission → return error (block)
799
- Returns a dict with result, None if within limits.
804
+ """Resolve ``Attributes.deliverySof`` and enforce DBM package limits.
805
+
806
+ Miravia ``/product/create`` expects ``Attributes.deliverySof`` to be
807
+ ``"DBM"`` or ``"DBS"``. When the seller did not choose one it is
808
+ defaulted here: generally DBM, but DBS when the seller has DBS
809
+ permission. If the effective method is DBM but the package exceeds the
810
+ DBM limits (weight <= 40KG, L+W+H <= 300cm, volumetric weight
811
+ L*W*H/6000 <= 40), it is switched to DBS when permitted, otherwise the
812
+ caller blocks. Returns a result dict, or None when nothing to do.
800
813
  """
801
- attrs = product_payload.get("Attributes") or {}
802
- delivery_method = attrs.get("delivery_method") or attrs.get("Delivery_method") or ""
803
-
804
- # Only check if delivery method is DBM
805
- if delivery_method.upper() != "DBM":
814
+ attrs = product_payload.setdefault("Attributes", {})
815
+ delivery_method = str(attrs.get("deliverySof") or "").strip().upper()
816
+ downgraded_from_dbs = False
817
+
818
+ if delivery_method not in ("DBM", "DBS"):
819
+ # No explicit entry: default to DBS when the seller is permitted,
820
+ # otherwise DBM.
821
+ delivery_method = "DBS" if has_dbs_permission else "DBM"
822
+ attrs["deliverySof"] = delivery_method
823
+ elif delivery_method == "DBS" and not has_dbs_permission:
824
+ # Seller explicitly chose DBS but the account has no DBS permission:
825
+ # never grant DBS without permission — fall back to DBM and still
826
+ # enforce the DBM package limits below.
827
+ delivery_method = "DBM"
828
+ attrs["deliverySof"] = "DBM"
829
+ downgraded_from_dbs = True
830
+
831
+ # Only DBM carries package limits.
832
+ if delivery_method != "DBM":
806
833
  return None
807
834
 
808
835
  skus = (product_payload.get("Skus") or {}).get("Sku") or []
@@ -837,17 +864,17 @@ class PublishModule:
837
864
  )
838
865
 
839
866
  if not exceeded:
867
+ if downgraded_from_dbs:
868
+ return {
869
+ "downgraded_to_dbm": True,
870
+ "message": i18n._("pub_dbs_no_permission_downgrade"),
871
+ }
840
872
  return None
841
873
 
842
874
  # Exceeded DBM limits — check DBS permission
843
875
  if has_dbs_permission:
844
876
  # Auto-switch to DBS
845
- attrs["delivery_method"] = "DBS"
846
- attrs["Delivery_method"] = "DBS"
847
- # Also update SKU-level if present
848
- for s in skus:
849
- if s.get("delivery_method"):
850
- s["delivery_method"] = "DBS"
877
+ attrs["deliverySof"] = "DBS"
851
878
  return {
852
879
  "switched_to_dbs": True,
853
880
  "reasons": exceeded,
@@ -870,13 +897,30 @@ class PublishModule:
870
897
  missing: Optional[Dict[str, str]] = None,
871
898
  draft: bool = False,
872
899
  draft_issues: Optional[List[str]] = None,
900
+ category_attributes: Optional[List[Dict[str, Any]]] = None,
873
901
  ) -> Dict[str, Any]:
874
902
  """Validate, preview, pre-validate, and execute product creation."""
875
- _validate_create_payload(product_payload)
903
+ # Decide SellerSku mandatory-ness from the category attribute API via the
904
+ # unified rule, so this pre-submit check matches every other layer
905
+ # (empty SellerSku is allowed and auto-generated when not mandatory).
906
+ if category_attributes is None:
907
+ cat_id = (preview_summary or {}).get("category_id")
908
+ lang = self.args.lang if getattr(self.args, "lang", None) not in (None, "auto") else "en_US"
909
+ try:
910
+ category_attributes = self.product.get_category_attributes(
911
+ str(cat_id), language_code=lang
912
+ )
913
+ except Exception:
914
+ category_attributes = []
915
+ seller_sku_required = self._field_is_required(
916
+ "SellerSku", category_attributes or []
917
+ )
918
+ _validate_create_payload(product_payload, seller_sku_required=seller_sku_required)
876
919
 
877
920
  # Check DBM limits — auto-switch to DBS if exceeded (requires DBS permission)
878
921
  has_dbs = self.product.get_shipping_permission()
879
922
  dbm_result = self._check_dbm_limits(product_payload, has_dbs_permission=has_dbs)
923
+ dbm_notes: List[str] = []
880
924
  if dbm_result:
881
925
  if dbm_result.get("blocked"):
882
926
  # No DBS permission and exceeds DBM limits — block
@@ -889,10 +933,12 @@ class PublishModule:
889
933
  ],
890
934
  }
891
935
  if dbm_result.get("switched_to_dbs"):
892
- if draft_issues is not None:
893
- draft_issues.append(dbm_result["message"])
894
- for reason in dbm_result.get("reasons", []):
895
- draft_issues.append(f" - {reason}")
936
+ dbm_notes.append(dbm_result["message"])
937
+ dbm_notes.extend(f" - {r}" for r in dbm_result.get("reasons", []))
938
+ if dbm_result.get("downgraded_to_dbm"):
939
+ # Seller asked for DBS but has no DBS permission — explain the
940
+ # fallback so the delivery-method change is not silent.
941
+ dbm_notes.append(dbm_result["message"])
896
942
 
897
943
  # Run pre-validate early so errors are visible in the preview
898
944
  prevalidate_result = self._run_prevalidate(product_payload)
@@ -905,6 +951,7 @@ class PublishModule:
905
951
 
906
952
  # Merge all issues for preview
907
953
  all_issues = list(draft_issues or [])
954
+ all_issues.extend(dbm_notes)
908
955
  if prevalidate_errors:
909
956
  all_issues.extend(prevalidate_errors)
910
957
 
@@ -988,26 +1035,34 @@ class PublishModule:
988
1035
  i18n._("pub_sku_already_exists_next"),
989
1036
  ],
990
1037
  }
991
- # Code 4234: draft product created but missing required fields
1038
+ # Code 4234 in draft mode: only a success when the API actually
1039
+ # returned an item_id — without one, the draft was NOT created and
1040
+ # the error must be surfaced with full diagnostics.
992
1041
  if exc.code == "4234" and draft:
993
1042
  err_data = (exc.payload.get("data") or {}) if exc.payload else {}
994
1043
  item_id = err_data.get("item_id")
995
- pdp_url = self._make_pdp_url(item_id)
996
- return {
997
- "status": "draft_created_with_issues",
998
- "message": i18n._("pub_draft_created_with_issues"),
999
- "item_id": item_id,
1000
- "pdp_url": pdp_url,
1001
- "api_message": exc.msg,
1002
- "draft": True,
1003
- "next_actions": [
1004
- i18n._("pub_draft_update_hint"),
1005
- ],
1006
- }
1044
+ if item_id:
1045
+ pdp_url = self._make_pdp_url(item_id)
1046
+ return {
1047
+ "status": "draft_created_with_issues",
1048
+ "message": i18n._("pub_draft_created_with_issues"),
1049
+ "item_id": item_id,
1050
+ "pdp_url": pdp_url,
1051
+ "api_message": exc.msg,
1052
+ "draft": True,
1053
+ "next_actions": [
1054
+ i18n._("pub_draft_update_hint"),
1055
+ ],
1056
+ }
1057
+ else:
1058
+ return self._make_create_failed_response(exc, draft)
1007
1059
  raise
1008
1060
  data = result.get("data", {}) or {}
1009
1061
 
1010
1062
  item_id = data.get("item_id")
1063
+ if not item_id:
1064
+ # No item_id ⇒ creation failed even though code was 0.
1065
+ return self._make_create_failed_response(exc=None, draft=draft, data=data)
1011
1066
  pdp_url = self._make_pdp_url(item_id)
1012
1067
  return {
1013
1068
  "status": "success",
@@ -1020,6 +1075,49 @@ class PublishModule:
1020
1075
  "next_actions": [i18n._("pub_create_success_next")],
1021
1076
  }
1022
1077
 
1078
+ def _make_create_failed_response(
1079
+ self,
1080
+ exc: Optional[MiraviaError],
1081
+ draft: bool,
1082
+ data: Optional[Dict[str, Any]] = None,
1083
+ ) -> Dict[str, Any]:
1084
+ """Build a structured ``create_failed`` response with API diagnostics.
1085
+
1086
+ A create call without ``item_id`` in the payload means the product
1087
+ (draft or active) was NOT created — never report it as success.
1088
+ ``request_id`` / ``_trace_id_`` are echoed back so the seller can
1089
+ locate the root cause in logs or with platform support.
1090
+ """
1091
+ payload = (exc.payload if exc else None) or {}
1092
+ err_type = str(payload.get("type") or "")
1093
+ request_id = str(payload.get("request_id") or payload.get("requestId") or "")
1094
+ trace_id = str(payload.get("_trace_id_") or payload.get("trace_id") or "")
1095
+ next_actions: List[str] = [
1096
+ i18n._("pub_create_failed_check_and_rerun"),
1097
+ ]
1098
+ if trace_id or request_id:
1099
+ next_actions.append(
1100
+ i18n._("pub_create_failed_trace_hint", trace_id=trace_id, request_id=request_id)
1101
+ )
1102
+ return {
1103
+ "status": "create_failed",
1104
+ "title": i18n._("pub_create_failed_title"),
1105
+ "message": i18n._(
1106
+ "pub_create_failed_message",
1107
+ code=(exc.code if exc else "0"),
1108
+ reason=(exc.msg if exc else i18n._("pub_create_failed_no_item_id")),
1109
+ ),
1110
+ "code": (exc.code if exc else "0"),
1111
+ "error_type": err_type,
1112
+ "api_message": (exc.msg if exc else ""),
1113
+ "hint": (exc.hint if exc else ""),
1114
+ "request_id": request_id,
1115
+ "trace_id": trace_id,
1116
+ "draft": draft,
1117
+ "item_id": None,
1118
+ "next_actions": next_actions,
1119
+ }
1120
+
1023
1121
  # ==================================================================
1024
1122
  # AI-assisted create flow
1025
1123
  # ==================================================================
@@ -1215,7 +1313,9 @@ class PublishModule:
1215
1313
  logger.debug("CRO requirement API is not available yet")
1216
1314
 
1217
1315
  # Stage 3: resolve compliance (manufacturer / EURP)
1218
- compliance_result = self._resolve_compliance_info_with_check(schema)
1316
+ compliance_result = self._resolve_compliance_info_with_check(
1317
+ schema, draft=getattr(self.args, "draft", False)
1318
+ )
1219
1319
  if compliance_result.get("status"):
1220
1320
  return compliance_result
1221
1321
 
@@ -1257,6 +1357,26 @@ class PublishModule:
1257
1357
  draft=draft_requested,
1258
1358
  )
1259
1359
 
1360
+ # Explicit --attribute-data must reach the AI payload too (it used to be
1361
+ # honored only in the manual flow, so AI-mode values were silently
1362
+ # dropped). Applied before unmatched-column binding so explicit values win.
1363
+ self._apply_attribute_data_override(product_payload)
1364
+
1365
+ # Bind extra Excel/CSV columns that the whitelist did not recognize,
1366
+ # using the category attribute definitions fetched above.
1367
+ unmapped_cols = self._apply_unmatched_category_attributes(
1368
+ product_payload, schema, category_attributes
1369
+ )
1370
+ self._unmapped_columns = unmapped_cols
1371
+
1372
+ # If a provided column could not be confidently matched but plausibly
1373
+ # maps to one or more category attributes, ask the seller to confirm the
1374
+ # mapping instead of silently dropping it (which would otherwise surface
1375
+ # later as a cryptic batchValidate "missing attribute" error).
1376
+ mapping_check = self._check_unmapped_columns(unmapped_cols, product_payload)
1377
+ if mapping_check:
1378
+ return mapping_check
1379
+
1260
1380
  # Stage 3b: unified compliance check (GPSR + Safety Warning + compliance docs)
1261
1381
  draft_issues: list = []
1262
1382
  compliance_check = self._check_compliance_fields(
@@ -1298,8 +1418,26 @@ class PublishModule:
1298
1418
  return cat_attr_result
1299
1419
 
1300
1420
  # Stage 4b: mandatory field checks (cannot continue if missing)
1301
- mandatory_result = self._check_mandatory_fields(product_payload, schema)
1421
+ mandatory_result = self._check_mandatory_fields(
1422
+ product_payload, schema, category_attributes
1423
+ )
1302
1424
  if mandatory_result.get("status") == "need_mandatory_fields":
1425
+ hard_missing = mandatory_result.get("hard_missing_fields") or []
1426
+ if hard_missing:
1427
+ # Platform hard requirement (e.g. main image): /product/create
1428
+ # rejects it with 4224 even for Draft, so block unconditionally and
1429
+ # do NOT offer or fall back to draft mode.
1430
+ mandatory_result["missing_fields"] = (
1431
+ hard_missing + mandatory_result.get("missing_fields", [])
1432
+ )
1433
+ mandatory_result["message"] = i18n._(
1434
+ "pub_image_required_no_draft", fields=", ".join(hard_missing)
1435
+ )
1436
+ mandatory_result["next_actions"] = [
1437
+ i18n._("pub_add_images_and_rerun")
1438
+ ]
1439
+ mandatory_result["draft_mode_available"] = False
1440
+ return mandatory_result
1303
1441
  if draft_requested:
1304
1442
  for field in mandatory_result.get("missing_fields", []):
1305
1443
  draft_issues.append(f"Missing mandatory field: {field}")
@@ -1311,7 +1449,7 @@ class PublishModule:
1311
1449
  return mandatory_result
1312
1450
 
1313
1451
  # Stage 5: field summary and draft/continue confirmation
1314
- key_missing = self._build_key_missing_list(
1452
+ key_missing, api_optional = self._build_key_missing_list(
1315
1453
  schema, product_payload, compliance_result, category_attributes
1316
1454
  )
1317
1455
  country_price_note = self._check_country_price_recommendation(schema)
@@ -1335,7 +1473,8 @@ class PublishModule:
1335
1473
  provided_fs_token = getattr(self.args, "field_summary_token", None)
1336
1474
  if provided_fs_token != expected_fs_token:
1337
1475
  return self._make_field_summary_preview(
1338
- fs_preview, key_missing, country_price_note
1476
+ fs_preview, key_missing, country_price_note,
1477
+ api_optional=api_optional,
1339
1478
  )
1340
1479
 
1341
1480
  # Stage 6: finalize (pre-validate + create)
@@ -1349,6 +1488,7 @@ class PublishModule:
1349
1488
  missing={k: k for k in key_missing},
1350
1489
  draft=draft_requested,
1351
1490
  draft_issues=draft_issues or None,
1491
+ category_attributes=category_attributes,
1352
1492
  )
1353
1493
 
1354
1494
  # ------------------------------------------------------------------
@@ -1363,7 +1503,6 @@ class PublishModule:
1363
1503
  ("brand_id", "Brand", "mandatory"),
1364
1504
  ("general_price", "General price", "mandatory"),
1365
1505
  ("stock", "Stock", "mandatory"),
1366
- ("seller_sku", "Seller SKU", "mandatory"),
1367
1506
  ("weight_kg", "Weight", "mandatory"),
1368
1507
  ("length_cm", "Length", "mandatory"),
1369
1508
  ("width_cm", "Width", "mandatory"),
@@ -1371,6 +1510,8 @@ class PublishModule:
1371
1510
  ("delivery_method", "Delivery method", "mandatory"),
1372
1511
  # Conditional fields — missing suggests Draft
1373
1512
  ("italy_price", "Italy price", "conditional"),
1513
+ # Seller SKU is optional (create API auto-generates when empty)
1514
+ ("seller_sku", "Seller SKU", "conditional"),
1374
1515
  ("ean_code", "EAN code", "conditional"),
1375
1516
  ("manufacturer", "Manufacturer", "conditional"),
1376
1517
  ("eu_responsible_person", "EU responsible person", "conditional"),
@@ -1383,6 +1524,33 @@ class PublishModule:
1383
1524
  ("isbn", "ISBN", "conditional"),
1384
1525
  ]
1385
1526
 
1527
+ # Conditional fields WE always require regardless of category (GPSR law,
1528
+ # Miravia site-pricing rule, etc.) — missing them always prompts the user.
1529
+ _HARD_REQUIRED_CONDITIONAL_KEYS = frozenset(
1530
+ {
1531
+ "manufacturer",
1532
+ "eu_responsible_person",
1533
+ "italy_price",
1534
+ "standard_unit_type",
1535
+ }
1536
+ )
1537
+
1538
+ # Structural fields Miravia always requires for any product (not category
1539
+ # attributes, so the API never governs them) — always blocking. NOTE:
1540
+ # delivery method (Attributes.deliverySof) is intentionally NOT here — it is
1541
+ # auto-defaulted (DBM, or DBS when the seller has DBS permission) rather
1542
+ # than hard-collected; see _check_dbm_limits.
1543
+ _CORE_MANDATORY_KEYS = frozenset(
1544
+ {
1545
+ "product_title",
1546
+ "product_images",
1547
+ "category_id",
1548
+ "brand_id",
1549
+ "general_price",
1550
+ "stock",
1551
+ }
1552
+ )
1553
+
1386
1554
  def _resolve_category_for_schema(
1387
1555
  self, schema: MiraviaListingSchema
1388
1556
  ) -> Dict[str, Any]:
@@ -1435,7 +1603,7 @@ class PublishModule:
1435
1603
  ) -> Dict[str, Any]:
1436
1604
  """Try recommend API first, fallback to category tree."""
1437
1605
  title = schema.product_title
1438
- image_urls = schema.image_urls or []
1606
+ image_urls = schema.product_images or []
1439
1607
  if title or image_urls:
1440
1608
  try:
1441
1609
  recommendations = self.product.recommend_category(
@@ -1535,15 +1703,19 @@ class PublishModule:
1535
1703
  """
1536
1704
  confirmed_brand_id = getattr(self.args, "confirmed_brand_id", None)
1537
1705
  if confirmed_brand_id:
1706
+ # Honor an explicitly passed --brand-name too (aligns with the
1707
+ # manual flow's _resolve_brand_interactive). Otherwise a seller who
1708
+ # confirms by ID but whose source lacks the brand name would have
1709
+ # the brand silently dropped from the payload.
1538
1710
  return {
1539
1711
  "brand_id": int(confirmed_brand_id),
1540
- "brand_name": schema.brand_name or "",
1712
+ "brand_name": getattr(self.args, "brand_name", None) or schema.brand_name or "",
1541
1713
  }
1542
1714
 
1543
1715
  if schema.brand_id:
1544
1716
  return {
1545
1717
  "brand_id": int(schema.brand_id),
1546
- "brand_name": schema.brand_name or "",
1718
+ "brand_name": getattr(self.args, "brand_name", None) or schema.brand_name or "",
1547
1719
  }
1548
1720
 
1549
1721
  brand_name = self.args.brand_name or schema.brand_name
@@ -1584,10 +1756,14 @@ class PublishModule:
1584
1756
  "pub_brand_not_found_message", brand_name=brand_name
1585
1757
  ),
1586
1758
  "brand_name": brand_name,
1759
+ "brand_list_link": (
1760
+ "https://sellercenter.miravia.es/apps/setting/seller/brandlist"
1761
+ ),
1587
1762
  "create_brand_link": (
1588
1763
  "https://sellercenter.miravia.es/apps/product/publish/brand"
1589
1764
  ),
1590
1765
  "next_actions": [
1766
+ i18n._("pub_brand_list_link"),
1591
1767
  i18n._("pub_brand_create_link"),
1592
1768
  i18n._("pub_brand_create_next"),
1593
1769
  i18n._("pub_brand_reconfirm_or_choose"),
@@ -1617,9 +1793,15 @@ class PublishModule:
1617
1793
  }
1618
1794
 
1619
1795
  def _resolve_compliance_info_with_check(
1620
- self, schema: MiraviaListingSchema
1796
+ self, schema: MiraviaListingSchema, draft: bool = False
1621
1797
  ) -> Dict[str, Any]:
1622
- """Resolve manufacturer / EURP IDs and surface mismatches."""
1798
+ """Resolve manufacturer / EURP IDs and surface mismatches.
1799
+
1800
+ In draft mode an unmatched manufacturer / EU responsible person is not a
1801
+ hard blocker: the IDs are left empty and the seller completes them in the
1802
+ Seller Center draft, so the flow continues instead of returning
1803
+ ``need_compliance``.
1804
+ """
1623
1805
  result: Dict[str, Any] = {}
1624
1806
  choices: List[Dict[str, Any]] = []
1625
1807
 
@@ -1667,6 +1849,11 @@ class PublishModule:
1667
1849
  logger.warning("Failed to query EU responsible person: %s", exc)
1668
1850
 
1669
1851
  if result.get("manufacturer_mismatch") or result.get("eurp_mismatch"):
1852
+ if draft:
1853
+ logger.info(
1854
+ "Draft mode: deferring unmatched manufacturer/EURP to Seller Center"
1855
+ )
1856
+ return {}
1670
1857
  return {
1671
1858
  "status": "need_compliance",
1672
1859
  "message": i18n._("pub_manufacturer_eurp_mismatch"),
@@ -1701,14 +1888,28 @@ class PublishModule:
1701
1888
  return None
1702
1889
 
1703
1890
  payload_attrs = product_payload.get("Attributes") or {}
1891
+ payload_skus = (product_payload.get("Skus") or {}).get("Sku") or []
1892
+
1893
+ def _present_in(container: Dict[str, Any], attr: Dict[str, Any]) -> bool:
1894
+ for key in (attr.get("name"), attr.get("en_name")):
1895
+ if key and str(container.get(key) or "").strip():
1896
+ return True
1897
+ return False
1898
+
1704
1899
  missing_attrs: List[Dict[str, Any]] = []
1705
1900
  for attr in mandatory:
1706
- # Check by en_name or name key in payload attributes
1707
- en_name = (attr.get("en_name") or attr.get("name") or "").strip()
1708
- if not en_name:
1901
+ if not (attr.get("en_name") or attr.get("name")):
1709
1902
  continue
1710
- val = payload_attrs.get(en_name)
1711
- if val is None or (isinstance(val, str) and not val.strip()):
1903
+ if (attr.get("attribute_type") or "normal") == "sku":
1904
+ # SKU-level attribute: must be filled on every SKU.
1905
+ if payload_skus and all(
1906
+ _present_in(sku, attr) for sku in payload_skus
1907
+ ):
1908
+ continue
1909
+ missing_attrs.append(attr)
1910
+ else:
1911
+ if _present_in(payload_attrs, attr):
1912
+ continue
1712
1913
  missing_attrs.append(attr)
1713
1914
 
1714
1915
  if not missing_attrs:
@@ -1754,32 +1955,59 @@ class PublishModule:
1754
1955
  }
1755
1956
 
1756
1957
  def _check_mandatory_fields(
1757
- self, product_payload: Dict[str, Any], schema: MiraviaListingSchema
1958
+ self,
1959
+ product_payload: Dict[str, Any],
1960
+ schema: MiraviaListingSchema,
1961
+ category_attributes: Optional[List[Dict[str, Any]]] = None,
1758
1962
  ) -> Dict[str, Any]:
1759
1963
  """Check required fields that block continuation when missing.
1760
1964
 
1761
- Per the Miravia ``/product/create`` documentation, package
1762
- dimensions/weight and ``SellerSku`` are required at the SKU level.
1965
+ Uses the same ``_field_is_required`` rule as every other layer:
1966
+ - Structural fields (images / general price / stock) are always blocking.
1967
+ - Package weight/dimensions are SKU-level category attributes, so they
1968
+ only block when the API marks them ``is_mandatory`` for this category
1969
+ (batchValidate remains the authoritative backstop).
1970
+ - ``SellerSku`` is never blocking: the create API auto-generates it.
1763
1971
  """
1972
+ category_attributes = category_attributes or []
1764
1973
  missing: List[str] = []
1765
- images = (product_payload.get("Images") or {}).get("Image") or []
1766
- if not images:
1767
- missing.append(i18n._("label_image_url"))
1974
+ hard_missing: List[str] = []
1768
1975
 
1769
1976
  skus = (product_payload.get("Skus") or {}).get("Sku") or []
1770
- has_price = any(sku.get("price") for sku in skus)
1771
- if not has_price:
1772
- missing.append("General price")
1773
1977
 
1774
- has_stock = any(sku.get("quantity") for sku in skus)
1775
- if not has_stock:
1776
- missing.append("Stock")
1978
+ # Main image is a platform-level hard requirement: /product/create rejects
1979
+ # an imageless product with 4224 MAIN_IMAGE_OR_SKU_IMAGE_EMPTY even for
1980
+ # Draft, so it must BLOCK and can never be routed to Draft. Miravia accepts
1981
+ # either an SPU-level image or any SKU-level image, so only block when both
1982
+ # are empty.
1983
+ images = (product_payload.get("Images") or {}).get("Image") or []
1984
+ has_sku_image = any((s.get("Images") or {}).get("Image") for s in skus)
1985
+ if (
1986
+ self._field_is_required("product_images", category_attributes)
1987
+ and not images
1988
+ and not has_sku_image
1989
+ ):
1990
+ hard_missing.append(i18n._("label_image_url"))
1991
+
1992
+ if self._field_is_required("general_price", category_attributes):
1993
+ if not any(sku.get("price") for sku in skus):
1994
+ missing.append("General price")
1995
+
1996
+ if self._field_is_required("stock", category_attributes):
1997
+ if not any(sku.get("quantity") for sku in skus):
1998
+ missing.append("Stock")
1777
1999
 
1778
2000
  package_dims = ("package_weight", "package_length", "package_width", "package_height")
2001
+ # Weight/Dimensions are mandatory by default (Miravia needs them for
2002
+ # shipping); a dimension is only skipped when the category API EXPLICITLY
2003
+ # marks it optional (is_mandatory=0). A missing attribute or API failure
2004
+ # keeps it blocking (fail safe toward blocking).
2005
+ required_dims = [
2006
+ dim for dim in package_dims
2007
+ if not self._field_explicitly_optional(dim, category_attributes)
2008
+ ]
1779
2009
  for idx, sku in enumerate(skus, start=1):
1780
- if not (sku.get("SellerSku") or sku.get("seller_sku")):
1781
- missing.append(f"Seller SKU (SKU #{idx})")
1782
- for dim in package_dims:
2010
+ for dim in required_dims:
1783
2011
  val = sku.get(dim)
1784
2012
  try:
1785
2013
  if val is None or float(val) <= 0:
@@ -1787,13 +2015,14 @@ class PublishModule:
1787
2015
  except (TypeError, ValueError):
1788
2016
  missing.append(f"{dim.replace('_', ' ').title()} (SKU #{idx})")
1789
2017
 
1790
- if missing:
2018
+ if missing or hard_missing:
1791
2019
  return {
1792
2020
  "status": "need_mandatory_fields",
1793
2021
  "message": i18n._(
1794
- "pub_mandatory_fields_missing", fields=", ".join(missing)
2022
+ "pub_mandatory_fields_missing", fields=", ".join(hard_missing + missing)
1795
2023
  ),
1796
2024
  "missing_fields": missing,
2025
+ "hard_missing_fields": hard_missing,
1797
2026
  "next_actions": [i18n._("interactive_preview_next")],
1798
2027
  }
1799
2028
  return {}
@@ -1804,24 +2033,64 @@ class PublishModule:
1804
2033
  product_payload: Dict[str, Any],
1805
2034
  compliance_result: Dict[str, Any],
1806
2035
  category_attributes: List[Dict[str, Any]],
1807
- ) -> List[str]:
1808
- """Return internal keys of fields that are missing but can be skipped in draft mode.
2036
+ ) -> "tuple[List[str], List[str]]":
2037
+ """Return internal keys of fields that are missing but can be skipped in draft mode,
2038
+ plus keys the category API explicitly marks as optional (not to be flagged).
1809
2039
 
1810
2040
  Keys match ``_TRACKED_FIELDS`` internal names so the summary preview
1811
2041
  can correctly mark them as missing.
1812
2042
  """
1813
2043
  missing: List[str] = []
1814
2044
  skus = (product_payload.get("Skus") or {}).get("Sku") or []
2045
+
2046
+ # Conditional fields not in our own hard-requirement list follow the
2047
+ # category attribute API (is_mandatory). e.g. EAN code is optional for
2048
+ # jeans, so an empty EAN must NOT be flagged as missing.
2049
+ api_optional: List[str] = []
2050
+
2051
+ # Delivery method (Attributes.deliverySof) is NOT a hard-collect field:
2052
+ # Miravia defaults it when absent (DBM, or DBS if the seller has DBS
2053
+ # permission — see _check_dbm_limits), so an empty value shows as
2054
+ # "Optional" here and never blocks.
2055
+ if not (getattr(schema, "delivery_method", None) or "").strip():
2056
+ api_optional.append("delivery_method")
1815
2057
  has_ean = any(sku.get("ean_code") for sku in skus) or bool(schema.ean_codes)
1816
2058
  if not has_ean:
1817
- missing.append("ean_code")
2059
+ if self._field_is_required("ean_code", category_attributes):
2060
+ missing.append("ean_code")
2061
+ else:
2062
+ api_optional.append("ean_code")
1818
2063
 
1819
2064
  if self._check_italy_price_mandatory(schema):
1820
2065
  missing.append("italy_price")
1821
2066
 
1822
- if schema.standard_unit_type and not schema.unit_count:
2067
+ def _payload_has(keys: "tuple[str, ...]") -> bool:
2068
+ """True when any non-empty value for the given key variants exists in
2069
+ the bound SKU payload or product-level Attributes. Covers the
2070
+ category-attribute-binding path where Unit_Count lands in the SKU
2071
+ even though schema.unit_count stays None."""
2072
+ cand = {self._squash_attr_name(k) for k in keys}
2073
+ for sku in skus:
2074
+ for k, v in sku.items():
2075
+ if self._squash_attr_name(str(k)) in cand and str(v or "").strip():
2076
+ return True
2077
+ for k, v in (product_payload.get("Attributes") or {}).items():
2078
+ if self._squash_attr_name(str(k)) in cand and str(v or "").strip():
2079
+ return True
2080
+ return False
2081
+
2082
+ unit_count_present = bool(
2083
+ (schema.unit_count is not None and str(schema.unit_count).strip())
2084
+ or _payload_has(("unit_count", "Unit_Count"))
2085
+ )
2086
+ unit_type_present = bool(
2087
+ (schema.standard_unit_type and str(schema.standard_unit_type).strip())
2088
+ or _payload_has(("unit_count_type", "standard_unit_type", "Unit_Count_Type"))
2089
+ )
2090
+
2091
+ if unit_type_present and not unit_count_present:
1823
2092
  missing.append("unit_count")
1824
- elif self._is_category_attribute_mandatory(category_attributes, "unit_count") and not schema.unit_count:
2093
+ elif self._is_category_attribute_mandatory(category_attributes, "unit_count") and not unit_count_present:
1825
2094
  missing.append("unit_count")
1826
2095
 
1827
2096
  if not schema.sku_attributes_food_health and self._category_looks_like_food_health(
@@ -1840,18 +2109,29 @@ class PublishModule:
1840
2109
  missing.append("eu_responsible_person")
1841
2110
 
1842
2111
  if schema.safety_warning is None:
1843
- missing.append("safety_warning")
2112
+ if self._field_is_required("safety_warning", category_attributes):
2113
+ missing.append("safety_warning")
2114
+ else:
2115
+ api_optional.append("safety_warning")
1844
2116
 
1845
2117
  if not schema.package_photos:
1846
- missing.append("package_photos")
2118
+ if self._field_is_required("package_photos", category_attributes):
2119
+ missing.append("package_photos")
2120
+ else:
2121
+ api_optional.append("package_photos")
1847
2122
 
1848
- if self._is_category_attribute_mandatory(category_attributes, "compliance_documents") and not schema.compliance_documents:
2123
+ if not schema.compliance_documents and self._is_category_attribute_mandatory(
2124
+ category_attributes, "compliance_documents"
2125
+ ):
1849
2126
  missing.append("compliance_documents")
1850
2127
 
2128
+ # Seller SKU is optional: when empty the create API auto-generates a
2129
+ # unique value. Never block or suggest Draft for it — just show as
2130
+ # Optional in the summary when not provided.
1851
2131
  if not any(sku.get("SellerSku") or sku.get("seller_sku") for sku in skus):
1852
- missing.append("seller_sku")
2132
+ api_optional.append("seller_sku")
1853
2133
 
1854
- return missing
2134
+ return missing, api_optional
1855
2135
 
1856
2136
  def _category_looks_like_food_health(
1857
2137
  self, category_attributes: List[Dict[str, Any]]
@@ -1865,14 +2145,309 @@ class PublishModule:
1865
2145
  for kw in ("food", "health", "nutrition", "allergen", "ingredient")
1866
2146
  )
1867
2147
 
2148
+ @staticmethod
2149
+ def _normalize_attr_name(value: Any) -> str:
2150
+ """Normalize an attribute/field name for fuzzy matching across layers."""
2151
+ return re.sub(r"[^a-z0-9]+", "_", str(value).strip().lower()).strip("_")
2152
+
2153
+ @staticmethod
2154
+ def _squash_attr_name(value: Any) -> str:
2155
+ """Aggressively squash a name (drop all separators) for matching.
2156
+
2157
+ Lets ``SellerSku`` match an API attribute named ``seller_sku`` and
2158
+ ``Unit Count`` match ``unit_count`` — variants that differ only by
2159
+ underscores/spacing.
2160
+ """
2161
+ return re.sub(r"[^a-z0-9]+", "", str(value).strip().lower())
2162
+
2163
+ def _find_category_attribute(
2164
+ self,
2165
+ category_attributes: List[Dict[str, Any]],
2166
+ candidates: tuple,
2167
+ ) -> Optional[Dict[str, Any]]:
2168
+ """Locate a category attribute definition by any of its name variants.
2169
+
2170
+ Matches on both the underscore-normalized form and the fully squashed
2171
+ form so ``SellerSku``/``seller_sku`` and similar casing/spacing variants
2172
+ resolve to the same attribute.
2173
+ """
2174
+ wanted = set()
2175
+ for c in candidates:
2176
+ wanted.add(self._normalize_attr_name(c))
2177
+ wanted.add(self._squash_attr_name(c))
2178
+ # Pass 1: exact match on any name variant (normalized or squashed).
2179
+ for attr in category_attributes or []:
2180
+ names = set()
2181
+ for key in ("name", "en_name", "label", "attribute_name"):
2182
+ if attr.get(key):
2183
+ names.add(self._normalize_attr_name(attr.get(key)))
2184
+ names.add(self._squash_attr_name(attr.get(key)))
2185
+ if names & wanted:
2186
+ return attr
2187
+ # Pass 2: conservative substring — a candidate squashed name (>=4 chars)
2188
+ # contained inside an attribute name variant. One-directional (candidate
2189
+ # in attribute) so short/generic headers don't shadow real ones.
2190
+ wanted_sq = {
2191
+ self._squash_attr_name(c) for c in candidates if len(self._squash_attr_name(c)) >= 4
2192
+ }
2193
+ if wanted_sq:
2194
+ for attr in category_attributes or []:
2195
+ for key in ("name", "en_name", "label", "attribute_name"):
2196
+ if not attr.get(key):
2197
+ continue
2198
+ a_sq = self._squash_attr_name(attr.get(key))
2199
+ if any(w in a_sq for w in wanted_sq):
2200
+ return attr
2201
+ return None
2202
+
2203
+ def _apply_unmatched_category_attributes(
2204
+ self,
2205
+ product_payload: Dict[str, Any],
2206
+ schema: MiraviaListingSchema,
2207
+ category_attributes: List[Dict[str, Any]],
2208
+ ) -> List[str]:
2209
+ """Bind non-whitelisted Excel/CSV columns to real category attributes.
2210
+
2211
+ ``schema.unmatched_columns`` holds every column header the rule
2212
+ extractor could not map to a known field. For each, we look it up
2213
+ against ``/category/attributes/get``; when it matches, we write the
2214
+ value under the attribute's canonical key — into each SKU for
2215
+ ``attribute_type == 'sku'``, otherwise into the product-level
2216
+ ``Attributes``. Existing keys are never overwritten (whitelist/LLM
2217
+ values win). Returns a list of ``{header, value, candidates}`` for the
2218
+ headers that matched no category attribute confidently, so the caller can
2219
+ ask the seller to confirm the mapping instead of silently dropping them.
2220
+
2221
+ We intentionally bind ONLY the non-whitelisted columns here. Columns
2222
+ the whitelist already consumed (Title/Price/Image/Color/Stock/...) are
2223
+ assembled by the core extractor into their canonical payload shape; if
2224
+ we also re-bound them we would inject duplicate or case-colliding keys
2225
+ into the SKU (e.g. lowercase ``images`` next to the ``Images`` object,
2226
+ ``color_family`` next to ``color``), and ``/product/create`` rejects
2227
+ such a payload with E005 Invalid Request Format even though
2228
+ batchValidate tolerates the extra keys.
2229
+ """
2230
+ unmatched = getattr(schema, "unmatched_columns", {}) or {}
2231
+ if not unmatched:
2232
+ return []
2233
+ attributes = product_payload.setdefault("Attributes", {})
2234
+ skus = (product_payload.get("Skus") or {}).get("Sku") or []
2235
+ unmapped: List[Dict[str, Any]] = []
2236
+ for header, value in unmatched.items():
2237
+ attr = self._find_category_attribute(category_attributes, (header,))
2238
+ if not attr:
2239
+ # No confident match: collect the header with fuzzy candidates so
2240
+ # the caller can ask the seller to confirm the intended attribute
2241
+ # instead of silently dropping the column.
2242
+ unmapped.append(
2243
+ {
2244
+ "header": header,
2245
+ "value": value,
2246
+ "candidates": [
2247
+ {
2248
+ "name": c.get("name"),
2249
+ "en_name": c.get("en_name"),
2250
+ "label": c.get("label") or c.get("en_name") or c.get("name"),
2251
+ "attribute_type": c.get("attribute_type") or "normal",
2252
+ "is_mandatory": str(c.get("is_mandatory", "0")),
2253
+ }
2254
+ for c in self._suggest_attribute_candidates(
2255
+ header, category_attributes
2256
+ )
2257
+ ],
2258
+ }
2259
+ )
2260
+ continue
2261
+ key = attr.get("name") or attr.get("en_name") or header
2262
+ if (attr.get("attribute_type") or "normal") == "sku":
2263
+ for sku in skus:
2264
+ if key not in sku:
2265
+ sku[key] = value
2266
+ else:
2267
+ if key not in attributes:
2268
+ attributes[key] = value
2269
+ if unmapped:
2270
+ logger.info(
2271
+ "Excel/CSV columns not confidently matched: %s",
2272
+ ", ".join(u["header"] for u in unmapped),
2273
+ )
2274
+ return unmapped
2275
+
2276
+ def _suggest_attribute_candidates(
2277
+ self,
2278
+ header: str,
2279
+ category_attributes: List[Dict[str, Any]],
2280
+ max_candidates: int = 8,
2281
+ ) -> List[Dict[str, Any]]:
2282
+ """Fuzzy-suggest category attributes a free-text column header might map
2283
+ to — for the confirmation prompt only, never auto-bound.
2284
+
2285
+ Uses a shared-prefix (stem) heuristic so e.g. header ``packaging``
2286
+ suggests ``Package_Label_Photo_EU``. Deliberately looser than
2287
+ ``_find_category_attribute`` because a human confirms the result.
2288
+ """
2289
+ def _words(text: Any) -> List[str]:
2290
+ return [w for w in re.split(r"[^a-z0-9]+", str(text).lower()) if len(w) >= 4]
2291
+
2292
+ header_words = set(_words(header))
2293
+ h_sq = self._squash_attr_name(header)
2294
+ if h_sq:
2295
+ header_words.add(h_sq)
2296
+ out: List[Dict[str, Any]] = []
2297
+ for attr in category_attributes or []:
2298
+ attr_words: List[str] = []
2299
+ for key in ("name", "en_name", "label", "attribute_name"):
2300
+ if attr.get(key):
2301
+ attr_words.extend(_words(attr.get(key)))
2302
+ attr_words.append(self._squash_attr_name(attr.get(key)))
2303
+ hit = False
2304
+ for hw in header_words:
2305
+ for aw in attr_words:
2306
+ if (
2307
+ len(hw) >= 5
2308
+ and len(aw) >= 5
2309
+ and hw[:5] == aw[:5]
2310
+ ) or hw in aw or aw in hw:
2311
+ hit = True
2312
+ break
2313
+ if hit:
2314
+ break
2315
+ if hit:
2316
+ out.append(attr)
2317
+ if len(out) >= max_candidates:
2318
+ break
2319
+ return out
2320
+
2321
+ def _apply_attribute_data_override(self, product_payload: Dict[str, Any]) -> None:
2322
+ """Merge ``--attribute-data`` into the AI-built payload.
2323
+
2324
+ The AI flow builds Attributes/Skus from the extracted schema and never
2325
+ consulted ``--attribute-data``, so values passed there were silently
2326
+ dropped. ``--attribute-data`` is the seller's own explicit input, so it
2327
+ overrides extracted values; SKU-level keys are merged into every SKU.
2328
+ """
2329
+ raw = getattr(self.args, "attribute_data", None)
2330
+ if not raw:
2331
+ return
2332
+ try:
2333
+ data = json.loads(raw)
2334
+ except json.JSONDecodeError as exc:
2335
+ raise ValueError(f"--attribute-data is not valid JSON: {exc}") from exc
2336
+ if not isinstance(data, dict):
2337
+ raise ValueError("--attribute-data must be a JSON object")
2338
+ attributes = product_payload.setdefault("Attributes", {})
2339
+ skus = (product_payload.get("Skus") or {}).get("Sku") or []
2340
+ for key, value in data.items():
2341
+ if key in _SKU_LEVEL_ATTRIBUTE_NAMES:
2342
+ for sku in skus:
2343
+ sku[key] = value
2344
+ else:
2345
+ attributes[key] = value
2346
+
2347
+ def _check_unmapped_columns(
2348
+ self,
2349
+ unmapped_cols: List[Dict[str, Any]],
2350
+ product_payload: Dict[str, Any],
2351
+ ) -> Optional[Dict[str, Any]]:
2352
+ """Return a ``need_attribute_mapping`` prompt when an unmapped column
2353
+ looks like a category attribute the seller intended, so they confirm the
2354
+ mapping instead of us dropping it silently (which would later surface as
2355
+ a cryptic batchValidate "missing attribute" error). Columns with no
2356
+ plausible candidate — or whose candidates are already filled via
2357
+ ``--attribute-data`` — are ignored.
2358
+ """
2359
+ attributes = product_payload.get("Attributes") or {}
2360
+ skus = (product_payload.get("Skus") or {}).get("Sku") or []
2361
+
2362
+ def _filled(cand: Dict[str, Any]) -> bool:
2363
+ names = [cand.get("name"), cand.get("en_name")]
2364
+
2365
+ def _present(container: Dict[str, Any]) -> bool:
2366
+ return any(n and str(container.get(n) or "").strip() for n in names)
2367
+
2368
+ if (cand.get("attribute_type") or "normal") == "sku":
2369
+ return bool(skus) and all(_present(s) for s in skus)
2370
+ return _present(attributes)
2371
+
2372
+ need: List[Dict[str, Any]] = []
2373
+ for item in unmapped_cols or []:
2374
+ open_cands = [c for c in (item.get("candidates") or []) if not _filled(c)]
2375
+ if open_cands:
2376
+ need.append({**item, "candidates": open_cands})
2377
+ if not need:
2378
+ return None
2379
+ return {
2380
+ "status": "need_attribute_mapping",
2381
+ "title": i18n._("pub_need_attribute_mapping_title"),
2382
+ "message": i18n._("pub_need_attribute_mapping_description"),
2383
+ "unmapped_columns": need,
2384
+ "next_actions": [i18n._("pub_need_attribute_mapping_next")],
2385
+ }
2386
+
2387
+ def _field_is_required(
2388
+ self,
2389
+ field_key: str,
2390
+ category_attributes: List[Dict[str, Any]],
2391
+ ) -> bool:
2392
+ """THE single source of truth for whether a field must be filled.
2393
+
2394
+ Priority: our own rules first, then the category attribute API.
2395
+ 1. Structural fields (title/images/price/stock/...) and hard-required
2396
+ conditional fields (GPSR manufacturer/EURP, Italy price, unit type)
2397
+ are always required — our rule hits regardless of category.
2398
+ 2. Everything else follows ``/category/attributes/get``: a matching
2399
+ attribute with ``is_mandatory=1`` (or the postToInactive flag) means
2400
+ required; a matching non-mandatory attribute, or no attribute at all,
2401
+ means optional.
2402
+
2403
+ Every mandatory/conditional check in the flow must call this instead of
2404
+ hard-coding per-field requirements, so AI-extract and manual paths stay
2405
+ consistent.
2406
+ """
2407
+ if (
2408
+ field_key in self._CORE_MANDATORY_KEYS
2409
+ or field_key in self._HARD_REQUIRED_CONDITIONAL_KEYS
2410
+ ):
2411
+ return True
2412
+ attr = self._find_category_attribute(category_attributes, (field_key,))
2413
+ if attr is None:
2414
+ return False
2415
+ if str(attr.get("is_mandatory", "0")).strip() == "1":
2416
+ return True
2417
+ # Fall back to the explicit postToInactive flag when present
2418
+ return str(attr.get("post_to_inactive", "0")).strip() == "1"
2419
+
2420
+ def _field_explicitly_optional(
2421
+ self,
2422
+ field_key: str,
2423
+ category_attributes: List[Dict[str, Any]],
2424
+ ) -> bool:
2425
+ """True ONLY when the category attribute API returns a matching attribute
2426
+ that is explicitly non-mandatory. Used for fields that default to
2427
+ mandatory (package weight/dimensions, needed for shipping): they block
2428
+ unless the API clearly says otherwise. A missing attribute (or an API
2429
+ call failure / empty list) returns False, i.e. stays mandatory — fail
2430
+ safe toward blocking."""
2431
+ attr = self._find_category_attribute(category_attributes or [], (field_key,))
2432
+ if not attr:
2433
+ return False
2434
+ if str(attr.get("post_to_inactive", "0")).strip() == "1":
2435
+ return False
2436
+ return str(attr.get("is_mandatory", "0")).strip() != "1"
2437
+
1868
2438
  def _is_category_attribute_mandatory(
1869
2439
  self, category_attributes: List[Dict[str, Any]], attr_name: str
1870
2440
  ) -> bool:
1871
- """Check if a category attribute is marked as mandatory (is_mandatory=1)."""
1872
- for attr in category_attributes:
1873
- if attr.get("name") == attr_name:
1874
- return bool(attr.get("is_mandatory"))
1875
- return False
2441
+ """Check if a category attribute is marked as mandatory (is_mandatory=1).
2442
+
2443
+ ``is_mandatory`` may arrive as int or string ("0"/"1") — ``bool("0")``
2444
+ is True, so compare the normalized string instead. Name matching is
2445
+ normalized across ``name``/``en_name``/``label`` variants.
2446
+ """
2447
+ attr = self._find_category_attribute(category_attributes, (attr_name,))
2448
+ if attr is None:
2449
+ return False
2450
+ return str(attr.get("is_mandatory", "0")).strip() == "1"
1876
2451
 
1877
2452
  def _check_country_price_recommendation(
1878
2453
  self, schema: MiraviaListingSchema
@@ -1939,22 +2514,27 @@ class PublishModule:
1939
2514
  fs_preview: Dict[str, Any],
1940
2515
  key_missing: List[str],
1941
2516
  country_price_note: Optional[str] = None,
2517
+ api_optional: Optional[List[str]] = None,
1942
2518
  ) -> Dict[str, Any]:
1943
2519
  """Return the field extraction summary and draft/continue prompt.
1944
2520
 
1945
2521
  Fields are grouped by Mandatory vs Conditional. Missing mandatory
1946
2522
  fields block publish; missing conditional fields suggest Draft.
2523
+ Conditional fields the category API explicitly marks as optional and
2524
+ that are not filled show as ``Optional`` instead of ``MISSING``.
1947
2525
  """
1948
2526
  token = _make_publish_preview_token(fs_preview)
2527
+ optional_set = set(api_optional or [])
1949
2528
 
1950
2529
  mandatory_lines: List[str] = []
1951
2530
  conditional_lines: List[str] = []
1952
2531
  for key, label, category in self._TRACKED_FIELDS:
1953
- status = (
1954
- i18n._("pub_field_status_missing")
1955
- if key in key_missing
1956
- else i18n._("pub_field_status_filled")
1957
- )
2532
+ if key in key_missing:
2533
+ status = i18n._("pub_field_status_missing")
2534
+ elif key in optional_set:
2535
+ status = i18n._("pub_field_status_optional")
2536
+ else:
2537
+ status = i18n._("pub_field_status_filled")
1958
2538
  line = f" {label}: {status}"
1959
2539
  if category == "mandatory":
1960
2540
  mandatory_lines.append(line)
@@ -2090,7 +2670,7 @@ class PublishModule:
2090
2670
  confirmed_brand_id = getattr(self.args, "confirmed_brand_id", None)
2091
2671
  if confirmed_brand_id:
2092
2672
  return {
2093
- "brand_name": schema.brand_name or "",
2673
+ "brand_name": getattr(self.args, "brand_name", None) or schema.brand_name or "",
2094
2674
  "brand_id": int(confirmed_brand_id),
2095
2675
  }
2096
2676
 
@@ -2229,7 +2809,11 @@ class PublishModule:
2229
2809
 
2230
2810
  Per the Miravia ``/product/create`` documentation:
2231
2811
 
2232
- - ``SellerSku`` is mandatory for every SKU.
2812
+ - ``SellerSku`` is NOT hard-coded mandatory: whether it must be filled is
2813
+ governed by the category attribute API and enforced once in
2814
+ :meth:`_validate_create_payload` (via ``seller_sku_required``), which
2815
+ applies to both Draft and online publishes. Here we simply omit an
2816
+ empty SellerSku so ``/product/create`` can auto-generate a value.
2233
2817
  - Package dimensions/weight are per-SKU fields
2234
2818
  (``package_length/width/height/weight``).
2235
2819
  - Sales properties are flat SKU fields, not a nested ``Variation``
@@ -2258,11 +2842,11 @@ class PublishModule:
2258
2842
  seller_sku = raw.get("seller_sku") or (
2259
2843
  schema.seller_skus[idx] if idx < len(schema.seller_skus) else None
2260
2844
  )
2261
- if not seller_sku:
2262
- raise ValueError(
2263
- i18n._("pub_seller_sku_required", idx=idx + 1)
2264
- )
2265
- sku["SellerSku"] = seller_sku
2845
+ # Do NOT hard-require SellerSku here. Its mandatory-ness comes from
2846
+ # the category attribute API and is enforced in _validate_create_payload
2847
+ # (not relaxed by draft). Omit empty values so create can auto-generate.
2848
+ if seller_sku:
2849
+ sku["SellerSku"] = seller_sku
2266
2850
 
2267
2851
  ean_code = raw.get("ean_code") or (
2268
2852
  schema.ean_codes[idx] if idx < len(schema.ean_codes) else None
@@ -2281,16 +2865,18 @@ class PublishModule:
2281
2865
  if quantity is not None:
2282
2866
  sku["quantity"] = int(quantity)
2283
2867
 
2284
- # Package dimensions & weight (per-SKU per Miravia docs)
2285
- for dim, schema_field in (
2286
- ("package_weight", "weight_kg"),
2287
- ("package_length", "length_cm"),
2288
- ("package_width", "width_cm"),
2289
- ("package_height", "height_cm"),
2868
+ # Package dimensions & weight (per-SKU per Miravia docs).
2869
+ # Normalize unit-suffixed strings to the bare number Miravia's
2870
+ # /product/create requires: weight in kg, dimensions in cm.
2871
+ for dim, schema_field, conv in (
2872
+ ("package_weight", "weight_kg", _convert_weight),
2873
+ ("package_length", "length_cm", _convert_length),
2874
+ ("package_width", "width_cm", _convert_length),
2875
+ ("package_height", "height_cm", _convert_length),
2290
2876
  ):
2291
2877
  value = raw.get(dim) or getattr(schema, schema_field, None)
2292
2878
  if value is not None:
2293
- sku[dim] = str(value)
2879
+ sku[dim] = _format_measurement(value, conv)
2294
2880
 
2295
2881
  # Sales properties are flat SKU fields
2296
2882
  v1_name = _normalize_variation_name(schema.variation_name_1)
@@ -2771,10 +3357,11 @@ class PublishModule:
2771
3357
  processed["SellerSku"] = processed.pop("seller_sku")
2772
3358
  elif "seller_sku" in processed:
2773
3359
  processed.pop("seller_sku") # SellerSku already set
3360
+ # SellerSku is optional (governed by the category attribute API and
3361
+ # enforced in _validate_create_payload, not relaxed by draft). When
3362
+ # empty, drop the key so /product/create can auto-generate it.
2774
3363
  if not processed.get("SellerSku"):
2775
- raise ValueError(
2776
- i18n._("pub_seller_sku_required", idx=idx + 1)
2777
- )
3364
+ processed.pop("SellerSku", None)
2778
3365
  # Migrate SKU-level images
2779
3366
  sku_images = processed.get("Images", {}).get("Image", [])
2780
3367
  if sku_images:
@@ -2915,12 +3502,19 @@ def _validate_title(title: str) -> None:
2915
3502
  )
2916
3503
 
2917
3504
 
2918
- def _validate_create_payload(payload: Dict[str, Any]) -> None:
3505
+ def _validate_create_payload(
3506
+ payload: Dict[str, Any],
3507
+ seller_sku_required: bool = False,
3508
+ ) -> None:
2919
3509
  """Validate a Miravia ``/product/create`` Product payload.
2920
3510
 
2921
3511
  Enforces the field constraints from the Miravia ``/product/create``
2922
3512
  documentation. Package dimensions/weight and ``SellerSku`` are validated
2923
3513
  at the SKU level; the legacy ``Product.Package`` node is no longer used.
3514
+
3515
+ ``seller_sku_required`` is decided by the caller from the category attribute
3516
+ API (``_field_is_required``): when False, an empty SellerSku is allowed and
3517
+ the create API auto-generates a unique value.
2924
3518
  """
2925
3519
  attributes = payload.get("Attributes") or {}
2926
3520
  title = attributes.get("name")
@@ -2942,6 +3536,17 @@ def _validate_create_payload(payload: Dict[str, Any]) -> None:
2942
3536
  i18n._("pub_too_many_skus", max=_MAX_SKUS_PER_PRODUCT, count=len(skus))
2943
3537
  )
2944
3538
 
3539
+ # Authoritative backstop: Miravia /product/create requires at least one image
3540
+ # at SPU or SKU level and rejects an imageless product with 4224
3541
+ # MAIN_IMAGE_OR_SKU_IMAGE_EMPTY even for Draft. Enforce it here so no path
3542
+ # (manual mode, or a draft that slipped past the earlier stage) can submit an
3543
+ # imageless product.
3544
+ has_sku_image = any((s.get("Images") or {}).get("Image") for s in skus)
3545
+ if not images and not has_sku_image:
3546
+ raise ValueError(
3547
+ i18n._("pub_image_required_no_draft", fields=i18n._("label_image_url"))
3548
+ )
3549
+
2945
3550
  # Check EAN duplicate within same product
2946
3551
  ean_codes = [sku.get("ean_code") for sku in skus if sku.get("ean_code")]
2947
3552
  if len(ean_codes) != len(set(ean_codes)):
@@ -2953,7 +3558,7 @@ def _validate_create_payload(payload: Dict[str, Any]) -> None:
2953
3558
  raise ValueError(i18n._("pub_seller_sku_duplicate_in_product"))
2954
3559
 
2955
3560
  for idx, sku in enumerate(skus, start=1):
2956
- if not sku.get("SellerSku"):
3561
+ if seller_sku_required and not (sku.get("SellerSku") or sku.get("seller_sku")):
2957
3562
  raise ValueError(i18n._("pub_seller_sku_required", idx=idx))
2958
3563
 
2959
3564
  price = sku.get("price")
@@ -3190,6 +3795,32 @@ def _convert_length(value: str) -> float:
3190
3795
  return number * _LENGTH_TO_CM[unit]
3191
3796
 
3192
3797
 
3798
+ def _format_measurement(value: Any, converter) -> str:
3799
+ """Normalize a possibly unit-suffixed measurement into a plain numeric
3800
+ string in the target base unit.
3801
+
3802
+ ``converter`` is :func:`_convert_weight` (-> kg) or :func:`_convert_length`
3803
+ (-> cm). Examples: ``"0.6kg" -> "0.6"``, ``"500g" -> "0.5"``,
3804
+ ``"5cm" -> "5"``, ``"50mm" -> "5"``, ``30 -> "30"``.
3805
+
3806
+ Falls back to the cleaned numeric substring when the unit is unrecognized,
3807
+ so we never crash on odd inputs; Miravia's ``/product/create`` requires a
3808
+ bare number > 0 for package weight/dimensions, and leaving a unit suffix
3809
+ like ``"0.6kg"`` in the payload makes batchValidate reject it with
3810
+ ``"not valid, only a number"``.
3811
+ """
3812
+ try:
3813
+ number = float(converter(value))
3814
+ except (TypeError, ValueError, AttributeError):
3815
+ m = re.search(r"(\d+(?:[.,]\d+)?)", str(value))
3816
+ if not m:
3817
+ return str(value).strip()
3818
+ number = float(m.group(1).replace(",", "."))
3819
+ if abs(number - round(number)) < 1e-9:
3820
+ return str(int(round(number)))
3821
+ return ("%.6f" % number).rstrip("0").rstrip(".")
3822
+
3823
+
3193
3824
  def _validate_dimension(value: Any, name: str, maximum: int) -> None:
3194
3825
  """Validate dimension value after unit conversion.
3195
3826