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