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
@@ -204,6 +204,7 @@ def create_session(
|
||||
token = secrets.token_urlsafe(32)
|
||||
token_hash = hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
now = datetime.now(timezone.utc)
|
||||
purge_expired_sessions(connection)
|
||||
with connection:
|
||||
connection.execute(
|
||||
"""
|
||||
@@ -220,6 +221,17 @@ def create_session(
|
||||
return token
|
||||
|
||||
|
||||
def purge_expired_sessions(connection: sqlite3.Connection) -> int:
|
||||
"""Drop expired or revoked session rows so they do not accumulate."""
|
||||
now = utc_now()
|
||||
with connection:
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM sessions WHERE expires_at <= ? OR revoked_at IS NOT NULL",
|
||||
(now,),
|
||||
)
|
||||
return int(cursor.rowcount or 0)
|
||||
|
||||
|
||||
def resolve_session(connection: sqlite3.Connection, token: str) -> sqlite3.Row | None:
|
||||
"""Return the user row for a live session token, else None.
|
||||
|
||||
|
||||
@@ -109,11 +109,46 @@ def _company_rows(connection: sqlite3.Connection) -> list[sqlite3.Row]:
|
||||
).fetchall()
|
||||
|
||||
|
||||
def _period_status_for_cutoff(
|
||||
connection: sqlite3.Connection, cutoff: str
|
||||
) -> tuple[str | None, str]:
|
||||
ym = str(cutoff or "")[:7]
|
||||
exists = connection.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'period_close_runs'"
|
||||
).fetchone()
|
||||
if exists is None:
|
||||
return None, "—"
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT status FROM period_close_runs
|
||||
WHERE year_month = ?
|
||||
ORDER BY version DESC LIMIT 1
|
||||
""",
|
||||
(ym,),
|
||||
).fetchone()
|
||||
mapping = {
|
||||
"closed": ("closed", "已锁定"),
|
||||
"reopened": ("reopened", "已重开"),
|
||||
"failed": ("failed", "结账失败"),
|
||||
"pending": ("pending", "待结账"),
|
||||
"closing": ("closing", "处理中"),
|
||||
}
|
||||
if row is not None:
|
||||
return mapping.get(row["status"], (row["status"], str(row["status"])))
|
||||
locked = connection.execute(
|
||||
"SELECT 1 FROM closed_periods WHERE year_month = ?", (ym,)
|
||||
).fetchone()
|
||||
if locked is not None:
|
||||
return "closed", "已锁定"
|
||||
return None, "—"
|
||||
|
||||
|
||||
def company_summaries(
|
||||
connection: sqlite3.Connection, *, from_date: str, cutoff: str
|
||||
) -> tuple[list[dict[str, object]], dict[str, object]]:
|
||||
companies = _company_rows(connection)
|
||||
events = _load_eligible(connection, from_date=from_date, cutoff=cutoff)
|
||||
period_status, period_label = _period_status_for_cutoff(connection, cutoff)
|
||||
|
||||
debit_total = ZERO
|
||||
credit_total = ZERO
|
||||
@@ -125,8 +160,8 @@ def company_summaries(
|
||||
"detail_count": 0,
|
||||
"debit": ZERO,
|
||||
"credit": ZERO,
|
||||
"period_status": None,
|
||||
"period_status_label": "—",
|
||||
"period_status": period_status,
|
||||
"period_status_label": period_label,
|
||||
}
|
||||
|
||||
for event in events:
|
||||
|
||||
@@ -1096,6 +1096,106 @@ MIGRATIONS: tuple[Migration, ...] = (
|
||||
CREATE INDEX idx_reminders_company ON reminders (company_id);
|
||||
""",
|
||||
),
|
||||
|
||||
Migration(
|
||||
version=10,
|
||||
name="00010_period_close_reopen",
|
||||
# HEL-196/269: monthly close snapshots, reopen approval, late arrivals
|
||||
# after lock, and an append-only period audit trail. closed_periods
|
||||
# (from 0008) remains the live lock index; this table is the versioned
|
||||
# report history.
|
||||
up="""
|
||||
CREATE TABLE period_close_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
year_month TEXT NOT NULL,
|
||||
version INTEGER NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN (
|
||||
'pending', 'closing', 'closed', 'failed', 'reopened'
|
||||
)),
|
||||
snapshot_json TEXT,
|
||||
snapshot_hash TEXT,
|
||||
report_no TEXT,
|
||||
blockers_json TEXT,
|
||||
fail_reason TEXT,
|
||||
closed_at TEXT,
|
||||
closed_by INTEGER REFERENCES users (id),
|
||||
closed_by_username TEXT,
|
||||
reopen_window_end TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE (year_month, version)
|
||||
);
|
||||
|
||||
CREATE TABLE period_reopen_requests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
period_close_id INTEGER NOT NULL REFERENCES period_close_runs (id),
|
||||
year_month TEXT NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
companies_note TEXT,
|
||||
window_days INTEGER NOT NULL DEFAULT 3,
|
||||
status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')),
|
||||
requester_id INTEGER REFERENCES users (id),
|
||||
requester_username TEXT,
|
||||
requested_at TEXT NOT NULL,
|
||||
reviewer_id INTEGER REFERENCES users (id),
|
||||
reviewer_username TEXT,
|
||||
reviewed_at TEXT,
|
||||
review_comment TEXT,
|
||||
before_json TEXT,
|
||||
after_json TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE period_late_arrivals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
year_month TEXT NOT NULL,
|
||||
source_row_id INTEGER NOT NULL UNIQUE REFERENCES source_rows (id),
|
||||
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'absorbed')),
|
||||
created_at TEXT NOT NULL,
|
||||
actor_user_id INTEGER REFERENCES users (id),
|
||||
actor_username TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE period_audit_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT NOT NULL,
|
||||
actor_user_id INTEGER REFERENCES users (id),
|
||||
actor_username TEXT,
|
||||
actor_role TEXT,
|
||||
action TEXT NOT NULL,
|
||||
year_month TEXT,
|
||||
object_label TEXT,
|
||||
reason TEXT,
|
||||
before_json TEXT,
|
||||
after_json TEXT,
|
||||
report_no TEXT,
|
||||
related_id INTEGER
|
||||
);
|
||||
|
||||
CREATE INDEX idx_period_close_month ON period_close_runs (year_month, version);
|
||||
CREATE INDEX idx_period_reopen_status ON period_reopen_requests (status, year_month);
|
||||
CREATE INDEX idx_period_audit_created ON period_audit_events (created_at);
|
||||
|
||||
CREATE TRIGGER period_audit_no_update BEFORE UPDATE ON period_audit_events
|
||||
BEGIN SELECT RAISE (ABORT, 'period_audit_events rows are append-only'); END;
|
||||
CREATE TRIGGER period_audit_no_delete BEFORE DELETE ON period_audit_events
|
||||
BEGIN SELECT RAISE (ABORT, 'period_audit_events rows are immutable history'); END;
|
||||
CREATE TRIGGER period_close_snapshot_no_update BEFORE UPDATE ON period_close_runs
|
||||
WHEN OLD.snapshot_json IS NOT NULL AND NEW.snapshot_json IS NOT OLD.snapshot_json
|
||||
BEGIN SELECT RAISE (ABORT, 'closed snapshots cannot be rewritten'); END;
|
||||
""",
|
||||
down="""
|
||||
DROP TRIGGER IF EXISTS period_close_snapshot_no_update;
|
||||
DROP TRIGGER IF EXISTS period_audit_no_delete;
|
||||
DROP TRIGGER IF EXISTS period_audit_no_update;
|
||||
DROP INDEX IF EXISTS idx_period_audit_created;
|
||||
DROP INDEX IF EXISTS idx_period_reopen_status;
|
||||
DROP INDEX IF EXISTS idx_period_close_month;
|
||||
DROP TABLE IF EXISTS period_audit_events;
|
||||
DROP TABLE IF EXISTS period_late_arrivals;
|
||||
DROP TABLE IF EXISTS period_reopen_requests;
|
||||
DROP TABLE IF EXISTS period_close_runs;
|
||||
""",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -1108,6 +1208,10 @@ def connect(path: str | Path) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(str(db_path), timeout=30)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA foreign_keys = ON")
|
||||
# WAL lowers write-lock contention on file databases. Skip :memory:
|
||||
# because WAL requires a real file.
|
||||
if str(db_path) != ":memory:":
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
return connection
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Server-side flow listing and export from confirmed source rows.
|
||||
|
||||
Lists and exports only cashier-confirmed worksheets. Match state is derived
|
||||
from current transfer decisions when a source row is claimed; otherwise the
|
||||
row is labelled 未归集. Company users only see their own company.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal, InvalidOperation
|
||||
import sqlite3
|
||||
|
||||
|
||||
def _dec(value: object) -> Decimal:
|
||||
try:
|
||||
return Decimal(str(value or "0"))
|
||||
except (InvalidOperation, TypeError):
|
||||
return Decimal("0")
|
||||
|
||||
|
||||
def _direction(income: object, expense: object) -> tuple[str, Decimal]:
|
||||
income_d = _dec(income)
|
||||
expense_d = _dec(expense)
|
||||
if expense_d > 0 and income_d <= 0:
|
||||
return "付", expense_d
|
||||
return "收", income_d if income_d > 0 else expense_d
|
||||
|
||||
|
||||
def _status_label(classification: str | None, pairing: str | None, locked: int | None) -> tuple[str, str]:
|
||||
if classification in ("unresolved", "needs_review"):
|
||||
return "单边", "danger"
|
||||
if classification == "intercompany" and pairing == "paired":
|
||||
return "已归集", "success"
|
||||
if classification == "intercompany" and locked:
|
||||
return "已归集", "success"
|
||||
if classification == "intercompany":
|
||||
return "待确认", "warn"
|
||||
if classification == "same_company":
|
||||
return "同公司调拨", "muted"
|
||||
if classification == "external":
|
||||
return "未归集 · 外部", "muted"
|
||||
if classification:
|
||||
return "未归集", "muted"
|
||||
return "未归集", "muted"
|
||||
|
||||
|
||||
def list_flows(
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
company_id: int | None = None,
|
||||
bank: str | None = None,
|
||||
account: str | None = None,
|
||||
start: str | None = None,
|
||||
end: str | None = None,
|
||||
keyword: str | None = None,
|
||||
limit: int = 200,
|
||||
offset: int = 0,
|
||||
) -> dict[str, object]:
|
||||
clauses = ["rv.review_status = 'confirmed'"]
|
||||
params: list[object] = []
|
||||
if company_id is not None:
|
||||
clauses.append("b.company_id = ?")
|
||||
params.append(int(company_id))
|
||||
if start:
|
||||
clauses.append("date(r.transaction_at) >= date(?)")
|
||||
params.append(start)
|
||||
if end:
|
||||
clauses.append("date(r.transaction_at) <= date(?)")
|
||||
params.append(end)
|
||||
if account:
|
||||
clauses.append("r.own_account LIKE ?")
|
||||
params.append(f"%{account}%")
|
||||
if bank:
|
||||
clauses.append("(COALESCE(ba.bank_name, '') LIKE ? OR r.own_name LIKE ?)")
|
||||
params.extend([f"%{bank}%", f"%{bank}%"])
|
||||
if keyword:
|
||||
like = f"%{keyword}%"
|
||||
clauses.append(
|
||||
"(r.counterparty_name LIKE ? OR r.summary LIKE ? OR r.reference LIKE ? "
|
||||
"OR r.purpose LIKE ? OR c.name LIKE ?)"
|
||||
)
|
||||
params.extend([like, like, like, like, like])
|
||||
where = " AND ".join(clauses)
|
||||
limit = max(1, min(int(limit), 500))
|
||||
offset = max(0, int(offset))
|
||||
count_row = connection.execute(
|
||||
f"""
|
||||
SELECT COUNT(*) AS n
|
||||
FROM source_rows r
|
||||
JOIN sheet_batches s ON s.id = r.sheet_batch_id
|
||||
JOIN import_batches b ON b.id = s.import_batch_id
|
||||
JOIN sheet_reviews rv ON rv.sheet_batch_id = s.id
|
||||
JOIN companies c ON c.id = b.company_id
|
||||
LEFT JOIN bank_accounts ba ON ba.account_number = r.own_account
|
||||
WHERE {where}
|
||||
""",
|
||||
params,
|
||||
).fetchone()
|
||||
rows = connection.execute(
|
||||
f"""
|
||||
SELECT r.id, r.transaction_at, r.income, r.expense, r.balance,
|
||||
r.own_account, r.own_name, r.counterparty_account, r.counterparty_name,
|
||||
r.counterparty_bank, r.summary, r.purpose, r.reference, r.currency,
|
||||
b.id AS batch_id, b.company_id, c.name AS company_name,
|
||||
COALESCE(ba.bank_name, '') AS bank_name,
|
||||
s.sheet_name, r.source_row,
|
||||
d.classification, d.pairing, d.locked, d.effective_at
|
||||
FROM source_rows r
|
||||
JOIN sheet_batches s ON s.id = r.sheet_batch_id
|
||||
JOIN import_batches b ON b.id = s.import_batch_id
|
||||
JOIN sheet_reviews rv ON rv.sheet_batch_id = s.id
|
||||
JOIN companies c ON c.id = b.company_id
|
||||
LEFT JOIN bank_accounts ba ON ba.account_number = r.own_account
|
||||
LEFT JOIN transfer_observation_claims toc ON toc.source_row_id = r.id
|
||||
LEFT JOIN transfer_match_decisions d ON d.id = toc.decision_id
|
||||
WHERE {where}
|
||||
ORDER BY r.transaction_at DESC, r.id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
[*params, limit, offset],
|
||||
).fetchall()
|
||||
items = []
|
||||
inflow = Decimal("0")
|
||||
outflow = Decimal("0")
|
||||
for row in rows:
|
||||
direction, amount = _direction(row["income"], row["expense"])
|
||||
if direction == "收":
|
||||
inflow += amount
|
||||
else:
|
||||
outflow += amount
|
||||
status, status_kind = _status_label(
|
||||
row["classification"], row["pairing"], row["locked"]
|
||||
)
|
||||
tail = str(row["own_account"] or "")[-4:]
|
||||
items.append(
|
||||
{
|
||||
"id": int(row["id"]),
|
||||
"date": str(row["transaction_at"] or "")[:10],
|
||||
"time": str(row["transaction_at"] or ""),
|
||||
"company_id": int(row["company_id"]),
|
||||
"company": row["company_name"],
|
||||
"bank": row["bank_name"] or "",
|
||||
"account": row["own_account"],
|
||||
"account_label": (
|
||||
f"{row['bank_name']} · 尾号 {tail}" if row["bank_name"] and tail else (row["own_account"] or "—")
|
||||
),
|
||||
"own_name": row["own_name"],
|
||||
"direction": direction,
|
||||
"peer": row["counterparty_name"] or "—",
|
||||
"peer_account": row["counterparty_account"] or "—",
|
||||
"peer_bank": row["counterparty_bank"] or "—",
|
||||
"summary": row["summary"] or row["purpose"] or "—",
|
||||
"serial": row["reference"] or "—",
|
||||
"status": status,
|
||||
"status_kind": status_kind,
|
||||
"amount": str(amount),
|
||||
"balance": str(row["balance"] or ""),
|
||||
"currency": row["currency"] or "CNY",
|
||||
"batch_id": int(row["batch_id"]),
|
||||
"batch": f"IMP-{int(row['batch_id']):06d}",
|
||||
"locator": f"{row['sheet_name']}!R{row['source_row']}",
|
||||
"year_month": str(row["transaction_at"] or "")[:7],
|
||||
}
|
||||
)
|
||||
return {
|
||||
"items": items,
|
||||
"total": int(count_row["n"]),
|
||||
"inflow": str(inflow),
|
||||
"outflow": str(outflow),
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user