@miravia_test/miravia-publish-skill 0.1.2 → 0.1.3

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.
@@ -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 modules.extraction_engine import (
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 modules.file_reader import extract_text, read_sources
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
 
@@ -273,6 +278,10 @@ class PublishModule:
273
278
  if skus:
274
279
  product_payload["Skus"] = {"Sku": skus}
275
280
 
281
+ # Draft mode — set Status to inactive
282
+ if draft_requested:
283
+ product_payload["Status"] = "inactive"
284
+
276
285
  # Inject safety warning from CLI flags into Attributes
277
286
  safety_warning_arg = getattr(self.args, "safety_warning", None)
278
287
  if safety_warning_arg is not None:
@@ -398,7 +407,27 @@ class PublishModule:
398
407
  for m in gpsr_missing:
399
408
  draft_issues.append(f"NOTE: {m}")
400
409
 
401
- # Check postToInactive fields (EAN, compliance_documents, unit_count, food_attributes)
410
+ # Stage 3b: unified compliance check (Safety Warning + compliance docs)
411
+ compliance_check = self._check_compliance_fields(
412
+ product_payload,
413
+ category_id=category_id,
414
+ draft_requested=draft_requested,
415
+ draft_issues=draft_issues,
416
+ )
417
+ if compliance_check:
418
+ return compliance_check
419
+
420
+ # Stage 3c: special category rules (PPU / ISBN / food attributes)
421
+ special_check = self._check_special_category_rules(
422
+ product_payload,
423
+ category_id=category_id,
424
+ draft_requested=draft_requested,
425
+ draft_issues=draft_issues,
426
+ )
427
+ if special_check:
428
+ return special_check
429
+
430
+ # Check postToInactive fields (EAN only; PPU/food moved to Stage 3c)
402
431
  post_to_inactive_result = self._check_post_to_inactive_fields(
403
432
  product_payload, str(category_id), draft_requested, draft_issues
404
433
  )
@@ -450,37 +479,6 @@ class PublishModule:
450
479
  "Please fill it or publish to Draft."
451
480
  )
452
481
 
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
482
  # Handle postToInactive missing fields
485
483
  if post_to_inactive_missing:
486
484
  if not draft_requested:
@@ -499,6 +497,354 @@ class PublishModule:
499
497
 
500
498
  return None
501
499
 
