@miravia_test/miravia-publish-skill 0.1.6 → 0.1.7

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,27 @@ 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 ""
814
+ attrs = product_payload.setdefault("Attributes", {})
815
+ delivery_method = str(attrs.get("deliverySof") or "").strip().upper()
803
816
 
804
- # Only check if delivery method is DBM
805
- if delivery_method.upper() != "DBM":
817
+ # Default when there is no explicit entry: DBS if the seller is
818
+ # permitted, otherwise DBM.
819
+ if delivery_method not in ("DBM", "DBS"):
820
+ delivery_method = "DBS" if has_dbs_permission else "DBM"
821
+ attrs["deliverySof"] = delivery_method
822
+
823
+ # Only DBM carries package limits.
824
+ if delivery_method != "DBM":
806
825
  return None
807
826
 
808
827
  skus = (product_payload.get("Skus") or {}).get("Sku") or []
@@ -842,12 +861,7 @@ class PublishModule:
842
861
  # Exceeded DBM limits — check DBS permission
843
862
  if has_dbs_permission:
844
863
  # 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"
864
+ attrs["deliverySof"] = "DBS"
851
865
  return {
852
866
  "switched_to_dbs": True,
853
867
  "reasons": exceeded,
@@ -870,9 +884,25 @@ class PublishModule:
870
884
  missing: Optional[Dict[str, str]] = None,
871
885
  draft: bool = False,
872
886
  draft_issues: Optional[List[str]] = None,
887
+ category_attributes: Optional[List[Dict[str, Any]]] = None,
873
888
  ) -> Dict[str, Any]:
874
889
  """Validate, preview, pre-validate, and execute product creation."""
875
- _validate_create_payload(product_payload)
890
+ # Decide SellerSku mandatory-ness from the category attribute API via the
891
+ # unified rule, so this pre-submit check matches every other layer
892
+ # (empty SellerSku is allowed and auto-generated when not mandatory).
893
+ if category_attributes is None:
894
+ cat_id = (preview_summary or {}).get("category_id")
895
+ lang = self.args.lang if getattr(self.args, "lang", None) not in (None, "auto") else "en_US"
896
+ try:
897
+ category_attributes = self.product.get_category_attributes(
898
+ str(cat_id), language_code=lang
899
+ )
900
+ except Exception:
901
+ category_attributes = []
902
+ seller_sku_required = self._field_is_required(
903
+ "SellerSku", category_attributes or []
904
+ )
905
+ _validate_create_payload(product_payload, seller_sku_required=seller_sku_required)
876
906
 
877
907
  # Check DBM limits — auto-switch to DBS if exceeded (requires DBS permission)
878
908
  has_dbs = self.product.get_shipping_permission()
@@ -988,26 +1018,34 @@ class PublishModule:
988
1018
  i18n._("pub_sku_already_exists_next"),
989
1019
  ],
990
1020
  }
991
- # Code 4234: draft product created but missing required fields
1021
+ # Code 4234 in draft mode: only a success when the API actually
1022
+ # returned an item_id — without one, the draft was NOT created and
1023
+ # the error must be surfaced with full diagnostics.
992
1024
  if exc.code == "4234" and draft:
993
1025
  err_data = (exc.payload.get("data") or {}) if exc.payload else {}
994
1026
  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
- }
1027
+ if item_id:
1028
+ pdp_url = self._make_pdp_url(item_id)
1029
+ return {
1030
+ "status": "draft_created_with_issues",
1031
+ "message": i18n._("pub_draft_created_with_issues"),
1032
+ "item_id": item_id,
1033
+ "pdp_url": pdp_url,
1034
+ "api_message": exc.msg,
1035
+ "draft": True,
1036
+ "next_actions": [
1037
+ i18n._("pub_draft_update_hint"),
1038
+ ],
1039
+ }
1040
+ else:
1041
+ return self._make_create_failed_response(exc, draft)
1007
1042
  raise
1008
1043
  data = result.get("data", {}) or {}
1009
1044
 
1010
1045
  item_id = data.get("item_id")
1046
+ if not item_id:
1047
+ # No item_id ⇒ creation failed even though code was 0.
1048
+ return self._make_create_failed_response(exc=None, draft=draft, data=data)
1011
1049
  pdp_url = self._make_pdp_url(item_id)
1012
1050
  return {
1013
1051
  "status": "success",
@@ -1020,6 +1058,49 @@ class PublishModule:
1020
1058
  "next_actions": [i18n._("pub_create_success_next")],
1021
1059
  }
1022
1060
 
