@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.
@@ -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
 
@@ -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
- raise ValueError(i18n._("pub_create_requires_category"))
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
- raise ValueError(i18n._("pub_create_requires_name"))
157
- _validate_title(product_name)
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
- # Check postToInactive fields (EAN, compliance_documents, unit_count, food_attributes)
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
- content_items = read_sources(sources)
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
- raise ValueError(i18n._("pub_ai_post_no_content"))
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
- raise ValueError(title_err)
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
- ("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"),
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 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)}
1369
+ """Resolve category from extracted name/ID, CLI args, or recommendation.
830
1370
 
831
- if self.args.category_id:
832
- return {"category_id": int(self.args.category_id)}
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
- if schema.category_id:
835
- return {"category_id": int(schema.category_id)}
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
- category_name = schema.category_name
838
- if not category_name:
839
- return {
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": i18n._("pub_ai_post_need_category"),
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
- 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
- }
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
- 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
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
- if len(display_candidates) == 1:
864
- return {"category_id": display_candidates[0].get("category_id")}
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
- 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")
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
- for c in display_candidates
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 key fields that are missing but can be skipped in draft mode."""
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("EAN code")
1797
+ missing.append("ean_code")
1161
1798
 
1162
1799
  if self._check_italy_price_mandatory(schema):
1163
- missing.append("Italy price")
1800
+ missing.append("italy_price")
1164
1801
 
1165
1802
  if schema.standard_unit_type and not schema.unit_count:
1166
- missing.append("Unit count")
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("Unit count")
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("Food & health SKU attributes")
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("Manufacturer")
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("EU responsible person")
1820
+ missing.append("eu_responsible_person")
1184
1821
 
1185
1822
  if schema.safety_warning is None:
1186
- missing.append("Safety warning")
1823
+ missing.append("safety_warning")
1187
1824
 
1188
1825
  if not schema.package_photos:
1189
- missing.append("Package photos")
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("Compliance documents")
1829
+ missing.append("compliance_documents")
1193
1830
 
1194
- if not any(sku.get("SellerSku") for sku in skus):
1195
- missing.append("Seller SKU")
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
- lines: List[str] = []
1256
- for key, label in self._TRACKED_FIELDS:
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
- lines.append(f"- {label}: {status}")
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 key_missing:
1266
- actions.insert(0, i18n._("pub_key_fields_missing_draft_question"))
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": lines,
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
- for img in unique_images:
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
- _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)
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
- number = float(value)
2243
- except (TypeError, ValueError) as exc:
2244
- raise ValueError(i18n._("pub_invalid_dimension", name=name, value=value)) from exc
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