新規作成

This commit is contained in:
admin
2025-12-13 23:35:45 +09:00
parent 446f17f21a
commit 49cf65a5cb
2696 changed files with 332525 additions and 0 deletions

0
backend/app/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

View File

View File

View File

Binary file not shown.

Binary file not shown.

View File

View File

@@ -0,0 +1,14 @@
import os
from psycopg import connect
from psycopg.rows import dict_row
def get_connection():
return connect(
host=os.getenv("DB_HOST"),
port=os.getenv("DB_PORT"),
dbname=os.getenv("DB_NAME"),
user=os.getenv("DB_USER"),
password=os.getenv("DB_PASSWORD"),
row_factory=dict_row,
)

44
backend/app/main.py Normal file
View File

@@ -0,0 +1,44 @@
from dotenv import load_dotenv
load_dotenv()
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from pydantic import ValidationError
# FastAPI アプリケーション作成
app = FastAPI(
title="日本小規模企業向け会計システム"
)
@app.exception_handler(ValidationError)
async def validation_exception_handler(request: Request, exc: ValidationError):
# ここでは最初のエラーだけ簡潔に返す(必要に応じて整形)
first = exc.errors()[0]
msg = first.get("msg", "入力値が不正です。")
return JSONResponse(status_code=400, content={"detail": msg})
# ─────────────────────────
# ルート(稼働確認)
# ─────────────────────────
@app.get("/", summary="稼働確認")
def root():
return {"status": "ok"}
# ─────────────────────────
# ルーター登録(※ 必ず app 定義の後)
# ─────────────────────────
from app.modules.accounts.router import router as accounts_router
app.include_router(accounts_router)
from app.modules.journals.router import router as journals_router
app.include_router(journals_router)
from app.modules.ledger.router import router as ledger_router
app.include_router(ledger_router)
from app.modules.trial_balance.router import router as trial_balance_router
app.include_router(trial_balance_router)

View File

View File

View File

@@ -0,0 +1,66 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, field_validator
from enum import Enum
from psycopg.errors import UniqueViolation, CheckViolation, StringDataRightTruncation
from app.core.database import get_connection
router = APIRouter(prefix="/accounts", tags=["科目マスタ"])
class AccountType(str, Enum):
asset = "asset"
liability = "liability"
equity = "equity"
revenue = "revenue"
expense = "expense"
class AccountCreate(BaseModel):
account_code: str
account_name: str
account_type: AccountType
@field_validator("account_code")
@classmethod
def validate_code(cls, v: str):
if not v.isdigit():
raise ValueError("科目コードは数字のみで入力してください")
if len(v) > 20:
raise ValueError("科目コードが長すぎます20文字以内")
return v
@field_validator("account_name")
@classmethod
def validate_name(cls, v: str):
if not v.strip():
raise ValueError("科目名を入力してください")
if len(v) > 100:
raise ValueError("科目名が長すぎます100文字以内")
return v
@router.get("/", summary="科目一覧取得")
def list_accounts():
with get_connection() as conn, conn.cursor() as cur:
cur.execute("""
SELECT account_id, account_code, account_name, account_type, is_active
FROM accounts ORDER BY account_code
""")
return cur.fetchall()
@router.post("/", summary="科目登録", status_code=201)
def create_account(data: AccountCreate):
try:
with get_connection() as conn, conn.cursor() as cur:
cur.execute("""
INSERT INTO accounts (account_code, account_name, account_type)
VALUES (%s, %s, %s) RETURNING account_id
""", (data.account_code, data.account_name, data.account_type.value))
new_id = cur.fetchone()["account_id"]
conn.commit()
return {"account_id": new_id}
except UniqueViolation:
# 409 也行;这里按你的要求统一 400
raise HTTPException(status_code=400, detail="この科目コードは既に登録されています。")
except StringDataRightTruncation:
raise HTTPException(status_code=400, detail="入力文字数が制限を超えています。")
except CheckViolation:
raise HTTPException(status_code=400, detail="入力値が不正です。")

View File

View File

