sino-bc-cli 1.8.19 → 1.8.21
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/SKILL.md +1 -1
- package/dist/index.js +1 -1
- package/dist/skills/custom/biz-line-strategy-generate/SKILL.md +53 -63
- package/dist/skills/custom/biz-line-strategy-generate/references/api-flow.md +6 -5
- package/dist/skills/custom/biz-line-strategy-generate/references/four-strategies.md +19 -17
- package/dist/skills/custom/biz-line-strategy-generate/references/metrics.md +29 -280
- package/dist/skills/custom/biz-line-strategy-generate/references/output-excel.md +9 -53
- package/dist/skills/custom/biz-line-strategy-generate/references/runtime.md +22 -57
- package/dist/skills/custom/biz-line-strategy-generate/references/spec.md +124 -0
- package/dist/skills/custom/biz-line-strategy-generate/scripts/build_report.py +172 -35
- package/dist/skills/custom/biz-line-strategy-generate/scripts/cleanup.py +71 -80
- package/dist/skills/custom/biz-line-strategy-generate/scripts/cli_json.py +288 -18
- package/dist/skills/custom/biz-line-strategy-generate/scripts/fetch_data.py +84 -46
- package/dist/skills/custom/biz-line-strategy-generate/scripts/run_workflow.py +189 -0
- package/dist/skills/custom/biz-line-strategy-generate/scripts/summarize.py +315 -238
- package/dist/skills/custom/biz-line-strategy-generate/scripts/test_strategy_rules.py +215 -0
- package/dist/skills/custom/biz-line-strategy-generate/scripts/test_workflow.py +345 -0
- package/package.json +1 -1
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# 业务线策略生成
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
为业务线策略生成技能规定可复用的分析、Excel 导出与批量执行行为,保证多条业务线运行时有明确的并发上限、独立结果和失败信息,并在每条任务结束后仅保留验证通过的最终文件,避免临时数据持续堆积或误删其他任务产物。
|
|
6
|
+
|
|
7
|
+
## Requirements
|
|
8
|
+
|
|
9
|
+
### Requirement: 统一生成入口与业务线隔离
|
|
10
|
+
|
|
11
|
+
技能 MUST 提供覆盖查询、拉数、分析、校验、导出和清理的统一入口。单业务线与多业务线 MUST 使用相同分析规则。输入 MUST 支持名称查找和显式业务线 ID 列表;批量候选 MUST 按 ID 去重。名称匹配不唯一时 MUST 明确报告歧义而非自动取第一条。业务请求 MUST 复用现有 CLI,MUST NOT 手写网关 HTTP 或绕过身份约束。
|
|
12
|
+
|
|
13
|
+
#### Scenario: 批量重复 ID 不重复生成
|
|
14
|
+
- **GIVEN** 输入列表包含相同业务线 ID 两次
|
|
15
|
+
- **WHEN** 执行统一入口
|
|
16
|
+
- **THEN** 该业务线只执行一次,拥有独立工作目录和唯一最终 Excel
|
|
17
|
+
|
|
18
|
+
#### Scenario: 名称存在歧义
|
|
19
|
+
- **GIVEN** 名称查询返回多条候选
|
|
20
|
+
- **WHEN** 解析单业务线目标
|
|
21
|
+
- **THEN** 返回候选与歧义错误,不启动这些候选的投放拉数任务
|
|
22
|
+
|
|
23
|
+
### Requirement: 单次执行最多并发三条业务线
|
|
24
|
+
|
|
25
|
+
一次技能调用 MUST 默认最多同时运行 3 条业务线任务,允许设置 1 至 3,超出范围 MUST 在启动业务任务前拒绝。一个业务线从拉数到清理结束 MUST 占用同一个并发名额。该业务线内部的 CLI 请求和分页 MUST 继续顺序执行,MUST NOT 叠加子级并发。待处理业务线在名额释放后继续启动。
|
|
26
|
+
|
|
27
|
+
#### Scenario: 十条业务线受并发上限约束
|
|
28
|
+
- **GIVEN** 请求生成 10 条不同业务线
|
|
29
|
+
- **WHEN** 默认执行批量入口
|
|
30
|
+
- **THEN** 任意时刻运行中的业务线数不超过 3,且有名额后继续处理队列
|
|
31
|
+
- **AND** 已完成业务线立即交付并清理,不等待全部 10 条结束
|
|
32
|
+
|
|
33
|
+
#### Scenario: 非法并发参数不发起请求
|
|
34
|
+
- **GIVEN** 并发参数为 0、4 或非整数
|
|
35
|
+
- **WHEN** 启动统一入口
|
|
36
|
+
- **THEN** 返回参数错误,不创建业务输出或调用业务接口
|
|
37
|
+
|
|
38
|
+
### Requirement: 分页与失败回包必须完整校验
|
|
39
|
+
|
|
40
|
+
每页 CLI 回包 MUST 直写文件并检查退出码、信封和分页总数。异常、解析失败、未到总数即遇空页、达到分页上限仍未取全,MUST 阻止该业务线生成成功报告。CLI stderr 错误 MUST 保留到本次任务结果摘要,而非当作无数据。其他业务线 MUST 可以继续执行。
|
|
41
|
+
|
|
42
|
+
#### Scenario: 后续页失败不导出部分结果
|
|
43
|
+
- **GIVEN** 首页成功且声明仍有下一页
|
|
44
|
+
- **WHEN** 后续页调用失败或返回空页但累计数量不足
|
|
45
|
+
- **THEN** 该业务线失败,不将不完整数据生成最终 Excel,其他业务线继续执行
|
|
46
|
+
|
|
47
|
+
### Requirement: 指标与周期口径固定
|
|
48
|
+
|
|
49
|
+
分析 MUST 从同粒度基础指标先求和再计算比率,分母为零或所需指标缺失时明确不可计算。安装 CPI MUST 标注为美元消耗除以平台安装量,MUST NOT 直接改称客户激活或留存成本。缺少同币种、同转化定义或留存数据时,客户 KPI MUST 标为不可判定,MUST NOT 使用固定 CPM 阈值替代。首期按可判定 KPI 输出,否则待观察;第二期按可计算核心指标环比判定,绝对变化不超过 10% 为稳定;成本下降、收益指标上升为改善。无可比核心指标时 MUST 待观察并说明原因。
|
|
50
|
+
|
|
51
|
+
请求周期与实际返回的数据覆盖范围 MUST 分别展示。数据未覆盖完整请求周期时 MUST 提示不完整,MUST NOT 将较短周期消耗下降直接解释为主动减预算。新增部分成效 MUST 单独聚合,MUST NOT 将业务线整体环比宣称为单一动作的因果效果。
|
|
52
|
+
|
|
53
|
+
#### Scenario: 人民币 CPA 与美元安装数据不可直接对标
|
|
54
|
+
- **GIVEN** 客户目标是人民币 CPA 与次日留存,接口返回美元消耗和平台安装量
|
|
55
|
+
- **WHEN** 缺少汇率、CPA 定义或留存数据
|
|
56
|
+
- **THEN** 仅计算已支持的观察指标,明确客户 KPI 暂不可判定,不生成 CPM 默认达标结论
|
|
57
|
+
|
|
58
|
+
#### Scenario: 本期六天与上期七天
|
|
59
|
+
- **GIVEN** 请求两个七天周期,但本期只返回六个日期
|
|
60
|
+
- **WHEN** 生成策略及 learning
|
|
61
|
+
- **THEN** 展示请求日期和实际覆盖日期,并提示消耗总额比较存在周期差异
|
|
62
|
+
|
|
63
|
+
### Requirement: 对象与明细按稳定标识统计
|
|
64
|
+
|
|
65
|
+
广告和广告组 MUST 按渠道及 ID 判断新增与本期未出现,MUST NOT 仅按名称去重。完整明细 MUST 保留同名不同 ID 的记录。两期集合差异 MUST NOT 被描述为已确认的创建或暂停操作。出价变化 MUST 按广告组日快照计算,并区分上调、下调、期内变化但首末持平;无值 MUST NOT 默认为零。国家全量与过消耗门槛的新增成效 MUST 可在明细追溯。
|
|
66
|
+
|
|
67
|
+
#### Scenario: 同名广告不可合并
|
|
68
|
+
- **GIVEN** 两个不同广告 ID 使用同一名称
|
|
69
|
+
- **WHEN** 统计新增广告并导出明细
|
|
70
|
+
- **THEN** 分别计数并输出 ID,不因名称相同而丢失其中一条
|
|
71
|
+
|
|
72
|
+
#### Scenario: 期内调价后恢复原价
|
|
73
|
+
- **GIVEN** 一个广告组期内出价变化但首末有效快照一致
|
|
74
|
+
- **WHEN** 生成出价摘要
|
|
75
|
+
- **THEN** 计为首末持平的期内变化,不归类为下调
|
|
76
|
+
|
|
77
|
+
### Requirement: 每条业务线只交付一份校验通过的最终 Excel
|
|
78
|
+
|
|
79
|
+
每条业务线 MUST 独立产出一份包含固定 12 列策略摘要及完整明细的 Excel。分析与排版 MUST 由固定脚本完成;正常流程 MUST 一次生成最终样式,MUST NOT 依赖 Agent 临时生成修订脚本或二次导入重排。文件发布前 MUST 检查 Excel 容器、两张表、列名及记录完整性。最终路径 MUST 位于待清理工作目录之外,名称 MUST 能区分业务线与请求,MUST NOT 覆盖已存在的其他文件。
|
|
80
|
+
|
|
81
|
+
#### Scenario: 并发生成不覆盖结果
|
|
82
|
+
- **GIVEN** 两条业务线在同一秒完成导出
|
|
83
|
+
- **WHEN** 发布最终文件
|
|
84
|
+
- **THEN** 获得两个不同且可读取的文件路径,各自包含对应业务线数据
|
|
85
|
+
|
|
86
|
+
#### Scenario: 校验失败不发布损坏文件
|
|
87
|
+
- **GIVEN** Excel 缺少明细表或明细行数不一致
|
|
88
|
+
- **WHEN** 执行发布前校验
|
|
89
|
+
- **THEN** 该任务失败,不对外声明最终 Excel 已生成
|
|
90
|
+
|
|
91
|
+
### Requirement: 任务结束后按归属清理全部临时产物
|
|
92
|
+
|
|
93
|
+
每条业务线任务完成后,MUST 清理本次任务创建的全部非最终产物,包括但不限于 JSON、请求/响应、日志副本、预览图片、临时脚本、临时 Excel、检查文件及运行时缓存。成功时 MUST 先确认最终文件已发布且验证通过,再清理;失败时 MUST 先保存可返回的错误摘要到内存/输出,再清理其临时和不完整产物。所有任务结束后 MUST 清理本次批次共享临时目录。MUST NOT 保留额外磁盘分析报告或将已删除目录作为交付物。
|
|
94
|
+
|
|
95
|
+
清理 MUST 以本次创建的独立目录及登记文件为边界,校验规范化绝对路径及所有权,MUST NOT 按扩展名或桌面 glob 广泛扫描。MUST NOT 删除历史请求、其他并行业务线、仓库源码、技能目录、平台凭证或 CLI 全局审计日志。符号链接、Windows junction/reparse point MUST NOT 被递归跟随到边界外。存在清理失败时 MUST 明确报告残留路径和原因,不宣称已完全清理。
|
|
96
|
+
|
|
97
|
+
#### Scenario: 成功后只保留最终文件
|
|
98
|
+
- **GIVEN** 本次业务线目录中存在 JSON、PNG、TXT、无扩展名文件和临时子目录
|
|
99
|
+
- **WHEN** 最终 Excel 发布并校验成功
|
|
100
|
+
- **THEN** 上述临时产物及业务线临时目录均被清理,最终 Excel 仍可读取
|
|
101
|
+
|
|
102
|
+
#### Scenario: 失败任务不残留临时文件
|
|
103
|
+
- **GIVEN** 拉数或分析失败且已创建部分回包和临时输出
|
|
104
|
+
- **WHEN** 该业务线任务结束
|
|
105
|
+
- **THEN** 返回业务线 ID、失败阶段和错误摘要,清理其拥有的临时产物,继续处理其他任务
|
|
106
|
+
|
|
107
|
+
#### Scenario: 清理链接时保护外部目录
|
|
108
|
+
- **GIVEN** 本次临时目录中有链接指向另一任务或用户目录
|
|
109
|
+
- **WHEN** 执行清理
|
|
110
|
+
- **THEN** 不遍历或删除链接目标,外部文件及其他任务目录保持不变
|
|
111
|
+
|
|
112
|
+
#### Scenario: 文件占用导致清理失败
|
|
113
|
+
- **GIVEN** 最终 Excel 已验证,但一个临时文件被占用且删除失败
|
|
114
|
+
- **WHEN** 工作流结束清理
|
|
115
|
+
- **THEN** 返回最终文件路径及清理失败状态和残留路径,批次以非成功状态结束
|
|
116
|
+
|
|
117
|
+
### Requirement: 批次结果可观察且失败互不掩盖
|
|
118
|
+
|
|
119
|
+
工作流 MUST 按业务线返回阶段状态、最终 Excel 路径或失败原因,并提供阶段耗时和批次成功/失败数量。结果 MUST 区分生成失败、导出失败、清理失败;任一任务失败时批次 MUST 非零退出,同时保留其他任务的成功文件。耗时结果 MUST 通过输出或最终 Excel 内已有适当区域提供,MUST NOT 为此保留额外临时报告。用户可处理的中断 MUST 停止启动新任务并等待/终止子进程后执行边界内清理;进程被强制杀死或断电的情形不得宣称已完成清理。
|
|
120
|
+
|
|
121
|
+
#### Scenario: 混合结果完整汇报
|
|
122
|
+
- **GIVEN** 三条业务线分别成功、拉数失败、清理失败
|
|
123
|
+
- **WHEN** 批次收尾
|
|
124
|
+
- **THEN** 输出三条各自结果与阶段耗时,保留成功的最终 Excel,批次非零退出
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
把业务线策略生成结果写成 .xlsx(业务线策略生成 skill 专用,纯标准库实现,无第三方依赖)。
|
|
5
5
|
|
|
6
6
|
职责边界:本脚本只负责「结构化数据 -> Excel 文件」的机械落盘,
|
|
7
|
-
|
|
7
|
+
不做数据拉取和业务判断;统一入口先通过固定汇总脚本生成内容。
|
|
8
8
|
|
|
9
9
|
用法:
|
|
10
10
|
python3 build_report.py --input payload.json [--output /path/to/file.xlsx]
|
|
@@ -13,16 +13,15 @@
|
|
|
13
13
|
payload.json 结构:
|
|
14
14
|
{
|
|
15
15
|
"output_path": "可选,未传时默认写到桌面 业务线策略生成_YYYYMMDD_HHMMSS.xlsx",
|
|
16
|
-
"rows": [
|
|
16
|
+
"rows": [ { "...策略摘要行..." } ],
|
|
17
|
+
"details": [
|
|
17
18
|
{
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"audienceStrategy": "受众策略内容", "audienceLearning": "受众策略learning",
|
|
25
|
-
"creativeStrategy": "素材策略内容", "creativeLearning": "素材策略learning"
|
|
19
|
+
"bizLine": "业务线",
|
|
20
|
+
"kind": "国家|广告组|出价|兴趣词|关键词|广告",
|
|
21
|
+
"action": "新增|本期未出现|上调|下调|当期",
|
|
22
|
+
"channel": "Meta|Google|TikTok",
|
|
23
|
+
"name": "名称",
|
|
24
|
+
"extra": "补充"
|
|
26
25
|
}
|
|
27
26
|
]
|
|
28
27
|
}
|
|
@@ -39,6 +38,11 @@ import json
|
|
|
39
38
|
import os
|
|
40
39
|
import sys
|
|
41
40
|
import zipfile
|
|
41
|
+
import math
|
|
42
|
+
import unicodedata
|
|
43
|
+
import uuid
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
from xml.etree import ElementTree as ET
|
|
42
46
|
from datetime import datetime
|
|
43
47
|
from xml.sax.saxutils import escape as xml_escape
|
|
44
48
|
|
|
@@ -58,11 +62,20 @@ COLUMNS = [
|
|
|
58
62
|
]
|
|
59
63
|
|
|
60
64
|
# 策略内容列文字量最大,给最宽的列宽(前 4 列是基础信息列)
|
|
65
|
+
DETAIL_COLUMNS = [
|
|
66
|
+
("bizLine", "业务线"),
|
|
67
|
+
("kind", "明细类型"),
|
|
68
|
+
("action", "动作"),
|
|
69
|
+
("channel", "渠道"),
|
|
70
|
+
("name", "名称"),
|
|
71
|
+
("extra", "补充"),
|
|
72
|
+
]
|
|
73
|
+
|
|
61
74
|
CONTENT_KEYS = {key for key, _label in COLUMNS[4:]}
|
|
62
75
|
|
|
63
76
|
# 列宽:策略内容列最宽;项目名称列因单元格内多行罗列项目而适度加宽
|
|
64
77
|
COL_WIDTH_DEFAULT = 14
|
|
65
|
-
COL_WIDTH_CONTENT =
|
|
78
|
+
COL_WIDTH_CONTENT = 48
|
|
66
79
|
COL_WIDTH_MULTILINE = 22
|
|
67
80
|
MULTILINE_KEYS = {"period", "project"}
|
|
68
81
|
|
|
@@ -72,6 +85,7 @@ CONTENT_TYPES_XML = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
|
72
85
|
<Default Extension="xml" ContentType="application/xml"/>
|
|
73
86
|
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
|
|
74
87
|
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
|
|
88
|
+
<Override PartName="/xl/worksheets/sheet2.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
|
|
75
89
|
<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
|
|
76
90
|
<Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/>
|
|
77
91
|
</Types>"""
|
|
@@ -86,11 +100,15 @@ WORKBOOK_RELS_XML = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
|
86
100
|
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
|
|
87
101
|
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
|
|
88
102
|
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/>
|
|
103
|
+
<Relationship Id="rId4" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet2.xml"/>
|
|
89
104
|
</Relationships>"""
|
|
90
105
|
|
|
91
106
|
WORKBOOK_XML = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
92
107
|
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
|
93
|
-
<sheets
|
|
108
|
+
<sheets>
|
|
109
|
+
<sheet name="策略" sheetId="1" r:id="rId1"/>
|
|
110
|
+
<sheet name="明细" sheetId="2" r:id="rId4"/>
|
|
111
|
+
</sheets>
|
|
94
112
|
</workbook>"""
|
|
95
113
|
|
|
96
114
|
# cellXfs: 0=默认, 1=表头(粗体+浅底), 2=内容(自动换行+顶对齐)
|
|
@@ -129,8 +147,7 @@ def col_letter(index: int) -> str:
|
|
|
129
147
|
return letters
|
|
130
148
|
|
|
131
149
|
|
|
132
|
-
def
|
|
133
|
-
"""收集全部字符串去重,返回 (sst_xml, header_indices, row_indices)"""
|
|
150
|
+
def _intern_factory():
|
|
134
151
|
seen = {}
|
|
135
152
|
order = []
|
|
136
153
|
|
|
@@ -140,34 +157,47 @@ def build_shared_strings(rows):
|
|
|
140
157
|
order.append(value)
|
|
141
158
|
return seen[value]
|
|
142
159
|
|
|
160
|
+
return intern, order
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def build_shared_strings(rows, details=None):
|
|
164
|
+
"""收集策略表 + 明细表全部字符串去重。"""
|
|
165
|
+
intern, order = _intern_factory()
|
|
143
166
|
header_indices = [intern(label) for _key, label in COLUMNS]
|
|
144
|
-
row_indices = [
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
167
|
+
row_indices = [
|
|
168
|
+
[intern(str(row.get(key, "") or "")) for key, _label in COLUMNS] for row in rows
|
|
169
|
+
]
|
|
170
|
+
detail_header = [intern(label) for _key, label in DETAIL_COLUMNS]
|
|
171
|
+
detail_rows = []
|
|
172
|
+
for row in details or []:
|
|
173
|
+
detail_rows.append(
|
|
174
|
+
[intern(str(row.get(key, "") or "")) for key, _label in DETAIL_COLUMNS]
|
|
148
175
|
)
|
|
149
176
|
|
|
177
|
+
# XML 会把原始 CR/CRLF 归一化为 LF;用字符引用保留客户文本,保证逐字回读。
|
|
150
178
|
items = "".join(
|
|
151
|
-
f'<si><t xml:space="preserve">{xml_escape(text)}</t></si>' for text in order
|
|
179
|
+
f'<si><t xml:space="preserve">{xml_escape(text, {chr(13): " "})}</t></si>' for text in order
|
|
152
180
|
)
|
|
153
181
|
sst_xml = (
|
|
154
182
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
155
183
|
f'<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" '
|
|
156
184
|
f'count="{len(order)}" uniqueCount="{len(order)}">{items}</sst>'
|
|
157
185
|
)
|
|
158
|
-
return sst_xml, header_indices, row_indices
|
|
186
|
+
return sst_xml, header_indices, row_indices, detail_header, detail_rows
|
|
159
187
|
|
|
160
188
|
|
|
161
|
-
def build_sheet_xml(header_indices, row_indices):
|
|
162
|
-
"""生成
|
|
163
|
-
|
|
189
|
+
def build_sheet_xml(header_indices, row_indices, columns, content_keys=None, multiline_keys=None, values=None):
|
|
190
|
+
"""生成 worksheet xml:首行表头样式 s="1",其余行内容样式 s="2"(自动换行)"""
|
|
191
|
+
content_keys = content_keys or set()
|
|
192
|
+
multiline_keys = multiline_keys or set()
|
|
193
|
+
col_count = len(columns)
|
|
164
194
|
dimension_ref = f"A1:{col_letter(col_count - 1)}{len(row_indices) + 1}"
|
|
165
195
|
|
|
166
196
|
cols_xml_parts = []
|
|
167
|
-
for i, (key, _label) in enumerate(
|
|
168
|
-
if key in
|
|
197
|
+
for i, (key, _label) in enumerate(columns):
|
|
198
|
+
if key in content_keys:
|
|
169
199
|
width = COL_WIDTH_CONTENT
|
|
170
|
-
elif key in
|
|
200
|
+
elif key in multiline_keys:
|
|
171
201
|
width = COL_WIDTH_MULTILINE
|
|
172
202
|
else:
|
|
173
203
|
width = COL_WIDTH_DEFAULT
|
|
@@ -181,7 +211,14 @@ def build_sheet_xml(header_indices, row_indices):
|
|
|
181
211
|
for i, sst_index in enumerate(indices):
|
|
182
212
|
ref = f"{col_letter(i)}{row_num}"
|
|
183
213
|
cells.append(f'<c r="{ref}" s="{style_id}" t="s"><v>{sst_index}</v></c>')
|
|
184
|
-
|
|
214
|
+
# Excel 不会可靠地对程序生成的行自动增高,按中文显示宽度确定行高。
|
|
215
|
+
if row_num == 1:
|
|
216
|
+
height = 32
|
|
217
|
+
else:
|
|
218
|
+
record = (values or [{}] * len(row_indices))[row_num - 2]
|
|
219
|
+
lines = max((visual_lines(str(record.get(key) or ""), COL_WIDTH_CONTENT if key in content_keys else (COL_WIDTH_MULTILINE if key in multiline_keys else COL_WIDTH_DEFAULT)) for key, _ in columns), default=1)
|
|
220
|
+
height = min(409, max(30, lines * 15 + 10))
|
|
221
|
+
return f'<row r="{row_num}" ht="{height}" customHeight="1">{"".join(cells)}</row>'
|
|
185
222
|
|
|
186
223
|
rows_xml_parts = [row_xml(1, header_indices, 1)]
|
|
187
224
|
for offset, indices in enumerate(row_indices):
|
|
@@ -196,7 +233,7 @@ def build_sheet_xml(header_indices, row_indices):
|
|
|
196
233
|
'<sheetViews><sheetView workbookViewId="0">'
|
|
197
234
|
'<pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/>'
|
|
198
235
|
'</sheetView></sheetViews>'
|
|
199
|
-
f'{cols_xml}{sheet_data}'
|
|
236
|
+
f'{cols_xml}{sheet_data}<autoFilter ref="{dimension_ref}"/>'
|
|
200
237
|
'</worksheet>'
|
|
201
238
|
)
|
|
202
239
|
|
|
@@ -209,18 +246,89 @@ def default_output_path() -> str:
|
|
|
209
246
|
# 部分中文 Windows / OneDrive 环境桌面路径不同,找不到就退回主目录
|
|
210
247
|
desktop = home
|
|
211
248
|
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
212
|
-
return os.path.join(desktop, f"业务线策略生成_{stamp}.xlsx")
|
|
249
|
+
return os.path.join(desktop, f"业务线策略生成_{stamp}_{uuid.uuid4().hex[:8]}.xlsx")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def visual_lines(text, width):
|
|
253
|
+
"""中日韩字符占两格,保守估算换行后高度。"""
|
|
254
|
+
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'))
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def chunks(text, width, max_lines=20):
|
|
258
|
+
"""按可见行宽拆分,续行不丢字符,避免 Excel 行高及单元格字数上限。"""
|
|
259
|
+
result, part = [], ''
|
|
260
|
+
# 线性维护行宽,不对越来越长的片段重复扫描。
|
|
261
|
+
lines, used = 1, 0
|
|
262
|
+
for char in text:
|
|
263
|
+
size = 2 if unicodedata.east_asian_width(char) in 'WF' else 1
|
|
264
|
+
next_line = char == '\n' or used + size > width - 2
|
|
265
|
+
if (next_line and lines >= max_lines) or len(part) >= 16000:
|
|
266
|
+
result.append(part)
|
|
267
|
+
part, lines, used = '', 1, 0
|
|
268
|
+
if char == '\n':
|
|
269
|
+
lines, used = lines + 1, 0
|
|
270
|
+
elif used + size > width - 2:
|
|
271
|
+
lines, used = lines + 1, size
|
|
272
|
+
else:
|
|
273
|
+
used += size
|
|
274
|
+
part += char
|
|
275
|
+
return result + [part] if part or not result else result
|
|
213
276
|
|
|
214
277
|
|
|
215
|
-
def
|
|
216
|
-
|
|
217
|
-
|
|
278
|
+
def prepare_layout(rows, details):
|
|
279
|
+
"""主表只放可见摘要,超长原文转完整明细;明细用续行保持全量。"""
|
|
280
|
+
rows = [dict(r) for r in rows]
|
|
281
|
+
details = [dict(r) for r in details or []]
|
|
282
|
+
for row in rows:
|
|
283
|
+
for key, label in COLUMNS:
|
|
284
|
+
text = str(row.get(key) or '')
|
|
285
|
+
width = COL_WIDTH_CONTENT if key in CONTENT_KEYS else (COL_WIDTH_MULTILINE if key in MULTILINE_KEYS else COL_WIDTH_DEFAULT)
|
|
286
|
+
parts = chunks(text, width, 18)
|
|
287
|
+
if len(parts) > 1:
|
|
288
|
+
details.append({'bizLine': row.get('bizLine', ''), 'kind': '完整说明', 'action': label, 'name': label, 'extra': text})
|
|
289
|
+
row[key] = parts[0] + '\n(完整内容见明细/完整说明)'
|
|
290
|
+
expanded = []
|
|
291
|
+
for record in details:
|
|
292
|
+
split = {key: chunks(str(record.get(key) or ''), COL_WIDTH_CONTENT if key in ('name', 'extra') else COL_WIDTH_DEFAULT, 18) for key, _ in DETAIL_COLUMNS}
|
|
293
|
+
count = max(map(len, split.values()))
|
|
294
|
+
for index in range(count):
|
|
295
|
+
# 续行有行号标识,身份短字段重复展示便于筛选;长字段逐段保留。
|
|
296
|
+
row = {key: (parts[index] if index < len(parts) else (parts[0] if len(parts) == 1 and key not in ('name', 'extra') else '')) for key, parts in split.items()}
|
|
297
|
+
if count > 1:
|
|
298
|
+
row['action'] += f' [续 {index + 1}/{count}]'
|
|
299
|
+
expanded.append(row)
|
|
300
|
+
return rows, expanded
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def write_xlsx(rows, output_path: str, details=None) -> str:
|
|
304
|
+
if not rows:
|
|
305
|
+
raise ValueError('策略数据为空')
|
|
306
|
+
rows, details = prepare_layout(rows, details)
|
|
307
|
+
sst_xml, header_indices, row_indices, detail_header, detail_rows = build_shared_strings(
|
|
308
|
+
rows, details
|
|
309
|
+
)
|
|
310
|
+
sheet_xml = build_sheet_xml(
|
|
311
|
+
header_indices,
|
|
312
|
+
row_indices,
|
|
313
|
+
COLUMNS,
|
|
314
|
+
content_keys=CONTENT_KEYS,
|
|
315
|
+
multiline_keys=MULTILINE_KEYS,
|
|
316
|
+
values=rows,
|
|
317
|
+
)
|
|
318
|
+
detail_xml = build_sheet_xml(
|
|
319
|
+
detail_header,
|
|
320
|
+
detail_rows,
|
|
321
|
+
DETAIL_COLUMNS,
|
|
322
|
+
content_keys={"name", "extra"},
|
|
323
|
+
multiline_keys=set(),
|
|
324
|
+
values=details,
|
|
325
|
+
)
|
|
218
326
|
|
|
219
327
|
out_dir = os.path.dirname(output_path)
|
|
220
328
|
if out_dir and not os.path.isdir(out_dir):
|
|
221
329
|
os.makedirs(out_dir, exist_ok=True)
|
|
222
330
|
|
|
223
|
-
with zipfile.ZipFile(output_path, "
|
|
331
|
+
with zipfile.ZipFile(output_path, "x", zipfile.ZIP_DEFLATED) as zf:
|
|
224
332
|
zf.writestr("[Content_Types].xml", CONTENT_TYPES_XML)
|
|
225
333
|
zf.writestr("_rels/.rels", ROOT_RELS_XML)
|
|
226
334
|
zf.writestr("xl/workbook.xml", WORKBOOK_XML)
|
|
@@ -228,10 +336,31 @@ def write_xlsx(rows, output_path: str) -> str:
|
|
|
228
336
|
zf.writestr("xl/styles.xml", STYLES_XML)
|
|
229
337
|
zf.writestr("xl/sharedStrings.xml", sst_xml)
|
|
230
338
|
zf.writestr("xl/worksheets/sheet1.xml", sheet_xml)
|
|
339
|
+
zf.writestr("xl/worksheets/sheet2.xml", detail_xml)
|
|
231
340
|
|
|
232
341
|
return output_path
|
|
233
342
|
|
|
234
343
|
|
|
344
|
+
def validate_xlsx(path, rows, details):
|
|
345
|
+
"""逐单元格回读,与布局后的完整输入比较,避免只检验文件存在。"""
|
|
346
|
+
ns = {'s': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
|
|
347
|
+
expected = prepare_layout(rows, details)
|
|
348
|
+
with zipfile.ZipFile(path) as archive:
|
|
349
|
+
if archive.testzip():
|
|
350
|
+
raise ValueError('Excel ZIP 校验失败')
|
|
351
|
+
workbook = ET.fromstring(archive.read('xl/workbook.xml'))
|
|
352
|
+
if [s.attrib['name'] for s in workbook.findall('s:sheets/s:sheet', ns)] != ['策略', '明细']:
|
|
353
|
+
raise ValueError('Excel 必须包含策略和明细两张表')
|
|
354
|
+
strings = [''.join(item.itertext()) for item in ET.fromstring(archive.read('xl/sharedStrings.xml'))]
|
|
355
|
+
for index, (records, columns) in enumerate(zip(expected, (COLUMNS, DETAIL_COLUMNS)), 1):
|
|
356
|
+
sheet = ET.fromstring(archive.read(f'xl/worksheets/sheet{index}.xml'))
|
|
357
|
+
actual = [[strings[int(cell.find('s:v', ns).text)] for cell in row] for row in sheet.findall('s:sheetData/s:row', ns)]
|
|
358
|
+
target = [[label for _, label in columns]] + [[str(r.get(key) or '') for key, _ in columns] for r in records]
|
|
359
|
+
if actual != target:
|
|
360
|
+
raise ValueError(f'Excel 第 {index} 张表内容或行列数不完整')
|
|
361
|
+
return {'rows': len(expected[0]), 'details': len(expected[1])}
|
|
362
|
+
|
|
363
|
+
|
|
235
364
|
def main() -> int:
|
|
236
365
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
237
366
|
parser.add_argument(
|
|
@@ -258,12 +387,20 @@ def main() -> int:
|
|
|
258
387
|
|
|
259
388
|
output_path = args.output or payload.get("output_path") or default_output_path()
|
|
260
389
|
output_path = os.path.abspath(output_path)
|
|
390
|
+
details = payload.get("details") or []
|
|
391
|
+
if not isinstance(details, list):
|
|
392
|
+
details = []
|
|
261
393
|
|
|
262
|
-
write_xlsx(rows, output_path)
|
|
394
|
+
write_xlsx(rows, output_path, details=details)
|
|
263
395
|
|
|
264
396
|
print(
|
|
265
397
|
json.dumps(
|
|
266
|
-
{
|
|
398
|
+
{
|
|
399
|
+
"ok": True,
|
|
400
|
+
"output_path": output_path,
|
|
401
|
+
"row_count": len(rows),
|
|
402
|
+
"detail_count": len(details),
|
|
403
|
+
},
|
|
267
404
|
ensure_ascii=False,
|
|
268
405
|
)
|
|
269
406
|
)
|
|
@@ -1,96 +1,87 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
# -*- coding: utf-8 -*-
|
|
3
|
-
"""
|
|
4
|
-
|
|
5
|
-
不会按桌面文件名 glob,也不会删除未出现在 --written-excel 里的文件。
|
|
6
|
-
"""
|
|
3
|
+
"""仅清理本次进程创建并登记的目录,所有类型临时文件统一处理。"""
|
|
7
4
|
from __future__ import annotations
|
|
8
5
|
|
|
9
|
-
import argparse
|
|
10
6
|
import json
|
|
11
7
|
import os
|
|
12
|
-
import
|
|
13
|
-
import
|
|
14
|
-
|
|
8
|
+
import stat
|
|
9
|
+
import tempfile
|
|
10
|
+
import uuid
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
15
13
|
|
|
16
|
-
|
|
17
|
-
if not path:
|
|
18
|
-
return {"ok": False, "error": "empty --request-dir"}
|
|
19
|
-
abs_path = os.path.abspath(path)
|
|
20
|
-
if not os.path.isdir(abs_path):
|
|
21
|
-
return {"ok": True, "removed_dir": abs_path, "existed": False}
|
|
22
|
-
# 只允许删 sino-bc-strategy 下的某一层 requestId 目录,防止误传桌面/主目录
|
|
23
|
-
parent = os.path.basename(os.path.dirname(abs_path)).lower()
|
|
24
|
-
if parent != "sino-bc-strategy":
|
|
25
|
-
return {
|
|
26
|
-
"ok": False,
|
|
27
|
-
"error": "refuse: --request-dir 必须是 .../sino-bc-strategy/<requestId>",
|
|
28
|
-
}
|
|
29
|
-
shutil.rmtree(abs_path, ignore_errors=False)
|
|
30
|
-
return {"ok": True, "removed_dir": abs_path, "existed": True}
|
|
14
|
+
MARKER = '.strategy-owner.json'
|
|
31
15
|
|
|
32
16
|
|
|
33
|
-
def
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
for raw in written:
|
|
38
|
-
if not raw:
|
|
39
|
-
continue
|
|
40
|
-
target = os.path.abspath(raw)
|
|
41
|
-
if keep_abs and os.path.normcase(target) == os.path.normcase(keep_abs):
|
|
42
|
-
skipped.append({"path": target, "reason": "keep"})
|
|
43
|
-
continue
|
|
44
|
-
if not os.path.isfile(target):
|
|
45
|
-
skipped.append({"path": target, "reason": "missing"})
|
|
46
|
-
continue
|
|
47
|
-
os.remove(target)
|
|
48
|
-
deleted.append(target)
|
|
49
|
-
return {"ok": True, "deleted": deleted, "skipped": skipped, "kept": keep_abs}
|
|
17
|
+
def is_link(path: Path) -> bool:
|
|
18
|
+
"""Windows junction 等重解析点与符号链接都不允许递归跟随。"""
|
|
19
|
+
info = path.lstat()
|
|
20
|
+
return stat.S_ISLNK(info.st_mode) or bool(getattr(info, 'st_file_attributes', 0) & 0x400)
|
|
50
21
|
|
|
51
22
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
)
|
|
59
|
-
parser.add_argument(
|
|
60
|
-
"--keep-excel",
|
|
61
|
-
default=None,
|
|
62
|
-
help="最终保留的 xlsx 绝对路径",
|
|
63
|
-
)
|
|
64
|
-
parser.add_argument(
|
|
65
|
-
"--written-excel",
|
|
66
|
-
action="append",
|
|
67
|
-
default=[],
|
|
68
|
-
help="本次请求成功写出过的 xlsx,可重复;只从这份名单里删",
|
|
69
|
-
)
|
|
70
|
-
args = parser.parse_args()
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class OwnedDirectory:
|
|
25
|
+
"""创建时保存真实父路径与随机令牌,清理时不能只相信目录名。"""
|
|
26
|
+
path: Path
|
|
27
|
+
parent: Path
|
|
28
|
+
token: str
|
|
71
29
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
)
|
|
79
|
-
return
|
|
30
|
+
@classmethod
|
|
31
|
+
def create(cls, parent: Path, prefix: str):
|
|
32
|
+
parent = parent.resolve()
|
|
33
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
path = Path(tempfile.mkdtemp(prefix=prefix, dir=parent))
|
|
35
|
+
token = uuid.uuid4().hex
|
|
36
|
+
(path / MARKER).write_text(json.dumps({'token': token, 'path': str(path)}), encoding='utf-8')
|
|
37
|
+
return cls(path, parent, token)
|
|
80
38
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
39
|
+
def validate(self):
|
|
40
|
+
"""根目录或父路径被替换后拒绝删除,防止越界。"""
|
|
41
|
+
if is_link(self.path) or self.path.resolve() != self.path or self.path.parent.resolve() != self.parent:
|
|
42
|
+
raise ValueError(f'清理范围已变化:{self.path}')
|
|
43
|
+
marker = self.path / MARKER
|
|
44
|
+
if is_link(marker):
|
|
45
|
+
raise ValueError('所有权文件不能是链接')
|
|
46
|
+
data = json.loads(marker.read_text(encoding='utf-8'))
|
|
47
|
+
if data != {'token': self.token, 'path': str(self.path)}:
|
|
48
|
+
raise ValueError(f'所有权不匹配:{self.path}')
|
|
90
49
|
|
|
91
|
-
|
|
92
|
-
|
|
50
|
+
def cleanup(self, final: Path | None = None) -> dict:
|
|
51
|
+
"""不按后缀筛选;保留可返回的失败清单,绝不静默忽略残留。"""
|
|
52
|
+
errors = []
|
|
53
|
+
try:
|
|
54
|
+
self.validate()
|
|
55
|
+
if final is not None and final.resolve().is_relative_to(self.path):
|
|
56
|
+
raise ValueError('最终 Excel 位于临时目录内,拒绝清理')
|
|
57
|
+
except Exception as exc:
|
|
58
|
+
return {'ok': False, 'residual': [{'path': str(self.path), 'error': str(exc)}]}
|
|
93
59
|
|
|
60
|
+
def remove(path):
|
|
61
|
+
try:
|
|
62
|
+
# 先 lstat 再决定删除方式;对 junction 仅解除链接自身。
|
|
63
|
+
if is_link(path):
|
|
64
|
+
if path.is_symlink():
|
|
65
|
+
path.unlink()
|
|
66
|
+
else:
|
|
67
|
+
os.rmdir(path)
|
|
68
|
+
elif path.is_dir():
|
|
69
|
+
for child in path.iterdir():
|
|
70
|
+
remove(child)
|
|
71
|
+
path.rmdir()
|
|
72
|
+
else:
|
|
73
|
+
path.unlink()
|
|
74
|
+
except Exception as exc:
|
|
75
|
+
errors.append({'path': str(path), 'error': str(exc)})
|
|
94
76
|
|
|
95
|
-
|
|
96
|
-
|
|
77
|
+
# 若某子项未能删除,留下所有权标记供诊断,不伪报清理成功。
|
|
78
|
+
for child in self.path.iterdir():
|
|
79
|
+
if child.name != MARKER:
|
|
80
|
+
remove(child)
|
|
81
|
+
if not errors:
|
|
82
|
+
remove(self.path / MARKER)
|
|
83
|
+
try:
|
|
84
|
+
self.path.rmdir()
|
|
85
|
+
except Exception as exc:
|
|
86
|
+
errors.append({'path': str(self.path), 'error': str(exc)})
|
|
87
|
+
return {'ok': not errors, 'residual': errors}
|