Compare commits

..
Author SHA1 Message Date
0fa32e1571 HEL-204: 修复弹窗作用域、断档首屏与期初表单 reset
将 openModal/closeModal 提升为顶层定义;公司端启动调用 loadCompanyCoverageGaps;异步提交前保存 form 引用。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-27 17:25:21 +00:00
10 changed files with 25 additions and 1014 deletions
-1
View File
@@ -1,7 +1,6 @@
__pycache__/
*.py[cod]
.venv/
.chrome-libs/
.tmp-*/
# 运行时数据与真实银行文件一律不进仓库(样本仅限流水模板/中已脱敏的六份)
+2 -4
View File
@@ -840,14 +840,12 @@ def _pair_net_change(
""",
params,
).fetchall()
# 站在本公司视角:正数=应收方向。转出/垫付(payer)使应收增加。
# 与 company_transfers.net = outflow inflow 及页面文案口径一致。
total = Decimal("0")
for row in rows:
amount = _parse_decimal(row["amount"])
if row["payer_id"] == viewer_id:
if row["payee_id"] == viewer_id:
total += amount
elif row["payee_id"] == viewer_id:
elif row["payer_id"] == viewer_id:
total -= amount
return total
+2 -53
View File
@@ -470,59 +470,8 @@ class BalanceBasisTests(CalculationBase):
balance = calculation.compute_pair_balance(
self.connection, self.company_a, self.company_b, cutoff="2026-01-31"
)
# 甲转出 80 → 应收方向为正
self.assertEqual("80.00", balance["net_change"])
self.assertEqual("80.00", balance["closing"])
def test_opening_plus_outflow_increases_receivable(self) -> None:
"""期初应收 200 + 本期转出垫付 100 → 期末应收 300;乙方对称为应付 300。"""
from test_matching import MatchingBase
item = calculation.create_opening_balance(
self.connection, self.company_a, self.company_b, "200", "期初应收", self.admin
)
calculation.confirm_opening_balance(
self.connection, item["id"], "确认", self.admin
)
helper = object.__new__(MatchingBase)
helper.connection = self.connection
row_a = helper.add_row(
self.company_a,
own_account="6222000000000001",
cp_account="6222000000000002",
expense="100.00",
at="2026-01-15T10:00:00",
)
matching.reconcile_rows(self.connection, [row_a])
row_b = helper.add_row(
self.company_b,
own_account="6222000000000002",
cp_account="6222000000000001",
income="100.00",
at="2026-01-15T11:00:00",
)
matching.reconcile_rows(self.connection, [row_b])
bal_a = calculation.compute_pair_balance(
self.connection, self.company_a, self.company_b, cutoff="2026-01-31"
)
self.assertEqual("full", bal_a["basis"])
self.assertEqual("200", bal_a["opening"])
self.assertEqual("100.00", bal_a["net_change"])
self.assertEqual("300.00", bal_a["closing"])
bal_b = calculation.compute_pair_balance(
self.connection, self.company_b, self.company_a, cutoff="2026-01-31"
)
self.assertEqual("full", bal_b["basis"])
self.assertEqual("-200", bal_b["opening"])
self.assertEqual("-100.00", bal_b["net_change"])
self.assertEqual("-300.00", bal_b["closing"])
# 双边守恒
self.assertEqual(
Decimal(bal_a["closing"]) + Decimal(bal_b["closing"]),
Decimal("0"),
)
self.assertEqual("-80.00", balance["net_change"])
self.assertEqual("-80.00", balance["closing"])
class CalculationApiTests(unittest.TestCase):
+1 -1
View File
@@ -50,7 +50,7 @@ class ConfirmStatusSourceContractTests(unittest.TestCase):
self.assertIn('id="workspacePendingStatus"', html)
self.assertIn('id="workspaceFlowSub"', html)
self.assertIn('data-view-link="reconcile"', html)
self.assertIn("app.js?v=13", html)
self.assertIn("app.js?v=12", html)
# 静态初值仍为进行中(黄),由 JS 在 pending=0 时切 done
self.assertRegex(html, r'class="flow-step doing"[^>]*data-view-link="reconcile"')
+2 -2
View File
@@ -32,7 +32,7 @@ class TransfersPageSourceContractTests(unittest.TestCase):
self.assertIn("期间净变动", html)
self.assertNotIn("本公司往来合计", html)
self.assertIn("design-system.css?v=6", html)
self.assertIn("app.js?v=13", html)
self.assertIn("app.js?v=12", html)
# 侧栏顺序:流水管理 → 转账往来 → 往来确认
flows = html.index('data-view="flows"')
transfers = html.index('data-view="transfers"')
@@ -113,7 +113,7 @@ class TransfersPageLayoutSmokeTests(unittest.TestCase):
for width in (360, 820, 1440):
page.set_viewport_size({"width": width, "height": 900})
page.set_content(
html.replace('src="app.js?v=13"', 'src=""'),
html.replace('src="app.js?v=12"', 'src=""'),
base_url=self.base,
)
page.evaluate(
-518
View File
@@ -1,518 +0,0 @@
"""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()
-417
View File
@@ -1,417 +0,0 @@
"""HEL-206: 公司间期末余额方向——期初应收 + 转出垫付 = 期末应收增加。"""
from __future__ import annotations
import os
import tempfile
import threading
import unittest
from decimal import Decimal
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]
ADMIN_PASSWORD = "AdminPass123"
CASHIER_A_PASSWORD = "CashierA123"
CASHIER_B_PASSWORD = "CashierB123"
try:
from playwright.sync_api import sync_playwright
except ImportError: # pragma: no cover
sync_playwright = None
def _prepare_chrome_libs() -> Path | None:
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 Hel206BalanceDirectionUnitTests(unittest.TestCase):
"""不依赖浏览器:期初 200 + 转出 100 → 甲应收 300 / 乙应付 300。"""
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
self.addCleanup(self.temp_dir.cleanup)
self.db_path = Path(self.temp_dir.name) / "app.db"
self.connection = connect(self.db_path)
self.addCleanup(self.connection.close)
migrate(self.connection)
auth.create_user(self.connection, "admin-u", ADMIN_PASSWORD, "admin")
self.admin = self.connection.execute(
"SELECT * FROM users WHERE username = 'admin-u'"
).fetchone()
self.company_a = master_data.create_company(
self.connection, "甲公司", None, None, None
)
self.company_b = master_data.create_company(
self.connection, "乙公司", None, None, None
)
self.account_a = self._approve("6222000000000001", self.company_a)
self.account_b = self._approve("6222000000000002", self.company_b)
calculation.set_calculation_start_date(
self.connection, "2026-01-01", "起算", self.admin
)
def _approve(self, number: str, company_id: int):
account = master_data.submit_bank_account(
self.connection,
company_id=company_id,
bank_name="中信银行",
account_type="基本户",
account_number=number,
start_date="2026-01-01",
actor=None,
)
return master_data.review_bank_account(
self.connection,
account["id"],
"approve",
None,
self.admin,
effective_from="2026-01-01",
)
def _add_row(self, company_id, account_id, own, cp, *, income, expense, at, ref):
with self.connection:
cursor = self.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 = self.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 = self.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)
self.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 = self.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)
def test_opening_200_plus_outflow_100_equals_closing_300(self) -> None:
item = calculation.create_opening_balance(
self.connection, self.company_a, self.company_b, "200", "期初应收", self.admin
)
calculation.confirm_opening_balance(
self.connection, item["id"], "确认", self.admin
)
row_a = self._add_row(
self.company_a,
self.account_a["id"],
"6222000000000001",
"6222000000000002",
income="0",
expense="100.00",
at="2026-01-15T10:00:00",
ref="sha-hel206-a",
)
row_b = self._add_row(
self.company_b,
self.account_b["id"],
"6222000000000002",
"6222000000000001",
income="100.00",
expense="0",
at="2026-01-15T11:00:00",
ref="sha-hel206-b",
)
matching.reconcile_rows(self.connection, [row_a, row_b])
bal_a = calculation.compute_pair_balance(
self.connection, self.company_a, self.company_b, cutoff="2026-01-31"
)
bal_b = calculation.compute_pair_balance(
self.connection, self.company_b, self.company_a, cutoff="2026-01-31"
)
self.assertEqual("200", bal_a["opening"])
self.assertEqual("100.00", bal_a["net_change"])
self.assertEqual("300.00", bal_a["closing"])
self.assertEqual("-200", bal_b["opening"])
self.assertEqual("-100.00", bal_b["net_change"])
self.assertEqual("-300.00", bal_b["closing"])
self.assertEqual(
Decimal(bal_a["closing"]) + Decimal(bal_b["closing"]),
Decimal("0"),
)
@unittest.skipUnless(sync_playwright, "playwright 未安装,跳过浏览器冒烟")
@unittest.skipUnless(_chromium_available(), "chromium 无法启动,跳过浏览器冒烟")
class Hel206BrowserDirectionTests(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_A_PASSWORD, "company",
cls.company_a, must_change_password=False,
)
auth.create_user(
connection, "cashier-b", CASHIER_B_PASSWORD, "company",
cls.company_b, must_change_password=False,
)
admin = connection.execute(
"SELECT * FROM users WHERE username = 'group-admin'"
).fetchone()
def approve(company_id, number):
account = master_data.submit_bank_account(
connection,
company_id=company_id,
bank_name="中信银行",
account_type="基本户",
account_number=number,
start_date="2026-01-01",
actor=None,
)
return master_data.review_bank_account(
connection, account["id"], "approve", None, admin,
effective_from="2026-01-01",
)
account_a = approve(cls.company_a, "6222000000000001")
account_b = approve(cls.company_b, "6222000000000002")
calculation.set_calculation_start_date(
connection, "2026-01-01", "初始化起算", admin
)
item = calculation.create_opening_balance(
connection, cls.company_a, cls.company_b, "200", "期初应收", admin
)
calculation.confirm_opening_balance(connection, item["id"], "确认", admin)
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, account_a["id"], "6222000000000001", "6222000000000002",
income="0", expense="100.00", at="2026-01-15T10:00:00", ref="sha-hel206-br-a",
)
row_b = add_row(
cls.company_b, account_b["id"], "6222000000000002", "6222000000000001",
income="100.00", expense="0", at="2026-01-15T11:00:00", ref="sha-hel206-br-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 _login(self, page, *, username: str, password: str) -> None:
page.goto(f"{self.base}/login-company.html", wait_until="domcontentloaded")
page.fill("#account", username)
page.fill("#password", password)
page.click('button[type="submit"]')
page.wait_for_url("**/company.html", timeout=15000)
def test_company_ending_direction_in_browser(self) -> None:
with sync_playwright() as p:
browser = p.chromium.launch(
headless=True, args=["--no-sandbox", "--disable-dev-shm-usage"]
)
try:
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)))
# 甲公司:期初 200 + 转出 100 → 期末应收 300
self._login(page, username="cashier-a", password=CASHIER_A_PASSWORD)
page.click('a[data-view="transfers"]')
page.wait_for_selector("#tfStatEndingCard:not([hidden])", timeout=15000)
bal_a = page.evaluate(
"""async () => {
const res = await fetch('/api/company/balances?cutoff=2026-01-31');
return await res.json();
}"""
)
self.assertEqual("ok", bal_a.get("status"))
self.assertEqual("full", bal_a.get("basis"))
pair_a = next(
p for p in bal_a["pairs"]
if p["counterparty_company_id"] == self.company_b
)
self.assertEqual("200", pair_a["opening"])
self.assertEqual("100.00", pair_a["net_change"])
self.assertEqual("300.00", pair_a["closing"])
ending_text = page.locator("#tfStatEnding").inner_text()
self.assertIn("0.03", ending_text) # 300 元 = 0.03 万元
self.assertEqual([], errors, errors)
# 乙公司:对称应付 300
self._login(page, username="cashier-b", password=CASHIER_B_PASSWORD)
page.click('a[data-view="transfers"]')
page.wait_for_selector("#tfStatEndingCard:not([hidden])", timeout=15000)
bal_b = page.evaluate(
"""async () => {
const res = await fetch('/api/company/balances?cutoff=2026-01-31');
return await res.json();
}"""
)
pair_b = next(
p for p in bal_b["pairs"]
if p["counterparty_company_id"] == self.company_a
)
self.assertEqual("-200", pair_b["opening"])
self.assertEqual("-100.00", pair_b["net_change"])
self.assertEqual("-300.00", pair_b["closing"])
ending_b = page.locator("#tfStatEnding").inner_text()
self.assertIn("0.03", ending_b)
fatal = [e for e in errors if "is not defined" in e or "Cannot read" in e]
self.assertEqual([], fatal, fatal)
finally:
browser.close()
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -962,6 +962,6 @@
</div>
</div>
<div class="toast-region" id="toastRegion" aria-live="polite"></div>
<script src="app.js?v=13"></script>
<script src="app.js?v=9"></script>
</body>
</html>
+16 -16
View File
@@ -1,6 +1,9 @@
const $ = (selector, scope = document) => scope.querySelector(selector);
const $$ = (selector, scope = document) => [...scope.querySelectorAll(selector)];
function openModal(id) { $("#" + id)?.classList.add("open"); }
function closeModal(id) { $("#" + id)?.classList.remove("open"); }
const portal = document.body.dataset.portal || "entry";
const viewNames = portal === "admin"
? { dashboard: "管理总览", pair: "往来查询", audit: "审核中心", flows: "流水管理", companies: "公司与账号", settings: "结账与期初", reminders: "提醒管理" }
@@ -1175,9 +1178,6 @@ function humanizeChangeValue(value) {
return String(value);
}
function openModal(id) { $("#" + id)?.classList.add("open"); }
function closeModal(id) { $("#" + id)?.classList.remove("open"); }
function askReason({ title, subtitle, confirmLabel } = {}) {
return new Promise((resolve) => {
const dialog = $("#reasonDialog");
@@ -3395,7 +3395,7 @@ function yuanToWan(value) {
return n / 10000;
}
function formatWanHtml(value, { signed = false } = {}) {
function formatWan(value, { signed = false } = {}) {
const wan = yuanToWan(value);
if (wan === null) return "—";
const abs = Math.abs(wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
@@ -3472,13 +3472,13 @@ function renderWorkspaceTransfersCard(summary) {
const net = $("#wsTfNet");
const pendingEl = $("#wsTfPending");
const netLabel = $("#wsTfNetLabel");
if (inflow) inflow.innerHTML = formatWanHtml(confirmed.inflow_total, { signed: true });
if (inflow) inflow.innerHTML = formatWan(confirmed.inflow_total, { signed: true });
if (outflow) {
outflow.innerHTML = formatWanHtml(
outflow.innerHTML = formatWan(
confirmed.outflow_total != null ? -Math.abs(Number(confirmed.outflow_total)) : null,
{ signed: true },
);
// formatWanHtml with negative value already adds ; ensure unit
// formatWan with negative value already adds ; ensure unit
if (confirmed.outflow_total != null) {
const wan = yuanToWan(confirmed.outflow_total);
const abs = Math.abs(wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
@@ -3488,12 +3488,12 @@ function renderWorkspaceTransfersCard(summary) {
if (netLabel) netLabel.textContent = `${netLabelForWindow(win)}${netMeta.label !== "持平" ? ` · ${netMeta.label}` : ""}`;
if (net) {
net.className = `ms-value ${netMeta.signedClass}`.trim();
net.innerHTML = formatWanHtml(confirmed.net_change, { signed: true });
net.innerHTML = formatWan(confirmed.net_change, { signed: true });
}
if (pendingEl) {
const pCount = Number(pending.count) || 0;
pendingEl.innerHTML = pCount
? `${formatWanHtml(pending.amount_total)}<span class="unit"> · ${pCount} 笔</span>`
? `${formatWan(pending.amount_total)}<span class="unit"> · ${pCount} 笔</span>`
: `0.00<span class="unit">万元 · 0 笔</span>`;
}
}
@@ -3564,7 +3564,7 @@ function renderTransfersOverview(summary) {
const netFoot = $("#tfStatNetFoot");
const netMeta = netDirectionMeta(confirmed.net_change, confirmed.net_direction);
if (companies) companies.innerHTML = `${cps.length}<span class="unit">家</span>`;
if (inflow) inflow.innerHTML = formatWanHtml(confirmed.inflow_total, { signed: true });
if (inflow) inflow.innerHTML = formatWan(confirmed.inflow_total, { signed: true });
if (outflow) {
const wan = yuanToWan(confirmed.outflow_total);
const abs = wan === null ? "—" : Math.abs(wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
@@ -3575,7 +3575,7 @@ function renderTransfersOverview(summary) {
const tag = netMeta.label !== "持平"
? ` <span class="xfer-dir-tag ${netMeta.className}">${netMeta.label}</span>`
: "";
net.innerHTML = `${formatWanHtml(confirmed.net_change, { signed: true })}${tag}`;
net.innerHTML = `${formatWan(confirmed.net_change, { signed: true })}${tag}`;
}
if (netFoot) {
netFoot.textContent = win.has_opening
@@ -3589,7 +3589,7 @@ function renderTransfersOverview(summary) {
const tfPendingCount = $("#tfPendingCount");
const tfPendingAmount = $("#tfPendingAmount");
if (tfConfirmedCount) tfConfirmedCount.textContent = `${confirmedCount}`;
if (tfConfirmedNet) tfConfirmedNet.innerHTML = formatWanHtml(confirmed.net_change, { signed: true });
if (tfConfirmedNet) tfConfirmedNet.innerHTML = formatWan(confirmed.net_change, { signed: true });
const openingCard = $("#tfStatOpeningCard");
const endingCard = $("#tfStatEndingCard");
const openingEl = $("#tfStatOpening");
@@ -3597,15 +3597,15 @@ function renderTransfersOverview(summary) {
if (win.has_opening) {
if (openingCard) openingCard.hidden = false;
if (endingCard) endingCard.hidden = false;
if (openingEl) openingEl.innerHTML = formatWanHtml(win.opening, { signed: true });
if (endingEl) endingEl.innerHTML = formatWanHtml(win.ending, { signed: true });
if (openingEl) openingEl.innerHTML = formatWan(win.opening, { signed: true });
if (endingEl) endingEl.innerHTML = formatWan(win.ending, { signed: true });
} else {
if (openingCard) openingCard.hidden = true;
if (endingCard) endingCard.hidden = true;
}
if (tfPendingCount) tfPendingCount.textContent = `${pCount}`;
if (tfPendingAmount) tfPendingAmount.innerHTML = formatWanHtml(pending.amount_total || 0);
if (tfPendingAmount) tfPendingAmount.innerHTML = formatWan(pending.amount_total || 0);
const tbody = $("#transfersCpBody");
const tfoot = $("#transfersCpFoot");
@@ -3706,7 +3706,7 @@ async function openTransfersDetail(companyId, companyName, { keepFilters = false
$("#tfDetailCount").innerHTML = ``;
$("#tfDetailCountFoot").textContent = pendingN ? `其中待确认 ${pendingN} 笔(汇总)` : "按筛选加载明细";
if (cp) {
$("#tfDetailIn").innerHTML = formatWanHtml(cp.confirmed_inflow, { signed: true });
$("#tfDetailIn").innerHTML = formatWan(cp.confirmed_inflow, { signed: true });
const wan = yuanToWan(cp.confirmed_outflow);
$("#tfDetailOut").innerHTML = wan === null
? "—"
+1 -1
View File
@@ -1108,6 +1108,6 @@
</aside>
<div class="toast-region" id="toastRegion" aria-live="polite"></div>
<script src="app.js?v=13"></script>
<script src="app.js?v=12"></script>
</body>
</html>