Compare commits

...
Author SHA1 Message Date
611b09d57c HEL-205: 纠正期末余额应收应付方向
翻转 _pair_net_change 符号与全站「转出为正=应收」口径对齐,并补齐甲乙双边与浏览器方向断言。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-27 17:52:48 +00:00
3 changed files with 230 additions and 19 deletions
+4 -2
View File
@@ -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
+153 -4
View File
@@ -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",
+73 -13
View File
@@ -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()