HEL-282: 写库路径事务提交与审计留痕自查修复
同类未提交即 close 回滚、业务与审计拆成两笔事务的路径一并收进可嵌套事务边界。 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
9e0e4a103a
commit
9193a3fce0
@@ -18,7 +18,7 @@ import secrets
|
||||
import sqlite3
|
||||
import string
|
||||
|
||||
from .db import utc_now
|
||||
from .db import transaction, utc_now
|
||||
|
||||
|
||||
MIN_PASSWORD_LENGTH = 8
|
||||
@@ -308,7 +308,7 @@ def audit(
|
||||
ip: str | None = None,
|
||||
) -> None:
|
||||
"""Append an audit log entry. Never pass passwords in ``detail``."""
|
||||
with connection:
|
||||
with transaction(connection):
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO audit_log (
|
||||
|
||||
@@ -7,8 +7,8 @@ from decimal import Decimal, InvalidOperation
|
||||
import json
|
||||
import sqlite3
|
||||
|
||||
from .db import utc_now
|
||||
from . import master_data, matching
|
||||
from .db import transaction, utc_now
|
||||
from . import auth, master_data, matching
|
||||
|
||||
|
||||
SETTING_START_DATE = "calculation_start_date"
|
||||
@@ -81,7 +81,7 @@ def set_calculation_start_date(
|
||||
raise LockedError("已有结账月份,起算日已锁定。")
|
||||
before = get_calculation_start_date(connection)
|
||||
now = utc_now()
|
||||
with connection:
|
||||
with transaction(connection):
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO system_settings (key, value, updated_at, updated_by)
|
||||
@@ -185,7 +185,7 @@ def create_opening_balance(
|
||||
raise ConflictError("该对公司已有确认期初,请使用修订。")
|
||||
revision = _next_revision(connection, low_id, high_id)
|
||||
now = utc_now()
|
||||
with connection:
|
||||
with transaction(connection):
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO opening_balance_revisions (
|
||||
@@ -235,7 +235,7 @@ def confirm_opening_balance(
|
||||
reason = str(reason or "").strip()
|
||||
if len(reason) < 2:
|
||||
raise ValueError("确认期初必须填写原因。")
|
||||
with connection:
|
||||
with transaction(connection):
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE opening_balance_revisions SET status = 'confirmed', reason = ?
|
||||
@@ -278,7 +278,7 @@ def revise_opening_balance(
|
||||
high_id = int(row["company_id_high"])
|
||||
revision = _next_revision(connection, low_id, high_id)
|
||||
now = utc_now()
|
||||
with connection:
|
||||
with transaction(connection):
|
||||
connection.execute(
|
||||
"UPDATE opening_balance_revisions SET status = 'superseded' WHERE id = ?",
|
||||
(revision_id,),
|
||||
@@ -333,7 +333,7 @@ def void_opening_balance(
|
||||
reason = str(reason or "").strip()
|
||||
if len(reason) < 2:
|
||||
raise ValueError("作废期初必须填写原因。")
|
||||
with connection:
|
||||
with transaction(connection):
|
||||
connection.execute(
|
||||
"UPDATE opening_balance_revisions SET status = 'void', reason = ? WHERE id = ?",
|
||||
(reason, revision_id),
|
||||
@@ -551,7 +551,7 @@ def recalculate_coverage_gaps(connection: sqlite3.Connection) -> int:
|
||||
"SELECT * FROM bank_accounts WHERE status = 'active'"
|
||||
).fetchall()
|
||||
rebuilt = 0
|
||||
with connection:
|
||||
with transaction(connection):
|
||||
for account in accounts:
|
||||
connection.execute(
|
||||
"""
|
||||
@@ -668,7 +668,7 @@ def submit_no_business_attestation(
|
||||
if account["company_id"] != company_id:
|
||||
raise ValueError("只能为本公司账户提交说明。")
|
||||
now = utc_now()
|
||||
with connection:
|
||||
with transaction(connection):
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO no_business_attestations (
|
||||
@@ -688,6 +688,13 @@ def submit_no_business_attestation(
|
||||
),
|
||||
)
|
||||
attestation_id = int(cursor.lastrowid)
|
||||
auth.audit(
|
||||
connection,
|
||||
"attestation_submit",
|
||||
actor=actor,
|
||||
target=f"attestation:{attestation_id}",
|
||||
detail=f"account:{bank_account_id};gap:{gap_start}..{gap_end}",
|
||||
)
|
||||
return attestation_payload(connection, attestation_id)
|
||||
|
||||
|
||||
@@ -712,7 +719,7 @@ def review_no_business_attestation(
|
||||
raise ValueError("审核必须填写理由。")
|
||||
status = "approved" if decision == "approve" else "rejected"
|
||||
now = utc_now()
|
||||
with connection:
|
||||
with transaction(connection):
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE no_business_attestations
|
||||
@@ -748,6 +755,13 @@ def review_no_business_attestation(
|
||||
""",
|
||||
(row["bank_account_id"], row["gap_end"], row["gap_start"]),
|
||||
)
|
||||
auth.audit(
|
||||
connection,
|
||||
f"attestation_{decision}",
|
||||
actor=actor,
|
||||
target=f"attestation:{attestation_id}",
|
||||
detail=review_reason,
|
||||
)
|
||||
return attestation_payload(connection, attestation_id)
|
||||
|
||||
|
||||
|
||||
@@ -10,10 +10,12 @@ version order; each records itself in ``schema_migrations`` so re-running
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
from typing import Iterator
|
||||
|
||||
|
||||
DEFAULT_DB_PATH = Path("data/app.db")
|
||||
@@ -23,6 +25,31 @@ def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def transaction(connection: sqlite3.Connection) -> Iterator[sqlite3.Connection]:
|
||||
"""Own a write transaction only when the caller has not already started one.
|
||||
|
||||
Nested helpers join the outer boundary so business rows and their audit
|
||||
trail commit or roll back together. Standalone callers still commit before
|
||||
return, so ``connection.close()`` cannot silently drop the work (HEL-270).
|
||||
``sqlite3.Connection`` as a context manager always commits on exit even
|
||||
when it did not begin the transaction; do not use it for nestable writes.
|
||||
"""
|
||||
began = False
|
||||
if not connection.in_transaction:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
began = True
|
||||
try:
|
||||
yield connection
|
||||
except Exception:
|
||||
if began:
|
||||
connection.rollback()
|
||||
raise
|
||||
else:
|
||||
if began:
|
||||
connection.commit()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Migration:
|
||||
version: int
|
||||
|
||||
@@ -650,6 +650,14 @@ def review_sheets(
|
||||
actor=actor,
|
||||
)
|
||||
ledger_events.reconcile_bank_events(connection, actor=actor)
|
||||
if updated:
|
||||
auth.audit(
|
||||
connection,
|
||||
f"sheet_{decision}",
|
||||
actor=actor,
|
||||
target=f"batch:{batch_id}",
|
||||
detail=f"sheets:{','.join(updated)}" + (f";reason:{reason}" if reason else ""),
|
||||
)
|
||||
if began:
|
||||
connection.commit()
|
||||
except Exception:
|
||||
@@ -657,14 +665,6 @@ def review_sheets(
|
||||
connection.rollback()
|
||||
raise
|
||||
|
||||
if updated:
|
||||
auth.audit(
|
||||
connection,
|
||||
f"sheet_{decision}",
|
||||
actor=actor,
|
||||
target=f"batch:{batch_id}",
|
||||
detail=f"sheets:{','.join(updated)}" + (f";reason:{reason}" if reason else ""),
|
||||
)
|
||||
payload: dict[str, object] = {"updated": updated, "already": already}
|
||||
if matching_result is not None:
|
||||
payload["matching"] = matching_result
|
||||
|
||||
@@ -18,7 +18,7 @@ from decimal import Decimal, InvalidOperation
|
||||
import json
|
||||
import sqlite3
|
||||
|
||||
from .db import utc_now
|
||||
from .db import transaction, utc_now
|
||||
from .subjects import MIRROR, SUBJECTS, mirror_subject
|
||||
|
||||
|
||||
@@ -428,26 +428,30 @@ def reopen_subject(
|
||||
raise LedgerInputError(
|
||||
"该事件没有银行来源,无法重新进入科目审核;请改用调整或冲销。"
|
||||
)
|
||||
if not _has_reversal(connection, ledger_event_id):
|
||||
create_reversal(
|
||||
connection, ledger_event_id,
|
||||
source_kind=current["source_kind"],
|
||||
source_revision_token=current["source_revision_token"],
|
||||
reason="科目复核:原确认事件冲销",
|
||||
actor=actor,
|
||||
idempotency_key=(idempotency_key + ":rev" if idempotency_key else None),
|
||||
rule_version=current["rule_version"],
|
||||
)
|
||||
ev = connection.execute(
|
||||
"SELECT * FROM eligible_intercompany_events WHERE event_id = ?",
|
||||
(bank_claim["bank_event_id"],),
|
||||
).fetchone()
|
||||
if ev is None:
|
||||
raise LedgerInputError("银行事件已不再纳入往来,无法重新入账。")
|
||||
event_id, revision_id = _create_bank_event(
|
||||
connection, ev, actor, reason="科目复核后重新入账,待确认科目",
|
||||
replacing_claim=bank_claim,
|
||||
)
|
||||
# One nestable transaction: reversal, replacement event, source re-claim
|
||||
# and suggestions commit together. create_event used to commit on its own,
|
||||
# leaving the bank-source UPDATE uncommitted for connection.close().
|
||||
with transaction(connection):
|
||||
if not _has_reversal(connection, ledger_event_id):
|
||||
create_reversal(
|
||||
connection, ledger_event_id,
|
||||
source_kind=current["source_kind"],
|
||||
source_revision_token=current["source_revision_token"],
|
||||
reason="科目复核:原确认事件冲销",
|
||||
actor=actor,
|
||||
idempotency_key=(idempotency_key + ":rev" if idempotency_key else None),
|
||||
rule_version=current["rule_version"],
|
||||
)
|
||||
event_id, revision_id = _create_bank_event(
|
||||
connection, ev, actor, reason="科目复核后重新入账,待确认科目",
|
||||
replacing_claim=bank_claim,
|
||||
)
|
||||
return event_id, revision_id
|
||||
|
||||
|
||||
@@ -553,51 +557,52 @@ def _create_bank_event(
|
||||
reason: str,
|
||||
replacing_claim: sqlite3.Row | None = None,
|
||||
) -> tuple[int, int]:
|
||||
event_id, revision_id = create_event(
|
||||
connection,
|
||||
state="pending_subject",
|
||||
effective_at=event["effective_at"],
|
||||
amount=event["amount"],
|
||||
currency=event["currency"],
|
||||
payer_company_id=event["payer_company_id"],
|
||||
payee_company_id=event["payee_company_id"],
|
||||
perspective_company_id=None,
|
||||
subject_code=None,
|
||||
source_kind="bank",
|
||||
source_revision_token=event["decision_id"],
|
||||
posting_kind="normal",
|
||||
rule_version=SUBJECT_RULE_VERSION,
|
||||
evidence_json=json.dumps(
|
||||
{
|
||||
"bank_event_id": event["event_id"],
|
||||
"decision_id": event["decision_id"],
|
||||
"pairing": event["pairing"],
|
||||
"evidence_count": event["evidence_count"],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
actor=actor,
|
||||
reason=reason,
|
||||
)
|
||||
if replacing_claim is not None:
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE ledger_event_bank_sources SET ledger_event_id = ?
|
||||
WHERE bank_event_id = ?
|
||||
""",
|
||||
(event_id, event["event_id"]),
|
||||
with transaction(connection):
|
||||
event_id, revision_id = create_event(
|
||||
connection,
|
||||
state="pending_subject",
|
||||
effective_at=event["effective_at"],
|
||||
amount=event["amount"],
|
||||
currency=event["currency"],
|
||||
payer_company_id=event["payer_company_id"],
|
||||
payee_company_id=event["payee_company_id"],
|
||||
perspective_company_id=None,
|
||||
subject_code=None,
|
||||
source_kind="bank",
|
||||
source_revision_token=event["decision_id"],
|
||||
posting_kind="normal",
|
||||
rule_version=SUBJECT_RULE_VERSION,
|
||||
evidence_json=json.dumps(
|
||||
{
|
||||
"bank_event_id": event["event_id"],
|
||||
"decision_id": event["decision_id"],
|
||||
"pairing": event["pairing"],
|
||||
"evidence_count": event["evidence_count"],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
actor=actor,
|
||||
reason=reason,
|
||||
)
|
||||
else:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO ledger_event_bank_sources (bank_event_id, ledger_event_id)
|
||||
VALUES (?, ?)
|
||||
""",
|
||||
(event["event_id"], event_id),
|
||||
)
|
||||
from .subjects import store_suggestions
|
||||
if replacing_claim is not None:
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE ledger_event_bank_sources SET ledger_event_id = ?
|
||||
WHERE bank_event_id = ?
|
||||
""",
|
||||
(event_id, event["event_id"]),
|
||||
)
|
||||
else:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO ledger_event_bank_sources (bank_event_id, ledger_event_id)
|
||||
VALUES (?, ?)
|
||||
""",
|
||||
(event["event_id"], event_id),
|
||||
)
|
||||
from .subjects import store_suggestions
|
||||
|
||||
store_suggestions(connection, event_id)
|
||||
store_suggestions(connection, event_id)
|
||||
return event_id, revision_id
|
||||
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import json
|
||||
import re
|
||||
import sqlite3
|
||||
|
||||
from .db import utc_now
|
||||
from .db import transaction, utc_now
|
||||
|
||||
|
||||
ACCOUNT_TYPES = ("基本户", "一般户", "专用户")
|
||||
@@ -143,7 +143,7 @@ def create_company(
|
||||
raise ValueError("公司名称不能为空。")
|
||||
now = utc_now()
|
||||
try:
|
||||
with connection:
|
||||
with transaction(connection):
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO companies (
|
||||
@@ -153,16 +153,15 @@ def create_company(
|
||||
(name, (credit_code or "").strip() or None,
|
||||
(cashier_name or "").strip() or None, now, now),
|
||||
)
|
||||
company_id = int(cursor.lastrowid)
|
||||
record_change(
|
||||
connection, "company", company_id, "create",
|
||||
None, {"name": name, "credit_code": credit_code or None,
|
||||
"cashier_name": cashier_name or None, "status": "active"},
|
||||
None, actor,
|
||||
)
|
||||
except sqlite3.IntegrityError as exc:
|
||||
raise ConflictError("公司名称已存在。") from exc
|
||||
company_id = int(cursor.lastrowid)
|
||||
with connection:
|
||||
record_change(
|
||||
connection, "company", company_id, "create",
|
||||
None, {"name": name, "credit_code": credit_code or None,
|
||||
"cashier_name": cashier_name or None, "status": "active"},
|
||||
None, actor,
|
||||
)
|
||||
return company_id
|
||||
|
||||
|
||||
@@ -210,7 +209,7 @@ def submit_bank_account(
|
||||
|
||||
if existing is None:
|
||||
try:
|
||||
with connection:
|
||||
with transaction(connection):
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO bank_accounts (
|
||||
@@ -222,23 +221,22 @@ def submit_bank_account(
|
||||
(company_id, number, holder, bank, kind,
|
||||
requested_from, actor["id"] if actor else None, now, now),
|
||||
)
|
||||
account_id = int(cursor.lastrowid)
|
||||
record_change(
|
||||
connection, "bank_account", account_id, "submit", None,
|
||||
{"company_id": company_id, "account_number": number,
|
||||
"bank_name": bank, "account_type": kind, "status": "pending",
|
||||
"effective_from": requested_from},
|
||||
None, actor,
|
||||
)
|
||||
except sqlite3.IntegrityError as exc:
|
||||
# Lost a concurrent-insert race on the UNIQUE constraint.
|
||||
raise ConflictError("该银行账号已登记,请等待现有申请处理。") from exc
|
||||
account_id = int(cursor.lastrowid)
|
||||
with connection:
|
||||
record_change(
|
||||
connection, "bank_account", account_id, "submit", None,
|
||||
{"company_id": company_id, "account_number": number,
|
||||
"bank_name": bank, "account_type": kind, "status": "pending",
|
||||
"effective_from": requested_from},
|
||||
None, actor,
|
||||
)
|
||||
return get_account(connection, account_id)
|
||||
|
||||
if existing["status"] == "returned" and existing["company_id"] == company_id:
|
||||
before = _snapshot(existing)
|
||||
with connection:
|
||||
with transaction(connection):
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE bank_accounts
|
||||
@@ -287,7 +285,7 @@ def review_bank_account(
|
||||
if account["status"] != "pending":
|
||||
raise ConflictError("只有待复核的账户可以审核通过。")
|
||||
start = validate_date(effective_from, "启用日期") or account["effective_from"] or today
|
||||
with connection:
|
||||
with transaction(connection):
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE bank_accounts
|
||||
@@ -306,7 +304,7 @@ def review_bank_account(
|
||||
raise ConflictError("只有待复核的账户可以退回。")
|
||||
if reason is None:
|
||||
raise ValueError("退回必须填写原因。")
|
||||
with connection:
|
||||
with transaction(connection):
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE bank_accounts
|
||||
@@ -326,7 +324,7 @@ def review_bank_account(
|
||||
if reason is None:
|
||||
raise ValueError("停用必须填写原因。")
|
||||
end = validate_date(effective_to, "停用日期") or today
|
||||
with connection:
|
||||
with transaction(connection):
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE bank_accounts
|
||||
@@ -448,7 +446,7 @@ def add_alias(
|
||||
if start and end and end < start:
|
||||
raise ValueError("别名失效日期不能早于生效日期。")
|
||||
try:
|
||||
with connection:
|
||||
with transaction(connection):
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO account_aliases (
|
||||
@@ -459,17 +457,16 @@ def add_alias(
|
||||
(account_id, alias_kind, value, rank, start, end,
|
||||
actor["id"] if actor else None, utc_now()),
|
||||
)
|
||||
alias_id = int(cursor.lastrowid)
|
||||
record_change(
|
||||
connection, "account_alias", alias_id, "create", None,
|
||||
{"bank_account_id": account_id, "alias_kind": alias_kind,
|
||||
"alias_value": value, "priority": rank,
|
||||
"effective_from": start, "effective_to": end},
|
||||
None, actor,
|
||||
)
|
||||
except sqlite3.IntegrityError as exc:
|
||||
raise ConflictError("该账户下相同别名已存在。") from exc
|
||||
alias_id = int(cursor.lastrowid)
|
||||
with connection:
|
||||
record_change(
|
||||
connection, "account_alias", alias_id, "create", None,
|
||||
{"bank_account_id": account_id, "alias_kind": alias_kind,
|
||||
"alias_value": value, "priority": rank,
|
||||
"effective_from": start, "effective_to": end},
|
||||
None, actor,
|
||||
)
|
||||
return alias_id
|
||||
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ import re
|
||||
import sqlite3
|
||||
|
||||
from .auth import audit
|
||||
from .db import utc_now
|
||||
from .db import transaction, utc_now
|
||||
from .master_data import (
|
||||
is_identifiable,
|
||||
normalize_account_number,
|
||||
@@ -1457,36 +1457,36 @@ def rebuild_current_projection(connection: sqlite3.Connection) -> int:
|
||||
no current pointer and no claims. Returns the number of current decisions
|
||||
rebuilt. Intended as a recovery/consistency entry point.
|
||||
"""
|
||||
connection.execute("DELETE FROM transfer_observation_claims")
|
||||
connection.execute("DELETE FROM current_transfer_decisions")
|
||||
events = connection.execute(
|
||||
"""
|
||||
SELECT e.id AS event_id,
|
||||
(SELECT d2.id FROM transfer_match_decisions d2
|
||||
WHERE d2.event_id = e.id
|
||||
ORDER BY d2.revision DESC LIMIT 1) AS latest_id
|
||||
FROM canonical_transfer_events e
|
||||
WHERE e.lifecycle = 'active'
|
||||
"""
|
||||
).fetchall()
|
||||
rebuilt = 0
|
||||
for event in events:
|
||||
if event["latest_id"] is None:
|
||||
continue
|
||||
latest = connection.execute(
|
||||
"SELECT mode FROM transfer_match_decisions WHERE id = ?",
|
||||
(event["latest_id"],),
|
||||
).fetchone()
|
||||
if latest is None or latest["mode"] == MODE_REVERSAL:
|
||||
continue
|
||||
observations = connection.execute(
|
||||
with transaction(connection):
|
||||
connection.execute("DELETE FROM transfer_observation_claims")
|
||||
connection.execute("DELETE FROM current_transfer_decisions")
|
||||
events = connection.execute(
|
||||
"""
|
||||
SELECT source_row_id FROM transfer_decision_observations
|
||||
WHERE decision_id = ? ORDER BY id
|
||||
SELECT e.id AS event_id,
|
||||
(SELECT d2.id FROM transfer_match_decisions d2
|
||||
WHERE d2.event_id = e.id
|
||||
ORDER BY d2.revision DESC LIMIT 1) AS latest_id
|
||||
FROM canonical_transfer_events e
|
||||
WHERE e.lifecycle = 'active'
|
||||
""",
|
||||
(event["latest_id"],),
|
||||
).fetchall()
|
||||
with connection:
|
||||
for event in events:
|
||||
if event["latest_id"] is None:
|
||||
continue
|
||||
latest = connection.execute(
|
||||
"SELECT mode FROM transfer_match_decisions WHERE id = ?",
|
||||
(event["latest_id"],),
|
||||
).fetchone()
|
||||
if latest is None or latest["mode"] == MODE_REVERSAL:
|
||||
continue
|
||||
observations = connection.execute(
|
||||
"""
|
||||
SELECT source_row_id FROM transfer_decision_observations
|
||||
WHERE decision_id = ? ORDER BY id
|
||||
""",
|
||||
(event["latest_id"],),
|
||||
).fetchall()
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO current_transfer_decisions (event_id, decision_id)
|
||||
@@ -1502,7 +1502,7 @@ def rebuild_current_projection(connection: sqlite3.Connection) -> int:
|
||||
""",
|
||||
(observation["source_row_id"], event["event_id"], event["latest_id"]),
|
||||
)
|
||||
rebuilt += 1
|
||||
rebuilt += 1
|
||||
return rebuilt
|
||||
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import json
|
||||
import re
|
||||
import sqlite3
|
||||
|
||||
from .db import utc_now
|
||||
from .db import transaction, utc_now
|
||||
from . import calculation, dashboard, settings as settings_mod
|
||||
|
||||
|
||||
@@ -237,10 +237,9 @@ def record_late_arrivals(
|
||||
return 0
|
||||
now = utc_now()
|
||||
inserted = 0
|
||||
# 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:
|
||||
# Nestable transaction: rows and their audit trail commit or roll back
|
||||
# together. ``with connection:`` would commit an outer caller early.
|
||||
with transaction(connection):
|
||||
for row_id, year_month in items:
|
||||
existing = connection.execute(
|
||||
"SELECT id FROM period_late_arrivals WHERE source_row_id = ?",
|
||||
@@ -530,7 +529,7 @@ def ensure_pending_tasks(
|
||||
first = earliest_month(connection) or last
|
||||
created: list[str] = []
|
||||
month = first
|
||||
with connection:
|
||||
with transaction(connection):
|
||||
while month <= last:
|
||||
existing = _current_run(connection, month)
|
||||
if existing is None:
|
||||
@@ -598,7 +597,7 @@ def execute_close(
|
||||
snapshot = build_snapshot(connection, year_month)
|
||||
digest = _hash_payload(snapshot)
|
||||
now = utc_now()
|
||||
with connection:
|
||||
with transaction(connection):
|
||||
if current is not None and current["status"] == "pending":
|
||||
version = int(current["version"])
|
||||
report_no = _report_no(year_month, version)
|
||||
@@ -665,7 +664,7 @@ def mark_close_failed(
|
||||
reason = str(reason or "").strip() or "结账失败"
|
||||
current = _current_run(connection, year_month)
|
||||
now = utc_now()
|
||||
with connection:
|
||||
with transaction(connection):
|
||||
if current is None:
|
||||
version = 1
|
||||
connection.execute(
|
||||
@@ -727,7 +726,7 @@ def request_reopen(
|
||||
if pending is not None:
|
||||
raise PeriodConflictError("该账期已有待审批的重开申请。")
|
||||
now = utc_now()
|
||||
with connection:
|
||||
with transaction(connection):
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO period_reopen_requests (
|
||||
@@ -786,7 +785,7 @@ def decide_reopen(
|
||||
close_run = connection.execute(
|
||||
"SELECT * FROM period_close_runs WHERE id = ?", (row["period_close_id"],)
|
||||
).fetchone()
|
||||
with connection:
|
||||
with transaction(connection):
|
||||
if approve:
|
||||
window_end = (
|
||||
datetime.now(timezone.utc) + timedelta(days=int(row["window_days"]))
|
||||
@@ -873,7 +872,7 @@ def expire_reopen_windows(
|
||||
for row in rows:
|
||||
year_month = row["year_month"]
|
||||
now = utc_now()
|
||||
with connection:
|
||||
with transaction(connection):
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE period_close_runs
|
||||
|
||||
@@ -12,7 +12,7 @@ from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
from .db import utc_now
|
||||
from .db import transaction, utc_now
|
||||
from .master_data import (
|
||||
ConflictError,
|
||||
mask_account_number,
|
||||
@@ -67,7 +67,7 @@ def submit_mapping(
|
||||
).fetchone()
|
||||
if existing is None:
|
||||
try:
|
||||
with connection:
|
||||
with transaction(connection):
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO personal_transit_mappings (
|
||||
@@ -81,22 +81,21 @@ def submit_mapping(
|
||||
start, actor["id"] if actor else None, now, now,
|
||||
),
|
||||
)
|
||||
mapping_id = int(cursor.lastrowid)
|
||||
record_change(
|
||||
connection, "personal_transit_mapping", mapping_id, "submit", None,
|
||||
{"account_number": number, "account_name": holder,
|
||||
"represented_company_id": represented_company_id,
|
||||
"allowed_direction": direction, "status": "pending",
|
||||
"effective_from": start},
|
||||
None, actor,
|
||||
)
|
||||
except sqlite3.IntegrityError as exc:
|
||||
raise ConflictError("该个人过账账号已登记,请等待现有申请处理。") from exc
|
||||
mapping_id = int(cursor.lastrowid)
|
||||
with connection:
|
||||
record_change(
|
||||
connection, "personal_transit_mapping", mapping_id, "submit", None,
|
||||
{"account_number": number, "account_name": holder,
|
||||
"represented_company_id": represented_company_id,
|
||||
"allowed_direction": direction, "status": "pending",
|
||||
"effective_from": start},
|
||||
None, actor,
|
||||
)
|
||||
return get_mapping(connection, mapping_id)
|
||||
|
||||
if existing["status"] == "returned":
|
||||
with connection:
|
||||
with transaction(connection):
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE personal_transit_mappings
|
||||
@@ -143,7 +142,7 @@ def review_mapping(
|
||||
if mapping["status"] != "pending":
|
||||
raise ConflictError("只有待复核的映射可以审核通过。")
|
||||
start = validate_date(effective_from, "生效日期") or mapping["effective_from"] or today
|
||||
with connection:
|
||||
with transaction(connection):
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE personal_transit_mappings
|
||||
@@ -162,7 +161,7 @@ def review_mapping(
|
||||
raise ConflictError("只有待复核的映射可以退回。")
|
||||
if reason is None:
|
||||
raise ValueError("退回必须填写原因。")
|
||||
with connection:
|
||||
with transaction(connection):
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE personal_transit_mappings
|
||||
@@ -182,7 +181,7 @@ def review_mapping(
|
||||
if reason is None:
|
||||
raise ValueError("停用必须填写原因。")
|
||||
end = validate_date(effective_to, "停用日期") or today
|
||||
with connection:
|
||||
with transaction(connection):
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE personal_transit_mappings
|
||||
|
||||
@@ -12,7 +12,7 @@ import re
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
|
||||
from .db import utc_now
|
||||
from .db import utc_now, transaction
|
||||
|
||||
|
||||
# Defaults are applied when a key is absent; the value type is always string.
|
||||
@@ -93,7 +93,7 @@ def update_settings(
|
||||
if not cleaned:
|
||||
raise ValueError("没有需要保存的设置项。")
|
||||
current = get_settings(connection)
|
||||
with connection:
|
||||
with transaction(connection):
|
||||
for key, new_value in cleaned.items():
|
||||
old_value = current.get(key)
|
||||
if old_value == new_value:
|
||||
|
||||
Reference in New Issue
Block a user