1061
+ def _make_create_failed_response(
1062
+ self,
1063
+ exc: Optional[MiraviaError],
1064
+ draft: bool,
1065
+ data: Optional[Dict[str, Any]] = None,
1066
+ ) -> Dict[str, Any]:
1067
+ """Build a structured ``create_failed`` response with API diagnostics.
1068
+
1069
+ A create call without ``item_id`` in the payload means the product
1070
+ (draft or active) was NOT created — never report it as success.
1071
+ ``request_id`` / ``_trace_id_`` are echoed back so the seller can
1072
+ locate the root cause in logs or with platform support.
1073
+ """
1074
+ payload = (exc.payload if exc else None) or {}
1075
+ err_type = str(payload.get("type") or "")
1076
+ request_id = str(payload.get("request_id") or payload.get("requestId") or "")
1077
+ trace_id = str(payload.get("_trace_id_") or payload.get("trace_id") or "")
1078
+ next_actions: List[str] = [
1079
+ i18n._("pub_create_failed_check_and_rerun"),
1080
+ ]
1081
+ if trace_id or request_id:
1082
+ next_actions.append(
1083
+ i18n._("pub_create_failed_trace_hint", trace_id=trace_id, request_id=request_id)
1084
+ )
1085
+ return {
1086
+ "status": "create_failed",
1087
+ "title": i18n._("pub_create_failed_title"),
1088
+ "message": i18n._(
1089
+ "pub_create_failed_message",
1090
+ code=(exc.code if exc else "0"),
1091
+ reason=(exc.msg if exc else i18n._("pub_create_failed_no_item_id")),
1092
+ ),
1093
+ "code": (exc.code if exc else "0"),
1094
+ "error_type": err_type,
1095
+ "api_message": (exc.msg if exc else ""),
1096
+ "hint": (exc.hint if exc else ""),
1097
+ "request_id": request_id,
1098
+ "trace_id": trace_id,
1099
+ "draft": draft,
1100
+ "item_id": None,
1101
+ "next_actions": next_actions,
1102
+ }
1103
+
1023
1104
  # ==================================================================
1024
1105
  # AI-assisted create flow
1025
1106
  # ==================================================================
@@ -1215,7 +1296,9 @@ class PublishModule:
1215
1296
  logger.debug("CRO requirement API is not available yet")
1216
1297
 
1217
1298
  # Stage 3: resolve compliance (manufacturer / EURP)
1218
- compliance_result = self._resolve_compliance_info_with_check(schema)
1299
+ compliance_result = self._resolve_compliance_info_with_check(
1300
+ schema, draft=getattr(self.args, "draft", False)
1301
+ )
1219
1302
  if compliance_result.get("status"):
1220
1303
  return compliance_result
1221
1304
 
@@ -1257,6 +1340,12 @@ class PublishModule:
1257
1340
  draft=draft_requested,
1258
1341
  )
1259
1342
 
1343
+ # Bind extra Excel/CSV columns that the whitelist did not recognize,
1344
+ # using the category attribute definitions fetched above.
1345
+ self._unmapped_columns = self._apply_unmatched_category_attributes(
1346
+ product_payload, schema, category_attributes
1347
+ )
1348
+
1260
1349
  # Stage 3b: unified compliance check (GPSR + Safety Warning + compliance docs)
1261
1350
  draft_issues: list = []
1262
1351
  compliance_check = self._check_compliance_fields(
@@ -1298,8 +1387,26 @@ class PublishModule:
1298
1387
  return cat_attr_result
1299
1388
 
1300
1389
  # Stage 4b: mandatory field checks (cannot continue if missing)
1301
- mandatory_result = self._check_mandatory_fields(product_payload, schema)
1390
+ mandatory_result = self._check_mandatory_fields(
1391
+ product_payload, schema, category_attributes
1392
+ )
1302
1393
  if mandatory_result.get("status") == "need_mandatory_fields":
1394
+ hard_missing = mandatory_result.get("hard_missing_fields") or []
1395
+ if hard_missing:
1396
+ # Platform hard requirement (e.g. main image): /product/create
1397
+ # rejects it with 4224 even for Draft, so block unconditionally and
1398
+ # do NOT offer or fall back to draft mode.
1399
+ mandatory_result["missing_fields"] = (
1400
+ hard_missing + mandatory_result.get("missing_fields", [])
1401
+ )
1402
+ mandatory_result["message"] = i18n._(
1403
+ "pub_image_required_no_draft", fields=", ".join(hard_missing)
1404
+ )
1405
+ mandatory_result["next_actions"] = [
1406
+ i18n._("pub_add_images_and_rerun")
1407
+ ]
1408
+ mandatory_result["draft_mode_available"] = False
1409
+ return mandatory_result
1303
1410
  if draft_requested:
1304
1411
  for field in mandatory_result.get("missing_fields", []):
1305
1412
  draft_issues.append(f"Missing mandatory field: {field}")
@@ -1311,7 +1418,7 @@ class PublishModule:
1311
1418
  return mandatory_result
1312
1419
 
1313
1420
  # Stage 5: field summary and draft/continue confirmation
1314
- key_missing = self._build_key_missing_list(
1421
+ key_missing, api_optional = self._build_key_missing_list(
1315
1422
  schema, product_payload, compliance_result, category_attributes
1316
1423
  )
1317
1424
  country_price_note = self._check_country_price_recommendation(schema)
@@ -1335,7 +1442,8 @@ class PublishModule:
1335
1442
  provided_fs_token = getattr(self.args, "field_summary_token", None)
1336
1443
  if provided_fs_token != expected_fs_token:
1337
1444
  return self._make_field_summary_preview(
1338
- fs_preview, key_missing, country_price_note
1445
+ fs_preview, key_missing, country_price_note,
1446
+ api_optional=api_optional,
1339
1447
  )
1340
1448
 
1341
1449
  # Stage 6: finalize (pre-validate + create)
@@ -1349,6 +1457,7 @@ class PublishModule:
1349
1457
  missing={k: k for k in key_missing},
1350
1458
  draft=draft_requested,
1351
1459
  draft_issues=draft_issues or None,
1460
+ category_attributes=category_attributes,
1352
1461
  )
1353
1462
 
1354
1463
  # ------------------------------------------------------------------
@@ -1363,7 +1472,6 @@ class PublishModule:
1363
1472
  ("brand_id", "Brand", "mandatory"),
1364
1473
  ("general_price", "General price", "mandatory"),
1365
1474
  ("stock", "Stock", "mandatory"),
1366
- ("seller_sku", "Seller SKU", "mandatory"),
1367
1475
  ("weight_kg", "Weight", "mandatory"),
1368
1476
  ("length_cm", "Length", "mandatory"),
1369
1477
  ("width_cm", "Width", "mandatory"),
@@ -1371,6 +1479,8 @@ class PublishModule:
1371
1479
  ("delivery_method", "Delivery method", "mandatory"),
1372
1480
  # Conditional fields — missing suggests Draft
1373
1481
  ("italy_price", "Italy price", "conditional"),
1482
+ # Seller SKU is optional (create API auto-generates when empty)
1483
+ ("seller_sku", "Seller SKU", "conditional"),
1374
1484
  ("ean_code", "EAN code", "conditional"),
1375
1485
  ("manufacturer", "Manufacturer", "conditional"),
1376
1486
  ("eu_responsible_person", "EU responsible person", "conditional"),
@@ -1383,6 +1493,33 @@ class PublishModule:
1383
1493
  ("isbn", "ISBN", "conditional"),
1384
1494
  ]
