HEL-270: 修复闭期补录未提交即回滚丢失(补回归测试)
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
+5
-4
@@ -14,16 +14,17 @@ MONTH_DIR="$DEST/monthly"
|
|||||||
mkdir -p "$DAY_DIR" "$MONTH_DIR"
|
mkdir -p "$DAY_DIR" "$MONTH_DIR"
|
||||||
|
|
||||||
# 1) SQLite 在线热备(容器内 python sqlite3 backup API,主库可继续写入)
|
# 1) SQLite 在线热备(容器内 python sqlite3 backup API,主库可继续写入)
|
||||||
docker exec "$CONTAINER" python - <<'PY'
|
# 备份文件写进数据卷 /app/data(宿主机 deploy/../data 可直接读取),避免
|
||||||
|
# read_only 容器 + tmpfs 下 docker cp 取不到文件的问题。
|
||||||
|
docker exec -i "$CONTAINER" python - <<'PY'
|
||||||
import os, sqlite3
|
import os, sqlite3
|
||||||
src = sqlite3.connect(os.environ.get("APP_DB_PATH", "/app/data/app.db"))
|
src = sqlite3.connect(os.environ.get("APP_DB_PATH", "/app/data/app.db"))
|
||||||
dst = sqlite3.connect("/tmp/app.db.backup")
|
dst = sqlite3.connect("/app/data/.backup-tmp.db")
|
||||||
src.backup(dst)
|
src.backup(dst)
|
||||||
dst.close(); src.close()
|
dst.close(); src.close()
|
||||||
print("hot-backup ok")
|
print("hot-backup ok")
|
||||||
PY
|
PY
|
||||||
docker cp "$CONTAINER:/tmp/app.db.backup" "$DAY_DIR/app.db.$STAMP"
|
mv "$ROOT/../data/.backup-tmp.db" "$DAY_DIR/app.db.$STAMP"
|
||||||
docker exec "$CONTAINER" rm -f /tmp/app.db.backup
|
|
||||||
|
|
||||||
# 2) 原始文件目录打包(银行原始证据,只读复制,绝不改动)
|
# 2) 原始文件目录打包(银行原始证据,只读复制,绝不改动)
|
||||||
tar -C "$ROOT/.." -czf "$DAY_DIR/files.$STAMP.tar.gz" data/files
|
tar -C "$ROOT/.." -czf "$DAY_DIR/files.$STAMP.tar.gz" data/files
|
||||||
|
|||||||
@@ -237,37 +237,41 @@ def record_late_arrivals(
|
|||||||
return 0
|
return 0
|
||||||
now = utc_now()
|
now = utc_now()
|
||||||
inserted = 0
|
inserted = 0
|
||||||
for row_id, year_month in items:
|
# One transaction so the rows and their audit trail commit or roll back
|
||||||
existing = connection.execute(
|
# together; without it the caller's connection.close() silently rolled
|
||||||
"SELECT id FROM period_late_arrivals WHERE source_row_id = ?",
|
# the late-arrival records back while the API still reported them.
|
||||||
(row_id,),
|
with connection:
|
||||||
).fetchone()
|
for row_id, year_month in items:
|
||||||
if existing is not None:
|
existing = connection.execute(
|
||||||
continue
|
"SELECT id FROM period_late_arrivals WHERE source_row_id = ?",
|
||||||
connection.execute(
|
(row_id,),
|
||||||
"""
|
).fetchone()
|
||||||
INSERT INTO period_late_arrivals (
|
if existing is not None:
|
||||||
year_month, source_row_id, status, created_at, actor_user_id, actor_username
|
continue
|
||||||
) VALUES (?, ?, 'open', ?, ?, ?)
|
connection.execute(
|
||||||
""",
|
"""
|
||||||
(
|
INSERT INTO period_late_arrivals (
|
||||||
|
year_month, source_row_id, status, created_at, actor_user_id, actor_username
|
||||||
|
) VALUES (?, ?, 'open', ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
year_month,
|
||||||
|
row_id,
|
||||||
|
now,
|
||||||
|
actor["id"] if actor is not None else None,
|
||||||
|
_actor_name(actor),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
inserted += 1
|
||||||
|
_append_audit(
|
||||||
|
connection,
|
||||||
|
actor,
|
||||||
|
"late_arrival",
|
||||||
year_month,
|
year_month,
|
||||||
row_id,
|
object_label=f"源行 {row_id}",
|
||||||
now,
|
reason="闭期后到达的流水,未改写已结账快照",
|
||||||
actor["id"] if actor is not None else None,
|
after={"source_row_id": row_id},
|
||||||
_actor_name(actor),
|
)
|
||||||
),
|
|
||||||
)
|
|
||||||
inserted += 1
|
|
||||||
_append_audit(
|
|
||||||
connection,
|
|
||||||
actor,
|
|
||||||
"late_arrival",
|
|
||||||
year_month,
|
|
||||||
object_label=f"源行 {row_id}",
|
|
||||||
reason="闭期后到达的流水,未改写已结账快照",
|
|
||||||
after={"source_row_id": row_id},
|
|
||||||
)
|
|
||||||
return inserted
|
return inserted
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,33 @@ class PeriodCloseTests(LedgerBase):
|
|||||||
again = period_close.close_payload(self.connection, self.MONTH)
|
again = period_close.close_payload(self.connection, self.MONTH)
|
||||||
self.assertEqual(digest, again["snapshot_hash"])
|
self.assertEqual(digest, again["snapshot_hash"])
|
||||||
|
|
||||||
|
def test_late_arrivals_survive_connection_close(self) -> None:
|
||||||
|
"""Regression: the reconcile handler closes its connection right after
|
||||||
|
recording locked rows; uncommitted inserts used to vanish silently."""
|
||||||
|
from bank_importer.db import connect as db_connect
|
||||||
|
|
||||||
|
self._cover_month()
|
||||||
|
self._close()
|
||||||
|
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(1, period_close.record_late_arrivals(self.connection, locked, self.admin))
|
||||||
|
self.connection.close()
|
||||||
|
fresh = db_connect(self.db_path)
|
||||||
|
try:
|
||||||
|
rows = fresh.execute(
|
||||||
|
"SELECT source_row_id FROM period_late_arrivals"
|
||||||
|
).fetchall()
|
||||||
|
audits = fresh.execute(
|
||||||
|
"SELECT action FROM period_audit_events WHERE action = 'late_arrival'"
|
||||||
|
).fetchall()
|
||||||
|
finally:
|
||||||
|
fresh.close()
|
||||||
|
self.assertEqual([late_id], [r["source_row_id"] for r in rows])
|
||||||
|
self.assertEqual(1, len(audits))
|
||||||
|
|
||||||
def test_snapshot_row_cannot_be_updated(self) -> None:
|
def test_snapshot_row_cannot_be_updated(self) -> None:
|
||||||
self._cover_month()
|
self._cover_month()
|
||||||
self._close()
|
self._close()
|
||||||
|
|||||||
Reference in New Issue
Block a user