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.
@@ -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 结构(统一工作流另将 Agent 回包传给导出函数):
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 _intern_factory():
161
- seen = {}
162
- order = []
163
-
164
- def intern(value, rich=False):
165
- # 同文字的策略富文本与普通单元格分别去重,避免格式串到明细或learning。
166
- key = (value, rich)
167
- if key not in seen:
168
- seen[key] = len(order)
169
- order.append(key)
170
- return seen[key]
171
-
172
- return intern, order
173
-
174
-
175
- def build_shared_strings(rows, details=None, *, include_strategy=True, ai_cells=None):
176
- """按输出范围收集字符串;AI 输入文件只包含明细文字。"""
177
- intern, order = _intern_factory()
178
- header_indices = [intern(label) for _key, label in COLUMNS] if include_strategy else []
179
- row_indices = ([
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): '&#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, details = prepare_layout(rows, details)
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
- # 保持单元格全文;超过Excel实际字符限制时明确失败,不拆行或静默丢字。
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
- _rows, clean_details = prepare_layout([], details)
453
- for row in clean_details:
454
- for key, _label in DETAIL_COLUMNS:
455
- if len(str(row.get(key) or '').encode('utf-16-le')) // 2 > 32767:
456
- raise ValueError(f'明细 {key} 超过 Excel 单元格上限')
457
- strings, _head, _rows, detail_head, detail_rows, _ai = build_shared_strings(
458
- [], clean_details, include_strategy=False)
459
- sheet = build_sheet_xml(detail_head, detail_rows, DETAIL_COLUMNS,
460
- content_keys={'name', 'extra'},
461
- multiline_keys={'id', *(code for code, _label, _is_percent in REPORT_METRICS)},
462
- values=clean_details)
463
- # 从现有标准库工作簿结构裁剪成单 sheet 包,不保留策略表的共享字符串。
464
- content_types = re.sub(r'<Override PartName="/xl/worksheets/sheet2.xml"[^>]*/>\n?', '', CONTENT_TYPES_XML)
465
- relationships = re.sub(r'<Relationship Id="rId4"[^>]*/>\n?', '', WORKBOOK_RELS_XML)
466
- workbook = (WORKBOOK_XML.replace('<sheet name="策略" sheetId="1" r:id="rId1"/>', '')
467
- .replace('<sheet name="明细" sheetId="2" r:id="rId4"/>',
468
- '<sheet name="明细" sheetId="1" r:id="rId1"/>'))
469
- out_dir = os.path.dirname(output_path)
470
- if out_dir:
471
- os.makedirs(out_dir, exist_ok=True)
472
- with zipfile.ZipFile(output_path, 'x', zipfile.ZIP_DEFLATED) as archive:
473
- for name, value in (('[Content_Types].xml', content_types), ('_rels/.rels', ROOT_RELS_XML),
474
- ('xl/workbook.xml', workbook), ('xl/_rels/workbook.xml.rels', relationships),
475
- ('xl/styles.xml', STYLES_XML), ('xl/sharedStrings.xml', strings),
476
- ('xl/worksheets/sheet1.xml', sheet)):
477
- archive.writestr(name, value)
478
- return output_path
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
- ns = {'s': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
484
- _rows, clean_details = prepare_layout([], details)
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', ns)] != ['明细']:
545
+ if [item.attrib['name'] for item in workbook.findall('s:sheets/s:sheet', _NS)] != ['明细']:
490
546
  raise ValueError('AI 输入工作簿只能包含明细 sheet')
491
- strings = [''.join(item.itertext()) for item in ET.fromstring(archive.read('xl/sharedStrings.xml'))]
492
- sheet = ET.fromstring(archive.read('xl/worksheets/sheet1.xml'))
493
- actual = [[strings[int(cell.find('s:v', ns).text)] for cell in row]
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
- strings = [''.join(node.text or '' for node in item.findall('.//s:t', ns))
520
- for item in ET.fromstring(archive.read('xl/sharedStrings.xml'))]
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 >= len(strings):
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 = prepare_layout(rows, details)
554
- with zipfile.ZipFile(path) as archive:
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
- strings = [''.join(item.itertext()) for item in ET.fromstring(archive.read('xl/sharedStrings.xml'))]
620
+ _load_strings(archive, connection)
562
621
  for index, (records, columns) in enumerate(zip(expected, (COLUMNS, DETAIL_COLUMNS)), 1):
563
- sheet = ET.fromstring(archive.read(f'xl/worksheets/sheet{index}.xml'))
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
- details = payload.get("details") or []
609
- if not isinstance(details, list):
610
- details = []
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