@miravia_test/miravia-publish-skill 0.1.2 → 0.1.4
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 +23 -9
- package/dist/TECHNICAL.md +1 -1
- package/dist/VERSION +1 -1
- package/dist/reference_publish.md +2 -2
- package/dist/scripts/cli_common.py +2 -2
- package/dist/scripts/{modules/extraction_engine.py → extraction_engine.py} +15 -6
- package/dist/scripts/field_definitions.py +3 -3
- package/dist/scripts/{modules/file_reader.py → file_reader.py} +84 -16
- package/dist/scripts/i18n.py +158 -5
- package/dist/scripts/miravia_client.py +1 -0
- package/dist/scripts/product_client.py +25 -0
- package/dist/scripts/product_skill.py +258 -8
- package/dist/scripts/{modules/publish_module.py → publish_module.py} +1151 -132
- package/package.json +1 -1
- package/dist/scripts/modules/__init__.py +0 -1
|
@@ -29,7 +29,7 @@ import telemetry
|
|
|
29
29
|
from miravia_client import MiraviaClient, MiraviaError
|
|
30
30
|
from product_client import ProductClient, _normalize_variation_name
|
|
31
31
|
|
|
32
|
-
from
|
|
32
|
+
from extraction_engine import (
|
|
33
33
|
CRITICAL_FIELDS,
|
|
34
34
|
ExtractionEngine,
|
|
35
35
|
LLMClient,
|
|
@@ -37,7 +37,7 @@ from modules.extraction_engine import (
|
|
|
37
37
|
check_missing,
|
|
38
38
|
validate_title,
|
|
39
39
|
)
|
|
40
|
-
from
|
|
40
|
+
from file_reader import extract_text, read_sources, get_excel_row_count
|
|
41
41
|
|
|
42
42
|
logger = logging.getLogger("miravia.product.publish")
|
|
43
43
|
|
|
@@ -67,6 +67,11 @@ _MAX_DIMENSION_CM = 300
|
|
|
67
67
|
_MAX_IMAGE_SIZE_MB = 5
|
|
68
68
|
_VALID_IMAGE_FORMATS = {".jpg", ".jpeg", ".png", ".webp"}
|
|
69
69
|
|
|
70
|
+
# DBM (Drop by Miravia) limits — packages exceeding these auto-switch to DBS
|
|
71
|
+
_DBM_MAX_WEIGHT_KG = 40
|
|
72
|
+
_DBM_MAX_DIM_SUM_CM = 300
|
|
73
|
+
_DBM_MAX_VOLUME_WEIGHT = 40 # L*W*H/6000
|
|
74
|
+
|
|
70
75
|
|
|
71
76
|
_BRAND_PERMISSION_CODES = frozenset(("BRAND_PENDING", "BRAND_PERMISSION_DENIED"))
|
|
72
77
|
|
|
@@ -151,10 +156,25 @@ class PublishModule:
|
|
|
151
156
|
category_id = self.args.category_id
|
|
152
157
|
product_name = self.args.product_name
|
|
153
158
|
if not category_id:
|
|
154
|
-
|
|
159
|
+
return {
|
|
160
|
+
"status": "need_category",
|
|
161
|
+
"message": i18n._("pub_create_requires_category"),
|
|
162
|
+
"next_actions": [i18n._("pub_ai_post_provide_category")],
|
|
163
|
+
}
|
|
155
164
|
if not product_name:
|
|
156
|
-
|
|
157
|
-
|
|
165
|
+
return {
|
|
166
|
+
"status": "title_invalid",
|
|
167
|
+
"message": i18n._("pub_create_requires_name"),
|
|
168
|
+
"next_actions": [i18n._("pub_ai_post_requires_source")],
|
|
169
|
+
}
|
|
170
|
+
title_err = validate_title(product_name)
|
|
171
|
+
if title_err:
|
|
172
|
+
return {
|
|
173
|
+
"status": "title_invalid",
|
|
174
|
+
"message": title_err,
|
|
175
|
+
"format_suggestion": i18n._("pub_title_format_suggestion"),
|
|
176
|
+
"next_actions": [i18n._("pub_ai_post_requires_source")],
|
|
177
|
+
}
|
|
158
178
|
|
|
159
179
|
brand_resolution = self._resolve_brand_interactive()
|
|
160
180
|
if brand_resolution.get("status") == "need_brand":
|
|
@@ -273,6 +293,10 @@ class PublishModule:
|
|
|
273
293
|
if skus:
|
|
274
294
|
product_payload["Skus"] = {"Sku": skus}
|
|
275
295
|
|
|
296
|
+
# Draft mode — set Status to inactive
|
|
297
|
+
if draft_requested:
|
|
298
|
+
product_payload["Status"] = "inactive"
|
|
299
|
+
|
|
276
300
|
# Inject safety warning from CLI flags into Attributes
|
|
277
301
|
safety_warning_arg = getattr(self.args, "safety_warning", None)
|
|
278
302
|
if safety_warning_arg is not None:
|
|
@@ -398,7 +422,27 @@ class PublishModule:
|
|
|
398
422
|
for m in gpsr_missing:
|
|
399
423
|
draft_issues.append(f"NOTE: {m}")
|
|
400
424
|
|
|
401
|
-
#
|
|
425
|
+
# Stage 3b: unified compliance check (Safety Warning + compliance docs)
|
|
426
|
+
compliance_check = self._check_compliance_fields(
|
|
427
|
+
product_payload,
|
|
428
|
+
category_id=category_id,
|
|
429
|
+
draft_requested=draft_requested,
|
|
430
|
+
draft_issues=draft_issues,
|
|
431
|
+
)
|
|
432
|
+
if compliance_check:
|
|
433
|
+
return compliance_check
|
|
434
|
+
|
|
435
|
+
# Stage 3c: special category rules (PPU / ISBN / food attributes)
|
|
436
|
+
special_check = self._check_special_category_rules(
|
|
437
|
+
product_payload,
|
|
438
|
+
category_id=category_id,
|
|
439
|
+
draft_requested=draft_requested,
|
|
440
|
+
draft_issues=draft_issues,
|
|
441
|
+
)
|
|
442
|
+
if special_check:
|
|
443
|
+
return special_check
|
|
444
|
+
|
|
445
|
+
# Check postToInactive fields (EAN only; PPU/food moved to Stage 3c)
|
|
402
446
|
post_to_inactive_result = self._check_post_to_inactive_fields(
|
|
403
447
|
product_payload, str(category_id), draft_requested, draft_issues
|
|
404
448
|
)
|
|
@@ -450,37 +494,6 @@ class PublishModule:
|
|
|
450
494
|
"Please fill it or publish to Draft."
|
|
451
495
|
)
|
|
452
496
|
|
|
453
|
-
# Check compliance_documents
|
|
454
|
-
compliance_docs = getattr(self.args, "compliance_documents", None) or []
|
|
455
|
-
compliance_mandatory = self._is_category_attribute_mandatory(
|
|
456
|
-
definitions, "compliance_documents"
|
|
457
|
-
)
|
|
458
|
-
if compliance_mandatory and not compliance_docs:
|
|
459
|
-
post_to_inactive_missing.append(
|
|
460
|
-
"Compliance documents are required for this category but not filled. "
|
|
461
|
-
"Please fill them or publish to Draft."
|
|
462
|
-
)
|
|
463
|
-
|
|
464
|
-
# Check unit_count - mandatory for nail polish category
|
|
465
|
-
unit_count = getattr(self.args, "unit_count", None)
|
|
466
|
-
unit_count_mandatory = self._is_category_attribute_mandatory(
|
|
467
|
-
definitions, "unit_count"
|
|
468
|
-
)
|
|
469
|
-
if unit_count_mandatory and not unit_count:
|
|
470
|
-
post_to_inactive_missing.append(
|
|
471
|
-
"Unit count is required for this category but not filled. "
|
|
472
|
-
"Please fill it or publish to Draft."
|
|
473
|
-
)
|
|
474
|
-
|
|
475
|
-
# Check food_attributes - mandatory for food category
|
|
476
|
-
food_attrs = getattr(self.args, "sku_attributes_food_health", None) or {}
|
|
477
|
-
food_mandatory = self._category_looks_like_food_health(definitions)
|
|
478
|
-
if food_mandatory and not food_attrs:
|
|
479
|
-
post_to_inactive_missing.append(
|
|
480
|
-
"Food & health attributes are required for this category but not filled. "
|
|
481
|
-
"Please fill them or publish to Draft."
|
|
482
|
-
)
|
|
483
|
-
|
|
484
497
|
# Handle postToInactive missing fields
|
|
485
498
|
if post_to_inactive_missing:
|
|
486
499
|
if not draft_requested:
|
|
@@ -499,6 +512,354 @@ class PublishModule:
|
|
|
499
512
|
|
|
500
513
|
return None
|
|
501
514
|
|
|
515
|
+
def _check_compliance_fields(
|
|
516
|
+
self,
|
|
517
|
+
product_payload: Dict[str, Any],
|
|
518
|
+
category_attributes: Optional[List[Dict[str, Any]]] = None,
|
|
519
|
+
*,
|
|
520
|
+
category_id: Optional[Any] = None,
|
|
521
|
+
schema: Optional[Any] = None,
|
|
522
|
+
draft_requested: bool = False,
|
|
523
|
+
draft_issues: Optional[List[str]] = None,
|
|
524
|
+
) -> Optional[Dict[str, Any]]:
|
|
525
|
+
"""Unified compliance validation after payload is built.
|
|
526
|
+
|
|
527
|
+
Checks:
|
|
528
|
+
1. GPSR (manufacturer + EU responsible) — for AI flow
|
|
529
|
+
2. Safety Warning linkage (Yes → content required)
|
|
530
|
+
3. Compliance documents (category-mandatory → required)
|
|
531
|
+
|
|
532
|
+
Returns a ``compliance_missing_suggest_draft`` response when any
|
|
533
|
+
compliance field is missing, or ``None`` when all are satisfied.
|
|
534
|
+
"""
|
|
535
|
+
compliance_issues: List[str] = []
|
|
536
|
+
attributes = product_payload.get("Attributes") or {}
|
|
537
|
+
|
|
538
|
+
# Fetch category attributes if not provided (manual flow)
|
|
539
|
+
if category_attributes is None and category_id is not None:
|
|
540
|
+
lang = self.args.lang if self.args.lang not in (None, "auto") else "en_US"
|
|
541
|
+
try:
|
|
542
|
+
category_attributes = self.product.get_category_attributes(
|
|
543
|
+
str(category_id), language_code=lang
|
|
544
|
+
)
|
|
545
|
+
except Exception:
|
|
546
|
+
category_attributes = []
|
|
547
|
+
if category_attributes is None:
|
|
548
|
+
category_attributes = []
|
|
549
|
+
|
|
550
|
+
# --- 1. GPSR check (mainly for AI flow; manual flow has inline check) ---
|
|
551
|
+
mfr_name = ""
|
|
552
|
+
eurp_name = ""
|
|
553
|
+
if schema:
|
|
554
|
+
mfr_name = schema.manufacturer_name or ""
|
|
555
|
+
eurp_name = schema.eu_responsible_person_name or ""
|
|
556
|
+
else:
|
|
557
|
+
mfr_name = getattr(self.args, "manufacturer_name", None) or ""
|
|
558
|
+
eurp_name = getattr(self.args, "eu_responsible_person_name", None) or ""
|
|
559
|
+
|
|
560
|
+
if not mfr_name and not eurp_name:
|
|
561
|
+
compliance_issues.append(
|
|
562
|
+
i18n._("pub_compliance_manufacturer_eurp_both_empty")
|
|
563
|
+
)
|
|
564
|
+
elif mfr_name and not eurp_name:
|
|
565
|
+
compliance_issues.append(
|
|
566
|
+
i18n._("pub_compliance_eurp_missing_with_manufacturer")
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
# --- 2. Safety Warning linkage ---
|
|
570
|
+
safety_val = (
|
|
571
|
+
attributes.get("Does this product have a safety warning")
|
|
572
|
+
or attributes.get("Does_this_product_have_a_safety_warning")
|
|
573
|
+
or ""
|
|
574
|
+
)
|
|
575
|
+
if safety_val == "Yes":
|
|
576
|
+
safety_content = attributes.get("Safety_warning_content") or ""
|
|
577
|
+
if not safety_content.strip():
|
|
578
|
+
compliance_issues.append(
|
|
579
|
+
i18n._("pub_safety_warning_content_missing")
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
# --- 3. Compliance documents (category-mandatory) ---
|
|
583
|
+
compliance_docs = None
|
|
584
|
+
if schema:
|
|
585
|
+
compliance_docs = schema.compliance_documents or None
|
|
586
|
+
else:
|
|
587
|
+
compliance_docs = getattr(self.args, "compliance_documents", None) or None
|
|
588
|
+
|
|
589
|
+
compliance_mandatory = self._is_category_attribute_mandatory(
|
|
590
|
+
category_attributes, "compliance_documents"
|
|
591
|
+
)
|
|
592
|
+
if compliance_mandatory and not compliance_docs:
|
|
593
|
+
compliance_issues.append(
|
|
594
|
+
i18n._("pub_compliance_docs_missing")
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
if not compliance_issues:
|
|
598
|
+
return None
|
|
599
|
+
|
|
600
|
+
if not draft_requested:
|
|
601
|
+
return {
|
|
602
|
+
"status": "compliance_missing_suggest_draft",
|
|
603
|
+
"message": i18n._("pub_compliance_missing_message"),
|
|
604
|
+
"compliance_issues": compliance_issues,
|
|
605
|
+
"next_actions": [
|
|
606
|
+
i18n._("pub_compliance_fill_or_draft"),
|
|
607
|
+
i18n._("pub_draft_option_hint"),
|
|
608
|
+
],
|
|
609
|
+
}
|
|
610
|
+
else:
|
|
611
|
+
if draft_issues is not None:
|
|
612
|
+
for issue in compliance_issues:
|
|
613
|
+
draft_issues.append(f"NOTE: {issue}")
|
|
614
|
+
return None
|
|
615
|
+
|
|
616
|
+
def _check_special_category_rules(
|
|
617
|
+
self,
|
|
618
|
+
product_payload: Dict[str, Any],
|
|
619
|
+
category_attributes: Optional[List[Dict[str, Any]]] = None,
|
|
620
|
+
*,
|
|
621
|
+
category_id: Optional[Any] = None,
|
|
622
|
+
schema: Optional[Any] = None,
|
|
623
|
+
draft_requested: bool = False,
|
|
624
|
+
draft_issues: Optional[List[str]] = None,
|
|
625
|
+
) -> Optional[Dict[str, Any]]:
|
|
626
|
+
"""Validate special category rules: PPU, ISBN, food attributes.
|
|
627
|
+
|
|
628
|
+
Returns:
|
|
629
|
+
- ``special_category_block`` for hard errors (invalid PPU value, ISBN missing for books)
|
|
630
|
+
- ``special_category_suggest_draft`` for missing fields (PPU missing, food attrs missing)
|
|
631
|
+
- ``None`` when all rules are satisfied.
|
|
632
|
+
"""
|
|
633
|
+
attributes = product_payload.get("Attributes") or {}
|
|
634
|
+
|
|
635
|
+
# Fetch category attributes if not provided
|
|
636
|
+
if category_attributes is None and category_id is not None:
|
|
637
|
+
lang = self.args.lang if self.args.lang not in (None, "auto") else "en_US"
|
|
638
|
+
try:
|
|
639
|
+
category_attributes = self.product.get_category_attributes(
|
|
640
|
+
str(category_id), language_code=lang
|
|
641
|
+
)
|
|
642
|
+
except Exception:
|
|
643
|
+
category_attributes = []
|
|
644
|
+
if category_attributes is None:
|
|
645
|
+
category_attributes = []
|
|
646
|
+
|
|
647
|
+
# --- 1. PPU (Price Per Unit): standard_unit_type + unit_count ---
|
|
648
|
+
ppu_missing: List[str] = []
|
|
649
|
+
ppu_invalid: List[str] = []
|
|
650
|
+
|
|
651
|
+
# Get values from schema or CLI args
|
|
652
|
+
if schema:
|
|
653
|
+
unit_type = schema.standard_unit_type or ""
|
|
654
|
+
unit_count_val = schema.unit_count
|
|
655
|
+
else:
|
|
656
|
+
unit_type = getattr(self.args, "standard_unit_type", None) or getattr(
|
|
657
|
+
self.args, "unit_count_type", None
|
|
658
|
+
) or ""
|
|
659
|
+
unit_count_val = getattr(self.args, "unit_count", None)
|
|
660
|
+
|
|
661
|
+
# Also check payload attributes as fallback
|
|
662
|
+
if not unit_type:
|
|
663
|
+
unit_type = (
|
|
664
|
+
attributes.get("unit_count_type")
|
|
665
|
+
or attributes.get("standard_unit_type")
|
|
666
|
+
or ""
|
|
667
|
+
)
|
|
668
|
+
if unit_count_val is None:
|
|
669
|
+
unit_count_val = attributes.get("unit_count")
|
|
670
|
+
|
|
671
|
+
unit_count_str = str(unit_count_val).strip() if unit_count_val is not None else ""
|
|
672
|
+
|
|
673
|
+
# Check if PPU is category-mandatory
|
|
674
|
+
ppu_mandatory = (
|
|
675
|
+
self._is_category_attribute_mandatory(category_attributes, "unit_count")
|
|
676
|
+
or self._is_category_attribute_mandatory(
|
|
677
|
+
category_attributes, "unit_count_type"
|
|
678
|
+
)
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
if ppu_mandatory or unit_type or unit_count_str:
|
|
682
|
+
# Check missing
|
|
683
|
+
if not unit_type and not unit_count_str:
|
|
684
|
+
ppu_missing.append(
|
|
685
|
+
i18n._("pub_ppu_both_missing")
|
|
686
|
+
)
|
|
687
|
+
elif not unit_type:
|
|
688
|
+
ppu_missing.append(
|
|
689
|
+
i18n._("pub_ppu_type_missing")
|
|
690
|
+
)
|
|
691
|
+
elif not unit_count_str:
|
|
692
|
+
ppu_missing.append(
|
|
693
|
+
i18n._("pub_ppu_count_missing")
|
|
694
|
+
)
|
|
695
|
+
else:
|
|
696
|
+
# Both present — validate unit_count value
|
|
697
|
+
try:
|
|
698
|
+
uc = float(unit_count_str)
|
|
699
|
+
if uc <= 0:
|
|
700
|
+
ppu_invalid.append(
|
|
701
|
+
i18n._("pub_ppu_count_invalid", value=unit_count_str)
|
|
702
|
+
)
|
|
703
|
+
except (TypeError, ValueError):
|
|
704
|
+
ppu_invalid.append(
|
|
705
|
+
i18n._("pub_ppu_count_not_number", value=unit_count_str)
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
# --- 2. ISBN for book categories ---
|
|
709
|
+
isbn_missing = False
|
|
710
|
+
isbn_mandatory = self._is_category_attribute_mandatory(
|
|
711
|
+
category_attributes, "isbn"
|
|
712
|
+
)
|
|
713
|
+
if isbn_mandatory:
|
|
714
|
+
isbn_val = None
|
|
715
|
+
if schema:
|
|
716
|
+
isbn_val = schema.isbn
|
|
717
|
+
else:
|
|
718
|
+
isbn_val = getattr(self.args, "isbn", None)
|
|
719
|
+
if not isbn_val:
|
|
720
|
+
# Also check payload attributes
|
|
721
|
+
isbn_val = attributes.get("isbn")
|
|
722
|
+
if not isbn_val:
|
|
723
|
+
isbn_missing = True
|
|
724
|
+
|
|
725
|
+
# --- 3. Food & health attributes ---
|
|
726
|
+
food_missing = False
|
|
727
|
+
food_mandatory = self._category_looks_like_food_health(category_attributes)
|
|
728
|
+
if food_mandatory:
|
|
729
|
+
food_val = None
|
|
730
|
+
if schema:
|
|
731
|
+
food_val = schema.sku_attributes_food_health
|
|
732
|
+
else:
|
|
733
|
+
food_val = getattr(self.args, "sku_attributes_food_health", None) or None
|
|
734
|
+
if not food_val:
|
|
735
|
+
food_missing = True
|
|
736
|
+
|
|
737
|
+
# --- Build response ---
|
|
738
|
+
# Hard blocks first (invalid values, ISBN missing for books)
|
|
739
|
+
if ppu_invalid:
|
|
740
|
+
return {
|
|
741
|
+
"status": "special_category_block",
|
|
742
|
+
"message": i18n._("pub_ppu_validation_failed"),
|
|
743
|
+
"block_issues": ppu_invalid,
|
|
744
|
+
"next_actions": [i18n._("pub_ppu_fix_and_rerun")],
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
if isbn_missing:
|
|
748
|
+
return {
|
|
749
|
+
"status": "special_category_block",
|
|
750
|
+
"message": i18n._("pub_isbn_required_for_books"),
|
|
751
|
+
"block_issues": [i18n._("pub_isbn_missing")],
|
|
752
|
+
"next_actions": [i18n._("pub_isbn_fix_and_rerun")],
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
# Draft suggestions (missing fields)
|
|
756
|
+
draft_issues_list: List[str] = []
|
|
757
|
+
if ppu_missing:
|
|
758
|
+
draft_issues_list.extend(ppu_missing)
|
|
759
|
+
if food_missing:
|
|
760
|
+
draft_issues_list.append(i18n._("pub_food_attrs_missing"))
|
|
761
|
+
|
|
762
|
+
if not draft_issues_list:
|
|
763
|
+
return None
|
|
764
|
+
|
|
765
|
+
if not draft_requested:
|
|
766
|
+
return {
|
|
767
|
+
"status": "special_category_suggest_draft",
|
|
768
|
+
"message": i18n._("pub_special_category_missing_message"),
|
|
769
|
+
"missing_fields": draft_issues_list,
|
|
770
|
+
"next_actions": [
|
|
771
|
+
i18n._("pub_special_category_fill_or_draft"),
|
|
772
|
+
i18n._("pub_draft_option_hint"),
|
|
773
|
+
],
|
|
774
|
+
}
|
|
775
|
+
else:
|
|
776
|
+
if draft_issues is not None:
|
|
777
|
+
for issue in draft_issues_list:
|
|
778
|
+
draft_issues.append(f"NOTE: {issue}")
|
|
779
|
+
return None
|
|
780
|
+
|
|
781
|
+
def _check_dbm_limits(
|
|
782
|
+
self,
|
|
783
|
+
product_payload: Dict[str, Any],
|
|
784
|
+
*,
|
|
785
|
+
has_dbs_permission: bool = False,
|
|
786
|
+
) -> Optional[Dict[str, Any]]:
|
|
787
|
+
"""Check if package exceeds DBM limits and auto-switch to DBS.
|
|
788
|
+
|
|
789
|
+
DBM limits:
|
|
790
|
+
- Weight <= 40KG
|
|
791
|
+
- L+W+H <= 300cm
|
|
792
|
+
- Volumetric weight (L*W*H/6000) <= 40
|
|
793
|
+
|
|
794
|
+
If any limit is exceeded and delivery_method is DBM:
|
|
795
|
+
- Seller has DBS permission → auto-switch to DBS
|
|
796
|
+
- Seller has no DBS permission → return error (block)
|
|
797
|
+
Returns a dict with result, None if within limits.
|
|
798
|
+
"""
|
|
799
|
+
attrs = product_payload.get("Attributes") or {}
|
|
800
|
+
delivery_method = attrs.get("delivery_method") or attrs.get("Delivery_method") or ""
|
|
801
|
+
|
|
802
|
+
# Only check if delivery method is DBM
|
|
803
|
+
if delivery_method.upper() != "DBM":
|
|
804
|
+
return None
|
|
805
|
+
|
|
806
|
+
skus = (product_payload.get("Skus") or {}).get("Sku") or []
|
|
807
|
+
if not skus:
|
|
808
|
+
return None
|
|
809
|
+
|
|
810
|
+
# Check first SKU (DBM/DBS is product-level, same for all SKUs)
|
|
811
|
+
sku = skus[0]
|
|
812
|
+
weight = float(sku.get("package_weight") or 0)
|
|
813
|
+
length = float(sku.get("package_length") or 0)
|
|
814
|
+
width = float(sku.get("package_width") or 0)
|
|
815
|
+
height = float(sku.get("package_height") or 0)
|
|
816
|
+
|
|
817
|
+
if not (weight and length and width and height):
|
|
818
|
+
return None
|
|
819
|
+
|
|
820
|
+
dim_sum = length + width + height
|
|
821
|
+
volume_weight = (length * width * height) / 6000
|
|
822
|
+
|
|
823
|
+
exceeded: List[str] = []
|
|
824
|
+
if weight > _DBM_MAX_WEIGHT_KG:
|
|
825
|
+
exceeded.append(
|
|
826
|
+
i18n._("pub_dbm_weight_exceeded", weight=f"{weight}kg", limit=_DBM_MAX_WEIGHT_KG)
|
|
827
|
+
)
|
|
828
|
+
if dim_sum > _DBM_MAX_DIM_SUM_CM:
|
|
829
|
+
exceeded.append(
|
|
830
|
+
i18n._("pub_dbm_dim_sum_exceeded", dim_sum=f"{dim_sum}cm", limit=_DBM_MAX_DIM_SUM_CM)
|
|
831
|
+
)
|
|
832
|
+
if volume_weight > _DBM_MAX_VOLUME_WEIGHT:
|
|
833
|
+
exceeded.append(
|
|
834
|
+
i18n._("pub_dbm_volume_exceeded", volume=f"{volume_weight:.1f}", limit=_DBM_MAX_VOLUME_WEIGHT)
|
|
835
|
+
)
|
|
836
|
+
|
|
837
|
+
if not exceeded:
|
|
838
|
+
return None
|
|
839
|
+
|
|
840
|
+
# Exceeded DBM limits — check DBS permission
|
|
841
|
+
if has_dbs_permission:
|
|
842
|
+
# Auto-switch to DBS
|
|
843
|
+
attrs["delivery_method"] = "DBS"
|
|
844
|
+
attrs["Delivery_method"] = "DBS"
|
|
845
|
+
# Also update SKU-level if present
|
|
846
|
+
for s in skus:
|
|
847
|
+
if s.get("delivery_method"):
|
|
848
|
+
s["delivery_method"] = "DBS"
|
|
849
|
+
return {
|
|
850
|
+
"switched_to_dbs": True,
|
|
851
|
+
"reasons": exceeded,
|
|
852
|
+
"message": i18n._("pub_dbm_auto_switch_to_dbs"),
|
|
853
|
+
}
|
|
854
|
+
else:
|
|
855
|
+
# No DBS permission — block with error
|
|
856
|
+
return {
|
|
857
|
+
"switched_to_dbs": False,
|
|
858
|
+
"blocked": True,
|
|
859
|
+
"reasons": exceeded,
|
|
860
|
+
"message": i18n._("pub_dbm_no_dbs_permission"),
|
|
861
|
+
}
|
|
862
|
+
|
|
502
863
|
def _finalize_create(
|
|
503
864
|
self,
|
|
504
865
|
product_payload: Dict[str, Any],
|
|
@@ -511,6 +872,26 @@ class PublishModule:
|
|
|
511
872
|
"""Validate, preview, pre-validate, and execute product creation."""
|
|
512
873
|
_validate_create_payload(product_payload)
|
|
513
874
|
|
|
875
|
+
# Check DBM limits — auto-switch to DBS if exceeded (requires DBS permission)
|
|
876
|
+
has_dbs = self.product.get_shipping_permission()
|
|
877
|
+
dbm_result = self._check_dbm_limits(product_payload, has_dbs_permission=has_dbs)
|
|
878
|
+
if dbm_result:
|
|
879
|
+
if dbm_result.get("blocked"):
|
|
880
|
+
# No DBS permission and exceeds DBM limits — block
|
|
881
|
+
return {
|
|
882
|
+
"status": "dbm_limit_exceeded_no_dbs",
|
|
883
|
+
"message": dbm_result["message"],
|
|
884
|
+
"reasons": dbm_result.get("reasons", []),
|
|
885
|
+
"next_actions": [
|
|
886
|
+
i18n._("pub_dbm_fix_or_apply_dbs"),
|
|
887
|
+
],
|
|
888
|
+
}
|
|
889
|
+
if dbm_result.get("switched_to_dbs"):
|
|
890
|
+
if draft_issues is not None:
|
|
891
|
+
draft_issues.append(dbm_result["message"])
|
|
892
|
+
for reason in dbm_result.get("reasons", []):
|
|
893
|
+
draft_issues.append(f" - {reason}")
|
|
894
|
+
|
|
514
895
|
# Run pre-validate early so errors are visible in the preview
|
|
515
896
|
prevalidate_result = self._run_prevalidate(product_payload)
|
|
516
897
|
prevalidate_errors = []
|
|
@@ -594,6 +975,17 @@ class PublishModule:
|
|
|
594
975
|
i18n._("pub_brand_permission_draft_suggestion", code=exc.code),
|
|
595
976
|
],
|
|
596
977
|
}
|
|
978
|
+
# SKU already exists — concurrency conflict or duplicate SKU
|
|
979
|
+
if exc.code == "SKU_ALREADY_EXISTS":
|
|
980
|
+
return {
|
|
981
|
+
"status": "sku_already_exists",
|
|
982
|
+
"message": i18n._("pub_sku_already_exists"),
|
|
983
|
+
"code": exc.code,
|
|
984
|
+
"hint": exc.hint,
|
|
985
|
+
"next_actions": [
|
|
986
|
+
i18n._("pub_sku_already_exists_next"),
|
|
987
|
+
],
|
|
988
|
+
}
|
|
597
989
|
# Code 4234: draft product created but missing required fields
|
|
598
990
|
if exc.code == "4234" and draft:
|
|
599
991
|
err_data = (exc.payload.get("data") or {}) if exc.payload else {}
|
|
@@ -630,15 +1022,115 @@ class PublishModule:
|
|
|
630
1022
|
# AI-assisted create flow
|
|
631
1023
|
# ==================================================================
|
|
632
1024
|
|
|
1025
|
+
def _extract_product_summaries(
|
|
1026
|
+
self, content_items: List[Dict[str, Any]]
|
|
1027
|
+
) -> List[Dict[str, Any]]:
|
|
1028
|
+
"""Extract basic product info from each Excel row for display.
|
|
1029
|
+
|
|
1030
|
+
Returns a list of dicts with index, title, price, sku fields.
|
|
1031
|
+
"""
|
|
1032
|
+
summaries: List[Dict[str, Any]] = []
|
|
1033
|
+
idx = 1
|
|
1034
|
+
for item in content_items:
|
|
1035
|
+
if item.get("type") != "table":
|
|
1036
|
+
continue
|
|
1037
|
+
content = item.get("content")
|
|
1038
|
+
if not content:
|
|
1039
|
+
continue
|
|
1040
|
+
tables = content if isinstance(content, list) else [content]
|
|
1041
|
+
for table in tables:
|
|
1042
|
+
rows = []
|
|
1043
|
+
if isinstance(table, dict) and "rows" in table:
|
|
1044
|
+
rows = table.get("rows", [])
|
|
1045
|
+
elif isinstance(table, list):
|
|
1046
|
+
rows = table
|
|
1047
|
+
for row in rows:
|
|
1048
|
+
# Try to extract common product fields
|
|
1049
|
+
title = (
|
|
1050
|
+
row.get("product_title")
|
|
1051
|
+
or row.get("title")
|
|
1052
|
+
or row.get("name")
|
|
1053
|
+
or row.get("product_name")
|
|
1054
|
+
or ""
|
|
1055
|
+
)
|
|
1056
|
+
price = (
|
|
1057
|
+
row.get("general_price")
|
|
1058
|
+
or row.get("price")
|
|
1059
|
+
or row.get("original_price")
|
|
1060
|
+
or ""
|
|
1061
|
+
)
|
|
1062
|
+
sku = (
|
|
1063
|
+
row.get("seller_sku")
|
|
1064
|
+
or row.get("SellerSku")
|
|
1065
|
+
or row.get("sku")
|
|
1066
|
+
or ""
|
|
1067
|
+
)
|
|
1068
|
+
summaries.append({
|
|
1069
|
+
"index": idx,
|
|
1070
|
+
"title": str(title) if title else "",
|
|
1071
|
+
"price": str(price) if price else "",
|
|
1072
|
+
"sku": str(sku) if sku else "",
|
|
1073
|
+
})
|
|
1074
|
+
idx += 1
|
|
1075
|
+
return summaries
|
|
1076
|
+
|
|
633
1077
|
def _run_create_from_source(self) -> Dict[str, Any]:
|
|
634
1078
|
"""AI posting: read files/URLs, extract fields, confirm, and publish."""
|
|
635
1079
|
sources = getattr(self.args, "source", []) or []
|
|
636
1080
|
if not sources:
|
|
637
1081
|
raise ValueError(i18n._("pub_ai_post_requires_source"))
|
|
638
1082
|
|
|
639
|
-
|
|
1083
|
+
try:
|
|
1084
|
+
content_items = read_sources(sources)
|
|
1085
|
+
except ValueError as exc:
|
|
1086
|
+
msg = str(exc)
|
|
1087
|
+
# Map file_reader ValueError messages to i18n status codes
|
|
1088
|
+
if "Unsupported file format" in msg:
|
|
1089
|
+
return {
|
|
1090
|
+
"status": "unsupported_format",
|
|
1091
|
+
"message": i18n._("pub_unsupported_format", source=sources[0]),
|
|
1092
|
+
"next_actions": [i18n._("pub_ai_post_requires_source")],
|
|
1093
|
+
}
|
|
1094
|
+
if "empty" in msg.lower() or "corrupted" in msg.lower():
|
|
1095
|
+
return {
|
|
1096
|
+
"status": "file_empty_or_corrupted",
|
|
1097
|
+
"message": i18n._("pub_file_empty_or_corrupted", source=sources[0]),
|
|
1098
|
+
"next_actions": [i18n._("pub_ai_post_requires_source")],
|
|
1099
|
+
}
|
|
1100
|
+
if "Failed to fetch URL" in msg or "fetch" in msg.lower():
|
|
1101
|
+
return {
|
|
1102
|
+
"status": "invalid_url",
|
|
1103
|
+
"message": i18n._("pub_url_fetch_failed", source=sources[0], error=msg),
|
|
1104
|
+
"next_actions": [i18n._("pub_ai_post_requires_source")],
|
|
1105
|
+
}
|
|
1106
|
+
# Fallback: return the raw error message
|
|
1107
|
+
return {
|
|
1108
|
+
"status": "file_read_error",
|
|
1109
|
+
"message": msg,
|
|
1110
|
+
"next_actions": [i18n._("pub_ai_post_requires_source")],
|
|
1111
|
+
}
|
|
640
1112
|
if not content_items:
|
|
641
|
-
|
|
1113
|
+
return {
|
|
1114
|
+
"status": "file_empty_or_corrupted",
|
|
1115
|
+
"message": i18n._("pub_file_empty_or_corrupted", source=sources[0] if sources else ""),
|
|
1116
|
+
"next_actions": [i18n._("pub_ai_post_requires_source")],
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
# Check for multiple products in Excel
|
|
1120
|
+
row_count = get_excel_row_count(content_items)
|
|
1121
|
+
if row_count > 1:
|
|
1122
|
+
# Extract basic info from each row for display
|
|
1123
|
+
product_summaries = self._extract_product_summaries(content_items)
|
|
1124
|
+
return {
|
|
1125
|
+
"status": "multiple_products_detected",
|
|
1126
|
+
"message": i18n._("pub_multiple_products_detected", count=row_count),
|
|
1127
|
+
"product_summaries": product_summaries,
|
|
1128
|
+
"next_actions": [
|
|
1129
|
+
i18n._("pub_multiple_products_select_index"),
|
|
1130
|
+
i18n._("pub_multiple_products_select_all"),
|
|
1131
|
+
i18n._("pub_multiple_products_cancel"),
|
|
1132
|
+
],
|
|
1133
|
+
}
|
|
642
1134
|
|
|
643
1135
|
source_text = extract_text(content_items)
|
|
644
1136
|
engine = ExtractionEngine(LLMClient())
|
|
@@ -669,7 +1161,12 @@ class PublishModule:
|
|
|
669
1161
|
|
|
670
1162
|
title_err = validate_title(schema.product_title)
|
|
671
1163
|
if title_err:
|
|
672
|
-
|
|
1164
|
+
return {
|
|
1165
|
+
"status": "title_invalid",
|
|
1166
|
+
"message": title_err,
|
|
1167
|
+
"format_suggestion": i18n._("pub_title_format_suggestion"),
|
|
1168
|
+
"next_actions": [i18n._("pub_ai_post_requires_source")],
|
|
1169
|
+
}
|
|
673
1170
|
|
|
674
1171
|
ctb_preview = {
|
|
675
1172
|
"stage": "ctb",
|
|
@@ -702,7 +1199,7 @@ class PublishModule:
|
|
|
702
1199
|
if compliance_result.get("status"):
|
|
703
1200
|
return compliance_result
|
|
704
1201
|
|
|
705
|
-
images, failed_images = self._process_images_for_schema(schema)
|
|
1202
|
+
images, failed_images, image_warnings = self._process_images_for_schema(schema)
|
|
706
1203
|
if failed_images:
|
|
707
1204
|
return {
|
|
708
1205
|
"status": "need_images",
|
|
@@ -724,6 +1221,14 @@ class PublishModule:
|
|
|
724
1221
|
|
|
725
1222
|
draft_requested = getattr(self.args, "draft", False)
|
|
726
1223
|
|
|
1224
|
+
# Sanitize HTML in long description before building payload
|
|
1225
|
+
html_cleaned = False
|
|
1226
|
+
if schema.product_long_description:
|
|
1227
|
+
sanitized, was_cleaned = _sanitize_html(schema.product_long_description)
|
|
1228
|
+
if was_cleaned:
|
|
1229
|
+
html_cleaned = True
|
|
1230
|
+
schema.product_long_description = sanitized
|
|
1231
|
+
|
|
727
1232
|
product_payload = self.product.build_create_product_payload(
|
|
728
1233
|
schema.to_dict(),
|
|
729
1234
|
category_id=category_id,
|
|
@@ -732,11 +1237,33 @@ class PublishModule:
|
|
|
732
1237
|
draft=draft_requested,
|
|
733
1238
|
)
|
|
734
1239
|
|
|
1240
|
+
# Stage 3b: unified compliance check (GPSR + Safety Warning + compliance docs)
|
|
1241
|
+
draft_issues: list = []
|
|
1242
|
+
compliance_check = self._check_compliance_fields(
|
|
1243
|
+
product_payload,
|
|
1244
|
+
category_attributes,
|
|
1245
|
+
schema=schema,
|
|
1246
|
+
draft_requested=draft_requested,
|
|
1247
|
+
draft_issues=draft_issues,
|
|
1248
|
+
)
|
|
1249
|
+
if compliance_check:
|
|
1250
|
+
return compliance_check
|
|
1251
|
+
|
|
1252
|
+
# Stage 3c: special category rules (PPU / ISBN / food attributes)
|
|
1253
|
+
special_check = self._check_special_category_rules(
|
|
1254
|
+
product_payload,
|
|
1255
|
+
category_attributes,
|
|
1256
|
+
schema=schema,
|
|
1257
|
+
draft_requested=draft_requested,
|
|
1258
|
+
draft_issues=draft_issues,
|
|
1259
|
+
)
|
|
1260
|
+
if special_check:
|
|
1261
|
+
return special_check
|
|
1262
|
+
|
|
735
1263
|
# Stage 4a: check category-specific mandatory attributes
|
|
736
1264
|
cat_attr_result = self._check_category_mandatory_attributes(
|
|
737
1265
|
product_payload, category_attributes, category_id
|
|
738
1266
|
)
|
|
739
|
-
draft_issues: list = []
|
|
740
1267
|
if cat_attr_result:
|
|
741
1268
|
if draft_requested:
|
|
742
1269
|
for attr in cat_attr_result.get("missing_attributes", []):
|
|
@@ -768,6 +1295,10 @@ class PublishModule:
|
|
|
768
1295
|
schema, product_payload, compliance_result, category_attributes
|
|
769
1296
|
)
|
|
770
1297
|
country_price_note = self._check_country_price_recommendation(schema)
|
|
1298
|
+
|
|
1299
|
+
# Check for >2 sales attributes (variation_3+)
|
|
1300
|
+
variation_warnings = self._check_extra_variations(schema)
|
|
1301
|
+
|
|
771
1302
|
fs_preview = {
|
|
772
1303
|
"stage": "field_summary",
|
|
773
1304
|
"category_id": int(category_id),
|
|
@@ -775,6 +1306,10 @@ class PublishModule:
|
|
|
775
1306
|
"brand_id": brand_id,
|
|
776
1307
|
"key_missing": sorted(key_missing),
|
|
777
1308
|
"draft": draft_requested,
|
|
1309
|
+
"image_warnings": image_warnings,
|
|
1310
|
+
"variation_warnings": variation_warnings,
|
|
1311
|
+
"html_cleaned": html_cleaned,
|
|
1312
|
+
"description_empty": not schema.product_long_description,
|
|
778
1313
|
}
|
|
779
1314
|
expected_fs_token = _make_publish_preview_token(fs_preview)
|
|
780
1315
|
provided_fs_token = getattr(self.args, "field_summary_token", None)
|
|
@@ -801,81 +1336,175 @@ class PublishModule:
|
|
|
801
1336
|
# ------------------------------------------------------------------
|
|
802
1337
|
|
|
803
1338
|
_TRACKED_FIELDS = [
|
|
804
|
-
|
|
805
|
-
("
|
|
806
|
-
("
|
|
807
|
-
("
|
|
808
|
-
("
|
|
809
|
-
("
|
|
810
|
-
("
|
|
811
|
-
("
|
|
812
|
-
("weight_kg", "Weight"),
|
|
813
|
-
("length_cm", "Length"),
|
|
814
|
-
("width_cm", "Width"),
|
|
815
|
-
("height_cm", "Height"),
|
|
816
|
-
("
|
|
817
|
-
|
|
818
|
-
("
|
|
819
|
-
("
|
|
820
|
-
("
|
|
1339
|
+
# Mandatory fields — missing blocks publish entirely
|
|
1340
|
+
("product_title", "Product title", "mandatory"),
|
|
1341
|
+
("product_images", "Product images", "mandatory"),
|
|
1342
|
+
("category_id", "Category", "mandatory"),
|
|
1343
|
+
("brand_id", "Brand", "mandatory"),
|
|
1344
|
+
("general_price", "General price", "mandatory"),
|
|
1345
|
+
("stock", "Stock", "mandatory"),
|
|
1346
|
+
("seller_sku", "Seller SKU", "mandatory"),
|
|
1347
|
+
("weight_kg", "Weight", "mandatory"),
|
|
1348
|
+
("length_cm", "Length", "mandatory"),
|
|
1349
|
+
("width_cm", "Width", "mandatory"),
|
|
1350
|
+
("height_cm", "Height", "mandatory"),
|
|
1351
|
+
("delivery_method", "Delivery method", "mandatory"),
|
|
1352
|
+
# Conditional fields — missing suggests Draft
|
|
1353
|
+
("italy_price", "Italy price", "conditional"),
|
|
1354
|
+
("ean_code", "EAN code", "conditional"),
|
|
1355
|
+
("manufacturer", "Manufacturer", "conditional"),
|
|
1356
|
+
("eu_responsible_person", "EU responsible person", "conditional"),
|
|
1357
|
+
("safety_warning", "Safety warning", "conditional"),
|
|
1358
|
+
("package_photos", "Package photos", "conditional"),
|
|
1359
|
+
("compliance_documents", "Compliance documents", "conditional"),
|
|
1360
|
+
("standard_unit_type", "Unit count type", "conditional"),
|
|
1361
|
+
("unit_count", "Unit count", "conditional"),
|
|
1362
|
+
("sku_attributes_food_health", "Food & health attributes", "conditional"),
|
|
1363
|
+
("isbn", "ISBN", "conditional"),
|
|
821
1364
|
]
|
|
822
1365
|
|
|
823
1366
|
def _resolve_category_for_schema(
|
|
824
1367
|
self, schema: MiraviaListingSchema
|
|
825
1368
|
) -> Dict[str, Any]:
|
|
826
|
-
"""Resolve category from extracted name/ID
|
|
827
|
-
confirmed_category_id = getattr(self.args, "confirmed_category_id", None)
|
|
828
|
-
if confirmed_category_id:
|
|
829
|
-
return {"category_id": int(confirmed_category_id)}
|
|
1369
|
+
"""Resolve category from extracted name/ID, CLI args, or recommendation.
|
|
830
1370
|
|
|
831
|
-
|
|
832
|
-
|
|
1371
|
+
Priority:
|
|
1372
|
+
1. --confirmed-category-id: validated against tree
|
|
1373
|
+
2. --category-id / schema.category_id: validated against tree
|
|
1374
|
+
3. schema.category_name: keyword search
|
|
1375
|
+
4. Recommend API: title/images → Top1/TopN/empty
|
|
1376
|
+
5. Fallback: full category tree
|
|
833
1377
|
|
|
834
|
-
|
|
835
|
-
|
|
1378
|
+
All "invalid" paths try recommend API before falling back to tree.
|
|
1379
|
+
"""
|
|
1380
|
+
# Fetch category tree once — used for validation and fallback display
|
|
1381
|
+
lang = self.args.lang if self.args.lang not in (None, "auto") else "en"
|
|
1382
|
+
category_tree: Dict[str, Any] = {}
|
|
1383
|
+
valid_ids: set = set()
|
|
1384
|
+
category_permissions: Dict[int, bool] = {}
|
|
1385
|
+
try:
|
|
1386
|
+
category_tree = self.product.get_category_tree(language_code=lang)
|
|
1387
|
+
valid_ids = _collect_category_ids(category_tree)
|
|
1388
|
+
category_permissions = _collect_category_permissions(category_tree)
|
|
1389
|
+
except MiraviaError as exc:
|
|
1390
|
+
logger.warning("Failed to fetch category tree: %s", exc)
|
|
836
1391
|
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
1392
|
+
def _need_category_response(
|
|
1393
|
+
reason_msg: str,
|
|
1394
|
+
choices: Optional[List[Dict[str, Any]]] = None,
|
|
1395
|
+
) -> Dict[str, Any]:
|
|
1396
|
+
resp: Dict[str, Any] = {
|
|
840
1397
|
"status": "need_category",
|
|
841
|
-
"message":
|
|
1398
|
+
"message": reason_msg,
|
|
1399
|
+
"category_tree": category_tree,
|
|
1400
|
+
"category_permissions": category_permissions,
|
|
842
1401
|
"next_actions": [i18n._("pub_ai_post_provide_category")],
|
|
843
1402
|
}
|
|
1403
|
+
if choices:
|
|
1404
|
+
resp["choices"] = choices
|
|
1405
|
+
resp["next_actions"] = [
|
|
1406
|
+
i18n._(
|
|
1407
|
+
"pub_category_confirm_next", category_id=c.get("category_id")
|
|
1408
|
+
)
|
|
1409
|
+
for c in choices
|
|
1410
|
+
]
|
|
1411
|
+
return resp
|
|
1412
|
+
|
|
1413
|
+
def _try_recommend_or_tree(
|
|
1414
|
+
fallback_msg: str,
|
|
1415
|
+
) -> Dict[str, Any]:
|
|
1416
|
+
"""Try recommend API first, fallback to category tree."""
|
|
1417
|
+
title = schema.product_title
|
|
1418
|
+
image_urls = schema.image_urls or []
|
|
1419
|
+
if title or image_urls:
|
|
1420
|
+
try:
|
|
1421
|
+
recommendations = self.product.recommend_category(
|
|
1422
|
+
title=title, image_urls=image_urls if image_urls else None
|
|
1423
|
+
)
|
|
1424
|
+
if recommendations:
|
|
1425
|
+
# Top1: auto-select
|
|
1426
|
+
if len(recommendations) == 1:
|
|
1427
|
+
rec = recommendations[0]
|
|
1428
|
+
cid = rec.get("category_id") or rec.get("id")
|
|
1429
|
+
if cid:
|
|
1430
|
+
return {"category_id": int(cid)}
|
|
1431
|
+
# TopN: show candidates
|
|
1432
|
+
return _need_category_response(
|
|
1433
|
+
i18n._(
|
|
1434
|
+
"pub_ai_post_category_recommend",
|
|
1435
|
+
count=len(recommendations),
|
|
1436
|
+
),
|
|
1437
|
+
choices=recommendations,
|
|
1438
|
+
)
|
|
1439
|
+
except MiraviaError as exc:
|
|
1440
|
+
logger.warning("Category recommendation failed: %s", exc)
|
|
1441
|
+
# Fallback: show full tree
|
|
1442
|
+
return _need_category_response(fallback_msg)
|
|
1443
|
+
|
|
1444
|
+
def _validate_category_id(cid: int) -> Optional[Dict[str, Any]]:
|
|
1445
|
+
"""Validate category ID. If invalid, try recommend then tree."""
|
|
1446
|
+
if valid_ids and cid not in valid_ids:
|
|
1447
|
+
return _try_recommend_or_tree(
|
|
1448
|
+
i18n._("pub_ai_post_category_id_invalid", category_id=cid)
|
|
1449
|
+
)
|
|
1450
|
+
return None
|
|
844
1451
|
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
}
|
|
1452
|
+
# 1. --confirmed-category-id: validate
|
|
1453
|
+
confirmed_category_id = getattr(self.args, "confirmed_category_id", None)
|
|
1454
|
+
if confirmed_category_id:
|
|
1455
|
+
cid = int(confirmed_category_id)
|
|
1456
|
+
err = _validate_category_id(cid)
|
|
1457
|
+
if err:
|
|
1458
|
+
return err
|
|
1459
|
+
return {"category_id": cid}
|
|
854
1460
|
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
if
|
|
860
|
-
|
|
861
|
-
|
|
1461
|
+
# 2. --category-id: validate
|
|
1462
|
+
if self.args.category_id:
|
|
1463
|
+
cid = int(self.args.category_id)
|
|
1464
|
+
err = _validate_category_id(cid)
|
|
1465
|
+
if err:
|
|
1466
|
+
return err
|
|
1467
|
+
return {"category_id": cid}
|
|
862
1468
|
|
|
863
|
-
|
|
864
|
-
|
|
1469
|
+
# 3. schema.category_id: validate
|
|
1470
|
+
if schema.category_id:
|
|
1471
|
+
cid = int(schema.category_id)
|
|
1472
|
+
err = _validate_category_id(cid)
|
|
1473
|
+
if err:
|
|
1474
|
+
return err
|
|
1475
|
+
return {"category_id": cid}
|
|
865
1476
|
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
1477
|
+
# 4. schema.category_name: keyword search
|
|
1478
|
+
category_name = schema.category_name
|
|
1479
|
+
if category_name:
|
|
1480
|
+
candidates = self.product.search_category_by_keyword(category_name)
|
|
1481
|
+
if candidates:
|
|
1482
|
+
name_lower = category_name.strip().lower()
|
|
1483
|
+
exact = [
|
|
1484
|
+
c
|
|
1485
|
+
for c in candidates
|
|
1486
|
+
if (c.get("name") or "").strip().lower() == name_lower
|
|
1487
|
+
]
|
|
1488
|
+
display_candidates = exact if exact else candidates
|
|
1489
|
+
|
|
1490
|
+
if len(display_candidates) == 1:
|
|
1491
|
+
return {"category_id": display_candidates[0].get("category_id")}
|
|
1492
|
+
|
|
1493
|
+
return _need_category_response(
|
|
1494
|
+
i18n._(
|
|
1495
|
+
"pub_ai_post_category_multiple", count=len(display_candidates)
|
|
1496
|
+
),
|
|
1497
|
+
choices=display_candidates,
|
|
875
1498
|
)
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
1499
|
+
# Keyword search failed → try recommend then tree
|
|
1500
|
+
return _try_recommend_or_tree(
|
|
1501
|
+
i18n._("pub_ai_post_category_not_found", name=category_name)
|
|
1502
|
+
)
|
|
1503
|
+
|
|
1504
|
+
# 5. No category info at all → try recommend then tree
|
|
1505
|
+
return _try_recommend_or_tree(
|
|
1506
|
+
i18n._("pub_ai_post_need_category")
|
|
1507
|
+
)
|
|
879
1508
|
|
|
880
1509
|
def _resolve_brand_for_schema(
|
|
881
1510
|
self, schema: MiraviaListingSchema
|
|
@@ -1122,9 +1751,13 @@ class PublishModule:
|
|
|
1122
1751
|
if not has_price:
|
|
1123
1752
|
missing.append("General price")
|
|
1124
1753
|
|
|
1754
|
+
has_stock = any(sku.get("quantity") for sku in skus)
|
|
1755
|
+
if not has_stock:
|
|
1756
|
+
missing.append("Stock")
|
|
1757
|
+
|
|
1125
1758
|
package_dims = ("package_weight", "package_length", "package_width", "package_height")
|
|
1126
1759
|
for idx, sku in enumerate(skus, start=1):
|
|
1127
|
-
if not sku.get("SellerSku"):
|
|
1760
|
+
if not (sku.get("SellerSku") or sku.get("seller_sku")):
|
|
1128
1761
|
missing.append(f"Seller SKU (SKU #{idx})")
|
|
1129
1762
|
for dim in package_dims:
|
|
1130
1763
|
val = sku.get(dim)
|
|
@@ -1152,47 +1785,51 @@ class PublishModule:
|
|
|
1152
1785
|
compliance_result: Dict[str, Any],
|
|
1153
1786
|
category_attributes: List[Dict[str, Any]],
|
|
1154
1787
|
) -> List[str]:
|
|
1155
|
-
"""Return
|
|
1788
|
+
"""Return internal keys of fields that are missing but can be skipped in draft mode.
|
|
1789
|
+
|
|
1790
|
+
Keys match ``_TRACKED_FIELDS`` internal names so the summary preview
|
|
1791
|
+
can correctly mark them as missing.
|
|
1792
|
+
"""
|
|
1156
1793
|
missing: List[str] = []
|
|
1157
1794
|
skus = (product_payload.get("Skus") or {}).get("Sku") or []
|
|
1158
1795
|
has_ean = any(sku.get("ean_code") for sku in skus) or bool(schema.ean_codes)
|
|
1159
1796
|
if not has_ean:
|
|
1160
|
-
missing.append("
|
|
1797
|
+
missing.append("ean_code")
|
|
1161
1798
|
|
|
1162
1799
|
if self._check_italy_price_mandatory(schema):
|
|
1163
|
-
missing.append("
|
|
1800
|
+
missing.append("italy_price")
|
|
1164
1801
|
|
|
1165
1802
|
if schema.standard_unit_type and not schema.unit_count:
|
|
1166
|
-
missing.append("
|
|
1803
|
+
missing.append("unit_count")
|
|
1167
1804
|
elif self._is_category_attribute_mandatory(category_attributes, "unit_count") and not schema.unit_count:
|
|
1168
|
-
missing.append("
|
|
1805
|
+
missing.append("unit_count")
|
|
1169
1806
|
|
|
1170
1807
|
if not schema.sku_attributes_food_health and self._category_looks_like_food_health(
|
|
1171
1808
|
category_attributes
|
|
1172
1809
|
):
|
|
1173
|
-
missing.append("
|
|
1810
|
+
missing.append("sku_attributes_food_health")
|
|
1174
1811
|
|
|
1175
1812
|
if compliance_result.get("manufacturer_mismatch") or (
|
|
1176
1813
|
not schema.manufacturer_id and not schema.manufacturer_name
|
|
1177
1814
|
):
|
|
1178
|
-
missing.append("
|
|
1815
|
+
missing.append("manufacturer")
|
|
1179
1816
|
if compliance_result.get("eurp_mismatch") or (
|
|
1180
1817
|
not schema.eu_responsible_person_id
|
|
1181
1818
|
and not schema.eu_responsible_person_name
|
|
1182
1819
|
):
|
|
1183
|
-
missing.append("
|
|
1820
|
+
missing.append("eu_responsible_person")
|
|
1184
1821
|
|
|
1185
1822
|
if schema.safety_warning is None:
|
|
1186
|
-
missing.append("
|
|
1823
|
+
missing.append("safety_warning")
|
|
1187
1824
|
|
|
1188
1825
|
if not schema.package_photos:
|
|
1189
|
-
missing.append("
|
|
1826
|
+
missing.append("package_photos")
|
|
1190
1827
|
|
|
1191
1828
|
if self._is_category_attribute_mandatory(category_attributes, "compliance_documents") and not schema.compliance_documents:
|
|
1192
|
-
missing.append("
|
|
1829
|
+
missing.append("compliance_documents")
|
|
1193
1830
|
|
|
1194
|
-
if not any(sku.get("SellerSku") for sku in skus):
|
|
1195
|
-
missing.append("
|
|
1831
|
+
if not any(sku.get("SellerSku") or sku.get("seller_sku") for sku in skus):
|
|
1832
|
+
missing.append("seller_sku")
|
|
1196
1833
|
|
|
1197
1834
|
return missing
|
|
1198
1835
|
|
|
@@ -1244,32 +1881,105 @@ class PublishModule:
|
|
|
1244
1881
|
return True
|
|
1245
1882
|
return False
|
|
1246
1883
|
|
|
1884
|
+
def _check_extra_variations(self, schema: MiraviaListingSchema) -> List[str]:
|
|
1885
|
+
"""Check if raw_skus contain variation_3+ fields (Miravia supports max 2).
|
|
1886
|
+
|
|
1887
|
+
Returns a list of warning messages if extra variations are detected.
|
|
1888
|
+
"""
|
|
1889
|
+
warnings: List[str] = []
|
|
1890
|
+
extra_vars: set = set()
|
|
1891
|
+
|
|
1892
|
+
for raw in schema.raw_skus:
|
|
1893
|
+
if not isinstance(raw, dict):
|
|
1894
|
+
continue
|
|
1895
|
+
for key in raw.keys():
|
|
1896
|
+
# Check for variation_3, variation_4, etc.
|
|
1897
|
+
if key.startswith("variation_"):
|
|
1898
|
+
try:
|
|
1899
|
+
num = int(key.split("_")[1])
|
|
1900
|
+
if num > 2:
|
|
1901
|
+
extra_vars.add(key)
|
|
1902
|
+
except (IndexError, ValueError):
|
|
1903
|
+
pass
|
|
1904
|
+
|
|
1905
|
+
if extra_vars:
|
|
1906
|
+
sorted_vars = sorted(extra_vars)
|
|
1907
|
+
warnings.append(
|
|
1908
|
+
i18n._(
|
|
1909
|
+
"pub_too_many_variations",
|
|
1910
|
+
count=len(sorted_vars),
|
|
1911
|
+
variations=", ".join(sorted_vars),
|
|
1912
|
+
)
|
|
1913
|
+
)
|
|
1914
|
+
|
|
1915
|
+
return warnings
|
|
1916
|
+
|
|
1247
1917
|
def _make_field_summary_preview(
|
|
1248
1918
|
self,
|
|
1249
1919
|
fs_preview: Dict[str, Any],
|
|
1250
1920
|
key_missing: List[str],
|
|
1251
1921
|
country_price_note: Optional[str] = None,
|
|
1252
1922
|
) -> Dict[str, Any]:
|
|
1253
|
-
"""Return the field extraction summary and draft/continue prompt.
|
|
1923
|
+
"""Return the field extraction summary and draft/continue prompt.
|
|
1924
|
+
|
|
1925
|
+
Fields are grouped by Mandatory vs Conditional. Missing mandatory
|
|
1926
|
+
fields block publish; missing conditional fields suggest Draft.
|
|
1927
|
+
"""
|
|
1254
1928
|
token = _make_publish_preview_token(fs_preview)
|
|
1255
|
-
|
|
1256
|
-
|
|
1929
|
+
|
|
1930
|
+
mandatory_lines: List[str] = []
|
|
1931
|
+
conditional_lines: List[str] = []
|
|
1932
|
+
for key, label, category in self._TRACKED_FIELDS:
|
|
1257
1933
|
status = (
|
|
1258
1934
|
i18n._("pub_field_status_missing")
|
|
1259
1935
|
if key in key_missing
|
|
1260
1936
|
else i18n._("pub_field_status_filled")
|
|
1261
1937
|
)
|
|
1262
|
-
|
|
1938
|
+
line = f" {label}: {status}"
|
|
1939
|
+
if category == "mandatory":
|
|
1940
|
+
mandatory_lines.append(line)
|
|
1941
|
+
else:
|
|
1942
|
+
conditional_lines.append(line)
|
|
1943
|
+
|
|
1944
|
+
# Build summary with section headers
|
|
1945
|
+
summary: List[str] = []
|
|
1946
|
+
summary.append(i18n._("pub_summary_mandatory_section"))
|
|
1947
|
+
summary.extend(mandatory_lines)
|
|
1948
|
+
summary.append("")
|
|
1949
|
+
summary.append(i18n._("pub_summary_conditional_section"))
|
|
1950
|
+
summary.extend(conditional_lines)
|
|
1951
|
+
|
|
1952
|
+
# Separate mandatory vs conditional missing
|
|
1953
|
+
mandatory_missing = [
|
|
1954
|
+
label for key, label, cat in self._TRACKED_FIELDS
|
|
1955
|
+
if cat == "mandatory" and key in key_missing
|
|
1956
|
+
]
|
|
1957
|
+
conditional_missing = [
|
|
1958
|
+
label for key, label, cat in self._TRACKED_FIELDS
|
|
1959
|
+
if cat == "conditional" and key in key_missing
|
|
1960
|
+
]
|
|
1263
1961
|
|
|
1264
1962
|
actions = [i18n._("pub_final_confirm_next", token=token)]
|
|
1265
|
-
if
|
|
1266
|
-
|
|
1963
|
+
if mandatory_missing:
|
|
1964
|
+
# Mandatory missing — block with clear message
|
|
1965
|
+
actions.insert(0, i18n._(
|
|
1966
|
+
"pub_mandatory_missing_block",
|
|
1967
|
+
fields=", ".join(mandatory_missing),
|
|
1968
|
+
))
|
|
1969
|
+
elif conditional_missing:
|
|
1970
|
+
# Conditional missing — suggest Draft
|
|
1971
|
+
actions.insert(0, i18n._(
|
|
1972
|
+
"pub_conditional_missing_draft",
|
|
1973
|
+
fields=", ".join(conditional_missing),
|
|
1974
|
+
))
|
|
1267
1975
|
|
|
1268
1976
|
payload: Dict[str, Any] = {
|
|
1269
1977
|
"status": "need_field_summary",
|
|
1270
1978
|
"title": i18n._("pub_field_summary_title"),
|
|
1271
1979
|
"message": i18n._("pub_field_summary_description"),
|
|
1272
|
-
"summary":
|
|
1980
|
+
"summary": summary,
|
|
1981
|
+
"mandatory_missing": mandatory_missing,
|
|
1982
|
+
"conditional_missing": conditional_missing,
|
|
1273
1983
|
"key_missing": key_missing,
|
|
1274
1984
|
"field_summary_token": token,
|
|
1275
1985
|
"next_actions": actions,
|
|
@@ -1438,8 +2148,12 @@ class PublishModule:
|
|
|
1438
2148
|
|
|
1439
2149
|
def _process_images_for_schema(
|
|
1440
2150
|
self, schema: MiraviaListingSchema
|
|
1441
|
-
) -> Tuple[List[str], List[str]]:
|
|
1442
|
-
"""Migrate URLs and upload local image files, returning CDN URLs.
|
|
2151
|
+
) -> Tuple[List[str], List[str], List[str]]:
|
|
2152
|
+
"""Migrate URLs and upload local image files, returning CDN URLs.
|
|
2153
|
+
|
|
2154
|
+
Returns:
|
|
2155
|
+
Tuple of (migrated_urls, failed_urls, warnings)
|
|
2156
|
+
"""
|
|
1443
2157
|
from pathlib import Path
|
|
1444
2158
|
|
|
1445
2159
|
all_images: List[str] = []
|
|
@@ -1462,7 +2176,14 @@ class PublishModule:
|
|
|
1462
2176
|
|
|
1463
2177
|
migrated: List[str] = []
|
|
1464
2178
|
failed: List[str] = []
|
|
1465
|
-
|
|
2179
|
+
warnings: List[str] = []
|
|
2180
|
+
for idx, img in enumerate(unique_images, start=1):
|
|
2181
|
+
# Check white background for local files (non-blocking warning)
|
|
2182
|
+
if not _check_white_background(img):
|
|
2183
|
+
warnings.append(
|
|
2184
|
+
i18n._("pub_white_background_warning", idx=idx)
|
|
2185
|
+
)
|
|
2186
|
+
|
|
1466
2187
|
if _is_miravia_cdn(img):
|
|
1467
2188
|
migrated.append(img)
|
|
1468
2189
|
elif img.startswith("http://") or img.startswith("https://"):
|
|
@@ -1479,7 +2200,7 @@ class PublishModule:
|
|
|
1479
2200
|
failed.append(img)
|
|
1480
2201
|
else:
|
|
1481
2202
|
failed.append(img)
|
|
1482
|
-
return migrated, failed
|
|
2203
|
+
return migrated, failed, warnings
|
|
1483
2204
|
|
|
1484
2205
|
def _build_sku_list_from_schema(
|
|
1485
2206
|
self, schema: MiraviaListingSchema
|
|
@@ -1597,6 +2318,31 @@ class PublishModule:
|
|
|
1597
2318
|
errors.extend(vr.get("errors", []))
|
|
1598
2319
|
|
|
1599
2320
|
if errors:
|
|
2321
|
+
# Check for category permission errors first
|
|
2322
|
+
has_category_permission_error = any(
|
|
2323
|
+
"category" in str(e.get("field", "")).lower()
|
|
2324
|
+
and ("permission" in str(e.get("msg", "")).lower()
|
|
2325
|
+
or "not allow" in str(e.get("msg", "")).lower()
|
|
2326
|
+
or "no access" in str(e.get("msg", "")).lower())
|
|
2327
|
+
for e in errors
|
|
2328
|
+
)
|
|
2329
|
+
if has_category_permission_error:
|
|
2330
|
+
category_id = product_payload.get("Attributes", {}).get("PrimaryCategory", "")
|
|
2331
|
+
return {
|
|
2332
|
+
"status": "need_category_permission",
|
|
2333
|
+
"title": i18n._("pub_category_permission_title"),
|
|
2334
|
+
"message": i18n._(
|
|
2335
|
+
"pub_category_permission_message",
|
|
2336
|
+
category_name=str(category_id),
|
|
2337
|
+
category_id=str(category_id),
|
|
2338
|
+
),
|
|
2339
|
+
"apply_link": i18n._("pub_category_permission_apply_link"),
|
|
2340
|
+
"next_actions": [
|
|
2341
|
+
i18n._("pub_category_permission_draft_next"),
|
|
2342
|
+
i18n._("pub_category_permission_change_next"),
|
|
2343
|
+
],
|
|
2344
|
+
}
|
|
2345
|
+
|
|
1600
2346
|
guides = self._field_definition_guides(errors)
|
|
1601
2347
|
next_actions: List[str] = [
|
|
1602
2348
|
i18n._("pub_ai_post_prevalidate_fix"),
|
|
@@ -2000,6 +2746,11 @@ class PublishModule:
|
|
|
2000
2746
|
skus: List[Dict[str, Any]] = []
|
|
2001
2747
|
for idx, sku in enumerate(data):
|
|
2002
2748
|
processed = dict(sku)
|
|
2749
|
+
# Normalize seller_sku → SellerSku (accept both snake_case and PascalCase)
|
|
2750
|
+
if "seller_sku" in processed and "SellerSku" not in processed:
|
|
2751
|
+
processed["SellerSku"] = processed.pop("seller_sku")
|
|
2752
|
+
elif "seller_sku" in processed:
|
|
2753
|
+
processed.pop("seller_sku") # SellerSku already set
|
|
2003
2754
|
if not processed.get("SellerSku"):
|
|
2004
2755
|
raise ValueError(
|
|
2005
2756
|
i18n._("pub_seller_sku_required", idx=idx + 1)
|
|
@@ -2067,6 +2818,60 @@ class PublishModule:
|
|
|
2067
2818
|
# ---------------------------------------------------------------------------
|
|
2068
2819
|
|
|
2069
2820
|
|
|
2821
|
+
def _collect_category_ids(tree: Any) -> set:
|
|
2822
|
+
"""Recursively collect all category IDs from a category tree structure.
|
|
2823
|
+
|
|
2824
|
+
Handles both dict and list structures, extracting category_id / id fields.
|
|
2825
|
+
Returns an empty set if the tree is empty or malformed.
|
|
2826
|
+
"""
|
|
2827
|
+
ids: set = set()
|
|
2828
|
+
if isinstance(tree, dict):
|
|
2829
|
+
# Try common tree root keys
|
|
2830
|
+
categories = tree.get("categories") or tree.get("data") or tree.get("children") or []
|
|
2831
|
+
if isinstance(categories, list):
|
|
2832
|
+
for cat in categories:
|
|
2833
|
+
ids |= _collect_category_ids(cat)
|
|
2834
|
+
else:
|
|
2835
|
+
# Single category node
|
|
2836
|
+
cid = tree.get("category_id") or tree.get("id")
|
|
2837
|
+
if cid:
|
|
2838
|
+
ids.add(int(cid))
|
|
2839
|
+
children = tree.get("children") or tree.get("sub_categories") or []
|
|
2840
|
+
if isinstance(children, list):
|
|
2841
|
+
ids |= _collect_category_ids(children)
|
|
2842
|
+
elif isinstance(tree, list):
|
|
2843
|
+
for item in tree:
|
|
2844
|
+
ids |= _collect_category_ids(item)
|
|
2845
|
+
return ids
|
|
2846
|
+
|
|
2847
|
+
|
|
2848
|
+
def _collect_category_permissions(tree: Any) -> Dict[int, bool]:
|
|
2849
|
+
"""Recursively collect category permission info from tree.
|
|
2850
|
+
|
|
2851
|
+
Returns a dict mapping category_id -> has_permission (bool).
|
|
2852
|
+
Permission field values: "yes" -> True, "no" -> False.
|
|
2853
|
+
"""
|
|
2854
|
+
permissions: Dict[int, bool] = {}
|
|
2855
|
+
if isinstance(tree, dict):
|
|
2856
|
+
categories = tree.get("categories") or tree.get("data") or tree.get("children") or []
|
|
2857
|
+
if isinstance(categories, list):
|
|
2858
|
+
for cat in categories:
|
|
2859
|
+
permissions.update(_collect_category_permissions(cat))
|
|
2860
|
+
else:
|
|
2861
|
+
# Single category node
|
|
2862
|
+
cid = tree.get("category_id") or tree.get("id")
|
|
2863
|
+
if cid:
|
|
2864
|
+
perm = tree.get("permission", "").lower()
|
|
2865
|
+
permissions[int(cid)] = perm == "yes"
|
|
2866
|
+
children = tree.get("children") or tree.get("sub_categories") or []
|
|
2867
|
+
if isinstance(children, list):
|
|
2868
|
+
permissions.update(_collect_category_permissions(children))
|
|
2869
|
+
elif isinstance(tree, list):
|
|
2870
|
+
for item in tree:
|
|
2871
|
+
permissions.update(_collect_category_permissions(item))
|
|
2872
|
+
return permissions
|
|
2873
|
+
|
|
2874
|
+
|
|
2070
2875
|
def _is_miravia_cdn(url: str) -> bool:
|
|
2071
2876
|
"""Check whether a URL is already on Miravia CDN."""
|
|
2072
2877
|
url_lower = url.lower()
|
|
@@ -2122,6 +2927,11 @@ def _validate_create_payload(payload: Dict[str, Any]) -> None:
|
|
|
2122
2927
|
if len(ean_codes) != len(set(ean_codes)):
|
|
2123
2928
|
raise ValueError(i18n._("pub_ean_duplicate_in_product"))
|
|
2124
2929
|
|
|
2930
|
+
# Check Seller SKU duplicate within same product
|
|
2931
|
+
seller_skus = [sku.get("SellerSku") or sku.get("seller_sku") for sku in skus if sku.get("SellerSku") or sku.get("seller_sku")]
|
|
2932
|
+
if len(seller_skus) != len(set(seller_skus)):
|
|
2933
|
+
raise ValueError(i18n._("pub_seller_sku_duplicate_in_product"))
|
|
2934
|
+
|
|
2125
2935
|
for idx, sku in enumerate(skus, start=1):
|
|
2126
2936
|
if not sku.get("SellerSku"):
|
|
2127
2937
|
raise ValueError(i18n._("pub_seller_sku_required", idx=idx))
|
|
@@ -2140,9 +2950,22 @@ def _validate_create_payload(payload: Dict[str, Any]) -> None:
|
|
|
2140
2950
|
_validate_ean(ean, idx)
|
|
2141
2951
|
|
|
2142
2952
|
# Per-SKU package dimensions/weight
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2953
|
+
weight = sku.get("package_weight")
|
|
2954
|
+
length = sku.get("package_length")
|
|
2955
|
+
width = sku.get("package_width")
|
|
2956
|
+
height = sku.get("package_height")
|
|
2957
|
+
|
|
2958
|
+
# Check: if weight is present, L/W/H must also be present
|
|
2959
|
+
if weight and not (length and width and height):
|
|
2960
|
+
raise ValueError(i18n._("pub_dimension_incomplete", idx=idx))
|
|
2961
|
+
|
|
2962
|
+
_validate_dimension(weight, "weight", _MAX_WEIGHT_KG)
|
|
2963
|
+
for dim_name, dim_value in (
|
|
2964
|
+
("package_length", length),
|
|
2965
|
+
("package_width", width),
|
|
2966
|
+
("package_height", height),
|
|
2967
|
+
):
|
|
2968
|
+
_validate_dimension(dim_value, dim_name, _MAX_DIMENSION_CM)
|
|
2146
2969
|
|
|
2147
2970
|
safety = attributes.get("safety_warning")
|
|
2148
2971
|
if safety and safety not in ("Yes", "No"):
|
|
@@ -2235,13 +3058,138 @@ def _validate_image_format(image_url: str, idx: int) -> None:
|
|
|
2235
3058
|
)
|
|
2236
3059
|
|
|
2237
3060
|
|
|
3061
|
+
def _check_white_background(image_url: str) -> bool:
|
|
3062
|
+
"""Check if image has white background.
|
|
3063
|
+
|
|
3064
|
+
Samples pixels from corners and edges. Returns True if background
|
|
3065
|
+
appears to be white (RGB >= 240), False otherwise.
|
|
3066
|
+
Only works for local files; returns True for remote URLs (skip check).
|
|
3067
|
+
"""
|
|
3068
|
+
from pathlib import Path
|
|
3069
|
+
|
|
3070
|
+
# Only check local files
|
|
3071
|
+
if not (image_url.startswith("/") or image_url.startswith("file://")):
|
|
3072
|
+
return True # Skip check for remote URLs
|
|
3073
|
+
|
|
3074
|
+
local_path = image_url.replace("file://", "")
|
|
3075
|
+
path = Path(local_path)
|
|
3076
|
+
if not path.exists():
|
|
3077
|
+
return True # Skip if file not found
|
|
3078
|
+
|
|
3079
|
+
try:
|
|
3080
|
+
from PIL import Image
|
|
3081
|
+
|
|
3082
|
+
img = Image.open(path)
|
|
3083
|
+
width, height = img.size
|
|
3084
|
+
if width < 10 or height < 10:
|
|
3085
|
+
return True # Too small to check
|
|
3086
|
+
|
|
3087
|
+
# Convert to RGB if necessary
|
|
3088
|
+
if img.mode != "RGB":
|
|
3089
|
+
img = img.convert("RGB")
|
|
3090
|
+
|
|
3091
|
+
# Sample pixels from corners and edges
|
|
3092
|
+
sample_points = [
|
|
3093
|
+
(0, 0), # Top-left
|
|
3094
|
+
(width - 1, 0), # Top-right
|
|
3095
|
+
(0, height - 1), # Bottom-left
|
|
3096
|
+
(width - 1, height - 1), # Bottom-right
|
|
3097
|
+
(width // 2, 0), # Top-center
|
|
3098
|
+
(width // 2, height - 1), # Bottom-center
|
|
3099
|
+
(0, height // 2), # Left-center
|
|
3100
|
+
(width - 1, height // 2), # Right-center
|
|
3101
|
+
]
|
|
3102
|
+
|
|
3103
|
+
white_count = 0
|
|
3104
|
+
for x, y in sample_points:
|
|
3105
|
+
pixel = img.getpixel((x, y))
|
|
3106
|
+
# Check if pixel is white or near-white (RGB >= 240)
|
|
3107
|
+
if all(c >= 240 for c in pixel[:3]):
|
|
3108
|
+
white_count += 1
|
|
3109
|
+
|
|
3110
|
+
# If 6+ out of 8 sample points are white, consider it white background
|
|
3111
|
+
return white_count >= 6
|
|
3112
|
+
|
|
3113
|
+
except Exception:
|
|
3114
|
+
return True # Skip check if PIL not available or error occurs
|
|
3115
|
+
|
|
3116
|
+
|
|
3117
|
+
# Unit conversion constants
|
|
3118
|
+
_WEIGHT_TO_KG = {
|
|
3119
|
+
"kg": 1.0,
|
|
3120
|
+
"g": 0.001,
|
|
3121
|
+
"t": 1000.0,
|
|
3122
|
+
"oz": 0.02835,
|
|
3123
|
+
"lb": 0.453592,
|
|
3124
|
+
}
|
|
3125
|
+
|
|
3126
|
+
_LENGTH_TO_CM = {
|
|
3127
|
+
"cm": 1.0,
|
|
3128
|
+
"mm": 0.1,
|
|
3129
|
+
"m": 100.0,
|
|
3130
|
+
"in": 2.54,
|
|
3131
|
+
"ft": 30.48,
|
|
3132
|
+
}
|
|
3133
|
+
|
|
3134
|
+
|
|
3135
|
+
def _convert_weight(value: str) -> float:
|
|
3136
|
+
"""Convert weight string to kg.
|
|
3137
|
+
|
|
3138
|
+
Handles: '1000 oz', '500g', '1t', '2.5 kg', etc.
|
|
3139
|
+
Returns value in kg.
|
|
3140
|
+
"""
|
|
3141
|
+
import re
|
|
3142
|
+
|
|
3143
|
+
text = str(value).strip().lower()
|
|
3144
|
+
match = re.match(r"([\d.]+)\s*(kg|g|t|oz|lb)?", text)
|
|
3145
|
+
if not match:
|
|
3146
|
+
raise ValueError(f"Invalid weight format: {value}")
|
|
3147
|
+
number = float(match.group(1))
|
|
3148
|
+
unit = match.group(2) or "kg"
|
|
3149
|
+
if unit not in _WEIGHT_TO_KG:
|
|
3150
|
+
raise ValueError(f"Unknown weight unit: {unit}")
|
|
3151
|
+
return number * _WEIGHT_TO_KG[unit]
|
|
3152
|
+
|
|
3153
|
+
|
|
3154
|
+
def _convert_length(value: str) -> float:
|
|
3155
|
+
"""Convert length string to cm.
|
|
3156
|
+
|
|
3157
|
+
Handles: '10 in', '155 mm', '1.1m', '1ft', '25.4 cm', etc.
|
|
3158
|
+
Returns value in cm.
|
|
3159
|
+
"""
|
|
3160
|
+
import re
|
|
3161
|
+
|
|
3162
|
+
text = str(value).strip().lower()
|
|
3163
|
+
match = re.match(r"([\d.]+)\s*(cm|mm|m|in|ft)?", text)
|
|
3164
|
+
if not match:
|
|
3165
|
+
raise ValueError(f"Invalid length format: {value}")
|
|
3166
|
+
number = float(match.group(1))
|
|
3167
|
+
unit = match.group(2) or "cm"
|
|
3168
|
+
if unit not in _LENGTH_TO_CM:
|
|
3169
|
+
raise ValueError(f"Unknown length unit: {unit}")
|
|
3170
|
+
return number * _LENGTH_TO_CM[unit]
|
|
3171
|
+
|
|
3172
|
+
|
|
2238
3173
|
def _validate_dimension(value: Any, name: str, maximum: int) -> None:
|
|
3174
|
+
"""Validate dimension value after unit conversion.
|
|
3175
|
+
|
|
3176
|
+
For weight: converts to kg, checks 0 < value <= _MAX_WEIGHT_KG
|
|
3177
|
+
For dimensions: converts to cm, checks 0 < value <= _MAX_DIMENSION_CM
|
|
3178
|
+
"""
|
|
2239
3179
|
if value is None:
|
|
2240
3180
|
return
|
|
2241
3181
|
try:
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
3182
|
+
# Try unit conversion first
|
|
3183
|
+
if name == "weight":
|
|
3184
|
+
number = _convert_weight(value)
|
|
3185
|
+
else:
|
|
3186
|
+
number = _convert_length(value)
|
|
3187
|
+
except (TypeError, ValueError, AttributeError) as exc:
|
|
3188
|
+
# Fallback to simple float conversion
|
|
3189
|
+
try:
|
|
3190
|
+
number = float(value)
|
|
3191
|
+
except (TypeError, ValueError) as exc2:
|
|
3192
|
+
raise ValueError(i18n._("pub_invalid_dimension", name=name, value=value)) from exc2
|
|
2245
3193
|
if number <= 0 or number > maximum:
|
|
2246
3194
|
raise ValueError(
|
|
2247
3195
|
i18n._(
|
|
@@ -2253,6 +3201,77 @@ def _validate_dimension(value: Any, name: str, maximum: int) -> None:
|
|
|
2253
3201
|
)
|
|
2254
3202
|
|
|
2255
3203
|
|
|
3204
|
+
# Safe HTML tags for product descriptions (Miravia product editor supports these)
|
|
3205
|
+
_SAFE_HTML_TAGS = frozenset({
|
|
3206
|
+
"p", "br", "b", "strong", "i", "em", "u",
|
|
3207
|
+
"ul", "ol", "li",
|
|
3208
|
+
"h1", "h2", "h3", "h4", "h5", "h6",
|
|
3209
|
+
"table", "tr", "td", "th", "thead", "tbody",
|
|
3210
|
+
"a", "img",
|
|
3211
|
+
})
|
|
3212
|
+
|
|
3213
|
+
|
|
3214
|
+
def _sanitize_html(html: str) -> tuple:
|
|
3215
|
+
"""Sanitize HTML content for product description.
|
|
3216
|
+
|
|
3217
|
+
Keeps only safe tags (p, br, b, ul, li, etc.) and removes:
|
|
3218
|
+
- <script>, <iframe>, <object>, <embed> and similar
|
|
3219
|
+
- style attributes, onclick/onerror handlers
|
|
3220
|
+
- <div> with style attributes
|
|
3221
|
+
|
|
3222
|
+
Returns:
|
|
3223
|
+
Tuple of (cleaned_html, was_cleaned: bool)
|
|
3224
|
+
"""
|
|
3225
|
+
import re
|
|
3226
|
+
|
|
3227
|
+
if not html:
|
|
3228
|
+
return html, False
|
|
3229
|
+
|
|
3230
|
+
original = html
|
|
3231
|
+
cleaned = html
|
|
3232
|
+
|
|
3233
|
+
# Remove dangerous tags entirely (including content)
|
|
3234
|
+
dangerous_tags = ["script", "iframe", "object", "embed", "applet", "form", "input"]
|
|
3235
|
+
for tag in dangerous_tags:
|
|
3236
|
+
cleaned = re.sub(
|
|
3237
|
+
rf"<{tag}[^>]*>.*?</{tag}>",
|
|
3238
|
+
"",
|
|
3239
|
+
cleaned,
|
|
3240
|
+
flags=re.IGNORECASE | re.DOTALL,
|
|
3241
|
+
)
|
|
3242
|
+
# Also remove self-closing or unclosed dangerous tags
|
|
3243
|
+
cleaned = re.sub(
|
|
3244
|
+
rf"<{tag}[^>]*/?>",
|
|
3245
|
+
"",
|
|
3246
|
+
cleaned,
|
|
3247
|
+
flags=re.IGNORECASE,
|
|
3248
|
+
)
|
|
3249
|
+
|
|
3250
|
+
# Remove style attributes from all tags
|
|
3251
|
+
cleaned = re.sub(r'\s+style\s*=\s*["\'][^"\']*["\']', "", cleaned, flags=re.IGNORECASE)
|
|
3252
|
+
|
|
3253
|
+
# Remove event handlers (onclick, onerror, onload, etc.)
|
|
3254
|
+
cleaned = re.sub(r'\s+on\w+\s*=\s*["\'][^"\']*["\']', "", cleaned, flags=re.IGNORECASE)
|
|
3255
|
+
|
|
3256
|
+
# Remove <div> tags but keep content (div is not in safe list but content is useful)
|
|
3257
|
+
cleaned = re.sub(r"</?div[^>]*>", "", cleaned, flags=re.IGNORECASE)
|
|
3258
|
+
|
|
3259
|
+
# Remove any tags not in the safe list
|
|
3260
|
+
def _replace_unsafe_tag(match):
|
|
3261
|
+
full_match = match.group(0)
|
|
3262
|
+
tag_match = re.match(r"</?([a-zA-Z][a-zA-Z0-9]*)", full_match)
|
|
3263
|
+
if tag_match:
|
|
3264
|
+
tag_name = tag_match.group(1).lower()
|
|
3265
|
+
if tag_name not in _SAFE_HTML_TAGS:
|
|
3266
|
+
return "" # Remove unsafe tag
|
|
3267
|
+
return full_match # Keep safe tag
|
|
3268
|
+
|
|
3269
|
+
cleaned = re.sub(r"</?[a-zA-Z][a-zA-Z0-9]*[^>]*>", _replace_unsafe_tag, cleaned)
|
|
3270
|
+
|
|
3271
|
+
was_cleaned = cleaned != original
|
|
3272
|
+
return cleaned, was_cleaned
|
|
3273
|
+
|
|
3274
|
+
|
|
2256
3275
|
def _load_preview_secret() -> bytes:
|
|
2257
3276
|
"""Return a machine-local secret for publish preview tokens.
|
|
2258
3277
|
|