This commit is contained in:
FNOS-WIN11ENT\choshoukaku
2025-12-18 15:30:39 +09:00
parent 88163423e0
commit 87fa1aaa1f
12 changed files with 742 additions and 192 deletions

View File

@@ -4,13 +4,15 @@ load_dotenv()
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from pydantic import ValidationError from pydantic import ValidationError
from fastapi.middleware.cors import CORSMiddleware
# FastAPI アプリケーション作成 # FastAPI アプリケーション作成
app = FastAPI( app = FastAPI(
title="日本小規模企業向け会計システム" title="日本小規模企業向け会計システム"
) )
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
@@ -64,5 +66,7 @@ app.include_router(opening_balance_lock_router)
from app.routers import journal_entries from app.routers import journal_entries
app.include_router(journal_entries.router) app.include_router(journal_entries.router)
from app.routers import month_locks
app.include_router(month_locks.router)

View File

@@ -1,109 +1,29 @@
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from decimal import Decimal from decimal import Decimal
from typing import Dict, Any, List from typing import Dict, Any, List, Optional
from app.core.database import get_connection
from app.modules.trial_balance.service import fetch_trial_balance from app.modules.trial_balance.service import fetch_trial_balance
router = APIRouter(prefix="/trial-balance", tags=["試算表"]) router = APIRouter(prefix="/trial-balance", tags=["試算表"])
def D(x) -> Decimal:
return Decimal(str(x or 0))
@router.get("", summary="試算表(全科目)") @router.get("", summary="試算表(全科目)")
def get_trial_balance( def get_trial_balance(
fiscal_year: int = Query(..., description="会計年度(例: 2025"), fiscal_year: Optional[int] = Query(None, description="会計年度(例: 2025"),
date_from: str = Query(..., description="YYYY-MM-DD"), date_from: Optional[str] = Query(None, description="YYYY-MM-DD"),
date_to: str = Query(..., 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: if date_from > date_to:
raise HTTPException(status_code=400, detail="期間指定が不正です。") raise HTTPException(status_code=400, detail="期間指定が不正です。")
result = fetch_trial_balance(date_from, date_to) result = fetch_trial_balance(date_from, date_to)
return { return result
"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
}

View File

@@ -33,6 +33,7 @@ def fetch_trial_balance(date_from: str, date_to: str):
SELECT COALESCE(SUM(l.debit - l.credit), 0) AS opening SELECT COALESCE(SUM(l.debit - l.credit), 0) AS opening
FROM journal_lines l FROM journal_lines l
JOIN journal_entries j ON j.journal_id = l.journal_id JOIN journal_entries j ON j.journal_id = l.journal_id
AND j.is_deleted = false
WHERE l.account_id = %s WHERE l.account_id = %s
AND j.journal_date < %s AND j.journal_date < %s
""", (aid, date_from)) """, (aid, date_from))
@@ -45,6 +46,7 @@ def fetch_trial_balance(date_from: str, date_to: str):
COALESCE(SUM(l.credit), 0) AS credit COALESCE(SUM(l.credit), 0) AS credit
FROM journal_lines l FROM journal_lines l
JOIN journal_entries j ON j.journal_id = l.journal_id JOIN journal_entries j ON j.journal_id = l.journal_id
AND j.is_deleted = false
WHERE l.account_id = %s WHERE l.account_id = %s
AND j.journal_date BETWEEN %s AND %s AND j.journal_date BETWEEN %s AND %s
""", (aid, date_from, date_to)) """, (aid, date_from, date_to))

View File

@@ -19,24 +19,69 @@ class JournalEntryRequest(BaseModel):
description: str description: str
lines: List[JournalLine] 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): 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 get_connection() as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
# 仕訳ヘッダ # 仕訳ヘッダ
cur.execute(""" cur.execute("""
INSERT INTO journal_entries (entry_date, description) INSERT INTO journal_entries (
VALUES (%s, %s) journal_date,
RETURNING id description,
""", (req.entry_date, req.description)) fiscal_year,
entry_id = cur.fetchone()[0] 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: for line in req.lines:
cur.execute(""" cur.execute("""
INSERT INTO journal_lines INSERT INTO journal_lines (
(journal_entry_id, account_id, debit, credit) journal_id,
account_id,
debit,
credit
)
VALUES (%s, %s, %s, %s) VALUES (%s, %s, %s, %s)
""", ( """, (
entry_id, entry_id,
@@ -47,4 +92,121 @@ def create_journal_entry(req: JournalEntryRequest):
conn.commit() 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
}

View 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 必须是 112")
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 必须是 112")
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
}

View 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

View 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
View 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();

View File

@@ -22,7 +22,7 @@
<div class="row"> <div class="row">
<label>仕訳日</label> <label>仕訳日</label>
<input type="date" id="entryDate" /> <input type="date" id="entryDate" onchange="onEntryDateChanged()"/>
</div> </div>
<div class="row"> <div class="row">
@@ -37,6 +37,14 @@
<label>仮受消費税等 勘定</label> <label>仮受消費税等 勘定</label>
<select id="taxReceivedSelect"></select> <select id="taxReceivedSelect"></select>
</div> </div>
<div style="margin-bottom: 10px;">
<label>消費税 端数処理:</label>
<select id="roundingMode">
<option value="round">四捨五入</option>
<option value="floor">切捨て</option>
<option value="ceil">切上げ</option>
</select>
</div>
<table id="linesTable"> <table id="linesTable">
<thead> <thead>
@@ -57,7 +65,43 @@
<button onclick="submitJournal()">登録</button> <button onclick="submitJournal()">登録</button>
</div> </div>
<h3>月次锁定管理</h3>
<div style="margin-bottom: 8px;">
<label>年度:</label>
<input type="number" id="lockYear" value="2025" style="width:80px;">
<label>月份:</label>
<input type="number" id="lockMonth" min="1" max="12" value="6" style="width:60px;">
</div>
<button onclick="lockMonth()">🔒 锁定</button>
<button onclick="unlockMonth()">🔓 解锁</button>
<div id="lockResult" style="margin-top:10px;color:#333;"></div>
<div style="margin-top:12px;">
<button onclick="refreshMonthLocks()">🔄 刷新锁定列表</button>
<pre id="lockList" style="background:#f7f7f7;padding:8px;"></pre>
</div>
<div id="monthLockBanner"
style="display:none;
margin:8px 0;
padding:6px 10px;
background:#ffecec;
border:1px solid #ffaaaa;
color:#a00000;">
🔒 该月份已锁定,不能编辑仕訳
</div>
<script> <script>
let rowSeq = 0; // 各行唯一ID
const API = "http://127.0.0.1:18080"; const API = "http://127.0.0.1:18080";
let accounts = []; let accounts = [];
@@ -115,10 +159,16 @@ function setupTaxSelectors() {
// ----------------------------- // -----------------------------
// 行追加 // 行追加
// ----------------------------- // -----------------------------
function addRow(isTaxRow = false, taxInfo = null) { function addRow(isTaxRow = false, taxInfo = null, parentId = null) {
const tbody = document.querySelector("#linesTable tbody"); const tbody = document.querySelector("#linesTable tbody");
const tr = document.createElement("tr"); const tr = document.createElement("tr");
if (isTaxRow) tr.classList.add("tax-row");
if (!isTaxRow) {
tr.dataset.rowId = String(++rowSeq);
} else {
tr.classList.add("tax-row");
tr.dataset.parentId = parentId;
}
tr.innerHTML = ` tr.innerHTML = `
<td> <td>
@@ -149,26 +199,46 @@ function addRow(isTaxRow = false, taxInfo = null) {
tbody.appendChild(tr); tbody.appendChild(tr);
if (!isTaxRow) { // 税行の場合,填值
tr.querySelector(".debit").addEventListener("input", () => handleTax(tr));
tr.querySelector(".credit").addEventListener("input", () => handleTax(tr));
tr.querySelector(".taxType").addEventListener("change", () => handleTax(tr));
tr.querySelector(".taxDirection").addEventListener("change", () => handleTax(tr));
}
if (isTaxRow && taxInfo) { if (isTaxRow && taxInfo) {
const acc = tr.querySelector(".account"); const acc = tr.querySelector(".account");
acc.value = taxInfo.accountId || ""; acc.value = taxInfo.accountId || "";
if (taxInfo.side === "debit") tr.querySelector(".debit").value = taxInfo.amount; if (taxInfo.side === "debit") tr.querySelector(".debit").value = taxInfo.amount;
if (taxInfo.side === "credit") tr.querySelector(".credit").value = taxInfo.amount; if (taxInfo.side === "credit") tr.querySelector(".credit").value = taxInfo.amount;
} }
// 普通行:绑定事件
if (!isTaxRow) {
const recalc = () => handleTax(tr);
tr.querySelector(".debit").addEventListener("input", recalc);
tr.querySelector(".credit").addEventListener("input", recalc);
tr.querySelector(".taxType").addEventListener("change", recalc);
tr.querySelector(".taxDirection").addEventListener("change", recalc);
}
return tr;
} }
// 删除该行的所有税行
function removeTaxRowsOf(row) {
const id = row.dataset.rowId;
if (!id) return;
document.querySelectorAll(`tr.tax-row[data-parent-id="${id}"]`).forEach(r => r.remove());
}
function removeRow(btn) {
const row = btn.closest("tr");
removeTaxRowsOf(row);
row.remove();
}
// ----------------------------- // -----------------------------
// 税行 自動生成/更新10%/8%のみ、四捨五入) // 税行 自動生成/更新10%/8%のみ、四捨五入)
// ----------------------------- // -----------------------------
function handleTax(row) { function handleTax(row) {
removeTaxRow(row); // 清理旧税行
removeTaxRowsOf(row);
const taxType = row.querySelector(".taxType").value; const taxType = row.querySelector(".taxType").value;
const taxDir = row.querySelector(".taxDirection").value; const taxDir = row.querySelector(".taxDirection").value;
@@ -176,28 +246,48 @@ function handleTax(row) {
const credit = Number(row.querySelector(".credit").value || 0); const credit = Number(row.querySelector(".credit").value || 0);
if (taxType === "none" || taxDir === "none") return; if (taxType === "none" || taxDir === "none") return;
const baseAmount = debit || credit;
if (!baseAmount) return; const totalAmount = debit || credit;
if (!totalAmount) return;
const rate = taxType === "8" ? 0.08 : 0.10; const rate = taxType === "8" ? 0.08 : 0.10;
const taxAmount = Math.round(baseAmount * rate); const roundingMode = document.getElementById("roundingMode").value;
// 税込 → 税額逆算
const rawTax = totalAmount * rate / (1 + rate);
let taxAmount;
switch (roundingMode) {
case "floor": taxAmount = Math.floor(rawTax); break;
case "ceil": taxAmount = Math.ceil(rawTax); break;
default: taxAmount = Math.round(rawTax);
}
const parentId = row.dataset.rowId;
if (taxDir === "paid") { if (taxDir === "paid") {
const taxAcc = document.getElementById("taxPaidSelect").value; const taxAcc = document.getElementById("taxPaidSelect").value;
if (!taxAcc) { alert("仮払消費税等 勘定を選択してください"); return; } if (!taxAcc) return alert("仮払消費税等 勘定を選択してください");
addRow(true, { accountId: taxAcc, amount: taxAmount, side: "debit" }); addRow(true, { accountId: taxAcc, amount: taxAmount, side: "debit" }, parentId);
} }
if (taxDir === "received") { if (taxDir === "received") {
const taxAcc = document.getElementById("taxReceivedSelect").value; const taxAcc = document.getElementById("taxReceivedSelect").value;
if (!taxAcc) { alert("仮受消費税等 勘定を選択してください"); return; } if (!taxAcc) return alert("仮受消費税等 勘定を選択してください");
addRow(true, { accountId: taxAcc, amount: taxAmount, side: "credit" }); addRow(true, { accountId: taxAcc, amount: taxAmount, side: "credit" }, parentId);
} }
} }
// 税行は常に「直後の1行」を使う // 税行は常に「直後の1行」を使う
function removeTaxRow(row) { function removeTaxRow(row) {
const next = row.nextElementSibling; const id = row.dataset.rowId;
if (next && next.classList.contains("tax-row")) next.remove(); if (!id) return;
document
.querySelectorAll(`tr.tax-row[data-parent-id="${id}"]`)
.forEach(tr => tr.remove());
} }
function removeRow(btn) { function removeRow(btn) {
@@ -254,6 +344,137 @@ async function submitJournal() {
alert(`登録完了ID=${result.journal_entry_id}`); alert(`登録完了ID=${result.journal_entry_id}`);
location.reload(); location.reload();
} }
const API_BASE = "http://127.0.0.1:18080";
async function callApi(path) {
try {
const res = await fetch(`${API_BASE}${path}`, { method: "POST" });
const text = await res.text();
let data = {};
try { data = text ? JSON.parse(text) : {}; } catch { data = { detail: text }; }
if (!res.ok) {
throw new Error(data.detail || `HTTP ${res.status}`);
}
return data;
} catch (e) {
throw new Error(e.message || "请求失败");
}
}
async function lockMonth() {
const year = document.getElementById("lockYear").value;
const month = document.getElementById("lockMonth").value;
const el = document.getElementById("lockResult");
el.innerText = "处理中...";
try {
await callApi(`/month-locks/lock?fiscal_year=${year}&month=${month}`);
el.innerText = `${year}${month}月 已锁定`;
await refreshMonthLocks(); // 可选:自动刷新列表
} catch (e) {
el.innerText = `❌ 错误:${e.message}`;
}
}
async function unlockMonth() {
const year = document.getElementById("lockYear").value;
const month = document.getElementById("lockMonth").value;
const el = document.getElementById("lockResult");
el.innerText = "处理中...";
try {
await callApi(`/month-locks/unlock?fiscal_year=${year}&month=${month}`);
el.innerText = `${year}${month}月 已解锁`;
await refreshMonthLocks(); // 可选:自动刷新列表
} catch (e) {
el.innerText = `❌ 错误:${e.message}`;
}
}
async function refreshMonthLocks() {
const el = document.getElementById("lockList");
el.textContent = "读取中...";
try {
const res = await fetch(`${API_BASE}/month-locks`);
const data = await res.json();
// 只显示 locked 的,避免太长
const locked = (data || []).filter(x => x.is_locked);
if (locked.length === 0) {
el.textContent = "(当前没有锁定月份)";
return;
}
el.textContent = locked
.map(x => `${x.fiscal_year}-${String(x.month).padStart(2, "0")} locked_by=${x.locked_by ?? ""}`)
.join("\n");
} catch (e) {
el.textContent = `读取失败:${e.message || e}`;
}
}
async function checkMonthLockByDate(dateStr) {
if (!dateStr) return false;
const d = new Date(dateStr);
const year = d.getFullYear();
const month = d.getMonth() + 1;
const res = await fetch(`${API_BASE}/month-locks`);
const data = await res.json();
return (data || []).some(
x => x.fiscal_year === year && x.month === month && x.is_locked
);
}
async function onEntryDateChanged() {
const dateStr = document.getElementById("entryDate").value;
const locked = await checkMonthLockByDate(dateStr);
const banner = document.getElementById("monthLockBanner");
if (locked) {
banner.style.display = "block";
setJournalReadonly(true);
} else {
banner.style.display = "none";
setJournalReadonly(false);
}
}
function setJournalReadonly(readonly) {
// 保存按钮
const saveBtn = document.getElementById("saveButton");
if (saveBtn) saveBtn.disabled = readonly;
// 行追加按钮
const addRowBtn = document.getElementById("addRowButton");
if (addRowBtn) addRowBtn.disabled = readonly;
// 所有“删除行”按钮
document.querySelectorAll(".delete-row-btn").forEach(btn => {
btn.disabled = readonly;
});
// 所有仕訳输入项(金额 / 科目等)
document.querySelectorAll(".journal-input").forEach(input => {
input.disabled = readonly;
});
}
</script> </script>
</body> </body>

View File

@@ -1,40 +1,32 @@
async function loadTrialBalance() { document.addEventListener("DOMContentLoaded", () => {
const from = document.getElementById("from").value; console.log("trial-balance.js 已执行");
const to = document.getElementById("to").value; const API_URL = "http://127.0.0.1:18080/trial-balance";
if (!from || !to) { fetch(API_URL)
alert("期間を指定してください"); .then(r => { if (!r.ok) throw new Error("API 返回错误: " + r.status); return r.json(); })
return; .then(data => {
}
try { console.log("试算表 API 数据:", data);
const data = await apiGet("/trial-balance", { const tbody = document.querySelector("#trialBalanceTable tbody");
date_from: from, if (!tbody) { console.error("未找到 #trialBalanceTable tbody"); return; }
date_to: to,
});
const tbody = document.querySelector("#trial-table tbody"); let totalDebit = 0, totalCredit = 0;
tbody.innerHTML = ""; 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 = `
<td class="left">${row.account_code}</td>
<td class="left">${row.account_name}</td>
<td>${debit.toLocaleString()}</td>
<td>${credit.toLocaleString()}</td>`;
tbody.appendChild(tr);
});
tr.innerHTML = ` document.getElementById("totalDebit").textContent = totalDebit.toLocaleString();
<td>${acc.account_code}</td> document.getElementById("totalCredit").textContent = totalCredit.toLocaleString();
<td> })
<a href="ledger.html?account_id=${acc.account_id}&from=${from}&to=${to}"> .catch(err => { console.error("試算表读取失败:", err); alert("試算表读取失败,请查看控制台日志"); });
${acc.account_name} });
</a>
</td>
<td>${acc.opening_balance}</td>
<td>${acc.debit}</td>
<td>${acc.credit}</td>
<td>${acc.closing_balance}</td>
`;
tbody.appendChild(tr);
});
} catch (e) {
alert(e.message);
}
}

View File

@@ -1,37 +1,56 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ja"> <html lang="ja">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<title>算表</title> <title>算表</title>
<link rel="stylesheet" href="css/style.css" /> <style>
</head> table {
<body> border-collapse: collapse;
<h1>試算表</h1> width: 100%;
}
th, td {
border: 1px solid #ccc;
padding: 6px 10px;
text-align: right;
}
th {
background: #f5f5f5;
}
td.left {
text-align: left;
}
tfoot td {
font-weight: bold;
background: #fafafa;
}
</style>
</head>
<body>
<label> <h2>試算表</h2>
期首:
<input type="date" id="from" />
</label>
<label>
期末:
<input type="date" id="to" />
</label>
<button id="load">表示</button>
<table border="1"> <table id="trialBalanceTable">
<thead> <thead>
<tr> <tr>
<th>科目</th> <th>科目代码</th>
<th>期首</th> <th>科目名称</th>
<th>借方</th> <th>借方合计</th>
<th>貸方</th> <th>贷方合计</th>
<th>期末</th> </tr>
</tr> </thead>
</thead> <tbody>
<tbody id="tb-body"></tbody> <!-- JS 动态插入 -->
</table> </tbody>
<tfoot>
<tr>
<td colspan="2">合计</td>
<td id="totalDebit">0</td>
<td id="totalCredit">0</td>
</tr>
</tfoot>
</table>
<script src="js/api.js"></script> <script src="./js/trial-balance.js"></script>
<script src="js/trial-balance.js"></script>
</body> </body>
</html> </html>

View File

@@ -104,4 +104,18 @@ def root():
pip install psycopg[binary]==3.2.3 pip install psycopg[binary]==3.2.3
C:\workspace\njts-accounting-core\backend uvicorn app.main:app --reload --host 127.0.0.1 --port 18080 C:\workspace\njts-accounting-core\backend uvicorn app.main:app --reload --host 127.0.0.1 --port 18080
version = 1初始输入
version >= 2你改过
updated_at最后一次修改时间
updated_reason
可空
例如:金额修正、补录摘要、对账调整