HEL-205: 纠正期末余额应收应付方向
翻转 _pair_net_change 符号与全站「转出为正=应收」口径对齐,并补齐甲乙双边与浏览器方向断言。 Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
co-authored by
Cursor
multica-agent
parent
fe6b4e59ec
commit
611b09d57c
@@ -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
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user