diff --git a/src/bank_importer/calculation.py b/src/bank_importer/calculation.py index 89e6541..b2d7da9 100644 --- a/src/bank_importer/calculation.py +++ b/src/bank_importer/calculation.py @@ -840,12 +840,14 @@ 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["payee_id"] == viewer_id: + if row["payer_id"] == viewer_id: total += amount - elif row["payer_id"] == viewer_id: + elif row["payee_id"] == viewer_id: total -= amount return total diff --git a/tests/test_calculation.py b/tests/test_calculation.py index 1817dc6..68cd81e 100644 --- a/tests/test_calculation.py +++ b/tests/test_calculation.py @@ -470,8 +470,59 @@ class BalanceBasisTests(CalculationBase): balance = calculation.compute_pair_balance( self.connection, self.company_a, self.company_b, cutoff="2026-01-31" ) - self.assertEqual("-80.00", balance["net_change"]) - self.assertEqual("-80.00", balance["closing"]) + # 甲转出 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"), + ) class CalculationApiTests(unittest.TestCase): diff --git a/tests/test_hel206_balance_direction.py b/tests/test_hel206_balance_direction.py new file mode 100644 index 0000000..a0c51eb --- /dev/null +++ b/tests/test_hel206_balance_direction.py @@ -0,0 +1,417 @@ +"""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()