"""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()