修正
This commit is contained in:
@@ -4,13 +4,15 @@ load_dotenv()
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import ValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
|
||||
# FastAPI アプリケーション作成
|
||||
app = FastAPI(
|
||||
title="日本小規模企業向け会計システム"
|
||||
)
|
||||
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@@ -64,5 +66,7 @@ app.include_router(opening_balance_lock_router)
|
||||
from app.routers import journal_entries
|
||||
app.include_router(journal_entries.router)
|
||||
|
||||
from app.routers import month_locks
|
||||
app.include_router(month_locks.router)
|
||||
|
||||
|
||||
|
||||
@@ -1,109 +1,29 @@
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from decimal import Decimal
|
||||
from typing import Dict, Any, List
|
||||
from app.core.database import get_connection
|
||||
from typing import Dict, Any, List, Optional
|
||||
from app.modules.trial_balance.service import fetch_trial_balance
|
||||
|
||||
router = APIRouter(prefix="/trial-balance", tags=["試算表"])
|
||||
|
||||
def D(x) -> Decimal:
|
||||
return Decimal(str(x or 0))
|
||||
|
||||
@router.get("", summary="試算表(全科目)")
|
||||
def get_trial_balance(
|
||||
fiscal_year: int = Query(..., description="会計年度(例: 2025)"),
|
||||
date_from: str = Query(..., description="YYYY-MM-DD"),
|
||||
date_to: str = Query(..., description="YYYY-MM-DD"),
|
||||
fiscal_year: Optional[int] = Query(None, description="会計年度(例: 2025)"),
|
||||
date_from: Optional[str] = Query(None, description="YYYY-MM-DD"),
|
||||
date_to: Optional[str] = Query(None, description="YYYY-MM-DD"),
|
||||
):
|
||||
print("trial-balance NEW VERSION (optional params)")
|
||||
|
||||
# 👉 没传参数时,给默认值(开发期非常推荐)
|
||||
if fiscal_year is None:
|
||||
fiscal_year = 2025
|
||||
if date_from is None:
|
||||
date_from = "2025-01-01"
|
||||
if date_to is None:
|
||||
date_to = "2025-12-31"
|
||||
|
||||
if date_from > date_to:
|
||||
raise HTTPException(status_code=400, detail="期間指定が不正です。")
|
||||
|
||||
result = fetch_trial_balance(date_from, date_to)
|
||||
|
||||
return {
|
||||
"period": {
|
||||
"from": date_from,
|
||||
"to": date_to
|
||||
},
|
||||
**result
|
||||
}
|
||||
|
||||
with get_connection() as conn, conn.cursor() as cur:
|
||||
|
||||
# 全科目取得
|
||||
cur.execute("""
|
||||
SELECT account_id, account_code, account_name, account_type
|
||||
FROM accounts
|
||||
WHERE is_active = true
|
||||
ORDER BY account_code
|
||||
""")
|
||||
accounts = cur.fetchall()
|
||||
|
||||
result_accounts: List[Dict[str, Any]] = []
|
||||
|
||||
total_opening = Decimal("0")
|
||||
total_debit = Decimal("0")
|
||||
total_credit = Decimal("0")
|
||||
total_closing = Decimal("0")
|
||||
|
||||
for acc in accounts:
|
||||
aid = acc["account_id"]
|
||||
|
||||
# 期首残高(opening_balances)
|
||||
cur.execute("""
|
||||
SELECT
|
||||
COALESCE(opening_debit, 0) - COALESCE(opening_credit, 0) AS opening
|
||||
FROM opening_balances
|
||||
WHERE fiscal_year = %s
|
||||
AND account_id = %s
|
||||
""", (fiscal_year, aid))
|
||||
|
||||
row = cur.fetchone()
|
||||
opening = D(row["opening"]) if row else D(0)
|
||||
|
||||
|
||||
# 当期
|
||||
cur.execute("""
|
||||
SELECT
|
||||
COALESCE(SUM(l.debit), 0) AS debit,
|
||||
COALESCE(SUM(l.credit), 0) AS credit
|
||||
FROM journal_lines l
|
||||
JOIN journal_entries j ON j.journal_id = l.journal_id
|
||||
WHERE l.account_id = %s
|
||||
AND j.journal_date BETWEEN %s AND %s
|
||||
""", (aid, date_from, date_to))
|
||||
row = cur.fetchone()
|
||||
debit = D(row["debit"])
|
||||
credit = D(row["credit"])
|
||||
|
||||
closing = opening + debit - credit
|
||||
|
||||
total_opening += opening
|
||||
total_debit += debit
|
||||
total_credit += credit
|
||||
total_closing += closing
|
||||
|
||||
result_accounts.append({
|
||||
"account_id": aid,
|
||||
"account_code": acc["account_code"],
|
||||
"account_name": acc["account_name"],
|
||||
"account_type": acc["account_type"],
|
||||
"opening_balance": str(opening),
|
||||
"debit": str(debit),
|
||||
"credit": str(credit),
|
||||
"closing_balance": str(closing),
|
||||
})
|
||||
|
||||
return {
|
||||
"period": {
|
||||
"from": date_from,
|
||||
"to": date_to
|
||||
},
|
||||
"totals": {
|
||||
"opening_balance": str(total_opening),
|
||||
"debit": str(total_debit),
|
||||
"credit": str(total_credit),
|
||||
"closing_balance": str(total_closing)
|
||||
},
|
||||
"accounts": result_accounts
|
||||
}
|
||||
return result
|
||||
|
||||
@@ -33,6 +33,7 @@ def fetch_trial_balance(date_from: str, date_to: str):
|
||||
SELECT COALESCE(SUM(l.debit - l.credit), 0) AS opening
|
||||
FROM journal_lines l
|
||||
JOIN journal_entries j ON j.journal_id = l.journal_id
|
||||
AND j.is_deleted = false
|
||||
WHERE l.account_id = %s
|
||||
AND j.journal_date < %s
|
||||
""", (aid, date_from))
|
||||
@@ -45,6 +46,7 @@ def fetch_trial_balance(date_from: str, date_to: str):
|
||||
COALESCE(SUM(l.credit), 0) AS credit
|
||||
FROM journal_lines l
|
||||
JOIN journal_entries j ON j.journal_id = l.journal_id
|
||||
AND j.is_deleted = false
|
||||
WHERE l.account_id = %s
|
||||
AND j.journal_date BETWEEN %s AND %s
|
||||
""", (aid, date_from, date_to))
|
||||
|
||||
@@ -19,24 +19,69 @@ class JournalEntryRequest(BaseModel):
|
||||
description: str
|
||||
lines: List[JournalLine]
|
||||
|
||||
class JournalEntryUpdateRequest(BaseModel):
|
||||
description: str
|
||||
lines: List[JournalLine]
|
||||
updated_reason: str
|
||||
|
||||
@router.post("")
|
||||
|
||||
class JournalEntryDeleteRequest(BaseModel):
|
||||
deleted_reason: str
|
||||
|
||||
@router.post("", summary="仕訳登録")
|
||||
def create_journal_entry(req: JournalEntryRequest):
|
||||
|
||||
from app.utils.month_lock import is_month_locked
|
||||
|
||||
if is_month_locked(req.entry_date):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="该月份已锁定,不能新增仕訳"
|
||||
)
|
||||
|
||||
if not req.lines:
|
||||
raise HTTPException(status_code=400, detail="仕訳明細不能为空")
|
||||
|
||||
# (可选)简单的借贷平衡校验
|
||||
total_debit = sum(l.debit for l in req.lines)
|
||||
total_credit = sum(l.credit for l in req.lines)
|
||||
if total_debit != total_credit:
|
||||
raise HTTPException(status_code=400, detail="借方和贷方不平衡")
|
||||
|
||||
fiscal_year = req.entry_date.year
|
||||
|
||||
with get_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
# 仕訳ヘッダ
|
||||
cur.execute("""
|
||||
INSERT INTO journal_entries (entry_date, description)
|
||||
VALUES (%s, %s)
|
||||
RETURNING id
|
||||
""", (req.entry_date, req.description))
|
||||
entry_id = cur.fetchone()[0]
|
||||
INSERT INTO journal_entries (
|
||||
journal_date,
|
||||
description,
|
||||
fiscal_year,
|
||||
version,
|
||||
is_deleted,
|
||||
created_by
|
||||
)
|
||||
VALUES (%s, %s, %s, 1, false, %s)
|
||||
RETURNING journal_id
|
||||
""", (
|
||||
req.entry_date,
|
||||
req.description,
|
||||
fiscal_year,
|
||||
"system" # 以后可以换成登录用户
|
||||
))
|
||||
|
||||
entry_id = cur.fetchone()["journal_id"]
|
||||
|
||||
# 明細
|
||||
for line in req.lines:
|
||||
cur.execute("""
|
||||
INSERT INTO journal_lines
|
||||
(journal_entry_id, account_id, debit, credit)
|
||||
INSERT INTO journal_lines (
|
||||
journal_id,
|
||||
account_id,
|
||||
debit,
|
||||
credit
|
||||
)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
""", (
|
||||
entry_id,
|
||||
@@ -47,4 +92,121 @@ def create_journal_entry(req: JournalEntryRequest):
|
||||
|
||||
conn.commit()
|
||||
|
||||
return {"status": "ok", "journal_entry_id": entry_id}
|
||||
return {
|
||||
"status": "ok",
|
||||
"journal_entry_id": entry_id
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{journal_id}")
|
||||
def update_journal_entry(journal_id: int, req: JournalEntryUpdateRequest):
|
||||
from app.utils.month_lock import is_month_locked
|
||||
|
||||
with get_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
|
||||
# ① 取得现有仕訳
|
||||
cur.execute("""
|
||||
SELECT journal_date, is_deleted, version
|
||||
FROM journal_entries
|
||||
WHERE journal_id = %s
|
||||
""", (journal_id,))
|
||||
row = cur.fetchone()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="仕訳が存在しません")
|
||||
|
||||
if row["is_deleted"]:
|
||||
raise HTTPException(status_code=400, detail="削除済み仕訳は更新できません")
|
||||
|
||||
journal_date = row["journal_date"]
|
||||
|
||||
# 借贷平衡校验(update)
|
||||
total_debit = sum(l.debit for l in req.lines)
|
||||
total_credit = sum(l.credit for l in req.lines)
|
||||
|
||||
if total_debit != total_credit:
|
||||
raise HTTPException(status_code=400, detail="借方和贷方不平衡")
|
||||
|
||||
|
||||
# ② 月次锁定检查
|
||||
if is_month_locked(journal_date):
|
||||
raise HTTPException(status_code=400, detail="该月份已锁定,不能修改仕訳")
|
||||
|
||||
# ③ 更新 header
|
||||
cur.execute("""
|
||||
UPDATE journal_entries
|
||||
SET description = %s,
|
||||
version = version + 1,
|
||||
updated_at = NOW(),
|
||||
updated_reason = %s
|
||||
WHERE journal_id = %s
|
||||
""", (
|
||||
req.description,
|
||||
req.updated_reason,
|
||||
journal_id
|
||||
))
|
||||
|
||||
# ④ 删除原有明细(物理删除,OK)
|
||||
cur.execute("""
|
||||
DELETE FROM journal_lines
|
||||
WHERE journal_id = %s
|
||||
""", (journal_id,))
|
||||
|
||||
# ⑤ 插入新明细
|
||||
for line in req.lines:
|
||||
cur.execute("""
|
||||
INSERT INTO journal_lines (
|
||||
journal_id,
|
||||
account_id,
|
||||
debit,
|
||||
credit
|
||||
) VALUES (%s, %s, %s, %s)
|
||||
""", (
|
||||
journal_id,
|
||||
line.account_id,
|
||||
line.debit,
|
||||
line.credit
|
||||
))
|
||||
|
||||
conn.commit()
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"journal_id": journal_id,
|
||||
"message": "仕訳を更新しました"
|
||||
}
|
||||
|
||||
|
||||
|
||||
@router.delete("/{journal_id}", summary="仕訳删除(软删除)")
|
||||
def delete_journal_entry(journal_id: int, req: JournalEntryDeleteRequest):
|
||||
|
||||
with get_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
UPDATE journal_entries
|
||||
SET is_deleted = true,
|
||||
deleted_at = NOW(),
|
||||
deleted_reason = %s
|
||||
WHERE journal_id = %s
|
||||
AND is_deleted = false
|
||||
RETURNING journal_id
|
||||
""", (
|
||||
req.deleted_reason,
|
||||
journal_id
|
||||
))
|
||||
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="仕訳不存在,或已被删除"
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
return {
|
||||
"status": "deleted",
|
||||
"journal_entry_id": journal_id
|
||||
}
|
||||
|
||||
93
backend/app/routers/month_locks.py
Normal file
93
backend/app/routers/month_locks.py
Normal file
@@ -0,0 +1,93 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from app.core.database import get_connection
|
||||
|
||||
router = APIRouter(prefix="/month-locks", tags=["月次锁定"])
|
||||
|
||||
|
||||
# ---------- GET:月次锁定一覧 ----------
|
||||
|
||||
@router.get("", summary="月次锁定一覧取得")
|
||||
def get_month_locks():
|
||||
with get_connection() as conn, conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT
|
||||
fiscal_year,
|
||||
month,
|
||||
is_locked,
|
||||
locked_at,
|
||||
locked_by
|
||||
FROM month_locks
|
||||
ORDER BY fiscal_year, month
|
||||
""")
|
||||
return cur.fetchall()
|
||||
|
||||
|
||||
# ---------- POST:锁定月份 ----------
|
||||
|
||||
@router.post("/lock", summary="锁定月份")
|
||||
def lock_month(fiscal_year: int, month: int):
|
||||
# 参数校验
|
||||
if not (1 <= month <= 12):
|
||||
raise HTTPException(status_code=400, detail="month 必须是 1~12")
|
||||
|
||||
with get_connection() as conn, conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
INSERT INTO month_locks (
|
||||
fiscal_year,
|
||||
month,
|
||||
is_locked,
|
||||
locked_at,
|
||||
locked_by
|
||||
)
|
||||
VALUES (%s, %s, true, NOW(), %s)
|
||||
ON CONFLICT (fiscal_year, month)
|
||||
DO UPDATE
|
||||
SET is_locked = true,
|
||||
locked_at = NOW(),
|
||||
locked_by = EXCLUDED.locked_by
|
||||
""", (
|
||||
fiscal_year,
|
||||
month,
|
||||
"system" # 将来可替换为登录用户
|
||||
))
|
||||
conn.commit()
|
||||
|
||||
return {
|
||||
"status": "locked",
|
||||
"fiscal_year": fiscal_year,
|
||||
"month": month
|
||||
}
|
||||
|
||||
|
||||
# ---------- POST:解锁月份 ----------
|
||||
|
||||
@router.post("/unlock", summary="解锁月份")
|
||||
def unlock_month(fiscal_year: int, month: int):
|
||||
# 参数校验
|
||||
if not (1 <= month <= 12):
|
||||
raise HTTPException(status_code=400, detail="month 必须是 1~12")
|
||||
|
||||
with get_connection() as conn, conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
UPDATE month_locks
|
||||
SET is_locked = false
|
||||
WHERE fiscal_year = %s
|
||||
AND month = %s
|
||||
""", (
|
||||
fiscal_year,
|
||||
month
|
||||
))
|
||||
|
||||
if cur.rowcount == 0:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="指定月份不存在"
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
return {
|
||||
"status": "unlocked",
|
||||
"fiscal_year": fiscal_year,
|
||||
"month": month
|
||||
}
|
||||
59
backend/app/routers/trial_balance.py
Normal file
59
backend/app/routers/trial_balance.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from fastapi import APIRouter, Query
|
||||
from datetime import date
|
||||
from typing import Optional
|
||||
from app.core.database import get_connection
|
||||
|
||||
router = APIRouter(prefix="/trial-balance", tags=["试算表"])
|
||||
|
||||
|
||||
@router.get("", summary="试算表(全科目)")
|
||||
def get_trial_balance(
|
||||
fiscal_year: Optional[int] = Query(None, description="会计年度(可选)"),
|
||||
date_from: Optional[date] = Query(None, description="开始日期(可选)"),
|
||||
date_to: Optional[date] = Query(None, description="结束日期(可选)"),
|
||||
):
|
||||
print("### trial-balance OPTIONAL PARAMS ACTIVE ###")
|
||||
|
||||
sql = """
|
||||
SELECT
|
||||
a.account_id,
|
||||
a.account_code,
|
||||
a.account_name,
|
||||
a.account_type,
|
||||
COALESCE(SUM(l.debit), 0) AS debit,
|
||||
COALESCE(SUM(l.credit), 0) AS credit
|
||||
FROM journal_lines l
|
||||
JOIN journal_entries e ON e.journal_id = l.journal_id
|
||||
JOIN accounts a ON a.account_id = l.account_id
|
||||
WHERE 1 = 1
|
||||
"""
|
||||
|
||||
params = {}
|
||||
|
||||
if fiscal_year is not None:
|
||||
sql += " AND e.fiscal_year = %(fiscal_year)s"
|
||||
params["fiscal_year"] = fiscal_year
|
||||
|
||||
if date_from is not None:
|
||||
sql += " AND e.journal_date >= %(date_from)s"
|
||||
params["date_from"] = date_from
|
||||
|
||||
if date_to is not None:
|
||||
sql += " AND e.journal_date <= %(date_to)s"
|
||||
params["date_to"] = date_to
|
||||
|
||||
sql += """
|
||||
GROUP BY
|
||||
a.account_id,
|
||||
a.account_code,
|
||||
a.account_name,
|
||||
a.account_type
|
||||
ORDER BY
|
||||
a.account_code
|
||||
"""
|
||||
|
||||
with get_connection() as conn, conn.cursor() as cur:
|
||||
cur.execute(sql, params)
|
||||
rows = cur.fetchall()
|
||||
|
||||
return rows
|
||||
17
backend/app/utils/month_lock.py
Normal file
17
backend/app/utils/month_lock.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from datetime import date
|
||||
from app.core.database import get_connection
|
||||
|
||||
def is_month_locked(target_date: date) -> bool:
|
||||
fiscal_year = target_date.year
|
||||
month = target_date.month
|
||||
|
||||
with get_connection() as conn, conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT 1
|
||||
FROM month_locks
|
||||
WHERE fiscal_year = %s
|
||||
AND month = %s
|
||||
AND is_locked = true
|
||||
""", (fiscal_year, month))
|
||||
|
||||
return cur.fetchone() is not None
|
||||
47
backend/sql/schema.sql
Normal file
47
backend/sql/schema.sql
Normal file
@@ -0,0 +1,47 @@
|
||||
CREATE OR REPLACE FUNCTION fn_check_month_locked(p_date date)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM month_locks
|
||||
WHERE lock_month = date_trunc('month', p_date)
|
||||
AND is_locked = true
|
||||
) THEN
|
||||
RAISE EXCEPTION
|
||||
'该月份已锁定,不能操作该仕訳(%)',
|
||||
to_char(p_date, 'YYYY-MM');
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION trg_journal_entries_month_lock()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
PERFORM fn_check_month_locked(NEW.journal_date);
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF TG_OP = 'UPDATE' THEN
|
||||
PERFORM fn_check_month_locked(OLD.journal_date);
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
PERFORM fn_check_month_locked(OLD.journal_date);
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER trg_journal_entries_month_lock
|
||||
BEFORE INSERT OR UPDATE OR DELETE
|
||||
ON journal_entries
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION trg_journal_entries_month_lock();
|
||||
Reference in New Issue
Block a user