diff --git a/src/bank_importer/calculation.py b/src/bank_importer/calculation.py index 89e6541..945cde4 100644 --- a/src/bank_importer/calculation.py +++ b/src/bank_importer/calculation.py @@ -840,12 +840,14 @@ def _pair_net_change( """, params, ).fetchall() + # 全站往来口径:转出(付款方)为正 = 本方应收增加;转入(收款方)为负 = 本方应付增加。 + # 与 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..dd1317f 100644 --- a/tests/test_calculation.py +++ b/tests/test_calculation.py @@ -470,8 +470,54 @@ 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_pair_direction_both_sides_opening_plus_outflow(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-10T10: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-10T11:00:00", + ) + matching.reconcile_rows(self.connection, [row_b]) + + from_a = calculation.compute_pair_balance( + self.connection, self.company_a, self.company_b, cutoff="2026-01-31" + ) + self.assertEqual("full", from_a["basis"]) + self.assertEqual("200", from_a["opening"]) + self.assertEqual("100.00", from_a["net_change"]) + self.assertEqual("300.00", from_a["closing"]) + + from_b = calculation.compute_pair_balance( + self.connection, self.company_b, self.company_a, cutoff="2026-01-31" + ) + self.assertEqual("full", from_b["basis"]) + self.assertEqual("-200", from_b["opening"]) + self.assertEqual("-100.00", from_b["net_change"]) + self.assertEqual("-300.00", from_b["closing"]) class CalculationApiTests(unittest.TestCase): @@ -489,11 +535,42 @@ class CalculationApiTests(unittest.TestCase): connection, "group-admin", "AdminPass123", "admin", must_change_password=False, ) - company_id = master_data.create_company(connection, "甲公司", None, None, None) + 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", "CashierA123", "company", company_id, + connection, "cashier-a", "CashierA123", "company", cls.company_a, must_change_password=False, ) + admin = connection.execute( + "SELECT * FROM users WHERE username = 'group-admin'" + ).fetchone() + account_a = master_data.submit_bank_account( + connection, + company_id=cls.company_a, + bank_name="中信银行", + account_type="基本户", + account_number="6222000000000001", + start_date="2026-01-01", + actor=None, + ) + cls.account_a = master_data.review_bank_account( + connection, account_a["id"], "approve", None, admin, effective_from="2026-01-01", + ) + account_b = master_data.submit_bank_account( + connection, + company_id=cls.company_b, + bank_name="中信银行", + account_type="基本户", + account_number="6222000000000002", + start_date="2026-01-01", + actor=None, + ) + cls.account_b = master_data.review_bank_account( + connection, account_b["id"], "approve", None, admin, effective_from="2026-01-01", + ) + calculation.set_calculation_start_date( + connection, "2026-01-01", "初始化", admin + ) connection.close() cls.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), server.AppHandler) cls.port = cls.httpd.server_address[1] @@ -506,6 +583,78 @@ class CalculationApiTests(unittest.TestCase): cls.httpd.shutdown() cls.temp_dir.cleanup() + def _seed_pair_direction_fixture(self) -> None: + """甲应收乙 200 + 甲转出 100,供 balances/pair 双边断言。""" + from test_matching import MatchingBase + + connection = connect(self.db_path) + admin = connection.execute( + "SELECT * FROM users WHERE username = 'group-admin'" + ).fetchone() + existing = connection.execute( + "SELECT 1 FROM opening_balance_revisions LIMIT 1" + ).fetchone() + if existing is None: + item = calculation.create_opening_balance( + connection, + self.company_a, + self.company_b, + "200", + "应收期初", + admin, + viewer_company_id=self.company_a, + ) + calculation.confirm_opening_balance( + connection, item["id"], "确认", admin + ) + helper = object.__new__(MatchingBase) + helper.connection = connection + row_a = helper.add_row( + self.company_a, + own_account="6222000000000001", + cp_account="6222000000000002", + expense="100.00", + at="2026-01-10T10:00:00", + ) + matching.reconcile_rows(connection, [row_a]) + row_b = helper.add_row( + self.company_b, + own_account="6222000000000002", + cp_account="6222000000000001", + income="100.00", + at="2026-01-10T11:00:00", + ) + matching.reconcile_rows(connection, [row_b]) + connection.close() + + def test_admin_balances_pair_direction_both_sides(self) -> None: + self._seed_pair_direction_fixture() + client = Client("127.0.0.1", self.port) + client.post_json("/api/login", { + "username": "group-admin", "password": "AdminPass123", "portal": "admin", + }) + status, _, body = client.request( + "GET", + f"/api/admin/balances/pair?from_company_id={self.company_a}" + f"&to_company_id={self.company_b}&cutoff=2026-01-31", + ) + self.assertEqual(200, status) + bal_a = as_json(body)["balance"] + self.assertEqual("200", bal_a["opening"]) + self.assertEqual("100.00", bal_a["net_change"]) + self.assertEqual("300.00", bal_a["closing"]) + + status, _, body = client.request( + "GET", + f"/api/admin/balances/pair?from_company_id={self.company_b}" + f"&to_company_id={self.company_a}&cutoff=2026-01-31", + ) + self.assertEqual(200, status) + bal_b = as_json(body)["balance"] + self.assertEqual("-200", bal_b["opening"]) + self.assertEqual("-100.00", bal_b["net_change"]) + self.assertEqual("-300.00", bal_b["closing"]) + def test_company_cannot_call_admin_start_date(self) -> None: self.client.post_json("/api/login", { "username": "cashier-a", "password": "CashierA123", "portal": "company", diff --git a/tests/test_hel203_browser_smoke.py b/tests/test_hel203_browser_smoke.py index 4ac8145..b622b80 100644 --- a/tests/test_hel203_browser_smoke.py +++ b/tests/test_hel203_browser_smoke.py @@ -133,6 +133,14 @@ class Hel203BrowserSmokeTests(unittest.TestCase): cls.company_a, must_change_password=False, ) + auth.create_user( + connection, + "cashier-b", + CASHIER_PASSWORD, + "company", + cls.company_b, + must_change_password=False, + ) admin = connection.execute( "SELECT * FROM users WHERE username = 'group-admin'" ).fetchone() @@ -162,7 +170,7 @@ class Hel203BrowserSmokeTests(unittest.TestCase): start_date="2026-06-01", actor=None, ) - master_data.review_bank_account( + cls.account_b = master_data.review_bank_account( connection, account_b["id"], "approve", @@ -305,7 +313,7 @@ class Hel203BrowserSmokeTests(unittest.TestCase): ) row_b = _add_row( cls.company_b, - account_b["id"], + cls.account_b["id"], "6222000000000002", "6222000000000001", income="100.00", @@ -418,7 +426,7 @@ class Hel203BrowserSmokeTests(unittest.TestCase): timeout=8000, ) - # 公司端看到完整期末口径 + # 甲公司端:期初 200 + 转出 100 → 期末 300(应收),与净额卡方向一致 self._login( page, portal="company", @@ -429,21 +437,73 @@ class Hel203BrowserSmokeTests(unittest.TestCase): 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('期末') - ); + return data && !data.hidden && card && !card.hidden; }""", timeout=15000, ) + summary_a = page.evaluate( + """async () => { + const r = await fetch('/api/company/intercompany/summary'); + return r.json(); + }""" + ) + self.assertEqual("ok", summary_a.get("status")) + self.assertTrue(summary_a["window"]["has_opening"]) + self.assertEqual("200.00", summary_a["window"]["opening"]) + self.assertEqual("300.00", summary_a["window"]["ending"]) + self.assertEqual("100.00", summary_a["confirmed"]["net_change"]) + self.assertEqual("receivable", summary_a["confirmed"]["net_direction"]) + + opening_text = page.locator("#tfStatOpening").inner_text() + ending_text = page.locator("#tfStatEnding").inner_text() + net_text = page.locator("#tfStatNet").inner_text() + self.assertIn("0.02", opening_text) + self.assertIn("0.03", ending_text) + self.assertIn("0.01", net_text) + self.assertIn("应收", net_text) + # 页面不得出现与「正数=应收」矛盾的应付标签套在正净额上 + self.assertNotRegex(net_text, r"\+.*应付") + + # 乙公司端:期初 −200 + 转入 100 → 期末 −300(应付) + self._login( + page, + portal="company", + username="cashier-b", + password=CASHIER_PASSWORD, + ) + page.click('a[data-view="transfers"]') + page.wait_for_function( + """() => { + const data = document.getElementById('transfersData'); + const card = document.getElementById('tfStatEndingCard'); + return data && !data.hidden && card && !card.hidden; + }""", + timeout=15000, + ) + summary_b = page.evaluate( + """async () => { + const r = await fetch('/api/company/intercompany/summary'); + return r.json(); + }""" + ) + self.assertEqual("ok", summary_b.get("status")) + self.assertEqual("-200.00", summary_b["window"]["opening"]) + self.assertEqual("-300.00", summary_b["window"]["ending"]) + self.assertEqual("-100.00", summary_b["confirmed"]["net_change"]) + self.assertEqual("payable", summary_b["confirmed"]["net_direction"]) + ending_b = page.locator("#tfStatEnding").inner_text() + net_b = page.locator("#tfStatNet").inner_text() + self.assertIn("0.03", ending_b) + self.assertIn("应付", net_b) + fatal = [e for e in errors if "openModal is not defined" in e - or "Cannot read properties of null" in e] + or "Cannot read properties of null" in e + or e.startswith("pageerror") + or "pageerror" in e.lower()] + # page.on('pageerror') 已直接 push 字符串,过滤明显运行时错误 + runtime = [e for e in errors if not e.startswith("console.")] + self.assertEqual([], runtime, runtime) self.assertEqual([], fatal, fatal) finally: browser.close()