1385
1495
 
1496
+ # Conditional fields WE always require regardless of category (GPSR law,
1497
+ # Miravia site-pricing rule, etc.) — missing them always prompts the user.
1498
+ _HARD_REQUIRED_CONDITIONAL_KEYS = frozenset(
1499
+ {
1500
+ "manufacturer",
1501
+ "eu_responsible_person",
1502
+ "italy_price",
1503
+ "standard_unit_type",
1504
+ }
1505
+ )
1506
+
1507
+ # Structural fields Miravia always requires for any product (not category
1508
+ # attributes, so the API never governs them) — always blocking. NOTE:
1509
+ # delivery method (Attributes.deliverySof) is intentionally NOT here — it is
1510
+ # auto-defaulted (DBM, or DBS when the seller has DBS permission) rather
1511
+ # than hard-collected; see _check_dbm_limits.
1512
+ _CORE_MANDATORY_KEYS = frozenset(
1513
+ {
1514
+ "product_title",
1515
+ "product_images",
1516
+ "category_id",
1517
+ "brand_id",
1518
+ "general_price",
1519
+ "stock",
1520
+ }
1521
+ )
1522
+
1386
1523
  def _resolve_category_for_schema(
1387
1524
  self, schema: MiraviaListingSchema
1388
1525
  ) -> Dict[str, Any]:
@@ -1435,7 +1572,7 @@ class PublishModule:
1435
1572
  ) -> Dict[str, Any]:
1436
1573
  """Try recommend API first, fallback to category tree."""
1437
1574
  title = schema.product_title
1438
- image_urls = schema.image_urls or []
1575
+ image_urls = schema.product_images or []
1439
1576
  if title or image_urls:
1440
1577
  try:
1441
1578
  recommendations = self.product.recommend_category(
@@ -1584,10 +1721,14 @@ class PublishModule:
1584
1721
  "pub_brand_not_found_message", brand_name=brand_name
1585
1722
  ),
1586
1723
  "brand_name": brand_name,
1724
+ "brand_list_link": (
1725
+ "https://sellercenter.miravia.es/apps/setting/seller/brandlist"
1726
+ ),
1587
1727
  "create_brand_link": (
1588
1728
  "https://sellercenter.miravia.es/apps/product/publish/brand"
1589
1729
  ),
1590
1730
  "next_actions": [
1731
+ i18n._("pub_brand_list_link"),
1591
1732
  i18n._("pub_brand_create_link"),
1592
1733
  i18n._("pub_brand_create_next"),
1593
1734
  i18n._("pub_brand_reconfirm_or_choose"),
@@ -1617,9 +1758,15 @@ class PublishModule:
1617
1758
  }
1618
1759
 
1619
1760
  def _resolve_compliance_info_with_check(
1620
- self, schema: MiraviaListingSchema
1761
+ self, schema: MiraviaListingSchema, draft: bool = False
1621
1762
  ) -> Dict[str, Any]:
1622
- """Resolve manufacturer / EURP IDs and surface mismatches."""
1763
+ """Resolve manufacturer / EURP IDs and surface mismatches.
1764
+
1765
+ In draft mode an unmatched manufacturer / EU responsible person is not a
1766
+ hard blocker: the IDs are left empty and the seller completes them in the
1767
+ Seller Center draft, so the flow continues instead of returning
1768
+ ``need_compliance``.
1769
+ """
1623
1770
  result: Dict[str, Any] = {}
1624
1771
  choices: List[Dict[str, Any]] = []
1625
1772
 
@@ -1667,6 +1814,11 @@ class PublishModule:
1667
1814
  logger.warning("Failed to query EU responsible person: %s", exc)
1668
1815
 
1669
1816
  if result.get("manufacturer_mismatch") or result.get("eurp_mismatch"):
