@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.
- package/dist/SKILL.md +15 -6
- package/dist/VERSION +1 -1
- package/dist/scripts/extraction_engine.py +41 -1
- package/dist/scripts/i18n.py +44 -4
- package/dist/scripts/miravia_client.py +6 -0
- package/dist/scripts/product_client.py +21 -4
- package/dist/scripts/product_skill.py +38 -0
- package/dist/scripts/publish_module.py +559 -100
- package/package.json +1 -1
|
@@ -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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
"""
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
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.
|
|
802
|
-
delivery_method = attrs.get("
|
|
814
|
+
attrs = product_payload.setdefault("Attributes", {})
|
|
815
|
+
delivery_method = str(attrs.get("deliverySof") or "").strip().upper()
|
|
803
816
|
|
|
804
|
-
#
|
|
805
|
-
|
|
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["
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
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(
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
1711
|
-
|
|
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,
|
|
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
|
-
|
|
1762
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
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
|
-
|
|
1954
|
-
i18n._("pub_field_status_missing")
|
|
1955
|
-
|
|
1956
|
-
|
|
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
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
("
|
|
2289
|
-
("
|
|
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] =
|
|
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(
|
|
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
|
|