diff --git a/scripts/shot_hel174.py b/scripts/shot_hel174.py
new file mode 100644
index 0000000..ba13085
--- /dev/null
+++ b/scripts/shot_hel174.py
@@ -0,0 +1,186 @@
+#!/usr/bin/env python3
+"""HEL-174: 360 / 820 / 1440 截图——待确认黄 vs 已完成绿。"""
+
+from __future__ import annotations
+
+import re
+import threading
+from functools import partial
+from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+
+from playwright.sync_api import sync_playwright
+
+ROOT = Path(__file__).resolve().parents[1]
+WEB = ROOT / "web"
+OUT = Path(__file__).resolve().parent.parent / "hel174-shots"
+OUT.mkdir(parents=True, exist_ok=True)
+
+VIEWPORTS = [
+ (1440, 900, "1440"),
+ (820, 900, "820"),
+ (360, 800, "360"),
+]
+
+
+def extract_fn(source: str, name: str) -> str:
+ marker = f"function {name}("
+ start = source.index(marker)
+ depth = 0
+ for i, ch in enumerate(source[start:], start):
+ if ch == "{":
+ depth += 1
+ elif ch == "}":
+ depth -= 1
+ if depth == 0:
+ return source[start : i + 1]
+ raise RuntimeError(name)
+
+
+def main() -> None:
+ app_js = (WEB / "app.js").read_text(encoding="utf-8")
+ inject = "\n".join(
+ [
+ "const state = {};",
+ "function $(sel, root) { return (root || document).querySelector(sel); }",
+ "function $$(sel, root) { return Array.from((root || document).querySelectorAll(sel)); }",
+ extract_fn(app_js, "formatWorkspaceAmount"),
+ extract_fn(app_js, "applyCompanyWorkspace"),
+ ]
+ )
+
+ handler = partial(SimpleHTTPRequestHandler, directory=str(WEB))
+ httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler)
+ port = httpd.server_address[1]
+ thread = threading.Thread(target=httpd.serve_forever, daemon=True)
+ thread.start()
+ base = f"http://127.0.0.1:{port}"
+
+ html = f"""
+
+
+
+
+HEL-174 往来确认状态色
+
+
+
+
+
状态预览
+
+
+
+
+ 账期流程 · 2026-07…
+
+
+
+
+
+ 待确认
+ 已确认
+
+
0
+
+
+"""
+
+ with sync_playwright() as p:
+ browser = p.chromium.launch(headless=True, args=["--no-sandbox"])
+ page = browser.new_page()
+ for width, height, tag in VIEWPORTS:
+ page.set_viewport_size({"width": width, "height": height})
+ page.set_content(html, wait_until="domcontentloaded")
+ page.add_script_tag(content=inject)
+
+ page.evaluate(
+ """() => {
+ document.getElementById('shotLabel').textContent = '待确认(应黄)';
+ applyCompanyWorkspace({
+ pending_unilateral: 3, pending_total: 3,
+ unilateral_events: [
+ {event_id:1, amount:'100000.00', currency:'CNY',
+ counterparty_company_name:'金牛贸易', effective_at:'2026-07-03'},
+ {event_id:2, amount:'200000.00', currency:'CNY',
+ counterparty_company_name:'金牛物流', effective_at:'2026-07-11'},
+ {event_id:3, amount:'300000.00', currency:'CNY',
+ counterparty_company_name:'金牛置业', effective_at:'2026-07-24'}
+ ]
+ });
+ }"""
+ )
+ page.wait_for_timeout(200)
+ page.screenshot(path=str(OUT / f"hel174-pending-{tag}.png"), full_page=True)
+
+ page.evaluate(
+ """() => {
+ document.getElementById('shotLabel').textContent = '已完成(应绿)';
+ applyCompanyWorkspace({
+ pending_unilateral: 0, pending_total: 0, unilateral_events: []
+ });
+ }"""
+ )
+ page.wait_for_timeout(200)
+ page.screenshot(path=str(OUT / f"hel174-done-{tag}.png"), full_page=True)
+
+ colors = page.evaluate(
+ """() => {
+ const step = document.querySelector('.flow-step[data-view-link="reconcile"]');
+ const state = document.getElementById('workspaceConfirmState');
+ const pill = document.getElementById('workspacePendingStatus');
+ return {
+ stepClass: step.className,
+ stateText: state.textContent,
+ stateColor: getComputedStyle(state).color,
+ pillClass: pill.className,
+ overflowX: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
+ };
+ }"""
+ )
+ assert "done" in colors["stepClass"], colors
+ assert "pill-success" in colors["pillClass"], colors
+ assert colors["stateText"] == "已完成", colors
+ assert not colors["overflowX"], colors
+ print(tag, colors)
+
+ browser.close()
+ httpd.shutdown()
+ print("shots:", sorted(p.name for p in OUT.glob("*.png")))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/test_company_confirm_status_color.py b/tests/test_company_confirm_status_color.py
new file mode 100644
index 0000000..1165a56
--- /dev/null
+++ b/tests/test_company_confirm_status_color.py
@@ -0,0 +1,210 @@
+"""HEL-174: 公司端往来确认完成态用 success 绿,待确认保留 warn 黄。"""
+
+from __future__ import annotations
+
+import re
+import threading
+import unittest
+from functools import partial
+from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+WEB = ROOT / "web"
+
+try:
+ from playwright.sync_api import sync_playwright
+except ImportError: # pragma: no cover - 默认测试环境无浏览器依赖
+ sync_playwright = None
+
+
+class ConfirmStatusSourceContractTests(unittest.TestCase):
+ """不依赖浏览器:锁住完成态切绿 / 待确认仍黄的实现契约。"""
+
+ def test_app_js_toggles_success_green_when_pending_zero(self) -> None:
+ js = (WEB / "app.js").read_text(encoding="utf-8")
+ self.assertIn('pill ${total ? "pill-warn" : "pill-success"}', js)
+ self.assertIn('flowStep.classList.toggle("doing", pending > 0)', js)
+ self.assertIn('flowStep.classList.toggle("done", pending === 0)', js)
+ self.assertIn('notice.classList.toggle("warn", pending > 0)', js)
+ self.assertIn('notice.classList.toggle("success", pending === 0)', js)
+ # 文案:完成=已完成,不把待确认一并改绿
+ self.assertRegex(js, r'status\.textContent = total \? `\$\{total\} 项待处理` : "已完成"')
+ self.assertRegex(js, r'flowState\.textContent = pending \? `待处理 \$\{pending\} 笔` : "已完成"')
+
+ def test_design_tokens_map_done_to_success_doing_to_warn(self) -> None:
+ css = (WEB / "design-system.css").read_text(encoding="utf-8")
+ self.assertIn("--success:", css)
+ self.assertIn("--warn:", css)
+ self.assertIn(".flow-step.done .fs-dot { background: var(--success); }", css)
+ self.assertIn(".flow-step.done .fs-state { color: var(--success); }", css)
+ self.assertIn(".flow-step.doing .fs-dot { background: var(--warn);", css)
+ self.assertIn(".pill-success { background: var(--success-soft); color: var(--success); }", css)
+ self.assertIn(".pill-warn { background: var(--warn-soft);", css)
+ self.assertIn(".notice.success { background: var(--success-soft);", css)
+ self.assertIn(".notice.warn { background: var(--warn-soft);", css)
+
+ def test_company_html_exposes_flow_step_and_cache_bust(self) -> None:
+ html = (WEB / "company.html").read_text(encoding="utf-8")
+ self.assertIn('id="workspaceConfirmState"', html)
+ self.assertIn('id="workspacePendingStatus"', html)
+ self.assertIn('id="workspaceFlowSub"', html)
+ self.assertIn('data-view-link="reconcile"', html)
+ self.assertIn("app.js?v=11", html)
+ # 静态初值仍为进行中(黄),由 JS 在 pending=0 时切 done
+ self.assertRegex(html, r'class="flow-step doing"[^>]*data-view-link="reconcile"')
+
+
+def _extract_fn(source: str, name: str) -> str:
+ marker = f"function {name}("
+ start = source.index(marker)
+ depth = 0
+ for i, ch in enumerate(source[start:], start):
+ if ch == "{":
+ depth += 1
+ elif ch == "}":
+ depth -= 1
+ if depth == 0:
+ return source[start : i + 1]
+ raise AssertionError(f"未能截取 function {name}")
+
+
+@unittest.skipUnless(sync_playwright, "playwright 未安装,跳过真实 DOM 色值校验")
+class ConfirmStatusDomTests(unittest.TestCase):
+ """真实浏览器:pending>0 为黄,pending=0 为绿。"""
+
+ @classmethod
+ def setUpClass(cls) -> None:
+ handler = partial(SimpleHTTPRequestHandler, directory=str(WEB))
+ cls.httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler)
+ cls.port = cls.httpd.server_address[1]
+ cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
+ cls.thread.start()
+ cls.base = f"http://127.0.0.1:{cls.port}"
+ app_js = (WEB / "app.js").read_text(encoding="utf-8")
+ cls.inject_js = "\n".join(
+ [
+ "const state = {};",
+ "function $(sel, root) { return (root || document).querySelector(sel); }",
+ "function $$(sel, root) { return Array.from((root || document).querySelectorAll(sel)); }",
+ _extract_fn(app_js, "formatWorkspaceAmount"),
+ _extract_fn(app_js, "applyCompanyWorkspace"),
+ ]
+ )
+
+ @classmethod
+ def tearDownClass(cls) -> None:
+ cls.httpd.shutdown()
+ cls.httpd.server_close()
+
+ def _open_fixture(self, page):
+ page.goto(f"{self.base}/design-system.css", wait_until="domcontentloaded")
+ page.set_content(
+ f"""
+
+
+
+
+
+
+
+
+
+ 0
+
+""",
+ wait_until="domcontentloaded",
+ )
+ page.add_script_tag(content=self.inject_js)
+ page.wait_for_function("() => typeof applyCompanyWorkspace === 'function'")
+
+ @staticmethod
+ def _colors(page) -> dict:
+ return page.evaluate(
+ """() => {
+ const step = document.querySelector('.flow-step[data-view-link="reconcile"]');
+ const state = document.getElementById('workspaceConfirmState');
+ const pill = document.getElementById('workspacePendingStatus');
+ const notice = document.getElementById('blocking-notice');
+ const cs = (el) => getComputedStyle(el);
+ return {
+ stepClass: step.className,
+ stateText: state.textContent,
+ stateColor: cs(state).color,
+ pillClass: pill.className,
+ pillColor: cs(pill).color,
+ pillText: pill.textContent,
+ noticeClass: notice.className,
+ };
+ }"""
+ )
+
+ def test_pending_stays_warn_completed_turns_success(self) -> None:
+ with sync_playwright() as p:
+ browser = p.chromium.launch(headless=True, args=["--no-sandbox"])
+ page = browser.new_page(viewport={"width": 1440, "height": 900})
+ self._open_fixture(page)
+
+ page.evaluate(
+ """() => applyCompanyWorkspace({
+ pending_unilateral: 2,
+ pending_total: 2,
+ unilateral_events: [
+ {event_id: 1, amount: '100.00', currency: 'CNY',
+ counterparty_company_name: '乙', effective_at: '2026-07-01'}
+ ]
+ })"""
+ )
+ pending = self._colors(page)
+ self.assertIn("doing", pending["stepClass"])
+ self.assertNotIn("done", pending["stepClass"].split())
+ self.assertIn("pill-warn", pending["pillClass"])
+ self.assertNotIn("pill-success", pending["pillClass"])
+ self.assertIn("warn", pending["noticeClass"].split())
+ self.assertIn("待处理", pending["stateText"])
+ self.assertIn("待处理", pending["pillText"])
+
+ page.evaluate(
+ """() => applyCompanyWorkspace({
+ pending_unilateral: 0,
+ pending_total: 0,
+ unilateral_events: []
+ })"""
+ )
+ done = self._colors(page)
+ self.assertIn("done", done["stepClass"])
+ self.assertNotIn("doing", done["stepClass"].split())
+ self.assertIn("pill-success", done["pillClass"])
+ self.assertNotIn("pill-warn", done["pillClass"])
+ self.assertIn("success", done["noticeClass"].split())
+ self.assertEqual("已完成", done["stateText"])
+ self.assertEqual("已完成", done["pillText"])
+
+ self.assertNotEqual(pending["stateColor"], done["stateColor"])
+ self.assertNotEqual(pending["pillColor"], done["pillColor"])
+ browser.close()
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/web/app.js b/web/app.js
index 0e04cf5..8e9561e 100644
--- a/web/app.js
+++ b/web/app.js
@@ -2619,6 +2619,18 @@ function applyCompanyWorkspace(payload) {
? `单边流水 ${pending} 笔待确认`
: "已全部确认,等待集团结账";
}
+ // 完成态必须切到 success 绿(.flow-step.done),待确认保留 warn 黄(.doing)
+ const flowStep = flowState?.closest(".flow-step");
+ if (flowStep) {
+ flowStep.classList.toggle("doing", pending > 0);
+ flowStep.classList.toggle("done", pending === 0);
+ }
+ const flowSub = $("#workspaceFlowSub");
+ if (flowSub) {
+ flowSub.textContent = pending
+ ? "当前停在第 3 步「往来确认」,完成后即可等待集团结账"
+ : "第 3 步「往来确认」已完成,等待集团复核与结账";
+ }
const countMatch = $("#count-match");
if (countMatch) countMatch.textContent = String(pending);
diff --git a/web/company.html b/web/company.html
index 85bf2cf..a52069f 100644
--- a/web/company.html
+++ b/web/company.html
@@ -63,7 +63,7 @@
- 账期流程 · 2026-07当前停在第 3 步「往来确认」,完成后即可等待集团结账
+ 账期流程 · 2026-07当前停在第 3 步「往来确认」,完成后即可等待集团结账
结账日顺延至 08-29
@@ -814,6 +814,6 @@
-
+