1817
+ if draft:
1818
+ logger.info(
1819
+ "Draft mode: deferring unmatched manufacturer/EURP to Seller Center"
1820
+ )
1821
+ return {}
1670
1822
  return {
1671
1823
  "status": "need_compliance",
1672
1824
  "message": i18n._("pub_manufacturer_eurp_mismatch"),
@@ -1701,14 +1853,28 @@ class PublishModule:
1701
1853
  return None
1702
1854
 
1703
1855
  payload_attrs = product_payload.get("Attributes") or {}
1856
+ payload_skus = (product_payload.get("Skus") or {}).get("Sku") or []
1857
+
1858
+ def _present_in(container: Dict[str, Any], attr: Dict[str, Any]) -> bool:
1859
+ for key in (attr.get("name"), attr.get("en_name")):
1860
+ if key and str(container.get(key) or "").strip():
1861
+ return True
1862
+ return False
1863
+
1704
1864
  missing_attrs: List[Dict[str, Any]] = []
1705
1865
  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:
1866
+ if not (attr.get("en_name") or attr.get("name")):
1709
1867
  continue
1710
- val = payload_attrs.get(en_name)
1711
- if val is None or (isinstance(val, str) and not val.strip()):
1868
+ if (attr.get("attribute_type") or "normal") == "sku":
1869
+ # SKU-level attribute: must be filled on every SKU.
1870
+ if payload_skus and all(
1871
+ _present_in(sku, attr) for sku in payload_skus
1872
+ ):
1873
+ continue
1874
+ missing_attrs.append(attr)
1875
+ else:
1876
+ if _present_in(payload_attrs, attr):
1877
+ continue
1712
1878
  missing_attrs.append(attr)
1713
1879
 
1714
1880
  if not missing_attrs:
@@ -1754,32 +1920,59 @@ class PublishModule:
1754
1920
  }
1755
1921
 
1756
1922
  def _check_mandatory_fields(
1757
- self, product_payload: Dict[str, Any], schema: MiraviaListingSchema
1923
+ self,
1924
+ product_payload: Dict[str, Any],
1925
+ schema: MiraviaListingSchema,
1926
+ category_attributes: Optional[List[Dict[str, Any]]] = None,
1758
1927
  ) -> Dict[str, Any]:
1759
1928
  """Check required fields that block continuation when missing.
1760
1929
 
1761
- Per the Miravia ``/product/create`` documentation, package
1762
- dimensions/weight and ``SellerSku`` are required at the SKU level.
1930
+ Uses the same ``_field_is_required`` rule as every other layer:
1931
+ - Structural fields (images / general price / stock) are always blocking.
1932
+ - Package weight/dimensions are SKU-level category attributes, so they
1933
+ only block when the API marks them ``is_mandatory`` for this category
1934
+ (batchValidate remains the authoritative backstop).
1935
+ - ``SellerSku`` is never blocking: the create API auto-generates it.
1763
1936
  """
1937
+ category_attributes = category_attributes or []
1764
1938
  missing: List[str] = []
1765
- images = (product_payload.get("Images") or {}).get("Image") or []
1766
- if not images:
1767
- missing.append(i18n._("label_image_url"))
1939
+ hard_missing: List[str] = []
1768
1940
 
1769
1941
  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
1942
 
1774
- has_stock = any(sku.get("quantity") for sku in skus)
1775
- if not has_stock:
1776
- missing.append("Stock")
1943
+ # Main image is a platform-level hard requirement: /product/create rejects
1944
+ # an imageless product with 4224 MAIN_IMAGE_OR_SKU_IMAGE_EMPTY even for
1945
+ # Draft, so it must BLOCK and can never be routed to Draft. Miravia accepts
1946
+ # either an SPU-level image or any SKU-level image, so only block when both
1947
+ # are empty.
1948
+ images = (product_payload.get("Images") or {}).get("Image") or []
1949
+ has_sku_image = any((s.get("Images") or {}).get("Image") for s in skus)
1950
+ if (
1951
+ self._field_is_required("product_images", category_attributes)
1952
+ and not images
1953
+ and not has_sku_image
1954
+ ):
1955
+ hard_missing.append(i18n._("label_image_url"))
1956
+
1957
+ if self._field_is_required("general_price", category_attributes):
1958
+ if not any(sku.get("price") for sku in skus):
1959
+ missing.append("General price")
1960
+
1961
+ if self._field_is_required("stock", category_attributes):
1962
+ if not any(sku.get("quantity") for sku in skus):
1963
+ missing.append("Stock")
1777
1964
 
1778
1965
  package_dims = ("package_weight", "package_length", "package_width", "package_height")
1966
+ # Weight/Dimensions are mandatory by default (Miravia needs them for
1967
+ # shipping); a dimension is only skipped when the category API EXPLICITLY
1968
+ # marks it optional (is_mandatory=0). A missing attribute or API failure
1969
+ # keeps it blocking (fail safe toward blocking).
1970
+ required_dims = [
1971
+ dim for dim in package_dims
1972
+ if not self._field_explicitly_optional(dim, category_attributes)
1973
+ ]
1779
1974
  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:
1975
+ for dim in required_dims:
1783
1976
  val = sku.get(dim)
1784
1977
  try:
1785
1978
  if val is None or float(val) <= 0:
@@ -1787,13 +1980,14 @@ class PublishModule:
1787
1980
  except (TypeError, ValueError):
1788
1981
  missing.append(f"{dim.replace('_', ' ').title()} (SKU #{idx})")
1789
1982
 
1790
- if missing:
1983
+ if missing or hard_missing:
1791
1984
  return {
1792
1985
  "status": "need_mandatory_fields",
1793
1986
  "message": i18n._(
1794
- "pub_mandatory_fields_missing", fields=", ".join(missing)
1987
+ "pub_mandatory_fields_missing", fields=", ".join(hard_missing + missing)
1795
1988
  ),
1796
1989
  "missing_fields": missing,
1990
+ "hard_missing_fields": hard_missing,
1797
1991
  "next_actions": [i18n._("interactive_preview_next")],
1798
1992
  }
