preflight-kit 0.2.0__py3-none-any.whl
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.
- preflight_kit/__init__.py +0 -0
- preflight_kit/cli.py +72 -0
- preflight_kit/engine.py +62 -0
- preflight_kit/fixer.py +50 -0
- preflight_kit/header_registry.py +126 -0
- preflight_kit/json_dump.py +85 -0
- preflight_kit/loader.py +291 -0
- preflight_kit/models.py +66 -0
- preflight_kit/reporters.py +145 -0
- preflight_kit/rules/__init__.py +4 -0
- preflight_kit/rules/file_rules.py +210 -0
- preflight_kit/rules/row_rules.py +448 -0
- preflight_kit-0.2.0.dist-info/METADATA +124 -0
- preflight_kit-0.2.0.dist-info/RECORD +17 -0
- preflight_kit-0.2.0.dist-info/WHEEL +4 -0
- preflight_kit-0.2.0.dist-info/entry_points.txt +2 -0
- preflight_kit-0.2.0.dist-info/licenses/LICENSE +33 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
from ..loader import LoadResult
|
|
5
|
+
from ..models import Finding, Severity, FixClass, ImportIntent, MAX_IMPORT_BYTES
|
|
6
|
+
from ..header_registry import match_kind, CANONICAL_TO_HEADERS
|
|
7
|
+
|
|
8
|
+
_CONTROL_CHARS = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f]")
|
|
9
|
+
_SMART_QUOTES = ("‘", "’", "“", "”")
|
|
10
|
+
_MAX_BYTES = MAX_IMPORT_BYTES
|
|
11
|
+
_ROW_WARN_THRESHOLD = 5000
|
|
12
|
+
# F03c: ファイルに最低限必要な canonical 列(列そのものの存在を要求する。
|
|
13
|
+
# 値の空欄は R01/R02 が intent 別 severity で扱う。列の欠落は構造問題なので
|
|
14
|
+
# critical。spec F03c「Title/Handle 相当列が無い」に対応)。
|
|
15
|
+
_REQUIRED_CANONICAL = {"title", "handle"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _header_finding(rule_id, severity, fix_class, field_name, message, suggested_fix):
|
|
19
|
+
return Finding(
|
|
20
|
+
row=None,
|
|
21
|
+
product_group_id=None,
|
|
22
|
+
row_kind=None,
|
|
23
|
+
handle=None,
|
|
24
|
+
sku=None,
|
|
25
|
+
severity=severity,
|
|
26
|
+
rule_id=rule_id,
|
|
27
|
+
field=field_name,
|
|
28
|
+
message=message,
|
|
29
|
+
suggested_fix=suggested_fix,
|
|
30
|
+
fix_class=fix_class,
|
|
31
|
+
auto_fixable=(fix_class == FixClass.PROVEN),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def rule_f01c(load: LoadResult, intent: ImportIntent) -> list[Finding]:
|
|
36
|
+
out = []
|
|
37
|
+
for row in load.rows:
|
|
38
|
+
for col, val in row.cells.items():
|
|
39
|
+
if _CONTROL_CHARS.search(val or ""):
|
|
40
|
+
out.append(
|
|
41
|
+
Finding(
|
|
42
|
+
row=row.line_no,
|
|
43
|
+
product_group_id=row.product_group_id,
|
|
44
|
+
row_kind=row.row_kind,
|
|
45
|
+
handle=row.get("handle"),
|
|
46
|
+
sku=row.get("sku"),
|
|
47
|
+
severity=Severity.WARNING,
|
|
48
|
+
rule_id="F01c",
|
|
49
|
+
field=col,
|
|
50
|
+
message=f"Control character in column '{col}'.",
|
|
51
|
+
suggested_fix="Remove the control character manually.",
|
|
52
|
+
fix_class=FixClass.NONE,
|
|
53
|
+
auto_fixable=False,
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
return out
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def rule_f02(load: LoadResult, intent: ImportIntent) -> list[Finding]:
|
|
60
|
+
out = []
|
|
61
|
+
for row in load.rows:
|
|
62
|
+
for col, val in row.cells.items():
|
|
63
|
+
if any(q in (val or "") for q in _SMART_QUOTES):
|
|
64
|
+
out.append(
|
|
65
|
+
Finding(
|
|
66
|
+
row=row.line_no,
|
|
67
|
+
product_group_id=row.product_group_id,
|
|
68
|
+
row_kind=row.row_kind,
|
|
69
|
+
handle=row.get("handle"),
|
|
70
|
+
sku=row.get("sku"),
|
|
71
|
+
severity=Severity.WARNING,
|
|
72
|
+
rule_id="F02",
|
|
73
|
+
field=col,
|
|
74
|
+
message=f"Smart/curly quotes in column '{col}'.",
|
|
75
|
+
suggested_fix="Review manually; not auto-replaced (body text vs CSV quoting cannot be distinguished).",
|
|
76
|
+
fix_class=FixClass.SUGGESTED,
|
|
77
|
+
auto_fixable=False,
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
break
|
|
81
|
+
return out
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def rule_f03a(load: LoadResult, intent: ImportIntent) -> list[Finding]:
|
|
85
|
+
out = []
|
|
86
|
+
for col in load.header:
|
|
87
|
+
if match_kind(col) == "case_only":
|
|
88
|
+
out.append(
|
|
89
|
+
_header_finding(
|
|
90
|
+
"F03a",
|
|
91
|
+
Severity.CRITICAL,
|
|
92
|
+
FixClass.PROVEN,
|
|
93
|
+
col,
|
|
94
|
+
f"Header '{col}' differs only by case from a known column.",
|
|
95
|
+
"Header case normalized automatically.",
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
return out
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def rule_f03b(load: LoadResult, intent: ImportIntent) -> list[Finding]:
|
|
102
|
+
out = []
|
|
103
|
+
for col in load.header:
|
|
104
|
+
if match_kind(col) == "alias":
|
|
105
|
+
out.append(
|
|
106
|
+
_header_finding(
|
|
107
|
+
"F03b",
|
|
108
|
+
Severity.WARNING,
|
|
109
|
+
FixClass.SUGGESTED,
|
|
110
|
+
col,
|
|
111
|
+
f"Header '{col}' is a legacy alias.",
|
|
112
|
+
"Consider renaming to the current header (not auto-applied; may change meaning).",
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
return out
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def rule_f03c(load: LoadResult, intent: ImportIntent) -> list[Finding]:
|
|
119
|
+
present = set(load.canonical_map.values())
|
|
120
|
+
missing = _REQUIRED_CANONICAL - present
|
|
121
|
+
out = []
|
|
122
|
+
for key in sorted(missing):
|
|
123
|
+
out.append(
|
|
124
|
+
_header_finding(
|
|
125
|
+
"F03c",
|
|
126
|
+
Severity.CRITICAL,
|
|
127
|
+
FixClass.NONE,
|
|
128
|
+
key,
|
|
129
|
+
f"Required column for '{key}' is missing.",
|
|
130
|
+
f"Add the '{CANONICAL_TO_HEADERS[key][0]}' column.",
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
return out
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def rule_f03d(load: LoadResult, intent: ImportIntent) -> list[Finding]:
|
|
137
|
+
out = []
|
|
138
|
+
for col in load.header:
|
|
139
|
+
if match_kind(col) == "unknown":
|
|
140
|
+
out.append(
|
|
141
|
+
_header_finding(
|
|
142
|
+
"F03d",
|
|
143
|
+
Severity.WARNING,
|
|
144
|
+
FixClass.NONE,
|
|
145
|
+
col,
|
|
146
|
+
f"Unknown column '{col}' (possible typo).",
|
|
147
|
+
"Verify the column name; not removed or renamed automatically.",
|
|
148
|
+
)
|
|
149
|
+
)
|
|
150
|
+
return out
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def rule_f03e(load: LoadResult, intent: ImportIntent) -> list[Finding]:
|
|
154
|
+
out = []
|
|
155
|
+
for col in load.header:
|
|
156
|
+
if match_kind(col) == "variant_metafield":
|
|
157
|
+
out.append(
|
|
158
|
+
_header_finding(
|
|
159
|
+
"F03e",
|
|
160
|
+
Severity.WARNING,
|
|
161
|
+
FixClass.NONE,
|
|
162
|
+
col,
|
|
163
|
+
f"Variant metafield column '{col}' is not supported by standard product CSV import.",
|
|
164
|
+
"Remove or import variant metafields via a dedicated method.",
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
return out
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def rule_f04a(load: LoadResult, intent: ImportIntent) -> list[Finding]:
|
|
171
|
+
if load.raw_byte_size > _MAX_BYTES:
|
|
172
|
+
return [
|
|
173
|
+
_header_finding(
|
|
174
|
+
"F04a",
|
|
175
|
+
Severity.CRITICAL,
|
|
176
|
+
FixClass.NONE,
|
|
177
|
+
None,
|
|
178
|
+
f"File is {load.raw_byte_size} bytes, over the 15MB Shopify import limit.",
|
|
179
|
+
"Split the CSV into files under 15MB.",
|
|
180
|
+
)
|
|
181
|
+
]
|
|
182
|
+
return []
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def rule_f04b(load: LoadResult, intent: ImportIntent) -> list[Finding]:
|
|
186
|
+
if len(load.rows) > _ROW_WARN_THRESHOLD:
|
|
187
|
+
return [
|
|
188
|
+
_header_finding(
|
|
189
|
+
"F04b",
|
|
190
|
+
Severity.WARNING,
|
|
191
|
+
FixClass.NONE,
|
|
192
|
+
None,
|
|
193
|
+
f"File has {len(load.rows)} rows; consider splitting for reliability.",
|
|
194
|
+
"Split into smaller files if import is slow or fails.",
|
|
195
|
+
)
|
|
196
|
+
]
|
|
197
|
+
return []
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
ALL_FILE_RULES = [
|
|
201
|
+
rule_f01c,
|
|
202
|
+
rule_f02,
|
|
203
|
+
rule_f03a,
|
|
204
|
+
rule_f03b,
|
|
205
|
+
rule_f03c,
|
|
206
|
+
rule_f03d,
|
|
207
|
+
rule_f03e,
|
|
208
|
+
rule_f04a,
|
|
209
|
+
rule_f04b,
|
|
210
|
+
]
|
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import re
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
|
|
5
|
+
from ..loader import LoadResult
|
|
6
|
+
from ..models import Finding, RowKind, Severity, FixClass, ImportIntent
|
|
7
|
+
|
|
8
|
+
_DANGEROUS_SKU = re.compile(r"[,\"\n\r\t]")
|
|
9
|
+
_SCI_NOTATION = re.compile(r"^\d+(\.\d+)?[eE][+-]?\d+$")
|
|
10
|
+
_INVENTORY_POLICY_VALID = {"deny", "continue"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _row_finding(
|
|
14
|
+
row, rule_id, severity, message, suggested_fix, field_name, fix_class=FixClass.NONE
|
|
15
|
+
):
|
|
16
|
+
return Finding(
|
|
17
|
+
row=row.line_no,
|
|
18
|
+
product_group_id=row.product_group_id,
|
|
19
|
+
row_kind=row.row_kind,
|
|
20
|
+
handle=row.get("handle"),
|
|
21
|
+
sku=row.get("sku"),
|
|
22
|
+
severity=severity,
|
|
23
|
+
rule_id=rule_id,
|
|
24
|
+
field=field_name,
|
|
25
|
+
message=message,
|
|
26
|
+
suggested_fix=suggested_fix,
|
|
27
|
+
fix_class=fix_class,
|
|
28
|
+
auto_fixable=(fix_class == FixClass.PROVEN),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _blank(v: str | None) -> bool:
|
|
33
|
+
return (v or "").strip() == ""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def rule_r01(load: LoadResult, intent: ImportIntent) -> list[Finding]:
|
|
37
|
+
"""product-start 行の Title 欠落。new/update=critical, mixed=warning。"""
|
|
38
|
+
out = []
|
|
39
|
+
for row in load.rows:
|
|
40
|
+
if not row.is_product_start:
|
|
41
|
+
continue
|
|
42
|
+
if _blank(row.get("title")):
|
|
43
|
+
sev = (
|
|
44
|
+
Severity.WARNING if intent == ImportIntent.MIXED else Severity.CRITICAL
|
|
45
|
+
)
|
|
46
|
+
out.append(
|
|
47
|
+
_row_finding(
|
|
48
|
+
row,
|
|
49
|
+
"R01",
|
|
50
|
+
sev,
|
|
51
|
+
"Product-start row is missing Title.",
|
|
52
|
+
"Add a Title for the product.",
|
|
53
|
+
"Title",
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
return out
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def rule_r02(load: LoadResult, intent: ImportIntent) -> list[Finding]:
|
|
60
|
+
"""handle 欠落。
|
|
61
|
+
- product-start 行: update=critical, new/mixed=warning(必須欄マトリクス)。
|
|
62
|
+
- variant/image 行: 親グループ handle の継承が全 intent で必須。空なら critical
|
|
63
|
+
(#3: マトリクスの variant/image セル。ファイル単体で親を特定できない)。
|
|
64
|
+
"""
|
|
65
|
+
out = []
|
|
66
|
+
for row in load.rows:
|
|
67
|
+
if not _blank(row.get("handle")):
|
|
68
|
+
continue
|
|
69
|
+
if row.is_product_start:
|
|
70
|
+
sev = (
|
|
71
|
+
Severity.CRITICAL if intent == ImportIntent.UPDATE else Severity.WARNING
|
|
72
|
+
)
|
|
73
|
+
out.append(
|
|
74
|
+
_row_finding(
|
|
75
|
+
row,
|
|
76
|
+
"R02",
|
|
77
|
+
sev,
|
|
78
|
+
"Product-start row is missing URL handle.",
|
|
79
|
+
"Add a URL handle (required to target an existing product on update).",
|
|
80
|
+
"URL handle",
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
else:
|
|
84
|
+
# variant / image 行は親 handle の継承が必須(全 intent で critical)
|
|
85
|
+
out.append(
|
|
86
|
+
_row_finding(
|
|
87
|
+
row,
|
|
88
|
+
"R02",
|
|
89
|
+
Severity.CRITICAL,
|
|
90
|
+
"Variant/image row is missing the parent URL handle.",
|
|
91
|
+
"Repeat the parent product's URL handle on every variant/image row.",
|
|
92
|
+
"URL handle",
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
return out
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def rule_r03(load: LoadResult, intent: ImportIntent) -> list[Finding]:
|
|
99
|
+
"""同一 handle に複数 product-start 行(サイレント上書き事故)。"""
|
|
100
|
+
by_handle: dict[str, list] = defaultdict(list)
|
|
101
|
+
for row in load.rows:
|
|
102
|
+
if row.is_product_start and not _blank(row.get("handle")):
|
|
103
|
+
by_handle[row.get("handle").strip()].append(row)
|
|
104
|
+
out = []
|
|
105
|
+
for handle, rows in by_handle.items():
|
|
106
|
+
if len(rows) > 1:
|
|
107
|
+
line_nos = ", ".join(str(r.line_no) for r in rows)
|
|
108
|
+
for r in rows:
|
|
109
|
+
out.append(
|
|
110
|
+
_row_finding(
|
|
111
|
+
r,
|
|
112
|
+
"R03",
|
|
113
|
+
Severity.CRITICAL,
|
|
114
|
+
f"{len(rows)} product-start rows share handle '{handle}' (rows {line_nos}).",
|
|
115
|
+
"Keep one product-start row per handle; merge or rename duplicates.",
|
|
116
|
+
"URL handle",
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
return out
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def rule_r04(load: LoadResult, intent: ImportIntent) -> list[Finding]:
|
|
123
|
+
"""グループ内 Option 値の組み合わせ重複。"""
|
|
124
|
+
seen: dict[str, dict] = defaultdict(dict)
|
|
125
|
+
out = []
|
|
126
|
+
for row in load.rows:
|
|
127
|
+
gid = row.product_group_id
|
|
128
|
+
if gid is None:
|
|
129
|
+
continue
|
|
130
|
+
combo = (
|
|
131
|
+
(row.get("option1_value") or "").strip(),
|
|
132
|
+
(row.get("option2_value") or "").strip(),
|
|
133
|
+
(row.get("option3_value") or "").strip(),
|
|
134
|
+
)
|
|
135
|
+
if any(combo):
|
|
136
|
+
if combo in seen[gid]:
|
|
137
|
+
out.append(
|
|
138
|
+
_row_finding(
|
|
139
|
+
row,
|
|
140
|
+
"R04",
|
|
141
|
+
Severity.CRITICAL,
|
|
142
|
+
f"Duplicate option combination {combo} within product group.",
|
|
143
|
+
"Remove or fix the duplicate variant.",
|
|
144
|
+
"Option1 value",
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
else:
|
|
148
|
+
seen[gid][combo] = row.line_no
|
|
149
|
+
return out
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def rule_r05(load: LoadResult, intent: ImportIntent) -> list[Finding]:
|
|
153
|
+
"""Option1 dependency 違反。"""
|
|
154
|
+
out = []
|
|
155
|
+
for row in load.rows:
|
|
156
|
+
o1n, o1v = row.get("option1_name"), row.get("option1_value")
|
|
157
|
+
o2n, o2v = row.get("option2_name"), row.get("option2_value")
|
|
158
|
+
problem = None
|
|
159
|
+
if not _blank(o1n) and _blank(o1v):
|
|
160
|
+
problem = "Option1 name set but Option1 value is empty."
|
|
161
|
+
elif _blank(o1n) and not _blank(o1v):
|
|
162
|
+
problem = "Option1 value set but Option1 name is empty."
|
|
163
|
+
elif (not _blank(o2n) or not _blank(o2v)) and _blank(o1n):
|
|
164
|
+
problem = "Option2 present but Option1 is missing."
|
|
165
|
+
if problem:
|
|
166
|
+
out.append(
|
|
167
|
+
_row_finding(
|
|
168
|
+
row,
|
|
169
|
+
"R05",
|
|
170
|
+
Severity.CRITICAL,
|
|
171
|
+
problem,
|
|
172
|
+
"Fix option name/value pairing (Shopify 'Line is invalid').",
|
|
173
|
+
"Option1 name",
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
return out
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def rule_r06(load: LoadResult, intent: ImportIntent) -> list[Finding]:
|
|
180
|
+
"""SKU 空欄 / グループ跨ぎ重複 / 危険文字。"""
|
|
181
|
+
out = []
|
|
182
|
+
# SKU 列が CSV に存在するか(列ごと無いのは構造問題で R06 の対象外)。
|
|
183
|
+
sku_present = "sku" in set(load.canonical_map.values())
|
|
184
|
+
sku_groups: dict[str, set] = defaultdict(set)
|
|
185
|
+
for row in load.rows:
|
|
186
|
+
sku = (row.get("sku") or "").strip()
|
|
187
|
+
if sku:
|
|
188
|
+
sku_groups[sku].add(row.product_group_id)
|
|
189
|
+
if _DANGEROUS_SKU.search(sku):
|
|
190
|
+
out.append(
|
|
191
|
+
_row_finding(
|
|
192
|
+
row,
|
|
193
|
+
"R06",
|
|
194
|
+
Severity.WARNING,
|
|
195
|
+
f"SKU '{sku}' contains characters that may break CSV (comma/quote/newline/tab).",
|
|
196
|
+
"Review and clean the SKU.",
|
|
197
|
+
"SKU",
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
elif sku_present and row.row_kind != RowKind.IMAGE:
|
|
201
|
+
# SKU 列はあるが値が空(image 行は SKU 不要なので除外)。
|
|
202
|
+
out.append(
|
|
203
|
+
_row_finding(
|
|
204
|
+
row,
|
|
205
|
+
"R06",
|
|
206
|
+
Severity.WARNING,
|
|
207
|
+
"SKU is empty (inventory/variant tracking may not work).",
|
|
208
|
+
"Set a unique SKU for this variant, or confirm it is intentionally blank.",
|
|
209
|
+
"SKU",
|
|
210
|
+
)
|
|
211
|
+
)
|
|
212
|
+
for row in load.rows:
|
|
213
|
+
sku = (row.get("sku") or "").strip()
|
|
214
|
+
if sku and len(sku_groups[sku]) > 1:
|
|
215
|
+
out.append(
|
|
216
|
+
_row_finding(
|
|
217
|
+
row,
|
|
218
|
+
"R06",
|
|
219
|
+
Severity.WARNING,
|
|
220
|
+
f"SKU '{sku}' is reused across multiple product groups.",
|
|
221
|
+
"Ensure SKUs are unique per variant.",
|
|
222
|
+
"SKU",
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
return out
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def rule_r07(load: LoadResult, intent: ImportIntent) -> list[Finding]:
|
|
229
|
+
"""inventory_policy が許容値以外。SoT 未確定のため warning + suggested 止まり。"""
|
|
230
|
+
out = []
|
|
231
|
+
for row in load.rows:
|
|
232
|
+
val = (row.get("inventory_policy") or "").strip()
|
|
233
|
+
if val and val.lower() not in _INVENTORY_POLICY_VALID:
|
|
234
|
+
out.append(
|
|
235
|
+
_row_finding(
|
|
236
|
+
row,
|
|
237
|
+
"R07",
|
|
238
|
+
Severity.WARNING,
|
|
239
|
+
f"inventory_policy '{val}' is not a documented value (deny/continue).",
|
|
240
|
+
"Verify against current Shopify CSV docs; not auto-normalized (SoT unsettled).",
|
|
241
|
+
"Continue selling when out of stock",
|
|
242
|
+
fix_class=FixClass.SUGGESTED,
|
|
243
|
+
)
|
|
244
|
+
)
|
|
245
|
+
return out
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def rule_r08(load: LoadResult, intent: ImportIntent) -> list[Finding]:
|
|
249
|
+
"""inventory_tracker ありで qty が非数値/空。"""
|
|
250
|
+
out = []
|
|
251
|
+
for row in load.rows:
|
|
252
|
+
tracker = (row.get("inventory_tracker") or "").strip()
|
|
253
|
+
if not tracker:
|
|
254
|
+
continue
|
|
255
|
+
qty = (row.get("inventory_qty") or "").strip()
|
|
256
|
+
if qty == "" or not re.fullmatch(r"-?\d+", qty):
|
|
257
|
+
out.append(
|
|
258
|
+
_row_finding(
|
|
259
|
+
row,
|
|
260
|
+
"R08",
|
|
261
|
+
Severity.WARNING,
|
|
262
|
+
f"Inventory tracker '{tracker}' set but inventory quantity is empty/non-numeric.",
|
|
263
|
+
"Set a numeric inventory quantity when a tracker is active.",
|
|
264
|
+
"Inventory quantity",
|
|
265
|
+
)
|
|
266
|
+
)
|
|
267
|
+
return out
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def rule_r09(load: LoadResult, intent: ImportIntent) -> list[Finding]:
|
|
271
|
+
"""fulfillment_service 空欄(情報提供)。"""
|
|
272
|
+
out = []
|
|
273
|
+
for row in load.rows:
|
|
274
|
+
if row.row_kind == RowKind.IMAGE:
|
|
275
|
+
continue
|
|
276
|
+
if (row.get("sku") or "").strip() and _blank(row.get("fulfillment_service")):
|
|
277
|
+
out.append(
|
|
278
|
+
_row_finding(
|
|
279
|
+
row,
|
|
280
|
+
"R09",
|
|
281
|
+
Severity.WARNING,
|
|
282
|
+
"Fulfillment service is empty (defaults to manual).",
|
|
283
|
+
"Set explicitly if a non-manual fulfillment service is required.",
|
|
284
|
+
"Fulfillment service",
|
|
285
|
+
)
|
|
286
|
+
)
|
|
287
|
+
return out
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def rule_r10(load: LoadResult, intent: ImportIntent) -> list[Finding]:
|
|
291
|
+
"""価格が非数値 / 負数 / Compare-at <= Price。"""
|
|
292
|
+
out = []
|
|
293
|
+
for row in load.rows:
|
|
294
|
+
price_s = (row.get("price") or "").strip()
|
|
295
|
+
price: float | None = None
|
|
296
|
+
if price_s:
|
|
297
|
+
try:
|
|
298
|
+
price = float(price_s)
|
|
299
|
+
except ValueError:
|
|
300
|
+
price = None
|
|
301
|
+
out.append(
|
|
302
|
+
_row_finding(
|
|
303
|
+
row,
|
|
304
|
+
"R10",
|
|
305
|
+
Severity.WARNING,
|
|
306
|
+
f"Price '{price_s}' is not numeric.",
|
|
307
|
+
"Use a numeric price.",
|
|
308
|
+
"Price",
|
|
309
|
+
)
|
|
310
|
+
)
|
|
311
|
+
if price is not None and price < 0:
|
|
312
|
+
out.append(
|
|
313
|
+
_row_finding(
|
|
314
|
+
row,
|
|
315
|
+
"R10",
|
|
316
|
+
Severity.WARNING,
|
|
317
|
+
f"Price '{price_s}' is negative.",
|
|
318
|
+
"Use a non-negative price.",
|
|
319
|
+
"Price",
|
|
320
|
+
)
|
|
321
|
+
)
|
|
322
|
+
# compare_at_price は price とは独立に数値判定する。ここを price の
|
|
323
|
+
# try に同居させると compare_at の非数値が「Price is not numeric」と
|
|
324
|
+
# 誤報告される(round-1 non-blocking)。
|
|
325
|
+
cap_s = (row.get("compare_at_price") or "").strip()
|
|
326
|
+
if cap_s:
|
|
327
|
+
try:
|
|
328
|
+
cap = float(cap_s)
|
|
329
|
+
except ValueError:
|
|
330
|
+
out.append(
|
|
331
|
+
_row_finding(
|
|
332
|
+
row,
|
|
333
|
+
"R10",
|
|
334
|
+
Severity.WARNING,
|
|
335
|
+
f"Compare-at price '{cap_s}' is not numeric.",
|
|
336
|
+
"Use a numeric compare-at price.",
|
|
337
|
+
"Compare-at price",
|
|
338
|
+
)
|
|
339
|
+
)
|
|
340
|
+
else:
|
|
341
|
+
if price is not None and cap <= price:
|
|
342
|
+
out.append(
|
|
343
|
+
_row_finding(
|
|
344
|
+
row,
|
|
345
|
+
"R10",
|
|
346
|
+
Severity.WARNING,
|
|
347
|
+
f"Compare-at price {cap} <= price {price}.",
|
|
348
|
+
"Compare-at price should exceed price to show a discount.",
|
|
349
|
+
"Compare-at price",
|
|
350
|
+
)
|
|
351
|
+
)
|
|
352
|
+
return out
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def rule_r11(load: LoadResult, intent: ImportIntent) -> list[Finding]:
|
|
356
|
+
"""image alt 有 but image_src 空。"""
|
|
357
|
+
out = []
|
|
358
|
+
for row in load.rows:
|
|
359
|
+
if not _blank(row.get("image_alt")) and _blank(row.get("image_src")):
|
|
360
|
+
out.append(
|
|
361
|
+
_row_finding(
|
|
362
|
+
row,
|
|
363
|
+
"R11",
|
|
364
|
+
Severity.CRITICAL,
|
|
365
|
+
"Image alt text present but image URL is empty.",
|
|
366
|
+
"Add the image URL or remove the alt text.",
|
|
367
|
+
"Image alt text",
|
|
368
|
+
)
|
|
369
|
+
)
|
|
370
|
+
return out
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def rule_r12(load: LoadResult, intent: ImportIntent) -> list[Finding]:
|
|
374
|
+
"""image_src の URL 構文不正 / scheme 無 / ローカルパス。"""
|
|
375
|
+
out = []
|
|
376
|
+
for row in load.rows:
|
|
377
|
+
src = (row.get("image_src") or "").strip()
|
|
378
|
+
if src and not re.match(r"^https?://", src, re.IGNORECASE):
|
|
379
|
+
out.append(
|
|
380
|
+
_row_finding(
|
|
381
|
+
row,
|
|
382
|
+
"R12",
|
|
383
|
+
Severity.WARNING,
|
|
384
|
+
f"Image URL '{src}' has no http(s) scheme (local path or malformed).",
|
|
385
|
+
"Use a public http(s) URL. (HTTP reachability is not checked.)",
|
|
386
|
+
"Product image URL",
|
|
387
|
+
)
|
|
388
|
+
)
|
|
389
|
+
return out
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def rule_r13(load: LoadResult, intent: ImportIntent) -> list[Finding]:
|
|
393
|
+
"""Excel 由来の SKU 科学表記。"""
|
|
394
|
+
out = []
|
|
395
|
+
for row in load.rows:
|
|
396
|
+
sku = (row.get("sku") or "").strip()
|
|
397
|
+
if sku and _SCI_NOTATION.match(sku):
|
|
398
|
+
out.append(
|
|
399
|
+
_row_finding(
|
|
400
|
+
row,
|
|
401
|
+
"R13",
|
|
402
|
+
Severity.WARNING,
|
|
403
|
+
f"SKU '{sku}' looks like Excel scientific notation.",
|
|
404
|
+
"Re-format the SKU as text to avoid data loss.",
|
|
405
|
+
"SKU",
|
|
406
|
+
)
|
|
407
|
+
)
|
|
408
|
+
return out
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def rule_r14(load: LoadResult, intent: ImportIntent) -> list[Finding]:
|
|
412
|
+
"""option 位置入れ替えの疑い(ファイル単体で確定不能・注意喚起のみ)。"""
|
|
413
|
+
out = []
|
|
414
|
+
for row in load.rows:
|
|
415
|
+
if (
|
|
416
|
+
row.is_product_start
|
|
417
|
+
and _blank(row.get("option1_name"))
|
|
418
|
+
and not _blank(row.get("option2_name"))
|
|
419
|
+
):
|
|
420
|
+
out.append(
|
|
421
|
+
_row_finding(
|
|
422
|
+
row,
|
|
423
|
+
"R14",
|
|
424
|
+
Severity.WARNING,
|
|
425
|
+
"Option2 set without Option1 on product-start row (possible option position shift).",
|
|
426
|
+
"Verify option positions against the existing product (not checkable from file alone).",
|
|
427
|
+
"Option1 name",
|
|
428
|
+
)
|
|
429
|
+
)
|
|
430
|
+
return out
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
ALL_ROW_RULES = [
|
|
434
|
+
rule_r01,
|
|
435
|
+
rule_r02,
|
|
436
|
+
rule_r03,
|
|
437
|
+
rule_r04,
|
|
438
|
+
rule_r05,
|
|
439
|
+
rule_r06,
|
|
440
|
+
rule_r07,
|
|
441
|
+
rule_r08,
|
|
442
|
+
rule_r09,
|
|
443
|
+
rule_r10,
|
|
444
|
+
rule_r11,
|
|
445
|
+
rule_r12,
|
|
446
|
+
rule_r13,
|
|
447
|
+
rule_r14,
|
|
448
|
+
]
|