From 267ebd37f507ec26ddb196f29e604e6ef7826530 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=80=BB=E5=B7=A5?= Date: Sun, 30 Aug 2026 22:29:52 +0800 Subject: [PATCH] =?UTF-8?q?HEL-270:=20=E4=BF=AE=E5=A4=8D=E9=97=AD=E6=9C=9F?= =?UTF-8?q?=E8=A1=A5=E5=BD=95=E6=9C=AA=E6=8F=90=E4=BA=A4=E5=8D=B3=E5=9B=9E?= =?UTF-8?q?=E6=BB=9A=E4=B8=A2=E5=A4=B1=EF=BC=88=E8=A1=A5=E5=9B=9E=E5=BD=92?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: multica-agent --- deploy/backup.sh | 9 +++-- src/bank_importer/period_close.py | 64 ++++++++++++++++--------------- tests/test_period_close.py | 27 +++++++++++++ 3 files changed, 66 insertions(+), 34 deletions(-) diff --git a/deploy/backup.sh b/deploy/backup.sh index b4789fa..b952728 100755 --- a/deploy/backup.sh +++ b/deploy/backup.sh @@ -14,16 +14,17 @@ MONTH_DIR="$DEST/monthly" mkdir -p "$DAY_DIR" "$MONTH_DIR" # 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 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) dst.close(); src.close() print("hot-backup ok") PY -docker cp "$CONTAINER:/tmp/app.db.backup" "$DAY_DIR/app.db.$STAMP" -docker exec "$CONTAINER" rm -f /tmp/app.db.backup +mv "$ROOT/../data/.backup-tmp.db" "$DAY_DIR/app.db.$STAMP" # 2) 原始文件目录打包(银行原始证据,只读复制,绝不改动) tar -C "$ROOT/.." -czf "$DAY_DIR/files.$STAMP.tar.gz" data/files diff --git a/src/bank_importer/period_close.py b/src/bank_importer/period_close.py index 542b920..f3f140f 100644 --- a/src/bank_importer/period_close.py +++ b/src/bank_importer/period_close.py @@ -237,37 +237,41 @@ def record_late_arrivals( return 0 now = utc_now() inserted = 0 - for row_id, year_month in items: - existing = connection.execute( - "SELECT id FROM period_late_arrivals WHERE source_row_id = ?", - (row_id,), - ).fetchone() - if existing is not None: - continue - connection.execute( - """ - INSERT INTO period_late_arrivals ( - year_month, source_row_id, status, created_at, actor_user_id, actor_username - ) VALUES (?, ?, 'open', ?, ?, ?) - """, - ( + # One transaction so the rows and their audit trail commit or roll back + # together; without it the caller's connection.close() silently rolled + # the late-arrival records back while the API still reported them. + with connection: + for row_id, year_month in items: + existing = connection.execute( + "SELECT id FROM period_late_arrivals WHERE source_row_id = ?", + (row_id,), + ).fetchone() + if existing is not None: + continue + 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, - 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, - object_label=f"源行 {row_id}", - reason="闭期后到达的流水,未改写已结账快照", - after={"source_row_id": row_id}, - ) + object_label=f"源行 {row_id}", + reason="闭期后到达的流水,未改写已结账快照", + after={"source_row_id": row_id}, + ) return inserted diff --git a/tests/test_period_close.py b/tests/test_period_close.py index 7bdf793..5e9c492 100644 --- a/tests/test_period_close.py +++ b/tests/test_period_close.py @@ -110,6 +110,33 @@ class PeriodCloseTests(LedgerBase): again = period_close.close_payload(self.connection, self.MONTH) 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: self._cover_month() self._close()