1799
1993
  return {}
@@ -1804,24 +1998,64 @@ class PublishModule:
1804
1998
  product_payload: Dict[str, Any],
1805
1999
  compliance_result: Dict[str, Any],
1806
2000
  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.
2001
+ ) -> "tuple[List[str], List[str]]":
2002
+ """Return internal keys of fields that are missing but can be skipped in draft mode,
2003
+ plus keys the category API explicitly marks as optional (not to be flagged).
1809
2004
 
1810
2005
  Keys match ``_TRACKED_FIELDS`` internal names so the summary preview
1811
2006
  can correctly mark them as missing.
1812
2007
  """
1813
2008
  missing: List[str] = []
1814
2009
  skus = (product_payload.get("Skus") or {}).get("Sku") or []
2010
+
2011
+ # Conditional fields not in our own hard-requirement list follow the
2012
+ # category attribute API (is_mandatory). e.g. EAN code is optional for
2013
+ # jeans, so an empty EAN must NOT be flagged as missing.
2014
+ api_optional: List[str] = []
2015
+
2016
+ # Delivery method (Attributes.deliverySof) is NOT a hard-collect field:
2017
+ # Miravia defaults it when absent (DBM, or DBS if the seller has DBS
2018
+ # permission — see _check_dbm_limits), so an empty value shows as
2019
+ # "Optional" here and never blocks.
2020
+ if not (getattr(schema, "delivery_method", None) or "").strip():
2021
+ api_optional.append("delivery_method")
1815
2022
  has_ean = any(sku.get("ean_code") for sku in skus) or bool(schema.ean_codes)
1816
2023
  if not has_ean:
1817
- missing.append("ean_code")
2024
+ if self._field_is_required("ean_code", category_attributes):
2025
+ missing.append("ean_code")
2026
+ else:
2027
+ api_optional.append("ean_code")
1818
2028
 
1819
2029
  if self._check_italy_price_mandatory(schema):
1820
2030
  missing.append("italy_price")
1821
2031
 
1822
- if schema.standard_unit_type and not schema.unit_count:
2032
+ def _payload_has(keys: "tuple[str, ...]") -> bool:
2033
+ """True when any non-empty value for the given key variants exists in
2034
+ the bound SKU payload or product-level Attributes. Covers the
2035
+ category-attribute-binding path where Unit_Count lands in the SKU
2036
+ even though schema.unit_count stays None."""
2037
+ cand = {self._squash_attr_name(k) for k in keys}
2038
+ for sku in skus:
2039
+ for k, v in sku.items():
2040
+ if self._squash_attr_name(str(k)) in cand and str(v or "").strip():
2041
+ return True
2042
+ for k, v in (product_payload.get("Attributes") or {}).items():
2043
+ if self._squash_attr_name(str(k)) in cand and str(v or "").strip():
2044
+ return True
2045
+ return False
2046
+
2047
+ unit_count_present = bool(
2048
+ (schema.unit_count is not None and str(schema.unit_count).strip())
2049
+ or _payload_has(("unit_count", "Unit_Count"))
2050
+ )
2051
+ unit_type_present = bool(
2052
+ (schema.standard_unit_type and str(schema.standard_unit_type).strip())
2053
+ or _payload_has(("unit_count_type", "standard_unit_type", "Unit_Count_Type"))
2054
+ )
2055
+
2056
+ if unit_type_present and not unit_count_present:
1823
2057
  missing.append("unit_count")
1824
- elif self._is_category_attribute_mandatory(category_attributes, "unit_count") and not schema.unit_count:
2058
+ elif self._is_category_attribute_mandatory(category_attributes, "unit_count") and not unit_count_present:
1825
2059
  missing.append("unit_count")
1826
2060
 
