sino-bc-cli 1.0.0-beta.0

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.
Files changed (123) hide show
  1. package/README.md +256 -0
  2. package/dist/affordance/auth.md +31 -0
  3. package/dist/affordance/bill.md +48 -0
  4. package/dist/affordance/biz-line-ai-strategy.md +35 -0
  5. package/dist/affordance/biz-line-metrics.md +47 -0
  6. package/dist/affordance/biz-line.md +84 -0
  7. package/dist/affordance/common-file.md +25 -0
  8. package/dist/affordance/dashboard.md +75 -0
  9. package/dist/affordance/dict.md +28 -0
  10. package/dist/affordance/document.md +67 -0
  11. package/dist/affordance/im.md +91 -0
  12. package/dist/affordance/invoice.md +49 -0
  13. package/dist/affordance/media.md +17 -0
  14. package/dist/affordance/organization.md +47 -0
  15. package/dist/affordance/project-analysis.md +95 -0
  16. package/dist/affordance/project.md +321 -0
  17. package/dist/affordance/quota.md +19 -0
  18. package/dist/affordance/reconciliation.md +41 -0
  19. package/dist/affordance/report.md +75 -0
  20. package/dist/affordance/sso-team-group.md +30 -0
  21. package/dist/affordance/task.md +148 -0
  22. package/dist/affordance/tenant.md +17 -0
  23. package/dist/affordance/user.md +41 -0
  24. package/dist/affordance/wallet.md +100 -0
  25. package/dist/api-doc/README.md +67 -0
  26. package/dist/api-doc/custom/template.md +0 -0
  27. package/dist/api-doc/oapi/auth.md +61 -0
  28. package/dist/api-doc/oapi/bill.md +130 -0
  29. package/dist/api-doc/oapi/biz-line-ai-strategy.md +119 -0
  30. package/dist/api-doc/oapi/biz-line-metrics.md +419 -0
  31. package/dist/api-doc/oapi/biz-line.md +382 -0
  32. package/dist/api-doc/oapi/common-file.md +50 -0
  33. package/dist/api-doc/oapi/dashboard.md +218 -0
  34. package/dist/api-doc/oapi/dict.md +49 -0
  35. package/dist/api-doc/oapi/document.md +182 -0
  36. package/dist/api-doc/oapi/im.md +278 -0
  37. package/dist/api-doc/oapi/invoice.md +129 -0
  38. package/dist/api-doc/oapi/media.md +17 -0
  39. package/dist/api-doc/oapi/organization.md +94 -0
  40. package/dist/api-doc/oapi/project-analysis.md +364 -0
  41. package/dist/api-doc/oapi/project.md +1114 -0
  42. package/dist/api-doc/oapi/quota.md +21 -0
  43. package/dist/api-doc/oapi/reconciliation.md +98 -0
  44. package/dist/api-doc/oapi/report.md +254 -0
  45. package/dist/api-doc/oapi/sso-team-group.md +54 -0
  46. package/dist/api-doc/oapi/task.md +528 -0
  47. package/dist/api-doc/oapi/tenant.md +17 -0
  48. package/dist/api-doc/oapi/user.md +98 -0
  49. package/dist/api-doc/oapi/wallet.md +275 -0
  50. package/dist/generated/api-catalog.json +39681 -0
  51. package/dist/generated/api-directory-index.json +1509 -0
  52. package/dist/index.js +2 -0
  53. package/dist/skill-manifest.json +336 -0
  54. package/dist/skills/biz-line-strategy-generate/SKILL.md +122 -0
  55. package/dist/skills/biz-line-strategy-generate/references/ai-strategy-prompt.md +50 -0
  56. package/dist/skills/biz-line-strategy-generate/references/api-flow.md +155 -0
  57. package/dist/skills/biz-line-strategy-generate/references/four-strategies.md +58 -0
  58. package/dist/skills/biz-line-strategy-generate/references/metrics.md +119 -0
  59. package/dist/skills/biz-line-strategy-generate/references/output-excel.md +63 -0
  60. package/dist/skills/biz-line-strategy-generate/references/runtime.md +127 -0
  61. package/dist/skills/biz-line-strategy-generate/references/spec.md +500 -0
  62. package/dist/skills/biz-line-strategy-generate/scripts/ai_strategy.py +89 -0
  63. package/dist/skills/biz-line-strategy-generate/scripts/build_report.py +629 -0
  64. package/dist/skills/biz-line-strategy-generate/scripts/cleanup.py +87 -0
  65. package/dist/skills/biz-line-strategy-generate/scripts/cli_json.py +705 -0
  66. package/dist/skills/biz-line-strategy-generate/scripts/fetch_data.py +449 -0
  67. package/dist/skills/biz-line-strategy-generate/scripts/run_workflow.py +402 -0
  68. package/dist/skills/biz-line-strategy-generate/scripts/strategy_output.py +556 -0
  69. package/dist/skills/biz-line-strategy-generate/scripts/submit_tags.py +32 -0
  70. package/dist/skills/biz-line-strategy-generate/scripts/summarize.py +932 -0
  71. package/dist/skills/biz-line-strategy-generate/scripts/test_ai_strategy.py +235 -0
  72. package/dist/skills/biz-line-strategy-generate/scripts/test_cli_json.py +97 -0
  73. package/dist/skills/biz-line-strategy-generate/scripts/test_database_output.py +904 -0
  74. package/dist/skills/biz-line-strategy-generate/scripts/test_pipeline.py +253 -0
  75. package/dist/skills/biz-line-strategy-generate/scripts/test_strategy_rules.py +224 -0
  76. package/dist/skills/biz-line-strategy-generate/scripts/test_tag_submission.py +238 -0
  77. package/dist/skills/biz-line-strategy-generate/scripts/test_workflow.py +1233 -0
  78. package/dist/skills/sino-bc-cli/SKILL.md +62 -0
  79. package/dist/skills/sino-bc-cli/domains/sino-bc-auth/SKILL.md +36 -0
  80. package/dist/skills/sino-bc-cli/domains/sino-bc-bill/SKILL.md +48 -0
  81. package/dist/skills/sino-bc-cli/domains/sino-bc-biz-line/SKILL.md +58 -0
  82. package/dist/skills/sino-bc-cli/domains/sino-bc-biz-line/references/common-mistakes.md +7 -0
  83. package/dist/skills/sino-bc-cli/domains/sino-bc-biz-line/references/enums.md +13 -0
  84. package/dist/skills/sino-bc-cli/domains/sino-bc-biz-line-ai-strategy/SKILL.md +46 -0
  85. package/dist/skills/sino-bc-cli/domains/sino-bc-biz-line-metrics/SKILL.md +46 -0
  86. package/dist/skills/sino-bc-cli/domains/sino-bc-common-file/SKILL.md +44 -0
  87. package/dist/skills/sino-bc-cli/domains/sino-bc-dashboard/SKILL.md +50 -0
  88. package/dist/skills/sino-bc-cli/domains/sino-bc-dict/SKILL.md +48 -0
  89. package/dist/skills/sino-bc-cli/domains/sino-bc-document/SKILL.md +53 -0
  90. package/dist/skills/sino-bc-cli/domains/sino-bc-im/SKILL.md +62 -0
  91. package/dist/skills/sino-bc-cli/domains/sino-bc-invoice/SKILL.md +48 -0
  92. package/dist/skills/sino-bc-cli/domains/sino-bc-media/SKILL.md +40 -0
  93. package/dist/skills/sino-bc-cli/domains/sino-bc-organization/SKILL.md +46 -0
  94. package/dist/skills/sino-bc-cli/domains/sino-bc-project/SKILL.md +112 -0
  95. package/dist/skills/sino-bc-cli/domains/sino-bc-project-analysis/SKILL.md +61 -0
  96. package/dist/skills/sino-bc-cli/domains/sino-bc-project-analysis/references/analysis-workflow.md +21 -0
  97. package/dist/skills/sino-bc-cli/domains/sino-bc-project-analysis/references/filters-and-metrics.md +30 -0
  98. package/dist/skills/sino-bc-cli/domains/sino-bc-quota/SKILL.md +44 -0
  99. package/dist/skills/sino-bc-cli/domains/sino-bc-reconciliation/SKILL.md +47 -0
  100. package/dist/skills/sino-bc-cli/domains/sino-bc-report/SKILL.md +53 -0
  101. package/dist/skills/sino-bc-cli/domains/sino-bc-sso-team-group/SKILL.md +45 -0
  102. package/dist/skills/sino-bc-cli/domains/sino-bc-task/SKILL.md +73 -0
  103. package/dist/skills/sino-bc-cli/domains/sino-bc-task/references/state-machine.md +18 -0
  104. package/dist/skills/sino-bc-cli/domains/sino-bc-tenant/SKILL.md +34 -0
  105. package/dist/skills/sino-bc-cli/domains/sino-bc-user/SKILL.md +57 -0
  106. package/dist/skills/sino-bc-cli/domains/sino-bc-wallet/SKILL.md +55 -0
  107. package/dist/skills/sino-bc-cli/references/domain-index/module-8534789.md +53 -0
  108. package/dist/skills/sino-bc-cli/references/domain-index/module-8551546.md +70 -0
  109. package/dist/skills/sino-bc-cli/references/domain-index/module-8562638.md +13 -0
  110. package/dist/skills/sino-bc-cli/references/domain-index/module-8566367.md +13 -0
  111. package/dist/skills/sino-bc-cli/references/domain-index/module-8581524.md +17 -0
  112. package/dist/skills/sino-bc-cli/references/domain-index/module-8601540.md +13 -0
  113. package/dist/skills/sino-bc-cli/references/domain-index/module-8614891.md +33 -0
  114. package/dist/skills/sino-bc-cli/references/domain-index/module-8621792.md +17 -0
  115. package/dist/skills/sino-bc-shared/SKILL.md +31 -0
  116. package/dist/skills/sino-bc-shared/references/auth-recovery.md +36 -0
  117. package/dist/skills/sino-bc-shared/references/config-and-env.md +60 -0
  118. package/dist/skills/sino-bc-shared/references/output-contract.md +86 -0
  119. package/dist/skills/sino-bc-shared/references/skill-lifecycle.md +17 -0
  120. package/dist/skills/sino-bc-shared/references/tenant-context.md +21 -0
  121. package/dist/skills/sino-bc-shared/references/write-approval.md +16 -0
  122. package/package.json +70 -0
  123. package/scripts/postinstall.cjs +36 -0
