@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.
- package/dist/SKILL.md +16 -6
- package/dist/VERSION +1 -1
- package/dist/scripts/extraction_engine.py +43 -1
- package/dist/scripts/i18n.py +52 -4
- package/dist/scripts/miravia_client.py +6 -0
- package/dist/scripts/product_client.py +23 -4
- package/dist/scripts/product_skill.py +57 -0
- package/dist/scripts/publish_module.py +748 -117
- 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,35 @@ 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("
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
if delivery_method
|
|
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["
|
|
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
|
-
|
|
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
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
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
|
|
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
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
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(
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
1711
|
-
|
|
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,
|
|
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
|
-
|
|
1762
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
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
|
-
|
|
1954
|
-
i18n._("pub_field_status_missing")
|
|
1955
|
-
|
|
1956
|
-
|
|
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
|
|
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
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
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
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
("
|
|
2289
|
-
("
|
|
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] =
|
|
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
|
-
|
|
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(
|
|
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
|
|