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 @@
| 科目 | -期首 | -借方 | -貸方 | -期末 | -
|---|
| 科目代码 | +科目名称 | +借方合计 | +贷方合计 | +
|---|---|---|---|
| 合计 | +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 @@ -
- -
- - -