1827
2061
  if not schema.sku_attributes_food_health and self._category_looks_like_food_health(
@@ -1840,18 +2074,29 @@ class PublishModule:
1840
2074
  missing.append("eu_responsible_person")
1841
2075
 
1842
2076
  if schema.safety_warning is None:
1843
- missing.append("safety_warning")
2077
+ if self._field_is_required("safety_warning", category_attributes):
2078
+ missing.append("safety_warning")
2079
+ else:
2080
+ api_optional.append("safety_warning")
1844
2081
 
1845
2082
  if not schema.package_photos:
1846
- missing.append("package_photos")
2083
+ if self._field_is_required("package_photos", category_attributes):
2084
+ missing.append("package_photos")
2085
+ else:
2086
+ api_optional.append("package_photos")
1847
2087
 
1848
- if self._is_category_attribute_mandatory(category_attributes, "compliance_documents") and not schema.compliance_documents:
2088
+ if not schema.compliance_documents and self._is_category_attribute_mandatory(
2089
+ category_attributes, "compliance_documents"
2090
+ ):
1849
2091
  missing.append("compliance_documents")
1850
2092
 
2093
+ # Seller SKU is optional: when empty the create API auto-generates a
2094
+ # unique value. Never block or suggest Draft for it — just show as
2095
+ # Optional in the summary when not provided.
1851
2096
  if not any(sku.get("SellerSku") or sku.get("seller_sku") for sku in skus):
1852
- missing.append("seller_sku")
2097
+ api_optional.append("seller_sku")
1853
2098
 
1854
- return missing
2099
+ return missing, api_optional
1855
2100
 
1856
2101
  def _category_looks_like_food_health(
1857
2102
  self, category_attributes: List[Dict[str, Any]]
@@ -1865,14 +2110,177 @@ class PublishModule:
1865
2110
  for kw in ("food", "health", "nutrition", "allergen", "ingredient")
1866
2111
  )
1867
2112
 
2113
+ @staticmethod
2114
+ def _normalize_attr_name(value: Any) -> str:
2115
+ """Normalize an attribute/field name for fuzzy matching across layers."""
2116
+ return re.sub(r"[^a-z0-9]+", "_", str(value).strip().lower()).strip("_")
2117
+
2118
+ @staticmethod
2119
+ def _squash_attr_name(value: Any) -> str:
2120
+ """Aggressively squash a name (drop all separators) for matching.
2121
+
2122
+ Lets ``SellerSku`` match an API attribute named ``seller_sku`` and
2123
+ ``Unit Count`` match ``unit_count`` — variants that differ only by
2124
+ underscores/spacing.
2125
+ """
2126
+ return re.sub(r"[^a-z0-9]+", "", str(value).strip().lower())
2127
+
2128
+ def _find_category_attribute(
2129
+ self,
2130
+ category_attributes: List[Dict[str, Any]],
2131
+ candidates: tuple,
2132
+ ) -> Optional[Dict[str, Any]]:
2133
+ """Locate a category attribute definition by any of its name variants.
2134
+
2135
+ Matches on both the underscore-normalized form and the fully squashed
2136
+ form so ``SellerSku``/``seller_sku`` and similar casing/spacing variants
2137
+ resolve to the same attribute.
2138
+ """
2139
+ wanted = set()
2140
+ for c in candidates:
2141
+ wanted.add(self._normalize_attr_name(c))
2142
+ wanted.add(self._squash_attr_name(c))
2143
+ # Pass 1: exact match on any name variant (normalized or squashed).
2144
+ for attr in category_attributes or []:
2145
+ names = set()
2146
+ for key in ("name", "en_name", "label", "attribute_name"):
2147
+ if attr.get(key):
2148
+ names.add(self._normalize_attr_name(attr.get(key)))
2149
+ names.add(self._squash_attr_name(attr.get(key)))
2150
+ if names & wanted:
2151
+ return attr
2152
+ # Pass 2: conservative substring — a candidate squashed name (>=4 chars)
2153
+ # contained inside an attribute name variant. One-directional (candidate
2154
+ # in attribute) so short/generic headers don't shadow real ones.
2155
+ wanted_sq = {
2156
+ self._squash_attr_name(c) for c in candidates if len(self._squash_attr_name(c)) >= 4
2157
+ }
2158
+ if wanted_sq:
2159
+ for attr in category_attributes or []:
2160
+ for key in ("name", "en_name", "label", "attribute_name"):
2161
+ if not attr.get(key):
2162
+ continue
2163
+ a_sq = self._squash_attr_name(attr.get(key))
2164
+ if any(w in a_sq for w in wanted_sq):
2165
+ return attr
2166
+ return None
2167
+
2168
+ def _apply_unmatched_category_attributes(
2169
+ self,
2170
+ product_payload: Dict[str, Any],
2171
+ schema: MiraviaListingSchema,
2172
+ category_attributes: List[Dict[str, Any]],
2173
+ ) -> List[str]:
2174
+ """Bind non-whitelisted Excel/CSV columns to real category attributes.
2175
+
2176
+ ``schema.unmatched_columns`` holds every column header the rule
2177
+ extractor could not map to a known field. For each, we look it up
2178
+ against ``/category/attributes/get``; when it matches, we write the
2179
+ value under the attribute's canonical key — into each SKU for
2180
+ ``attribute_type == 'sku'``, otherwise into the product-level
2181
+ ``Attributes``. Existing keys are never overwritten (whitelist/LLM
2182
+ values win). Returns the headers that matched no category attribute
2183
+ so the caller can surface them instead of silently dropping them.
2184
+
2185
+ We intentionally bind ONLY the non-whitelisted columns here. Columns
2186
+ the whitelist already consumed (Title/Price/Image/Color/Stock/...) are
2187
+ assembled by the core extractor into their canonical payload shape; if
2188
+ we also re-bound them we would inject duplicate or case-colliding keys
2189
+ into the SKU (e.g. lowercase ``images`` next to the ``Images`` object,
2190
+ ``color_family`` next to ``color``), and ``/product/create`` rejects
2191
+ such a payload with E005 Invalid Request Format even though
2192
+ batchValidate tolerates the extra keys.
2193
+ """
2194
+ unmatched = getattr(schema, "unmatched_columns", {}) or {}
2195
+ if not unmatched:
2196
+ return []
2197
+ attributes = product_payload.setdefault("Attributes", {})
2198
+ skus = (product_payload.get("Skus") or {}).get("Sku") or []
2199
+ unmapped: List[str] = []
2200
+ for header, value in unmatched.items():
2201
+ attr = self._find_category_attribute(category_attributes, (header,))
2202
+ if not attr:
2203
+ unmapped.append(header)
2204
+ continue
2205
+ key = attr.get("name") or attr.get("en_name") or header
2206
+ if (attr.get("attribute_type") or "normal") == "sku":
2207
+ for sku in skus:
2208
+ if key not in sku:
2209
+ sku[key] = value
2210
+ else:
2211
+ if key not in attributes:
2212
+ attributes[key] = value
2213
+ if unmapped:
2214
+ logger.info(
2215
+ "Excel/CSV columns not matching any category attribute: %s",
2216
+ ", ".join(unmapped),
2217
+ )
2218
+ return unmapped
2219
+
2220
+ def _field_is_required(
2221
+ self,
2222
+ field_key: str,
2223
+ category_attributes: List[Dict[str, Any]],
2224
+ ) -> bool:
2225
+ """THE single source of truth for whether a field must be filled.
2226
+
2227
+ Priority: our own rules first, then the category attribute API.
2228
+ 1. Structural fields (title/images/price/stock/...) and hard-required
2229
+ conditional fields (GPSR manufacturer/EURP, Italy price, unit type)
2230
+ are always required — our rule hits regardless of category.
2231
+ 2. Everything else follows ``/category/attributes/get``: a matching
2232
+ attribute with ``is_mandatory=1`` (or the postToInactive flag) means
2233
+ required; a matching non-mandatory attribute, or no attribute at all,
2234
+ means optional.
2235
+
2236
+ Every mandatory/conditional check in the flow must call this instead of
2237
+ hard-coding per-field requirements, so AI-extract and manual paths stay
2238
+ consistent.
2239
+ """
2240
+ if (
2241
+ field_key in self._CORE_MANDATORY_KEYS
2242
+ or field_key in self._HARD_REQUIRED_CONDITIONAL_KEYS
2243
+ ):
2244
+ return True
2245
+ attr = self._find_category_attribute(category_attributes, (field_key,))
2246
+ if attr is None:
2247
+ return False
2248
+ if str(attr.get("is_mandatory", "0")).strip() == "1":
2249
+ return True
2250
+ # Fall back to the explicit postToInactive flag when present
2251
+ return str(attr.get("post_to_inactive", "0")).strip() == "1"
2252
+
2253
+ def _field_explicitly_optional(
2254
+ self,
2255
+ field_key: str,
2256
+ category_attributes: List[Dict[str, Any]],
2257
+ ) -> bool:
2258
+ """True ONLY when the category attribute API returns a matching attribute
2259
+ that is explicitly non-mandatory. Used for fields that default to
2260
+ mandatory (package weight/dimensions, needed for shipping): they block
2261
+ unless the API clearly says otherwise. A missing attribute (or an API
2262
+ call failure / empty list) returns False, i.e. stays mandatory — fail
2263
+ safe toward blocking."""
2264
+ attr = self._find_category_attribute(category_attributes or [], (field_key,))
2265
+ if not attr:
2266
+ return False
2267
+ if str(attr.get("post_to_inactive", "0")).strip() == "1":
2268
+ return False
2269
+ return str(attr.get("is_mandatory", "0")).strip() != "1"
2270
+
1868
2271
  def _is_category_attribute_mandatory(
1869
2272
  self, category_attributes: List[Dict[str, Any]], attr_name: str
1870
2273
  ) -> 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
2274
+ """Check if a category attribute is marked as mandatory (is_mandatory=1).
2275
+
2276
+ ``is_mandatory`` may arrive as int or string ("0"/"1") — ``bool("0")``
2277
+ is True, so compare the normalized string instead. Name matching is
2278
+ normalized across ``name``/``en_name``/``label`` variants.
2279
+ """
2280
+ attr = self._find_category_attribute(category_attributes, (attr_name,))
2281
+ if attr is None:
2282
+ return False
2283
+ return str(attr.get("is_mandatory", "0")).strip() == "1"
1876
2284
 
1877
2285
  def _check_country_price_recommendation(
1878
2286
  self, schema: MiraviaListingSchema
@@ -1939,22 +2347,27 @@ class PublishModule:
1939
2347
  fs_preview: Dict[str, Any],
1940
2348
  key_missing: List[str],
1941
2349
  country_price_note: Optional[str] = None,
2350
+ api_optional: Optional[List[str]] = None,
1942
2351
  ) -> Dict[str, Any]:
1943
2352
  """Return the field extraction summary and draft/continue prompt.
1944
2353
 
1945
2354
  Fields are grouped by Mandatory vs Conditional. Missing mandatory
1946
2355
  fields block publish; missing conditional fields suggest Draft.
2356
+ Conditional fields the category API explicitly marks as optional and
2357
+ that are not filled show as ``Optional`` instead of ``MISSING``.
1947
2358
  """
1948
2359
  token = _make_publish_preview_token(fs_preview)
2360
+ optional_set = set(api_optional or [])
1949
2361
 
1950
2362
  mandatory_lines: List[str] = []
1951
2363
  conditional_lines: List[str] = []
1952
2364
  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
- )
2365
+ if key in key_missing:
2366
+ status = i18n._("pub_field_status_missing")
2367
+ elif key in optional_set:
2368
+ status = i18n._("pub_field_status_optional")
2369
+ else:
2370
+ status = i18n._("pub_field_status_filled")
1958
2371
  line = f" {label}: {status}"