@@ -0,0 +1,904 @@
1
+ """数据库输出离线回归:使用真实工作流和替代 CLI,不访问生产数据。"""
2
+ import json
3
+ import sys
4
+ import tempfile
5
+ import threading
6
+ import unittest
7
+ import zipfile
8
+ from pathlib import Path
9
+ from unittest.mock import patch
10
+ from xml.etree import ElementTree as ET
11
+
12
+ sys.dont_write_bytecode = True
13
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
14
+
15
+ from strategy_output import GenerationOutcome, precheck_projects, ready_body, save_result, validate_period
16
+ from strategy_output import (load_tags, effect_code, TagCache, industry_condition, split_ai_learning,
17
+ choice_key, submit_tag_choices)
18
+ from concurrent.futures import ThreadPoolExecutor
19
+ from run_workflow import run_batch, workflow_parser
20
+ from test_workflow import arguments, small_payload
21
+ from fetch_data import fetch_business, load_projects, resolve_periods
22
+ from datetime import date
23
+ from argparse import Namespace
24
+
25
+
26
+ class TagReviewProtocol(unittest.TestCase):
27
+ """标签审核必须绑定业务线与本次尝试,同行业同标签也不能串线。"""
28
+
29
+ def setUp(self):
30
+ self.row = {key + 'Strategy': f'{key} 完整策略正文:新增互动目标与国家'
31
+ for key in ('budget', 'product', 'audience')}
32
+ self.dictionaries = {key: [{
33
+ 'id': index, 'name': f'{key}具体标签', 'description': '与正文动作对应',
34
+ 'itemCode': '', 'isEnable': 1, 'isOther': 0, 'sortOrder': 0, 'children': None,
35
+ }, {'id': index + 10, 'name': '其他', 'description': '', 'isOther': 1}]
36
+ for index, key in enumerate(('budget', 'product', 'audience'), 1)}
37
+
38
+ def response_for(self, request):
39
+ """测试回包只从当前请求取身份和当前场景候选,模拟正确 Agent。"""
40
+ return {key: request[key] for key in ('bizLineId', 'attempt', 'reviewId')} | {
41
+ 'selections': {key: [{'id': item['labels'][0]['id'],
42
+ 'choiceKey': item['labels'][0]['choiceKey'], 'reason': '正文明确出现对应动作'}]
43
+ for key, item in request['strategies'].items()},
44
+ }
45
+
46
+ def test_bound_response_is_compact_and_timed(self):
47
+ """保留全部正文与候选,去掉空白管理字段并输出可核对的审核耗时。"""
48
+ from strategy_output import review_tags
49
+ events = []
50
+ with tempfile.TemporaryDirectory() as tmp:
51
+ def progress(event):
52
+ events.append(event)
53
+ if event.get('event') != 'tag_review_required':
54
+ return
55
+ request = json.loads(Path(event['request_path']).read_text(encoding='utf-8'))
56
+ self.assertEqual((request['bizLineId'], request['attempt'], request['reviewId']),
57
+ (340, 2, event['reviewId']))
58
+ self.assertEqual(request['strategies']['budget']['strategy'], self.row['budgetStrategy'])
59
+ self.assertEqual(len(request['strategies']['budget']['labels']), 2)
60
+ self.assertEqual(request['strategies']['budget']['labels'][0], {
61
+ 'id': 1, 'name': 'budget具体标签', 'description': '与正文动作对应', 'isOther': 0,
62
+ 'choiceKey': choice_key(request['reviewId'], 'budget', 1),
63
+ })
64
+ self.assertEqual(request['strategies']['budget']['labels'][1], {
65
+ 'id': 11, 'name': '其他', 'isOther': 1,
66
+ 'choiceKey': choice_key(request['reviewId'], 'budget', 11),
67
+ })
68
+ selected = {'selections': {key: [{'choiceKey': item['labels'][0]['choiceKey'],
69
+ 'reason': '正文明确出现对应动作'}]
70
+ for key, item in request['strategies'].items()}}
71
+ submit_tag_choices(event['request_path'], event['reviewId'], selected)
72
+ choices = review_tags(self.row, self.dictionaries, {}, Path(tmp), threading.Event(), progress,
73
+ biz_line_id=340, attempt=2)
74
+ self.assertEqual(choices['budget'][0]['id'], 1)
75
+ self.assertTrue(events[0]['issuedAt'].endswith('Z'))
76
+ self.assertEqual(events[-1]['event'], 'tag_review_accepted')
77
+ self.assertGreaterEqual(events[-1]['waitSeconds'], 0)
78
+
79
+ def test_same_industry_concurrent_responses_cannot_cross(self):
80
+ """并发错投不会被接受;两次后各线只拿本场景真实的其他标签。"""
81
+ from strategy_output import review_tags
82
+ barrier = threading.Barrier(3)
83
+ lock = threading.Lock()
84
+ events = {}
85
+ with tempfile.TemporaryDirectory() as tmp:
86
+ def run(ident):
87
+ root = Path(tmp) / str(ident)
88
+ root.mkdir()
89
+ def progress(event):
90
+ if event.get('event') != 'tag_review_required':
91
+ return
92
+ with lock:
93
+ events[(ident, event['reviewAttempt'])] = event
94
+ barrier.wait(timeout=5)
95
+ recipient = 340 + (ident - 339) % 3
96
+ request = json.loads(Path(event['request_path']).read_text(encoding='utf-8'))
97
+ Path(events[(recipient, event['reviewAttempt'])]['response_path']).write_text(
98
+ json.dumps(self.response_for(request)), encoding='utf-8')
99
+ choices = review_tags(self.row, self.dictionaries, {}, root, threading.Event(), progress,
100
+ biz_line_id=ident, attempt=1)
101
+ self.assertEqual(choices['budget'][0]['id'], 11)
102
+ with ThreadPoolExecutor(max_workers=3) as pool:
103
+ list(pool.map(run, (340, 341, 342)))
104
+
105
+ def test_stale_or_missing_identity_is_rejected(self):
106
+ """旧轮次或旧格式回包不能用本行业合法 ID 绕过身份核对。"""
107
+ from strategy_output import review_tags
108
+ for mutation in ('old_attempt', 'old_review_id', 'missing_identity'):
109
+ with self.subTest(mutation=mutation), tempfile.TemporaryDirectory() as tmp:
110
+ def progress(event):
111
+ if event.get('event') != 'tag_review_required':
112
+ return
113
+ request = json.loads(Path(event['request_path']).read_text(encoding='utf-8'))
114
+ response = self.response_for(request)
115
+ if mutation == 'old_attempt':
116
+ response['attempt'] -= 1
117
+ elif mutation == 'old_review_id':
118
+ response['reviewId'] = 'previous-review-id'
119
+ else:
120
+ response.pop('reviewId')
121
+ Path(event['response_path']).write_text(json.dumps(response), encoding='utf-8')
122
+ choices = review_tags(self.row, self.dictionaries, {}, Path(tmp), threading.Event(), progress,
123
+ biz_line_id=340, attempt=2)
124
+ self.assertEqual(choices['budget'][0]['id'], 11)
125
+
126
+ def test_second_review_accepts_without_reusing_first_response(self):
127
+ """一次拒绝后立即重新请求,第二次使用新路径与审核 ID。"""
128
+ from strategy_output import review_tags
129
+ requests, events = [], []
130
+ with tempfile.TemporaryDirectory() as tmp:
131
+ def progress(event):
132
+ events.append(event)
133
+ if event.get('event') != 'tag_review_required':
134
+ return
135
+ request = json.loads(Path(event['request_path']).read_text(encoding='utf-8'))
136
+ requests.append(request)
137
+ reply = self.response_for(request)
138
+ if len(requests) == 1:
139
+ reply['bizLineId'] += 1
140
+ else:
141
+ self.assertNotEqual(requests[0]['reviewId'], request['reviewId'])
142
+ self.assertNotEqual(events[0]['response_path'], event['response_path'])
143
+ # 旧响应晚到时仍只能落在第一轮独立路径。
144
+ Path(events[0]['response_path']).write_text(json.dumps(self.response_for(requests[0])), encoding='utf-8')
145
+ Path(event['response_path']).write_text(json.dumps(reply), encoding='utf-8')
146
+ choices = review_tags(self.row, self.dictionaries, {}, Path(tmp), threading.Event(), progress,
147
+ biz_line_id=340, attempt=1)
148
+ self.assertEqual(len(requests), 2)
149
+ self.assertEqual(choices['budget'][0]['id'], 1)
150
+ self.assertEqual(events[-1]['event'], 'tag_review_accepted')
151
+
152
+ def test_two_rejections_use_other_or_empty_for_each_scene(self):
153
+ """两次无效后只对缺其他的场景给空标签,策略正文仍保留。"""
154
+ from strategy_output import review_tags
155
+ self.dictionaries['audience'] = self.dictionaries['audience'][:1]
156
+ events = []
157
+ with tempfile.TemporaryDirectory() as tmp:
158
+ def progress(event):
159
+ events.append(event)
160
+ if event.get('event') == 'tag_review_required':
161
+ request = json.loads(Path(event['request_path']).read_text(encoding='utf-8'))
162
+ reply = self.response_for(request)
163
+ reply['bizLineId'] += 1
164
+ Path(event['response_path']).write_text(json.dumps(reply), encoding='utf-8')
165
+ choices = review_tags(self.row, self.dictionaries, {}, Path(tmp), threading.Event(), progress,
166
+ biz_line_id=340, attempt=1)
167
+ self.assertEqual(len([event for event in events if event['event'] == 'tag_review_required']), 2)
168
+ self.assertEqual(choices['budget'][0]['id'], 11)
169
+ self.assertEqual(choices['product'][0]['id'], 12)
170
+ self.assertEqual(choices['audience'], [])
171
+ self.assertEqual(events[-1]['event'], 'tag_review_fallback')
172
+ self.assertEqual(events[-1]['missingOtherScenes'], ['audience'])
173
+ self.assertEqual(self.row['audienceStrategy'], 'audience 完整策略正文:新增互动目标与国家')
174
+
175
+ def test_two_timeouts_then_fallback_after_seven_minutes_each(self):
176
+ """用模拟时钟验证每次420秒与两次上限,不真实等待。"""
177
+ from strategy_output import review_tags
178
+ clock = {'seconds': 0}
179
+ class NeverCancel:
180
+ def is_set(self):
181
+ return False
182
+ def wait(self, _):
183
+ clock['seconds'] += 210
184
+ return None
185
+ events = []
186
+ with tempfile.TemporaryDirectory() as tmp, patch('strategy_output.time.monotonic', side_effect=lambda: clock['seconds']):
187
+ choices = review_tags(self.row, self.dictionaries, {}, Path(tmp), NeverCancel(), events.append,
188
+ biz_line_id=340, attempt=1)
189
+ self.assertEqual(len([event for event in events if event['event'] == 'tag_review_required']), 2)
190
+ self.assertEqual(len([event for event in events if event['event'] == 'tag_review_rejected']), 2)
191
+ self.assertEqual(clock['seconds'], 840)
192
+ self.assertEqual(choices['budget'][0]['id'], 11)
193
+
194
+ def test_non_review_error_does_not_auto_fallback(self):
195
+ """进度输出自身故障不是标签拒绝,不能悄悄改选其他。"""
196
+ from strategy_output import review_tags
197
+ with tempfile.TemporaryDirectory() as tmp, self.assertRaisesRegex(OSError, 'progress failed'):
198
+ review_tags(self.row, self.dictionaries, {}, Path(tmp), threading.Event(),
199
+ lambda _: (_ for _ in ()).throw(OSError('progress failed')),
200
+ biz_line_id=340, attempt=1)
201
+
202
+
203
+ class DatabaseRules(unittest.TestCase):
204
+ """明确业务分支及非幂等写入边界。"""
205
+
206
+ def test_content_review_handoff_and_real_tag_ids(self):
207
+ """真实文件协议:名称不同也能由内容分析选择,Excel 名称与入库 ID 同源。"""
208
+ import threading
209
+ from strategy_output import review_tags, attach_tags
210
+ row = {key + 'Strategy': '上调 5 个广告组出价,分布在 EN(5个)'
211
+ for key in ('budget', 'product', 'audience')}
212
+ dictionaries = {key: [{'id': index, 'name': '竞价增强'}, {'id': index + 10, 'name': '其他'}]
213
+ for index, key in enumerate(('budget', 'product', 'audience'), 1)}
214
+ with tempfile.TemporaryDirectory() as tmp:
215
+ def respond(event):
216
+ if event['event'] != 'tag_review_required':
217
+ return
218
+ request = json.loads(Path(event['request_path']).read_text(encoding='utf-8'))
219
+ self.assertEqual(request['strategies']['budget']['strategy'], row['budgetStrategy'])
220
+ self.assertEqual([item['id'] for item in request['strategies']['budget']['labels']], [1, 11])
221
+ selection = {key: [{'choiceKey': item['labels'][0]['choiceKey'], 'reason': '上调广告组出价对应竞价增强'}]
222
+ for key, item in request['strategies'].items()}
223
+ submit_tag_choices(event['request_path'], event['reviewId'], {'selections': selection})
224
+ choices = review_tags(row, dictionaries, {}, Path(tmp), threading.Event(), respond,
225
+ biz_line_id=1, attempt=1)
226
+ attach_tags(row, dictionaries, {}, choices)
227
+ self.assertEqual(row['budgetTagIds'], [1])
228
+ self.assertEqual(row['budgetTags'], '竞价增强')
229
+
230
+ def test_tag_review_rejects_foreign_ids_and_other_mixed_with_match(self):
231
+ """不接受跨场景 ID、无理由、重复选择或具体标签同时带其他。"""
232
+ from strategy_output import reviewed_tags
233
+ labels = [{'id': 1, 'name': '拓展受众'}, {'id': 2, 'name': '其他'}]
234
+ for choices in ([], [{'id': 99, 'reason': '错误场景'}], [{'id': 1}],
235
+ [{'id': 1, 'reason': '新增受众'}, {'id': 2, 'reason': '其他'}],
236
+ [{'id': 1, 'reason': '新增受众'}, {'id': 1, 'reason': '重复'}]):
237
+ with self.subTest(choices=choices), self.assertRaises(ValueError):
238
+ reviewed_tags(labels, choices)
239
+ self.assertEqual(reviewed_tags(labels, [{'id': 2, 'reason': '正文未涉及受众拓展,其他标签均不适用'}]), [labels[1]])
240
+
241
+ def test_tag_review_cancellation(self):
242
+ """等待分析时取消可立即清理退出,不阻塞下一批。"""
243
+ import threading
244
+ from strategy_output import review_tags
245
+ cancel = threading.Event()
246
+ cancel.set()
247
+ keys = ('budget', 'product', 'audience')
248
+ with tempfile.TemporaryDirectory() as tmp, self.assertRaises(InterruptedError):
249
+ review_tags({key + 'Strategy': '内容' for key in keys},
250
+ {key: [{'id': 1, 'name': '其他'}] for key in keys}, {}, Path(tmp), cancel, lambda _: None,
251
+ biz_line_id=1, attempt=1)
252
+
253
+ def test_all_zero_stops_before_channel_query(self):
254
+ with tempfile.TemporaryDirectory() as tmp, patch('strategy_output.run_cli', return_value={
255
+ 'ok': True, 'data': {'result': [{'projectId': 1, 'lastWeekSpend': 0}], 'total': 1}
256
+ }) as api:
257
+ with self.assertRaises(GenerationOutcome) as caught:
258
+ precheck_projects(Path(tmp), 1)
259
+ self.assertEqual(caught.exception.code, 'NO_DELIVERY_DATA')
260
+ self.assertEqual(api.call_count, 1)
261
+ self.assertEqual(api.call_args.args[1], {'bizLineIdList': [1], 'pageNum': 1, 'pageSize': 100})
262
+
263
+ def test_explicit_skip_spend_still_checks_project_coverage(self):
264
+ """显式跳过只放行消耗门槛;项目渠道不再作为预检依据。"""
265
+ project = {'projectId': 1, 'lastWeekSpend': 0, 'channelIdList': ['unknown']}
266
+ page = {'ok': True, 'data': {'result': [project], 'total': 1}}
267
+ with tempfile.TemporaryDirectory() as tmp:
268
+ with patch('strategy_output.run_cli', return_value=page) as api:
269
+ precheck_projects(Path(tmp), 1, [1], skip_spend_check=True)
270
+ self.assertEqual(api.call_count, 1)
271
+ with patch('strategy_output.run_cli', return_value=page):
272
+ with self.assertRaisesRegex(RuntimeError, '未覆盖'):
273
+ precheck_projects(Path(tmp), 1, [1, 2], skip_spend_check=True)
274
+ self.assertFalse(workflow_parser().parse_args(['--id', '1']).skip_project_spend_check)
275
+ self.assertTrue(workflow_parser().parse_args(['--id', '1', '--skip-project-spend-check']).skip_project_spend_check)
276
+
277
+ def test_later_page_positive_ignores_project_channels(self):
278
+ """项目列表仅判断消耗;未知或缺失项目渠道不应提前拒绝。"""
279
+ pages = [
280
+ {'ok': True, 'data': {'result': [{'projectId': 1, 'lastWeekSpend': 0, 'channelIdList': ['unknown']}], 'total': 2}},
281
+ {'ok': True, 'data': {'result': [{'projectId': 2, 'lastWeekSpend': 2}], 'total': 2}},
282
+ ]
283
+ with tempfile.TemporaryDirectory() as tmp, patch('strategy_output.run_cli', side_effect=pages) as api:
284
+ precheck_projects(Path(tmp), 1)
285
+ self.assertEqual(api.call_count, 2)
286
+ self.assertEqual(api.call_args_list[1].args[1]['pageNum'], 2)
287
+
288
+ def test_missing_spend_and_failed_page_are_not_zero(self):
289
+ for env in ({'ok': True, 'data': {'result': [{'projectId': 1}], 'total': 1}}, {'ok': False, 'error': 'denied'}):
290
+ with self.subTest(env=env), tempfile.TemporaryDirectory() as tmp, patch('strategy_output.run_cli', return_value=env):
291
+ with self.assertRaises(RuntimeError) as caught:
292
+ precheck_projects(Path(tmp), 1)
293
+ self.assertNotEqual(getattr(caught.exception, 'code', None), 'NO_DELIVERY_DATA')
294
+
295
+ def test_default_service_filter_cannot_hide_linked_projects(self):
296
+ """即使分页 total 完整,也要与业务线 projectIds 核对,不能遗漏其他服务项目。"""
297
+ with tempfile.TemporaryDirectory() as tmp, patch('strategy_output.run_cli', return_value={
298
+ 'ok': True, 'data': {'result': [{'projectId': 1, 'lastWeekSpend': 0}], 'total': 1}
299
+ }):
300
+ with self.assertRaisesRegex(RuntimeError, '未覆盖业务线全部关联项目') as caught:
301
+ precheck_projects(Path(tmp), 1, [1, 2])
302
+ self.assertNotIsInstance(caught.exception, GenerationOutcome)
303
+
304
+ def test_calendar_periods_and_explicit_overrides(self):
305
+ """周一、周日及跨年均取已结束周,用户可分别覆盖两期。"""
306
+ args = Namespace(curr_start='', curr_end='', prev_start='', prev_end='')
307
+ for anchor in (date(2026, 9, 21), date(2026, 9, 22), date(2026, 9, 27)):
308
+ self.assertEqual(resolve_periods(args, anchor), {
309
+ 'curr': ('2026-09-14', '2026-09-20'), 'prev': ('2026-09-07', '2026-09-13')})
310
+ self.assertEqual(resolve_periods(args, date(2026, 1, 1)), {
311
+ 'curr': ('2025-12-22', '2025-12-28'), 'prev': ('2025-12-15', '2025-12-21')})
312
+ args.curr_start, args.curr_end = '2026-09-01', '2026-09-05'
313
+ self.assertEqual(resolve_periods(args, date(2026, 9, 22))['curr'], ('2026-09-01', '2026-09-05'))
314
+ self.assertEqual(resolve_periods(args, date(2026, 9, 22))['prev'], ('2026-09-07', '2026-09-13'))
315
+ args.prev_start, args.prev_end = '2026-08-25', '2026-08-29'
316
+ self.assertEqual(resolve_periods(args)['prev'], ('2026-08-25', '2026-08-29'))
317
+ args.prev_end = ''
318
+ with self.assertRaises(ValueError):
319
+ resolve_periods(args)
320
+
321
+ def test_invalid_week_rejected(self):
322
+ validate_period('2026-09-07', '2026-09-13')
323
+ for bounds in [('2026-09-08', '2026-09-13'), ('2026-09-07', '2026-09-20'), ('2026-08-31', '2026-09-06')]:
324
+ with self.subTest(bounds=bounds), self.assertRaises(ValueError):
325
+ validate_period(*bounds)
326
+
327
+ def test_full_ready_mapping_and_effect_validation(self):
328
+ """READY 正文与效果只取 AI 单元格,固定行仅提供审核过的标签绑定。"""
329
+ from build_report import read_ai_sheet, write_xlsx
330
+ row = {key + 'Strategy': '固定正文' for key in ('budget', 'product', 'audience', 'creative')}
331
+ for key in ('budget', 'product', 'audience'):
332
+ row.update({key + 'TagIds': [21], key + 'Tags': '真实标签'})
333
+ text = '一页速览\n- 概览。\n' + '\n'.join(
334
+ f'【{heading}】\nAI正文{index}\nLearning({label}):独立说明\nKPI目标 <= 3'
335
+ for index, (heading, label) in enumerate(zip(
336
+ ('预算策略', '广告产品策略', '受众策略', '创意策略'), ('有效', '无效', '待观察', '有效'))))
337
+ context = {'bizLineId': 1, 'periodStart': '2026-09-07', 'periodEnd': '2026-09-13'}
338
+ with tempfile.TemporaryDirectory() as tmp:
339
+ path = Path(tmp) / 'final.xlsx'
340
+ write_xlsx([row], str(path), [], ai_strategy=text)
341
+ sheet = read_ai_sheet(path)
342
+ body = ready_body(context, sheet, row, 'key.xlsx')
343
+ self.assertEqual(body['mediaStrategyTags'], [21])
344
+ self.assertEqual(body['mediaStrategyEffect'], 2)
345
+ self.assertEqual(body['creativeStrategy'], 'AI正文3')
346
+ self.assertEqual(body['budgetStrategySupplement'], 'AI正文0')
347
+ self.assertEqual(body['fileStorageKey'], 'key.xlsx')
348
+ for remote in ('budget', 'media', 'audience', 'creative'):
349
+ self.assertEqual(body[remote + 'StrategyLearning'], '独立说明\nKPI目标 <= 3')
350
+ self.assertEqual(row['budgetStrategy'], '固定正文')
351
+ sheet['预算策略标签'] = '另一业务线标签'
352
+ with self.assertRaisesRegex(ValueError, '标签'):
353
+ ready_body(context, sheet, row, 'key.xlsx')
354
+ sheet['预算策略标签'] = ''
355
+ row.update(budgetTags='', budgetTagIds=[], budgetTagFallbackMissingOther=True)
356
+ self.assertEqual(ready_body(context, sheet, row, 'key.xlsx')['budgetStrategyTags'], [])
357
+ row.pop('budgetTagFallbackMissingOther')
358
+ with self.assertRaises(ValueError):
359
+ ready_body(context, sheet, row, 'key.xlsx')
360
+
361
+ def test_learning_labels_and_standalone_heading(self):
362
+ """精确解析末段,不从正文中的有效词汇猜效果,保留说明换行。"""
363
+ variants = ('Learning({label}):', 'learning({label}):',
364
+ '【Learning】\nLearning({label}):', '【Learning】\n({label}):')
365
+ for label, effect in (('有效', 1), ('无效', 2), ('待观察', 3)):
366
+ for marker in variants:
367
+ with self.subTest(label=label, marker=marker):
368
+ self.assertEqual(split_ai_learning('有效动作正文\n' + marker.format(label=label) +
369
+ '动作解释\n- KPI数据'),
370
+ ('有效动作正文', effect, '动作解释\n- KPI数据'))
371
+ for value in ('正文', '正文\nLearning(潜力):说明', '正文\nLearning(有效): ',
372
+ 'Learning(有效):只有说明',
373
+ '正文\nLearning(有效):说明\n【调整方向】\n继续加投。',
374
+ '正文\nLearning(有效):说明\n策略总结:成本降低。',
375
+ '正文\nLearning(有效):说明\n关键数据: 消耗下降。',
376
+ '正文\nLearning(有效):说明\nLearning(无效):说明',
377
+ '正文\n【Learning】\n【Learning】\n(有效):说明'):
378
+ with self.subTest(value=value), self.assertRaises(ValueError):
379
+ split_ai_learning(value)
380
+
381
+ def test_save_failure_never_retries(self):
382
+ with tempfile.TemporaryDirectory() as tmp, patch('strategy_output.run_cli', return_value={'ok': False, 'error': 'timeout'}) as api:
383
+ with self.assertRaises(RuntimeError):
384
+ save_result(Path(tmp), {'generateCode': 'NO_PROJECT'})
385
+ self.assertEqual(api.call_count, 1)
386
+ self.assertEqual(api.call_args.args[0], ['biz-line-ai-strategy', '+ai-strategy-save'])
387
+
388
+ def test_dictionary_requests_and_empty_results(self):
389
+ with tempfile.TemporaryDirectory() as tmp, patch('strategy_output.run_cli', return_value={'ok': True, 'data': [
390
+ {'id': 1, 'name': '其他', 'isOther': 1, 'children': None}
391
+ ]}) as api:
392
+ dictionaries = load_tags(Path(tmp), '11')
393
+ self.assertEqual(len(dictionaries), 3)
394
+ self.assertEqual([call.args[1]['sceneCode'] for call in api.call_args_list],
395
+ ['BUDGET_STRATEGY', 'MEDIA_STRATEGY', 'AUDIENCE_STRATEGY'])
396
+ self.assertTrue(all(call.args[1]['filterCondition'] == '11' for call in api.call_args_list))
397
+ api.return_value = {'ok': True, 'data': []}
398
+ with self.assertRaises(ValueError):
399
+ load_tags(Path(tmp), '11')
400
+
401
+ def test_industry_cache_concurrent_reuse_and_isolation(self):
402
+ """多行业并发时同一行业只查三次,且多个行业只取首个。"""
403
+ with tempfile.TemporaryDirectory() as tmp, patch('strategy_output.run_cli', return_value={
404
+ 'ok': True, 'data': [{'id': 1, 'name': '其他'}]
405
+ }) as api:
406
+ cache = TagCache(Path(tmp))
407
+ lines = [{'adCategoryIndustryIds': ids} for ids in ([11, 22], [11], [22], [33], [22])]
408
+ with ThreadPoolExecutor(max_workers=3) as executor:
409
+ values = list(executor.map(cache.get, lines))
410
+ self.assertIs(values[0], values[1])
411
+ self.assertEqual(api.call_count, 9)
412
+ for industry in ('11', '22', '33'):
413
+ queries = [call.args[1] for call in api.call_args_list if call.args[1]['filterCondition'] == industry]
414
+ self.assertEqual(queries, [{'sceneCode': scene, 'filterCondition': industry} for scene in
415
+ ('BUDGET_STRATEGY', 'MEDIA_STRATEGY', 'AUDIENCE_STRATEGY')])
416
+ self.assertEqual(len({str(call.args[2]) for call in api.call_args_list}), 9)
417
+
418
+ def test_invalid_industry_and_failure_cache(self):
419
+ """不能改用第二个行业,失败仅缓存对应行业,不影响其他行业。"""
420
+ for ids in (None, [], [None, 11], [False], [-1], ['bad', 11]):
421
+ with self.assertRaises(ValueError):
422
+ industry_condition({'adCategoryIndustryIds': ids})
423
+ with tempfile.TemporaryDirectory() as tmp, patch('strategy_output.run_cli', return_value={'ok': True, 'data': []}) as api:
424
+ cache = TagCache(Path(tmp))
425
+ for _ in range(2):
426
+ with self.assertRaises((ValueError, RuntimeError)):
427
+ cache.get({'adCategoryIndustryIds': [11]})
428
+ self.assertEqual(api.call_count, 1)
429
+ api.return_value = {'ok': True, 'data': [{'id': 1, 'name': '其他'}]}
430
+ cache.get({'adCategoryIndustryIds': [22]})
431
+ self.assertEqual(api.call_count, 4)
432
+
433
+ def test_effects_and_parser(self):
434
+ self.assertEqual([effect_code(v) for v in ('有效', '无效', '待观察', '待观察(预警)')], [1, 2, 3, 3])
435
+ self.assertEqual(workflow_parser().parse_args(['--id', '1']).output_mode, 'excel')
436
+ self.assertEqual(workflow_parser().parse_args(['--id', '1', '--output-mode', 'database']).output_mode, 'database')
437
+
438
+ def test_real_fetch_zero_precheck_creates_context_before_stopping(self):
439
+ """不替换 fetch_business,确认真实入口在广告拉数前结束且可写正确周期状态。"""
440
+ with tempfile.TemporaryDirectory() as tmp:
441
+ root = Path(tmp)
442
+ args = Namespace(**{**vars(arguments(root)), 'id': 1, 'request_dir': str(root), 'output_mode': 'database'})
443
+ with patch('fetch_data.date', wraps=date) as clock, patch('fetch_data.run_cli', return_value={'ok': True, 'data': {'result': [{
444
+ 'id': 1, 'name': '匿名业务线', 'projectIds': [10],
445
+ 'periodStart': '2026-09-07', 'periodEnd': '2026-09-13',
446
+ }], 'total': 1}}) as fetch_api, patch('strategy_output.run_cli', return_value={
447
+ 'ok': True, 'data': {'result': [{'projectId': 10, 'lastWeekSpend': 0}], 'total': 1}
448
+ }):
449
+ clock.today.return_value = date(2026, 9, 22)
450
+ with self.assertRaises(GenerationOutcome) as outcome:
451
+ fetch_business(args)
452
+ self.assertEqual(outcome.exception.code, 'NO_DELIVERY_DATA')
453
+ self.assertEqual(fetch_api.call_count, 1)
454
+ self.assertEqual(json.loads((root / 'save-context.json').read_text())['periodStart'], '2026-09-14')
455
+
456
+ def test_fetch_queries_industry_tags_before_project_precheck(self):
457
+ """真实 fetch 入口先解析业务线行业并查字典,再进入项目检查。"""
458
+ with tempfile.TemporaryDirectory() as tmp:
459
+ args = Namespace(**{**vars(arguments(Path(tmp))), 'id': 1, 'request_dir': tmp,
460
+ 'output_mode': 'database', 'curr_start': '2026-09-07', 'curr_end': '2026-09-13',
461
+ 'tag_cache': TagCache(Path(tmp) / 'tags')})
462
+ events = []
463
+ def lookup(*_):
464
+ events.append('biz')
465
+ return {'ok': True, 'data': {'result': [{'id': 1, 'projectIds': [10], 'adCategoryIndustryIds': [11, 22]}], 'total': 1}}
466
+ def tags(command, body, out):
467
+ events.append(body)
468
+ return {'ok': True, 'data': [{'id': 1, 'name': '其他'}]}
469
+ def precheck(*_, **kwargs):
470
+ events.append('projects')
471
+ raise GenerationOutcome('NO_DELIVERY_DATA', '全零')
472
+ with patch('fetch_data.run_cli', side_effect=lookup), patch('strategy_output.run_cli', side_effect=tags), patch('fetch_data.precheck_projects', side_effect=precheck):
473
+ with self.assertRaises(GenerationOutcome):
474
+ fetch_business(args)
475
+ self.assertEqual(events, ['biz',
476
+ {'sceneCode': 'BUDGET_STRATEGY', 'filterCondition': '11'},
477
+ {'sceneCode': 'MEDIA_STRATEGY', 'filterCondition': '11'},
478
+ {'sceneCode': 'AUDIENCE_STRATEGY', 'filterCondition': '11'}, 'projects'])
479
+
480
+ def test_invalid_output_options_do_not_create_files(self):
481
+ with tempfile.TemporaryDirectory() as tmp:
482
+ args = arguments(Path(tmp) / 'out')
483
+ args.output_mode = 'database'
484
+ with self.assertRaises(ValueError):
485
+ run_batch(args, temp_parent=Path(tmp))
486
+ args.output_dir, args.curr_start = None, '2026-09-07'
487
+ with self.assertRaises(ValueError):
488
+ run_batch(args, temp_parent=Path(tmp))
489
+ self.assertFalse(list(Path(tmp).iterdir()))
490
+
491
+ def test_unknown_channel_is_not_dropped(self):
492
+ """后续项目的未知账号渠道也必须拒绝,不能只保留前面的支持账号。"""
493
+ projects = [{'projectName': '支持项目', 'adAccounts': [{'adAccountId': 'f', 'channelName': 'Meta'}]},
494
+ {'projectName': '其他项目', 'adAccounts': [{'adAccountId': 'x', 'channelName': 'Other'}]}]
495
+ with tempfile.TemporaryDirectory() as tmp, patch('fetch_data.run_cli', return_value={'ok': True, 'data': projects}):
496
+ with self.assertRaises(GenerationOutcome) as outcome:
497
+ load_projects(Path(tmp), 1)
498
+ self.assertEqual(outcome.exception.code, 'UNSUPPORTED_CHANNEL')
499
+
500
+
501
+ class AccountChannelSource(unittest.TestCase):
502
+ """关联账号是渠道唯一来源,项目分页的配置不能覆盖或绕过它。"""
503
+
504
+ def test_supported_channel_combinations_and_account_deduplication(self):
505
+ """三个渠道任意非空组合均可用,跨项目重复账号只统计一次。"""
506
+ entries = [('Facebook', 'fb'), ('Google', 'gg'), ('TikTok', 'tt')]
507
+ for mask in range(1, 8):
508
+ selected = [item for index, item in enumerate(entries) if mask & (1 << index)]
509
+ accounts = [{'adAccountId': key, 'channelName': name} for name, key in selected]
510
+ projects = [{'projectName': '项目一', 'adAccounts': accounts},
511
+ {'projectName': '项目二', 'adAccounts': accounts}]
512
+ with self.subTest(mask=mask), tempfile.TemporaryDirectory() as tmp, \
513
+ patch('fetch_data.run_cli', return_value={'ok': True, 'data': projects}) as api:
514
+ names, buckets = load_projects(Path(tmp), 1)
515
+ self.assertEqual(names, ['项目一', '项目二'])
516
+ self.assertEqual(buckets, {key: [key] if (name, key) in selected else [] for name, key in entries})
517
+ self.assertEqual(api.call_count, 1)
518
+ self.assertEqual(api.call_args.args[:2], (['biz-line', '+project-list'], {'bizLineId': 1}))
519
+
520
+ def test_channel_id_resolves_missing_or_stale_account_name(self):
521
+ """契约规定名称可能为空,channelId有值时以字典解析出的渠道为准。"""
522
+ projects = [{'projectName': '项目', 'adAccounts': [
523
+ {'adAccountId': 'g', 'channelId': 102, 'channelName': None},
524
+ {'adAccountId': 't', 'channelId': 103, 'channelName': 'Meta'},
525
+ ]}]
526
+ dictionary = [{'channelId': 102, 'channelName': 'Google'}, {'channelId': 103, 'channelName': 'TikTok'}]
527
+ with tempfile.TemporaryDirectory() as tmp, patch('fetch_data.run_cli', side_effect=[
528
+ {'ok': True, 'data': projects}, {'ok': True, 'data': dictionary},
529
+ ]) as api:
530
+ self.assertEqual(load_projects(Path(tmp), 1)[1], {'fb': [], 'gg': ['g'], 'tt': ['t']})
531
+ self.assertEqual(api.call_args_list[1].args[:2], (['media', '+channels'], None))
532
+ self.assertEqual(api.call_count, 2)
533
+
534
+ def test_unknown_id_cannot_be_hidden_by_supported_name(self):
535
+ """账号ID指向其他或未知渠道时,不能凭Facebook名称放行。"""
536
+ for dictionary in ([], [{'channelId': 999, 'channelName': 'Other'}]):
537
+ project = {'projectName': '项目', 'adAccounts': [
538
+ {'adAccountId': 'x', 'channelId': 999, 'channelName': 'Facebook'}]}
539
+ with self.subTest(dictionary=dictionary), tempfile.TemporaryDirectory() as tmp, \
540
+ patch('fetch_data.run_cli', side_effect=[{'ok': True, 'data': [project]}, {'ok': True, 'data': dictionary}]):
541
+ with self.assertRaises(GenerationOutcome) as caught:
542
+ load_projects(Path(tmp), 1)
543
+ self.assertEqual(caught.exception.code, 'UNSUPPORTED_CHANNEL')
544
+
545
+ def test_missing_channel_is_rejected_and_empty_accounts_are_preserved(self):
546
+ """有账号无渠道属于未知渠道;全部无账号留给原NO_AD_ACCOUNT逻辑。"""
547
+ for channel in (None, '', 'Other'):
548
+ project = {'projectName': '项目', 'adAccounts': [{'adAccountId': 'x', 'channelName': channel}]}
549
+ with self.subTest(channel=channel), tempfile.TemporaryDirectory() as tmp, \
550
+ patch('fetch_data.run_cli', return_value={'ok': True, 'data': [project]}):
551
+ with self.assertRaises(GenerationOutcome) as caught:
552
+ load_projects(Path(tmp), 1)
553
+ self.assertEqual(caught.exception.code, 'UNSUPPORTED_CHANNEL')
554
+ with tempfile.TemporaryDirectory() as tmp, patch('fetch_data.run_cli', return_value={
555
+ 'ok': True, 'data': [{'projectName': '项目', 'adAccounts': []}],
556
+ }) as api:
557
+ self.assertEqual(load_projects(Path(tmp), 1)[1], {'fb': [], 'gg': [], 'tt': []})
558
+ self.assertEqual(api.call_count, 1)
559
+
560
+ def test_malformed_accounts_and_lookup_failure_are_technical_errors(self):
561
+ """损坏回包或字典失败不能被丢弃后生成部分报告。"""
562
+ cases = [[None], [{'projectName': '项目', 'adAccounts': 'invalid'}],
563
+ [{'projectName': '项目', 'adAccounts': [None]}]]
564
+ for projects in cases:
565
+ with self.subTest(projects=projects), tempfile.TemporaryDirectory() as tmp, \
566
+ patch('fetch_data.run_cli', return_value={'ok': True, 'data': projects}):
567
+ with self.assertRaises(RuntimeError) as caught:
568
+ load_projects(Path(tmp), 1)
569
+ self.assertNotIsInstance(caught.exception, GenerationOutcome)
570
+ project = {'projectName': '项目', 'adAccounts': [{'adAccountId': 'f', 'channelId': 101}]}
571
+ with tempfile.TemporaryDirectory() as tmp, patch('fetch_data.run_cli', side_effect=[
572
+ {'ok': True, 'data': [project]}, {'ok': False, 'error': '查询失败'},
573
+ ]):
574
+ with self.assertRaises(RuntimeError) as caught:
575
+ load_projects(Path(tmp), 1)
576
+ self.assertNotIsInstance(caught.exception, GenerationOutcome)
577
+
578
+ def test_real_workflow_rejects_account_channel_even_with_explicit_override(self):
579
+ """项目渠道可用、消耗跳过和显式账号均不能绕过关联账号的未知渠道。"""
580
+ with tempfile.TemporaryDirectory() as tmp:
581
+ root = Path(tmp)
582
+ args = arguments(root / 'out')
583
+ args.output_mode, args.output_dir = 'database', None
584
+ args.curr_start, args.curr_end = '2026-09-07', '2026-09-13'
585
+ args.account_fb, args.skip_project_spend_check = ['user-account'], True
586
+ saved, calls = [], []
587
+ def api(command, body, out):
588
+ calls.append(command)
589
+ if command == ['biz-line', '+list']:
590
+ return {'ok': True, 'data': {'result': [{'id': 1, 'projectIds': [10], 'adCategoryIndustryIds': [11]}], 'total': 1}}
591
+ if command == ['biz-line', '+categories']:
592
+ return {'ok': True, 'data': [{'id': 1, 'name': '其他', 'isOther': 1}]}
593
+ if command == ['biz-line', '+queryByPage']:
594
+ return {'ok': True, 'data': {'result': [{'projectId': 10, 'lastWeekSpend': 0, 'channelIdList': [101]}], 'total': 1}}
595
+ if command == ['biz-line', '+detail']:
596
+ return {'ok': True, 'data': {'id': 1}}
597
+ if command == ['biz-line', '+project-list']:
598
+ return {'ok': True, 'data': [{'projectId': 10, 'projectName': '项目', 'adAccounts': [
599
+ {'adAccountId': 'f', 'channelName': 'Meta'}, {'adAccountId': 'x', 'channelName': 'Other'}]}]}
600
+ if command == ['biz-line-ai-strategy', '+ai-strategy-save']:
601
+ saved.append(body)
602
+ return {'ok': True, 'data': 1}
603
+ raise AssertionError(f'不应继续拉数或上传:{command}')
604
+ with patch('fetch_data.run_cli', side_effect=api), patch('strategy_output.run_cli', side_effect=api):
605
+ result = run_batch(args, progress=lambda _: None, temp_parent=root)
606
+ self.assertTrue(result['ok'], result)
607
+ self.assertEqual(result['results'][0]['attempts'], 1)
608
+ self.assertEqual([item['generateCode'] for item in saved], ['UNSUPPORTED_CHANNEL'])
609
+ self.assertIn('广告账号', saved[0]['remark'])
610
+ self.assertFalse(list(root.rglob('line-*')))
611
+
612
+
613
+ def test_real_fetch_ignores_project_channels_and_uses_account_channel_filters(self):
614
+ """项目渠道无效仍按账号真实渠道继续,维度成效只查询Google及TikTok。"""
615
+ with tempfile.TemporaryDirectory() as tmp:
616
+ root = Path(tmp)
617
+ args = Namespace(**{**vars(arguments(root / 'out')), 'id': 1, 'request_dir': tmp,
618
+ 'output_mode': 'database', 'curr_start': '2026-09-07', 'curr_end': '2026-09-13'})
619
+ calls = []
620
+ def api(command, body, out):
621
+ calls.append(command)
622
+ if command == ['biz-line', '+list']:
623
+ return {'ok': True, 'data': {'result': [{'id': 1, 'projectIds': [10, 20]}], 'total': 1}}
624
+ if command == ['biz-line', '+queryByPage']:
625
+ return {'ok': True, 'data': {'result': [
626
+ {'projectId': 10, 'lastWeekSpend': 1, 'channelIdList': ['unknown']},
627
+ {'projectId': 20, 'lastWeekSpend': 0}], 'total': 2}}
628
+ if command == ['biz-line', '+detail']:
629
+ return {'ok': True, 'data': {'id': 1}}
630
+ if command == ['biz-line', '+project-list']:
631
+ return {'ok': True, 'data': [
632
+ {'projectId': 10, 'projectName': '谷歌项目', 'adAccounts': [{'adAccountId': 'g', 'channelId': 102, 'channelName': None}]},
633
+ {'projectId': 20, 'projectName': '短视频项目', 'adAccounts': [{'adAccountId': 't', 'channelId': 103, 'channelName': 'Meta'}]}]}
634
+ if command == ['media', '+channels']:
635
+ return {'ok': True, 'data': [{'channelId': 102, 'channelName': 'Google'}, {'channelId': 103, 'channelName': 'TikTok'}]}
636
+ raise AssertionError(command)
637
+ with patch('fetch_data.run_cli', side_effect=api), patch('strategy_output.run_cli', side_effect=api), \
638
+ patch('fetch_data.fetch_paged', return_value=0) as metrics:
639
+ self.assertEqual(fetch_business(args)['id'], 1)
640
+ self.assertEqual(calls.count(['media', '+channels']), 1)
641
+ self.assertEqual({call.args[0] for call in metrics.call_args_list},
642
+ {'+google-dimension', '+google-metrics', '+tiktok-dimension', '+tiktok-metrics'})
643
+ for call in metrics.call_args_list:
644
+ self.assertEqual(call.args[1]['dimensionValues'], {'account_id': ['g' if 'google' in call.args[0] else 't']})
645
+ self.assertEqual(json.loads((root / 'projects.json').read_text(encoding='utf-8'))['allAccounts'],
646
+ {'fb': [], 'gg': ['g'], 'tt': ['t']})
647
+
648
+
649
+ class DatabaseWorkflow(unittest.TestCase):
650
+ """实际工作流导出真实 xlsx,仅替换外部 CLI;检查调用顺序与文件生命周期。"""
651
+
652
+ def run_fixture(self, root, *, failure=None, outcome=None):
653
+ args = arguments(root / 'desktop')
654
+ args.output_dir, args.output_mode = None, 'database'
655
+ payload = small_payload()
656
+ for key in ('budget', 'product', 'audience', 'creative'):
657
+ payload['rows'][0][key + 'Effect'] = 3
658
+ calls = []
659
+ self.fixture_counts = {'fetch': 0, 'analyze': 0}
660
+ self.fixture_review_events = []
661
+ self.fixture_ai_events = []
662
+ self.fixture_row = payload['rows'][0]
663
+ def fetch(local):
664
+ # 上下文写入时机与生产 fetch_business 一致。
665
+ self.fixture_counts['fetch'] += 1
666
+ (Path(local.request_dir) / 'save-context.json').write_text(json.dumps({
667
+ 'bizLineId': 1, 'periodStart': '2026-09-07', 'periodEnd': '2026-09-13',
668
+ }))
669
+ if outcome:
670
+ raise GenerationOutcome(outcome, 'fixture reason')
671
+ return {'id': 1, 'name': '匿名样本', 'adCategoryIndustryIds': [11]}
672
+ def api(command, body, out):
673
+ calls.append((command, body))
674
+ if command == ['biz-line', '+categories']:
675
+ if failure == 'review_identity_missing_other' and body['sceneCode'] == 'AUDIENCE_STRATEGY':
676
+ return {'ok': True, 'data': [{'id': len(calls), 'name': '受众具体标签'}]}
677
+ return {'ok': True, 'data': [{'id': len(calls), 'name': '其他'}]}
678
+ if command == ['common-file', '+upload']:
679
+ self.assertTrue(Path(body['file']).is_file())
680
+ self.assertEqual(body['serviceName'], 'sino-opti-hive')
681
+ with zipfile.ZipFile(body['file']) as workbook:
682
+ xml = ET.fromstring(workbook.read('xl/workbook.xml'))
683
+ ns = {'s': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
684
+ self.assertEqual([sheet.attrib['name'] for sheet in xml.findall('s:sheets/s:sheet', ns)],
685
+ ['策略', '明细', 'AI策略'])
686
+ if failure == 'upload':
687
+ return {'ok': False, 'error': 'upload failed'}
688
+ return {'ok': True, 'data': {'storageKey': '' if failure == 'empty_key' else 'saved.xlsx'}}
689
+ if command == ['biz-line-ai-strategy', '+ai-strategy-save']:
690
+ if body['generateCode'] == 'READY':
691
+ self.assertFalse((out.parent / 'final.xlsx').exists())
692
+ self.assertEqual(body['fileStorageKey'], 'saved.xlsx')
693
+ self.assertEqual(body['mediaStrategyEffect'], 2)
694
+ self.assertEqual(body['budgetStrategySupplement'], '- AI正文0。')
695
+ self.assertEqual(body['creativeStrategy'], '- AI正文3。')
696
+ self.assertEqual(body['budgetStrategyLearning'], 'AI说明0\n- KPI数据。')
697
+ if failure == 'save':
698
+ return {'ok': False, 'error': 'timeout'}
699
+ if failure == 'save_id':
700
+ return {'ok': True, 'data': None}
701
+ return {'ok': True, 'data': 123}
702
+ self.fail(f'未知请求 {command}')
703
+ def progress(event):
704
+ """通过真实交接文件提供离线 Agent 选择,验证保存前确实完成内容分析交接。"""
705
+ if event.get('event') == 'tag_review_required':
706
+ self.fixture_review_events.append(event)
707
+ request = json.loads(Path(event['request_path']).read_text(encoding='utf-8'))
708
+ selections = {key: [{'id': item['labels'][0]['id'], 'choiceKey': item['labels'][0]['choiceKey'],
709
+ 'reason': '离线样本字典仅有其他'}]
710
+ for key, item in request['strategies'].items()}
711
+ reply = {key: request[key] for key in ('bizLineId', 'attempt', 'reviewId')}
712
+ if failure in ('review_identity', 'review_identity_missing_other'):
713
+ reply['bizLineId'] += 1
714
+ if failure in ('review_identity', 'review_identity_missing_other'):
715
+ Path(event['response_path']).write_text(json.dumps({**reply, 'selections': selections}), encoding='utf-8')
716
+ else:
717
+ numbered = {key: [{'choiceKey': item['choiceKey'], 'reason': item['reason']} for item in items]
718
+ for key, items in selections.items()}
719
+ submit_tag_choices(event['request_path'], event['reviewId'], {'selections': numbered})
720
+ if event.get('event') == 'ai_strategy_required':
721
+ self.fixture_ai_events.append(event)
722
+ request = json.loads(Path(event['request_path']).read_text(encoding='utf-8'))
723
+ self.assertTrue(Path(request['workbookPath']).is_file())
724
+ with zipfile.ZipFile(request['workbookPath']) as source:
725
+ xml = ET.fromstring(source.read('xl/workbook.xml'))
726
+ ns = {'s': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
727
+ self.assertEqual([sheet.attrib['name'] for sheet in xml.findall('s:sheets/s:sheet', ns)], ['明细'])
728
+ self.assertIn('只依赖明细sheet,不要带入其他上下文', request['prompt'])
729
+ self.assertNotIn('payloadPath', request)
730
+ reply = {key: request[key] for key in ('bizLineId', 'attempt', 'requestId')}
731
+ self.assertIn('Learning', request['outputContract'])
732
+ reply['text'] = '一页速览\n- 样本数据。\n' + '\n'.join(
733
+ f'【{heading}】\n- AI正文{index}。\nLearning({label}):AI说明{index}\n- KPI数据。'
734
+ for index, (heading, label) in enumerate(zip(
735
+ ('预算策略', '广告产品策略', '受众策略', '创意策略'), ('有效', '无效', '待观察', '有效'))))
736
+ if failure == 'ai_learning' or (failure == 'ai_learning_then_valid' and event['reviewAttempt'] < 3):
737
+ reply['text'] = reply['text'].replace('Learning(有效)', 'Learning(不明)')
738
+ if failure == 'ai_identity':
739
+ reply['bizLineId'] += 1
740
+ Path(event['response_path']).write_text(json.dumps(reply, ensure_ascii=False), encoding='utf-8')
741
+ def analyze(_):
742
+ self.fixture_counts['analyze'] += 1
743
+ (_ / 'payload.json').write_text(json.dumps(payload, ensure_ascii=False), encoding='utf-8')
744
+ return payload, {'detail_count': 1, 'curr_total': 20, 'tag_evidence': {}}
745
+ with patch('run_workflow.fetch_business', fetch), patch('run_workflow.summarize_request', side_effect=analyze), patch('strategy_output.run_cli', api):
746
+ result = run_batch(args, progress=progress, temp_parent=root)
747
+ return result, calls
748
+
749
+ def test_success_upload_delete_save_without_desktop(self):
750
+ with tempfile.TemporaryDirectory() as tmp:
751
+ root = Path(tmp)
752
+ result, calls = self.run_fixture(root)
753
+ self.assertTrue(result['ok'])
754
+ self.assertEqual(result['results'][0]['aiStrategyId'], 123)
755
+ self.assertIsNone(result['results'][0]['output_path'])
756
+ self.assertEqual([c[0] for c in calls][-2:], [['common-file', '+upload'], ['biz-line-ai-strategy', '+ai-strategy-save']])
757
+ self.assertFalse(list(root.rglob('*.xlsx')))
758
+ # 审核完成后仍由业务线 worker 清理自己的目录,不留等待审核的临时文件。
759
+ self.assertTrue(result['results'][0]['cleanup']['ok'])
760
+ self.assertFalse(list(root.rglob('line-*')))
761
+ self.assertEqual(len(self.fixture_ai_events), 1)
762
+
763
+ def test_ai_rejected_three_times_does_not_restart_business_or_upload(self):
764
+ """AI 失败仅三次审核,整条业务线不进入批次重跑。"""
765
+ with tempfile.TemporaryDirectory() as tmp:
766
+ root = Path(tmp)
767
+ result, calls = self.run_fixture(root, failure='ai_identity')
768
+ self.assertFalse(result['ok'])
769
+ self.assertEqual(result['results'][0]['attempts'], 1)
770
+ self.assertEqual(self.fixture_counts, {'fetch': 1, 'analyze': 1})
771
+ self.assertEqual(len(self.fixture_ai_events), 3)
772
+ self.assertFalse(any(command == ['common-file', '+upload'] for command, _ in calls))
773
+ self.assertEqual(calls[-1][1]['generateCode'], 'GENERATION_FAILED')
774
+ self.assertFalse(list(root.rglob('line-*')))
775
+
776
+ def test_learning_format_failure_retries_only_ai_and_records_failure(self):
777
+ """三次非法 Learning 不上传,保存 GENERATION_FAILED,临时目录照常清理。"""
778
+ with tempfile.TemporaryDirectory() as tmp:
779
+ result, calls = self.run_fixture(Path(tmp), failure='ai_learning')
780
+ self.assertFalse(result['ok'])
781
+ self.assertEqual(self.fixture_counts, {'fetch': 1, 'analyze': 1})
782
+ self.assertEqual(len(self.fixture_review_events), 1)
783
+ self.assertEqual(len(self.fixture_ai_events), 3)
784
+ self.assertEqual(result['results'][0]['attempts'], 1)
785
+ self.assertEqual(calls[-1][1]['generateCode'], 'GENERATION_FAILED')
786
+ self.assertTrue(calls[-1][1]['remark'])
787
+ self.assertFalse(any(command == ['common-file', '+upload'] for command, _ in calls))
788
+ self.assertFalse(list(Path(tmp).rglob('line-*')))
789
+
790
+ def test_learning_third_reply_valid_without_refetch_or_retag(self):
791
+ """只重试文案,第三次修正后用 AI 正文与效果入库。"""
792
+ with tempfile.TemporaryDirectory() as tmp:
793
+ result, calls = self.run_fixture(Path(tmp), failure='ai_learning_then_valid')
794
+ self.assertTrue(result['ok'])
795
+ self.assertEqual(self.fixture_counts, {'fetch': 1, 'analyze': 1})
796
+ self.assertEqual(len(self.fixture_review_events), 1)
797
+ self.assertEqual(len(self.fixture_ai_events), 3)
798
+ self.assertEqual(calls[-1][1]['budgetStrategyEffect'], 1)
799
+
800
+ def test_sheet_tag_mismatch_is_checked_before_upload(self):
801
+ """工作簿读到其他业务线标签时预检即失败,不上传或提交 READY。"""
802
+ from build_report import read_ai_sheet
803
+ def foreign_tag(path):
804
+ cells = read_ai_sheet(path)
805
+ cells['预算策略标签'] = '其他业务线的标签'
806
+ return cells
807
+ with tempfile.TemporaryDirectory() as tmp, patch('run_workflow.read_ai_sheet', side_effect=foreign_tag):
808
+ result, calls = self.run_fixture(Path(tmp))
809
+ self.assertFalse(result['ok'])
810
+ self.assertFalse(any(command == ['common-file', '+upload'] for command, _ in calls))
811
+ self.assertTrue(all(body['generateCode'] == 'GENERATION_FAILED'
812
+ for command, body in calls if command == ['biz-line-ai-strategy', '+ai-strategy-save']))
813
+
814
+ def test_foreign_review_retries_only_tags_then_delivers_other(self):
815
+ """同行业错投两次只重选标签,不重新拉数,最终以其他标签交付。"""
816
+ with tempfile.TemporaryDirectory() as tmp:
817
+ root = Path(tmp)
818
+ result, calls = self.run_fixture(root, failure='review_identity')
819
+ self.assertTrue(result['ok'])
820
+ self.assertEqual(result['results'][0]['attempts'], 1)
821
+ self.assertEqual(self.fixture_counts, {'fetch': 1, 'analyze': 1})
822
+ self.assertEqual(len(self.fixture_review_events), 2)
823
+ self.assertEqual(sum(command == ['common-file', '+upload'] for command, _ in calls), 1)
824
+ self.assertEqual(calls[-1][1]['generateCode'], 'READY')
825
+ self.assertEqual(self.fixture_row['budgetTags'], '其他')
826
+ self.assertTrue(result['results'][0]['tag_review_fallback'])
827
+ self.assertFalse(list(root.rglob('line-*')))
828
+
829
+ def test_missing_other_keeps_strategy_and_learning_with_empty_tag(self):
830
+ """一个场景缺少其他仅令该场景标签为空,READY 仍保存完整策略。"""
831
+ with tempfile.TemporaryDirectory() as tmp:
832
+ result, calls = self.run_fixture(Path(tmp), failure='review_identity_missing_other')
833
+ self.assertTrue(result['ok'])
834
+ self.assertEqual(self.fixture_counts, {'fetch': 1, 'analyze': 1})
835
+ self.assertEqual(len(self.fixture_review_events), 2)
836
+ self.assertEqual(self.fixture_row['audienceTags'], '')
837
+ self.assertEqual(self.fixture_row['audienceTagIds'], [])
838
+ self.assertTrue(self.fixture_row['audienceStrategy'])
839
+ self.assertTrue(self.fixture_row['audienceLearning'])
840
+ body = calls[-1][1]
841
+ self.assertEqual(body['generateCode'], 'READY')
842
+ self.assertEqual(body['audienceStrategyTags'], [])
843
+ self.assertTrue(body['audienceStrategySupplement'])
844
+ self.assertTrue(body['audienceStrategyLearning'])
845
+ self.assertTrue(body['budgetStrategyTags'])
846
+ self.assertTrue(body['mediaStrategyTags'])
847
+ self.assertEqual(result['results'][0]['tag_review_fallback']['missingOtherScenes'], ['audience'])
848
+
849
+ def test_upload_errors_save_failure_status(self):
850
+ for failure in ('upload', 'empty_key'):
851
+ with self.subTest(failure=failure), tempfile.TemporaryDirectory() as tmp:
852
+ result, calls = self.run_fixture(Path(tmp), failure=failure)
853
+ self.assertFalse(result['ok'])
854
+ self.assertEqual(calls[-1][1]['generateCode'], 'GENERATION_FAILED')
855
+ self.assertNotIn('fileStorageKey', calls[-1][1])
856
+ self.assertEqual(result['results'][0]['attempts'], 3)
857
+ self.assertEqual(sum(c[0] == ['common-file', '+upload'] for c in calls), 3)
858
+
859
+ def test_save_failure_preserves_key_and_never_writes_again(self):
860
+ for failure in ('save', 'save_id'):
861
+ with self.subTest(failure=failure), tempfile.TemporaryDirectory() as tmp:
862
+ result, calls = self.run_fixture(Path(tmp), failure=failure)
863
+ self.assertFalse(result['ok'])
864
+ self.assertEqual(sum(c[0] == ['biz-line-ai-strategy', '+ai-strategy-save'] for c in calls), 1)
865
+ self.assertEqual(result['results'][0]['storageKey'], 'saved.xlsx')
866
+ self.assertTrue(result['results'][0]['save_result_unknown'])
867
+ self.assertFalse(list(Path(tmp).rglob('*.xlsx')))
868
+
869
+ def test_failed_business_status_save_is_not_replayed(self):
870
+ """不可生成状态保存超时同样可能已落库,不整条重跑。"""
871
+ with tempfile.TemporaryDirectory() as tmp:
872
+ result, calls = self.run_fixture(Path(tmp), failure='save', outcome='NO_PROJECT')
873
+ self.assertFalse(result['ok'])
874
+ self.assertTrue(result['results'][0]['save_result_unknown'])
875
+ self.assertEqual(result['results'][0]['attempts'], 1)
876
+ self.assertEqual(len(calls), 1)
877
+
878
+ def test_business_outcomes_saved_without_excel_or_upload(self):
879
+ for code in ('NO_PROJECT', 'UNSUPPORTED_CHANNEL', 'NO_AD_ACCOUNT', 'NO_DELIVERY_DATA'):
880
+ with self.subTest(code=code), tempfile.TemporaryDirectory() as tmp:
881
+ result, calls = self.run_fixture(Path(tmp), outcome=code)
882
+ self.assertTrue(result['ok'])
883
+ self.assertEqual(len(calls), 1)
884
+ self.assertEqual(calls[0][1]['generateCode'], code)
885
+ self.assertFalse(list(Path(tmp).rglob('*.xlsx')))
886
+
887
+ def test_excel_delete_failure_is_reported(self):
888
+ """模拟文件被占用,不能宣称清理成功;测试结束后由临时目录清理样本。"""
889
+ real_unlink = Path.unlink
890
+ def locked(path, *args, **kwargs):
891
+ if path.name == 'final.xlsx':
892
+ raise PermissionError('fixture file locked')
893
+ return real_unlink(path, *args, **kwargs)
894
+ with tempfile.TemporaryDirectory() as tmp:
895
+ with patch.object(Path, 'unlink', locked):
896
+ result, calls = self.run_fixture(Path(tmp))
897
+ self.assertFalse(result['ok'])
898
+ self.assertTrue(result['results'][0]['cleanup_failed'])
899
+ self.assertIn('fixture file locked', str(result))
900
+ self.assertEqual(calls[-1][1]['generateCode'], 'GENERATION_FAILED')
901
+
902
+
903
+ if __name__ == '__main__':
904
+ unittest.main()