500
+ def _check_compliance_fields(
501
+ self,
502
+ product_payload: Dict[str, Any],
503
+ category_attributes: Optional[List[Dict[str, Any]]] = None,
504
+ *,
505
+ category_id: Optional[Any] = None,
506
+ schema: Optional[Any] = None,
507
+ draft_requested: bool = False,
508
+ draft_issues: Optional[List[str]] = None,
509
+ ) -> Optional[Dict[str, Any]]:
510
+ """Unified compliance validation after payload is built.
511
+
512
+ Checks:
513
+ 1. GPSR (manufacturer + EU responsible) — for AI flow
514
+ 2. Safety Warning linkage (Yes → content required)
515
+ 3. Compliance documents (category-mandatory → required)
516
+
517
+ Returns a ``compliance_missing_suggest_draft`` response when any
518
+ compliance field is missing, or ``None`` when all are satisfied.
519
+ """
520
+ compliance_issues: List[str] = []
521
+ attributes = product_payload.get("Attributes") or {}
522
+
523
+ # Fetch category attributes if not provided (manual flow)
524
+ if category_attributes is None and category_id is not None:
525
+ lang = self.args.lang if self.args.lang not in (None, "auto") else "en_US"
526
+ try:
527
+ category_attributes = self.product.get_category_attributes(
528
+ str(category_id), language_code=lang
529
+ )
530
+ except Exception:
531
+ category_attributes = []
532
+ if category_attributes is None:
533
+ category_attributes = []
534
+
535
+ # --- 1. GPSR check (mainly for AI flow; manual flow has inline check) ---
536
+ mfr_name = ""
537
+ eurp_name = ""
538
+ if schema:
539
+ mfr_name = schema.manufacturer_name or ""
540
+ eurp_name = schema.eu_responsible_person_name or ""
541
+ else:
542
+ mfr_name = getattr(self.args, "manufacturer_name", None) or ""
543
+ eurp_name = getattr(self.args, "eu_responsible_person_name", None) or ""
544
+
545
+ if not mfr_name and not eurp_name:
546
+ compliance_issues.append(
547
+ i18n._("pub_compliance_manufacturer_eurp_both_empty")
548
+ )
549
+ elif mfr_name and not eurp_name:
550
+ compliance_issues.append(
551
+ i18n._("pub_compliance_eurp_missing_with_manufacturer")
552
+ )
553
+
554
+ # --- 2. Safety Warning linkage ---
555
+ safety_val = (
556
+ attributes.get("Does this product have a safety warning")
557
+ or attributes.get("Does_this_product_have_a_safety_warning")
558
+ or ""
559
+ )
560
+ if safety_val == "Yes":
561
+ safety_content = attributes.get("Safety_warning_content") or ""
562
+ if not safety_content.strip():
563
+ compliance_issues.append(
564
+ i18n._("pub_safety_warning_content_missing")
565
+ )
566
+
567
+ # --- 3. Compliance documents (category-mandatory) ---
568
+ compliance_docs = None
569
+ if schema:
570
+ compliance_docs = schema.compliance_documents or None
571
+ else:
572
+ compliance_docs = getattr(self.args, "compliance_documents", None) or None
573
+
574
+ compliance_mandatory = self._is_category_attribute_mandatory(
575
+ category_attributes, "compliance_documents"
576
+ )
577
+ if compliance_mandatory and not compliance_docs:
578
+ compliance_issues.append(
579
+ i18n._("pub_compliance_docs_missing")
580
+ )
581
+
582
+ if not compliance_issues:
583
+ return None
584
+
585
+ if not draft_requested:
586
+ return {
587
+ "status": "compliance_missing_suggest_draft",
588
+ "message": i18n._("pub_compliance_missing_message"),
589
+ "compliance_issues": compliance_issues,
590
+ "next_actions": [
591
+ i18n._("pub_compliance_fill_or_draft"),
592
+ i18n._("pub_draft_option_hint"),
593
+ ],
594
+ }
595
+ else:
596
+ if draft_issues is not None:
597
+ for issue in compliance_issues:
598
+ draft_issues.append(f"NOTE: {issue}")
599
+ return None
600
+
601
+ def _check_special_category_rules(
602
+ self,
603
+ product_payload: Dict[str, Any],
604
+ category_attributes: Optional[List[Dict[str, Any]]] = None,
605
+ *,
606
+ category_id: Optional[Any] = None,
607
+ schema: Optional[Any] = None,
608
+ draft_requested: bool = False,
609
+ draft_issues: Optional[List[str]] = None,
610
+ ) -> Optional[Dict[str, Any]]:
611
+ """Validate special category rules: PPU, ISBN, food attributes.
612
+
613
+ Returns:
614
+ - ``special_category_block`` for hard errors (invalid PPU value, ISBN missing for books)
615
+ - ``special_category_suggest_draft`` for missing fields (PPU missing, food attrs missing)
616
+ - ``None`` when all rules are satisfied.
617
+ """
618
+ attributes = product_payload.get("Attributes") or {}
619
+
620
+ # Fetch category attributes if not provided
621
+ if category_attributes is None and category_id is not None:
622
+ lang = self.args.lang if self.args.lang not in (None, "auto") else "en_US"
623
+ try:
624
+ category_attributes = self.product.get_category_attributes(
625
+ str(category_id), language_code=lang
626
+ )
627
+ except Exception:
628
+ category_attributes = []
629
+ if category_attributes is None:
630
+ category_attributes = []
631
+
632
+ # --- 1. PPU (Price Per Unit): standard_unit_type + unit_count ---
633
+ ppu_missing: List[str] = []
634
+ ppu_invalid: List[str] = []
635
+
636
+ # Get values from schema or CLI args
637
+ if schema:
638
+ unit_type = schema.standard_unit_type or ""
639
+ unit_count_val = schema.unit_count
640
+ else:
641
+ unit_type = getattr(self.args, "standard_unit_type", None) or getattr(
642
+ self.args, "unit_count_type", None
643
+ ) or ""
644
+ unit_count_val = getattr(self.args, "unit_count", None)
645
+
646
+ # Also check payload attributes as fallback
647
+ if not unit_type:
648
+ unit_type = (
649
+ attributes.get("unit_count_type")
650
+ or attributes.get("standard_unit_type")
651
+ or ""
652
+ )
653
+ if unit_count_val is None:
654
+ unit_count_val = attributes.get("unit_count")
655
+
656
+ unit_count_str = str(unit_count_val).strip() if unit_count_val is not None else ""
657
+
658
+ # Check if PPU is category-mandatory
659
+ ppu_mandatory = (
660
+ self._is_category_attribute_mandatory(category_attributes, "unit_count")
661
+ or self._is_category_attribute_mandatory(
662
+ category_attributes, "unit_count_type"
663
+ )
664
+ )
665
+
666
+ if ppu_mandatory or unit_type or unit_count_str:
667
+ # Check missing
668
+ if not unit_type and not unit_count_str:
669
+ ppu_missing.append(
670
+ i18n._("pub_ppu_both_missing")
671
+ )
672
+ elif not unit_type:
673
+ ppu_missing.append(
674
+ i18n._("pub_ppu_type_missing")
675
+ )
676
+ elif not unit_count_str:
677
+ ppu_missing.append(
678
+ i18n._("pub_ppu_count_missing")
679
+ )
680
+ else:
681
+ # Both present — validate unit_count value
682
+ try:
683
+ uc = float(unit_count_str)
684
+ if uc <= 0:
685
+ ppu_invalid.append(
686
+ i18n._("pub_ppu_count_invalid", value=unit_count_str)
687
+ )
688
+ except (TypeError, ValueError):
689
+ ppu_invalid.append(
690
+ i18n._("pub_ppu_count_not_number", value=unit_count_str)
691
+ )
692
+
693
+ # --- 2. ISBN for book categories ---
694
+ isbn_missing = False
695
+ isbn_mandatory = self._is_category_attribute_mandatory(
696
+ category_attributes, "isbn"
697
+ )
698
+ if isbn_mandatory:
699
+ isbn_val = None
700
+ if schema:
701
+ isbn_val = schema.isbn
702
+ else:
703
+ isbn_val = getattr(self.args, "isbn", None)
704
+ if not isbn_val:
705
+ # Also check payload attributes
706
+ isbn_val = attributes.get("isbn")
707
+ if not isbn_val:
708
+ isbn_missing = True
709
+
710
+ # --- 3. Food & health attributes ---
711
+ food_missing = False
712
+ food_mandatory = self._category_looks_like_food_health(category_attributes)
713
+ if food_mandatory:
714
+ food_val = None
715
+ if schema:
716
+ food_val = schema.sku_attributes_food_health
717
+ else:
718
+ food_val = getattr(self.args, "sku_attributes_food_health", None) or None
719
+ if not food_val:
720
+ food_missing = True
721
+
722
+ # --- Build response ---
723
+ # Hard blocks first (invalid values, ISBN missing for books)
724
+ if ppu_invalid:
725
+ return {
726
+ "status": "special_category_block",
727
+ "message": i18n._("pub_ppu_validation_failed"),
728
+ "block_issues": ppu_invalid,
729
+ "next_actions": [i18n._("pub_ppu_fix_and_rerun")],
730
+ }
731
+
732
+ if isbn_missing:
733
+ return {
734
+ "status": "special_category_block",
735
+ "message": i18n._("pub_isbn_required_for_books"),
736
+ "block_issues": [i18n._("pub_isbn_missing")],
737
+ "next_actions": [i18n._("pub_isbn_fix_and_rerun")],
738
+ }
739
+
740
+ # Draft suggestions (missing fields)
741
+ draft_issues_list: List[str] = []
742
+ if ppu_missing:
743
+ draft_issues_list.extend(ppu_missing)
744
+ if food_missing:
745
+ draft_issues_list.append(i18n._("pub_food_attrs_missing"))
746
+
747
+ if not draft_issues_list:
748
+ return None
749
+
750
+ if not draft_requested:
751
+ return {
752
+ "status": "special_category_suggest_draft",
753
+ "message": i18n._("pub_special_category_missing_message"),
754
+ "missing_fields": draft_issues_list,
755
+ "next_actions": [
756
+ i18n._("pub_special_category_fill_or_draft"),
757
+ i18n._("pub_draft_option_hint"),
758
+ ],
759
+ }
760
+ else:
761
+ if draft_issues is not None:
762
+ for issue in draft_issues_list:
763
+ draft_issues.append(f"NOTE: {issue}")
764
+ return None
765
+
766
+ def _check_dbm_limits(
767
+ self,
768
+ product_payload: Dict[str, Any],
769
+ *,
770
+ has_dbs_permission: bool = False,
771
+ ) -> Optional[Dict[str, Any]]:
772
+ """Check if package exceeds DBM limits and auto-switch to DBS.
773
+
774
+ DBM limits:
775
+ - Weight <= 40KG
776
+ - L+W+H <= 300cm
777
+ - Volumetric weight (L*W*H/6000) <= 40
778
+
779
+ If any limit is exceeded and delivery_method is DBM:
780
+ - Seller has DBS permission → auto-switch to DBS
781
+ - Seller has no DBS permission → return error (block)
782
+ Returns a dict with result, None if within limits.
783
+ """
784
+ attrs = product_payload.get("Attributes") or {}
785
+ delivery_method = attrs.get("delivery_method") or attrs.get("Delivery_method") or ""
786
+
787
+ # Only check if delivery method is DBM
788
+ if delivery_method.upper() != "DBM":
789
+ return None
790
+
791
+ skus = (product_payload.get("Skus") or {}).get("Sku") or []
792
+ if not skus:
793
+ return None
794
+
795
+ # Check first SKU (DBM/DBS is product-level, same for all SKUs)
796
+ sku = skus[0]
797
+ weight = float(sku.get("package_weight") or 0)
798
+ length = float(sku.get("package_length") or 0)
799
+ width = float(sku.get("package_width") or 0)
800
+ height = float(sku.get("package_height") or 0)
801
+
802
+ if not (weight and length and width and height):
803
+ return None
804
+
805
+ dim_sum = length + width + height
806
+ volume_weight = (length * width * height) / 6000
807
+
808
+ exceeded: List[str] = []
809
+ if weight > _DBM_MAX_WEIGHT_KG:
810
+ exceeded.append(
811
+ i18n._("pub_dbm_weight_exceeded", weight=f"{weight}kg", limit=_DBM_MAX_WEIGHT_KG)
812
+ )
813
+ if dim_sum > _DBM_MAX_DIM_SUM_CM:
814
+ exceeded.append(
815
+ i18n._("pub_dbm_dim_sum_exceeded", dim_sum=f"{dim_sum}cm", limit=_DBM_MAX_DIM_SUM_CM)
816
+ )
817
+ if volume_weight > _DBM_MAX_VOLUME_WEIGHT:
818
+ exceeded.append(
819
+ i18n._("pub_dbm_volume_exceeded", volume=f"{volume_weight:.1f}", limit=_DBM_MAX_VOLUME_WEIGHT)
820
+ )
821
+
822
+ if not exceeded:
823
+ return None
824
+
825
+ # Exceeded DBM limits — check DBS permission
826
+ if has_dbs_permission:
827
+ # Auto-switch to DBS
828
+ attrs["delivery_method"] = "DBS"
829
+ attrs["Delivery_method"] = "DBS"
830
+ # Also update SKU-level if present
831
+ for s in skus:
832
+ if s.get("delivery_method"):
833
+ s["delivery_method"] = "DBS"
834
+ return {
835
+ "switched_to_dbs": True,
836
+ "reasons": exceeded,
837
+ "message": i18n._("pub_dbm_auto_switch_to_dbs"),
838
+ }
839
+ else:
840
+ # No DBS permission — block with error
841
+ return {
842
+ "switched_to_dbs": False,
843
+ "blocked": True,
844
+ "reasons": exceeded,
845
+ "message": i18n._("pub_dbm_no_dbs_permission"),
846
+ }
847
+
502
848
  def _finalize_create(
503
849
  self,
504
850
  product_payload: Dict[str, Any],
@@ -511,6 +857,26 @@ class PublishModule:
511
857
  """Validate, preview, pre-validate, and execute product creation."""
512
858
  _validate_create_payload(product_payload)
513
859
 
860
+ # Check DBM limits — auto-switch to DBS if exceeded (requires DBS permission)
861
+ has_dbs = self.product.get_shipping_permission()
862
+ dbm_result = self._check_dbm_limits(product_payload, has_dbs_permission=has_dbs)
863
+ if dbm_result:
864
+ if dbm_result.get("blocked"):
865
+ # No DBS permission and exceeds DBM limits — block
866
+ return {
867
+ "status": "dbm_limit_exceeded_no_dbs",
868
+ "message": dbm_result["message"],
869
+ "reasons": dbm_result.get("reasons", []),
870
+ "next_actions": [
871
+ i18n._("pub_dbm_fix_or_apply_dbs"),
872
+ ],
873
+ }
874
+ if dbm_result.get("switched_to_dbs"):
875
+ if draft_issues is not None:
876
+ draft_issues.append(dbm_result["message"])
877
+ for reason in dbm_result.get("reasons", []):
878
+ draft_issues.append(f" - {reason}")
879
+
514
880
  # Run pre-validate early so errors are visible in the preview
515
881
  prevalidate_result = self._run_prevalidate(product_payload)
516
882
  prevalidate_errors = []
@@ -594,6 +960,17 @@ class PublishModule:
594
960
  i18n._("pub_brand_permission_draft_suggestion", code=exc.code),
595
961
  ],
