From 87fa1aaa1f1aa1d05b3ab55d627e7dfdda78eb46 Mon Sep 17 00:00:00 2001
From: "FNOS-WIN11ENT\\choshoukaku"
Date: Thu, 18 Dec 2025 15:30:39 +0900
Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/app/main.py | 6 +-
backend/app/modules/trial_balance/router.py | 110 ++------
backend/app/modules/trial_balance/service.py | 2 +
backend/app/routers/journal_entries.py | 180 ++++++++++++-
backend/app/routers/month_locks.py | 93 +++++++
backend/app/routers/trial_balance.py | 59 +++++
backend/app/utils/month_lock.py | 17 ++
backend/sql/schema.sql | 47 ++++
frontend/journal-entry.html | 261 +++++++++++++++++--
frontend/js/trial-balance.js | 62 ++---
frontend/trial-balance.html | 81 +++---
メモ.txt | 16 +-
12 files changed, 742 insertions(+), 192 deletions(-)
create mode 100644 backend/app/routers/month_locks.py
create mode 100644 backend/app/routers/trial_balance.py
create mode 100644 backend/app/utils/month_lock.py
create mode 100644 backend/sql/schema.sql
diff --git a/backend/app/main.py b/backend/app/main.py
index a782fdb..3aab2b7 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -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)
diff --git a/backend/app/modules/trial_balance/router.py b/backend/app/modules/trial_balance/router.py
index 531327a..9eecdb9 100644
--- a/backend/app/modules/trial_balance/router.py
+++ b/backend/app/modules/trial_balance/router.py
@@ -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
diff --git a/backend/app/modules/trial_balance/service.py b/backend/app/modules/trial_balance/service.py
index 9a06427..5a3a54b 100644
--- a/backend/app/modules/trial_balance/service.py
+++ b/backend/app/modules/trial_balance/service.py
@@ -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))
diff --git a/backend/app/routers/journal_entries.py b/backend/app/routers/journal_entries.py
index ae249e3..e0d9e12 100644
--- a/backend/app/routers/journal_entries.py
+++ b/backend/app/routers/journal_entries.py
@@ -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
+ }
diff --git a/backend/app/routers/month_locks.py b/backend/app/routers/month_locks.py
new file mode 100644
index 0000000..ff16c4f
--- /dev/null
+++ b/backend/app/routers/month_locks.py
@@ -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
+ }
diff --git a/backend/app/routers/trial_balance.py b/backend/app/routers/trial_balance.py
new file mode 100644
index 0000000..375d12b
--- /dev/null
+++ b/backend/app/routers/trial_balance.py
@@ -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
diff --git a/backend/app/utils/month_lock.py b/backend/app/utils/month_lock.py
new file mode 100644
index 0000000..5e90faa
--- /dev/null
+++ b/backend/app/utils/month_lock.py
@@ -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
diff --git a/backend/sql/schema.sql b/backend/sql/schema.sql
new file mode 100644
index 0000000..464944c
--- /dev/null
+++ b/backend/sql/schema.sql
@@ -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();
diff --git a/frontend/journal-entry.html b/frontend/journal-entry.html
index 477985f..40ee21e 100644
--- a/frontend/journal-entry.html
+++ b/frontend/journal-entry.html
@@ -22,7 +22,7 @@
-
+
@@ -37,6 +37,14 @@
+
+
+
+
- 試算表
+
+
+ 试算表
+
+
+
-
-
-
+試算表
-
-
-
- | 科目 |
- 期首 |
- 借方 |
- 貸方 |
- 期末 |
-
-
-
-
+
+
+
+ | 科目代码 |
+ 科目名称 |
+ 借方合计 |
+ 贷方合计 |
+
+
+
+
+
+
+
+ | 合计 |
+ 0 |
+ 0 |
+
+
+
-
-
-
+
+
+
diff --git a/frontend/js/trial-balance.js b/frontend/js/trial-balance.js
index e93ff4f..92d8464 100644
--- a/frontend/js/trial-balance.js
+++ b/frontend/js/trial-balance.js
@@ -1,40 +1,32 @@
-async function loadTrialBalance() {
- const from = document.getElementById("from").value;
- const to = document.getElementById("to").value;
+document.addEventListener("DOMContentLoaded", () => {
+ console.log("trial-balance.js 已执行");
+ const API_URL = "http://127.0.0.1:18080/trial-balance";
- if (!from || !to) {
- alert("期間を指定してください");
- return;
- }
+ fetch(API_URL)
+ .then(r => { if (!r.ok) throw new Error("API 返回错误: " + r.status); return r.json(); })
+ .then(data => {
- try {
- const data = await apiGet("/trial-balance", {
- date_from: from,
- date_to: to,
- });
+ console.log("试算表 API 数据:", data);
+ const tbody = document.querySelector("#trialBalanceTable tbody");
+ if (!tbody) { console.error("未找到 #trialBalanceTable tbody"); return; }
- const tbody = document.querySelector("#trial-table tbody");
- tbody.innerHTML = "";
+ let totalDebit = 0, totalCredit = 0;
+ data.accounts.forEach(row => {
+ const debit = Number(row.debit ?? 0);
+ const credit = Number(row.credit ?? 0);
+ totalDebit += debit; totalCredit += credit;
- data.accounts.forEach((acc) => {
- const tr = document.createElement("tr");
+ const tr = document.createElement("tr");
+ tr.innerHTML = `
+
`;
+ tbody.appendChild(tr);
+ });
- tr.innerHTML = `
-
- `;
-
- tbody.appendChild(tr);
- });
- } catch (e) {
- alert(e.message);
- }
-}
+ document.getElementById("totalDebit").textContent = totalDebit.toLocaleString();
+ document.getElementById("totalCredit").textContent = totalCredit.toLocaleString();
+ })
+ .catch(err => { console.error("試算表读取失败:", err); alert("試算表读取失败,请查看控制台日志"); });
+});
diff --git a/frontend/trial-balance.html b/frontend/trial-balance.html
index 3e24c0c..dd22787 100644
--- a/frontend/trial-balance.html
+++ b/frontend/trial-balance.html
@@ -1,37 +1,56 @@
-