Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fa32e1571 |
@@ -1,7 +1,6 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
.chrome-libs/
|
||||
.tmp-*/
|
||||
|
||||
# 运行时数据与真实银行文件一律不进仓库(样本仅限流水模板/中已脱敏的六份)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"')
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user