596
962
  }
963
+ # SKU already exists — concurrency conflict or duplicate SKU
964
+ if exc.code == "SKU_ALREADY_EXISTS":
965
+ return {
966
+ "status": "sku_already_exists",
967
+ "message": i18n._("pub_sku_already_exists"),
968
+ "code": exc.code,
969
+ "hint": exc.hint,
970
+ "next_actions": [
971
+ i18n._("pub_sku_already_exists_next"),
972
+ ],
973
+ }
597
974
  # Code 4234: draft product created but missing required fields
598
975
  if exc.code == "4234" and draft:
599
976
  err_data = (exc.payload.get("data") or {}) if exc.payload else {}
@@ -630,16 +1007,112 @@ class PublishModule:
630
1007
  # AI-assisted create flow
631
1008
  # ==================================================================
632
1009
 
1010
+ def _extract_product_summaries(
1011
+ self, content_items: List[Dict[str, Any]]
1012
+ ) -> List[Dict[str, Any]]:
1013
+ """Extract basic product info from each Excel row for display.
1014
+
1015
+ Returns a list of dicts with index, title, price, sku fields.
1016
+ """
1017
+ summaries: List[Dict[str, Any]] = []
1018
+ idx = 1
1019
+ for item in content_items:
1020
+ if item.get("type") != "table":
1021
+ continue
1022
+ content = item.get("content")
1023
+ if not content:
1024
+ continue
1025
+ tables = content if isinstance(content, list) else [content]
1026
+ for table in tables:
1027
+ rows = []
1028
+ if isinstance(table, dict) and "rows" in table:
1029
+ rows = table.get("rows", [])
1030
+ elif isinstance(table, list):
1031
+ rows = table
1032
+ for row in rows:
1033
+ # Try to extract common product fields
1034
+ title = (
1035
+ row.get("product_title")
1036
+ or row.get("title")
1037
+ or row.get("name")
1038
+ or row.get("product_name")
1039
+ or ""
1040
+ )
1041
+ price = (
1042
+ row.get("general_price")
1043
+ or row.get("price")
1044
+ or row.get("original_price")
1045
+ or ""
1046
+ )
1047
+ sku = (
1048
+ row.get("seller_sku")
1049
+ or row.get("SellerSku")
1050
+ or row.get("sku")
1051
+ or ""
1052
+ )
1053
+ summaries.append({
1054
+ "index": idx,
1055
+ "title": str(title) if title else "",
1056
+ "price": str(price) if price else "",
1057
+ "sku": str(sku) if sku else "",
1058
+ })
1059
+ idx += 1
1060
+ return summaries
1061
+
633
1062
  def _run_create_from_source(self) -> Dict[str, Any]:
634
1063
  """AI posting: read files/URLs, extract fields, confirm, and publish."""
635
1064
  sources = getattr(self.args, "source", []) or []
636
1065
  if not sources:
637
1066
  raise ValueError(i18n._("pub_ai_post_requires_source"))
638
1067
 
639
- content_items = read_sources(sources)
1068
+ try:
1069
+ content_items = read_sources(sources)
1070
+ except ValueError as exc:
1071
+ msg = str(exc)
1072
+ # Map file_reader ValueError messages to i18n status codes
1073
+ if "Unsupported file format" in msg:
1074
+ return {
1075
+ "status": "unsupported_format",
1076
+ "message": i18n._("pub_unsupported_format", source=sources[0]),
1077
+ "next_actions": [i18n._("pub_ai_post_requires_source")],
1078
+ }
1079
+ if "empty" in msg.lower() or "corrupted" in msg.lower():
1080
+ return {
1081
+ "status": "file_empty_or_corrupted",
1082
+ "message": i18n._("pub_file_empty_or_corrupted", source=sources[0]),
1083
+ "next_actions": [i18n._("pub_ai_post_requires_source")],
1084
+ }
1085
+ if "Failed to fetch URL" in msg or "fetch" in msg.lower():
1086
+ return {
1087
+ "status": "invalid_url",
1088
+ "message": i18n._("pub_url_fetch_failed", source=sources[0], error=msg),
1089
+ "next_actions": [i18n._("pub_ai_post_requires_source")],
1090
+ }
1091
+ # Fallback: return the raw error message
1092
+ return {
1093
+ "status": "file_read_error",
1094
+ "message": msg,
1095
+ "next_actions": [i18n._("pub_ai_post_requires_source")],
1096
+ }
640
1097
  if not content_items:
641
1098
  raise ValueError(i18n._("pub_ai_post_no_content"))
642
1099
 
1100
+ # Check for multiple products in Excel
1101
+ row_count = get_excel_row_count(content_items)
1102
+ if row_count > 1:
1103
+ # Extract basic info from each row for display
1104
+ product_summaries = self._extract_product_summaries(content_items)
1105
+ return {
1106
+ "status": "multiple_products_detected",
1107
+ "message": i18n._("pub_multiple_products_detected", count=row_count),
1108
+ "product_summaries": product_summaries,
1109
+ "next_actions": [
1110
+ i18n._("pub_multiple_products_select_index"),
1111
+ i18n._("pub_multiple_products_select_all"),
1112
+ i18n._("pub_multiple_products_cancel"),
1113
+ ],
1114
+ }
1115
+
643
1116
  source_text = extract_text(content_items)
644
1117
  engine = ExtractionEngine(LLMClient())
645
1118
  schema = engine.extract(source_text)
@@ -669,7 +1142,12 @@ class PublishModule:
669
1142
 
670
1143
  title_err = validate_title(schema.product_title)
671
1144
  if title_err:
672
- raise ValueError(title_err)
1145
+ return {
1146
+ "status": "title_invalid",
1147
+ "message": title_err,
1148
+ "format_suggestion": i18n._("pub_title_format_suggestion"),
1149
+ "next_actions": [i18n._("pub_ai_post_requires_source")],
1150
+ }
673
1151
 
