Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b49892e86d |
@@ -840,14 +840,12 @@ 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["payer_id"] == viewer_id:
|
||||
if row["payee_id"] == viewer_id:
|
||||
total += amount
|
||||
elif row["payee_id"] == viewer_id:
|
||||
elif row["payer_id"] == viewer_id:
|
||||
total -= amount
|
||||
return total
|
||||
|
||||
|
||||
@@ -470,59 +470,8 @@ class BalanceBasisTests(CalculationBase):
|
||||
balance = calculation.compute_pair_balance(
|
||||
self.connection, self.company_a, self.company_b, cutoff="2026-01-31"
|
||||
)
|
||||
# 甲转出 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"),
|
||||
)
|
||||
self.assertEqual("-80.00", balance["net_change"])
|
||||
self.assertEqual("-80.00", balance["closing"])
|
||||
|
||||
|
||||
class CalculationApiTests(unittest.TestCase):
|
||||
|
||||
@@ -1,417 +0,0 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user