HEL-269: 完成月结锁账、撤演示数据与代码收尾
锁账后写保护与闭期补录留痕;重开须审批并按版本链再结;列表/导出改走真实 API。 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
f5e0915f63
commit
e5e326514d
@@ -50,7 +50,7 @@ class ConfirmStatusSourceContractTests(unittest.TestCase):
|
||||
self.assertIn('id="workspacePendingStatus"', html)
|
||||
self.assertIn('id="workspaceFlowSub"', html)
|
||||
self.assertIn('data-view-link="reconcile"', html)
|
||||
self.assertIn("app.js?v=13", html)
|
||||
self.assertIn("app.js?v=15", html)
|
||||
# 静态初值仍为进行中(黄),由 JS 在 pending=0 时切 done
|
||||
self.assertRegex(html, r'class="flow-step doing"[^>]*data-view-link="reconcile"')
|
||||
|
||||
|
||||
@@ -31,8 +31,8 @@ class TransfersPageSourceContractTests(unittest.TestCase):
|
||||
self.assertIn('id="transferEvidenceDrawer"', html)
|
||||
self.assertIn("期间净变动", html)
|
||||
self.assertNotIn("本公司往来合计", html)
|
||||
self.assertIn("design-system.css?v=6", html)
|
||||
self.assertIn("app.js?v=13", html)
|
||||
self.assertIn("design-system.css?v=7", html)
|
||||
self.assertIn("app.js?v=15", html)
|
||||
# 侧栏顺序:流水管理 → 转账往来 → 往来确认
|
||||
flows = html.index('data-view="flows"')
|
||||
transfers = html.index('data-view="transfers"')
|
||||
@@ -54,6 +54,10 @@ class TransfersPageSourceContractTests(unittest.TestCase):
|
||||
self.assertIn("initTransfers()", js)
|
||||
self.assertIn("期间净变动", js)
|
||||
self.assertIn("has_opening", js)
|
||||
self.assertNotIn("FLOW_DEMO", js)
|
||||
self.assertNotIn("COMPANY_FLOWS", js)
|
||||
self.assertNotIn("IMP-DEMO", js)
|
||||
self.assertNotIn("ledger-demo-manual-records", js)
|
||||
# 不得把「期末余额」写死为无期初时的标签
|
||||
self.assertNotRegex(js, r'netLabelForWindow[^{]+{[^}]*return "期末余额"')
|
||||
|
||||
@@ -112,10 +116,8 @@ class TransfersPageLayoutSmokeTests(unittest.TestCase):
|
||||
page = browser.new_page()
|
||||
for width in (360, 820, 1440):
|
||||
page.set_viewport_size({"width": width, "height": 900})
|
||||
page.set_content(
|
||||
html.replace('src="app.js?v=13"', 'src=""'),
|
||||
base_url=self.base,
|
||||
)
|
||||
page.route("**/app.js**", lambda route: route.abort())
|
||||
page.goto(f"{self.base}/company.html")
|
||||
page.evaluate(
|
||||
"""() => {
|
||||
document.querySelectorAll('.app-view').forEach((el) => {
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Monthly close, reopen approval, snapshot hash and locked-period writes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from bank_importer import period_close
|
||||
from ledger_helpers import LedgerBase
|
||||
|
||||
|
||||
class PeriodCloseTests(LedgerBase):
|
||||
TODAY = date(2026, 8, 30)
|
||||
MONTH = "2026-07"
|
||||
|
||||
def _cover_month(self) -> None:
|
||||
self.add_row(
|
||||
self.company_a, own_account="6222000000000001",
|
||||
expense="100.00", at="2026-07-10T10:00:00",
|
||||
)
|
||||
self.add_row(
|
||||
self.company_b, own_account="6222000000000002",
|
||||
income="80.00", at="2026-07-12T10:00:00",
|
||||
)
|
||||
|
||||
def _close(self, month: str = MONTH):
|
||||
period_close.ensure_pending_tasks(self.connection, today=self.TODAY)
|
||||
return period_close.execute_close(
|
||||
self.connection, month, self.admin, confirm=True,
|
||||
)
|
||||
|
||||
def test_execute_requires_confirm_checkbox(self) -> None:
|
||||
self._cover_month()
|
||||
with self.assertRaises(period_close.PeriodCloseError) as ctx:
|
||||
period_close.execute_close(
|
||||
self.connection, self.MONTH, self.admin, confirm=False,
|
||||
)
|
||||
self.assertIn("勾选", str(ctx.exception))
|
||||
|
||||
def test_blockers_reject_close(self) -> None:
|
||||
period_close.ensure_pending_tasks(self.connection, today=self.TODAY)
|
||||
evaluation = period_close.evaluate_preconditions(self.connection, self.MONTH)
|
||||
self.assertFalse(evaluation["ready"])
|
||||
with self.assertRaises(period_close.PeriodCloseError):
|
||||
period_close.execute_close(
|
||||
self.connection, self.MONTH, self.admin, confirm=True,
|
||||
)
|
||||
|
||||
def test_close_snapshot_hash_stable_and_idempotent(self) -> None:
|
||||
self._cover_month()
|
||||
period_close.ensure_pending_tasks(self.connection, today=self.TODAY)
|
||||
before = period_close.build_snapshot(self.connection, self.MONTH)
|
||||
digest_before = hashlib.sha256(
|
||||
json.dumps(before, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
).hexdigest()
|
||||
first = period_close.execute_close(
|
||||
self.connection, self.MONTH, self.admin, confirm=True,
|
||||
)
|
||||
self.assertEqual("closed", first["status"])
|
||||
self.assertTrue(first["report_no"].startswith("MR-202607-"))
|
||||
digest = first["snapshot_hash"]
|
||||
self.assertEqual(64, len(digest))
|
||||
self.assertEqual(digest_before, digest)
|
||||
run = self.connection.execute(
|
||||
"SELECT snapshot_json, snapshot_hash FROM period_close_runs WHERE year_month = ?",
|
||||
(self.MONTH,),
|
||||
).fetchone()
|
||||
self.assertEqual(digest, run["snapshot_hash"])
|
||||
self.assertEqual(
|
||||
digest,
|
||||
hashlib.sha256(run["snapshot_json"].encode("utf-8")).hexdigest(),
|
||||
)
|
||||
self.assertEqual(
|
||||
digest,
|
||||
hashlib.sha256(run["snapshot_json"].encode("utf-8")).hexdigest(),
|
||||
)
|
||||
with self.assertRaises(period_close.PeriodConflictError):
|
||||
period_close.execute_close(
|
||||
self.connection, self.MONTH, self.admin, confirm=True,
|
||||
)
|
||||
|
||||
def test_locked_month_rejects_writes(self) -> None:
|
||||
self._cover_month()
|
||||
self._close()
|
||||
with self.assertRaises(period_close.PeriodLockedError) as ctx:
|
||||
period_close.assert_date_writable(self.connection, "2026-07-15")
|
||||
self.assertIn("2026-07", str(ctx.exception))
|
||||
self.assertIn("已结账锁定", str(ctx.exception))
|
||||
period_close.assert_date_writable(self.connection, "2026-08-01")
|
||||
|
||||
def test_late_arrivals_do_not_rewrite_snapshot(self) -> None:
|
||||
self._cover_month()
|
||||
closed = self._close()
|
||||
digest = closed["snapshot_hash"]
|
||||
late_id = self.add_row(
|
||||
self.company_a, own_account="6222000000000001",
|
||||
expense="12.00", at="2026-07-28T11:00:00",
|
||||
)
|
||||
writable, locked = period_close.split_writable_row_ids(self.connection, [late_id])
|
||||
self.assertEqual([], writable)
|
||||
self.assertEqual([(late_id, "2026-07")], locked)
|
||||
n = period_close.record_late_arrivals(self.connection, locked, self.admin)
|
||||
self.assertEqual(1, n)
|
||||
again = period_close.close_payload(self.connection, self.MONTH)
|
||||
self.assertEqual(digest, again["snapshot_hash"])
|
||||
|
||||
def test_snapshot_row_cannot_be_updated(self) -> None:
|
||||
self._cover_month()
|
||||
self._close()
|
||||
with self.assertRaises(Exception):
|
||||
with self.connection:
|
||||
self.connection.execute(
|
||||
"UPDATE period_close_runs SET snapshot_json = '{}' WHERE year_month = ?",
|
||||
(self.MONTH,),
|
||||
)
|
||||
|
||||
def test_reopen_reject_then_approve_and_reclose_version_chain(self) -> None:
|
||||
self._cover_month()
|
||||
first = self._close()
|
||||
with self.assertRaises(period_close.PeriodCloseError):
|
||||
period_close.request_reopen(
|
||||
self.connection, self.MONTH, self.admin, reason="太短",
|
||||
)
|
||||
req = period_close.request_reopen(
|
||||
self.connection, self.MONTH, self.admin,
|
||||
reason="补录金牛煤业七月运输费并核对金额",
|
||||
companies_note="甲公司 ↔ 乙公司",
|
||||
window_days=3,
|
||||
)
|
||||
self.assertEqual("pending", req["status"])
|
||||
rejected = period_close.decide_reopen(
|
||||
self.connection, req["id"], self.admin, approve=False, comment="证据不足",
|
||||
)
|
||||
self.assertEqual("rejected", rejected["status"])
|
||||
self.assertTrue(period_close.is_month_locked(self.connection, self.MONTH))
|
||||
req2 = period_close.request_reopen(
|
||||
self.connection, self.MONTH, self.admin,
|
||||
reason="已补齐银行回单,申请重开更正科目",
|
||||
)
|
||||
approved = period_close.decide_reopen(
|
||||
self.connection, req2["id"], self.admin, approve=True, comment="同意",
|
||||
)
|
||||
self.assertEqual("approved", approved["status"])
|
||||
self.assertFalse(period_close.is_month_locked(self.connection, self.MONTH))
|
||||
period_close.assert_date_writable(self.connection, "2026-07-15")
|
||||
second = period_close.execute_close(
|
||||
self.connection, self.MONTH, self.admin, confirm=True,
|
||||
)
|
||||
self.assertEqual("closed", second["status"])
|
||||
self.assertNotEqual(first["report_no"], second["report_no"])
|
||||
versions = self.connection.execute(
|
||||
"SELECT version, report_no FROM period_close_runs WHERE year_month = ? ORDER BY version",
|
||||
(self.MONTH,),
|
||||
).fetchall()
|
||||
self.assertGreaterEqual(len(versions), 2)
|
||||
self.assertEqual(1, versions[0]["version"])
|
||||
self.assertEqual(2, versions[-1]["version"])
|
||||
|
||||
def test_wal_on_file_database(self) -> None:
|
||||
mode = self.connection.execute("PRAGMA journal_mode").fetchone()[0]
|
||||
self.assertEqual("wal", str(mode).lower())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,101 @@
|
||||
"""HEL-269: 月结面板、重开审批、审计记录页结构与三档宽度。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import unittest
|
||||
from functools import partial
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
WEB = ROOT / "web"
|
||||
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except ImportError: # pragma: no cover
|
||||
sync_playwright = None
|
||||
|
||||
|
||||
class PeriodClosePageContractTests(unittest.TestCase):
|
||||
def test_admin_has_closing_reopen_and_audit_surfaces(self) -> None:
|
||||
html = (WEB / "admin.html").read_text(encoding="utf-8")
|
||||
self.assertIn('id="closingPanel"', html)
|
||||
self.assertIn('id="periodTimeline"', html)
|
||||
self.assertIn('data-page="period-audit"', html)
|
||||
self.assertIn('data-view="period-audit"', html)
|
||||
self.assertIn("重开审批", html)
|
||||
self.assertIn('id="reopenQueue"', html)
|
||||
self.assertIn('id="reopenRequestDialog"', html)
|
||||
self.assertIn('id="reopenDecideDialog"', html)
|
||||
self.assertIn("btn-warn", html)
|
||||
settings = html.index('data-view="settings"')
|
||||
audit_nav = html.index('data-view="period-audit"')
|
||||
reminders = html.index('data-view="reminders"')
|
||||
self.assertLess(settings, audit_nav)
|
||||
self.assertLess(audit_nav, reminders)
|
||||
css = (WEB / "design-system.css").read_text(encoding="utf-8")
|
||||
self.assertIn(".btn-warn", css)
|
||||
self.assertIn(".pill-lock", css)
|
||||
self.assertIn(".tl-cell.locked", css)
|
||||
self.assertIn(".diff-grid", css)
|
||||
self.assertIn(".empty-icon", css)
|
||||
js = (WEB / "app.js").read_text(encoding="utf-8")
|
||||
self.assertIn("/api/admin/period-closes", js)
|
||||
self.assertIn("/api/admin/period-reopens", js)
|
||||
self.assertIn("/api/admin/period-audit", js)
|
||||
self.assertIn("/api/flows", js)
|
||||
self.assertIn("/api/company/manual-records", js)
|
||||
|
||||
|
||||
def _chromium_available() -> bool:
|
||||
try:
|
||||
import ctypes.util
|
||||
return bool(ctypes.util.find_library("atk-1.0"))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@unittest.skipUnless(sync_playwright, "playwright 未安装,跳过布局冒烟")
|
||||
@unittest.skipUnless(_chromium_available(), "系统缺少 chromium 依赖库(如 libatk),跳过布局冒烟")
|
||||
class PeriodCloseLayoutSmokeTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
handler = partial(SimpleHTTPRequestHandler, directory=str(WEB))
|
||||
cls.httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
||||
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()
|
||||
|
||||
def test_settings_and_audit_no_horizontal_overflow(self) -> None:
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch()
|
||||
page = browser.new_page()
|
||||
page.route("**/app.js**", lambda route: route.abort())
|
||||
for width in (360, 820, 1440):
|
||||
page.set_viewport_size({"width": width, "height": 900})
|
||||
page.goto(f"{self.base}/admin.html")
|
||||
for view in ("settings", "period-audit"):
|
||||
page.evaluate(
|
||||
"""(view) => {
|
||||
document.querySelectorAll('.app-view').forEach((el) => {
|
||||
el.classList.toggle('is-active', el.dataset.page === view);
|
||||
});
|
||||
}""",
|
||||
view,
|
||||
)
|
||||
overflow = page.evaluate(
|
||||
"() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1"
|
||||
)
|
||||
self.assertFalse(overflow, f"{width}px {view} 出现横向溢出")
|
||||
browser.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -41,7 +41,7 @@ class PersistenceTestCase(unittest.TestCase):
|
||||
class MigrationTests(PersistenceTestCase):
|
||||
def test_migrate_creates_schema_and_is_idempotent(self) -> None:
|
||||
first = applied_versions(self.connection)
|
||||
self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8, 9], first)
|
||||
self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], first)
|
||||
self.assertEqual([], migrate(self.connection))
|
||||
self.assertEqual(first, applied_versions(self.connection))
|
||||
tables = {
|
||||
@@ -89,19 +89,23 @@ class MigrationTests(PersistenceTestCase):
|
||||
"coverage_gaps",
|
||||
"no_business_attestations",
|
||||
"reminders_legacy_manual",
|
||||
"period_close_runs",
|
||||
"period_reopen_requests",
|
||||
"period_late_arrivals",
|
||||
"period_audit_events",
|
||||
"schema_migrations",
|
||||
):
|
||||
self.assertIn(table, tables)
|
||||
|
||||
def test_rollback_removes_schema_and_forward_rebuilds_it(self) -> None:
|
||||
self.assertEqual([9, 8, 7, 6, 5, 4, 3, 2, 1], rollback(self.connection, 0))
|
||||
self.assertEqual([10, 9, 8, 7, 6, 5, 4, 3, 2, 1], rollback(self.connection, 0))
|
||||
self.assertEqual([], applied_versions(self.connection))
|
||||
remaining = self.connection.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'source_rows'"
|
||||
).fetchone()
|
||||
self.assertIsNone(remaining)
|
||||
self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8, 9], migrate(self.connection))
|
||||
self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8, 9], applied_versions(self.connection))
|
||||
self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], migrate(self.connection))
|
||||
self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], applied_versions(self.connection))
|
||||
|
||||
def test_rollback_to_4_keeps_bank_evidence_and_drops_event_layer(self) -> None:
|
||||
self.import_sample()
|
||||
@@ -109,7 +113,7 @@ class MigrationTests(PersistenceTestCase):
|
||||
"SELECT COUNT(*) AS n FROM source_rows"
|
||||
).fetchone()["n"]
|
||||
self.assertGreater(row_count, 0)
|
||||
self.assertEqual([9, 8, 7, 6, 5], rollback(self.connection, 4))
|
||||
self.assertEqual([10, 9, 8, 7, 6, 5], rollback(self.connection, 4))
|
||||
# The pre-migration evidence and schema are untouched.
|
||||
self.assertEqual(
|
||||
row_count,
|
||||
|
||||
@@ -477,8 +477,8 @@ class MigrationTests(unittest.TestCase):
|
||||
versions = connection.execute(
|
||||
"SELECT version FROM schema_migrations ORDER BY version"
|
||||
).fetchall()
|
||||
self.assertEqual(9, versions[-1]["version"])
|
||||
connection.execute("DELETE FROM schema_migrations WHERE version = 9")
|
||||
self.assertEqual(10, versions[-1]["version"])
|
||||
connection.execute("DELETE FROM schema_migrations WHERE version = 10")
|
||||
connection.executescript(
|
||||
"""
|
||||
DROP TRIGGER IF EXISTS reminders_no_delete;
|
||||
|
||||
@@ -37,8 +37,8 @@ class RemindersPageSourceContractTests(unittest.TestCase):
|
||||
self.assertIn('id="reminder-tbody"', html)
|
||||
self.assertIn('id="reminder-tabs"', html)
|
||||
self.assertIn('id="reminder-detail-drawer"', html)
|
||||
self.assertIn("design-system.css?v=9", html)
|
||||
self.assertIn("app.js?v=14", html)
|
||||
self.assertIn("design-system.css?v=10", html)
|
||||
self.assertIn("app.js?v=15", html)
|
||||
pending = html.index('id="pending-reminders-card"')
|
||||
history = html.index('id="reminder-history-card"')
|
||||
send = html.index('id="send-reminder-card"')
|
||||
@@ -115,7 +115,7 @@ class RemindersPageLayoutSmokeTests(unittest.TestCase):
|
||||
html = (WEB / "admin.html").read_text(encoding="utf-8")
|
||||
page.set_viewport_size({"width": width, "height": 900})
|
||||
page.set_content(
|
||||
html.replace('src="app.js?v=14"', 'src=""'),
|
||||
html.replace('src="app.js?v=15"', 'src=""'),
|
||||
base_url=self.base,
|
||||
)
|
||||
page.evaluate(
|
||||
@@ -138,7 +138,7 @@ class RemindersPageLayoutSmokeTests(unittest.TestCase):
|
||||
|
||||
def test_send_flow_columns_and_no_page_overflow(self) -> None:
|
||||
html = (WEB / "admin.html").read_text(encoding="utf-8")
|
||||
self.assertIn("app.js?v=14", html)
|
||||
self.assertIn("app.js?v=15", html)
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch()
|
||||
page = browser.new_page()
|
||||
|
||||
Reference in New Issue
Block a user