1959
2372
  if category == "mandatory":
1960
2373
  mandatory_lines.append(line)
@@ -2281,16 +2694,18 @@ class PublishModule:
2281
2694
  if quantity is not None:
2282
2695
  sku["quantity"] = int(quantity)
2283
2696
 
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"),
2697
+ # Package dimensions & weight (per-SKU per Miravia docs).
2698
+ # Normalize unit-suffixed strings to the bare number Miravia's
2699
+ # /product/create requires: weight in kg, dimensions in cm.
2700
+ for dim, schema_field, conv in (
2701
+ ("package_weight", "weight_kg", _convert_weight),
2702
+ ("package_length", "length_cm", _convert_length),
2703
+ ("package_width", "width_cm", _convert_length),
2704
+ ("package_height", "height_cm", _convert_length),
2290
2705
  ):
2291
2706
  value = raw.get(dim) or getattr(schema, schema_field, None)
2292
2707
  if value is not None:
2293
- sku[dim] = str(value)
2708
+ sku[dim] = _format_measurement(value, conv)
2294
2709
 
2295
2710
  # Sales properties are flat SKU fields
2296
2711
  v1_name = _normalize_variation_name(schema.variation_name_1)
@@ -2915,12 +3330,19 @@ def _validate_title(title: str) -> None:
2915
3330
  )
