sino-bc-cli 1.8.38 → 1.8.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1 -1
- package/dist/skill-manifest.json +11 -8
- package/dist/skills/biz-line-strategy-generate/SKILL.md +2 -0
- package/dist/skills/biz-line-strategy-generate/references/output-excel.md +6 -0
- package/dist/skills/biz-line-strategy-generate/references/runtime.md +8 -0
- package/dist/skills/biz-line-strategy-generate/scripts/build_report.py +299 -244
- package/dist/skills/biz-line-strategy-generate/scripts/detail_store.py +82 -0
- package/dist/skills/biz-line-strategy-generate/scripts/summarize.py +22 -22
- package/dist/skills/biz-line-strategy-generate/scripts/test_detail_store.py +122 -0
- package/dist/skills/biz-line-strategy-generate/scripts/test_excel_streaming.py +223 -0
- package/dist/skills/biz-line-strategy-generate/scripts/test_pipeline.py +4 -1
- package/dist/skills/biz-line-strategy-generate/scripts/test_workflow.py +40 -27
- package/package.json +1 -1
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
python3 build_report.py --input payload.json [--output /path/to/file.xlsx]
|
|
11
11
|
cat payload.json | python3 build_report.py --input -
|
|
12
12
|
|
|
13
|
-
payload.json
|
|
13
|
+
payload.json 兼容旧内嵌结构(统一工作流另将 Agent 回包传给导出函数):
|
|
14
14
|
{
|
|
15
15
|
"output_path": "可选,未传时默认写到桌面 业务线策略生成_YYYYMMDD_HHMMSS.xlsx",
|
|
16
16
|
"rows": [ { "...策略摘要行..." } ],
|
|
@@ -19,6 +19,9 @@ payload.json 结构(统一工作流另将 Agent 回包传给导出函数):
|
|
|
19
19
|
"...其余28项指标...": "...", "extra": ""}]
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
正常工作流以 detailSource={"version":1,"path":"本条明细.jsonl","count":N} 替代 details。
|
|
23
|
+
路径相对输入 JSON 所在目录;stdin 输入则相对当前目录。
|
|
24
|
+
|
|
22
25
|
列固定为:组别、业务线、本期/上期、项目名称、广告账号、
|
|
23
26
|
预算策略标签、预算策略内容、预算策略learning、广告产品策略标签、广告产品策略内容、广告产品策略learning、
|
|
24
27
|
受众策略标签、受众策略内容、受众策略learning、素材策略内容、素材策略learning。
|
|
@@ -37,6 +40,10 @@ import math
|
|
|
37
40
|
import unicodedata
|
|
38
41
|
import uuid
|
|
39
42
|
import re
|
|
43
|
+
import sqlite3
|
|
44
|
+
import tempfile
|
|
45
|
+
from collections import OrderedDict
|
|
46
|
+
from contextlib import closing, contextmanager
|
|
40
47
|
from pathlib import Path
|
|
41
48
|
from xml.etree import ElementTree as ET
|
|
42
49
|
from datetime import datetime
|
|
@@ -157,126 +164,26 @@ def col_letter(index: int) -> str:
|
|
|
157
164
|
return letters
|
|
158
165
|
|
|
159
166
|
|
|
160
|
-
def
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
[intern(str(row.get(key, "") or ""), key.endswith('Strategy') or key == 'adAccounts') for key, _label in COLUMNS] for row in rows
|
|
181
|
-
] if include_strategy else [])
|
|
182
|
-
detail_header = [intern(label) for _key, label in DETAIL_COLUMNS]
|
|
183
|
-
detail_rows = []
|
|
184
|
-
for row in details or []:
|
|
185
|
-
detail_rows.append(
|
|
186
|
-
[intern(str(row.get(key, "") or "")) for key, _label in DETAIL_COLUMNS]
|
|
187
|
-
)
|
|
188
|
-
|
|
189
|
-
# AI 表复用共享字符串池并追加到末尾,前两张表的字符串索引保持不变。
|
|
190
|
-
ai_header = [intern(label) for label in AI_COLUMNS] if ai_cells is not None else []
|
|
191
|
-
ai_values = [intern(plain, tuple(parts) if any(bold for _text, bold in parts) else False)
|
|
192
|
-
for plain, parts in ai_cells] if ai_cells is not None else []
|
|
193
|
-
|
|
194
|
-
def string_xml(text, rich):
|
|
195
|
-
"""共享字符串统一保留换行与加粗,避免 AI 表使用预览器不兼容的 inlineStr。"""
|
|
196
|
-
escape_text = lambda value: xml_escape(value, {chr(13): ' '})
|
|
197
|
-
if not rich:
|
|
198
|
-
return f'<si><t xml:space="preserve">{escape_text(text)}</t></si>'
|
|
199
|
-
if isinstance(rich, tuple):
|
|
200
|
-
# AI 的 Markdown 已被拆为文字及样式,不重新解释投放内容。
|
|
201
|
-
parts = rich
|
|
202
|
-
else:
|
|
203
|
-
pattern = r'(【[^\n】]+】|\[[^\n\]]+\]|(?m:^)(?:Meta|Google|TikTok)(?=:))'
|
|
204
|
-
parts, start = [], 0
|
|
205
|
-
for match in re.finditer(pattern, text):
|
|
206
|
-
parts.extend((part, bold) for part, bold in
|
|
207
|
-
((text[start:match.start()], False), (match.group(), True)) if part)
|
|
208
|
-
start = match.end()
|
|
209
|
-
if text[start:] or not parts:
|
|
210
|
-
parts.append((text[start:], False))
|
|
211
|
-
runs = ''.join(f'<r>{"<rPr><b/></rPr>" if bold else ""}'
|
|
212
|
-
f'<t xml:space="preserve">{escape_text(part)}</t></r>' for part, bold in parts)
|
|
213
|
-
return '<si>' + runs + '</si>'
|
|
214
|
-
items = ''.join(string_xml(text, rich) for text, rich in order)
|
|
215
|
-
count = len(header_indices) + sum(map(len, row_indices)) + len(detail_header) + sum(map(len, detail_rows)) + len(ai_header) + len(ai_values)
|
|
216
|
-
sst_xml = (
|
|
217
|
-
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
218
|
-
f'<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" '
|
|
219
|
-
f'count="{count}" uniqueCount="{len(order)}">{items}</sst>'
|
|
220
|
-
)
|
|
221
|
-
return sst_xml, header_indices, row_indices, detail_header, detail_rows, (ai_header, ai_values)
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
def build_sheet_xml(header_indices, row_indices, columns, content_keys=None, multiline_keys=None, values=None):
|
|
225
|
-
"""生成 worksheet xml:首行表头样式 s="1",其余行内容样式 s="2"(自动换行)"""
|
|
226
|
-
content_keys = content_keys or set()
|
|
227
|
-
multiline_keys = multiline_keys or set()
|
|
228
|
-
col_count = len(columns)
|
|
229
|
-
dimension_ref = f"A1:{col_letter(col_count - 1)}{len(row_indices) + 1}"
|
|
230
|
-
|
|
231
|
-
cols_xml_parts = []
|
|
232
|
-
widths = {}
|
|
233
|
-
for i, (key, _label) in enumerate(columns):
|
|
234
|
-
if key in content_keys:
|
|
235
|
-
width = COL_WIDTH_CONTENT
|
|
236
|
-
elif key in multiline_keys:
|
|
237
|
-
width = COL_WIDTH_MULTILINE
|
|
238
|
-
else:
|
|
239
|
-
width = COL_WIDTH_DEFAULT
|
|
240
|
-
# 尽量扩列减少换行;达到Excel行高上限仍保留单元格全文,不拆业务线行。
|
|
241
|
-
if key.endswith('Strategy'):
|
|
242
|
-
while width < 255 and any(visual_lines(str(record.get(key) or ''), width) > 26 for record in (values or [])):
|
|
243
|
-
width = min(255, width + 4)
|
|
244
|
-
widths[key] = width
|
|
245
|
-
cols_xml_parts.append(
|
|
246
|
-
f'<col min="{i + 1}" max="{i + 1}" width="{width}" customWidth="1"/>'
|
|
247
|
-
)
|
|
248
|
-
cols_xml = f"<cols>{''.join(cols_xml_parts)}</cols>"
|
|
249
|
-
|
|
250
|
-
def row_xml(row_num, indices, style_id):
|
|
251
|
-
cells = []
|
|
252
|
-
for i, sst_index in enumerate(indices):
|
|
253
|
-
ref = f"{col_letter(i)}{row_num}"
|
|
254
|
-
cells.append(f'<c r="{ref}" s="{style_id}" t="s"><v>{sst_index}</v></c>')
|
|
255
|
-
# Excel 不会可靠地对程序生成的行自动增高,按中文显示宽度确定行高。
|
|
256
|
-
if row_num == 1:
|
|
257
|
-
height = 32
|
|
258
|
-
else:
|
|
259
|
-
record = (values or [{}] * len(row_indices))[row_num - 2]
|
|
260
|
-
lines = max((visual_lines(str(record.get(key) or ""), widths[key]) for key, _ in columns), default=1)
|
|
261
|
-
height = min(409, max(30, lines * 15 + 10))
|
|
262
|
-
return f'<row r="{row_num}" ht="{height}" customHeight="1">{"".join(cells)}</row>'
|
|
263
|
-
|
|
264
|
-
rows_xml_parts = [row_xml(1, header_indices, 1)]
|
|
265
|
-
for offset, indices in enumerate(row_indices):
|
|
266
|
-
rows_xml_parts.append(row_xml(offset + 2, indices, 2))
|
|
267
|
-
|
|
268
|
-
sheet_data = f"<sheetData>{''.join(rows_xml_parts)}</sheetData>"
|
|
269
|
-
|
|
270
|
-
return (
|
|
271
|
-
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
272
|
-
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
|
|
273
|
-
f'<dimension ref="{dimension_ref}"/>'
|
|
274
|
-
'<sheetViews><sheetView workbookViewId="0">'
|
|
275
|
-
'<pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/>'
|
|
276
|
-
'</sheetView></sheetViews>'
|
|
277
|
-
f'{cols_xml}{sheet_data}<autoFilter ref="{dimension_ref}"/>'
|
|
278
|
-
'</worksheet>'
|
|
279
|
-
)
|
|
167
|
+
def string_xml(text, rich):
|
|
168
|
+
"""共享字符串统一保留换行与加粗,避免 AI 表使用预览器不兼容的 inlineStr。"""
|
|
169
|
+
escape_text = lambda value: xml_escape(value, {chr(13): ' '})
|
|
170
|
+
if not rich:
|
|
171
|
+
return f'<si><t xml:space="preserve">{escape_text(text)}</t></si>'
|
|
172
|
+
if isinstance(rich, tuple):
|
|
173
|
+
# AI 的 Markdown 已被拆为文字及样式,不重新解释投放内容。
|
|
174
|
+
parts = rich
|
|
175
|
+
else:
|
|
176
|
+
pattern = r'(【[^\n】]+】|\[[^\n\]]+\]|(?m:^)(?:Meta|Google|TikTok)(?=:))'
|
|
177
|
+
parts, start = [], 0
|
|
178
|
+
for match in re.finditer(pattern, text):
|
|
179
|
+
parts.extend((part, bold) for part, bold in
|
|
180
|
+
((text[start:match.start()], False), (match.group(), True)) if part)
|
|
181
|
+
start = match.end()
|
|
182
|
+
if text[start:] or not parts:
|
|
183
|
+
parts.append((text[start:], False))
|
|
184
|
+
runs = ''.join(f'<r>{"<rPr><b/></rPr>" if bold else ""}'
|
|
185
|
+
f'<t xml:space="preserve">{escape_text(part)}</t></r>' for part, bold in parts)
|
|
186
|
+
return '<si>' + runs + '</si>'
|
|
280
187
|
|
|
281
188
|
|
|
282
189
|
def default_output_path() -> str:
|
|
@@ -295,19 +202,6 @@ def visual_lines(text, width):
|
|
|
295
202
|
return sum(max(1, math.ceil(sum(2 if unicodedata.east_asian_width(c) in 'WF' else 1 for c in line) / max(1, width - 2))) for line in text.split('\n'))
|
|
296
203
|
|
|
297
204
|
|
|
298
|
-
def prepare_layout(rows, details):
|
|
299
|
-
"""每条业务线与维度明细均保持原行,动作列只记录业务动作。"""
|
|
300
|
-
# 写入与回读校验都走相同清洗,避免源数据中的非法控制字符使 sharedStrings.xml 无法解析。
|
|
301
|
-
def clean(record):
|
|
302
|
-
return {key: _sanitize_xml_text(value) if isinstance(value, str) else value
|
|
303
|
-
for key, value in record.items()}
|
|
304
|
-
|
|
305
|
-
rows = [clean(r) for r in rows]
|
|
306
|
-
details = [clean(r) for r in details or []]
|
|
307
|
-
# 单元格全文保留;超过 Excel 字符上限时在写入前明确报错,避免隐式截断。
|
|
308
|
-
return rows, details
|
|
309
|
-
|
|
310
|
-
|
|
311
205
|
AI_STRATEGY_HEADINGS = ('预算策略', '广告产品策略', '受众策略', '创意策略')
|
|
312
206
|
AI_COLUMNS = ('一页速览', '预算策略标签', '预算策略', '广告产品策略标签',
|
|
313
207
|
'广告产品策略', '受众策略标签', '受众策略', '创意策略')
|
|
@@ -383,120 +277,276 @@ def build_ai_sheet_xml(ai_sections, strategy_row, ai_indices):
|
|
|
383
277
|
'<autoFilter ref="A1:H2"/></worksheet>')
|
|
384
278
|
|
|
385
279
|
|
|
280
|
+
@contextmanager
|
|
281
|
+
def _string_index(root):
|
|
282
|
+
"""每次导出/回读独占磁盘索引;页缓存与热点缓存均不随明细总量增长。"""
|
|
283
|
+
with tempfile.TemporaryDirectory(prefix='.excel-index-', dir=root) as directory:
|
|
284
|
+
connection = sqlite3.connect(str(Path(directory) / 'strings.sqlite'))
|
|
285
|
+
try:
|
|
286
|
+
connection.execute('PRAGMA cache_size = -2048')
|
|
287
|
+
connection.execute('PRAGMA mmap_size = 0')
|
|
288
|
+
connection.execute('CREATE TABLE strings (id INTEGER PRIMARY KEY, key TEXT UNIQUE, text TEXT, rich TEXT)')
|
|
289
|
+
yield connection
|
|
290
|
+
finally:
|
|
291
|
+
connection.close()
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
class _Strings:
|
|
295
|
+
def __init__(self, connection):
|
|
296
|
+
self.connection = connection
|
|
297
|
+
self.cache = OrderedDict()
|
|
298
|
+
self.count = 0
|
|
299
|
+
self.unique = 0
|
|
300
|
+
|
|
301
|
+
def intern(self, text, rich=False):
|
|
302
|
+
self.count += 1
|
|
303
|
+
cacheable = isinstance(rich, bool) and len(text) <= 512
|
|
304
|
+
cache_key = (text, rich) if cacheable else None
|
|
305
|
+
if cacheable and cache_key in self.cache:
|
|
306
|
+
self.cache.move_to_end(cache_key)
|
|
307
|
+
return self.cache[cache_key]
|
|
308
|
+
encoded = json.dumps(rich, ensure_ascii=False, separators=(',', ':'))
|
|
309
|
+
key = json.dumps([text, rich], ensure_ascii=False, separators=(',', ':'))
|
|
310
|
+
found = self.connection.execute('SELECT id FROM strings WHERE key = ?', (key,)).fetchone()
|
|
311
|
+
if found is None:
|
|
312
|
+
ident = self.unique
|
|
313
|
+
self.connection.execute('INSERT INTO strings VALUES (?, ?, ?, ?)', (ident, key, text, encoded))
|
|
314
|
+
self.unique += 1
|
|
315
|
+
else:
|
|
316
|
+
ident = found[0]
|
|
317
|
+
# 长单元格不进入热点缓存,限制缓存的实际字节占用。
|
|
318
|
+
if cacheable:
|
|
319
|
+
self.cache[cache_key] = ident
|
|
320
|
+
if len(self.cache) > 512:
|
|
321
|
+
self.cache.popitem(last=False)
|
|
322
|
+
return ident
|
|
323
|
+
|
|
324
|
+
def write(self, archive):
|
|
325
|
+
with archive.open('xl/sharedStrings.xml', 'w', force_zip64=True) as stream:
|
|
326
|
+
stream.write(('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
327
|
+
'<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" '
|
|
328
|
+
f'count="{self.count}" uniqueCount="{self.unique}">').encode())
|
|
329
|
+
for text, encoded in self.connection.execute('SELECT text, rich FROM strings ORDER BY id'):
|
|
330
|
+
rich = json.loads(encoded)
|
|
331
|
+
if isinstance(rich, list):
|
|
332
|
+
rich = tuple(tuple(part) for part in rich)
|
|
333
|
+
stream.write(string_xml(text, rich).encode('utf-8'))
|
|
334
|
+
stream.write(b'</sst>')
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _clean_record(record):
|
|
338
|
+
return {key: _sanitize_xml_text(value) if isinstance(value, str) else value
|
|
339
|
+
for key, value in record.items()}
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _write_sheet(archive, name, records, columns, strings, content_keys, multiline_keys):
|
|
343
|
+
widths = {}
|
|
344
|
+
for key, _label in columns:
|
|
345
|
+
width = COL_WIDTH_CONTENT if key in content_keys else COL_WIDTH_MULTILINE if key in multiline_keys else COL_WIDTH_DEFAULT
|
|
346
|
+
if key.endswith('Strategy'):
|
|
347
|
+
while width < 255 and any(visual_lines(str(_clean_record(row).get(key) or ''), width) > 26 for row in records):
|
|
348
|
+
width = min(255, width + 4)
|
|
349
|
+
widths[key] = width
|
|
350
|
+
dimension = f'A1:{col_letter(len(columns) - 1)}{len(records) + 1}'
|
|
351
|
+
with archive.open(name, 'w', force_zip64=True) as stream:
|
|
352
|
+
stream.write(('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
353
|
+
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
|
|
354
|
+
f'<dimension ref="{dimension}"/>'
|
|
355
|
+
'<sheetViews><sheetView workbookViewId="0">'
|
|
356
|
+
'<pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/>'
|
|
357
|
+
'</sheetView></sheetViews><cols>' + ''.join(
|
|
358
|
+
f'<col min="{i + 1}" max="{i + 1}" width="{widths[key]}" customWidth="1"/>'
|
|
359
|
+
for i, (key, _label) in enumerate(columns)) + '</cols><sheetData>').encode())
|
|
360
|
+
def emit(number, values, height, header=False):
|
|
361
|
+
cells = []
|
|
362
|
+
for i, ((key, _label), value) in enumerate(zip(columns, values)):
|
|
363
|
+
if len(value.encode('utf-16-le')) // 2 > 32767:
|
|
364
|
+
raise ValueError(f'{key} 超过Excel单元格32767字符上限,请精简内容;不会拆分原始记录行')
|
|
365
|
+
rich = not header and columns is COLUMNS and (key.endswith('Strategy') or key == 'adAccounts')
|
|
366
|
+
ident = strings.intern(value, rich)
|
|
367
|
+
cells.append(f'<c r="{col_letter(i)}{number}" s="{1 if header else 2}" t="s"><v>{ident}</v></c>')
|
|
368
|
+
stream.write((f'<row r="{number}" ht="{height}" customHeight="1">' + ''.join(cells) + '</row>').encode())
|
|
369
|
+
emit(1, [label for _key, label in columns], 32, True)
|
|
370
|
+
count = 0
|
|
371
|
+
iterator = iter(records)
|
|
372
|
+
try:
|
|
373
|
+
for count, record in enumerate(iterator, 1):
|
|
374
|
+
record = _clean_record(record)
|
|
375
|
+
values = [str(record.get(key) or '') for key, _label in columns]
|
|
376
|
+
lines = max((visual_lines(value, widths[key]) for (key, _), value in zip(columns, values)), default=1)
|
|
377
|
+
emit(count + 1, values, min(409, max(30, lines * 15 + 10)))
|
|
378
|
+
finally:
|
|
379
|
+
if hasattr(iterator, 'close'):
|
|
380
|
+
iterator.close()
|
|
381
|
+
if count != len(records):
|
|
382
|
+
raise ValueError('Excel 明细源行数不一致')
|
|
383
|
+
stream.write(f'</sheetData><autoFilter ref="{dimension}"/></worksheet>'.encode())
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _write_workbook(rows, output_path, details, ai_sections, include_strategy):
|
|
387
|
+
output = Path(output_path)
|
|
388
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
389
|
+
root = Path(details.path).parent if hasattr(details, 'path') else output.parent
|
|
390
|
+
created = False
|
|
391
|
+
try:
|
|
392
|
+
with _string_index(root) as connection, zipfile.ZipFile(output, 'x', zipfile.ZIP_DEFLATED) as archive:
|
|
393
|
+
created = True
|
|
394
|
+
strings = _Strings(connection)
|
|
395
|
+
content_types, relationships, workbook = CONTENT_TYPES_XML, WORKBOOK_RELS_XML, WORKBOOK_XML
|
|
396
|
+
if not include_strategy:
|
|
397
|
+
content_types = re.sub(r'<Override PartName="/xl/worksheets/sheet2.xml"[^>]*/>\n?', '', content_types)
|
|
398
|
+
relationships = re.sub(r'<Relationship Id="rId4"[^>]*/>\n?', '', relationships)
|
|
399
|
+
workbook = (workbook.replace('<sheet name="策略" sheetId="1" r:id="rId1"/>', '')
|
|
400
|
+
.replace('<sheet name="明细" sheetId="2" r:id="rId4"/>', '<sheet name="明细" sheetId="1" r:id="rId1"/>'))
|
|
401
|
+
if ai_sections is not None:
|
|
402
|
+
content_types = content_types.replace('</Types>', '<Override PartName="/xl/worksheets/sheet3.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/></Types>')
|
|
403
|
+
relationships = relationships.replace('</Relationships>', '<Relationship Id="rId5" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet3.xml"/></Relationships>')
|
|
404
|
+
workbook = workbook.replace('</sheets>', '<sheet name="AI策略" sheetId="3" r:id="rId5"/></sheets>')
|
|
405
|
+
for name, value in (('[Content_Types].xml', content_types), ('_rels/.rels', ROOT_RELS_XML),
|
|
406
|
+
('xl/workbook.xml', workbook), ('xl/_rels/workbook.xml.rels', relationships),
|
|
407
|
+
('xl/styles.xml', STYLES_XML)):
|
|
408
|
+
archive.writestr(name, value)
|
|
409
|
+
if include_strategy:
|
|
410
|
+
_write_sheet(archive, 'xl/worksheets/sheet1.xml', rows, COLUMNS, strings, CONTENT_KEYS, MULTILINE_KEYS)
|
|
411
|
+
_write_sheet(archive, f'xl/worksheets/sheet{2 if include_strategy else 1}.xml', details,
|
|
412
|
+
DETAIL_COLUMNS, strings, {'name', 'extra'},
|
|
413
|
+
{'id', *(code for code, _label, _is_percent in REPORT_METRICS)})
|
|
414
|
+
if ai_sections is not None:
|
|
415
|
+
cells = ai_cell_values(ai_sections, rows[0])
|
|
416
|
+
headers = [strings.intern(label) for label in AI_COLUMNS]
|
|
417
|
+
values = [strings.intern(plain, tuple(parts) if any(bold for _text, bold in parts) else False)
|
|
418
|
+
for plain, parts in cells]
|
|
419
|
+
archive.writestr('xl/worksheets/sheet3.xml', build_ai_sheet_xml(ai_sections, rows[0], (headers, values)))
|
|
420
|
+
strings.write(archive)
|
|
421
|
+
except BaseException:
|
|
422
|
+
if created:
|
|
423
|
+
output.unlink()
|
|
424
|
+
raise
|
|
425
|
+
return output_path
|
|
426
|
+
|
|
427
|
+
|
|
386
428
|
def write_xlsx(rows, output_path: str, details=None, *, ai_strategy=None) -> str:
|
|
387
429
|
if not rows:
|
|
388
430
|
raise ValueError('策略数据为空')
|
|
389
|
-
rows
|
|
431
|
+
rows = [_clean_record(row) for row in rows]
|
|
390
432
|
ai_sections = prepare_ai_sections(ai_strategy) if ai_strategy is not None else None
|
|
391
433
|
if ai_sections is not None and len(rows) != 1:
|
|
392
434
|
raise ValueError('AI 策略 sheet 必须对应唯一业务线')
|
|
393
|
-
|
|
394
|
-
for row, columns in [(r, COLUMNS) for r in rows] + [(r, DETAIL_COLUMNS) for r in details]:
|
|
395
|
-
for key, _ in columns:
|
|
396
|
-
if len(str(row.get(key) or '').encode('utf-16-le')) // 2 > 32767:
|
|
397
|
-
raise ValueError(f'{key} 超过Excel单元格32767字符上限,请精简内容;不会拆分原始记录行')
|
|
398
|
-
sst_xml, header_indices, row_indices, detail_header, detail_rows, ai_indices = build_shared_strings(
|
|
399
|
-
rows, details, ai_cells=ai_cell_values(ai_sections, rows[0]) if ai_sections is not None else None
|
|
400
|
-
)
|
|
401
|
-
sheet_xml = build_sheet_xml(
|
|
402
|
-
header_indices,
|
|
403
|
-
row_indices,
|
|
404
|
-
COLUMNS,
|
|
405
|
-
content_keys=CONTENT_KEYS,
|
|
406
|
-
multiline_keys=MULTILINE_KEYS,
|
|
407
|
-
values=rows,
|
|
408
|
-
)
|
|
409
|
-
detail_xml = build_sheet_xml(
|
|
410
|
-
detail_header,
|
|
411
|
-
detail_rows,
|
|
412
|
-
DETAIL_COLUMNS,
|
|
413
|
-
content_keys={"name", "extra"},
|
|
414
|
-
multiline_keys={'id', *(code for code, _label, _is_percent in REPORT_METRICS)},
|
|
415
|
-
values=details,
|
|
416
|
-
)
|
|
417
|
-
|
|
418
|
-
out_dir = os.path.dirname(output_path)
|
|
419
|
-
if out_dir and not os.path.isdir(out_dir):
|
|
420
|
-
os.makedirs(out_dir, exist_ok=True)
|
|
421
|
-
|
|
422
|
-
with zipfile.ZipFile(output_path, "x", zipfile.ZIP_DEFLATED) as zf:
|
|
423
|
-
# 第三张表只在最终工作簿追加;草稿仍维持原两张表结构。
|
|
424
|
-
content_types = CONTENT_TYPES_XML
|
|
425
|
-
workbook_rels = WORKBOOK_RELS_XML
|
|
426
|
-
workbook_xml = WORKBOOK_XML
|
|
427
|
-
if ai_sections is not None:
|
|
428
|
-
content_types = content_types.replace('</Types>',
|
|
429
|
-
'<Override PartName="/xl/worksheets/sheet3.xml" '
|
|
430
|
-
'ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/></Types>')
|
|
431
|
-
workbook_rels = workbook_rels.replace('</Relationships>',
|
|
432
|
-
'<Relationship Id="rId5" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" '
|
|
433
|
-
'Target="worksheets/sheet3.xml"/></Relationships>')
|
|
434
|
-
workbook_xml = workbook_xml.replace('</sheets>',
|
|
435
|
-
'<sheet name="AI策略" sheetId="3" r:id="rId5"/></sheets>')
|
|
436
|
-
zf.writestr("[Content_Types].xml", content_types)
|
|
437
|
-
zf.writestr("_rels/.rels", ROOT_RELS_XML)
|
|
438
|
-
zf.writestr("xl/workbook.xml", workbook_xml)
|
|
439
|
-
zf.writestr("xl/_rels/workbook.xml.rels", workbook_rels)
|
|
440
|
-
zf.writestr("xl/styles.xml", STYLES_XML)
|
|
441
|
-
zf.writestr("xl/sharedStrings.xml", sst_xml)
|
|
442
|
-
zf.writestr("xl/worksheets/sheet1.xml", sheet_xml)
|
|
443
|
-
zf.writestr("xl/worksheets/sheet2.xml", detail_xml)
|
|
444
|
-
if ai_sections is not None:
|
|
445
|
-
zf.writestr("xl/worksheets/sheet3.xml", build_ai_sheet_xml(ai_sections, rows[0], ai_indices))
|
|
446
|
-
|
|
447
|
-
return output_path
|
|
435
|
+
return _write_workbook(rows, output_path, details if details is not None else [], ai_sections, True)
|
|
448
436
|
|
|
449
437
|
|
|
450
438
|
def write_detail_workbook(details, output_path):
|
|
451
439
|
"""给 AI 单独导出只有明细 sheet 的工作簿,不暴露固定策略正文。"""
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
440
|
+
return _write_workbook([], output_path, details, None, False)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
_NS = {'s': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _xml_items(archive, name, tag):
|
|
447
|
+
"""完整读取 XML,并在消费完 si/row 后从父节点移除,避免空节点积累。"""
|
|
448
|
+
prefix = '{' + _NS['s'] + '}'
|
|
449
|
+
qualified = prefix + tag
|
|
450
|
+
root_tag = prefix + ('worksheet' if tag == 'row' else 'sst')
|
|
451
|
+
sheet_data = prefix + 'sheetData'
|
|
452
|
+
sheet_data_count = 0
|
|
453
|
+
with archive.open(name) as stream:
|
|
454
|
+
stack = []
|
|
455
|
+
for event, element in ET.iterparse(stream, events=('start', 'end')):
|
|
456
|
+
if event == 'start':
|
|
457
|
+
if not stack and element.tag != root_tag:
|
|
458
|
+
raise ValueError('Excel XML 根节点无效')
|
|
459
|
+
if tag == 'row' and element.tag == sheet_data:
|
|
460
|
+
sheet_data_count += 1
|
|
461
|
+
if len(stack) != 1 or sheet_data_count != 1:
|
|
462
|
+
raise ValueError('Excel sheetData 层级或数量无效')
|
|
463
|
+
if element.tag == qualified:
|
|
464
|
+
valid_parent = (len(stack) == 2 and stack[-1].tag == sheet_data) if tag == 'row' else len(stack) == 1
|
|
465
|
+
if not valid_parent:
|
|
466
|
+
raise ValueError('Excel 行或共享字符串层级无效')
|
|
467
|
+
stack.append(element)
|
|
468
|
+
else:
|
|
469
|
+
if element.tag == qualified:
|
|
470
|
+
yield element
|
|
471
|
+
if len(stack) > 1:
|
|
472
|
+
stack[-2].remove(element)
|
|
473
|
+
element.clear()
|
|
474
|
+
stack.pop()
|
|
475
|
+
if tag == 'row' and sheet_data_count != 1:
|
|
476
|
+
raise ValueError('Excel 缺少唯一 sheetData')
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _load_strings(archive, connection):
|
|
480
|
+
with closing(_xml_items(archive, 'xl/sharedStrings.xml', 'si')) as items:
|
|
481
|
+
for ident, element in enumerate(items):
|
|
482
|
+
text = ''.join(node.text or '' for node in element.findall('.//s:t', _NS))
|
|
483
|
+
connection.execute('INSERT INTO strings (id, text) VALUES (?, ?)', (ident, text))
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _validate_sheet(archive, name, records, columns, connection):
|
|
487
|
+
expected = iter(records)
|
|
488
|
+
count = 0
|
|
489
|
+
actual = _xml_items(archive, name, 'row')
|
|
490
|
+
cache = OrderedDict()
|
|
491
|
+
try:
|
|
492
|
+
for count, row in enumerate(actual, 1):
|
|
493
|
+
if count == 1:
|
|
494
|
+
target = [label for _key, label in columns]
|
|
495
|
+
else:
|
|
496
|
+
try:
|
|
497
|
+
record = _clean_record(next(expected))
|
|
498
|
+
except StopIteration:
|
|
499
|
+
raise ValueError('Excel 表存在多余行') from None
|
|
500
|
+
target = [str(record.get(key) or '') for key, _label in columns]
|
|
501
|
+
cells = row.findall('s:c', _NS)
|
|
502
|
+
if row.get('r') != str(count) or len(cells) != len(columns):
|
|
503
|
+
raise ValueError('Excel 表行列数或坐标不完整')
|
|
504
|
+
references = []
|
|
505
|
+
for index, cell in enumerate(cells):
|
|
506
|
+
reference = cell.find('s:v', _NS)
|
|
507
|
+
if cell.get('r') != f'{col_letter(index)}{count}' or cell.get('t') != 's' \
|
|
508
|
+
or cell.find('s:f', _NS) is not None or reference is None \
|
|
509
|
+
or not re.fullmatch(r'[0-9]+', reference.text or ''):
|
|
510
|
+
raise ValueError('Excel 表单元格坐标或共享字符串索引无效')
|
|
511
|
+
ident = int(reference.text)
|
|
512
|
+
if ident > 9223372036854775807:
|
|
513
|
+
raise ValueError('Excel 共享字符串索引越界')
|
|
514
|
+
references.append(ident)
|
|
515
|
+
# 每行只查询缓存缺失项;行内值单独持有,避免缓存淘汰影响本行比较。
|
|
516
|
+
values = {ident: cache[ident] for ident in references if ident in cache}
|
|
517
|
+
missing = set(references) - values.keys()
|
|
518
|
+
if missing:
|
|
519
|
+
placeholders = ','.join('?' for _ in missing)
|
|
520
|
+
values.update(connection.execute(f'SELECT id, text FROM strings WHERE id IN ({placeholders})', tuple(missing)))
|
|
521
|
+
for ident, value in zip(references, target):
|
|
522
|
+
if ident not in values or values[ident] != value:
|
|
523
|
+
raise ValueError('Excel 表内容或共享字符串索引不完整')
|
|
524
|
+
if len(value) <= 1024:
|
|
525
|
+
cache[ident] = value
|
|
526
|
+
cache.move_to_end(ident)
|
|
527
|
+
if len(cache) > 512:
|
|
528
|
+
cache.popitem(last=False)
|
|
529
|
+
sentinel = object()
|
|
530
|
+
if count != len(records) + 1 or next(expected, sentinel) is not sentinel:
|
|
531
|
+
raise ValueError('Excel 表内容或行列数不完整')
|
|
532
|
+
finally:
|
|
533
|
+
actual.close()
|
|
534
|
+
if hasattr(expected, 'close'):
|
|
535
|
+
expected.close()
|
|
479
536
|
|
|
480
537
|
|
|
481
538
|
def validate_detail_workbook(path, details):
|
|
482
539
|
"""回读 AI 输入,确认唯一 sheet 与本条明细逐格一致。"""
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
with zipfile.ZipFile(path) as archive:
|
|
540
|
+
root = Path(details.path).parent if hasattr(details, 'path') else Path(path).parent
|
|
541
|
+
with zipfile.ZipFile(path) as archive, _string_index(root) as connection:
|
|
486
542
|
if archive.testzip():
|
|
487
543
|
raise ValueError('AI 明细工作簿 ZIP 校验失败')
|
|
488
544
|
workbook = ET.fromstring(archive.read('xl/workbook.xml'))
|
|
489
|
-
if [item.attrib['name'] for item in workbook.findall('s:sheets/s:sheet',
|
|
545
|
+
if [item.attrib['name'] for item in workbook.findall('s:sheets/s:sheet', _NS)] != ['明细']:
|
|
490
546
|
raise ValueError('AI 输入工作簿只能包含明细 sheet')
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
for row in sheet.findall('s:sheetData/s:row', ns)]
|
|
495
|
-
expected = [[label for _key, label in DETAIL_COLUMNS]] + [
|
|
496
|
-
[str(row.get(key) or '') for key, _label in DETAIL_COLUMNS] for row in clean_details]
|
|
497
|
-
if actual != expected:
|
|
498
|
-
raise ValueError('AI 输入明细 sheet 内容不完整')
|
|
499
|
-
return {'details': len(clean_details)}
|
|
547
|
+
_load_strings(archive, connection)
|
|
548
|
+
_validate_sheet(archive, 'xl/worksheets/sheet1.xml', details, DETAIL_COLUMNS, connection)
|
|
549
|
+
return {'details': len(details)}
|
|
500
550
|
|
|
501
551
|
|
|
502
552
|
def _read_ai_sheet(archive):
|
|
@@ -516,8 +566,16 @@ def _read_ai_sheet(archive):
|
|
|
516
566
|
rows = sheet.findall('s:sheetData/s:row', ns)
|
|
517
567
|
if len(rows) != 2 or [row.get('r') for row in rows] != ['1', '2']:
|
|
518
568
|
raise ValueError('AI 策略 sheet 必须有唯一业务线数据行')
|
|
519
|
-
|
|
520
|
-
|
|
569
|
+
needed = set()
|
|
570
|
+
for cell in sheet.findall('s:sheetData/s:row/s:c', ns):
|
|
571
|
+
reference = cell.find('s:v', ns)
|
|
572
|
+
if reference is not None and re.fullmatch(r'[0-9]+', reference.text or ''):
|
|
573
|
+
needed.add(int(reference.text))
|
|
574
|
+
strings = {}
|
|
575
|
+
with closing(_xml_items(archive, 'xl/sharedStrings.xml', 'si')) as items:
|
|
576
|
+
for ident, item in enumerate(items):
|
|
577
|
+
if ident in needed:
|
|
578
|
+
strings[ident] = ''.join(node.text or '' for node in item.findall('.//s:t', ns))
|
|
521
579
|
actual = []
|
|
522
580
|
for index, row in enumerate(rows, 1):
|
|
523
581
|
cells = row.findall('s:c', ns)
|
|
@@ -530,7 +588,7 @@ def _read_ai_sheet(archive):
|
|
|
530
588
|
or not re.fullmatch(r'[0-9]+', reference.text or ''):
|
|
531
589
|
raise ValueError('AI 策略 sheet 必须引用共享字符串')
|
|
532
590
|
ident = int(reference.text)
|
|
533
|
-
if ident
|
|
591
|
+
if ident not in strings:
|
|
534
592
|
raise ValueError('AI 策略 sheet 共享字符串索引越界')
|
|
535
593
|
values.append(strings[ident])
|
|
536
594
|
actual.append(values)
|
|
@@ -550,21 +608,18 @@ def read_ai_sheet(path):
|
|
|
550
608
|
def validate_xlsx(path, rows, details, *, ai_strategy=None):
|
|
551
609
|
"""逐单元格回读,与布局后的完整输入比较,避免只检验文件存在。"""
|
|
552
610
|
ns = {'s': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
|
|
553
|
-
expected =
|
|
554
|
-
|
|
611
|
+
expected = ([_clean_record(row) for row in rows], details)
|
|
612
|
+
root = Path(details.path).parent if hasattr(details, 'path') else Path(path).parent
|
|
613
|
+
with zipfile.ZipFile(path) as archive, _string_index(root) as connection:
|
|
555
614
|
if archive.testzip():
|
|
556
615
|
raise ValueError('Excel ZIP 校验失败')
|
|
557
616
|
workbook = ET.fromstring(archive.read('xl/workbook.xml'))
|
|
558
617
|
names = ['策略', '明细'] + (['AI策略'] if ai_strategy is not None else [])
|
|
559
618
|
if [s.attrib['name'] for s in workbook.findall('s:sheets/s:sheet', ns)] != names:
|
|
560
619
|
raise ValueError('Excel sheet 名称或顺序不完整')
|
|
561
|
-
|
|
620
|
+
_load_strings(archive, connection)
|
|
562
621
|
for index, (records, columns) in enumerate(zip(expected, (COLUMNS, DETAIL_COLUMNS)), 1):
|
|
563
|
-
|
|
564
|
-
actual = [[strings[int(cell.find('s:v', ns).text)] for cell in row] for row in sheet.findall('s:sheetData/s:row', ns)]
|
|
565
|
-
target = [[label for _, label in columns]] + [[str(r.get(key) or '') for key, _ in columns] for r in records]
|
|
566
|
-
if actual != target:
|
|
567
|
-
raise ValueError(f'Excel 第 {index} 张表内容或行列数不完整')
|
|
622
|
+
_validate_sheet(archive, f'xl/worksheets/sheet{index}.xml', records, columns, connection)
|
|
568
623
|
if ai_strategy is not None:
|
|
569
624
|
if len(expected[0]) != 1:
|
|
570
625
|
raise ValueError('AI 策略 sheet 必须对应唯一业务线')
|
|
@@ -605,9 +660,9 @@ def main() -> int:
|
|
|
605
660
|
|
|
606
661
|
output_path = args.output or payload.get("output_path") or default_output_path()
|
|
607
662
|
output_path = os.path.abspath(output_path)
|
|
608
|
-
|
|
609
|
-
if
|
|
610
|
-
|
|
663
|
+
from detail_store import details_from_payload
|
|
664
|
+
root = Path.cwd() if args.input == "-" else Path(args.input).resolve().parent
|
|
665
|
+
details = details_from_payload(payload, root)
|
|
611
666
|
|
|
612
667
|
write_xlsx(rows, output_path, details=details)
|
|
613
668
|
|