diff --git a/.gitignore b/.gitignore
index 21664bc..25e7b7f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,4 +25,16 @@ Thumbs.db
# ===== Docker / NAS =====
docker-data/
*.bak
-*.tmp
\ No newline at end of file
+*.tmp
+
+# Python
+__pycache__/
+*.pyc
+*.pyo
+*.pyd
+
+# Virtual environments
+.venv/
+venv/
+env/
+backend/.venv/
diff --git a/backend/app/core/fiscal_lock.py b/backend/app/core/fiscal_lock.py
new file mode 100644
index 0000000..646ad2e
--- /dev/null
+++ b/backend/app/core/fiscal_lock.py
@@ -0,0 +1,19 @@
+from fastapi import HTTPException
+from app.core.database import get_connection
+
+def check_fiscal_year_unlocked(journal_date):
+ fiscal_year = journal_date.year
+
+ with get_connection() as conn, conn.cursor() as cur:
+ cur.execute("""
+ SELECT is_locked
+ FROM fiscal_year_locks
+ WHERE fiscal_year = %s
+ """, (fiscal_year,))
+ row = cur.fetchone()
+
+ if row and row["is_locked"]:
+ raise HTTPException(
+ status_code=400,
+ detail="この会計年度は既に確定されているため、仕訳を変更できません。"
+ )
diff --git a/backend/app/main.py b/backend/app/main.py
index 0e5af8d..6ecace0 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -10,6 +10,16 @@ app = FastAPI(
title="日本小規模企業向け会計システム"
)
+from fastapi.middleware.cors import CORSMiddleware
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"], # 本地开发先用 *
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
@app.exception_handler(ValidationError)
async def validation_exception_handler(request: Request, exc: ValidationError):
# ここでは最初のエラーだけ簡潔に返す(必要に応じて整形)
@@ -45,3 +55,13 @@ app.include_router(trial_balance_router)
from app.modules.opening_balances.router import router as opening_router
app.include_router(opening_router)
+from app.modules.general_ledger.router import router as general_ledger_router
+app.include_router(general_ledger_router)
+
+from app.modules.opening_balances.lock_router import router as opening_balance_lock_router
+app.include_router(opening_balance_lock_router)
+
+
+
+
+
diff --git a/backend/app/modules/general_ledger/__init__.py b/backend/app/modules/general_ledger/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/modules/general_ledger/router.py b/backend/app/modules/general_ledger/router.py
new file mode 100644
index 0000000..31476a4
--- /dev/null
+++ b/backend/app/modules/general_ledger/router.py
@@ -0,0 +1,28 @@
+from fastapi import APIRouter, HTTPException, Query
+from app.modules.general_ledger.service import fetch_general_ledger
+
+router = APIRouter(
+ prefix="/general-ledger",
+ tags=["元帳"]
+)
+
+@router.get("", summary="元帳取得(科目別)")
+def get_general_ledger(
+ account_id: int = Query(...),
+ date_from: str = Query(...),
+ date_to: str = Query(...)
+):
+ if date_from > date_to:
+ raise HTTPException(status_code=400, detail="期間指定が不正です。")
+
+ result = fetch_general_ledger(account_id, date_from, date_to)
+ if result is None:
+ raise HTTPException(status_code=400, detail="科目が存在しません。")
+
+ return {
+ "period": {
+ "from": date_from,
+ "to": date_to
+ },
+ **result
+ }
diff --git a/backend/app/modules/general_ledger/service.py b/backend/app/modules/general_ledger/service.py
new file mode 100644
index 0000000..3c70b24
--- /dev/null
+++ b/backend/app/modules/general_ledger/service.py
@@ -0,0 +1,53 @@
+from decimal import Decimal
+from app.core.database import get_connection
+from app.modules.general_ledger.sql import GENERAL_LEDGER_SQL
+
+def D(x) -> Decimal:
+ return Decimal(str(x or 0))
+
+def fetch_general_ledger(account_id: int, date_from: str, date_to: str):
+
+ with get_connection() as conn, conn.cursor() as cur:
+
+ # ① 科目确认
+ cur.execute("""
+ SELECT account_code, account_name
+ FROM accounts
+ WHERE account_id = %s
+ AND is_active = true
+ """, (account_id,))
+ acc = cur.fetchone()
+ if not acc:
+ return None
+
+ # ② 元帳明细
+ cur.execute(
+ GENERAL_LEDGER_SQL,
+ {
+ "account_id": account_id,
+ "date_from": date_from,
+ "date_to": date_to
+ }
+ )
+ rows = cur.fetchall()
+
+ balance = Decimal("0")
+ lines = []
+
+ for r in rows:
+ balance += D(r["debit"]) - D(r["credit"])
+ lines.append({
+ "journal_date": r["journal_date"],
+ "description": r["description"],
+ "debit": str(D(r["debit"])),
+ "credit": str(D(r["credit"])),
+ "balance": str(balance)
+ })
+
+ return {
+ "account": {
+ "account_code": acc["account_code"],
+ "account_name": acc["account_name"]
+ },
+ "lines": lines
+ }
diff --git a/backend/app/modules/general_ledger/sql.py b/backend/app/modules/general_ledger/sql.py
new file mode 100644
index 0000000..6ace459
--- /dev/null
+++ b/backend/app/modules/general_ledger/sql.py
@@ -0,0 +1,14 @@
+# sql.py
+GENERAL_LEDGER_SQL = """
+SELECT
+ j.journal_date,
+ j.description,
+ l.debit,
+ l.credit
+FROM journal_lines l
+JOIN journal_entries j
+ ON j.journal_id = l.journal_id
+WHERE l.account_id = %(account_id)s
+ AND j.journal_date BETWEEN %(date_from)s AND %(date_to)s
+ORDER BY j.journal_date, l.line_id;
+"""
diff --git a/backend/app/modules/journals/router.py b/backend/app/modules/journals/router.py
index d555a4b..6a2adc6 100644
--- a/backend/app/modules/journals/router.py
+++ b/backend/app/modules/journals/router.py
@@ -4,6 +4,8 @@ from typing import List
from decimal import Decimal, InvalidOperation
from psycopg.errors import ForeignKeyViolation, CheckViolation
from app.core.database import get_connection
+from app.core.fiscal_lock import check_fiscal_year_unlocked
+from datetime import date
router = APIRouter(prefix="/journals", tags=["仕訳"])
@@ -109,3 +111,74 @@ def create_journal(data: JournalCreate):
raise HTTPException(status_code=400, detail="科目IDが不正です。")
except CheckViolation:
raise HTTPException(status_code=400, detail="金額の指定が不正です。")
+
+
+@router.put("/{journal_id}", summary="仕訳修正")
+def update_journal(journal_id: int, data: JournalCreate):
+
+ with get_connection() as conn, conn.cursor() as cur:
+
+ # ① 先取原仕訳の日付
+ cur.execute(
+ "SELECT journal_date FROM journal_entries WHERE journal_id = %s",
+ (journal_id,)
+ )
+ row = cur.fetchone()
+ if not row:
+ raise HTTPException(status_code=404, detail="仕訳が存在しません。")
+
+ # 🔒 ② 这里!!年度锁定检查
+ check_fiscal_year_unlocked(row["journal_date"])
+
+ # ③ 删除旧明细
+ cur.execute(
+ "DELETE FROM journal_lines WHERE journal_id = %s",
+ (journal_id,)
+ )
+
+ # ④ 更新ヘッダ
+ cur.execute(
+ "UPDATE journal_entries SET journal_date=%s, description=%s WHERE journal_id=%s",
+ (data.journal_date, data.description, journal_id)
+ )
+
+ # ⑤ 插入新明细
+ for l in data.lines:
+ cur.execute(
+ """INSERT INTO journal_lines (journal_id, account_id, debit, credit)
+ VALUES (%s, %s, %s, %s)""",
+ (journal_id, l.account_id, l.debit, l.credit)
+ )
+
+ conn.commit()
+ return {"message": "仕訳を修正しました。"}
+
+@router.delete("/{journal_id}", summary="仕訳削除")
+def delete_journal(journal_id: int):
+
+ with get_connection() as conn, conn.cursor() as cur:
+
+ # ① 先取日付
+ cur.execute(
+ "SELECT journal_date FROM journal_entries WHERE journal_id = %s",
+ (journal_id,)
+ )
+ row = cur.fetchone()
+ if not row:
+ raise HTTPException(status_code=404, detail="仕訳が存在しません。")
+
+ # 🔒 ② 年度锁定检查(就在这里)
+ check_fiscal_year_unlocked(row["journal_date"])
+
+ # ③ 删除
+ cur.execute(
+ "DELETE FROM journal_lines WHERE journal_id = %s",
+ (journal_id,)
+ )
+ cur.execute(
+ "DELETE FROM journal_entries WHERE journal_id = %s",
+ (journal_id,)
+ )
+
+ conn.commit()
+ return {"message": "仕訳を削除しました。"}
\ No newline at end of file
diff --git a/backend/app/modules/ledger/router.py b/backend/app/modules/ledger/router.py
index 5419427..ab3ebb5 100644
--- a/backend/app/modules/ledger/router.py
+++ b/backend/app/modules/ledger/router.py
@@ -1,109 +1,107 @@
from fastapi import APIRouter, HTTPException, Query
-from typing import Optional, List, Dict, Any
from decimal import Decimal
-from datetime import date
from app.core.database import get_connection
-router = APIRouter(prefix="/ledger", tags=["元帳"])
+router = APIRouter(
+ prefix="/general-ledger",
+ tags=["元帳"]
+)
-def to_decimal(x) -> Decimal:
- if isinstance(x, Decimal):
- return x
+
+def D(x) -> Decimal:
return Decimal(str(x or 0))
-@router.get("/{account_id}", summary="科目別元帳(残高推移)")
-def get_ledger(
- account_id: int,
- date_from: Optional[str] = Query(None, description="YYYY-MM-DD"),
- date_to: Optional[str] = Query(None, description="YYYY-MM-DD"),
+
+@router.get("", summary="元帳取得(期首残高対応)")
+def get_general_ledger(
+ account_id: int = Query(..., description="科目ID"),
+ fiscal_year: int = Query(..., description="会計年度(例:2025)"),
+ date_from: str = Query(..., description="YYYY-MM-DD"),
+ date_to: str = Query(..., description="YYYY-MM-DD"),
):
- # 1) 科目存在チェック
+ if date_from > date_to:
+ raise HTTPException(status_code=400, detail="期間指定が不正です。")
+
with get_connection() as conn, conn.cursor() as cur:
+
+ # ----------------------------
+ # 科目存在確認
+ # ----------------------------
cur.execute("""
- SELECT account_id, account_code, account_name, account_type
+ SELECT account_code, account_name
FROM accounts
WHERE account_id = %s
+ AND is_active = true
""", (account_id,))
- account = cur.fetchone()
- if not account:
- raise HTTPException(status_code=404, detail="指定した科目が見つかりません。")
+ acc = cur.fetchone()
- # 2) 期間デフォルト(最小〜最大)
- if not date_from:
- cur.execute("""
- SELECT MIN(j.journal_date) AS min_d
- FROM journal_entries j
- JOIN journal_lines l ON l.journal_id = j.journal_id
- WHERE l.account_id = %s
- """, (account_id,))
- row = cur.fetchone()
- date_from = (row["min_d"] or date.today()).isoformat()
+ if not acc:
+ raise HTTPException(status_code=400, detail="指定された科目は存在しません。")
- if not date_to:
- cur.execute("""
- SELECT MAX(j.journal_date) AS max_d
- FROM journal_entries j
- JOIN journal_lines l ON l.journal_id = j.journal_id
- WHERE l.account_id = %s
- """, (account_id,))
- row = cur.fetchone()
- date_to = (row["max_d"] or date.today()).isoformat()
-
- # 3) 期首残高(date_from より前)
+ # ----------------------------
+ # 期首残高(opening_balances)
+ # ----------------------------
cur.execute("""
- 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
- WHERE l.account_id = %s
- AND j.journal_date < %s
- """, (account_id, date_from))
- opening = to_decimal(cur.fetchone()["opening"])
+ SELECT
+ COALESCE(opening_debit, 0) - COALESCE(opening_credit, 0) AS opening
+ FROM opening_balances
+ WHERE fiscal_year = %s
+ AND account_id = %s
+ """, (fiscal_year, account_id))
- # 4) 期間内明細
+ row = cur.fetchone()
+ opening_balance = D(row["opening"]) if row else D(0)
+
+ # ----------------------------
+ # 期間内仕訳取得
+ # ----------------------------
cur.execute("""
- SELECT j.journal_date, j.journal_id, j.description,
- l.debit, l.credit
- FROM journal_lines l
- JOIN journal_entries j ON j.journal_id = l.journal_id
+ SELECT
+ j.journal_date,
+ j.description,
+ l.debit,
+ l.credit
+ FROM journal_entries j
+ JOIN journal_lines l
+ ON l.journal_id = j.journal_id
WHERE l.account_id = %s
AND j.journal_date BETWEEN %s AND %s
- ORDER BY j.journal_date, j.journal_id, l.line_id
+ ORDER BY j.journal_date, l.line_id
""", (account_id, date_from, date_to))
+
rows = cur.fetchall()
- # 5) 残高推移の計算(借方−貸方)
- balance = opening
- out_rows: List[Dict[str, Any]] = []
- total_debit = Decimal("0")
- total_credit = Decimal("0")
+ # ----------------------------
+ # 累計残高計算
+ # ----------------------------
+ balance = opening_balance
+ lines = []
- for r in rows:
- d = to_decimal(r["debit"])
- c = to_decimal(r["credit"])
- total_debit += d
- total_credit += c
- balance += (d - c)
- out_rows.append({
- "date": r["journal_date"].isoformat(),
- "journal_id": r["journal_id"],
- "description": r["description"],
- "debit": str(d),
- "credit": str(c),
- "balance": str(balance) # 借方残を正とする(借方−貸方)
- })
+ for r in rows:
+ debit = D(r["debit"])
+ credit = D(r["credit"])
+ balance += debit - credit
- result = {
+ lines.append({
+ "journal_date": r["journal_date"],
+ "description": r["description"],
+ "debit": str(debit),
+ "credit": str(credit),
+ "balance": str(balance),
+ })
+
+ return {
"account": {
- "id": account["account_id"],
- "code": account["account_code"],
- "name": account["account_name"],
- "type": account["account_type"],
+ "account_id": account_id,
+ "account_code": acc["account_code"],
+ "account_name": acc["account_name"],
},
- "period": {"from": date_from, "to": date_to},
- "opening_balance": str(opening),
- "total_debit": str(total_debit),
- "total_credit": str(total_credit),
+ "fiscal_year": fiscal_year,
+ "period": {
+ "from": date_from,
+ "to": date_to
+ },
+ "opening_balance": str(opening_balance),
+ "lines": lines,
"closing_balance": str(balance),
- "rows": out_rows
}
- return result
diff --git a/backend/app/modules/opening_balances/lock_router.py b/backend/app/modules/opening_balances/lock_router.py
new file mode 100644
index 0000000..527c2ed
--- /dev/null
+++ b/backend/app/modules/opening_balances/lock_router.py
@@ -0,0 +1,27 @@
+from fastapi import APIRouter, HTTPException
+from pydantic import BaseModel
+from app.core.database import get_connection
+
+router = APIRouter(
+ prefix="/opening-balances",
+ tags=["期首残高"]
+)
+
+class LockRequest(BaseModel):
+ fiscal_year: int
+
+@router.post("/lock", summary="期首残高年度確定")
+def lock_fiscal_year(req: LockRequest):
+
+ with get_connection() as conn, conn.cursor() as cur:
+ cur.execute("""
+ INSERT INTO fiscal_year_locks (fiscal_year, is_locked)
+ VALUES (%s, true)
+ ON CONFLICT (fiscal_year)
+ DO UPDATE SET
+ is_locked = true,
+ locked_at = CURRENT_TIMESTAMP
+ """, (req.fiscal_year,))
+ conn.commit()
+
+ return {"message": "期首残高を確定しました。"}
diff --git a/backend/app/modules/opening_balances/router.py b/backend/app/modules/opening_balances/router.py
index 4b40f4a..1103c29 100644
--- a/backend/app/modules/opening_balances/router.py
+++ b/backend/app/modules/opening_balances/router.py
@@ -1,55 +1,108 @@
-# backend/app/modules/opening_balances/router.py
-from fastapi import APIRouter, HTTPException
-from psycopg.errors import ForeignKeyViolation
+from fastapi import APIRouter, HTTPException, Query
+from pydantic import BaseModel
+from typing import List
+from decimal import Decimal
+
from app.core.database import get_connection
-from app.modules.opening_balances.schema import OpeningBalanceCreate
-router = APIRouter(
- prefix="/opening-balances",
- tags=["期首残高"]
-)
-
-@router.post("/", summary="期首残高 登録/更新")
-def upsert_opening_balance(data: OpeningBalanceCreate):
- try:
- with get_connection() as conn, conn.cursor() as cur:
- cur.execute("""
- INSERT INTO opening_balances
- (fiscal_year, account_id, opening_debit, opening_credit)
- VALUES (%s, %s, %s, %s)
- ON CONFLICT (fiscal_year, account_id)
- DO UPDATE SET
- opening_debit = EXCLUDED.opening_debit,
- opening_credit = EXCLUDED.opening_credit
- """, (
- data.fiscal_year,
- data.account_id,
- data.opening_debit,
- data.opening_credit
- ))
- conn.commit()
- return {"result": "ok"}
-
- except ForeignKeyViolation:
- raise HTTPException(
- status_code=400,
- detail="存在しない科目が指定されています"
- )
+router = APIRouter(prefix="/opening-balances", tags=["期首残高"])
-@router.get("/{fiscal_year}", summary="期首残高一覧取得")
-def list_opening_balances(fiscal_year: int):
+# ---------- Models ----------
+
+class OpeningBalanceItem(BaseModel):
+ account_id: int
+ debit: Decimal = Decimal("0")
+ credit: Decimal = Decimal("0")
+
+
+class OpeningBalanceRequest(BaseModel):
+ fiscal_year: int
+ balances: List[OpeningBalanceItem]
+
+
+# ---------- GET:期首残高取得 ----------
+
+@router.get("", summary="期首残高取得")
+def get_opening_balances(
+ fiscal_year: int = Query(..., description="会計年度(例:2025)")
+):
with get_connection() as conn, conn.cursor() as cur:
cur.execute("""
SELECT
+ a.account_id,
a.account_code,
a.account_name,
a.account_type,
- ob.opening_debit,
- ob.opening_credit
- FROM opening_balances ob
- JOIN accounts a ON a.account_id = ob.account_id
- WHERE ob.fiscal_year = %s
+ COALESCE(o.opening_debit, 0) AS opening_debit,
+ COALESCE(o.opening_credit, 0) AS opening_credit
+ FROM accounts a
+ LEFT JOIN opening_balances o
+ ON o.account_id = a.account_id
+ AND o.fiscal_year = %s
+ WHERE a.is_active = true
ORDER BY a.account_code
""", (fiscal_year,))
return cur.fetchall()
+
+
+# ---------- POST:期首残高保存 ----------
+
+@router.post("", summary="期首残高保存")
+def save_opening_balances(req: OpeningBalanceRequest):
+
+ # 年度锁定チェック
+ cur.execute("""
+ SELECT is_locked
+ FROM fiscal_year_locks
+ WHERE fiscal_year = %s
+ """, (req.fiscal_year,))
+
+ row = cur.fetchone()
+ if row and row["is_locked"]:
+ raise HTTPException(
+ status_code=400,
+ detail="この会計年度の期首残高は既に確定されています。"
+ )
+
+ total_debit = Decimal("0")
+ total_credit = Decimal("0")
+
+ for b in req.balances:
+ total_debit += b.debit
+ total_credit += b.credit
+
+ if total_debit != total_credit:
+ raise HTTPException(
+ status_code=400,
+ detail="借方合計と貸方合計が一致していません。"
+ )
+
+ with get_connection() as conn, conn.cursor() as cur:
+
+ # 既存データ削除(年度単位)
+ cur.execute("""
+ DELETE FROM opening_balances
+ WHERE fiscal_year = %s
+ """, (req.fiscal_year,))
+
+ # 再登録
+ for b in req.balances:
+ if b.debit == 0 and b.credit == 0:
+ continue
+
+ cur.execute("""
+ INSERT INTO opening_balances
+ (fiscal_year, account_id, opening_debit, opening_credit)
+ VALUES
+ (%s, %s, %s, %s)
+ """, (
+ req.fiscal_year,
+ b.account_id,
+ b.debit,
+ b.credit
+ ))
+
+ conn.commit()
+
+ return {"message": "期首残高を保存しました。"}
diff --git a/backend/app/modules/trial_balance/router.py b/backend/app/modules/trial_balance/router.py
index 73ad0df..531327a 100644
--- a/backend/app/modules/trial_balance/router.py
+++ b/backend/app/modules/trial_balance/router.py
@@ -2,6 +2,7 @@ from fastapi import APIRouter, HTTPException, Query
from decimal import Decimal
from typing import Dict, Any, List
from app.core.database import get_connection
+from app.modules.trial_balance.service import fetch_trial_balance
router = APIRouter(prefix="/trial-balance", tags=["試算表"])
@@ -10,12 +11,23 @@ def D(x) -> Decimal:
@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"),
):
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:
# 全科目取得
@@ -37,15 +49,18 @@ def get_trial_balance(
for acc in accounts:
aid = acc["account_id"]
- # 期首残高
+ # 期首残高(opening_balances)
cur.execute("""
- 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
- WHERE l.account_id = %s
- AND j.journal_date < %s
- """, (aid, date_from))
- opening = D(cur.fetchone()["opening"])
+ 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("""
diff --git a/backend/app/modules/trial_balance/service.py b/backend/app/modules/trial_balance/service.py
new file mode 100644
index 0000000..9a06427
--- /dev/null
+++ b/backend/app/modules/trial_balance/service.py
@@ -0,0 +1,81 @@
+# backend/app/modules/trial_balance/service.py
+from decimal import Decimal
+from typing import Dict, Any, List
+from app.core.database import get_connection
+
+def D(x) -> Decimal:
+ return Decimal(str(x or 0))
+
+def fetch_trial_balance(date_from: str, date_to: str):
+
+ 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"]
+
+ # 期首
+ cur.execute("""
+ 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
+ WHERE l.account_id = %s
+ AND j.journal_date < %s
+ """, (aid, date_from))
+ opening = D(cur.fetchone()["opening"])
+
+ # 当期
+ 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 {
+ "totals": {
+ "opening_balance": str(total_opening),
+ "debit": str(total_debit),
+ "credit": str(total_credit),
+ "closing_balance": str(total_closing)
+ },
+ "accounts": result_accounts
+ }
diff --git a/backend/app/modules/trial_balance/sql.py b/backend/app/modules/trial_balance/sql.py
new file mode 100644
index 0000000..47a1bc0
--- /dev/null
+++ b/backend/app/modules/trial_balance/sql.py
@@ -0,0 +1,84 @@
+# backend/app/modules/trial_balance/sql.py
+
+TRIAL_BALANCE_SQL = """
+WITH accounts_base AS (
+ SELECT
+ a.account_id,
+ a.account_code,
+ a.account_name,
+ a.account_type
+ FROM accounts a
+ WHERE a.is_active = true
+),
+
+opening AS (
+ SELECT
+ jl.account_id,
+ SUM(jl.debit) AS opening_debit,
+ SUM(jl.credit) AS opening_credit
+ FROM journal_entries je
+ JOIN journal_lines jl ON jl.journal_id = je.journal_id
+ WHERE je.description = '前期残高'
+ AND je.journal_date = %(start_date)s
+ GROUP BY jl.account_id
+),
+
+period AS (
+ SELECT
+ jl.account_id,
+ SUM(jl.debit) AS period_debit,
+ SUM(jl.credit) AS period_credit
+ FROM journal_entries je
+ JOIN journal_lines jl ON jl.journal_id = je.journal_id
+ WHERE je.journal_date BETWEEN %(start_date)s AND %(end_date)s
+ AND je.description <> '前期残高'
+ GROUP BY jl.account_id
+)
+
+SELECT
+ a.account_code,
+ a.account_name,
+ a.account_type,
+
+ COALESCE(o.opening_debit, 0) AS opening_debit,
+ COALESCE(o.opening_credit, 0) AS opening_credit,
+ COALESCE(p.period_debit, 0) AS period_debit,
+ COALESCE(p.period_credit, 0) AS period_credit,
+
+ CASE
+ WHEN (
+ COALESCE(o.opening_debit, 0)
+ - COALESCE(o.opening_credit, 0)
+ + COALESCE(p.period_debit, 0)
+ - COALESCE(p.period_credit, 0)
+ ) > 0
+ THEN (
+ COALESCE(o.opening_debit, 0)
+ - COALESCE(o.opening_credit, 0)
+ + COALESCE(p.period_debit, 0)
+ - COALESCE(p.period_credit, 0)
+ )
+ ELSE 0
+ END AS closing_debit,
+
+ CASE
+ WHEN (
+ COALESCE(o.opening_debit, 0)
+ - COALESCE(o.opening_credit, 0)
+ + COALESCE(p.period_debit, 0)
+ - COALESCE(p.period_credit, 0)
+ ) < 0
+ THEN ABS(
+ COALESCE(o.opening_debit, 0)
+ - COALESCE(o.opening_credit, 0)
+ + COALESCE(p.period_debit, 0)
+ - COALESCE(p.period_credit, 0)
+ )
+ ELSE 0
+ END AS closing_credit
+
+FROM accounts_base a
+LEFT JOIN opening o ON o.account_id = a.account_id
+LEFT JOIN period p ON p.account_id = a.account_id
+ORDER BY a.account_code;
+"""
diff --git a/frontend/css/style.css b/frontend/css/style.css
index 32eb461..534a387 100644
--- a/frontend/css/style.css
+++ b/frontend/css/style.css
@@ -1,14 +1,23 @@
body {
- font-family: system-ui, -apple-system, BlinkMacSystemFont;
- margin: 20px;
+ font-family: sans-serif;
}
table {
- margin-top: 10px;
border-collapse: collapse;
+ width: 100%;
}
th,
td {
- padding: 6px 10px;
+ border: 1px solid #999;
+ padding: 4px 8px;
+}
+
+th {
+ background: #eee;
+}
+
+a {
+ color: blue;
+ text-decoration: none;
}
diff --git a/frontend/index.html b/frontend/index.html
index 3047479..8880463 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -2,15 +2,35 @@
- NJTS 会計システム
+ 試算表
- NJTS 会計システム
+ 試算表
-
+
+
+
+
+
+
+ | 科目コード |
+ 科目名 |
+ 期首 |
+ 借方 |
+ 贷方 |
+ 期末 |
+
+
+
+
+
+
+
diff --git a/frontend/journal.html b/frontend/journal.html
new file mode 100644
index 0000000..2300707
--- /dev/null
+++ b/frontend/journal.html
@@ -0,0 +1,47 @@
+
+
+
+
+ 仕訳入力
+
+
+
+ 仕訳入力
+
+
+
+
+
+
+
+
+
+
+
+
+ | 科目ID |
+ 借方 |
+ 贷方 |
+ |
+
+
+
+
+
+
+
+
+
+
+ ← 戻る
+
+
+
+
+
diff --git a/frontend/js/api.js b/frontend/js/api.js
index f37f727..6edbfcb 100644
--- a/frontend/js/api.js
+++ b/frontend/js/api.js
@@ -1,10 +1,13 @@
-const API_BASE = "http://localhost:18080";
+const API_BASE = "http://127.0.0.1:18080";
-async function apiGet(path) {
- const res = await fetch(`${API_BASE}${path}`);
+async function apiGet(path, params = {}) {
+ const query = new URLSearchParams(params).toString();
+ const url = `${API_BASE}${path}?${query}`;
+
+ const res = await fetch(url);
if (!res.ok) {
- const text = await res.text();
- throw new Error(text || "APIエラー");
+ const data = await res.json();
+ throw new Error(data.detail || "APIエラー");
}
return res.json();
}
diff --git a/frontend/js/journal.js b/frontend/js/journal.js
new file mode 100644
index 0000000..b5e2583
--- /dev/null
+++ b/frontend/js/journal.js
@@ -0,0 +1,65 @@
+function addLine() {
+ const tbody = document.querySelector("#lines-table tbody");
+
+ const tr = document.createElement("tr");
+ tr.innerHTML = `
+ |
+ |
+ |
+ |
+ `;
+
+ tbody.appendChild(tr);
+}
+
+async function submitJournal() {
+ const date = document.getElementById("journal-date").value;
+ const desc = document.getElementById("description").value;
+
+ if (!date) {
+ alert("日付を入力してください");
+ return;
+ }
+
+ const lines = [];
+ document.querySelectorAll("#lines-table tbody tr").forEach((tr) => {
+ const accountId = tr.querySelector(".account-id").value;
+ const debit = tr.querySelector(".debit").value || 0;
+ const credit = tr.querySelector(".credit").value || 0;
+
+ if (!accountId) return;
+
+ lines.push({
+ account_id: Number(accountId),
+ debit: Number(debit),
+ credit: Number(credit),
+ });
+ });
+
+ if (lines.length < 2) {
+ alert("仕訳明細は2行以上必要です");
+ return;
+ }
+
+ try {
+ await fetch("http://127.0.0.1:18080/journals", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ journal_date: date,
+ description: desc,
+ lines: lines,
+ }),
+ }).then(async (res) => {
+ if (!res.ok) {
+ const data = await res.json();
+ throw new Error(data.detail || "登録失敗");
+ }
+ });
+
+ alert("仕訳を登録しました");
+ location.href = "index.html";
+ } catch (e) {
+ alert(e.message);
+ }
+}
diff --git a/frontend/js/ledger.js b/frontend/js/ledger.js
new file mode 100644
index 0000000..a91be7c
--- /dev/null
+++ b/frontend/js/ledger.js
@@ -0,0 +1,37 @@
+async function loadLedger() {
+ const params = new URLSearchParams(location.search);
+ const accountId = params.get("account_id");
+ const from = params.get("from");
+ const to = params.get("to");
+
+ try {
+ const data = await apiGet("/general-ledger", {
+ account_id: accountId,
+ date_from: from,
+ date_to: to,
+ });
+
+ document.getElementById(
+ "account-title"
+ ).innerText = `${data.account.account_code} ${data.account.account_name}`;
+
+ const tbody = document.getElementById("ledger-body");
+ tbody.innerHTML = "";
+
+ data.lines.forEach((line) => {
+ const tr = document.createElement("tr");
+ tr.innerHTML = `
+ ${line.journal_date} |
+ ${line.description} |
+ ${line.debit} |
+ ${line.credit} |
+ ${line.balance} |
+ `;
+ tbody.appendChild(tr);
+ });
+ } catch (e) {
+ alert(e.message);
+ }
+}
+
+loadLedger();
diff --git a/frontend/js/opening_balance.js b/frontend/js/opening_balance.js
new file mode 100644
index 0000000..4c156b3
--- /dev/null
+++ b/frontend/js/opening_balance.js
@@ -0,0 +1,152 @@
+let accounts = [];
+let retainedEarnings = null;
+let isLocked = false;
+
+function formatNumber(n) {
+ return Number(n || 0).toLocaleString();
+}
+
+function recalcTotals() {
+ let debit = 0;
+ let credit = 0;
+
+ // 先算:不含繰越利益
+ accounts.forEach((a) => {
+ if (a.account_name === "繰越利益") return;
+ debit += Number(a.opening_debit || 0);
+ credit += Number(a.opening_credit || 0);
+ });
+
+ const diff = debit - credit;
+
+ // 自动计算 繰越利益
+ if (retainedEarnings) {
+ if (diff > 0) {
+ retainedEarnings.opening_debit = 0;
+ retainedEarnings.opening_credit = diff;
+ } else {
+ retainedEarnings.opening_debit = -diff;
+ retainedEarnings.opening_credit = 0;
+ }
+ }
+
+ // 再算一次(包含繰越利益)
+ let finalDebit = 0;
+ let finalCredit = 0;
+
+ accounts.forEach((a) => {
+ finalDebit += Number(a.opening_debit || 0);
+ finalCredit += Number(a.opening_credit || 0);
+ });
+
+ document.getElementById("totalDebit").innerText = formatNumber(finalDebit);
+ document.getElementById("totalCredit").innerText = formatNumber(finalCredit);
+ document.getElementById("diff").innerText = formatNumber(
+ finalDebit - finalCredit
+ );
+}
+
+async function loadOpeningBalances() {
+ const year = document.getElementById("fiscalYear").value;
+
+ const data = await apiGet("/opening-balances", { fiscal_year: year });
+
+ accounts = data;
+ retainedEarnings = accounts.find((a) => a.account_name === "繰越利益");
+
+ const tbody = document.querySelector("#balanceTable tbody");
+ tbody.innerHTML = "";
+
+ GROUPS.forEach((group) => {
+ // 分组标题行
+ const headerTr = document.createElement("tr");
+ headerTr.innerHTML = `
+
+ ${group.label}
+ |
+ `;
+ tbody.appendChild(headerTr);
+
+ accounts.forEach((a, i) => {
+ if (a.account_type !== group.key) return;
+
+ const isRetained = a.account_name === "繰越利益";
+ const tr = document.createElement("tr");
+
+ tr.innerHTML = `
+ ${a.account_code} |
+ ${a.account_name} |
+
+
+ |
+
+
+ |
+ `;
+
+ tbody.appendChild(tr);
+ });
+ });
+
+ // 年度锁定チェック
+ fetch(
+ `http://127.0.0.1:18080/opening-balances/lock-status?fiscal_year=${year}`
+ )
+ .then((res) => res.json())
+ .then((data) => {
+ isLocked = data.is_locked;
+ document.querySelector(
+ "button[onclick='saveOpeningBalances()']"
+ ).disabled = isLocked;
+ if (isLocked) {
+ alert("この会計年度の期首残高は確定されています。");
+ }
+ });
+
+ recalcTotals();
+}
+
+async function saveOpeningBalances() {
+ if (isLocked) {
+ alert("この会計年度の期首残高は確定されているため、保存できません。");
+ return;
+ }
+ const year = Number(document.getElementById("fiscalYear").value);
+
+ const balances = accounts
+ // ❗ 排除 繰越利益
+ .filter(
+ (a) =>
+ a.account_name !== "繰越利益" &&
+ (Number(a.opening_debit) !== 0 || Number(a.opening_credit) !== 0)
+ )
+ .map((a) => ({
+ account_id: a.account_id,
+ debit: Number(a.opening_debit || 0),
+ credit: Number(a.opening_credit || 0),
+ }));
+
+ try {
+ await fetch("http://127.0.0.1:18080/opening-balances", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ fiscal_year: year,
+ balances: balances,
+ }),
+ }).then(async (res) => {
+ if (!res.ok) {
+ const data = await res.json();
+ throw new Error(data.detail);
+ }
+ });
+
+ alert("期首残高を保存しました");
+ } catch (e) {
+ alert(e.message);
+ }
+}
diff --git a/frontend/js/trial-balance.js b/frontend/js/trial-balance.js
index b785ba8..e93ff4f 100644
--- a/frontend/js/trial-balance.js
+++ b/frontend/js/trial-balance.js
@@ -1,4 +1,4 @@
-document.getElementById("load").onclick = async () => {
+async function loadTrialBalance() {
const from = document.getElementById("from").value;
const to = document.getElementById("to").value;
@@ -8,22 +8,33 @@ document.getElementById("load").onclick = async () => {
}
try {
- const data = await apiGet(`/trial-balance?date_from=${from}&date_to=${to}`);
- const tbody = document.getElementById("tb-body");
+ const data = await apiGet("/trial-balance", {
+ date_from: from,
+ date_to: to,
+ });
+
+ const tbody = document.querySelector("#trial-table tbody");
tbody.innerHTML = "";
- data.accounts.forEach((a) => {
+ data.accounts.forEach((acc) => {
const tr = document.createElement("tr");
+
tr.innerHTML = `
- ${a.account_name} |
- ${a.opening_balance} |
- ${a.debit} |
- ${a.credit} |
- ${a.closing_balance} |
+ ${acc.account_code} |
+
+
+ ${acc.account_name}
+
+ |
+ ${acc.opening_balance} |
+ ${acc.debit} |
+ ${acc.credit} |
+ ${acc.closing_balance} |
`;
+
tbody.appendChild(tr);
});
} catch (e) {
alert(e.message);
}
-};
+}
diff --git a/frontend/ledger.html b/frontend/ledger.html
new file mode 100644
index 0000000..2d4a4ed
--- /dev/null
+++ b/frontend/ledger.html
@@ -0,0 +1,29 @@
+
+
+
+
+ 元帳
+
+
+
+ 元帳
+
+
+
+
+ | 日付 |
+ 摘要 |
+ 借方 |
+ 贷方 |
+ 残高 |
+
+
+
+
+
+ ← 試算表へ戻る
+
+
+
+
+
diff --git a/frontend/opening_balance.html b/frontend/opening_balance.html
new file mode 100644
index 0000000..bc95359
--- /dev/null
+++ b/frontend/opening_balance.html
@@ -0,0 +1,48 @@
+
+
+
+
+ 期首残高入力
+
+
+
+ 期首残高入力
+
+
+
+
+
+
+
+
+
+ | 科目コード |
+ 科目名 |
+ 借方期首 |
+ 贷方期首 |
+
+
+
+
+
+
+
+
+ 借方合計:0
+ 贷方合計:0
+ 差額:0
+
+
+
+
+
+ ← 戻る
+
+
+
+
+
+s
diff --git a/メモ.txt b/メモ.txt
index 91396c5..92f5a48 100644
--- a/メモ.txt
+++ b/メモ.txt
@@ -59,9 +59,19 @@ cd C:\workspace\njts-accounting-core\backend
python -m venv .venv
+C:\workspace\njts-accounting-core\backend\.venv\pyvenv.cfg
+home = C:\Users\zxh_y\AppData\Local\Programs\Python\Python312
+include-system-site-packages = false
+version = 3.12.9
+executable = C:\Users\zxh_y\AppData\Local\Programs\Python\Python312\python.exe
+command = C:\Users\zxh_y\AppData\Local\Programs\Python\Python312\python.exe -m venv C:\workspace\njts-accounting-core\backend\.venv
+
+
cd C:\workspace\njts-accounting-core\backend
.venv\Scripts\activate
+uvicorn app.main:app --reload --host 127.0.0.1 --port 18080
+http://localhost:18080/docs
backend\requirements.txt
ーーーーーーーーーーーーー
@@ -91,4 +101,7 @@ def root():
-pip install psycopg[binary]==3.2.3
\ No newline at end of file
+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
\ No newline at end of file