@@ -0,0 +1,111 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, field_validator
from typing import List
from decimal import Decimal, InvalidOperation
from psycopg.errors import ForeignKeyViolation, CheckViolation
from app.core.database import get_connection
router = APIRouter(prefix="/journals", tags=["仕訳"])
class JournalLine(BaseModel):
account_id: int
debit: Decimal = Decimal("0")
credit: Decimal = Decimal("0")
@field_validator("debit", "credit", mode="before")
@classmethod
def to_decimal(cls, v):
# 允许传 0 / 整数 / 字符串
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 的校验在整体里做
return v
class JournalCreate(BaseModel):
journal_date: str # YYYY-MM-DD
description: str
lines: List[JournalLine]
@field_validator("journal_date")
@classmethod
def validate_date(cls, v: str):
# 简单校验格式,严格校验可用 datetime.date.fromisoformat
if len(v) != 10 or v[4] != "-" or v[7] != "-":
raise ValueError("日付はYYYY-MM-DD形式で入力してください")
return v
@field_validator("description")
@classmethod
def validate_desc(cls, v: str):
if not v.strip():
raise ValueError("摘要を入力してください")
if len(v) > 255:
raise ValueError("摘要が長すぎます255文字以内")
return v
@router.post("/", summary="仕訳登録", status_code=201)
def create_journal(data: JournalCreate):
# 1) 行必須
if not data.lines:
raise HTTPException(status_code=400, detail="仕訳明細を1行以上入力してください。")
# 2) 借貸排他 & 非ゼロ
for i, l in enumerate(data.lines, start=1):
if l.debit > 0 and l.credit > 0:
raise HTTPException(status_code=400, detail=f"{i}行目:借方と貸方を同時に入力しないでください。")
if l.debit == 0 and l.credit == 0:
raise HTTPException(status_code=400, detail=f"{i}行目:金額が未入力です(借方または貸方のどちらかに金額を入力)。")
# 3) 借貸一致
debit_total = sum(l.debit for l in data.lines)
credit_total = sum(l.credit for l in data.lines)
if debit_total != credit_total:
raise HTTPException(status_code=400, detail="借方合計と貸方合計が一致しません。")
# 4) 仕訳に使う科目が全て存在するか事前チェック
account_ids = sorted({l.account_id for l in data.lines})
with get_connection() as conn, conn.cursor() as cur:
cur.execute(
"SELECT account_id FROM accounts WHERE account_id = ANY(%s)",
(account_ids,)
)
found = {row["account_id"] for row in cur.fetchall()}
missing = [a for a in account_ids if a not in found]
if missing:
raise HTTPException(
status_code=400,
detail=f"存在しない科目IDが含まれています{missing}"
)
try:
# 5) 登録(ヘッダ → 明細)
cur.execute(
"INSERT INTO journal_entries (journal_date, description) VALUES (%s, %s) RETURNING journal_id",
(data.journal_date, data.description),
)
jid = cur.fetchone()["journal_id"]
for l in data.lines:
cur.execute(
"""INSERT INTO journal_lines (journal_id, account_id, debit, credit)
VALUES (%s, %s, %s, %s)""",
(jid, l.account_id, l.debit, l.credit),
)
conn.commit()
return {"journal_id": jid}
except ForeignKeyViolation:
# 理论上到不了(我们前面已做存在性检查),兜底
raise HTTPException(status_code=400, detail="科目IDが不正です。")
except CheckViolation:
raise HTTPException(status_code=400, detail="金額の指定が不正です。")

View File

View File

@@ -0,0 +1,109 @@
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=["元帳"])
def to_decimal(x) -> Decimal:
if isinstance(x, Decimal):
return x
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"),
):
# 1) 科目存在チェック
with get_connection() as conn, conn.cursor() as cur:
cur.execute("""
SELECT account_id, account_code, account_name, account_type
FROM accounts
WHERE account_id = %s
""", (account_id,))
account = cur.fetchone()
if not account:
raise HTTPException(status_code=404, detail="指定した科目が見つかりません。")
# 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 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 より前)
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"])
# 4) 期間内明細
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
WHERE l.account_id = %s
AND j.journal_date BETWEEN %s AND %s
ORDER BY j.journal_date, j.journal_id, 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")
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) # 借方残を正とする(借方−貸方)
})
result = {
"account": {
"id": account["account_id"],
"code": account["account_code"],
"name": account["account_name"],
"type": account["account_type"],
},
"period": {"from": date_from, "to": date_to},
"opening_balance": str(opening),
"total_debit": str(total_debit),
"total_credit": str(total_credit),
"closing_balance": str(balance),
"rows": out_rows
}
return result

View File

@@ -0,0 +1,94 @@
from fastapi import APIRouter, HTTPException, Query
from decimal import Decimal
from typing import Dict, Any, List
from app.core.database import get_connection
router = APIRouter(prefix="/trial-balance", tags=["試算表"])
def D(x) -> Decimal:
return Decimal(str(x or 0))
@router.get("", summary="試算表(全科目)")
def get_trial_balance(
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="期間指定が不正です。")
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 {
"period": {
"from": date_from,
"to": date_to
},
"totals": {
"opening_balance": str(total_opening),
"debit": str(total_debit),
"credit": str(total_credit),
"closing_balance": str(total_closing)
},
"accounts": result_accounts
}