20251215滴修正
This commit is contained in:
@@ -61,7 +61,8 @@ app.include_router(general_ledger_router)
|
|||||||
from app.modules.opening_balances.lock_router import router as opening_balance_lock_router
|
from app.modules.opening_balances.lock_router import router as opening_balance_lock_router
|
||||||
app.include_router(opening_balance_lock_router)
|
app.include_router(opening_balance_lock_router)
|
||||||
|
|
||||||
|
from app.routers import journal_entries
|
||||||
|
app.include_router(journal_entries.router)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
16
backend/app/modules/journal_entry.py
Normal file
16
backend/app/modules/journal_entry.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import List
|
||||||
|
from decimal import Decimal
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
|
||||||
|
class JournalLine(BaseModel):
|
||||||
|
account_id: int
|
||||||
|
debit: Decimal = Decimal("0")
|
||||||
|
credit: Decimal = Decimal("0")
|
||||||
|
|
||||||
|
|
||||||
|
class JournalEntryRequest(BaseModel):
|
||||||
|
entry_date: date
|
||||||
|
description: str
|
||||||
|
lines: List[JournalLine]
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, field_validator
|
||||||
from typing import List
|
from typing import List, Optional, Tuple
|
||||||
from decimal import Decimal
|
from decimal import Decimal, InvalidOperation
|
||||||
|
|
||||||
from app.core.database import get_connection
|
from app.core.database import get_connection
|
||||||
|
|
||||||
@@ -15,13 +15,33 @@ class OpeningBalanceItem(BaseModel):
|
|||||||
debit: Decimal = Decimal("0")
|
debit: Decimal = Decimal("0")
|
||||||
credit: Decimal = Decimal("0")
|
credit: Decimal = Decimal("0")
|
||||||
|
|
||||||
|
@field_validator("debit", "credit", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def to_decimal(cls, v):
|
||||||
|
try:
|
||||||
|
d = Decimal(str(v))
|
||||||
|
except InvalidOperation:
|
||||||
|
raise ValueError("金額は数値で入力してください")
|
||||||
|
if d < 0:
|
||||||
|
raise ValueError("金額は0以上で入力してください")
|
||||||
|
return d
|
||||||
|
|
||||||
|
@field_validator("credit")
|
||||||
|
@classmethod
|
||||||
|
def exclusive_debit_credit(cls, v, info):
|
||||||
|
# 同時に > 0 は不可(0/0 は許容:行スキップ用)
|
||||||
|
debit = info.data.get("debit", Decimal("0"))
|
||||||
|
if v > 0 and Decimal(debit) > 0:
|
||||||
|
raise ValueError("借方と貸方を同時に入力しないでください")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
class OpeningBalanceRequest(BaseModel):
|
class OpeningBalanceRequest(BaseModel):
|
||||||
fiscal_year: int
|
fiscal_year: int
|
||||||
balances: List[OpeningBalanceItem]
|
balances: List[OpeningBalanceItem]
|
||||||
|
|
||||||
|
|
||||||
# ---------- GET:期首残高取得 ----------
|
# ---------- GET:期首残高取得(BS のみ) ----------
|
||||||
|
|
||||||
@router.get("", summary="期首残高取得")
|
@router.get("", summary="期首残高取得")
|
||||||
def get_opening_balances(
|
def get_opening_balances(
|
||||||
@@ -41,68 +61,142 @@ def get_opening_balances(
|
|||||||
ON o.account_id = a.account_id
|
ON o.account_id = a.account_id
|
||||||
AND o.fiscal_year = %s
|
AND o.fiscal_year = %s
|
||||||
WHERE a.is_active = true
|
WHERE a.is_active = true
|
||||||
|
AND a.account_type IN ('asset','liability','equity')
|
||||||
ORDER BY a.account_code
|
ORDER BY a.account_code
|
||||||
""", (fiscal_year,))
|
""", (fiscal_year,))
|
||||||
return cur.fetchall()
|
return cur.fetchall()
|
||||||
|
|
||||||
|
|
||||||
# ---------- POST:期首残高保存 ----------
|
# ---------- 内部 util:繰越利益(剰余金)アカウント検索 ----------
|
||||||
|
|
||||||
|
def _find_retained_earnings(cur) -> Optional[Tuple[int, str]]:
|
||||||
|
"""
|
||||||
|
可能な名称で繰越利益(剰余金)科目を探す。
|
||||||
|
優先:'繰越利益剰余金' → '繰越利益'
|
||||||
|
戻り値: (account_id, account_name) or None
|
||||||
|
"""
|
||||||
|
cur.execute("""
|
||||||
|
SELECT account_id, account_name
|
||||||
|
FROM accounts
|
||||||
|
WHERE is_active = true
|
||||||
|
AND account_type = 'equity'
|
||||||
|
AND account_name IN ('繰越利益剰余金','繰越利益')
|
||||||
|
ORDER BY CASE account_name
|
||||||
|
WHEN '繰越利益剰余金' THEN 1
|
||||||
|
WHEN '繰越利益' THEN 2
|
||||||
|
ELSE 99
|
||||||
|
END
|
||||||
|
LIMIT 1
|
||||||
|
""")
|
||||||
|
row = cur.fetchone()
|
||||||
|
return (row["account_id"], row["account_name"]) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- POST:期首残高保存(BSのみ・繰越利益は自動計算) ----------
|
||||||
|
|
||||||
@router.post("", summary="期首残高保存")
|
@router.post("", summary="期首残高保存")
|
||||||
def save_opening_balances(req: OpeningBalanceRequest):
|
def save_opening_balances(req: OpeningBalanceRequest):
|
||||||
|
|
||||||
# 年度锁定チェック
|
with get_connection() as conn, conn.cursor() as cur:
|
||||||
|
# 年度ロック
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT is_locked
|
SELECT is_locked
|
||||||
FROM fiscal_year_locks
|
FROM fiscal_year_locks
|
||||||
WHERE fiscal_year = %s
|
WHERE fiscal_year = %s
|
||||||
""", (req.fiscal_year,))
|
""", (req.fiscal_year,))
|
||||||
|
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
if row and row["is_locked"]:
|
if row and row["is_locked"]:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=400, detail="この会計年度の期首残高は既に確定されています。")
|
||||||
status_code=400,
|
|
||||||
detail="この会計年度の期首残高は既に確定されています。"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
# 全 BS 科目取得(存在性/種別チェック用)
|
||||||
|
cur.execute("""
|
||||||
|
SELECT account_id, account_type, account_name
|
||||||
|
FROM accounts
|
||||||
|
WHERE is_active = true
|
||||||
|
""")
|
||||||
|
acc_map = {r["account_id"]: (r["account_type"], r["account_name"]) for r in cur.fetchall()}
|
||||||
|
|
||||||
|
# 入力の整形:BSのみ採用・繰越利益は無視(後で自動作成)
|
||||||
|
bs_items: List[OpeningBalanceItem] = []
|
||||||
total_debit = Decimal("0")
|
total_debit = Decimal("0")
|
||||||
total_credit = Decimal("0")
|
total_credit = Decimal("0")
|
||||||
|
|
||||||
for b in req.balances:
|
for b in req.balances:
|
||||||
total_debit += b.debit
|
# 科目存在
|
||||||
total_credit += b.credit
|
if b.account_id not in acc_map:
|
||||||
|
raise HTTPException(status_code=400, detail=f"存在しない科目IDです:{b.account_id}")
|
||||||
|
|
||||||
if total_debit != total_credit:
|
acc_type, acc_name = acc_map[b.account_id]
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail="借方合計と貸方合計が一致していません。"
|
|
||||||
)
|
|
||||||
|
|
||||||
with get_connection() as conn, conn.cursor() as cur:
|
# 繰越利益(剰余金)は前端から送られても無視(自動計算対象)
|
||||||
|
if acc_name in ("繰越利益剰余金", "繰越利益"):
|
||||||
|
continue
|
||||||
|
|
||||||
# 既存データ削除(年度単位)
|
# BS 以外は不可
|
||||||
cur.execute("""
|
if acc_type not in ("asset", "liability", "equity"):
|
||||||
DELETE FROM opening_balances
|
raise HTTPException(status_code=400, detail=f"BS科目のみ設定可能です({acc_name})")
|
||||||
WHERE fiscal_year = %s
|
|
||||||
""", (req.fiscal_year,))
|
|
||||||
|
|
||||||
# 再登録
|
# 0/0 はスキップ
|
||||||
for b in req.balances:
|
|
||||||
if b.debit == 0 and b.credit == 0:
|
if b.debit == 0 and b.credit == 0:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# 集計(繰越利益を除く)
|
||||||
|
total_debit += b.debit
|
||||||
|
total_credit += b.credit
|
||||||
|
bs_items.append(b)
|
||||||
|
|
||||||
|
# 繰越利益(剰余金)を自動計算
|
||||||
|
diff = total_debit - total_credit # 借方合計 − 貸方合計
|
||||||
|
re_account = _find_retained_earnings(cur)
|
||||||
|
|
||||||
|
# 既存データ削除(年度単位)
|
||||||
|
cur.execute("DELETE FROM opening_balances WHERE fiscal_year = %s", (req.fiscal_year,))
|
||||||
|
|
||||||
|
# 再登録:BS各行
|
||||||
|
for b in bs_items:
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
INSERT INTO opening_balances
|
INSERT INTO opening_balances
|
||||||
(fiscal_year, account_id, opening_debit, opening_credit)
|
(fiscal_year, account_id, opening_debit, opening_credit)
|
||||||
VALUES
|
VALUES (%s, %s, %s, %s)
|
||||||
(%s, %s, %s, %s)
|
""", (req.fiscal_year, b.account_id, b.debit, b.credit))
|
||||||
""", (
|
|
||||||
req.fiscal_year,
|
# 自動:繰越利益(剰余金)
|
||||||
b.account_id,
|
re_inserted = None
|
||||||
b.debit,
|
if re_account:
|
||||||
b.credit
|
re_id, re_name = re_account
|
||||||
))
|
if diff > 0:
|
||||||
|
# 借方が大 → その差額を「繰越利益(剰余金)の貸方」
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO opening_balances (fiscal_year, account_id, opening_debit, opening_credit)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
""", (req.fiscal_year, re_id, Decimal("0"), diff))
|
||||||
|
re_inserted = {"account_id": re_id, "name": re_name, "debit": "0", "credit": str(diff)}
|
||||||
|
elif diff < 0:
|
||||||
|
# 贷方が大 → その差額を「繰越利益(剰余金)の借方」
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO opening_balances (fiscal_year, account_id, opening_debit, opening_credit)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
""", (req.fiscal_year, re_id, -diff, Decimal("0")))
|
||||||
|
re_inserted = {"account_id": re_id, "name": re_name, "debit": str(-diff), "credit": "0"}
|
||||||
|
else:
|
||||||
|
# 完全一致 → 追加不要
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# 繰越利益科目が登録されていない場合:差額が 0 でないなら警告
|
||||||
|
if diff != 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="差額がありますが『繰越利益(剰余金)』科目が見つかりません。先に科目マスタを登録してください。"
|
||||||
|
)
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
return {"message": "期首残高を保存しました。"}
|
return {
|
||||||
|
"message": "期首残高を保存しました。",
|
||||||
|
"totals": {
|
||||||
|
"debit": str(total_debit),
|
||||||
|
"credit": str(total_credit),
|
||||||
|
"diff_before_retained": str(diff)
|
||||||
|
},
|
||||||
|
"retained_earnings_auto": re_inserted
|
||||||
|
}
|
||||||
|
|||||||
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/accounts.py
Normal file
0
backend/app/routers/accounts.py
Normal file
232
backend/app/routers/journal_entries.py
Normal file
232
backend/app/routers/journal_entries.py
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
from typing import Optional
|
||||||
|
from decimal import Decimal
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from app.core.database import get_connection
|
||||||
|
from app.models.journal_entry import JournalEntryRequest
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/journal-entries", tags=["仕訳"])
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# 仕訳登録
|
||||||
|
# -------------------------------------------------
|
||||||
|
@router.post("", summary="仕訳登録")
|
||||||
|
def create_journal_entry(req: JournalEntryRequest):
|
||||||
|
|
||||||
|
if len(req.lines) < 2:
|
||||||
|
raise HTTPException(status_code=400, detail="仕訳行は2行以上必要です")
|
||||||
|
|
||||||
|
if req.entry_date < date(2025, 6, 1):
|
||||||
|
raise HTTPException(status_code=400, detail="仕訳日は2025-06-01以降である必要があります")
|
||||||
|
|
||||||
|
debit_total = Decimal("0")
|
||||||
|
credit_total = Decimal("0")
|
||||||
|
|
||||||
|
for line in req.lines:
|
||||||
|
if line.debit < 0 or line.credit < 0:
|
||||||
|
raise HTTPException(status_code=400, detail="金額は正数で入力してください")
|
||||||
|
|
||||||
|
if line.debit > 0 and line.credit > 0:
|
||||||
|
raise HTTPException(status_code=400, detail="同一行で借方・貸方の同時入力は不可です")
|
||||||
|
|
||||||
|
debit_total += line.debit
|
||||||
|
credit_total += line.credit
|
||||||
|
|
||||||
|
if debit_total != credit_total:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"借方合計({debit_total})と貸方合計({credit_total})が一致しません"
|
||||||
|
)
|
||||||
|
|
||||||
|
with get_connection() as conn, conn.cursor() as cur:
|
||||||
|
|
||||||
|
# 期首ロック確認
|
||||||
|
cur.execute("""
|
||||||
|
SELECT is_locked
|
||||||
|
FROM opening_balance_locks
|
||||||
|
WHERE fiscal_year = 2025
|
||||||
|
""")
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row or not row[0]:
|
||||||
|
raise HTTPException(status_code=400, detail="期首残高が確定(lock)されていません")
|
||||||
|
|
||||||
|
# ヘッダ登録
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO journal_entries (entry_date, description)
|
||||||
|
VALUES (%s, %s)
|
||||||
|
RETURNING journal_entry_id
|
||||||
|
""", (req.entry_date, req.description))
|
||||||
|
journal_entry_id = cur.fetchone()[0]
|
||||||
|
|
||||||
|
# 明細登録
|
||||||
|
for line in req.lines:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO journal_lines
|
||||||
|
(journal_entry_id, account_id, debit, credit)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
""", (
|
||||||
|
journal_entry_id,
|
||||||
|
line.account_id,
|
||||||
|
line.debit,
|
||||||
|
line.credit
|
||||||
|
))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "仕訳を登録しました",
|
||||||
|
"journal_entry_id": journal_entry_id
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# 仕訳一覧取得
|
||||||
|
# -------------------------------------------------
|
||||||
|
@router.get("", summary="仕訳一覧取得")
|
||||||
|
def list_journal_entries(
|
||||||
|
from_date: Optional[str] = Query(None),
|
||||||
|
to_date: Optional[str] = Query(None),
|
||||||
|
keyword: Optional[str] = Query(None)
|
||||||
|
):
|
||||||
|
sql = """
|
||||||
|
SELECT
|
||||||
|
je.journal_entry_id,
|
||||||
|
je.entry_date,
|
||||||
|
je.description,
|
||||||
|
SUM(jl.debit) AS debit_total,
|
||||||
|
SUM(jl.credit) AS credit_total
|
||||||
|
FROM journal_entries je
|
||||||
|
JOIN journal_lines jl
|
||||||
|
ON jl.journal_entry_id = je.journal_entry_id
|
||||||
|
WHERE 1=1
|
||||||
|
"""
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if from_date:
|
||||||
|
sql += " AND je.entry_date >= %s"
|
||||||
|
params.append(from_date)
|
||||||
|
if to_date:
|
||||||
|
sql += " AND je.entry_date <= %s"
|
||||||
|
params.append(to_date)
|
||||||
|
if keyword:
|
||||||
|
sql += " AND je.description ILIKE %s"
|
||||||
|
params.append(f"%{keyword}%")
|
||||||
|
|
||||||
|
sql += """
|
||||||
|
GROUP BY je.journal_entry_id, je.entry_date, je.description
|
||||||
|
ORDER BY je.entry_date DESC, je.journal_entry_id DESC
|
||||||
|
"""
|
||||||
|
|
||||||
|
with get_connection() as conn, conn.cursor() as cur:
|
||||||
|
cur.execute(sql, params)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"journal_entry_id": r[0],
|
||||||
|
"entry_date": r[1],
|
||||||
|
"description": r[2],
|
||||||
|
"debit_total": r[3],
|
||||||
|
"credit_total": r[4],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# 仕訳明細取得
|
||||||
|
# -------------------------------------------------
|
||||||
|
@router.get("/{journal_entry_id}", summary="仕訳明細取得")
|
||||||
|
def get_journal_entry(journal_entry_id: int):
|
||||||
|
with get_connection() as conn, conn.cursor() as cur:
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
SELECT entry_date, description
|
||||||
|
FROM journal_entries
|
||||||
|
WHERE journal_entry_id = %s
|
||||||
|
""", (journal_entry_id,))
|
||||||
|
header = cur.fetchone()
|
||||||
|
if not header:
|
||||||
|
raise HTTPException(status_code=404, detail="仕訳が存在しません")
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
SELECT
|
||||||
|
jl.account_id,
|
||||||
|
a.account_code,
|
||||||
|
a.account_name,
|
||||||
|
jl.debit,
|
||||||
|
jl.credit
|
||||||
|
FROM journal_lines jl
|
||||||
|
JOIN accounts a ON a.account_id = jl.account_id
|
||||||
|
WHERE jl.journal_entry_id = %s
|
||||||
|
""", (journal_entry_id,))
|
||||||
|
lines = cur.fetchall()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"entry_date": header[0],
|
||||||
|
"description": header[1],
|
||||||
|
"lines": [
|
||||||
|
{
|
||||||
|
"account_id": l[0],
|
||||||
|
"account_code": l[1],
|
||||||
|
"account_name": l[2],
|
||||||
|
"debit": l[3],
|
||||||
|
"credit": l[4]
|
||||||
|
} for l in lines
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# 逆仕訳生成(修正仕訳)
|
||||||
|
# -------------------------------------------------
|
||||||
|
@router.post("/{journal_entry_id}/reverse", summary="逆仕訳生成")
|
||||||
|
def reverse_journal_entry(journal_entry_id: int):
|
||||||
|
with get_connection() as conn, conn.cursor() as cur:
|
||||||
|
|
||||||
|
# 元仕訳取得
|
||||||
|
cur.execute("""
|
||||||
|
SELECT entry_date, description
|
||||||
|
FROM journal_entries
|
||||||
|
WHERE journal_entry_id = %s
|
||||||
|
""", (journal_entry_id,))
|
||||||
|
header = cur.fetchone()
|
||||||
|
if not header:
|
||||||
|
raise HTTPException(status_code=404, detail="仕訳が存在しません")
|
||||||
|
|
||||||
|
entry_date, description = header
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
SELECT account_id, debit, credit
|
||||||
|
FROM journal_lines
|
||||||
|
WHERE journal_entry_id = %s
|
||||||
|
""", (journal_entry_id,))
|
||||||
|
lines = cur.fetchall()
|
||||||
|
if not lines:
|
||||||
|
raise HTTPException(status_code=400, detail="仕訳明細が存在しません")
|
||||||
|
|
||||||
|
# 逆仕訳ヘッダ
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO journal_entries (entry_date, description)
|
||||||
|
VALUES (%s, %s)
|
||||||
|
RETURNING journal_entry_id
|
||||||
|
""", (entry_date, f"【修正】{description}"))
|
||||||
|
new_entry_id = cur.fetchone()[0]
|
||||||
|
|
||||||
|
# 逆仕訳明細(借贷反転)
|
||||||
|
for account_id, debit, credit in lines:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO journal_lines
|
||||||
|
(journal_entry_id, account_id, debit, credit)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
""", (
|
||||||
|
new_entry_id,
|
||||||
|
account_id,
|
||||||
|
credit,
|
||||||
|
debit
|
||||||
|
))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "逆仕訳を作成しました",
|
||||||
|
"reversed_journal_entry_id": new_entry_id
|
||||||
|
}
|
||||||
0
backend/app/routers/opening_balances.py
Normal file
0
backend/app/routers/opening_balances.py
Normal file
Binary file not shown.
41
backend/sql/001_accounts_bs_master.sql
Normal file
41
backend/sql/001_accounts_bs_master.sql
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
-- =========================
|
||||||
|
-- BS 科目マスタ(3桁コード)
|
||||||
|
-- =========================
|
||||||
|
|
||||||
|
-- 資産
|
||||||
|
INSERT INTO accounts (account_code, account_name, account_type) VALUES
|
||||||
|
('101', '現金', 'asset'),
|
||||||
|
('102', '普通預金', 'asset'),
|
||||||
|
('103', '当座預金', 'asset'),
|
||||||
|
('104', '定期預金', 'asset'),
|
||||||
|
('201', '売掛金', 'asset'),
|
||||||
|
('202', '未収入金', 'asset'),
|
||||||
|
('203', '仮払金', 'asset'),
|
||||||
|
('204', '前払費用', 'asset'),
|
||||||
|
('205', '立替金', 'asset'),
|
||||||
|
('301', '有価証券', 'asset'),
|
||||||
|
('401', '建物', 'asset'),
|
||||||
|
('402', '建物附属設備', 'asset'),
|
||||||
|
('403', '構築物', 'asset'),
|
||||||
|
('404', '機械装置', 'asset'),
|
||||||
|
('405', '車両運搬具', 'asset'),
|
||||||
|
('406', '工具器具備品', 'asset'),
|
||||||
|
('407', '土地', 'asset'),
|
||||||
|
('408', 'ソフトウェア', 'asset');
|
||||||
|
|
||||||
|
-- 負債
|
||||||
|
INSERT INTO accounts (account_code, account_name, account_type) VALUES
|
||||||
|
('501', '買掛金', 'liability'),
|
||||||
|
('502', '未払金', 'liability'),
|
||||||
|
('503', '未払費用', 'liability'),
|
||||||
|
('504', '未払法人税等', 'liability'),
|
||||||
|
('505', '未払消費税等', 'liability'),
|
||||||
|
('506', '預り金', 'liability'),
|
||||||
|
('507', '前受金', 'liability'),
|
||||||
|
('508', '短期借入金', 'liability'),
|
||||||
|
('509', '長期借入金', 'liability');
|
||||||
|
|
||||||
|
-- 純資産
|
||||||
|
INSERT INTO accounts (account_code, account_name, account_type) VALUES
|
||||||
|
('601', '資本金', 'equity'),
|
||||||
|
('602', '繰越利益剰余金', 'equity');
|
||||||
324
frontend/journal-entry.html
Normal file
324
frontend/journal-entry.html
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ja">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>仕訳入力(複数行・消費税自動)</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: sans-serif; margin: 24px; }
|
||||||
|
table { border-collapse: collapse; width: 100%; margin-top: 12px; }
|
||||||
|
th, td { border: 1px solid #ccc; padding: 6px; text-align: center; }
|
||||||
|
th { background: #f5f5f5; }
|
||||||
|
input, select, button { padding: 6px; }
|
||||||
|
.right { text-align: right; }
|
||||||
|
.error { color: #c00; margin-top: 8px; }
|
||||||
|
.ok { color: #090; margin-top: 8px; }
|
||||||
|
.row { display: flex; gap: 12px; align-items: center; margin: 8px 0; }
|
||||||
|
.row > * { flex: 1; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<h2><div class="row">
|
||||||
|
<div>
|
||||||
|
<label>消費税 端数処理</label>
|
||||||
|
<select id="roundingMode">
|
||||||
|
<option value="round">四捨五入</option>
|
||||||
|
<option value="floor">切捨て</option>
|
||||||
|
<option value="ceil">切上げ</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div></h2>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<label>仕訳日</label>
|
||||||
|
<input type="date" id="entryDate" />
|
||||||
|
</div>
|
||||||
|
<div style="flex:2">
|
||||||
|
<label>摘要</label>
|
||||||
|
<input type="text" id="description" placeholder="例:売上計上/経費支払" style="width:100%" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<label>仮払消費税等 勘定</label>
|
||||||
|
<select id="taxDebitAccount"></select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>仮受消費税等 勘定</label>
|
||||||
|
<select id="taxCreditAccount"></select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table id="linesTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>科目</th>
|
||||||
|
<th>借方</th>
|
||||||
|
<th>貸方</th>
|
||||||
|
<th>税区分</th>
|
||||||
|
<th>税方向</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<th>合計</th>
|
||||||
|
<th id="debitTotal" class="right">0</th>
|
||||||
|
<th id="creditTotal" class="right">0</th>
|
||||||
|
<th colspan="3"></th>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<button onclick="addRow()">+ 行追加</button>
|
||||||
|
<button onclick="submitEntry()">登録</button>
|
||||||
|
|
||||||
|
<div id="result"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const API_BASE = "http://127.0.0.1:18080";
|
||||||
|
let accounts = [];
|
||||||
|
|
||||||
|
const TAX_OPTIONS = [
|
||||||
|
{ value: "none", label: "非課税" },
|
||||||
|
{ value: "10-excl", label: "10%(税抜)" },
|
||||||
|
{ value: "10-incl", label: "10%(税込)" },
|
||||||
|
{ value: "8-excl", label: "8%(税抜)" },
|
||||||
|
{ value: "8-incl", label: "8%(税込)" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const TAX_DIRECTIONS = [
|
||||||
|
{ value: "karibarai", label: "仮払(仕入・経費側)" },
|
||||||
|
{ value: "kariuke", label: "仮受(売上側)" }
|
||||||
|
];
|
||||||
|
|
||||||
|
// ------------------------
|
||||||
|
// 初期化
|
||||||
|
// ------------------------
|
||||||
|
async function init() {
|
||||||
|
const res = await fetch(`${API_BASE}/accounts`);
|
||||||
|
accounts = await res.json();
|
||||||
|
|
||||||
|
// 税勘定候補を抽出
|
||||||
|
const taxDebitSel = document.getElementById("taxDebitAccount");
|
||||||
|
const taxCreditSel = document.getElementById("taxCreditAccount");
|
||||||
|
|
||||||
|
const taxDebitCandidates = accounts.filter(a => a.account_name.includes("仮払消費税"));
|
||||||
|
const taxCreditCandidates = accounts.filter(a => a.account_name.includes("仮受消費税"));
|
||||||
|
|
||||||
|
// 仮払
|
||||||
|
fillSelect(taxDebitSel, taxDebitCandidates.length ? taxDebitCandidates : accounts);
|
||||||
|
// 仮受
|
||||||
|
fillSelect(taxCreditSel, taxCreditCandidates.length ? taxCreditCandidates : accounts);
|
||||||
|
|
||||||
|
addRow();
|
||||||
|
addRow();
|
||||||
|
recalc();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillSelect(sel, list) {
|
||||||
|
sel.innerHTML = "";
|
||||||
|
list.forEach(a => {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = a.account_id;
|
||||||
|
opt.textContent = `${a.account_code} ${a.account_name}`;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------
|
||||||
|
// 行追加・削除
|
||||||
|
// ------------------------
|
||||||
|
function addRow() {
|
||||||
|
const tbody = document.querySelector("#linesTable tbody");
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td>
|
||||||
|
<select class="account">
|
||||||
|
${accounts.map(a =>
|
||||||
|
`<option value="${a.account_id}">${a.account_code} ${a.account_name}</option>`
|
||||||
|
).join("")}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td><input type="number" min="0" class="debit" oninput="onDebitInput(this)" /></td>
|
||||||
|
<td><input type="number" min="0" class="credit" oninput="onCreditInput(this)" /></td>
|
||||||
|
<td>
|
||||||
|
<select class="tax">
|
||||||
|
${TAX_OPTIONS.map(t => `<option value="${t.value}">${t.label}</option>`).join("")}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select class="taxdir">
|
||||||
|
${TAX_DIRECTIONS.map(t => `<option value="${t.value}">${t.label}</option>`).join("")}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td><button onclick="removeRow(this)">削除</button></td>
|
||||||
|
`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRow(btn) {
|
||||||
|
btn.closest("tr").remove();
|
||||||
|
recalc();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDebitInput(input) {
|
||||||
|
const credit = input.closest("tr").querySelector(".credit");
|
||||||
|
if (input.value) credit.value = "";
|
||||||
|
recalc();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCreditInput(input) {
|
||||||
|
const debit = input.closest("tr").querySelector(".debit");
|
||||||
|
if (input.value) debit.value = "";
|
||||||
|
recalc();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------
|
||||||
|
// 合計再計算(税は送信時に展開するので、ここは本体行のみ)
|
||||||
|
// ------------------------
|
||||||
|
function recalc() {
|
||||||
|
let debitTotal = 0;
|
||||||
|
let creditTotal = 0;
|
||||||
|
document.querySelectorAll("#linesTable tbody tr").forEach(tr => {
|
||||||
|
const d = Number(tr.querySelector(".debit").value || 0);
|
||||||
|
const c = Number(tr.querySelector(".credit").value || 0);
|
||||||
|
debitTotal += d;
|
||||||
|
creditTotal += c;
|
||||||
|
});
|
||||||
|
document.getElementById("debitTotal").textContent = debitTotal.toLocaleString();
|
||||||
|
document.getElementById("creditTotal").textContent = creditTotal.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------
|
||||||
|
// 税計算(四捨五入)
|
||||||
|
// ------------------------
|
||||||
|
function calcTaxSplit(amount, rate, inclusive) {
|
||||||
|
if (!rate) return { base: amount, tax: 0 };
|
||||||
|
|
||||||
|
if (!inclusive) {
|
||||||
|
// 税抜:amount = 本体
|
||||||
|
const tax = applyRounding(amount * rate);
|
||||||
|
return { base: amount, tax };
|
||||||
|
} else {
|
||||||
|
// 税込:amount = 総額
|
||||||
|
const base = applyRounding(amount / (1 + rate));
|
||||||
|
const tax = amount - base; // 差額は必ず一致させる
|
||||||
|
return { base, tax };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTaxOption(opt) {
|
||||||
|
if (opt === "none") return { rate: 0, inclusive: false };
|
||||||
|
const [rateStr, mode] = opt.split("-");
|
||||||
|
const rate = rateStr === "10" ? 0.10 : 0.08;
|
||||||
|
return { rate, inclusive: mode === "incl" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------
|
||||||
|
// 送信(税行を自動付与)
|
||||||
|
// ------------------------
|
||||||
|
async function submitEntry() {
|
||||||
|
const result = document.getElementById("result");
|
||||||
|
result.textContent = "";
|
||||||
|
result.className = "";
|
||||||
|
|
||||||
|
const entryDate = document.getElementById("entryDate").value;
|
||||||
|
const desc = document.getElementById("description").value;
|
||||||
|
if (!entryDate) return showError("仕訳日を入力してください");
|
||||||
|
if (!desc) return showError("摘要を入力してください");
|
||||||
|
|
||||||
|
const taxDebitAccountId = Number(document.getElementById("taxDebitAccount").value);
|
||||||
|
const taxCreditAccountId = Number(document.getElementById("taxCreditAccount").value);
|
||||||
|
|
||||||
|
const baseLines = [];
|
||||||
|
const taxLines = [];
|
||||||
|
|
||||||
|
document.querySelectorAll("#linesTable tbody tr").forEach(tr => {
|
||||||
|
const accountId = Number(tr.querySelector(".account").value);
|
||||||
|
const debit = Number(tr.querySelector(".debit").value || 0);
|
||||||
|
const credit = Number(tr.querySelector(".credit").value || 0);
|
||||||
|
const taxOpt = tr.querySelector(".tax").value;
|
||||||
|
const taxDir = tr.querySelector(".taxdir").value;
|
||||||
|
|
||||||
|
// 空行は無視
|
||||||
|
if (debit === 0 && credit === 0) return;
|
||||||
|
|
||||||
|
const { rate, inclusive } = parseTaxOption(taxOpt);
|
||||||
|
|
||||||
|
if (debit > 0) {
|
||||||
|
const { base, tax } = calcTaxSplit(debit, rate, inclusive);
|
||||||
|
baseLines.push({ account_id: accountId, debit: base, credit: 0 });
|
||||||
|
if (tax > 0) {
|
||||||
|
if (taxDir === "karibarai") {
|
||||||
|
taxLines.push({ account_id: taxDebitAccountId, debit: tax, credit: 0 });
|
||||||
|
} else {
|
||||||
|
taxLines.push({ account_id: taxCreditAccountId, debit: 0, credit: tax });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const { base, tax } = calcTaxSplit(credit, rate, inclusive);
|
||||||
|
baseLines.push({ account_id: accountId, debit: 0, credit: base });
|
||||||
|
if (tax > 0) {
|
||||||
|
if (taxDir === "karibarai") {
|
||||||
|
taxLines.push({ account_id: taxDebitAccountId, debit: tax, credit: 0 });
|
||||||
|
} else {
|
||||||
|
taxLines.push({ account_id: taxCreditAccountId, debit: 0, credit: tax });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const lines = [...baseLines, ...taxLines];
|
||||||
|
|
||||||
|
if (lines.length < 2)
|
||||||
|
return showError("仕訳行は2行以上必要です");
|
||||||
|
|
||||||
|
// 借貸チェック(送信前に最終確認)
|
||||||
|
const tot = lines.reduce((s, l) => {
|
||||||
|
s.debit += Number(l.debit || 0);
|
||||||
|
s.credit += Number(l.credit || 0);
|
||||||
|
return s;
|
||||||
|
}, {debit:0, credit:0});
|
||||||
|
|
||||||
|
if (tot.debit !== tot.credit)
|
||||||
|
return showError(`借方合計(${tot.debit})と貸方合計(${tot.credit})が一致していません`);
|
||||||
|
|
||||||
|
const payload = { entry_date: entryDate, description: desc, lines };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/journal-entries`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) return showError(data.detail || "登録失敗");
|
||||||
|
showOk(`登録完了(ID: ${data.journal_entry_id})`);
|
||||||
|
} catch {
|
||||||
|
showError("通信エラー");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
const r = document.getElementById("result");
|
||||||
|
r.textContent = msg;
|
||||||
|
r.className = "error";
|
||||||
|
}
|
||||||
|
function showOk(msg) {
|
||||||
|
const r = document.getElementById("result");
|
||||||
|
r.textContent = msg;
|
||||||
|
r.className = "ok";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合計の初期表示用
|
||||||
|
document.addEventListener("input", () => recalc());
|
||||||
|
init();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
97
frontend/journal-list.html
Normal file
97
frontend/journal-list.html
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ja">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>仕訳一覧</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: sans-serif; margin: 24px; }
|
||||||
|
table { border-collapse: collapse; width: 100%; margin-top: 12px; }
|
||||||
|
th, td { border: 1px solid #ccc; padding: 6px; }
|
||||||
|
th { background: #f5f5f5; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<h2>仕訳一覧</h2>
|
||||||
|
|
||||||
|
<label>期間</label>
|
||||||
|
<input type="date" id="fromDate"> ~ <input type="date" id="toDate">
|
||||||
|
|
||||||
|
<label>摘要</label>
|
||||||
|
<input type="text" id="keyword">
|
||||||
|
|
||||||
|
<button onclick="search()">検索</button>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>日付</th>
|
||||||
|
<th>摘要</th>
|
||||||
|
<th>借方合計</th>
|
||||||
|
<th>贷方合计</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="list"></tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const API = "http://127.0.0.1:18080";
|
||||||
|
|
||||||
|
async function search() {
|
||||||
|
const from = document.getElementById("fromDate").value;
|
||||||
|
const to = document.getElementById("toDate").value;
|
||||||
|
const key = document.getElementById("keyword").value;
|
||||||
|
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (from) qs.append("from_date", from);
|
||||||
|
if (to) qs.append("to_date", to);
|
||||||
|
if (key) qs.append("keyword", key);
|
||||||
|
|
||||||
|
const res = await fetch(`${API}/journal-entries?${qs.toString()}`);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
const tbody = document.getElementById("list");
|
||||||
|
tbody.innerHTML = "";
|
||||||
|
|
||||||
|
data.forEach(e => {
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td>${e.entry_date}</td>
|
||||||
|
<td>${e.description}</td>
|
||||||
|
<td style="text-align:right">${e.debit_total.toLocaleString()}</td>
|
||||||
|
<td style="text-align:right">${e.credit_total.toLocaleString()}</td>
|
||||||
|
<td>
|
||||||
|
<button onclick="view(${e.journal_entry_id})">表示</button>
|
||||||
|
<button onclick="reverse(${e.journal_entry_id})">修正</button>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function view(id) {
|
||||||
|
window.open(`journal-view.html?id=${id}`, "_blank");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reverse(id) {
|
||||||
|
if (!confirm("この仕訳の逆仕訳を作成します。よろしいですか?")) return;
|
||||||
|
|
||||||
|
const res = await fetch(`${API}/journal-entries/${id}/reverse`, {
|
||||||
|
method: "POST"
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
alert(data.detail || "逆仕訳の作成に失敗しました");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
alert(`逆仕訳を作成しました(ID: ${data.reversed_journal_entry_id})`);
|
||||||
|
search();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user