新規作成
This commit is contained in:
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
BIN
backend/app/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
backend/app/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/main.cpython-312.pyc
Normal file
BIN
backend/app/__pycache__/main.cpython-312.pyc
Normal file
Binary file not shown.
0
backend/app/common/auth.py
Normal file
0
backend/app/common/auth.py
Normal file
0
backend/app/common/utils.py
Normal file
0
backend/app/common/utils.py
Normal file
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
BIN
backend/app/core/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
backend/app/core/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/core/__pycache__/database.cpython-312.pyc
Normal file
BIN
backend/app/core/__pycache__/database.cpython-312.pyc
Normal file
Binary file not shown.
0
backend/app/core/config.py
Normal file
0
backend/app/core/config.py
Normal file
14
backend/app/core/database.py
Normal file
14
backend/app/core/database.py
Normal 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
44
backend/app/main.py
Normal 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)
|
||||
|
||||
0
backend/app/modules/__init__.py
Normal file
0
backend/app/modules/__init__.py
Normal file
BIN
backend/app/modules/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
backend/app/modules/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
0
backend/app/modules/accounts/__init__.py
Normal file
0
backend/app/modules/accounts/__init__.py
Normal file
Binary file not shown.
BIN
backend/app/modules/accounts/__pycache__/router.cpython-312.pyc
Normal file
BIN
backend/app/modules/accounts/__pycache__/router.cpython-312.pyc
Normal file
Binary file not shown.
66
backend/app/modules/accounts/router.py
Normal file
66
backend/app/modules/accounts/router.py
Normal 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="入力値が不正です。")
|
||||
0
backend/app/modules/journals/__init__.py
Normal file
0
backend/app/modules/journals/__init__.py
Normal file
Binary file not shown.
BIN
backend/app/modules/journals/__pycache__/router.cpython-312.pyc
Normal file
BIN
backend/app/modules/journals/__pycache__/router.cpython-312.pyc
Normal file
Binary file not shown.
111
backend/app/modules/journals/router.py
Normal file
111
backend/app/modules/journals/router.py
Normal 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="金額の指定が不正です。")
|
||||
0
backend/app/modules/ledger/__init__.py
Normal file
0
backend/app/modules/ledger/__init__.py
Normal file
BIN
backend/app/modules/ledger/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
backend/app/modules/ledger/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/modules/ledger/__pycache__/router.cpython-312.pyc
Normal file
BIN
backend/app/modules/ledger/__pycache__/router.cpython-312.pyc
Normal file
Binary file not shown.
109
backend/app/modules/ledger/router.py
Normal file
109
backend/app/modules/ledger/router.py
Normal 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
|
||||
0
backend/app/modules/trial_balance/__init__.py
Normal file
0
backend/app/modules/trial_balance/__init__.py
Normal file
Binary file not shown.
Binary file not shown.
94
backend/app/modules/trial_balance/router.py
Normal file
94
backend/app/modules/trial_balance/router.py
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user