@miravia_test/miravia-publish-skill 0.1.7 → 0.1.9
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 +2 -0
- package/dist/VERSION +1 -1
- package/dist/scripts/config.py +25 -0
- package/dist/scripts/extraction_engine.py +2 -0
- package/dist/scripts/i18n.py +18 -0
- package/dist/scripts/product_client.py +13 -11
- package/dist/scripts/product_skill.py +21 -1
- package/dist/scripts/publish_module.py +380 -44
- package/package.json +1 -1
package/dist/SKILL.md
CHANGED
|
@@ -112,10 +112,12 @@ description: Miravia 商品发布 AI Skill。仅负责商品创建(手动一
|
|
|
112
112
|
| `need_brand_create` | 品牌未找到,需去 Seller Center 处理 | 先引导查看已有品牌及审核状态(https://sellercenter.miravia.es/apps/setting/seller/brandlist,可能已在审核中),确无再创建(https://sellercenter.miravia.es/apps/product/publish/brand);创建后重新运行命令 |
|
|
113
113
|
| `need_mandatory_fields` | 缺少必填字段;**图片缺失为硬阻断(不可 Draft)**,价格/重量/尺寸缺失可 `--draft` | 补充字段后重跑 |
|
|
114
114
|
| `need_category_attributes` | 类目必填属性缺失(AI抽取模式) | 通过 `--attribute-data` 补充或使用 `--draft` |
|
|
115
|
+
| `need_attribute_mapping` | Excel/CSV 列未能自动匹配到类目属性(不静默丢弃),返回该列及其候选属性 | 向用户展示候选属性,确认后用 `--attribute-data '{"<属性名>":"<值>"}'` 重跑;非商品属性则删除该列 |
|
|
115
116
|
| `need_field_summary` | 需要确认字段摘要或选择草稿模式 | 回传 `field_summary_token`,可加 `--draft` |
|
|
116
117
|
| `need_compliance` | 制造商/EU 负责人匹配失败 | 按返回候选选择或补充信息后重跑 |
|
|
117
118
|
| `compliance_missing_suggest_draft` | 合规字段缺失(制造商/欧代/Safety Warning内容/合规文档) | 补充合规字段或加 `--draft` 发布为草稿 |
|
|
118
119
|
| `gpsr_missing_suggest_draft` | GPSR 字段缺失(制造商和/或欧盟负责人为空) | 补充 GPSR 字段或加 `--draft` 发布为草稿 |
|
|
120
|
+
| `need_safety_warning_confirm` | AI/Excel 源检测到安全相关内容,但卖家未显式设置「该产品是否有安全警告」;系统**不会**静默默认为 No | 展示 `detected_safety_content` 证据与当前值,让卖家确认:确有则加 `--safety-warning yes`(必要时 `--safety-warning-content`)重跑;确无则加 `--safety-warning no` 确认;或在 Excel 补齐该列后重跑 |
|
|
119
121
|
| `post_to_inactive_missing_suggest_draft` | 类目必填字段缺失(EAN) | 补充字段或加 `--draft` 发布为草稿 |
|
|
120
122
|
| `special_category_block` | 特殊类目规则阻断(PPU 值无效 / 图书类目 ISBN 缺失) | 修正错误后重跑,不可转 Draft |
|
|
121
123
|
| `special_category_suggest_draft` | 特殊类目字段缺失(PPU 缺失 / 食品属性缺失) | 补充字段或加 `--draft` 发布为草稿 |
|
package/dist/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.1.
|
|
1
|
+
0.1.9
|
package/dist/scripts/config.py
CHANGED
|
@@ -220,6 +220,31 @@ def require_business_credentials() -> None:
|
|
|
220
220
|
pass
|
|
221
221
|
|
|
222
222
|
|
|
223
|
+
def has_valid_business_credentials() -> bool:
|
|
224
|
+
"""Return True when credentials are present and not expired.
|
|
225
|
+
|
|
226
|
+
Non-raising companion to ``require_business_credentials``. The publish
|
|
227
|
+
flow uses this to decide whether the seller is already authorized: when
|
|
228
|
+
valid credentials exist (they can only exist after a successful
|
|
229
|
+
``auth_cli.py login``, which grants the read-profile / access-products /
|
|
230
|
+
update-products scopes), the skill must not re-prompt the three-item
|
|
231
|
+
authorization consent on every run. Returns False for missing or expired
|
|
232
|
+
credentials so the caller falls back to asking the seller.
|
|
233
|
+
"""
|
|
234
|
+
if not (APP_KEY and APP_SECRET and ACCESS_TOKEN):
|
|
235
|
+
return False
|
|
236
|
+
|
|
237
|
+
if TOKEN_EXPIRES_AT:
|
|
238
|
+
try:
|
|
239
|
+
expires_at_ms = int(TOKEN_EXPIRES_AT)
|
|
240
|
+
if expires_at_ms > 0 and int(time.time() * 1000) > expires_at_ms - 60_000:
|
|
241
|
+
return False
|
|
242
|
+
except ValueError:
|
|
243
|
+
pass
|
|
244
|
+
|
|
245
|
+
return True
|
|
246
|
+
|
|
247
|
+
|
|
223
248
|
def mask_token(token: str) -> str:
|
|
224
249
|
if not token:
|
|
225
250
|
return "<empty>"
|
|
@@ -371,6 +371,7 @@ _TABLE_FIELD_SYNONYMS: Dict[str, tuple] = {
|
|
|
371
371
|
"length_cm": ("长度", "长cm", "长", "length", "largura"),
|
|
372
372
|
"width_cm": ("宽度", "宽cm", "宽", "width", "ancho"),
|
|
373
373
|
"height_cm": ("高度", "高cm", "高", "height", "altura"),
|
|
374
|
+
"delivery_method": ("deliverymethod", "delivery", "shippingmethod", "shipping", "物流方式", "物流", "发货方式", "配送方式", "metododeenvío", "metododeenvio", "envío", "envio", "spedizione", "metododiconsegna"),
|
|
374
375
|
"manufacturer_name": ("制造商", "厂家", "manufacturer", "fabricante"),
|
|
375
376
|
"eu_responsible_person_name": ("欧盟负责人", "欧代", "euresponsibleperson", "eurp"),
|
|
376
377
|
"variation_1": ("颜色", "color", "colour", "variation1", "variation", "款式"),
|
|
@@ -389,6 +390,7 @@ _PRODUCT_LEVEL_FIELDS = {
|
|
|
389
390
|
"length_cm",
|
|
390
391
|
"width_cm",
|
|
391
392
|
"height_cm",
|
|
393
|
+
"delivery_method",
|
|
392
394
|
"manufacturer_name",
|
|
393
395
|
"eu_responsible_person_name",
|
|
394
396
|
}
|
package/dist/scripts/i18n.py
CHANGED
|
@@ -218,6 +218,11 @@ TRANSLATIONS: Dict[str, Dict[str, str]] = {
|
|
|
218
218
|
"pub_compliance_manufacturer_eurp_both_empty": "Manufacturer and EU Responsible Person are both empty. Non-EU manufacturers require an EU Responsible Person.",
|
|
219
219
|
"pub_compliance_eurp_missing_with_manufacturer": "Manufacturer is filled but EU Responsible Person is empty. Non-EU manufacturers require an EU Responsible Person.",
|
|
220
220
|
"pub_safety_warning_content_missing": "Safety Warning is set to Yes but the warning content is empty. Please provide the safety warning content.",
|
|
221
|
+
"pub_safety_confirm_title": "Safety warning needs your confirmation",
|
|
222
|
+
"pub_safety_confirm_message": "We detected safety-related content in your source ({evidence}), but the 'Does this product have a safety warning' flag was not set explicitly. We will NOT silently default it to 'No'. Please confirm the correct value, or complete the field in your Excel and rerun.",
|
|
223
|
+
"pub_safety_confirm_next_yes": "If the product does have a safety warning, rerun with --safety-warning yes (add --safety-warning-content if needed).",
|
|
224
|
+
"pub_safety_confirm_next_no": "If it genuinely has no safety warning, rerun with --safety-warning no to confirm.",
|
|
225
|
+
"pub_safety_confirm_next_excel": "Your Excel fields may be incomplete; you can fill the safety column there and rerun.",
|
|
221
226
|
"pub_compliance_docs_missing": "Compliance documents are required for this category but not provided.",
|
|
222
227
|
"pub_compliance_missing_message": "Some compliance fields are missing. You can fill them or publish to Draft.",
|
|
223
228
|
"pub_compliance_fill_or_draft": "Fill the missing compliance fields, or publish as Draft to complete them later.",
|
|
@@ -249,6 +254,7 @@ TRANSLATIONS: Dict[str, Dict[str, str]] = {
|
|
|
249
254
|
"pub_dbm_dim_sum_exceeded": "Dimension sum {dim_sum} exceeds DBM limit {limit}cm.",
|
|
250
255
|
"pub_dbm_volume_exceeded": "Volumetric weight {volume} exceeds DBM limit {limit}.",
|
|
251
256
|
"pub_dbm_auto_switch_to_dbs": "Package exceeds DBM limits. Delivery method auto-switched to DBS.",
|
|
257
|
+
"pub_dbs_no_permission_downgrade": "Seller requested DBS but this account has no DBS permission; the delivery method was set to DBM.",
|
|
252
258
|
"pub_dbm_no_dbs_permission": "Package exceeds DBM limits but seller has no DBS permission. Cannot auto-switch to DBS.",
|
|
253
259
|
"pub_dbm_fix_or_apply_dbs": "Please reduce package size/weight to meet DBM limits, or apply for DBS permission in Seller Center.",
|
|
254
260
|
# SKU already exists (concurrency conflict)
|
|
@@ -338,6 +344,9 @@ TRANSLATIONS: Dict[str, Dict[str, str]] = {
|
|
|
338
344
|
"pub_need_category_attributes_description": "The category {category_id} requires the following attributes that were not found in the extracted data. Please provide them via --attribute-data or rerun with --draft to publish as an inactive draft.",
|
|
339
345
|
"pub_need_category_attributes_item": "{idx}. {label} ({en_name}) - {input_type}",
|
|
340
346
|
"pub_need_category_attributes_next": "Provide the missing attributes and rerun with --attribute-data '<json>', or use --draft to publish as inactive draft and complete them later.",
|
|
347
|
+
"pub_need_attribute_mapping_title": "Some columns could not be matched automatically",
|
|
348
|
+
"pub_need_attribute_mapping_description": "These Excel/CSV columns did not clearly match a category attribute, so they were not bound. Please confirm which attribute each column should map to, then rerun with --attribute-data using the canonical attribute names shown below (or drop the column if it is not a product attribute).",
|
|
349
|
+
"pub_need_attribute_mapping_next": "Rerun with --attribute-data '{\"<attribute_name>\": \"<value>\"}' using one of the suggested attribute names, or remove the column if it is not needed.",
|
|
341
350
|
"pub_html_cleaned_notice": "The product description has been cleaned: unsafe HTML tags (script, iframe, style attributes, etc.) were removed. Only safe formatting tags (p, br, b, ul, li, etc.) are kept.",
|
|
342
351
|
"pub_create_pending_review": "Product submitted successfully. It is currently under Miravia review and temporarily unavailable for sale; approval may take up to 48 hours, especially if package photos or documents are required. Once approved, it will be activated automatically. If rejected, please check the Suspended tab.",
|
|
343
352
|
"pub_pdp_link": "Product detail page: {url}",
|
|
@@ -883,6 +892,11 @@ TRANSLATIONS: Dict[str, Dict[str, str]] = {
|
|
|
883
892
|
"pub_compliance_manufacturer_eurp_both_empty": "制造商和欧盟负责人均为空。非欧盟制造商必须填写欧盟负责人。",
|
|
884
893
|
"pub_compliance_eurp_missing_with_manufacturer": "已填写制造商但欧盟负责人为空。非欧盟制造商必须填写欧盟负责人。",
|
|
885
894
|
"pub_safety_warning_content_missing": "安全警告已设为 Yes 但未填写警告内容。请补充安全警告内容。",
|
|
895
|
+
"pub_safety_confirm_title": "安全警告需要你确认",
|
|
896
|
+
"pub_safety_confirm_message": "我们从你的源文件中检测到安全相关内容({evidence}),但“该产品是否有安全警告”这一项没有被显式设置。我们不会把它静默默认为 No。请确认应取的值,或先在 Excel 中补全该项后重新运行。",
|
|
897
|
+
"pub_safety_confirm_next_yes": "如果该商品确实有安全警告,请加 --safety-warning yes 重新运行(必要时再加 --safety-warning-content)。",
|
|
898
|
+
"pub_safety_confirm_next_no": "如果确实没有安全警告,请加 --safety-warning no 确认。",
|
|
899
|
+
"pub_safety_confirm_next_excel": "Excel 字段可能不完整,你可以在其中补上安全警告列后重新运行。",
|
|
886
900
|
"pub_compliance_docs_missing": "该类目要求上传合规文档但未提供。",
|
|
887
901
|
"pub_compliance_missing_message": "部分合规字段缺失。您可以补充完整或发布为草稿。",
|
|
888
902
|
"pub_compliance_fill_or_draft": "补充缺失的合规字段,或发布为草稿(Draft)稍后在 Seller Center 中完善。",
|
|
@@ -914,6 +928,7 @@ TRANSLATIONS: Dict[str, Dict[str, str]] = {
|
|
|
914
928
|
"pub_dbm_dim_sum_exceeded": "三边之和 {dim_sum} 超过 DBM 限制 {limit}cm。",
|
|
915
929
|
"pub_dbm_volume_exceeded": "体积重 {volume} 超过 DBM 限制 {limit}。",
|
|
916
930
|
"pub_dbm_auto_switch_to_dbs": "包裹超出 DBM 限制,物流方式自动转为 DBS。",
|
|
931
|
+
"pub_dbs_no_permission_downgrade": "商家请求使用 DBS,但当前账号没有 DBS 权限,物流方式已设为 DBM。",
|
|
917
932
|
"pub_dbm_no_dbs_permission": "包裹超出 DBM 限制,但卖家无 DBS 权限,无法自动转为 DBS。",
|
|
918
933
|
"pub_dbm_fix_or_apply_dbs": "请减小包裹尺寸/重量以满足 DBM 限制,或在 Seller Center 申请 DBS 权限。",
|
|
919
934
|
# SKU already exists (concurrency conflict)
|
|
@@ -968,6 +983,9 @@ TRANSLATIONS: Dict[str, Dict[str, str]] = {
|
|
|
968
983
|
"pub_need_category_attributes_description": "类目 {category_id} 要求以下属性,但 AI 抽取的数据中未包含。请通过 --attribute-data 补充,或使用 --draft 发布为未激活草稿。",
|
|
969
984
|
"pub_need_category_attributes_item": "{idx}. {label}({en_name})- {input_type}",
|
|
970
985
|
"pub_need_category_attributes_next": "补充缺失属性后使用 --attribute-data '<json>' 重新运行,或使用 --draft 发布为未激活草稿稍后补充。",
|
|
986
|
+
"pub_need_attribute_mapping_title": "部分列未能自动匹配",
|
|
987
|
+
"pub_need_attribute_mapping_description": "以下 Excel/CSV 列未能明确匹配到类目属性,因此未绑定。请确认每列应对应哪个属性,然后用下方展示的规范属性名通过 --attribute-data 重新运行(若该列不是商品属性则可删除)。",
|
|
988
|
+
"pub_need_attribute_mapping_next": "使用下方建议的属性名之一,通过 --attribute-data '{\"<属性名>\": \"<值>\"}' 重新运行;若不需要该列则删除。",
|
|
971
989
|
"pub_html_cleaned_notice": "商品描述已清理:不安全的 HTML 标签(script、iframe、style 属性等)已被移除。仅保留安全格式标签(p、br、b、ul、li 等)。",
|
|
972
990
|
"pub_create_pending_review": "商品提交成功,目前正在接受 Miravia 审核,暂不可售;审核最长可能需要 48 小时,特别是需要包装照片或文件时。审核通过后会自动激活,如被拒绝请在“已暂停(Suspended)”选项卡中查看。",
|
|
973
991
|
"pub_pdp_link": "商品详情页:{url}",
|
|
@@ -360,13 +360,14 @@ class ProductClient:
|
|
|
360
360
|
|
|
361
361
|
# GPSR compliance fields are injected at SKU level (see below).
|
|
362
362
|
|
|
363
|
-
# Delivery method -> Miravia create field ``Attributes.
|
|
364
|
-
#
|
|
363
|
+
# Delivery method -> Miravia create field ``Attributes.delivery_option_sof``,
|
|
364
|
+
# which is a YES/NO flag: "Yes" = DBS (seller-fulfilled), "No" = DBM
|
|
365
|
+
# (platform-fulfilled). Only written when the seller explicitly chose one;
|
|
365
366
|
# when absent, publish_module._check_dbm_limits applies the default
|
|
366
|
-
# (DBM generally, DBS if the seller has DBS permission).
|
|
367
|
+
# (No/DBM generally, Yes/DBS if the seller has DBS permission).
|
|
367
368
|
_dm = str(data.get("delivery_method") or "").strip().upper()
|
|
368
369
|
if _dm in ("DBM", "DBS"):
|
|
369
|
-
attributes["
|
|
370
|
+
attributes["delivery_option_sof"] = "Yes" if _dm == "DBS" else "No"
|
|
370
371
|
|
|
371
372
|
product_payload: Dict[str, Any] = {
|
|
372
373
|
"PrimaryCategory": category_id,
|
|
@@ -405,13 +406,14 @@ class ProductClient:
|
|
|
405
406
|
if draft:
|
|
406
407
|
product_payload["Status"] = "inactive"
|
|
407
408
|
|
|
408
|
-
# Country-specific prices
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
409
|
+
# Country-specific prices are NOT injected here at the product level.
|
|
410
|
+
# Verified against the authoritative /product/create contract: each
|
|
411
|
+
# country override lives INSIDE the SKU as
|
|
412
|
+
# ``CountryPrice: [{"CountryCode":"ES","Price":"1.9","SalePrice":"1.8"}]``
|
|
413
|
+
# (capitalised keys). A product-level ``CountryPrice`` list with the
|
|
414
|
+
# old ``{"country","price"}`` shape is silently ignored by the API.
|
|
415
|
+
# This is built per SKU in
|
|
416
|
+
# ``PublishModule._build_sku_list_from_schema``.
|
|
415
417
|
|
|
416
418
|
# Standard unit type / count
|
|
417
419
|
if data.get("standard_unit_type"):
|
|
@@ -373,6 +373,25 @@ def _print_conversational_result(result: Dict[str, Any]) -> None:
|
|
|
373
373
|
print(f"• {action}")
|
|
374
374
|
return
|
|
375
375
|
|
|
376
|
+
# Unmapped Excel/CSV columns needing a confirmed attribute mapping
|
|
377
|
+
if status == "need_attribute_mapping":
|
|
378
|
+
print(result.get("title", ""))
|
|
379
|
+
print(result.get("message", ""))
|
|
380
|
+
print()
|
|
381
|
+
for item in result.get("unmapped_columns", []):
|
|
382
|
+
print(f" 列 \"{item.get('header')}\" = {item.get('value')} 可能对应:")
|
|
383
|
+
for c in item.get("candidates", []):
|
|
384
|
+
mand = "(必填)" if str(c.get("is_mandatory")) == "1" else ""
|
|
385
|
+
print(
|
|
386
|
+
f" - {c.get('label')} "
|
|
387
|
+
f"[name={c.get('name')}, en_name={c.get('en_name')}, "
|
|
388
|
+
f"type={c.get('attribute_type')}]{mand}"
|
|
389
|
+
)
|
|
390
|
+
print()
|
|
391
|
+
for action in result.get("next_actions", []):
|
|
392
|
+
print(f"• {action}")
|
|
393
|
+
return
|
|
394
|
+
|
|
376
395
|
if status == "need_images":
|
|
377
396
|
print(result.get("message", ""))
|
|
378
397
|
if result.get("failed_images"):
|
|
@@ -796,7 +815,8 @@ def main() -> None:
|
|
|
796
815
|
print(json.dumps(auth_payload, indent=2, ensure_ascii=False))
|
|
797
816
|
return
|
|
798
817
|
|
|
799
|
-
|
|
818
|
+
already_authorized = getattr(args, "authorized", False) or config.has_valid_business_credentials()
|
|
819
|
+
if not skip_credential_check and args.scenario and not already_authorized:
|
|
800
820
|
auth_payload = {
|
|
801
821
|
"status": "need_authorization",
|
|
802
822
|
"title": i18n._("pub_authorization_title"),
|
|
@@ -801,24 +801,55 @@ class PublishModule:
|
|
|
801
801
|
*,
|
|
802
802
|
has_dbs_permission: bool = False,
|
|
803
803
|
) -> Optional[Dict[str, Any]]:
|
|
804
|
-
"""Resolve ``Attributes.
|
|
805
|
-
|
|
806
|
-
Miravia ``/product/create`` expects ``Attributes.
|
|
807
|
-
``"
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
804
|
+
"""Resolve ``Attributes.delivery_option_sof`` and enforce DBM package limits.
|
|
805
|
+
|
|
806
|
+
Miravia ``/product/create`` expects ``Attributes.delivery_option_sof`` to be
|
|
807
|
+
a YES/NO flag: ``"Yes"`` = DBS (seller-fulfilled), ``"No"`` = DBM
|
|
808
|
+
(platform-fulfilled). The legacy ``deliverySof="DBM"/"DBS"`` literal is
|
|
809
|
+
silently ignored by the platform. Internally we normalise to ``"DBM"``/
|
|
810
|
+
``"DBS"`` for the package-limit logic and always write the flag back as
|
|
811
|
+
Yes/No. When the seller did not choose one it is defaulted here: generally
|
|
812
|
+
DBM (No), but DBS (Yes) when the seller has DBS permission. If the
|
|
813
|
+
effective method is DBM but the package exceeds the DBM limits (weight
|
|
814
|
+
<= 40KG, L+W+H <= 300cm, volumetric weight L*W*H/6000 <= 40), it is
|
|
815
|
+
switched to DBS when permitted, otherwise the caller blocks. Returns a
|
|
816
|
+
result dict, or None when nothing to do.
|
|
813
817
|
"""
|
|
814
818
|
attrs = product_payload.setdefault("Attributes", {})
|
|
815
|
-
delivery_method = str(attrs.get("deliverySof") or "").strip().upper()
|
|
816
819
|
|
|
817
|
-
|
|
818
|
-
|
|
820
|
+
def _write_flag(method: str) -> None:
|
|
821
|
+
# Canonical wire flag the platform actually honours; replaces the
|
|
822
|
+
# legacy deliverySof value so no stale key lingers.
|
|
823
|
+
attrs["delivery_option_sof"] = "Yes" if method == "DBS" else "No"
|
|
824
|
+
attrs.pop("deliverySof", None)
|
|
825
|
+
|
|
826
|
+
_raw = str(
|
|
827
|
+
attrs.get("delivery_option_sof") or attrs.get("deliverySof") or ""
|
|
828
|
+
).strip().upper()
|
|
829
|
+
# Accept the wire flag (Yes/No) as well as legacy DBM/DBS codes.
|
|
830
|
+
if _raw in ("YES", "DBS"):
|
|
831
|
+
delivery_method = "DBS"
|
|
832
|
+
elif _raw in ("NO", "DBM"):
|
|
833
|
+
delivery_method = "DBM"
|
|
834
|
+
else:
|
|
835
|
+
delivery_method = ""
|
|
836
|
+
downgraded_from_dbs = False
|
|
837
|
+
|
|
819
838
|
if delivery_method not in ("DBM", "DBS"):
|
|
839
|
+
# No explicit entry: default to DBS when the seller is permitted,
|
|
840
|
+
# otherwise DBM.
|
|
820
841
|
delivery_method = "DBS" if has_dbs_permission else "DBM"
|
|
821
|
-
|
|
842
|
+
_write_flag(delivery_method)
|
|
843
|
+
elif delivery_method == "DBS" and not has_dbs_permission:
|
|
844
|
+
# Seller explicitly chose DBS but the account has no DBS permission:
|
|
845
|
+
# never grant DBS without permission — fall back to DBM and still
|
|
846
|
+
# enforce the DBM package limits below.
|
|
847
|
+
delivery_method = "DBM"
|
|
848
|
+
_write_flag(delivery_method)
|
|
849
|
+
downgraded_from_dbs = True
|
|
850
|
+
else:
|
|
851
|
+
# Normalise an explicit/legacy value to the canonical Yes/No flag.
|
|
852
|
+
_write_flag(delivery_method)
|
|
822
853
|
|
|
823
854
|
# Only DBM carries package limits.
|
|
824
855
|
if delivery_method != "DBM":
|
|
@@ -856,12 +887,17 @@ class PublishModule:
|
|
|
856
887
|
)
|
|
857
888
|
|
|
858
889
|
if not exceeded:
|
|
890
|
+
if downgraded_from_dbs:
|
|
891
|
+
return {
|
|
892
|
+
"downgraded_to_dbm": True,
|
|
893
|
+
"message": i18n._("pub_dbs_no_permission_downgrade"),
|
|
894
|
+
}
|
|
859
895
|
return None
|
|
860
896
|
|
|
861
897
|
# Exceeded DBM limits — check DBS permission
|
|
862
898
|
if has_dbs_permission:
|
|
863
899
|
# Auto-switch to DBS
|
|
864
|
-
|
|
900
|
+
_write_flag("DBS")
|
|
865
901
|
return {
|
|
866
902
|
"switched_to_dbs": True,
|
|
867
903
|
"reasons": exceeded,
|
|
@@ -902,11 +938,14 @@ class PublishModule:
|
|
|
902
938
|
seller_sku_required = self._field_is_required(
|
|
903
939
|
"SellerSku", category_attributes or []
|
|
904
940
|
)
|
|
905
|
-
_validate_create_payload(
|
|
941
|
+
_validate_create_payload(
|
|
942
|
+
product_payload, seller_sku_required=seller_sku_required, draft=draft
|
|
943
|
+
)
|
|
906
944
|
|
|
907
945
|
# Check DBM limits — auto-switch to DBS if exceeded (requires DBS permission)
|
|
908
946
|
has_dbs = self.product.get_shipping_permission()
|
|
909
947
|
dbm_result = self._check_dbm_limits(product_payload, has_dbs_permission=has_dbs)
|
|
948
|
+
dbm_notes: List[str] = []
|
|
910
949
|
if dbm_result:
|
|
911
950
|
if dbm_result.get("blocked"):
|
|
912
951
|
# No DBS permission and exceeds DBM limits — block
|
|
@@ -919,10 +958,12 @@ class PublishModule:
|
|
|
919
958
|
],
|
|
920
959
|
}
|
|
921
960
|
if dbm_result.get("switched_to_dbs"):
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
961
|
+
dbm_notes.append(dbm_result["message"])
|
|
962
|
+
dbm_notes.extend(f" - {r}" for r in dbm_result.get("reasons", []))
|
|
963
|
+
if dbm_result.get("downgraded_to_dbm"):
|
|
964
|
+
# Seller asked for DBS but has no DBS permission — explain the
|
|
965
|
+
# fallback so the delivery-method change is not silent.
|
|
966
|
+
dbm_notes.append(dbm_result["message"])
|
|
926
967
|
|
|
927
968
|
# Run pre-validate early so errors are visible in the preview
|
|
928
969
|
prevalidate_result = self._run_prevalidate(product_payload)
|
|
@@ -935,6 +976,7 @@ class PublishModule:
|
|
|
935
976
|
|
|
936
977
|
# Merge all issues for preview
|
|
937
978
|
all_issues = list(draft_issues or [])
|
|
979
|
+
all_issues.extend(dbm_notes)
|
|
938
980
|
if prevalidate_errors:
|
|
939
981
|
all_issues.extend(prevalidate_errors)
|
|
940
982
|
|
|
@@ -1340,11 +1382,59 @@ class PublishModule:
|
|
|
1340
1382
|
draft=draft_requested,
|
|
1341
1383
|
)
|
|
1342
1384
|
|
|
1385
|
+
# Explicit --attribute-data must reach the AI payload too (it used to be
|
|
1386
|
+
# honored only in the manual flow, so AI-mode values were silently
|
|
1387
|
+
# dropped). Applied before unmatched-column binding so explicit values win.
|
|
1388
|
+
self._apply_attribute_data_override(product_payload)
|
|
1389
|
+
|
|
1343
1390
|
# Bind extra Excel/CSV columns that the whitelist did not recognize,
|
|
1344
1391
|
# using the category attribute definitions fetched above.
|
|
1345
|
-
|
|
1392
|
+
unmapped_cols = self._apply_unmatched_category_attributes(
|
|
1346
1393
|
product_payload, schema, category_attributes
|
|
1347
1394
|
)
|
|
1395
|
+
self._unmapped_columns = unmapped_cols
|
|
1396
|
+
|
|
1397
|
+
# If a provided column could not be confidently matched but plausibly
|
|
1398
|
+
# maps to one or more category attributes, ask the seller to confirm the
|
|
1399
|
+
# mapping instead of silently dropping it (which would otherwise surface
|
|
1400
|
+
# later as a cryptic batchValidate "missing attribute" error).
|
|
1401
|
+
mapping_check = self._check_unmapped_columns(unmapped_cols, product_payload)
|
|
1402
|
+
if mapping_check:
|
|
1403
|
+
return mapping_check
|
|
1404
|
+
|
|
1405
|
+
# Safety warning: honor an explicit --safety-warning in the source flow.
|
|
1406
|
+
# build_create_product_payload defaults the flag to "No" and manual mode
|
|
1407
|
+
# applies the CLI flags at build time, but the Excel/source flow never did
|
|
1408
|
+
# — so --safety-warning was silently ignored here. Apply it now so the
|
|
1409
|
+
# seller's explicit choice is never overwritten by the default.
|
|
1410
|
+
self._apply_safety_cli_override(product_payload)
|
|
1411
|
+
|
|
1412
|
+
# Safety guidance gate: never silently ship an inferred "No". If the seller
|
|
1413
|
+
# has not chosen a value but the source carries real safety content, pause
|
|
1414
|
+
# and ask them to confirm yes/no (or complete the Excel). The flag can only
|
|
1415
|
+
# be auto-populated once the seller answers, per the "surface assumed
|
|
1416
|
+
# values, do not silently replace" rule.
|
|
1417
|
+
if getattr(self.args, "safety_warning", None) is None:
|
|
1418
|
+
safety_evidence = self._detect_safety_content(product_payload, schema)
|
|
1419
|
+
if safety_evidence:
|
|
1420
|
+
current_flag = (product_payload.get("Attributes") or {}).get(
|
|
1421
|
+
"Does_this_product_have_a_safety_warning"
|
|
1422
|
+
)
|
|
1423
|
+
return {
|
|
1424
|
+
"status": "need_safety_warning_confirm",
|
|
1425
|
+
"title": i18n._("pub_safety_confirm_title"),
|
|
1426
|
+
"message": i18n._(
|
|
1427
|
+
"pub_safety_confirm_message",
|
|
1428
|
+
evidence=", ".join(safety_evidence),
|
|
1429
|
+
),
|
|
1430
|
+
"detected_safety_content": safety_evidence,
|
|
1431
|
+
"current_value": current_flag,
|
|
1432
|
+
"next_actions": [
|
|
1433
|
+
i18n._("pub_safety_confirm_next_yes"),
|
|
1434
|
+
i18n._("pub_safety_confirm_next_no"),
|
|
1435
|
+
i18n._("pub_safety_confirm_next_excel"),
|
|
1436
|
+
],
|
|
1437
|
+
}
|
|
1348
1438
|
|
|
1349
1439
|
# Stage 3b: unified compliance check (GPSR + Safety Warning + compliance docs)
|
|
1350
1440
|
draft_issues: list = []
|
|
@@ -1506,8 +1596,8 @@ class PublishModule:
|
|
|
1506
1596
|
|
|
1507
1597
|
# Structural fields Miravia always requires for any product (not category
|
|
1508
1598
|
# attributes, so the API never governs them) — always blocking. NOTE:
|
|
1509
|
-
# delivery method (Attributes.
|
|
1510
|
-
# auto-defaulted (DBM, or DBS when the seller has DBS permission) rather
|
|
1599
|
+
# delivery method (Attributes.delivery_option_sof) is intentionally NOT here — it is
|
|
1600
|
+
# auto-defaulted (DBM=No, or DBS=Yes when the seller has DBS permission) rather
|
|
1511
1601
|
# than hard-collected; see _check_dbm_limits.
|
|
1512
1602
|
_CORE_MANDATORY_KEYS = frozenset(
|
|
1513
1603
|
{
|
|
@@ -1672,15 +1762,19 @@ class PublishModule:
|
|
|
1672
1762
|
"""
|
|
1673
1763
|
confirmed_brand_id = getattr(self.args, "confirmed_brand_id", None)
|
|
1674
1764
|
if confirmed_brand_id:
|
|
1765
|
+
# Honor an explicitly passed --brand-name too (aligns with the
|
|
1766
|
+
# manual flow's _resolve_brand_interactive). Otherwise a seller who
|
|
1767
|
+
# confirms by ID but whose source lacks the brand name would have
|
|
1768
|
+
# the brand silently dropped from the payload.
|
|
1675
1769
|
return {
|
|
1676
1770
|
"brand_id": int(confirmed_brand_id),
|
|
1677
|
-
"brand_name": schema.brand_name or "",
|
|
1771
|
+
"brand_name": getattr(self.args, "brand_name", None) or schema.brand_name or "",
|
|
1678
1772
|
}
|
|
1679
1773
|
|
|
1680
1774
|
if schema.brand_id:
|
|
1681
1775
|
return {
|
|
1682
1776
|
"brand_id": int(schema.brand_id),
|
|
1683
|
-
"brand_name": schema.brand_name or "",
|
|
1777
|
+
"brand_name": getattr(self.args, "brand_name", None) or schema.brand_name or "",
|
|
1684
1778
|
}
|
|
1685
1779
|
|
|
1686
1780
|
brand_name = self.args.brand_name or schema.brand_name
|
|
@@ -2013,8 +2107,8 @@ class PublishModule:
|
|
|
2013
2107
|
# jeans, so an empty EAN must NOT be flagged as missing.
|
|
2014
2108
|
api_optional: List[str] = []
|
|
2015
2109
|
|
|
2016
|
-
# Delivery method (Attributes.
|
|
2017
|
-
# Miravia defaults it when absent (DBM, or DBS if the seller has DBS
|
|
2110
|
+
# Delivery method (Attributes.delivery_option_sof) is NOT a hard-collect field:
|
|
2111
|
+
# Miravia defaults it when absent (DBM=No, or DBS=Yes if the seller has DBS
|
|
2018
2112
|
# permission — see _check_dbm_limits), so an empty value shows as
|
|
2019
2113
|
# "Optional" here and never blocks.
|
|
2020
2114
|
if not (getattr(schema, "delivery_method", None) or "").strip():
|
|
@@ -2179,8 +2273,9 @@ class PublishModule:
|
|
|
2179
2273
|
value under the attribute's canonical key — into each SKU for
|
|
2180
2274
|
``attribute_type == 'sku'``, otherwise into the product-level
|
|
2181
2275
|
``Attributes``. Existing keys are never overwritten (whitelist/LLM
|
|
2182
|
-
values win). Returns
|
|
2183
|
-
|
|
2276
|
+
values win). Returns a list of ``{header, value, candidates}`` for the
|
|
2277
|
+
headers that matched no category attribute confidently, so the caller can
|
|
2278
|
+
ask the seller to confirm the mapping instead of silently dropping them.
|
|
2184
2279
|
|
|
2185
2280
|
We intentionally bind ONLY the non-whitelisted columns here. Columns
|
|
2186
2281
|
the whitelist already consumed (Title/Price/Image/Color/Stock/...) are
|
|
@@ -2196,11 +2291,31 @@ class PublishModule:
|
|
|
2196
2291
|
return []
|
|
2197
2292
|
attributes = product_payload.setdefault("Attributes", {})
|
|
2198
2293
|
skus = (product_payload.get("Skus") or {}).get("Sku") or []
|
|
2199
|
-
unmapped: List[str] = []
|
|
2294
|
+
unmapped: List[Dict[str, Any]] = []
|
|
2200
2295
|
for header, value in unmatched.items():
|
|
2201
2296
|
attr = self._find_category_attribute(category_attributes, (header,))
|
|
2202
2297
|
if not attr:
|
|
2203
|
-
|
|
2298
|
+
# No confident match: collect the header with fuzzy candidates so
|
|
2299
|
+
# the caller can ask the seller to confirm the intended attribute
|
|
2300
|
+
# instead of silently dropping the column.
|
|
2301
|
+
unmapped.append(
|
|
2302
|
+
{
|
|
2303
|
+
"header": header,
|
|
2304
|
+
"value": value,
|
|
2305
|
+
"candidates": [
|
|
2306
|
+
{
|
|
2307
|
+
"name": c.get("name"),
|
|
2308
|
+
"en_name": c.get("en_name"),
|
|
2309
|
+
"label": c.get("label") or c.get("en_name") or c.get("name"),
|
|
2310
|
+
"attribute_type": c.get("attribute_type") or "normal",
|
|
2311
|
+
"is_mandatory": str(c.get("is_mandatory", "0")),
|
|
2312
|
+
}
|
|
2313
|
+
for c in self._suggest_attribute_candidates(
|
|
2314
|
+
header, category_attributes
|
|
2315
|
+
)
|
|
2316
|
+
],
|
|
2317
|
+
}
|
|
2318
|
+
)
|
|
2204
2319
|
continue
|
|
2205
2320
|
key = attr.get("name") or attr.get("en_name") or header
|
|
2206
2321
|
if (attr.get("attribute_type") or "normal") == "sku":
|
|
@@ -2212,11 +2327,122 @@ class PublishModule:
|
|
|
2212
2327
|
attributes[key] = value
|
|
2213
2328
|
if unmapped:
|
|
2214
2329
|
logger.info(
|
|
2215
|
-
"Excel/CSV columns not
|
|
2216
|
-
", ".join(unmapped),
|
|
2330
|
+
"Excel/CSV columns not confidently matched: %s",
|
|
2331
|
+
", ".join(u["header"] for u in unmapped),
|
|
2217
2332
|
)
|
|
2218
2333
|
return unmapped
|
|
2219
2334
|
|
|
2335
|
+
def _suggest_attribute_candidates(
|
|
2336
|
+
self,
|
|
2337
|
+
header: str,
|
|
2338
|
+
category_attributes: List[Dict[str, Any]],
|
|
2339
|
+
max_candidates: int = 8,
|
|
2340
|
+
) -> List[Dict[str, Any]]:
|
|
2341
|
+
"""Fuzzy-suggest category attributes a free-text column header might map
|
|
2342
|
+
to — for the confirmation prompt only, never auto-bound.
|
|
2343
|
+
|
|
2344
|
+
Uses a shared-prefix (stem) heuristic so e.g. header ``packaging``
|
|
2345
|
+
suggests ``Package_Label_Photo_EU``. Deliberately looser than
|
|
2346
|
+
``_find_category_attribute`` because a human confirms the result.
|
|
2347
|
+
"""
|
|
2348
|
+
def _words(text: Any) -> List[str]:
|
|
2349
|
+
return [w for w in re.split(r"[^a-z0-9]+", str(text).lower()) if len(w) >= 4]
|
|
2350
|
+
|
|
2351
|
+
header_words = set(_words(header))
|
|
2352
|
+
h_sq = self._squash_attr_name(header)
|
|
2353
|
+
if h_sq:
|
|
2354
|
+
header_words.add(h_sq)
|
|
2355
|
+
out: List[Dict[str, Any]] = []
|
|
2356
|
+
for attr in category_attributes or []:
|
|
2357
|
+
attr_words: List[str] = []
|
|
2358
|
+
for key in ("name", "en_name", "label", "attribute_name"):
|
|
2359
|
+
if attr.get(key):
|
|
2360
|
+
attr_words.extend(_words(attr.get(key)))
|
|
2361
|
+
attr_words.append(self._squash_attr_name(attr.get(key)))
|
|
2362
|
+
hit = False
|
|
2363
|
+
for hw in header_words:
|
|
2364
|
+
for aw in attr_words:
|
|
2365
|
+
if (
|
|
2366
|
+
len(hw) >= 5
|
|
2367
|
+
and len(aw) >= 5
|
|
2368
|
+
and hw[:5] == aw[:5]
|
|
2369
|
+
) or hw in aw or aw in hw:
|
|
2370
|
+
hit = True
|
|
2371
|
+
break
|
|
2372
|
+
if hit:
|
|
2373
|
+
break
|
|
2374
|
+
if hit:
|
|
2375
|
+
out.append(attr)
|
|
2376
|
+
if len(out) >= max_candidates:
|
|
2377
|
+
break
|
|
2378
|
+
return out
|
|
2379
|
+
|
|
2380
|
+
def _apply_attribute_data_override(self, product_payload: Dict[str, Any]) -> None:
|
|
2381
|
+
"""Merge ``--attribute-data`` into the AI-built payload.
|
|
2382
|
+
|
|
2383
|
+
The AI flow builds Attributes/Skus from the extracted schema and never
|
|
2384
|
+
consulted ``--attribute-data``, so values passed there were silently
|
|
2385
|
+
dropped. ``--attribute-data`` is the seller's own explicit input, so it
|
|
2386
|
+
overrides extracted values; SKU-level keys are merged into every SKU.
|
|
2387
|
+
"""
|
|
2388
|
+
raw = getattr(self.args, "attribute_data", None)
|
|
2389
|
+
if not raw:
|
|
2390
|
+
return
|
|
2391
|
+
try:
|
|
2392
|
+
data = json.loads(raw)
|
|
2393
|
+
except json.JSONDecodeError as exc:
|
|
2394
|
+
raise ValueError(f"--attribute-data is not valid JSON: {exc}") from exc
|
|
2395
|
+
if not isinstance(data, dict):
|
|
2396
|
+
raise ValueError("--attribute-data must be a JSON object")
|
|
2397
|
+
attributes = product_payload.setdefault("Attributes", {})
|
|
2398
|
+
skus = (product_payload.get("Skus") or {}).get("Sku") or []
|
|
2399
|
+
for key, value in data.items():
|
|
2400
|
+
if key in _SKU_LEVEL_ATTRIBUTE_NAMES:
|
|
2401
|
+
for sku in skus:
|
|
2402
|
+
sku[key] = value
|
|
2403
|
+
else:
|
|
2404
|
+
attributes[key] = value
|
|
2405
|
+
|
|
2406
|
+
def _check_unmapped_columns(
|
|
2407
|
+
self,
|
|
2408
|
+
unmapped_cols: List[Dict[str, Any]],
|
|
2409
|
+
product_payload: Dict[str, Any],
|
|
2410
|
+
) -> Optional[Dict[str, Any]]:
|
|
2411
|
+
"""Return a ``need_attribute_mapping`` prompt when an unmapped column
|
|
2412
|
+
looks like a category attribute the seller intended, so they confirm the
|
|
2413
|
+
mapping instead of us dropping it silently (which would later surface as
|
|
2414
|
+
a cryptic batchValidate "missing attribute" error). Columns with no
|
|
2415
|
+
plausible candidate — or whose candidates are already filled via
|
|
2416
|
+
``--attribute-data`` — are ignored.
|
|
2417
|
+
"""
|
|
2418
|
+
attributes = product_payload.get("Attributes") or {}
|
|
2419
|
+
skus = (product_payload.get("Skus") or {}).get("Sku") or []
|
|
2420
|
+
|
|
2421
|
+
def _filled(cand: Dict[str, Any]) -> bool:
|
|
2422
|
+
names = [cand.get("name"), cand.get("en_name")]
|
|
2423
|
+
|
|
2424
|
+
def _present(container: Dict[str, Any]) -> bool:
|
|
2425
|
+
return any(n and str(container.get(n) or "").strip() for n in names)
|
|
2426
|
+
|
|
2427
|
+
if (cand.get("attribute_type") or "normal") == "sku":
|
|
2428
|
+
return bool(skus) and all(_present(s) for s in skus)
|
|
2429
|
+
return _present(attributes)
|
|
2430
|
+
|
|
2431
|
+
need: List[Dict[str, Any]] = []
|
|
2432
|
+
for item in unmapped_cols or []:
|
|
2433
|
+
open_cands = [c for c in (item.get("candidates") or []) if not _filled(c)]
|
|
2434
|
+
if open_cands:
|
|
2435
|
+
need.append({**item, "candidates": open_cands})
|
|
2436
|
+
if not need:
|
|
2437
|
+
return None
|
|
2438
|
+
return {
|
|
2439
|
+
"status": "need_attribute_mapping",
|
|
2440
|
+
"title": i18n._("pub_need_attribute_mapping_title"),
|
|
2441
|
+
"message": i18n._("pub_need_attribute_mapping_description"),
|
|
2442
|
+
"unmapped_columns": need,
|
|
2443
|
+
"next_actions": [i18n._("pub_need_attribute_mapping_next")],
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2220
2446
|
def _field_is_required(
|
|
2221
2447
|
self,
|
|
2222
2448
|
field_key: str,
|
|
@@ -2503,7 +2729,7 @@ class PublishModule:
|
|
|
2503
2729
|
confirmed_brand_id = getattr(self.args, "confirmed_brand_id", None)
|
|
2504
2730
|
if confirmed_brand_id:
|
|
2505
2731
|
return {
|
|
2506
|
-
"brand_name": schema.brand_name or "",
|
|
2732
|
+
"brand_name": getattr(self.args, "brand_name", None) or schema.brand_name or "",
|
|
2507
2733
|
"brand_id": int(confirmed_brand_id),
|
|
2508
2734
|
}
|
|
2509
2735
|
|
|
@@ -2642,7 +2868,11 @@ class PublishModule:
|
|
|
2642
2868
|
|
|
2643
2869
|
Per the Miravia ``/product/create`` documentation:
|
|
2644
2870
|
|
|
2645
|
-
- ``SellerSku`` is mandatory
|
|
2871
|
+
- ``SellerSku`` is NOT hard-coded mandatory: whether it must be filled is
|
|
2872
|
+
governed by the category attribute API and enforced once in
|
|
2873
|
+
:meth:`_validate_create_payload` (via ``seller_sku_required``), which
|
|
2874
|
+
applies to both Draft and online publishes. Here we simply omit an
|
|
2875
|
+
empty SellerSku so ``/product/create`` can auto-generate a value.
|
|
2646
2876
|
- Package dimensions/weight are per-SKU fields
|
|
2647
2877
|
(``package_length/width/height/weight``).
|
|
2648
2878
|
- Sales properties are flat SKU fields, not a nested ``Variation``
|
|
@@ -2671,11 +2901,12 @@ class PublishModule:
|
|
|
2671
2901
|
seller_sku = raw.get("seller_sku") or (
|
|
2672
2902
|
schema.seller_skus[idx] if idx < len(schema.seller_skus) else None
|
|
2673
2903
|
)
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2904
|
+
# Do NOT hard-require SellerSku here. Its mandatory-ness comes from
|
|
2905
|
+
# the category attribute API and is enforced in _validate_create_payload
|
|
2906
|
+
# (draft-aware: a live publish blocks an interface-mandatory empty value;
|
|
2907
|
+
# a draft defers it). Omit empty values so create can auto-generate.
|
|
2908
|
+
if seller_sku:
|
|
2909
|
+
sku["SellerSku"] = seller_sku
|
|
2679
2910
|
|
|
2680
2911
|
ean_code = raw.get("ean_code") or (
|
|
2681
2912
|
schema.ean_codes[idx] if idx < len(schema.ean_codes) else None
|
|
@@ -2721,6 +2952,25 @@ class PublishModule:
|
|
|
2721
2952
|
if sku_images:
|
|
2722
2953
|
sku["Images"] = {"Image": sku_images}
|
|
2723
2954
|
|
|
2955
|
+
# Country-specific prices live INSIDE each SKU (per the
|
|
2956
|
+
# authoritative /product/create contract), with capitalised keys
|
|
2957
|
+
# ``CountryCode`` / ``Price`` / ``SalePrice``. ``country_general_prices``
|
|
2958
|
+
# supplies the per-country Price and ``country_discounted_prices``
|
|
2959
|
+
# the per-country SalePrice. A product-level list or the old
|
|
2960
|
+
# lowercase ``{"country","price"}`` shape is silently ignored.
|
|
2961
|
+
_cgp = schema.country_general_prices or {}
|
|
2962
|
+
_cdp = schema.country_discounted_prices or {}
|
|
2963
|
+
_country_price = []
|
|
2964
|
+
for _cc in sorted(set(_cgp) | set(_cdp)):
|
|
2965
|
+
_entry: Dict[str, Any] = {"CountryCode": _cc}
|
|
2966
|
+
if _cgp.get(_cc) not in (None, ""):
|
|
2967
|
+
_entry["Price"] = str(_cgp[_cc])
|
|
2968
|
+
if _cdp.get(_cc) not in (None, ""):
|
|
2969
|
+
_entry["SalePrice"] = str(_cdp[_cc])
|
|
2970
|
+
_country_price.append(_entry)
|
|
2971
|
+
if _country_price:
|
|
2972
|
+
sku["CountryPrice"] = _country_price
|
|
2973
|
+
|
|
2724
2974
|
skus.append(sku)
|
|
2725
2975
|
|
|
2726
2976
|
if len(skus) > _MAX_SKUS_PER_PRODUCT:
|
|
@@ -2729,6 +2979,84 @@ class PublishModule:
|
|
|
2729
2979
|
)
|
|
2730
2980
|
return skus
|
|
2731
2981
|
|
|
2982
|
+
# Boilerplate default warning text — must NOT be treated as real safety content.
|
|
2983
|
+
_SAFETY_CONTENT_DEFAULT = "no specific safety warnings apply to this product"
|
|
2984
|
+
|
|
2985
|
+
def _apply_safety_cli_override(self, product_payload: Dict[str, Any]) -> None:
|
|
2986
|
+
"""Honor ``--safety-warning`` / ``--safety-warning-content`` in the source flow.
|
|
2987
|
+
|
|
2988
|
+
``build_create_product_payload`` defaults ``Does_this_product_have_a_safety_warning``
|
|
2989
|
+
to "No" when ``schema.safety_warning`` is falsy, and manual mode applies the CLI
|
|
2990
|
+
flags at build time (see the manual path). The Excel/source flow never applied
|
|
2991
|
+
them, so ``--safety-warning`` was silently ignored there. Write the seller's
|
|
2992
|
+
explicit choice here using the same "Yes"/"No" casing the client already emits,
|
|
2993
|
+
so the platform contract is unchanged and only the previously-ignored flag is
|
|
2994
|
+
now respected.
|
|
2995
|
+
"""
|
|
2996
|
+
safety_arg = getattr(self.args, "safety_warning", None)
|
|
2997
|
+
if safety_arg is None:
|
|
2998
|
+
return
|
|
2999
|
+
is_yes = str(safety_arg).strip().lower() in ("yes", "1", "true", "y")
|
|
3000
|
+
attributes = product_payload.setdefault("Attributes", {})
|
|
3001
|
+
value = "Yes" if is_yes else "No"
|
|
3002
|
+
attributes["Does this product have a safety warning"] = value
|
|
3003
|
+
attributes["Does_this_product_have_a_safety_warning"] = value
|
|
3004
|
+
content = getattr(self.args, "safety_warning_content", None)
|
|
3005
|
+
if is_yes and content:
|
|
3006
|
+
attributes["Safety_warning_content"] = content
|
|
3007
|
+
|
|
3008
|
+
def _detect_safety_content(
|
|
3009
|
+
self, product_payload: Dict[str, Any], schema: MiraviaListingSchema
|
|
3010
|
+
) -> List[str]:
|
|
3011
|
+
"""Return human-readable evidence that the product has safety info.
|
|
3012
|
+
|
|
3013
|
+
Used to decide whether the auto-defaulted safety flag should be surfaced
|
|
3014
|
+
to the seller instead of shipped silently. Ignores the boilerplate default
|
|
3015
|
+
warning text and a plain "No" flag so we do not raise false prompts. Counts
|
|
3016
|
+
real safety artifacts that came from the source: safety documents
|
|
3017
|
+
(Safety_Date_Sheet / CPSR / CPNP URLs), an affirmative Hazmat flag, a
|
|
3018
|
+
non-default ``Safety_warning_content`` already present, or an explicit
|
|
3019
|
+
``Does_this_product_have_a_safety_warning=Yes``.
|
|
3020
|
+
"""
|
|
3021
|
+
attributes = product_payload.get("Attributes") or {}
|
|
3022
|
+
evidence: List[str] = []
|
|
3023
|
+
for key, val in attributes.items():
|
|
3024
|
+
if not isinstance(val, str) or not val.strip():
|
|
3025
|
+
continue
|
|
3026
|
+
low = key.lower()
|
|
3027
|
+
# Skip the yes/no flag itself (handled explicitly below).
|
|
3028
|
+
if low in (
|
|
3029
|
+
"does this product have a safety warning",
|
|
3030
|
+
"does_this_product_have_a_safety_warning",
|
|
3031
|
+
):
|
|
3032
|
+
if val.strip().lower() == "yes":
|
|
3033
|
+
evidence.append(f"{key}=Yes")
|
|
3034
|
+
continue
|
|
3035
|
+
if not any(tok in low for tok in ("safety", "cpsr", "cpnp", "hazmat", "warning")):
|
|
3036
|
+
continue
|
|
3037
|
+
# Boilerplate default content is not evidence.
|
|
3038
|
+
if self._SAFETY_CONTENT_DEFAULT in val.strip().lower():
|
|
3039
|
+
continue
|
|
3040
|
+
# A negative/empty Hazmat flag is not evidence.
|
|
3041
|
+
if "hazmat" in low and val.strip().lower() in ("no", "false", "0", "n/a", "-"):
|
|
3042
|
+
continue
|
|
3043
|
+
# Null-like placeholder strings from empty cells are not evidence.
|
|
3044
|
+
if val.strip().lower() in ("none", "null", "n/a", "na", "-"):
|
|
3045
|
+
continue
|
|
3046
|
+
display = val if len(val) <= 60 else val[:57] + "..."
|
|
3047
|
+
evidence.append(f"{key}={display}")
|
|
3048
|
+
sc = getattr(schema, "safety_warning_content", None)
|
|
3049
|
+
if sc and self._SAFETY_CONTENT_DEFAULT not in str(sc).strip().lower():
|
|
3050
|
+
evidence.append("safety_warning_content provided")
|
|
3051
|
+
# Deduplicate while preserving order.
|
|
3052
|
+
seen: set = set()
|
|
3053
|
+
uniq: List[str] = []
|
|
3054
|
+
for item in evidence:
|
|
3055
|
+
if item not in seen:
|
|
3056
|
+
seen.add(item)
|
|
3057
|
+
uniq.append(item)
|
|
3058
|
+
return uniq
|
|
3059
|
+
|
|
2732
3060
|
def _run_prevalidate(
|
|
2733
3061
|
self, product_payload: Dict[str, Any]
|
|
2734
3062
|
) -> Optional[Dict[str, Any]]:
|
|
@@ -3186,10 +3514,11 @@ class PublishModule:
|
|
|
3186
3514
|
processed["SellerSku"] = processed.pop("seller_sku")
|
|
3187
3515
|
elif "seller_sku" in processed:
|
|
3188
3516
|
processed.pop("seller_sku") # SellerSku already set
|
|
3517
|
+
# SellerSku is optional (governed by the category attribute API and
|
|
3518
|
+
# enforced in _validate_create_payload, not relaxed by draft). When
|
|
3519
|
+
# empty, drop the key so /product/create can auto-generate it.
|
|
3189
3520
|
if not processed.get("SellerSku"):
|
|
3190
|
-
|
|
3191
|
-
i18n._("pub_seller_sku_required", idx=idx + 1)
|
|
3192
|
-
)
|
|
3521
|
+
processed.pop("SellerSku", None)
|
|
3193
3522
|
# Migrate SKU-level images
|
|
3194
3523
|
sku_images = processed.get("Images", {}).get("Image", [])
|
|
3195
3524
|
if sku_images:
|
|
@@ -3333,6 +3662,7 @@ def _validate_title(title: str) -> None:
|
|
|
3333
3662
|
def _validate_create_payload(
|
|
3334
3663
|
payload: Dict[str, Any],
|
|
3335
3664
|
seller_sku_required: bool = False,
|
|
3665
|
+
draft: bool = False,
|
|
3336
3666
|
) -> None:
|
|
3337
3667
|
"""Validate a Miravia ``/product/create`` Product payload.
|
|
3338
3668
|
|
|
@@ -3342,7 +3672,9 @@ def _validate_create_payload(
|
|
|
3342
3672
|
|
|
3343
3673
|
``seller_sku_required`` is decided by the caller from the category attribute
|
|
3344
3674
|
API (``_field_is_required``): when False, an empty SellerSku is allowed and
|
|
3345
|
-
the create API auto-generates a unique value.
|
|
3675
|
+
the create API auto-generates a unique value. When True but ``draft`` is set,
|
|
3676
|
+
the empty value is deferred to the draft (surfaced as a draft_issue) rather
|
|
3677
|
+
than hard-blocked, consistent with P7 — only a live publish enforces it.
|
|
3346
3678
|
"""
|
|
3347
3679
|
attributes = payload.get("Attributes") or {}
|
|
3348
3680
|
title = attributes.get("name")
|
|
@@ -3386,7 +3718,11 @@ def _validate_create_payload(
|
|
|
3386
3718
|
raise ValueError(i18n._("pub_seller_sku_duplicate_in_product"))
|
|
3387
3719
|
|
|
3388
3720
|
for idx, sku in enumerate(skus, start=1):
|
|
3389
|
-
if
|
|
3721
|
+
if (
|
|
3722
|
+
seller_sku_required
|
|
3723
|
+
and not draft
|
|
3724
|
+
and not (sku.get("SellerSku") or sku.get("seller_sku"))
|
|
3725
|
+
):
|
|
3390
3726
|
raise ValueError(i18n._("pub_seller_sku_required", idx=idx))
|
|
3391
3727
|
|
|
3392
3728
|
price = sku.get("price")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miravia_test/miravia-publish-skill",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "Miravia Product Publish Skill installer — one command to install the Miravia AI product listing skill into any AI Agent (Qoder / Cursor / Claude Code). Upload materials and let the AI extract data and publish products automatically.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"miravia-publish-skill": "bin/cli.js"
|