HEL-203: 修复原因弹窗作用域、首屏断档与表单 reset
将 openModal/closeModal 提升为顶层唯一定义,公司端启动即加载断档提醒, 并在 await 前提取表单引用;补充 Playwright 两端冒烟与源码契约测试。 Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
co-authored by
Cursor
multica-agent
parent
27f2b0b69a
commit
fe6b4e59ec
@@ -1,6 +1,7 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
.venv/
|
.venv/
|
||||||
|
.chrome-libs/
|
||||||
.tmp-*/
|
.tmp-*/
|
||||||
|
|
||||||
# 运行时数据与真实银行文件一律不进仓库(样本仅限流水模板/中已脱敏的六份)
|
# 运行时数据与真实银行文件一律不进仓库(样本仅限流水模板/中已脱敏的六份)
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ class ConfirmStatusSourceContractTests(unittest.TestCase):
|
|||||||
self.assertIn('id="workspacePendingStatus"', html)
|
self.assertIn('id="workspacePendingStatus"', html)
|
||||||
self.assertIn('id="workspaceFlowSub"', html)
|
self.assertIn('id="workspaceFlowSub"', html)
|
||||||
self.assertIn('data-view-link="reconcile"', html)
|
self.assertIn('data-view-link="reconcile"', html)
|
||||||
self.assertIn("app.js?v=12", html)
|
self.assertIn("app.js?v=13", html)
|
||||||
# 静态初值仍为进行中(黄),由 JS 在 pending=0 时切 done
|
# 静态初值仍为进行中(黄),由 JS 在 pending=0 时切 done
|
||||||
self.assertRegex(html, r'class="flow-step doing"[^>]*data-view-link="reconcile"')
|
self.assertRegex(html, r'class="flow-step doing"[^>]*data-view-link="reconcile"')
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class TransfersPageSourceContractTests(unittest.TestCase):
|
|||||||
self.assertIn("期间净变动", html)
|
self.assertIn("期间净变动", html)
|
||||||
self.assertNotIn("本公司往来合计", html)
|
self.assertNotIn("本公司往来合计", html)
|
||||||
self.assertIn("design-system.css?v=6", html)
|
self.assertIn("design-system.css?v=6", html)
|
||||||
self.assertIn("app.js?v=12", html)
|
self.assertIn("app.js?v=13", html)
|
||||||
# 侧栏顺序:流水管理 → 转账往来 → 往来确认
|
# 侧栏顺序:流水管理 → 转账往来 → 往来确认
|
||||||
flows = html.index('data-view="flows"')
|
flows = html.index('data-view="flows"')
|
||||||
transfers = html.index('data-view="transfers"')
|
transfers = html.index('data-view="transfers"')
|
||||||
@@ -113,7 +113,7 @@ class TransfersPageLayoutSmokeTests(unittest.TestCase):
|
|||||||
for width in (360, 820, 1440):
|
for width in (360, 820, 1440):
|
||||||
page.set_viewport_size({"width": width, "height": 900})
|
page.set_viewport_size({"width": width, "height": 900})
|
||||||
page.set_content(
|
page.set_content(
|
||||||
html.replace('src="app.js?v=12"', 'src=""'),
|
html.replace('src="app.js?v=13"', 'src=""'),
|
||||||
base_url=self.base,
|
base_url=self.base,
|
||||||
)
|
)
|
||||||
page.evaluate(
|
page.evaluate(
|
||||||
|
|||||||
@@ -0,0 +1,518 @@
|
|||||||
|
"""HEL-203: 真实浏览器冒烟——原因弹窗、首屏断档、表单 reset。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from bank_importer import auth, calculation, matching, master_data
|
||||||
|
from bank_importer.db import connect, migrate, utc_now
|
||||||
|
|
||||||
|
import server
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
WEB = ROOT / "web"
|
||||||
|
ADMIN_PASSWORD = "AdminPass123"
|
||||||
|
CASHIER_PASSWORD = "CashierA123"
|
||||||
|
|
||||||
|
try:
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
sync_playwright = None
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_chrome_libs() -> Path | None:
|
||||||
|
"""本机缺系统 atk 时,复用仓库旁的本地 chromium 依赖目录。"""
|
||||||
|
candidates = [ROOT / ".chrome-libs" / "lib"]
|
||||||
|
for lib_dir in candidates:
|
||||||
|
if (lib_dir / "libatk-1.0.so.0").exists():
|
||||||
|
current = os.environ.get("LD_LIBRARY_PATH", "")
|
||||||
|
prefix = str(lib_dir)
|
||||||
|
if prefix not in current.split(":"):
|
||||||
|
os.environ["LD_LIBRARY_PATH"] = (
|
||||||
|
f"{prefix}:{current}" if current else prefix
|
||||||
|
)
|
||||||
|
return lib_dir
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _chromium_available() -> bool:
|
||||||
|
if not sync_playwright:
|
||||||
|
return False
|
||||||
|
_prepare_chrome_libs()
|
||||||
|
try:
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=True, args=["--no-sandbox"])
|
||||||
|
browser.close()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class Hel203SourceContractTests(unittest.TestCase):
|
||||||
|
"""不依赖浏览器:锁住 N1~N3 的源码契约。"""
|
||||||
|
|
||||||
|
def test_open_modal_is_single_top_level(self) -> None:
|
||||||
|
js = (WEB / "app.js").read_text(encoding="utf-8")
|
||||||
|
defs = list(re.finditer(r"(?m)^function openModal\(", js))
|
||||||
|
self.assertEqual(1, len(defs), "openModal 必须只有一处顶层定义")
|
||||||
|
# 不得再出现在 initAdmin / initCompany 函数体内的局部副本
|
||||||
|
self.assertNotRegex(
|
||||||
|
js,
|
||||||
|
r"function initAdmin\(\)[\s\S]*?function openModal\(",
|
||||||
|
)
|
||||||
|
self.assertNotRegex(
|
||||||
|
js,
|
||||||
|
r"function initCompany\(\)[\s\S]*?function openModal\(",
|
||||||
|
)
|
||||||
|
self.assertIn("function askReason(", js)
|
||||||
|
ask_pos = js.index("function askReason(")
|
||||||
|
open_pos = defs[0].start()
|
||||||
|
self.assertLess(open_pos, ask_pos, "openModal 须在 askReason 之前定义")
|
||||||
|
|
||||||
|
def test_init_company_boots_coverage_gaps(self) -> None:
|
||||||
|
js = (WEB / "app.js").read_text(encoding="utf-8")
|
||||||
|
company_fn = js[js.index("function initCompany(") :]
|
||||||
|
boot = company_fn[: company_fn.index("\nif (portal ===")]
|
||||||
|
self.assertIn("await loadCompanyWorkspace()", boot)
|
||||||
|
self.assertIn("await loadCompanyCoverageGaps()", boot)
|
||||||
|
|
||||||
|
def test_async_forms_capture_form_before_await(self) -> None:
|
||||||
|
js = (WEB / "app.js").read_text(encoding="utf-8")
|
||||||
|
self.assertNotIn("event.currentTarget.reset()", js)
|
||||||
|
for marker in ("#companyForm", "#openingForm", "#accountForm"):
|
||||||
|
idx = js.index(marker)
|
||||||
|
chunk = js[idx : idx + 2500]
|
||||||
|
self.assertIn("const form = event.currentTarget", chunk)
|
||||||
|
self.assertIn("form.reset()", chunk)
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(sync_playwright, "playwright 未安装,跳过浏览器冒烟")
|
||||||
|
@unittest.skipUnless(_chromium_available(), "chromium 无法启动,跳过浏览器冒烟")
|
||||||
|
class Hel203BrowserSmokeTests(unittest.TestCase):
|
||||||
|
"""真实 Chromium:改起算日→期初→公司端期末;断档首屏→说明→审核。"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls) -> None:
|
||||||
|
_prepare_chrome_libs()
|
||||||
|
cls.temp_dir = tempfile.TemporaryDirectory()
|
||||||
|
root = Path(cls.temp_dir.name)
|
||||||
|
cls.db_path = root / "app.db"
|
||||||
|
cls.storage = root / "files"
|
||||||
|
cls.storage.mkdir()
|
||||||
|
|
||||||
|
cls._old_db = server.DB_PATH
|
||||||
|
cls._old_storage = server.STORAGE_DIR
|
||||||
|
server.DB_PATH = cls.db_path
|
||||||
|
server.STORAGE_DIR = cls.storage
|
||||||
|
|
||||||
|
connection = connect(cls.db_path)
|
||||||
|
migrate(connection)
|
||||||
|
auth.create_user(
|
||||||
|
connection,
|
||||||
|
"group-admin",
|
||||||
|
ADMIN_PASSWORD,
|
||||||
|
"admin",
|
||||||
|
must_change_password=False,
|
||||||
|
)
|
||||||
|
cls.company_a = master_data.create_company(
|
||||||
|
connection, "甲公司", None, None, None
|
||||||
|
)
|
||||||
|
cls.company_b = master_data.create_company(
|
||||||
|
connection, "乙公司", None, None, None
|
||||||
|
)
|
||||||
|
auth.create_user(
|
||||||
|
connection,
|
||||||
|
"cashier-a",
|
||||||
|
CASHIER_PASSWORD,
|
||||||
|
"company",
|
||||||
|
cls.company_a,
|
||||||
|
must_change_password=False,
|
||||||
|
)
|
||||||
|
admin = connection.execute(
|
||||||
|
"SELECT * FROM users WHERE username = 'group-admin'"
|
||||||
|
).fetchone()
|
||||||
|
account = master_data.submit_bank_account(
|
||||||
|
connection,
|
||||||
|
company_id=cls.company_a,
|
||||||
|
bank_name="中信银行",
|
||||||
|
account_type="基本户",
|
||||||
|
account_number="6222000000000001",
|
||||||
|
start_date="2026-06-01",
|
||||||
|
actor=None,
|
||||||
|
)
|
||||||
|
cls.account_a = master_data.review_bank_account(
|
||||||
|
connection,
|
||||||
|
account["id"],
|
||||||
|
"approve",
|
||||||
|
None,
|
||||||
|
admin,
|
||||||
|
effective_from="2026-06-01",
|
||||||
|
)
|
||||||
|
account_b = master_data.submit_bank_account(
|
||||||
|
connection,
|
||||||
|
company_id=cls.company_b,
|
||||||
|
bank_name="中信银行",
|
||||||
|
account_type="基本户",
|
||||||
|
account_number="6222000000000002",
|
||||||
|
start_date="2026-06-01",
|
||||||
|
actor=None,
|
||||||
|
)
|
||||||
|
master_data.review_bank_account(
|
||||||
|
connection,
|
||||||
|
account_b["id"],
|
||||||
|
"approve",
|
||||||
|
None,
|
||||||
|
admin,
|
||||||
|
effective_from="2026-06-01",
|
||||||
|
)
|
||||||
|
# 制造一处 mid 断档,供公司端首屏提醒
|
||||||
|
with connection:
|
||||||
|
cursor = connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO source_files
|
||||||
|
(sha256, original_filename, size_bytes, storage_path, created_at)
|
||||||
|
VALUES (?, 'gap.xlsx', 1, 'data/files/gap.xlsx', ?)
|
||||||
|
""",
|
||||||
|
("sha-hel203-gap", utc_now()),
|
||||||
|
)
|
||||||
|
source_file_id = int(cursor.lastrowid)
|
||||||
|
cursor = connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO import_batches (
|
||||||
|
source_file_id, status, company_id, upload_bank_account_id,
|
||||||
|
created_at, updated_at
|
||||||
|
) VALUES (?, 'parsed', ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
source_file_id,
|
||||||
|
cls.company_a,
|
||||||
|
cls.account_a["id"],
|
||||||
|
utc_now(),
|
||||||
|
utc_now(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
batch_id = int(cursor.lastrowid)
|
||||||
|
cursor = connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO sheet_batches (
|
||||||
|
import_batch_id, sheet_name, bank_name, template_id,
|
||||||
|
template_version, header_row, transaction_count, warnings,
|
||||||
|
created_at
|
||||||
|
) VALUES (?, '流水', '测试银行', 'test-v1', 1, 1, 1, '[]', ?)
|
||||||
|
""",
|
||||||
|
(batch_id, utc_now()),
|
||||||
|
)
|
||||||
|
sheet_batch_id = int(cursor.lastrowid)
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO sheet_reviews (
|
||||||
|
import_batch_id, sheet_name, outcome, sheet_batch_id,
|
||||||
|
review_status, created_at
|
||||||
|
) VALUES (?, '流水', 'parsed', ?, 'confirmed', ?)
|
||||||
|
""",
|
||||||
|
(batch_id, sheet_batch_id, utc_now()),
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO source_rows (
|
||||||
|
sheet_batch_id, source_row, transaction_at, income, expense,
|
||||||
|
own_account, created_at
|
||||||
|
) VALUES (?, 1, '2026-06-21T10:00:00', '0', '0', ?, ?)
|
||||||
|
""",
|
||||||
|
(sheet_batch_id, "6222000000000001", utc_now()),
|
||||||
|
)
|
||||||
|
calculation.set_calculation_start_date(
|
||||||
|
connection, "2026-06-01", "初始化起算", admin
|
||||||
|
)
|
||||||
|
calculation.recalculate_coverage_gaps(connection)
|
||||||
|
|
||||||
|
# 种一笔已确认往来,确认期初后公司端才能进入完整期末口径
|
||||||
|
def _add_row(company_id, account_id, own, cp, *, income, expense, at, ref):
|
||||||
|
with connection:
|
||||||
|
cursor = connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO source_files
|
||||||
|
(sha256, original_filename, size_bytes, storage_path, created_at)
|
||||||
|
VALUES (?, 'xfer.xlsx', 1, 'data/files/xfer.xlsx', ?)
|
||||||
|
""",
|
||||||
|
(ref, utc_now()),
|
||||||
|
)
|
||||||
|
source_file_id = int(cursor.lastrowid)
|
||||||
|
cursor = connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO import_batches (
|
||||||
|
source_file_id, status, company_id, upload_bank_account_id,
|
||||||
|
created_at, updated_at
|
||||||
|
) VALUES (?, 'parsed', ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(source_file_id, company_id, account_id, utc_now(), utc_now()),
|
||||||
|
)
|
||||||
|
batch_id = int(cursor.lastrowid)
|
||||||
|
cursor = connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO sheet_batches (
|
||||||
|
import_batch_id, sheet_name, bank_name, template_id,
|
||||||
|
template_version, header_row, transaction_count, warnings,
|
||||||
|
created_at
|
||||||
|
) VALUES (?, '流水', '测试银行', 'test-v1', 1, 1, 1, '[]', ?)
|
||||||
|
""",
|
||||||
|
(batch_id, utc_now()),
|
||||||
|
)
|
||||||
|
sheet_batch_id = int(cursor.lastrowid)
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO sheet_reviews (
|
||||||
|
import_batch_id, sheet_name, outcome, sheet_batch_id,
|
||||||
|
review_status, created_at
|
||||||
|
) VALUES (?, '流水', 'parsed', ?, 'confirmed', ?)
|
||||||
|
""",
|
||||||
|
(batch_id, sheet_batch_id, utc_now()),
|
||||||
|
)
|
||||||
|
cursor = connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO source_rows (
|
||||||
|
sheet_batch_id, source_row, transaction_at, income, expense,
|
||||||
|
own_account, own_name, counterparty_account, counterparty_name,
|
||||||
|
summary, purpose, currency, created_at
|
||||||
|
) VALUES (?, 1, ?, ?, ?, ?, '测试', ?, '对方', '往来', '往来款', 'CNY', ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
sheet_batch_id,
|
||||||
|
at,
|
||||||
|
income,
|
||||||
|
expense,
|
||||||
|
own,
|
||||||
|
cp,
|
||||||
|
utc_now(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return int(cursor.lastrowid)
|
||||||
|
|
||||||
|
row_a = _add_row(
|
||||||
|
cls.company_a,
|
||||||
|
cls.account_a["id"],
|
||||||
|
"6222000000000001",
|
||||||
|
"6222000000000002",
|
||||||
|
income="0",
|
||||||
|
expense="100.00",
|
||||||
|
at="2026-06-20T10:00:00",
|
||||||
|
ref="sha-hel203-a",
|
||||||
|
)
|
||||||
|
row_b = _add_row(
|
||||||
|
cls.company_b,
|
||||||
|
account_b["id"],
|
||||||
|
"6222000000000002",
|
||||||
|
"6222000000000001",
|
||||||
|
income="100.00",
|
||||||
|
expense="0",
|
||||||
|
at="2026-06-20T11:00:00",
|
||||||
|
ref="sha-hel203-b",
|
||||||
|
)
|
||||||
|
matching.reconcile_rows(connection, [row_a, row_b])
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
class QuietHandler(server.AppHandler):
|
||||||
|
def log_message(self, *args) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
cls.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
|
||||||
|
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}"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls) -> None:
|
||||||
|
cls.httpd.shutdown()
|
||||||
|
cls.httpd.server_close()
|
||||||
|
server.DB_PATH = cls._old_db
|
||||||
|
server.STORAGE_DIR = cls._old_storage
|
||||||
|
cls.temp_dir.cleanup()
|
||||||
|
|
||||||
|
def _new_page(self, playwright):
|
||||||
|
browser = playwright.chromium.launch(
|
||||||
|
headless=True, args=["--no-sandbox", "--disable-dev-shm-usage"]
|
||||||
|
)
|
||||||
|
context = browser.new_context(viewport={"width": 1440, "height": 900})
|
||||||
|
page = context.new_page()
|
||||||
|
errors: list[str] = []
|
||||||
|
page.on("pageerror", lambda err: errors.append(str(err)))
|
||||||
|
page.on(
|
||||||
|
"console",
|
||||||
|
lambda msg: errors.append(f"console.{msg.type}: {msg.text}")
|
||||||
|
if msg.type == "error"
|
||||||
|
else None,
|
||||||
|
)
|
||||||
|
return browser, page, errors
|
||||||
|
|
||||||
|
def _login(self, page, *, portal: str, username: str, password: str) -> None:
|
||||||
|
login_path = "login-admin.html" if portal == "admin" else "login-company.html"
|
||||||
|
page.goto(f"{self.base}/{login_path}", wait_until="domcontentloaded")
|
||||||
|
page.fill("#account", username)
|
||||||
|
page.fill("#password", password)
|
||||||
|
page.click('button[type="submit"]')
|
||||||
|
expect = "admin.html" if portal == "admin" else "company.html"
|
||||||
|
page.wait_for_url(f"**/{expect}", timeout=15000)
|
||||||
|
|
||||||
|
def test_02_admin_start_date_opening_company_ending(self) -> None:
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser, page, errors = self._new_page(p)
|
||||||
|
try:
|
||||||
|
self._login(
|
||||||
|
page,
|
||||||
|
portal="admin",
|
||||||
|
username="group-admin",
|
||||||
|
password=ADMIN_PASSWORD,
|
||||||
|
)
|
||||||
|
page.click('a[data-view="settings"]')
|
||||||
|
page.wait_for_selector("#cs-start", state="visible")
|
||||||
|
|
||||||
|
# 修改起算日 → 原因弹窗必须打开且发出 PUT
|
||||||
|
page.fill("#cs-start", "2026-06-15")
|
||||||
|
with page.expect_request(
|
||||||
|
lambda req: req.method == "PUT"
|
||||||
|
and "/api/admin/settings/calculation-start" in req.url
|
||||||
|
) as start_req:
|
||||||
|
page.click('#systemSettings button[type="submit"]')
|
||||||
|
page.wait_for_selector("#reasonDialog.open", timeout=5000)
|
||||||
|
page.fill("#reasonInput", "调整起算日供冒烟")
|
||||||
|
page.click("#reasonSubmit")
|
||||||
|
self.assertTrue(start_req.value.post_data)
|
||||||
|
page.wait_for_function(
|
||||||
|
"() => document.getElementById('cs-start')?.value === '2026-06-15'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 创建期初并确认
|
||||||
|
page.click("#openOpeningDialog")
|
||||||
|
page.wait_for_selector("#openingDialog.open")
|
||||||
|
page.select_option("#ob-from", label="甲公司")
|
||||||
|
page.select_option("#ob-to", label="乙公司")
|
||||||
|
page.fill("#ob-amount", "200")
|
||||||
|
page.fill("#ob-reason", "冒烟期初录入")
|
||||||
|
with page.expect_request(
|
||||||
|
lambda req: req.method == "POST"
|
||||||
|
and req.url.endswith("/api/admin/opening-balances")
|
||||||
|
):
|
||||||
|
page.click('#openingForm button[type="submit"]')
|
||||||
|
page.wait_for_selector(
|
||||||
|
'#openingRows button[data-confirm-opening]',
|
||||||
|
timeout=8000,
|
||||||
|
)
|
||||||
|
|
||||||
|
with page.expect_request(
|
||||||
|
lambda req: req.method == "POST"
|
||||||
|
and "/opening-balances/" in req.url
|
||||||
|
and req.url.endswith("/confirm")
|
||||||
|
):
|
||||||
|
page.click('#openingRows button[data-confirm-opening]')
|
||||||
|
page.wait_for_selector("#reasonDialog.open", timeout=5000)
|
||||||
|
page.fill("#reasonInput", "确认期初冒烟")
|
||||||
|
page.click("#reasonSubmit")
|
||||||
|
page.wait_for_selector(
|
||||||
|
'#openingRows button[data-void-opening]',
|
||||||
|
timeout=8000,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 公司端看到完整期末口径
|
||||||
|
self._login(
|
||||||
|
page,
|
||||||
|
portal="company",
|
||||||
|
username="cashier-a",
|
||||||
|
password=CASHIER_PASSWORD,
|
||||||
|
)
|
||||||
|
page.click('a[data-view="transfers"]')
|
||||||
|
page.wait_for_function(
|
||||||
|
"""() => {
|
||||||
|
const data = document.getElementById('transfersData');
|
||||||
|
const empty = document.getElementById('transfersEmpty');
|
||||||
|
const ready = (data && !data.hidden) || (empty && !empty.hidden);
|
||||||
|
const card = document.getElementById('tfStatEndingCard');
|
||||||
|
const title = document.getElementById('tfStatNetTitle');
|
||||||
|
const emptyHtml = document.getElementById('transfersEmptyStats')?.innerHTML || '';
|
||||||
|
return ready && (
|
||||||
|
(card && !card.hidden) ||
|
||||||
|
(title && title.textContent.includes('期末')) ||
|
||||||
|
emptyHtml.includes('期末')
|
||||||
|
);
|
||||||
|
}""",
|
||||||
|
timeout=15000,
|
||||||
|
)
|
||||||
|
fatal = [e for e in errors if "openModal is not defined" in e
|
||||||
|
or "Cannot read properties of null" in e]
|
||||||
|
self.assertEqual([], fatal, fatal)
|
||||||
|
finally:
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
def test_01_company_gap_notice_then_admin_approve(self) -> None:
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser, page, errors = self._new_page(p)
|
||||||
|
try:
|
||||||
|
self._login(
|
||||||
|
page,
|
||||||
|
portal="company",
|
||||||
|
username="cashier-a",
|
||||||
|
password=CASHIER_PASSWORD,
|
||||||
|
)
|
||||||
|
# 首屏即可见断档提醒,无需手动刷新
|
||||||
|
page.wait_for_selector(
|
||||||
|
"#companyCoverageNotice",
|
||||||
|
state="visible",
|
||||||
|
timeout=10000,
|
||||||
|
)
|
||||||
|
body = page.locator("#companyCoverageBody").inner_text()
|
||||||
|
self.assertTrue(body.strip())
|
||||||
|
self.assertNotIn("0002", body)
|
||||||
|
|
||||||
|
page.click("#openAttestationFromWorkspace")
|
||||||
|
page.wait_for_selector("#attestationDialog.open", timeout=5000)
|
||||||
|
page.fill("#att-reason", "节假日账户无资金往来")
|
||||||
|
with page.expect_request(
|
||||||
|
lambda req: req.method == "POST"
|
||||||
|
and req.url.endswith("/api/company/no-business-attestations")
|
||||||
|
) as att_req:
|
||||||
|
page.click('#attestationForm button[type="submit"]')
|
||||||
|
self.assertTrue(att_req.value.post_data)
|
||||||
|
page.wait_for_function(
|
||||||
|
"""() => !document.getElementById('attestationDialog')?.classList.contains('open')""",
|
||||||
|
timeout=8000,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._login(
|
||||||
|
page,
|
||||||
|
portal="admin",
|
||||||
|
username="group-admin",
|
||||||
|
password=ADMIN_PASSWORD,
|
||||||
|
)
|
||||||
|
page.click('a[data-view="audit"]')
|
||||||
|
page.wait_for_selector(
|
||||||
|
'button[data-audit-action="approve-attestation"]',
|
||||||
|
timeout=10000,
|
||||||
|
)
|
||||||
|
with page.expect_request(
|
||||||
|
lambda req: req.method == "POST"
|
||||||
|
and "/no-business-attestations/" in req.url
|
||||||
|
and req.url.endswith("/review")
|
||||||
|
):
|
||||||
|
page.click('button[data-audit-action="approve-attestation"]')
|
||||||
|
page.wait_for_selector("#reasonDialog.open", timeout=5000)
|
||||||
|
page.fill("#reasonInput", "审核通过说明")
|
||||||
|
page.click("#reasonSubmit")
|
||||||
|
page.wait_for_function(
|
||||||
|
"""() => !document.querySelector(
|
||||||
|
'button[data-audit-action=\"approve-attestation\"]'
|
||||||
|
)""",
|
||||||
|
timeout=10000,
|
||||||
|
)
|
||||||
|
fatal = [e for e in errors if "openModal is not defined" in e]
|
||||||
|
self.assertEqual([], fatal, fatal)
|
||||||
|
finally:
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+1
-1
@@ -962,6 +962,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="toast-region" id="toastRegion" aria-live="polite"></div>
|
<div class="toast-region" id="toastRegion" aria-live="polite"></div>
|
||||||
<script src="app.js?v=9"></script>
|
<script src="app.js?v=13"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+13
-10
@@ -1175,6 +1175,9 @@ function humanizeChangeValue(value) {
|
|||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openModal(id) { $("#" + id)?.classList.add("open"); }
|
||||||
|
function closeModal(id) { $("#" + id)?.classList.remove("open"); }
|
||||||
|
|
||||||
function askReason({ title, subtitle, confirmLabel } = {}) {
|
function askReason({ title, subtitle, confirmLabel } = {}) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const dialog = $("#reasonDialog");
|
const dialog = $("#reasonDialog");
|
||||||
@@ -1818,8 +1821,6 @@ function initAdmin() {
|
|||||||
}));
|
}));
|
||||||
$("#auditCompany")?.addEventListener("change", filterAuditRows);
|
$("#auditCompany")?.addEventListener("change", filterAuditRows);
|
||||||
|
|
||||||
function openModal(id) { $("#" + id)?.classList.add("open"); }
|
|
||||||
function closeModal(id) { $("#" + id)?.classList.remove("open"); }
|
|
||||||
document.querySelectorAll("[data-close]").forEach((el) => el.addEventListener("click", () => closeModal(el.dataset.close)));
|
document.querySelectorAll("[data-close]").forEach((el) => el.addEventListener("click", () => closeModal(el.dataset.close)));
|
||||||
document.querySelectorAll(".modal-backdrop").forEach((bd) => bd.addEventListener("click", (e) => { if (e.target === bd) bd.classList.remove("open"); }));
|
document.querySelectorAll(".modal-backdrop").forEach((bd) => bd.addEventListener("click", (e) => { if (e.target === bd) bd.classList.remove("open"); }));
|
||||||
document.addEventListener("keydown", (e) => { if (e.key === "Escape") document.querySelectorAll(".modal-backdrop.open").forEach((m) => m.classList.remove("open")); });
|
document.addEventListener("keydown", (e) => { if (e.key === "Escape") document.querySelectorAll(".modal-backdrop.open").forEach((m) => m.classList.remove("open")); });
|
||||||
@@ -2003,7 +2004,8 @@ function initAdmin() {
|
|||||||
$("#openCompanyDialog")?.addEventListener("click", () => openModal("companyDialog"));
|
$("#openCompanyDialog")?.addEventListener("click", () => openModal("companyDialog"));
|
||||||
$("#companyForm")?.addEventListener("submit", async (event) => {
|
$("#companyForm")?.addEventListener("submit", async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const data = new FormData(event.currentTarget);
|
const form = event.currentTarget;
|
||||||
|
const data = new FormData(form);
|
||||||
const companyName = String(data.get("companyName") || "").trim();
|
const companyName = String(data.get("companyName") || "").trim();
|
||||||
const loginName = String(data.get("loginName") || "").trim();
|
const loginName = String(data.get("loginName") || "").trim();
|
||||||
const createUser = data.get("createUser") !== null;
|
const createUser = data.get("createUser") !== null;
|
||||||
@@ -2031,7 +2033,7 @@ function initAdmin() {
|
|||||||
}
|
}
|
||||||
const accountCreated = Boolean(result.username);
|
const accountCreated = Boolean(result.username);
|
||||||
closeModal("companyDialog");
|
closeModal("companyDialog");
|
||||||
event.currentTarget.reset();
|
form.reset();
|
||||||
await loadAdminCompanies();
|
await loadAdminCompanies();
|
||||||
showToast(
|
showToast(
|
||||||
accountCreated ? "公司与账号已创建" : "公司已创建",
|
accountCreated ? "公司与账号已创建" : "公司已创建",
|
||||||
@@ -2246,7 +2248,8 @@ function initAdmin() {
|
|||||||
$$("[data-close-opening]").forEach((button) => button.addEventListener("click", () => closeModal("openingDialog")));
|
$$("[data-close-opening]").forEach((button) => button.addEventListener("click", () => closeModal("openingDialog")));
|
||||||
$("#openingForm")?.addEventListener("submit", async (event) => {
|
$("#openingForm")?.addEventListener("submit", async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const data = new FormData(event.currentTarget);
|
const form = event.currentTarget;
|
||||||
|
const data = new FormData(form);
|
||||||
const fromName = data.get("from");
|
const fromName = data.get("from");
|
||||||
const toName = data.get("to");
|
const toName = data.get("to");
|
||||||
if (fromName === toName) {
|
if (fromName === toName) {
|
||||||
@@ -2280,7 +2283,7 @@ function initAdmin() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
closeModal("openingDialog");
|
closeModal("openingDialog");
|
||||||
event.currentTarget.reset();
|
form.reset();
|
||||||
await loadOpeningBalances();
|
await loadOpeningBalances();
|
||||||
await loadCalculationChanges();
|
await loadCalculationChanges();
|
||||||
await loadCalculationSettings();
|
await loadCalculationSettings();
|
||||||
@@ -3976,8 +3979,6 @@ function initCompany() {
|
|||||||
loadCompanyAccounts();
|
loadCompanyAccounts();
|
||||||
loadImportBatches();
|
loadImportBatches();
|
||||||
|
|
||||||
function openModal(id) { $("#" + id)?.classList.add("open"); }
|
|
||||||
function closeModal(id) { $("#" + id)?.classList.remove("open"); }
|
|
||||||
$$("[data-close]").forEach((el) => el.addEventListener("click", () => closeModal(el.dataset.close)));
|
$$("[data-close]").forEach((el) => el.addEventListener("click", () => closeModal(el.dataset.close)));
|
||||||
$$(".modal-backdrop").forEach((bd) => bd.addEventListener("click", (e) => { if (e.target === bd) bd.classList.remove("open"); }));
|
$$(".modal-backdrop").forEach((bd) => bd.addEventListener("click", (e) => { if (e.target === bd) bd.classList.remove("open"); }));
|
||||||
document.addEventListener("keydown", (e) => { if (e.key === "Escape") $$(".modal-backdrop.open").forEach((m) => m.classList.remove("open")); });
|
document.addEventListener("keydown", (e) => { if (e.key === "Escape") $$(".modal-backdrop.open").forEach((m) => m.classList.remove("open")); });
|
||||||
@@ -4115,6 +4116,7 @@ function initCompany() {
|
|||||||
initReconcile();
|
initReconcile();
|
||||||
(async () => {
|
(async () => {
|
||||||
await loadCompanyWorkspace();
|
await loadCompanyWorkspace();
|
||||||
|
await loadCompanyCoverageGaps();
|
||||||
await renderReconcileMatchStack();
|
await renderReconcileMatchStack();
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -4133,7 +4135,8 @@ function initCompany() {
|
|||||||
});
|
});
|
||||||
$("#accountForm")?.addEventListener("submit", async (event) => {
|
$("#accountForm")?.addEventListener("submit", async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const data = new FormData(event.currentTarget);
|
const form = event.currentTarget;
|
||||||
|
const data = new FormData(form);
|
||||||
const response = await fetch("/api/company/accounts", {
|
const response = await fetch("/api/company/accounts", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -4154,7 +4157,7 @@ function initCompany() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
closeModal("accountDialog");
|
closeModal("accountDialog");
|
||||||
event.currentTarget.reset();
|
form.reset();
|
||||||
await loadCompanyAccounts();
|
await loadCompanyAccounts();
|
||||||
showToast("银行账户已提交登记", "复核通过前不能上传流水,也不参与账户识别和覆盖计算", "success");
|
showToast("银行账户已提交登记", "复核通过前不能上传流水,也不参与账户识别和覆盖计算", "success");
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-1
@@ -1108,6 +1108,6 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div class="toast-region" id="toastRegion" aria-live="polite"></div>
|
<div class="toast-region" id="toastRegion" aria-live="polite"></div>
|
||||||
<script src="app.js?v=12"></script>
|
<script src="app.js?v=13"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user