674
1152
  ctb_preview = {
675
1153
  "stage": "ctb",
@@ -702,7 +1180,7 @@ class PublishModule:
702
1180
  if compliance_result.get("status"):
703
1181
  return compliance_result
704
1182
 
705
- images, failed_images = self._process_images_for_schema(schema)
1183
+ images, failed_images, image_warnings = self._process_images_for_schema(schema)
706
1184
  if failed_images:
707
1185
  return {
708
1186
  "status": "need_images",
@@ -724,6 +1202,14 @@ class PublishModule:
724
1202
 
725
1203
  draft_requested = getattr(self.args, "draft", False)
726
1204
 
1205
+ # Sanitize HTML in long description before building payload
1206
+ html_cleaned = False
1207
+ if schema.product_long_description:
1208
+ sanitized, was_cleaned = _sanitize_html(schema.product_long_description)
1209
+ if was_cleaned:
1210
+ html_cleaned = True
1211
+ schema.product_long_description = sanitized
1212
+
727
1213
  product_payload = self.product.build_create_product_payload(
728
1214
  schema.to_dict(),
729
1215
  category_id=category_id,
@@ -732,11 +1218,33 @@ class PublishModule:
732
1218
  draft=draft_requested,
733
1219
  )
734
1220
 
1221
+ # Stage 3b: unified compliance check (GPSR + Safety Warning + compliance docs)
1222
+ draft_issues: list = []
1223
+ compliance_check = self._check_compliance_fields(
1224
+ product_payload,
1225
+ category_attributes,
1226
+ schema=schema,
1227
+ draft_requested=draft_requested,
1228
+ draft_issues=draft_issues,
1229
+ )
1230
+ if compliance_check:
1231
+ return compliance_check
1232
+
1233
+ # Stage 3c: special category rules (PPU / ISBN / food attributes)
1234
+ special_check = self._check_special_category_rules(
1235
+ product_payload,
1236
+ category_attributes,
1237
+ schema=schema,
1238
+ draft_requested=draft_requested,
1239
+ draft_issues=draft_issues,
1240
+ )
1241
+ if special_check:
1242
+ return special_check
1243
+
735
1244
  # Stage 4a: check category-specific mandatory attributes
736
1245
  cat_attr_result = self._check_category_mandatory_attributes(
737
1246
  product_payload, category_attributes, category_id
738
1247
  )
739
- draft_issues: list = []
740
1248
  if cat_attr_result:
741
1249
  if draft_requested:
742
1250
  for attr in cat_attr_result.get("missing_attributes", []):
@@ -768,6 +1276,10 @@ class PublishModule:
768
1276
  schema, product_payload, compliance_result, category_attributes
769
1277
  )
770
1278
  country_price_note = self._check_country_price_recommendation(schema)
1279
+
1280
+ # Check for >2 sales attributes (variation_3+)
1281
+ variation_warnings = self._check_extra_variations(schema)
1282
+
771
1283
  fs_preview = {
772
1284
  "stage": "field_summary",
773
1285
  "category_id": int(category_id),
@@ -775,6 +1287,10 @@ class PublishModule:
775
1287
  "brand_id": brand_id,
776
1288
  "key_missing": sorted(key_missing),
777
1289
  "draft": draft_requested,
1290
+ "image_warnings": image_warnings,
1291
+ "variation_warnings": variation_warnings,
1292
+ "html_cleaned": html_cleaned,
1293
+ "description_empty": not schema.product_long_description,
778
1294
  }
779
1295
  expected_fs_token = _make_publish_preview_token(fs_preview)
780
1296
  provided_fs_token = getattr(self.args, "field_summary_token", None)
@@ -801,81 +1317,175 @@ class PublishModule:
801
1317
  # ------------------------------------------------------------------
802
1318
 
803
1319
  _TRACKED_FIELDS = [
804
- ("product_title", "Product title"),
805
- ("product_images", "Product images"),
806
- ("category_id", "Category"),
807
- ("brand_id", "Brand"),
808
- ("general_price", "General price"),
809
- ("italy_price", "Italy price"),
810
- ("seller_sku", "Seller SKU"),
811
- ("ean_code", "EAN code"),
812
- ("weight_kg", "Weight"),
813
- ("length_cm", "Length"),
814
- ("width_cm", "Width"),
815
- ("height_cm", "Height"),
816
- ("manufacturer", "Manufacturer"),
817
- ("eu_responsible_person", "EU responsible person"),
818
- ("safety_warning", "Safety warning"),
819
- ("package_photos", "Package photos"),
820
- ("compliance_documents", "Compliance documents"),
1320
+ # Mandatory fields — missing blocks publish entirely
1321
+ ("product_title", "Product title", "mandatory"),
1322
+ ("product_images", "Product images", "mandatory"),
1323
+ ("category_id", "Category", "mandatory"),
1324
+ ("brand_id", "Brand", "mandatory"),
1325
+ ("general_price", "General price", "mandatory"),
1326
+ ("stock", "Stock", "mandatory"),
1327
+ ("seller_sku", "Seller SKU", "mandatory"),
1328
+ ("weight_kg", "Weight", "mandatory"),
1329
+ ("length_cm", "Length", "mandatory"),
1330
+ ("width_cm", "Width", "mandatory"),
1331
+ ("height_cm", "Height", "mandatory"),
1332
+ ("delivery_method", "Delivery method", "mandatory"),
1333
+ # Conditional fields — missing suggests Draft
1334
+ ("italy_price", "Italy price", "conditional"),
1335
+ ("ean_code", "EAN code", "conditional"),
1336
+ ("manufacturer", "Manufacturer", "conditional"),
1337
+ ("eu_responsible_person", "EU responsible person", "conditional"),
1338
+ ("safety_warning", "Safety warning", "conditional"),
1339
+ ("package_photos", "Package photos", "conditional"),
1340
+ ("compliance_documents", "Compliance documents", "conditional"),
1341
+ ("standard_unit_type", "Unit count type", "conditional"),
1342
+ ("unit_count", "Unit count", "conditional"),
1343
+ ("sku_attributes_food_health", "Food & health attributes", "conditional"),
1344
+ ("isbn", "ISBN", "conditional"),
821
1345
  ]
822
1346
 
823
1347
  def _resolve_category_for_schema(
824
1348
  self, schema: MiraviaListingSchema
825
1349
  ) -> Dict[str, Any]:
826
- """Resolve category from extracted name/ID or CLI args."""
827
- confirmed_category_id = getattr(self.args, "confirmed_category_id", None)
828
- if confirmed_category_id:
829
- return {"category_id": int(confirmed_category_id)}
1350
+ """Resolve category from extracted name/ID, CLI args, or recommendation.
830
1351
 
831
- if self.args.category_id:
832
- return {"category_id": int(self.args.category_id)}
1352
+ Priority:
1353
+ 1. --confirmed-category-id: validated against tree
1354
+ 2. --category-id / schema.category_id: validated against tree
1355
+ 3. schema.category_name: keyword search
1356
+ 4. Recommend API: title/images → Top1/TopN/empty
1357
+ 5. Fallback: full category tree
833
1358
 
834
- if schema.category_id:
835
- return {"category_id": int(schema.category_id)}
1359
+ All "invalid" paths try recommend API before falling back to tree.
1360
+ """
1361
+ # Fetch category tree once — used for validation and fallback display
1362
+ lang = self.args.lang if self.args.lang not in (None, "auto") else "en"
1363
+ category_tree: Dict[str, Any] = {}
1364
+ valid_ids: set = set()
1365
+ category_permissions: Dict[int, bool] = {}
1366
+ try:
1367
+ category_tree = self.product.get_category_tree(language_code=lang)
1368
+ valid_ids = _collect_category_ids(category_tree)
1369
+ category_permissions = _collect_category_permissions(category_tree)
1370
+ except MiraviaError as exc:
1371
+ logger.warning("Failed to fetch category tree: %s", exc)
836
1372
 
837
- category_name = schema.category_name
838
- if not category_name:
839
- return {
1373
+ def _need_category_response(
1374
+ reason_msg: str,
1375
+ choices: Optional[List[Dict[str, Any]]] = None,
1376
+ ) -> Dict[str, Any]:
1377
+ resp: Dict[str, Any] = {
840
1378
  "status": "need_category",
841
- "message": i18n._("pub_ai_post_need_category"),
1379
+ "message": reason_msg,
1380
+ "category_tree": category_tree,
1381
+ "category_permissions": category_permissions,
842
1382
  "next_actions": [i18n._("pub_ai_post_provide_category")],
843
1383
  }
1384
+ if choices:
1385
+ resp["choices"] = choices
1386
+ resp["next_actions"] = [
1387
+ i18n._(
1388
+ "pub_category_confirm_next", category_id=c.get("category_id")
1389
+ )
1390
+ for c in choices
1391
+ ]
1392
+ return resp
1393
+
1394
+ def _try_recommend_or_tree(
1395
+ fallback_msg: str,
1396
+ ) -> Dict[str, Any]:
1397
+ """Try recommend API first, fallback to category tree."""
1398
+ title = schema.product_title
1399
+ image_urls = schema.image_urls or []
1400
+ if title or image_urls:
1401
+ try:
1402
+ recommendations = self.product.recommend_category(
1403
+ title=title, image_urls=image_urls if image_urls else None
1404
+ )
1405
+ if recommendations:
1406
+ # Top1: auto-select
1407
+ if len(recommendations) == 1:
1408
+ rec = recommendations[0]
1409
+ cid = rec.get("category_id") or rec.get("id")
1410
+ if cid:
1411
+ return {"category_id": int(cid)}
1412
+ # TopN: show candidates
1413
+ return _need_category_response(
1414
+ i18n._(
1415
+ "pub_ai_post_category_recommend",
1416
+ count=len(recommendations),
1417
+ ),
1418
+ choices=recommendations,
1419
+ )
1420
+ except MiraviaError as exc:
1421
+ logger.warning("Category recommendation failed: %s", exc)
1422
+ # Fallback: show full tree
1423
+ return _need_category_response(fallback_msg)
1424
+
1425
+ def _validate_category_id(cid: int) -> Optional[Dict[str, Any]]:
1426
+ """Validate category ID. If invalid, try recommend then tree."""
1427
+ if valid_ids and cid not in valid_ids:
1428
+ return _try_recommend_or_tree(
1429
+ i18n._("pub_ai_post_category_id_invalid", category_id=cid)
1430
+ )
1431
+ return None
844
1432
 
845
- candidates = self.product.search_category_by_keyword(category_name)
846
- if not candidates:
847
- return {
848
- "status": "need_category",
849
- "message": i18n._(
850
- "pub_ai_post_category_not_found", name=category_name
851
- ),
852
- "next_actions": [i18n._("pub_ai_post_provide_category")],
853
- }
1433
+ # 1. --confirmed-category-id: validate
1434
+ confirmed_category_id = getattr(self.args, "confirmed_category_id", None)
1435
+ if confirmed_category_id:
1436
+ cid = int(confirmed_category_id)
1437
+ err = _validate_category_id(cid)
1438
+ if err:
1439
+ return err
1440
+ return {"category_id": cid}
854
1441
 
855
- name_lower = category_name.strip().lower()
856
- exact = [
857
- c
858
- for c in candidates
859
- if (c.get("name") or "").strip().lower() == name_lower
860
- ]
861
- display_candidates = exact if exact else candidates
1442
+ # 2. --category-id: validate
1443
+ if self.args.category_id:
1444
+ cid = int(self.args.category_id)
1445
+ err = _validate_category_id(cid)
1446
+ if err:
1447
+ return err
1448
+ return {"category_id": cid}
862
1449
 
863
- if len(display_candidates) == 1:
864
- return {"category_id": display_candidates[0].get("category_id")}
1450
+ # 3. schema.category_id: validate
1451
+ if schema.category_id:
1452
+ cid = int(schema.category_id)
1453
+ err = _validate_category_id(cid)
1454
+ if err:
1455
+ return err
1456
+ return {"category_id": cid}
865
1457
 
866
- return {
867
- "status": "need_category",
868
- "message": i18n._(
869
- "pub_ai_post_category_multiple", count=len(display_candidates)
870
- ),
871
- "choices": display_candidates,
872
- "next_actions": [
873
- i18n._(
874
- "pub_category_confirm_next", category_id=c.get("category_id")
1458
+ # 4. schema.category_name: keyword search
1459
+ category_name = schema.category_name
1460
+ if category_name:
1461
+ candidates = self.product.search_category_by_keyword(category_name)
1462
+ if candidates:
1463
+ name_lower = category_name.strip().lower()
1464
+ exact = [
1465
+ c
1466
+ for c in candidates
1467
+ if (c.get("name") or "").strip().lower() == name_lower
1468
+ ]
1469
+ display_candidates = exact if exact else candidates
1470
+
1471
+ if len(display_candidates) == 1:
1472
+ return {"category_id": display_candidates[0].get("category_id")}
1473
+
1474
+ return _need_category_response(
1475
+ i18n._(
1476
+ "pub_ai_post_category_multiple", count=len(display_candidates)
1477
+ ),
1478
+ choices=display_candidates,
875
1479
  )
876
- for c in display_candidates
877
- ],
878
- }
1480
+ # Keyword search failed → try recommend then tree
1481
+ return _try_recommend_or_tree(
1482
+ i18n._("pub_ai_post_category_not_found", name=category_name)
1483
+ )
1484
+
1485
+ # 5. No category info at all → try recommend then tree
1486
+ return _try_recommend_or_tree(
1487
+ i18n._("pub_ai_post_need_category")
1488
+ )
879
1489
 
880
1490
  def _resolve_brand_for_schema(
881
1491
  self, schema: MiraviaListingSchema
@@ -1122,6 +1732,10 @@ class PublishModule:
1122
1732
  if not has_price:
1123
1733
  missing.append("General price")
1124
1734
 
1735
+ has_stock = any(sku.get("quantity") for sku in skus)
1736
+ if not has_stock:
1737
+ missing.append("Stock")
1738
+
1125
1739
  package_dims = ("package_weight", "package_length", "package_width", "package_height")
1126
1740
  for idx, sku in enumerate(skus, start=1):
1127
1741
  if not sku.get("SellerSku"):
@@ -1152,47 +1766,51 @@ class PublishModule:
1152
1766
  compliance_result: Dict[str, Any],
1153
1767
  category_attributes: List[Dict[str, Any]],
1154
1768
  ) -> List[str]:
1155
- """Return key fields that are missing but can be skipped in draft mode."""
1769
+ """Return internal keys of fields that are missing but can be skipped in draft mode.
1770
+
1771
+ Keys match ``_TRACKED_FIELDS`` internal names so the summary preview
1772
+ can correctly mark them as missing.
1773
+ """
1156
1774
  missing: List[str] = []
1157
1775
  skus = (product_payload.get("Skus") or {}).get("Sku") or []
1158
1776
  has_ean = any(sku.get("ean_code") for sku in skus) or bool(schema.ean_codes)
1159
1777
  if not has_ean:
1160
- missing.append("EAN code")
1778
+ missing.append("ean_code")
1161
1779
 
1162
1780
  if self._check_italy_price_mandatory(schema):
1163
- missing.append("Italy price")
1781
+ missing.append("italy_price")
1164
1782
 
1165
1783
  if schema.standard_unit_type and not schema.unit_count:
1166
- missing.append("Unit count")
1784
+ missing.append("unit_count")
1167
1785
  elif self._is_category_attribute_mandatory(category_attributes, "unit_count") and not schema.unit_count:
1168
- missing.append("Unit count")
1786
+ missing.append("unit_count")
1169
1787
 
1170
1788
  if not schema.sku_attributes_food_health and self._category_looks_like_food_health(
1171
1789
  category_attributes
1172
1790
  ):
1173
- missing.append("Food & health SKU attributes")
1791
+ missing.append("sku_attributes_food_health")
1174
1792
 
1175
1793
  if compliance_result.get("manufacturer_mismatch") or (
1176
1794
  not schema.manufacturer_id and not schema.manufacturer_name
1177
1795
  ):
1178
- missing.append("Manufacturer")
1796
+ missing.append("manufacturer")
1179
1797
  if compliance_result.get("eurp_mismatch") or (
1180
1798
  not schema.eu_responsible_person_id
1181
1799
  and not schema.eu_responsible_person_name
1182
1800
  ):
1183
- missing.append("EU responsible person")
1801
+ missing.append("eu_responsible_person")
1184
1802
 
1185
1803
  if schema.safety_warning is None:
1186
- missing.append("Safety warning")
1804
+ missing.append("safety_warning")
1187
1805
 
1188
1806
  if not schema.package_photos:
1189
- missing.append("Package photos")
1807
+ missing.append("package_photos")
1190
1808
 
1191
1809
  if self._is_category_attribute_mandatory(category_attributes, "compliance_documents") and not schema.compliance_documents:
1192
- missing.append("Compliance documents")
1810
+ missing.append("compliance_documents")
1193
1811
 
1194
1812
  if not any(sku.get("SellerSku") for sku in skus):
1195
- missing.append("Seller SKU")
1813
+ missing.append("seller_sku")
1196
1814
 
1197
1815
  return missing
1198
1816
 
@@ -1244,32 +1862,105 @@ class PublishModule:
1244
1862
  return True
1245
1863
  return False
1246
1864
 
1865
+ def _check_extra_variations(self, schema: MiraviaListingSchema) -> List[str]:
1866
+ """Check if raw_skus contain variation_3+ fields (Miravia supports max 2).
1867
+
1868
+ Returns a list of warning messages if extra variations are detected.
1869
+ """
1870
+ warnings: List[str] = []
1871
+ extra_vars: set = set()
1872
+
1873
+ for raw in schema.raw_skus:
1874
+ if not isinstance(raw, dict):
1875
+ continue
1876
+ for key in raw.keys():
1877
+ # Check for variation_3, variation_4, etc.
1878
+ if key.startswith("variation_"):
1879
+ try:
1880
+ num = int(key.split("_")[1])
1881
+ if num > 2:
1882
+ extra_vars.add(key)
1883
+ except (IndexError, ValueError):
1884
+ pass
1885
+
1886
+ if extra_vars:
1887
+ sorted_vars = sorted(extra_vars)
1888
+ warnings.append(
1889
+ i18n._(
1890
+ "pub_too_many_variations",
1891
+ count=len(sorted_vars),
1892
+ variations=", ".join(sorted_vars),
1893
+ )
1894
+ )
1895
+
1896
+ return warnings
1897
+
1247
1898
  def _make_field_summary_preview(
1248
1899
  self,
1249
1900
  fs_preview: Dict[str, Any],
1250
1901
  key_missing: List[str],
1251
1902
  country_price_note: Optional[str] = None,
1252
1903
  ) -> Dict[str, Any]:
1253
- """Return the field extraction summary and draft/continue prompt."""
1904
+ """Return the field extraction summary and draft/continue prompt.
1905
+
1906
+ Fields are grouped by Mandatory vs Conditional. Missing mandatory
1907
+ fields block publish; missing conditional fields suggest Draft.
1908
+ """
1254
1909
  token = _make_publish_preview_token(fs_preview)
1255
- lines: List[str] = []
1256
- for key, label in self._TRACKED_FIELDS:
1910
+
1911
+ mandatory_lines: List[str] = []
1912
+ conditional_lines: List[str] = []
1913
+ for key, label, category in self._TRACKED_FIELDS:
1257
1914
  status = (
1258
1915
  i18n._("pub_field_status_missing")
1259
1916
  if key in key_missing
1260
1917
  else i18n._("pub_field_status_filled")
1261
1918
  )
1262
- lines.append(f"- {label}: {status}")
1919
+ line = f" {label}: {status}"
1920
+ if category == "mandatory":
1921
+ mandatory_lines.append(line)
1922
+ else:
1923
+ conditional_lines.append(line)
1924
+
1925
+ # Build summary with section headers
1926
+ summary: List[str] = []
1927
+ summary.append(i18n._("pub_summary_mandatory_section"))
1928
+ summary.extend(mandatory_lines)
1929
+ summary.append("")
1930
+ summary.append(i18n._("pub_summary_conditional_section"))
1931
+ summary.extend(conditional_lines)
1932
+
1933
+ # Separate mandatory vs conditional missing
1934
+ mandatory_missing = [
1935
+ label for key, label, cat in self._TRACKED_FIELDS
1936
+ if cat == "mandatory" and key in key_missing
1937
+ ]
1938
+ conditional_missing = [
1939
+ label for key, label, cat in self._TRACKED_FIELDS
1940
+ if cat == "conditional" and key in key_missing
1941
+ ]
1263
1942
 
1264
1943
  actions = [i18n._("pub_final_confirm_next", token=token)]
1265
- if key_missing:
1266
- actions.insert(0, i18n._("pub_key_fields_missing_draft_question"))
1944
+ if mandatory_missing:
1945
+ # Mandatory missing — block with clear message
1946
+ actions.insert(0, i18n._(
1947
+ "pub_mandatory_missing_block",
1948
+ fields=", ".join(mandatory_missing),
1949
+ ))
1950
+ elif conditional_missing:
1951
+ # Conditional missing — suggest Draft
1952
+ actions.insert(0, i18n._(
1953
+ "pub_conditional_missing_draft",
1954
+ fields=", ".join(conditional_missing),
1955
+ ))
1267
1956
 
1268
1957
  payload: Dict[str, Any] = {
1269
1958
  "status": "need_field_summary",
1270
1959
  "title": i18n._("pub_field_summary_title"),
1271
1960
  "message": i18n._("pub_field_summary_description"),
1272
- "summary": lines,
1961
+ "summary": summary,
1962
+ "mandatory_missing": mandatory_missing,
1963
+ "conditional_missing": conditional_missing,
1273
1964
  "key_missing": key_missing,
1274
1965
  "field_summary_token": token,
1275
1966
  "next_actions": actions,
@@ -1438,8 +2129,12 @@ class PublishModule:
1438
2129
 
1439
2130
  def _process_images_for_schema(
1440
2131
  self, schema: MiraviaListingSchema
1441
- ) -> Tuple[List[str], List[str]]:
1442
- """Migrate URLs and upload local image files, returning CDN URLs."""
2132
+ ) -> Tuple[List[str], List[str], List[str]]:
2133
+ """Migrate URLs and upload local image files, returning CDN URLs.
2134
+
2135
+ Returns:
2136
+ Tuple of (migrated_urls, failed_urls, warnings)
2137
+ """
1443
2138
  from pathlib import Path
1444
2139
 
1445
2140
  all_images: List[str] = []
@@ -1462,7 +2157,14 @@ class PublishModule:
1462
2157
 
1463
2158
  migrated: List[str] = []
1464
2159
  failed: List[str] = []
1465
- for img in unique_images:
2160
+ warnings: List[str] = []
2161
+ for idx, img in enumerate(unique_images, start=1):
2162
+ # Check white background for local files (non-blocking warning)
2163
+ if not _check_white_background(img):
2164
+ warnings.append(
2165
+ i18n._("pub_white_background_warning", idx=idx)
2166
+ )
2167
+
1466
2168
  if _is_miravia_cdn(img):
1467
2169
  migrated.append(img)
1468
2170
  elif img.startswith("http://") or img.startswith("https://"):
@@ -1479,7 +2181,7 @@ class PublishModule:
1479
2181
  failed.append(img)
1480
2182
  else:
1481
2183
  failed.append(img)
1482
- return migrated, failed
2184
+ return migrated, failed, warnings
1483
2185
 
1484
2186
  def _build_sku_list_from_schema(
1485
2187
  self, schema: MiraviaListingSchema
@@ -1597,6 +2299,31 @@ class PublishModule:
1597
2299
  errors.extend(vr.get("errors", []))
1598
2300
 
1599
2301
  if errors:
2302
+ # Check for category permission errors first
2303
+ has_category_permission_error = any(
2304
+ "category" in str(e.get("field", "")).lower()
2305
+ and ("permission" in str(e.get("msg", "")).lower()
2306
+ or "not allow" in str(e.get("msg", "")).lower()
2307
+ or "no access" in str(e.get("msg", "")).lower())
2308
+ for e in errors
2309
+ )
2310
+ if has_category_permission_error:
2311
+ category_id = product_payload.get("Attributes", {}).get("PrimaryCategory", "")
2312
+ return {
2313
+ "status": "need_category_permission",
2314
+ "title": i18n._("pub_category_permission_title"),
2315
+ "message": i18n._(
2316
+ "pub_category_permission_message",
2317
+ category_name=str(category_id),
2318
+ category_id=str(category_id),
2319
+ ),
2320
+ "apply_link": i18n._("pub_category_permission_apply_link"),
2321
+ "next_actions": [
2322
+ i18n._("pub_category_permission_draft_next"),
2323
+ i18n._("pub_category_permission_change_next"),
2324
+ ],
2325
+ }
2326
+
1600
2327
  guides = self._field_definition_guides(errors)
1601
2328
  next_actions: List[str] = [
1602
2329
  i18n._("pub_ai_post_prevalidate_fix"),
@@ -2067,6 +2794,60 @@ class PublishModule:
2067
2794
  # ---------------------------------------------------------------------------
2068
2795
 
2069
2796
 
2797
+ def _collect_category_ids(tree: Any) -> set:
2798
+ """Recursively collect all category IDs from a category tree structure.
2799
+
2800
+ Handles both dict and list structures, extracting category_id / id fields.
2801
+ Returns an empty set if the tree is empty or malformed.
2802
+ """
2803
+ ids: set = set()
2804
+ if isinstance(tree, dict):
2805
+ # Try common tree root keys
2806
+ categories = tree.get("categories") or tree.get("data") or tree.get("children") or []
2807
+ if isinstance(categories, list):
2808
+ for cat in categories:
2809
+ ids |= _collect_category_ids(cat)
2810
+ else:
2811
+ # Single category node
2812
+ cid = tree.get("category_id") or tree.get("id")
2813
+ if cid:
2814
+ ids.add(int(cid))
2815
+ children = tree.get("children") or tree.get("sub_categories") or []
2816
+ if isinstance(children, list):
2817
+ ids |= _collect_category_ids(children)
2818
+ elif isinstance(tree, list):
2819
+ for item in tree:
2820
+ ids |= _collect_category_ids(item)
2821
+ return ids
2822
+
2823
+
2824
+ def _collect_category_permissions(tree: Any) -> Dict[int, bool]:
2825
+ """Recursively collect category permission info from tree.
2826
+
2827
+ Returns a dict mapping category_id -> has_permission (bool).
2828
+ Permission field values: "yes" -> True, "no" -> False.
2829
+ """
2830
+ permissions: Dict[int, bool] = {}
2831
+ if isinstance(tree, dict):
2832
+ categories = tree.get("categories") or tree.get("data") or tree.get("children") or []
2833
+ if isinstance(categories, list):
2834
+ for cat in categories:
2835
+ permissions.update(_collect_category_permissions(cat))
2836
+ else:
2837
+ # Single category node
2838
+ cid = tree.get("category_id") or tree.get("id")
2839
+ if cid:
2840
+ perm = tree.get("permission", "").lower()
2841
+ permissions[int(cid)] = perm == "yes"
2842
+ children = tree.get("children") or tree.get("sub_categories") or []
2843
+ if isinstance(children, list):
2844
+ permissions.update(_collect_category_permissions(children))
2845
+ elif isinstance(tree, list):
2846
+ for item in tree:
2847
+ permissions.update(_collect_category_permissions(item))
2848
+ return permissions
2849
+
2850
+
2070
2851
  def _is_miravia_cdn(url: str) -> bool:
2071
2852
  """Check whether a URL is already on Miravia CDN."""
2072
2853
  url_lower = url.lower()
@@ -2122,6 +2903,11 @@ def _validate_create_payload(payload: Dict[str, Any]) -> None:
2122
2903
  if len(ean_codes) != len(set(ean_codes)):
2123
2904
  raise ValueError(i18n._("pub_ean_duplicate_in_product"))
2124
2905
 
2906
+ # Check Seller SKU duplicate within same product
2907
+ seller_skus = [sku.get("SellerSku") for sku in skus if sku.get("SellerSku")]
2908
+ if len(seller_skus) != len(set(seller_skus)):
2909
+ raise ValueError(i18n._("pub_seller_sku_duplicate_in_product"))
2910
+
2125
2911
  for idx, sku in enumerate(skus, start=1):
2126
2912
  if not sku.get("SellerSku"):
2127
2913
  raise ValueError(i18n._("pub_seller_sku_required", idx=idx))
@@ -2140,9 +2926,22 @@ def _validate_create_payload(payload: Dict[str, Any]) -> None:
2140
2926
  _validate_ean(ean, idx)
2141
2927
 
2142
2928
  # Per-SKU package dimensions/weight
2143
- _validate_dimension(sku.get("package_weight"), "weight", _MAX_WEIGHT_KG)
2144
- for dim in ("package_length", "package_width", "package_height"):
2145
- _validate_dimension(sku.get(dim), dim, _MAX_DIMENSION_CM)
2929
+ weight = sku.get("package_weight")
2930
+ length = sku.get("package_length")
2931
+ width = sku.get("package_width")
2932
+ height = sku.get("package_height")
2933
+
2934
+ # Check: if weight is present, L/W/H must also be present
2935
+ if weight and not (length and width and height):
2936
+ raise ValueError(i18n._("pub_dimension_incomplete", idx=idx))
2937
+
2938
+ _validate_dimension(weight, "weight", _MAX_WEIGHT_KG)
2939
+ for dim_name, dim_value in (
2940
+ ("package_length", length),
2941
+ ("package_width", width),
2942
+ ("package_height", height),
2943
+ ):
2944
+ _validate_dimension(dim_value, dim_name, _MAX_DIMENSION_CM)
2146
2945
 
2147
2946
  safety = attributes.get("safety_warning")
2148
2947
  if safety and safety not in ("Yes", "No"):
@@ -2235,13 +3034,138 @@ def _validate_image_format(image_url: str, idx: int) -> None:
2235
3034
  )
2236
3035
 
2237
3036
 
3037
+ def _check_white_background(image_url: str) -> bool:
3038
+ """Check if image has white background.
3039
+
3040
+ Samples pixels from corners and edges. Returns True if background
3041
+ appears to be white (RGB >= 240), False otherwise.
3042
+ Only works for local files; returns True for remote URLs (skip check).
3043
+ """
3044
+ from pathlib import Path
3045
+
3046
+ # Only check local files
3047
+ if not (image_url.startswith("/") or image_url.startswith("file://")):
3048
+ return True # Skip check for remote URLs
3049
+
3050
+ local_path = image_url.replace("file://", "")
3051
+ path = Path(local_path)
3052
+ if not path.exists():
3053
+ return True # Skip if file not found
3054
+
3055
+ try:
3056
+ from PIL import Image
3057
+
3058
+ img = Image.open(path)
3059
+ width, height = img.size
3060
+ if width < 10 or height < 10:
3061
+ return True # Too small to check
3062
+
3063
+ # Convert to RGB if necessary
3064
+ if img.mode != "RGB":
3065
+ img = img.convert("RGB")
3066
+
3067
+ # Sample pixels from corners and edges
3068
+ sample_points = [
3069
+ (0, 0), # Top-left
3070
+ (width - 1, 0), # Top-right
3071
+ (0, height - 1), # Bottom-left
3072
+ (width - 1, height - 1), # Bottom-right
3073
+ (width // 2, 0), # Top-center
3074
+ (width // 2, height - 1), # Bottom-center
3075
+ (0, height // 2), # Left-center
3076
+ (width - 1, height // 2), # Right-center
3077
+ ]
3078
+
3079
+ white_count = 0
3080
+ for x, y in sample_points:
3081
+ pixel = img.getpixel((x, y))
3082
+ # Check if pixel is white or near-white (RGB >= 240)
3083
+ if all(c >= 240 for c in pixel[:3]):
3084
+ white_count += 1
3085
+
3086
+ # If 6+ out of 8 sample points are white, consider it white background
3087
+ return white_count >= 6
3088
+
3089
+ except Exception:
3090
+ return True # Skip check if PIL not available or error occurs
3091
+
3092
+
3093
+ # Unit conversion constants
3094
+ _WEIGHT_TO_KG = {
3095
+ "kg": 1.0,
3096
+ "g": 0.001,
3097
+ "t": 1000.0,
3098
+ "oz": 0.02835,
3099
+ "lb": 0.453592,
3100
+ }
3101
+
3102
+ _LENGTH_TO_CM = {
3103
+ "cm": 1.0,
3104
+ "mm": 0.1,
3105
+ "m": 100.0,
3106
+ "in": 2.54,
3107
+ "ft": 30.48,
3108
+ }
3109
+
3110
+
3111
+ def _convert_weight(value: str) -> float:
3112
+ """Convert weight string to kg.
3113
+
3114
+ Handles: '1000 oz', '500g', '1t', '2.5 kg', etc.
3115
+ Returns value in kg.
3116
+ """
3117
+ import re
3118
+
3119
+ text = str(value).strip().lower()
3120
+ match = re.match(r"([\d.]+)\s*(kg|g|t|oz|lb)?", text)
3121
+ if not match:
3122
+ raise ValueError(f"Invalid weight format: {value}")
3123
+ number = float(match.group(1))
3124
+ unit = match.group(2) or "kg"
3125
+ if unit not in _WEIGHT_TO_KG:
3126
+ raise ValueError(f"Unknown weight unit: {unit}")
3127
+ return number * _WEIGHT_TO_KG[unit]
3128
+
3129
+
3130
+ def _convert_length(value: str) -> float:
3131
+ """Convert length string to cm.
3132
+
3133
+ Handles: '10 in', '155 mm', '1.1m', '1ft', '25.4 cm', etc.
3134
+ Returns value in cm.
3135
+ """
3136
+ import re
3137
+
3138
+ text = str(value).strip().lower()
3139
+ match = re.match(r"([\d.]+)\s*(cm|mm|m|in|ft)?", text)
3140
+ if not match:
3141
+ raise ValueError(f"Invalid length format: {value}")
3142
+ number = float(match.group(1))
3143
+ unit = match.group(2) or "cm"
3144
+ if unit not in _LENGTH_TO_CM:
3145
+ raise ValueError(f"Unknown length unit: {unit}")
3146
+ return number * _LENGTH_TO_CM[unit]
3147
+
3148
+
2238
3149
  def _validate_dimension(value: Any, name: str, maximum: int) -> None:
3150
+ """Validate dimension value after unit conversion.
3151
+
3152
+ For weight: converts to kg, checks 0 < value <= _MAX_WEIGHT_KG
3153
+ For dimensions: converts to cm, checks 0 < value <= _MAX_DIMENSION_CM
3154
+ """
2239
3155
  if value is None:
2240
3156
  return
2241
3157
  try:
2242
- number = float(value)
2243
- except (TypeError, ValueError) as exc:
2244
- raise ValueError(i18n._("pub_invalid_dimension", name=name, value=value)) from exc
3158
+ # Try unit conversion first
3159
+ if name == "weight":
3160
+ number = _convert_weight(value)
3161
+ else:
3162
+ number = _convert_length(value)
3163
+ except (TypeError, ValueError, AttributeError) as exc:
3164
+ # Fallback to simple float conversion
3165
+ try:
3166
+ number = float(value)
3167
+ except (TypeError, ValueError) as exc2:
3168
+ raise ValueError(i18n._("pub_invalid_dimension", name=name, value=value)) from exc2
2245
3169
  if number <= 0 or number > maximum:
2246
3170
  raise ValueError(
2247
3171
  i18n._(
@@ -2253,6 +3177,77 @@ def _validate_dimension(value: Any, name: str, maximum: int) -> None:
2253
3177
  )
2254
3178
 
2255
3179
 
3180
+ # Safe HTML tags for product descriptions (Miravia product editor supports these)
3181
+ _SAFE_HTML_TAGS = frozenset({
3182
+ "p", "br", "b", "strong", "i", "em", "u",
3183
+ "ul", "ol", "li",
3184
+ "h1", "h2", "h3", "h4", "h5", "h6",
3185
+ "table", "tr", "td", "th", "thead", "tbody",
3186
+ "a", "img",
3187
+ })
3188
+
3189
+
3190
+ def _sanitize_html(html: str) -> tuple:
3191
+ """Sanitize HTML content for product description.
3192
+
3193
+ Keeps only safe tags (p, br, b, ul, li, etc.) and removes:
3194
+ - <script>, <iframe>, <object>, <embed> and similar
3195
+ - style attributes, onclick/onerror handlers
3196
+ - <div> with style attributes
3197
+
3198
+ Returns:
3199
+ Tuple of (cleaned_html, was_cleaned: bool)
3200
+ """
3201
+ import re
3202
+
3203
+ if not html:
3204
+ return html, False
3205
+
3206
+ original = html
3207
+ cleaned = html
3208
+
3209
+ # Remove dangerous tags entirely (including content)
3210
+ dangerous_tags = ["script", "iframe", "object", "embed", "applet", "form", "input"]
3211
+ for tag in dangerous_tags:
3212
+ cleaned = re.sub(
3213
+ rf"<{tag}[^>]*>.*?</{tag}>",
3214
+ "",
3215
+ cleaned,
3216
+ flags=re.IGNORECASE | re.DOTALL,
3217
+ )
3218
+ # Also remove self-closing or unclosed dangerous tags
3219
+ cleaned = re.sub(
3220
+ rf"<{tag}[^>]*/?>",
3221
+ "",
3222
+ cleaned,
3223
+ flags=re.IGNORECASE,
3224
+ )
3225
+
3226
+ # Remove style attributes from all tags
3227
+ cleaned = re.sub(r'\s+style\s*=\s*["\'][^"\']*["\']', "", cleaned, flags=re.IGNORECASE)
3228
+
3229
+ # Remove event handlers (onclick, onerror, onload, etc.)
3230
+ cleaned = re.sub(r'\s+on\w+\s*=\s*["\'][^"\']*["\']', "", cleaned, flags=re.IGNORECASE)
3231
+
3232
+ # Remove <div> tags but keep content (div is not in safe list but content is useful)
3233
+ cleaned = re.sub(r"</?div[^>]*>", "", cleaned, flags=re.IGNORECASE)
3234
+
3235
+ # Remove any tags not in the safe list
3236
+ def _replace_unsafe_tag(match):
3237
+ full_match = match.group(0)
3238
+ tag_match = re.match(r"</?([a-zA-Z][a-zA-Z0-9]*)", full_match)
3239
+ if tag_match:
3240
+ tag_name = tag_match.group(1).lower()
3241
+ if tag_name not in _SAFE_HTML_TAGS:
3242
+ return "" # Remove unsafe tag
3243
+ return full_match # Keep safe tag
3244
+
3245
+ cleaned = re.sub(r"</?[a-zA-Z][a-zA-Z0-9]*[^>]*>", _replace_unsafe_tag, cleaned)
3246
+
3247
+ was_cleaned = cleaned != original
3248
+ return cleaned, was_cleaned
3249
+
3250
+
2256
3251
  def _load_preview_secret() -> bytes:
2257
3252
  """Return a machine-local secret for publish preview tokens.
2258
3253