2916
3331
 
2917
3332
 
2918
- def _validate_create_payload(payload: Dict[str, Any]) -> None:
3333
+ def _validate_create_payload(
3334
+ payload: Dict[str, Any],
3335
+ seller_sku_required: bool = False,
3336
+ ) -> None:
2919
3337
  """Validate a Miravia ``/product/create`` Product payload.
2920
3338
 
2921
3339
  Enforces the field constraints from the Miravia ``/product/create``
2922
3340
  documentation. Package dimensions/weight and ``SellerSku`` are validated
2923
3341
  at the SKU level; the legacy ``Product.Package`` node is no longer used.
3342
+
3343
+ ``seller_sku_required`` is decided by the caller from the category attribute
3344
+ API (``_field_is_required``): when False, an empty SellerSku is allowed and
3345
+ the create API auto-generates a unique value.
2924
3346
  """
2925
3347
  attributes = payload.get("Attributes") or {}
2926
3348
  title = attributes.get("name")
@@ -2942,6 +3364,17 @@ def _validate_create_payload(payload: Dict[str, Any]) -> None:
2942
3364
  i18n._("pub_too_many_skus", max=_MAX_SKUS_PER_PRODUCT, count=len(skus))
2943
3365
  )
2944
3366
 
3367
+ # Authoritative backstop: Miravia /product/create requires at least one image
3368
+ # at SPU or SKU level and rejects an imageless product with 4224
3369
+ # MAIN_IMAGE_OR_SKU_IMAGE_EMPTY even for Draft. Enforce it here so no path
3370
+ # (manual mode, or a draft that slipped past the earlier stage) can submit an
3371
+ # imageless product.
3372
+ has_sku_image = any((s.get("Images") or {}).get("Image") for s in skus)
3373
+ if not images and not has_sku_image:
3374
+ raise ValueError(
3375
+ i18n._("pub_image_required_no_draft", fields=i18n._("label_image_url"))
3376
+ )
3377
+
2945
3378
  # Check EAN duplicate within same product
2946
3379
  ean_codes = [sku.get("ean_code") for sku in skus if sku.get("ean_code")]
2947
3380
  if len(ean_codes) != len(set(ean_codes)):
@@ -2953,7 +3386,7 @@ def _validate_create_payload(payload: Dict[str, Any]) -> None:
2953
3386
  raise ValueError(i18n._("pub_seller_sku_duplicate_in_product"))
2954
3387
 
2955
3388
  for idx, sku in enumerate(skus, start=1):
2956
- if not sku.get("SellerSku"):
3389
+ if seller_sku_required and not (sku.get("SellerSku") or sku.get("seller_sku")):
2957
3390
  raise ValueError(i18n._("pub_seller_sku_required", idx=idx))
2958
3391
 
2959
3392
  price = sku.get("price")
@@ -3190,6 +3623,32 @@ def _convert_length(value: str) -> float:
3190
3623
  return number * _LENGTH_TO_CM[unit]
3191
3624
 
3192
3625
 
3626
+ def _format_measurement(value: Any, converter) -> str:
3627
+ """Normalize a possibly unit-suffixed measurement into a plain numeric
3628
+ string in the target base unit.
3629
+
3630
+ ``converter`` is :func:`_convert_weight` (-> kg) or :func:`_convert_length`
3631
+ (-> cm). Examples: ``"0.6kg" -> "0.6"``, ``"500g" -> "0.5"``,
3632
+ ``"5cm" -> "5"``, ``"50mm" -> "5"``, ``30 -> "30"``.
3633
+
3634
+ Falls back to the cleaned numeric substring when the unit is unrecognized,
3635
+ so we never crash on odd inputs; Miravia's ``/product/create`` requires a
3636
+ bare number > 0 for package weight/dimensions, and leaving a unit suffix
3637
+ like ``"0.6kg"`` in the payload makes batchValidate reject it with
3638
+ ``"not valid, only a number"``.
3639
+ """
3640
+ try:
3641
+ number = float(converter(value))
3642
+ except (TypeError, ValueError, AttributeError):
3643
+ m = re.search(r"(\d+(?:[.,]\d+)?)", str(value))
3644
+ if not m:
3645
+ return str(value).strip()
3646
+ number = float(m.group(1).replace(",", "."))
3647
+ if abs(number - round(number)) < 1e-9:
3648
+ return str(int(round(number)))
3649
+ return ("%.6f" % number).rstrip("0").rstrip(".")
3650
+
3651
+
3193
3652
  def _validate_dimension(value: Any, name: str, maximum: int) -> None:
3194
3653
  """Validate dimension value after unit conversion.
3195
3654