HEL-202: 基于 b27016d 返工起算日/期初/断档并补齐公司端
以 deploy/hel178 为基线整合 HEL-200 内核:迁移改为 0008 复用 system_settings;修复确认批次 list>int、截止日当天漏算与持久化断言; 公司端余额完整/降级口径与断档说明、管理端审核界面接线。 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
b27016d71d
commit
27f2b0b69a
@@ -0,0 +1,958 @@
|
||||
"""Calculation window: start date, opening balances, coverage gaps and balances."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from decimal import Decimal, InvalidOperation
|
||||
import json
|
||||
import sqlite3
|
||||
|
||||
from .db import utc_now
|
||||
from . import master_data, matching
|
||||
|
||||
|
||||
SETTING_START_DATE = "calculation_start_date"
|
||||
OPENING_STATUSES = ("draft", "confirmed", "superseded", "void")
|
||||
GAP_KINDS = ("head", "mid", "tail")
|
||||
GAP_STATUSES = ("open", "closed_attested")
|
||||
ATTESTATION_STATUSES = ("pending", "approved", "rejected")
|
||||
|
||||
|
||||
class LockedError(ValueError):
|
||||
"""Start date cannot change after a period is closed."""
|
||||
|
||||
|
||||
class ConflictError(ValueError):
|
||||
"""Revision or state conflict."""
|
||||
|
||||
|
||||
def utc_today() -> str:
|
||||
return datetime.now(timezone.utc).date().isoformat()
|
||||
|
||||
|
||||
def _parse_decimal(value: object) -> Decimal:
|
||||
try:
|
||||
return Decimal(str(value))
|
||||
except (InvalidOperation, TypeError):
|
||||
raise ValueError("金额格式无效。") from None
|
||||
|
||||
|
||||
def normalize_pair(company_a: int, company_b: int) -> tuple[int, int]:
|
||||
if company_a == company_b:
|
||||
raise ValueError("两家公司不能相同。")
|
||||
return (company_a, company_b) if company_a < company_b else (company_b, company_a)
|
||||
|
||||
|
||||
def signed_from_viewer(viewer_id: int, low_id: int, high_id: int, amount: Decimal) -> Decimal:
|
||||
return amount if viewer_id == low_id else -amount
|
||||
|
||||
|
||||
def get_setting(connection: sqlite3.Connection, key: str) -> str | None:
|
||||
row = connection.execute(
|
||||
"SELECT value FROM system_settings WHERE key = ?", (key,)
|
||||
).fetchone()
|
||||
return row["value"] if row is not None else None
|
||||
|
||||
|
||||
def get_calculation_start_date(connection: sqlite3.Connection) -> str | None:
|
||||
return get_setting(connection, SETTING_START_DATE)
|
||||
|
||||
|
||||
def has_closed_periods(connection: sqlite3.Connection) -> bool:
|
||||
row = connection.execute("SELECT 1 FROM closed_periods LIMIT 1").fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def is_start_date_locked(connection: sqlite3.Connection) -> bool:
|
||||
return has_closed_periods(connection)
|
||||
|
||||
|
||||
def set_calculation_start_date(
|
||||
connection: sqlite3.Connection,
|
||||
start_date: str,
|
||||
reason: str,
|
||||
actor: sqlite3.Row,
|
||||
) -> dict[str, object]:
|
||||
start_date = master_data.validate_date(start_date, "起算日", required=True)
|
||||
reason = str(reason or "").strip()
|
||||
if len(reason) < 2:
|
||||
raise ValueError("修改起算日必须填写原因。")
|
||||
if is_start_date_locked(connection):
|
||||
raise LockedError("已有结账月份,起算日已锁定。")
|
||||
before = get_calculation_start_date(connection)
|
||||
now = utc_now()
|
||||
with connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO system_settings (key, value, updated_at, updated_by)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
updated_at = excluded.updated_at,
|
||||
updated_by = excluded.updated_by
|
||||
""",
|
||||
(SETTING_START_DATE, start_date, now, actor["id"]),
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO system_setting_changes (
|
||||
key, before_value, after_value,
|
||||
actor_user_id, actor_username, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
SETTING_START_DATE,
|
||||
before,
|
||||
start_date,
|
||||
actor["id"],
|
||||
actor["username"],
|
||||
now,
|
||||
),
|
||||
)
|
||||
master_data.record_change(
|
||||
connection,
|
||||
"system_setting",
|
||||
0,
|
||||
"update_start_date",
|
||||
{"calculation_start_date": before},
|
||||
{"calculation_start_date": start_date},
|
||||
reason,
|
||||
actor,
|
||||
)
|
||||
recalculate_coverage_gaps(connection)
|
||||
return {
|
||||
"calculation_start_date": start_date,
|
||||
"locked": False,
|
||||
"previous": before,
|
||||
}
|
||||
|
||||
|
||||
def start_date_payload(connection: sqlite3.Connection) -> dict[str, object]:
|
||||
return {
|
||||
"calculation_start_date": get_calculation_start_date(connection),
|
||||
"locked": is_start_date_locked(connection),
|
||||
}
|
||||
|
||||
|
||||
def _current_opening_revision(
|
||||
connection: sqlite3.Connection, low_id: int, high_id: int
|
||||
) -> sqlite3.Row | None:
|
||||
return connection.execute(
|
||||
"""
|
||||
SELECT * FROM opening_balance_revisions
|
||||
WHERE company_id_low = ? AND company_id_high = ?
|
||||
AND status IN ('draft', 'confirmed')
|
||||
ORDER BY revision DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(low_id, high_id),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def _next_revision(connection: sqlite3.Connection, low_id: int, high_id: int) -> int:
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT MAX(revision) AS max_rev FROM opening_balance_revisions
|
||||
WHERE company_id_low = ? AND company_id_high = ?
|
||||
""",
|
||||
(low_id, high_id),
|
||||
).fetchone()
|
||||
return int(row["max_rev"] or 0) + 1
|
||||
|
||||
|
||||
def create_opening_balance(
|
||||
connection: sqlite3.Connection,
|
||||
company_a: int,
|
||||
company_b: int,
|
||||
amount: object,
|
||||
reason: str,
|
||||
actor: sqlite3.Row,
|
||||
*,
|
||||
currency: str = "CNY",
|
||||
viewer_company_id: int | None = None,
|
||||
) -> dict[str, object]:
|
||||
low_id, high_id = normalize_pair(company_a, company_b)
|
||||
decimal_amount = _parse_decimal(amount)
|
||||
if viewer_company_id is not None and viewer_company_id == high_id:
|
||||
decimal_amount = -decimal_amount
|
||||
reason = str(reason or "").strip()
|
||||
if len(reason) < 2:
|
||||
raise ValueError("录入期初必须填写原因。")
|
||||
existing = _current_opening_revision(connection, low_id, high_id)
|
||||
if existing is not None and existing["status"] == "draft":
|
||||
raise ConflictError("该公司对已有待确认期初,请先确认或作废后再录入。")
|
||||
if existing is not None and existing["status"] == "confirmed":
|
||||
raise ConflictError("该对公司已有确认期初,请使用修订。")
|
||||
revision = _next_revision(connection, low_id, high_id)
|
||||
now = utc_now()
|
||||
with connection:
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO opening_balance_revisions (
|
||||
company_id_low, company_id_high, amount, currency, revision,
|
||||
status, reason, actor_user_id, actor_username, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
low_id,
|
||||
high_id,
|
||||
str(decimal_amount),
|
||||
currency,
|
||||
revision,
|
||||
reason,
|
||||
actor["id"],
|
||||
actor["username"],
|
||||
now,
|
||||
),
|
||||
)
|
||||
row_id = int(cursor.lastrowid)
|
||||
master_data.record_change(
|
||||
connection,
|
||||
"opening_balance",
|
||||
row_id,
|
||||
"create_draft",
|
||||
None,
|
||||
{"company_id_low": low_id, "company_id_high": high_id, "amount": str(decimal_amount)},
|
||||
reason,
|
||||
actor,
|
||||
)
|
||||
return opening_balance_payload(connection, row_id)
|
||||
|
||||
|
||||
def confirm_opening_balance(
|
||||
connection: sqlite3.Connection,
|
||||
revision_id: int,
|
||||
reason: str,
|
||||
actor: sqlite3.Row,
|
||||
) -> dict[str, object]:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM opening_balance_revisions WHERE id = ?", (revision_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise ValueError("期初记录不存在。")
|
||||
if row["status"] != "draft":
|
||||
raise ConflictError("只能确认待确认状态的期初。")
|
||||
reason = str(reason or "").strip()
|
||||
if len(reason) < 2:
|
||||
raise ValueError("确认期初必须填写原因。")
|
||||
with connection:
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE opening_balance_revisions SET status = 'confirmed', reason = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(reason, revision_id),
|
||||
)
|
||||
master_data.record_change(
|
||||
connection,
|
||||
"opening_balance",
|
||||
revision_id,
|
||||
"confirm",
|
||||
{"status": "draft"},
|
||||
{"status": "confirmed"},
|
||||
reason,
|
||||
actor,
|
||||
)
|
||||
return opening_balance_payload(connection, revision_id)
|
||||
|
||||
|
||||
def revise_opening_balance(
|
||||
connection: sqlite3.Connection,
|
||||
revision_id: int,
|
||||
amount: object,
|
||||
reason: str,
|
||||
actor: sqlite3.Row,
|
||||
) -> dict[str, object]:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM opening_balance_revisions WHERE id = ?", (revision_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise ValueError("期初记录不存在。")
|
||||
if row["status"] != "confirmed":
|
||||
raise ConflictError("只能修订已确认期初。")
|
||||
decimal_amount = _parse_decimal(amount)
|
||||
reason = str(reason or "").strip()
|
||||
if len(reason) < 2:
|
||||
raise ValueError("修订期初必须填写原因。")
|
||||
low_id = int(row["company_id_low"])
|
||||
high_id = int(row["company_id_high"])
|
||||
revision = _next_revision(connection, low_id, high_id)
|
||||
now = utc_now()
|
||||
with connection:
|
||||
connection.execute(
|
||||
"UPDATE opening_balance_revisions SET status = 'superseded' WHERE id = ?",
|
||||
(revision_id,),
|
||||
)
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO opening_balance_revisions (
|
||||
company_id_low, company_id_high, amount, currency, revision,
|
||||
status, reason, actor_user_id, actor_username, supersedes_id, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
low_id,
|
||||
high_id,
|
||||
str(decimal_amount),
|
||||
row["currency"],
|
||||
revision,
|
||||
reason,
|
||||
actor["id"],
|
||||
actor["username"],
|
||||
revision_id,
|
||||
now,
|
||||
),
|
||||
)
|
||||
new_id = int(cursor.lastrowid)
|
||||
master_data.record_change(
|
||||
connection,
|
||||
"opening_balance",
|
||||
new_id,
|
||||
"revise",
|
||||
{"amount": row["amount"], "revision_id": revision_id},
|
||||
{"amount": str(decimal_amount), "revision_id": new_id},
|
||||
reason,
|
||||
actor,
|
||||
)
|
||||
return opening_balance_payload(connection, new_id)
|
||||
|
||||
|
||||
def void_opening_balance(
|
||||
connection: sqlite3.Connection,
|
||||
revision_id: int,
|
||||
reason: str,
|
||||
actor: sqlite3.Row,
|
||||
) -> dict[str, object]:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM opening_balance_revisions WHERE id = ?", (revision_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise ValueError("期初记录不存在。")
|
||||
if row["status"] not in ("draft", "confirmed"):
|
||||
raise ConflictError("该期初已作废或已被替代。")
|
||||
reason = str(reason or "").strip()
|
||||
if len(reason) < 2:
|
||||
raise ValueError("作废期初必须填写原因。")
|
||||
with connection:
|
||||
connection.execute(
|
||||
"UPDATE opening_balance_revisions SET status = 'void', reason = ? WHERE id = ?",
|
||||
(reason, revision_id),
|
||||
)
|
||||
master_data.record_change(
|
||||
connection,
|
||||
"opening_balance",
|
||||
revision_id,
|
||||
"void",
|
||||
{"status": row["status"]},
|
||||
{"status": "void"},
|
||||
reason,
|
||||
actor,
|
||||
)
|
||||
return opening_balance_payload(connection, revision_id)
|
||||
|
||||
|
||||
def opening_balance_payload(connection: sqlite3.Connection, revision_id: int) -> dict[str, object]:
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT r.*, cl.name AS company_low_name, ch.name AS company_high_name
|
||||
FROM opening_balance_revisions r
|
||||
JOIN companies cl ON cl.id = r.company_id_low
|
||||
JOIN companies ch ON ch.id = r.company_id_high
|
||||
WHERE r.id = ?
|
||||
""",
|
||||
(revision_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise ValueError("期初记录不存在。")
|
||||
amount = _parse_decimal(row["amount"])
|
||||
return {
|
||||
"id": row["id"],
|
||||
"company_id_low": row["company_id_low"],
|
||||
"company_id_high": row["company_id_high"],
|
||||
"company_low_name": row["company_low_name"],
|
||||
"company_high_name": row["company_high_name"],
|
||||
"amount": str(amount),
|
||||
"currency": row["currency"],
|
||||
"revision": row["revision"],
|
||||
"status": row["status"],
|
||||
"reason": row["reason"],
|
||||
"actor_username": row["actor_username"],
|
||||
"supersedes_id": row["supersedes_id"],
|
||||
"created_at": row["created_at"],
|
||||
"direction_low": "receivable" if amount >= 0 else "payable",
|
||||
}
|
||||
|
||||
|
||||
def list_opening_balances(connection: sqlite3.Connection) -> list[dict[str, object]]:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT r.id FROM opening_balance_revisions r
|
||||
JOIN (
|
||||
SELECT company_id_low, company_id_high, MAX(revision) AS max_rev
|
||||
FROM opening_balance_revisions
|
||||
WHERE status IN ('draft', 'confirmed', 'void')
|
||||
GROUP BY company_id_low, company_id_high
|
||||
) latest ON latest.company_id_low = r.company_id_low
|
||||
AND latest.company_id_high = r.company_id_high
|
||||
AND latest.max_rev = r.revision
|
||||
WHERE r.status IN ('draft', 'confirmed', 'void')
|
||||
ORDER BY r.company_id_low, r.company_id_high
|
||||
"""
|
||||
).fetchall()
|
||||
return [opening_balance_payload(connection, row["id"]) for row in rows]
|
||||
|
||||
|
||||
def confirmed_opening_amount(
|
||||
connection: sqlite3.Connection, low_id: int, high_id: int
|
||||
) -> Decimal | None:
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT amount FROM opening_balance_revisions
|
||||
WHERE company_id_low = ? AND company_id_high = ? AND status = 'confirmed'
|
||||
ORDER BY revision DESC LIMIT 1
|
||||
""",
|
||||
(low_id, high_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return _parse_decimal(row["amount"])
|
||||
|
||||
|
||||
def _date_add(day: str, delta: int) -> str:
|
||||
return (date.fromisoformat(day) + timedelta(days=delta)).isoformat()
|
||||
|
||||
|
||||
def _merge_intervals(intervals: list[tuple[str, str]]) -> list[tuple[str, str]]:
|
||||
if not intervals:
|
||||
return []
|
||||
sorted_intervals = sorted(intervals, key=lambda item: item[0])
|
||||
merged = [sorted_intervals[0]]
|
||||
for start, end in sorted_intervals[1:]:
|
||||
last_start, last_end = merged[-1]
|
||||
if date.fromisoformat(start) <= date.fromisoformat(_date_add(last_end, 1)):
|
||||
if date.fromisoformat(end) > date.fromisoformat(last_end):
|
||||
merged[-1] = (last_start, end)
|
||||
else:
|
||||
merged.append((start, end))
|
||||
return merged
|
||||
|
||||
|
||||
def _account_coverage_intervals(
|
||||
connection: sqlite3.Connection, account: sqlite3.Row
|
||||
) -> list[tuple[str, str]]:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT MIN(substr(r.transaction_at, 1, 10)) AS interval_start,
|
||||
MAX(substr(r.transaction_at, 1, 10)) AS interval_end
|
||||
FROM source_rows r
|
||||
JOIN sheet_batches s ON s.id = r.sheet_batch_id
|
||||
JOIN sheet_reviews rv ON rv.sheet_batch_id = s.id AND rv.review_status = 'confirmed'
|
||||
JOIN import_batches b ON b.id = s.import_batch_id
|
||||
WHERE b.upload_bank_account_id = ?
|
||||
OR r.own_account = ?
|
||||
GROUP BY s.id
|
||||
ORDER BY interval_start
|
||||
""",
|
||||
(account["id"], account["account_number"]),
|
||||
).fetchall()
|
||||
return [
|
||||
(row["interval_start"], row["interval_end"])
|
||||
for row in rows
|
||||
if row["interval_start"] and row["interval_end"]
|
||||
]
|
||||
|
||||
|
||||
def _account_transaction_dates(
|
||||
connection: sqlite3.Connection, account: sqlite3.Row
|
||||
) -> list[str]:
|
||||
intervals = _account_coverage_intervals(connection, account)
|
||||
dates: list[str] = []
|
||||
for start, end in intervals:
|
||||
current = date.fromisoformat(start)
|
||||
end_day = date.fromisoformat(end)
|
||||
while current <= end_day:
|
||||
dates.append(current.isoformat())
|
||||
current += timedelta(days=1)
|
||||
return dates
|
||||
|
||||
|
||||
def _dates_to_intervals(dates: list[str]) -> list[tuple[str, str]]:
|
||||
if not dates:
|
||||
return []
|
||||
intervals: list[tuple[str, str]] = []
|
||||
start = dates[0]
|
||||
prev = dates[0]
|
||||
for current in dates[1:]:
|
||||
if date.fromisoformat(current) == date.fromisoformat(prev) + timedelta(days=1):
|
||||
prev = current
|
||||
continue
|
||||
intervals.append((start, prev))
|
||||
start = current
|
||||
prev = current
|
||||
intervals.append((start, prev))
|
||||
return intervals
|
||||
|
||||
|
||||
def _detect_gaps(
|
||||
required_start: str,
|
||||
required_end: str,
|
||||
covered: list[tuple[str, str]],
|
||||
) -> list[tuple[str, str, str]]:
|
||||
if required_start > required_end:
|
||||
return []
|
||||
gaps: list[tuple[str, str, str]] = []
|
||||
if not covered:
|
||||
gaps.append((required_start, required_end, "head"))
|
||||
return gaps
|
||||
merged = _merge_intervals(covered)
|
||||
first_start, first_end = merged[0]
|
||||
if required_start < first_start:
|
||||
gaps.append((required_start, _date_add(first_start, -1), "head"))
|
||||
for index in range(len(merged) - 1):
|
||||
_, left_end = merged[index]
|
||||
right_start, _ = merged[index + 1]
|
||||
gap_start = _date_add(left_end, 1)
|
||||
gap_end = _date_add(right_start, -1)
|
||||
if gap_start <= gap_end:
|
||||
gaps.append((gap_start, gap_end, "mid"))
|
||||
last_start, last_end = merged[-1]
|
||||
tail_start = _date_add(last_end, 1)
|
||||
gap_from = max(tail_start, required_start)
|
||||
if gap_from <= required_end:
|
||||
kind = "tail" if last_end >= required_start else "head"
|
||||
gaps.append((gap_from, required_end, kind))
|
||||
return gaps
|
||||
|
||||
|
||||
def detect_account_gaps(
|
||||
connection: sqlite3.Connection,
|
||||
account: sqlite3.Row,
|
||||
*,
|
||||
start_date: str | None,
|
||||
today: str | None = None,
|
||||
) -> list[tuple[str, str, str]]:
|
||||
if account["status"] != "active":
|
||||
return []
|
||||
today = today or utc_today()
|
||||
effective_from = account["effective_from"] or start_date or today
|
||||
effective_to = account["effective_to"] or today
|
||||
required_start = max(filter(None, [start_date, effective_from]))
|
||||
required_end = min(today, effective_to)
|
||||
if not master_data.is_usable(account, required_start):
|
||||
return []
|
||||
intervals = _account_coverage_intervals(connection, account)
|
||||
return _detect_gaps(required_start, required_end, intervals)
|
||||
|
||||
|
||||
def recalculate_coverage_gaps(connection: sqlite3.Connection) -> int:
|
||||
start_date = get_calculation_start_date(connection)
|
||||
today = utc_today()
|
||||
accounts = connection.execute(
|
||||
"SELECT * FROM bank_accounts WHERE status = 'active'"
|
||||
).fetchall()
|
||||
rebuilt = 0
|
||||
with connection:
|
||||
for account in accounts:
|
||||
connection.execute(
|
||||
"""
|
||||
DELETE FROM coverage_gaps
|
||||
WHERE bank_account_id = ? AND status = 'open'
|
||||
""",
|
||||
(account["id"],),
|
||||
)
|
||||
for gap_start, gap_end, gap_kind in detect_account_gaps(
|
||||
connection, account, start_date=start_date, today=today
|
||||
):
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO coverage_gaps (
|
||||
bank_account_id, gap_start, gap_end, gap_kind,
|
||||
status, first_detected_at
|
||||
) VALUES (?, ?, ?, ?, 'open', ?)
|
||||
ON CONFLICT(bank_account_id, gap_start, gap_end) DO UPDATE SET
|
||||
gap_kind = excluded.gap_kind,
|
||||
status = CASE coverage_gaps.status
|
||||
WHEN 'closed_attested' THEN 'closed_attested'
|
||||
ELSE 'open'
|
||||
END
|
||||
""",
|
||||
(account["id"], gap_start, gap_end, gap_kind, utc_now()),
|
||||
)
|
||||
rebuilt += 1
|
||||
return rebuilt
|
||||
|
||||
|
||||
def coverage_gap_payload(connection: sqlite3.Connection, row: sqlite3.Row) -> dict[str, object]:
|
||||
account = master_data.get_account(connection, row["bank_account_id"])
|
||||
if account is None:
|
||||
raise ValueError("账户不存在。")
|
||||
company = connection.execute(
|
||||
"SELECT name FROM companies WHERE id = ?", (account["company_id"],)
|
||||
).fetchone()
|
||||
start = date.fromisoformat(row["gap_start"])
|
||||
end = date.fromisoformat(row["gap_end"])
|
||||
gap_days = (end - start).days + 1
|
||||
pending = connection.execute(
|
||||
"""
|
||||
SELECT id FROM no_business_attestations
|
||||
WHERE bank_account_id = ?
|
||||
AND gap_start = ? AND gap_end = ?
|
||||
AND status = 'pending'
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""",
|
||||
(row["bank_account_id"], row["gap_start"], row["gap_end"]),
|
||||
).fetchone()
|
||||
return {
|
||||
"id": row["id"],
|
||||
"bank_account_id": row["bank_account_id"],
|
||||
"company_id": account["company_id"],
|
||||
"company_name": company["name"] if company else None,
|
||||
"account_number_masked": master_data.mask_account_number(account["account_number"]),
|
||||
"gap_start": row["gap_start"],
|
||||
"gap_end": row["gap_end"],
|
||||
"gap_kind": row["gap_kind"],
|
||||
"gap_days": gap_days,
|
||||
"day_count": gap_days,
|
||||
"status": row["status"],
|
||||
"first_detected_at": row["first_detected_at"],
|
||||
"pending_attestation_id": pending["id"] if pending else None,
|
||||
}
|
||||
|
||||
|
||||
def list_coverage_gaps(
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
company_id: int | None = None,
|
||||
status: str | None = None,
|
||||
) -> list[dict[str, object]]:
|
||||
clauses: list[str] = []
|
||||
params: list[object] = []
|
||||
if company_id is not None:
|
||||
clauses.append("ba.company_id = ?")
|
||||
params.append(company_id)
|
||||
if status is not None:
|
||||
clauses.append("g.status = ?")
|
||||
params.append(status)
|
||||
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||
rows = connection.execute(
|
||||
f"""
|
||||
SELECT g.* FROM coverage_gaps g
|
||||
JOIN bank_accounts ba ON ba.id = g.bank_account_id
|
||||
{where}
|
||||
ORDER BY g.gap_start, g.bank_account_id
|
||||
""",
|
||||
params,
|
||||
).fetchall()
|
||||
return [coverage_gap_payload(connection, row) for row in rows]
|
||||
|
||||
|
||||
def submit_no_business_attestation(
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
company_id: int,
|
||||
bank_account_id: int,
|
||||
gap_start: str,
|
||||
gap_end: str,
|
||||
reason: str,
|
||||
evidence: str | None,
|
||||
actor: sqlite3.Row,
|
||||
) -> dict[str, object]:
|
||||
gap_start = master_data.validate_date(gap_start, "断档起始日", required=True)
|
||||
gap_end = master_data.validate_date(gap_end, "断档结束日", required=True)
|
||||
if gap_start > gap_end:
|
||||
raise ValueError("断档起始日不能晚于结束日。")
|
||||
reason = str(reason or "").strip()
|
||||
if len(reason) < 5:
|
||||
raise ValueError("无业务说明至少 5 个字符。")
|
||||
account = master_data.get_account(connection, bank_account_id)
|
||||
if account["company_id"] != company_id:
|
||||
raise ValueError("只能为本公司账户提交说明。")
|
||||
now = utc_now()
|
||||
with connection:
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO no_business_attestations (
|
||||
bank_account_id, gap_start, gap_end, reason, evidence,
|
||||
submitted_by, company_id, status, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?)
|
||||
""",
|
||||
(
|
||||
bank_account_id,
|
||||
gap_start,
|
||||
gap_end,
|
||||
reason,
|
||||
evidence,
|
||||
actor["id"],
|
||||
company_id,
|
||||
now,
|
||||
),
|
||||
)
|
||||
attestation_id = int(cursor.lastrowid)
|
||||
return attestation_payload(connection, attestation_id)
|
||||
|
||||
|
||||
def review_no_business_attestation(
|
||||
connection: sqlite3.Connection,
|
||||
attestation_id: int,
|
||||
decision: str,
|
||||
review_reason: str,
|
||||
actor: sqlite3.Row,
|
||||
) -> dict[str, object]:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM no_business_attestations WHERE id = ?", (attestation_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise ValueError("无业务说明不存在。")
|
||||
if row["status"] != "pending":
|
||||
raise ConflictError("该说明已审核。")
|
||||
review_reason = str(review_reason or "").strip()
|
||||
if decision not in ("approve", "reject"):
|
||||
raise ValueError("审核决定无效。")
|
||||
if len(review_reason) < 2:
|
||||
raise ValueError("审核必须填写理由。")
|
||||
status = "approved" if decision == "approve" else "rejected"
|
||||
now = utc_now()
|
||||
with connection:
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE no_business_attestations
|
||||
SET status = ?, reviewed_by = ?, reviewed_at = ?, review_reason = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(status, actor["id"], now, review_reason, attestation_id),
|
||||
)
|
||||
if status == "approved":
|
||||
# Exact match first so the attested row survives recalculate's
|
||||
# "DELETE ... status='open'" and stays closed_attested.
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE coverage_gaps
|
||||
SET status = 'closed_attested'
|
||||
WHERE bank_account_id = ?
|
||||
AND gap_start = ? AND gap_end = ?
|
||||
""",
|
||||
(row["bank_account_id"], row["gap_start"], row["gap_end"]),
|
||||
)
|
||||
recalculate_coverage_gaps(connection)
|
||||
if status == "approved":
|
||||
# Overlap match: if recalculate shifts boundaries, still close
|
||||
# any open gap that intersects the attested interval.
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE coverage_gaps
|
||||
SET status = 'closed_attested'
|
||||
WHERE bank_account_id = ?
|
||||
AND status = 'open'
|
||||
AND gap_start <= ?
|
||||
AND gap_end >= ?
|
||||
""",
|
||||
(row["bank_account_id"], row["gap_end"], row["gap_start"]),
|
||||
)
|
||||
return attestation_payload(connection, attestation_id)
|
||||
|
||||
|
||||
def attestation_payload(connection: sqlite3.Connection, attestation_id: int) -> dict[str, object]:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM no_business_attestations WHERE id = ?", (attestation_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise ValueError("无业务说明不存在。")
|
||||
account = master_data.get_account(connection, row["bank_account_id"])
|
||||
if account is None:
|
||||
raise ValueError("账户不存在。")
|
||||
return {
|
||||
"id": row["id"],
|
||||
"bank_account_id": row["bank_account_id"],
|
||||
"company_id": row["company_id"],
|
||||
"gap_start": row["gap_start"],
|
||||
"gap_end": row["gap_end"],
|
||||
"reason": row["reason"],
|
||||
"evidence": row["evidence"],
|
||||
"status": row["status"],
|
||||
"review_reason": row["review_reason"],
|
||||
"reviewed_at": row["reviewed_at"],
|
||||
"created_at": row["created_at"],
|
||||
"account_number_masked": master_data.mask_account_number(account["account_number"]),
|
||||
}
|
||||
|
||||
|
||||
def list_change_log(connection: sqlite3.Connection, limit: int = 100) -> list[dict[str, object]]:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT * FROM master_data_changes
|
||||
WHERE entity_type IN ('system_setting', 'opening_balance')
|
||||
ORDER BY id DESC LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
items: list[dict[str, object]] = []
|
||||
for row in rows:
|
||||
before = json.loads(row["before_json"]) if row["before_json"] else None
|
||||
after = json.loads(row["after_json"]) if row["after_json"] else None
|
||||
target = "起算日"
|
||||
if row["entity_type"] == "opening_balance":
|
||||
target = f"期初 #{row['entity_id']}"
|
||||
items.append(
|
||||
{
|
||||
"id": row["id"],
|
||||
"created_at": row["created_at"],
|
||||
"actor_username": row["actor_username"],
|
||||
"action": row["action"],
|
||||
"target": target,
|
||||
"before": before,
|
||||
"after": after,
|
||||
"reason": row["reason"],
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def _pair_net_change(
|
||||
connection: sqlite3.Connection,
|
||||
viewer_id: int,
|
||||
counterparty_id: int,
|
||||
*,
|
||||
cutoff: str | None,
|
||||
start_date: str | None,
|
||||
) -> Decimal:
|
||||
cutoff_where = ""
|
||||
params: list[object] = [viewer_id, counterparty_id, counterparty_id, viewer_id]
|
||||
if cutoff:
|
||||
cutoff_where = "AND substr(d.effective_at, 1, 10) <= ?"
|
||||
params.append(cutoff)
|
||||
start_where = ""
|
||||
if start_date:
|
||||
start_where = "AND substr(d.effective_at, 1, 10) >= ?"
|
||||
params.append(start_date)
|
||||
rows = connection.execute(
|
||||
f"""
|
||||
SELECT d.amount, payer.company_id AS payer_id, payee.company_id AS payee_id
|
||||
FROM eligible_intercompany_events e
|
||||
JOIN transfer_match_decisions d ON d.id = e.decision_id
|
||||
JOIN transfer_decision_participants payer ON payer.decision_id = d.id AND payer.role = 'payer'
|
||||
JOIN transfer_decision_participants payee ON payee.decision_id = d.id AND payee.role = 'payee'
|
||||
WHERE (
|
||||
(payer.company_id = ? AND payee.company_id = ?)
|
||||
OR (payer.company_id = ? AND payee.company_id = ?)
|
||||
)
|
||||
{cutoff_where}
|
||||
{start_where}
|
||||
""",
|
||||
params,
|
||||
).fetchall()
|
||||
total = Decimal("0")
|
||||
for row in rows:
|
||||
amount = _parse_decimal(row["amount"])
|
||||
if row["payee_id"] == viewer_id:
|
||||
total += amount
|
||||
elif row["payer_id"] == viewer_id:
|
||||
total -= amount
|
||||
return total
|
||||
|
||||
|
||||
def pair_has_confirmed_opening(
|
||||
connection: sqlite3.Connection, company_a: int, company_b: int
|
||||
) -> bool:
|
||||
low_id, high_id = normalize_pair(company_a, company_b)
|
||||
return confirmed_opening_amount(connection, low_id, high_id) is not None
|
||||
|
||||
|
||||
def compute_pair_balance(
|
||||
connection: sqlite3.Connection,
|
||||
viewer_id: int,
|
||||
counterparty_id: int,
|
||||
*,
|
||||
cutoff: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
cutoff = cutoff or utc_today()
|
||||
cutoff = master_data.validate_date(cutoff, "截止日", required=True)
|
||||
start_date = get_calculation_start_date(connection)
|
||||
low_id, high_id = normalize_pair(viewer_id, counterparty_id)
|
||||
opening_amount: Decimal | None = None
|
||||
if start_date and pair_has_confirmed_opening(connection, viewer_id, counterparty_id):
|
||||
stored = confirmed_opening_amount(connection, low_id, high_id)
|
||||
assert stored is not None
|
||||
opening_amount = signed_from_viewer(viewer_id, low_id, high_id, stored)
|
||||
net_change = _pair_net_change(
|
||||
connection,
|
||||
viewer_id,
|
||||
counterparty_id,
|
||||
cutoff=cutoff,
|
||||
start_date=start_date,
|
||||
)
|
||||
unresolved = matching.unresolved_amounts(connection, viewer_id, cutoff=cutoff)
|
||||
pending_total = Decimal("0")
|
||||
for row in unresolved:
|
||||
pending_total += _parse_decimal(row["amount"] or "0")
|
||||
basis = "full" if opening_amount is not None and start_date else "net_change"
|
||||
payload: dict[str, object] = {
|
||||
"viewer_company_id": viewer_id,
|
||||
"counterparty_company_id": counterparty_id,
|
||||
"effective_at": cutoff,
|
||||
"calculation_start_date": start_date,
|
||||
"net_change": str(net_change),
|
||||
"basis": basis,
|
||||
"pending_unconfirmed": str(pending_total),
|
||||
"currency": "CNY",
|
||||
}
|
||||
if basis == "full" and opening_amount is not None:
|
||||
closing = opening_amount + net_change
|
||||
payload["opening"] = str(opening_amount)
|
||||
payload["closing"] = str(closing)
|
||||
payload["opening_effective_at"] = _date_add(start_date, -1)
|
||||
return payload
|
||||
|
||||
|
||||
def list_company_counterparties(
|
||||
connection: sqlite3.Connection, company_id: int
|
||||
) -> list[int]:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT DISTINCT CASE
|
||||
WHEN payer.company_id = ? THEN payee.company_id
|
||||
ELSE payer.company_id
|
||||
END AS other_id
|
||||
FROM eligible_intercompany_events e
|
||||
JOIN transfer_decision_participants payer ON payer.decision_id = e.decision_id AND payer.role = 'payer'
|
||||
JOIN transfer_decision_participants payee ON payee.decision_id = e.decision_id AND payee.role = 'payee'
|
||||
WHERE payer.company_id = ? OR payee.company_id = ?
|
||||
""",
|
||||
(company_id, company_id, company_id),
|
||||
).fetchall()
|
||||
return [int(row["other_id"]) for row in rows if row["other_id"] is not None]
|
||||
|
||||
|
||||
def compute_company_balances(
|
||||
connection: sqlite3.Connection,
|
||||
company_id: int,
|
||||
*,
|
||||
cutoff: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
cutoff = cutoff or utc_today()
|
||||
start_date = get_calculation_start_date(connection)
|
||||
counterparties = list_company_counterparties(connection, company_id)
|
||||
pairs = [
|
||||
compute_pair_balance(connection, company_id, other_id, cutoff=cutoff)
|
||||
for other_id in counterparties
|
||||
]
|
||||
complete = bool(start_date) and all(item["basis"] == "full" for item in pairs)
|
||||
return {
|
||||
"company_id": company_id,
|
||||
"cutoff": cutoff,
|
||||
"calculation_start_date": start_date,
|
||||
"basis": "full" if complete and pairs else "net_change",
|
||||
"pairs": pairs,
|
||||
}
|
||||
|
||||
|
||||
def opening_coverage_summary(connection: sqlite3.Connection) -> dict[str, int]:
|
||||
companies = connection.execute(
|
||||
"SELECT COUNT(*) AS n FROM companies"
|
||||
).fetchone()["n"]
|
||||
recorded = connection.execute(
|
||||
"""
|
||||
SELECT COUNT(DISTINCT company_id_low || ':' || company_id_high) AS n
|
||||
FROM opening_balance_revisions WHERE status = 'confirmed'
|
||||
"""
|
||||
).fetchone()["n"]
|
||||
return {"company_count": companies, "confirmed_pair_count": recorded}
|
||||
@@ -20,7 +20,7 @@ from decimal import Decimal, InvalidOperation
|
||||
import re
|
||||
import sqlite3
|
||||
|
||||
from . import matching, settings
|
||||
from . import calculation, matching, settings
|
||||
|
||||
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
||||
_ZERO = Decimal("0.00")
|
||||
@@ -72,7 +72,8 @@ def _window_bounds(
|
||||
connection: sqlite3.Connection, as_of: str | None
|
||||
) -> tuple[str, str]:
|
||||
end = _validate_date(as_of, "as_of") if as_of else today_shanghai()
|
||||
start = settings.get_settings(connection).get("start_date") or "2026-01-01"
|
||||
calc_start = calculation.get_calculation_start_date(connection)
|
||||
start = calc_start or settings.get_settings(connection).get("start_date") or "2026-01-01"
|
||||
start = _validate_date(start, "start_date")
|
||||
if start > end:
|
||||
# Opening / start-date plumbing may lag; clamp rather than 500.
|
||||
@@ -282,15 +283,28 @@ def company_intercompany_summary(
|
||||
}
|
||||
)
|
||||
|
||||
# Enrich window with calculation-basis opening/ending when configured.
|
||||
balances = calculation.compute_company_balances(
|
||||
connection, company_id, cutoff=end
|
||||
)
|
||||
has_opening = balances.get("basis") == "full"
|
||||
opening_total = _ZERO
|
||||
ending_total = _ZERO
|
||||
if has_opening:
|
||||
for pair in balances.get("pairs") or []:
|
||||
opening_total += _as_decimal(pair.get("opening") or "0")
|
||||
ending_total += _as_decimal(pair.get("closing") or "0")
|
||||
|
||||
return {
|
||||
"own_company": own,
|
||||
"window": {
|
||||
"start": start,
|
||||
"end": end,
|
||||
"has_opening": False,
|
||||
# Reserved for opening-balance rollout; callers must not invent balances.
|
||||
"opening": None,
|
||||
"ending": None,
|
||||
"has_opening": has_opening,
|
||||
"opening": _money(opening_total) if has_opening else None,
|
||||
"ending": _money(ending_total) if has_opening else None,
|
||||
"basis": balances.get("basis"),
|
||||
"calculation_start_date": balances.get("calculation_start_date"),
|
||||
},
|
||||
"confirmed": {
|
||||
"outflow_total": _money(outflow),
|
||||
|
||||
@@ -846,6 +846,171 @@ MIGRATIONS: tuple[Migration, ...] = (
|
||||
DROP TABLE IF EXISTS system_settings;
|
||||
""",
|
||||
),
|
||||
|
||||
Migration(
|
||||
version=8,
|
||||
name="0008_calculation_window",
|
||||
# HEL-194/202: opening balances, coverage gaps, no-business attestations.
|
||||
# Reuses system_settings / system_setting_changes from 0007; does not
|
||||
# recreate them. Extends master_data_changes CHECK and overlays the
|
||||
# calculation_start_date filter onto eligible_intercompany_events.
|
||||
up="""
|
||||
CREATE TABLE closed_periods (
|
||||
year_month TEXT PRIMARY KEY,
|
||||
closed_at TEXT NOT NULL,
|
||||
closed_by INTEGER REFERENCES users (id)
|
||||
);
|
||||
|
||||
CREATE TABLE opening_balance_revisions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
company_id_low INTEGER NOT NULL REFERENCES companies (id),
|
||||
company_id_high INTEGER NOT NULL REFERENCES companies (id),
|
||||
amount TEXT NOT NULL,
|
||||
currency TEXT NOT NULL DEFAULT 'CNY',
|
||||
revision INTEGER NOT NULL,
|
||||
status TEXT NOT NULL
|
||||
CHECK (status IN ('draft', 'confirmed', 'superseded', 'void')),
|
||||
reason TEXT NOT NULL,
|
||||
actor_user_id INTEGER REFERENCES users (id),
|
||||
actor_username TEXT,
|
||||
supersedes_id INTEGER REFERENCES opening_balance_revisions (id),
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE (company_id_low, company_id_high, revision),
|
||||
CHECK (company_id_low < company_id_high)
|
||||
);
|
||||
|
||||
CREATE TABLE coverage_gaps (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
bank_account_id INTEGER NOT NULL REFERENCES bank_accounts (id),
|
||||
gap_start TEXT NOT NULL,
|
||||
gap_end TEXT NOT NULL,
|
||||
gap_kind TEXT NOT NULL CHECK (gap_kind IN ('head', 'mid', 'tail')),
|
||||
status TEXT NOT NULL DEFAULT 'open'
|
||||
CHECK (status IN ('open', 'closed_attested')),
|
||||
first_detected_at TEXT NOT NULL,
|
||||
UNIQUE (bank_account_id, gap_start, gap_end)
|
||||
);
|
||||
|
||||
CREATE TABLE no_business_attestations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
bank_account_id INTEGER NOT NULL REFERENCES bank_accounts (id),
|
||||
gap_start TEXT NOT NULL,
|
||||
gap_end TEXT NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
evidence TEXT,
|
||||
submitted_by INTEGER REFERENCES users (id),
|
||||
company_id INTEGER NOT NULL REFERENCES companies (id),
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'approved', 'rejected')),
|
||||
reviewed_by INTEGER REFERENCES users (id),
|
||||
reviewed_at TEXT,
|
||||
review_reason TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_opening_balance_pair ON opening_balance_revisions (company_id_low, company_id_high);
|
||||
CREATE INDEX idx_coverage_gaps_account ON coverage_gaps (bank_account_id);
|
||||
CREATE INDEX idx_attestations_company ON no_business_attestations (company_id);
|
||||
|
||||
CREATE TABLE master_data_changes_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
entity_type TEXT NOT NULL
|
||||
CHECK (entity_type IN (
|
||||
'company', 'user', 'bank_account', 'account_alias',
|
||||
'personal_transit_mapping', 'system_setting', 'opening_balance'
|
||||
)),
|
||||
entity_id INTEGER NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
before_json TEXT,
|
||||
after_json TEXT,
|
||||
reason TEXT,
|
||||
actor_user_id INTEGER REFERENCES users (id),
|
||||
actor_username TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO master_data_changes_new SELECT * FROM master_data_changes;
|
||||
DROP TABLE master_data_changes;
|
||||
ALTER TABLE master_data_changes_new RENAME TO master_data_changes;
|
||||
|
||||
DROP VIEW IF EXISTS eligible_intercompany_events;
|
||||
CREATE VIEW eligible_intercompany_events AS
|
||||
SELECT e.id AS event_id, d.id AS decision_id, d.revision AS revision,
|
||||
d.effective_at AS effective_at, d.amount AS amount, d.currency AS currency,
|
||||
payer.company_id AS payer_company_id,
|
||||
payer.bank_account_id AS payer_account_id,
|
||||
payee.company_id AS payee_company_id,
|
||||
payee.bank_account_id AS payee_account_id,
|
||||
d.pairing AS pairing, d.rule_version AS rule_version,
|
||||
(SELECT COUNT(*) FROM transfer_decision_observations o
|
||||
WHERE o.decision_id = d.id) AS evidence_count
|
||||
FROM current_transfer_decisions c
|
||||
JOIN canonical_transfer_events e ON e.id = c.event_id
|
||||
JOIN transfer_match_decisions d ON d.id = c.decision_id
|
||||
JOIN transfer_decision_participants payer
|
||||
ON payer.decision_id = d.id AND payer.role = 'payer'
|
||||
JOIN transfer_decision_participants payee
|
||||
ON payee.decision_id = d.id AND payee.role = 'payee'
|
||||
WHERE e.lifecycle = 'active' AND d.classification = 'intercompany'
|
||||
AND (d.pairing = 'paired' OR d.locked = 1)
|
||||
AND (
|
||||
(SELECT value FROM system_settings WHERE key = 'calculation_start_date') IS NULL
|
||||
OR substr(d.effective_at, 1, 10) >= (
|
||||
SELECT value FROM system_settings WHERE key = 'calculation_start_date'
|
||||
)
|
||||
);
|
||||
""",
|
||||
down="""
|
||||
DROP VIEW IF EXISTS eligible_intercompany_events;
|
||||
CREATE VIEW eligible_intercompany_events AS
|
||||
SELECT e.id AS event_id, d.id AS decision_id, d.revision AS revision,
|
||||
d.effective_at AS effective_at, d.amount AS amount, d.currency AS currency,
|
||||
payer.company_id AS payer_company_id,
|
||||
payer.bank_account_id AS payer_account_id,
|
||||
payee.company_id AS payee_company_id,
|
||||
payee.bank_account_id AS payee_account_id,
|
||||
d.pairing AS pairing, d.rule_version AS rule_version,
|
||||
(SELECT COUNT(*) FROM transfer_decision_observations o
|
||||
WHERE o.decision_id = d.id) AS evidence_count
|
||||
FROM current_transfer_decisions c
|
||||
JOIN canonical_transfer_events e ON e.id = c.event_id
|
||||
JOIN transfer_match_decisions d ON d.id = c.decision_id
|
||||
JOIN transfer_decision_participants payer
|
||||
ON payer.decision_id = d.id AND payer.role = 'payer'
|
||||
JOIN transfer_decision_participants payee
|
||||
ON payee.decision_id = d.id AND payee.role = 'payee'
|
||||
WHERE e.lifecycle = 'active' AND d.classification = 'intercompany'
|
||||
AND (d.pairing = 'paired' OR d.locked = 1);
|
||||
|
||||
CREATE TABLE master_data_changes_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
entity_type TEXT NOT NULL
|
||||
CHECK (entity_type IN (
|
||||
'company', 'user', 'bank_account', 'account_alias',
|
||||
'personal_transit_mapping'
|
||||
)),
|
||||
entity_id INTEGER NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
before_json TEXT,
|
||||
after_json TEXT,
|
||||
reason TEXT,
|
||||
actor_user_id INTEGER REFERENCES users (id),
|
||||
actor_username TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO master_data_changes_new SELECT * FROM master_data_changes
|
||||
WHERE entity_type NOT IN ('system_setting', 'opening_balance');
|
||||
DROP TABLE master_data_changes;
|
||||
ALTER TABLE master_data_changes_new RENAME TO master_data_changes;
|
||||
|
||||
DROP INDEX IF EXISTS idx_attestations_company;
|
||||
DROP INDEX IF EXISTS idx_coverage_gaps_account;
|
||||
DROP INDEX IF EXISTS idx_opening_balance_pair;
|
||||
DROP TABLE IF EXISTS no_business_attestations;
|
||||
DROP TABLE IF EXISTS coverage_gaps;
|
||||
DROP TABLE IF EXISTS opening_balance_revisions;
|
||||
DROP TABLE IF EXISTS closed_periods;
|
||||
""",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1523,7 +1523,7 @@ def unresolved_amounts(
|
||||
count as unresolved; paired intercompany, same-company and external events
|
||||
are resolved classifications and never appear here.
|
||||
"""
|
||||
cutoff_where = "AND d.effective_at <= ?" if cutoff else ""
|
||||
cutoff_where = "AND substr(d.effective_at, 1, 10) <= ?" if cutoff else ""
|
||||
params: list[object] = []
|
||||
if cutoff:
|
||||
params.append(cutoff)
|
||||
|
||||
Reference in New Issue
Block a user