chore: 年度リストを昇順に変更
This commit is contained in:
@@ -146,13 +146,6 @@ def run_auto_migration():
|
||||
except Exception as e:
|
||||
print(f"⚠️ ねんきんポータル テーブル作成エラー(無視): {e}")
|
||||
|
||||
# ── 銀行明細テーブル作成 ──
|
||||
try:
|
||||
from app.modules.bank_statements.migration import create_bank_statement_table
|
||||
create_bank_statement_table()
|
||||
except Exception as e:
|
||||
print(f"⚠️ 銀行明細テーブル作成エラー(無視): {e}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ 無法連接到數據庫或執行遷移: {e}")
|
||||
print("\n提示:")
|
||||
|
||||
@@ -321,18 +321,28 @@ def list_statements(
|
||||
year: Optional[int] = None,
|
||||
month: Optional[int] = None,
|
||||
bank_name: Optional[str] = None,
|
||||
date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None,
|
||||
limit: int = 10000,
|
||||
offset: int = 0,
|
||||
):
|
||||
conditions: list = []
|
||||
params: list = []
|
||||
|
||||
if year:
|
||||
conditions.append("EXTRACT(YEAR FROM tx_date)::int = %s")
|
||||
params.append(year)
|
||||
if month:
|
||||
conditions.append("EXTRACT(MONTH FROM tx_date)::int = %s")
|
||||
params.append(month)
|
||||
if date_from:
|
||||
conditions.append("tx_date >= %s::date")
|
||||
params.append(date_from)
|
||||
if date_to:
|
||||
conditions.append("tx_date <= %s::date")
|
||||
params.append(date_to)
|
||||
# date_from/date_to が両方未指定の場合のみ year/month フォールバック
|
||||
if not date_from and not date_to:
|
||||
if year:
|
||||
conditions.append("EXTRACT(YEAR FROM tx_date)::int = %s")
|
||||
params.append(year)
|
||||
if month:
|
||||
conditions.append("EXTRACT(MONTH FROM tx_date)::int = %s")
|
||||
params.append(month)
|
||||
if bank_name:
|
||||
conditions.append("bank_name = %s")
|
||||
params.append(bank_name)
|
||||
|
||||
@@ -8,6 +8,9 @@ import secrets
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import re
|
||||
import io
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@@ -233,3 +236,243 @@ async def mark_post_done(access_token: str, post_id: str) -> dict:
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
# ── 申請書作成 API ─────────────────────────────────────────────
|
||||
|
||||
async def get_procedure_skeleton(access_token: str, proc_id: str) -> dict:
|
||||
"""
|
||||
手続選択:申請書スケルトン(ひな形)取得
|
||||
GET /procedure/{proc_id}
|
||||
Returns: {results: {file_data (base64 zip), configuration_file_name, file_info}}
|
||||
"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
f"{API_BASE}/procedure/{proc_id}",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
timeout=60,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def submit_apply(access_token: str, proc_id: str,
|
||||
file_name: str, file_data_b64: str,
|
||||
trial: bool = True) -> dict:
|
||||
"""
|
||||
申請データ送信
|
||||
POST /apply {"proc_id": "...", "send_file": {"file_name": "...", "file_data": "base64..."}}
|
||||
Returns: {results: {arrive_id, arrive_date, proc_name, ...}}
|
||||
"""
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
if trial:
|
||||
headers["X-eGovAPI-Trial"] = "true"
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
f"{API_BASE}/apply",
|
||||
headers=headers,
|
||||
json={
|
||||
"proc_id": proc_id,
|
||||
"send_file": {
|
||||
"file_name": file_name,
|
||||
"file_data": file_data_b64,
|
||||
},
|
||||
},
|
||||
timeout=120,
|
||||
)
|
||||
if not resp.is_success:
|
||||
try:
|
||||
err_body = resp.json()
|
||||
except Exception:
|
||||
err_body = resp.text
|
||||
raise RuntimeError(f"HTTP {resp.status_code}: {err_body}")
|
||||
return resp.json()
|
||||
|
||||
|
||||
# ── スケルトン XML 修正 ──────────────────────────────────────────
|
||||
|
||||
def _fill_empty_tag(text: str, tag: str, value: str) -> str:
|
||||
"""kousei.xml 内の空タグ <tag/> または <tag></tag> を <tag>value</tag> に置換"""
|
||||
# self-closing: <tag/> または <tag />
|
||||
text = re.sub(r'<' + re.escape(tag) + r'\s*/>', f'<{tag}>{value}</{tag}>', text)
|
||||
# empty: <tag></tag> または <tag> </tag>
|
||||
text = re.sub(
|
||||
r'<' + re.escape(tag) + r'\s*>\s*</' + re.escape(tag) + r'>',
|
||||
f'<{tag}>{value}</{tag}>', text
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
def modify_skeleton_for_post_apply(
|
||||
file_data_b64: str,
|
||||
proc_id: str,
|
||||
proc_name: str,
|
||||
uketsuke_kikan_id: str,
|
||||
shinsei_shubetsu: str = "申込",
|
||||
shimei: str = "テスト タロウ",
|
||||
shimei_furigana: str = "テスト タロウ",
|
||||
yubin: str = "1300022",
|
||||
jusho: str = "東京都墨田区江東橋4丁目31番10号",
|
||||
jigyosho_seiri_mae: str = "41",
|
||||
jigyosho_seiri_ato: str = "シスメ",
|
||||
jigyosho_bangou: str = "23090",
|
||||
) -> str:
|
||||
"""
|
||||
スケルトン ZIP の kousei.xml に必須フィールドを埋めて再パックする。
|
||||
戻り値: 修正済み ZIP の base64 文字列
|
||||
"""
|
||||
raw = base64.b64decode(file_data_b64)
|
||||
buf_out = io.BytesIO()
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(raw), 'r') as zin:
|
||||
with zipfile.ZipFile(buf_out, 'w', zipfile.ZIP_DEFLATED) as zout:
|
||||
for item in zin.infolist():
|
||||
data = zin.read(item.filename)
|
||||
name_lower = item.filename.lower()
|
||||
if name_lower.endswith('/kousei.xml') or name_lower == 'kousei.xml':
|
||||
data = _modify_kousei_xml(
|
||||
data, proc_id, proc_name, uketsuke_kikan_id,
|
||||
shinsei_shubetsu, shimei, shimei_furigana, yubin, jusho
|
||||
)
|
||||
elif name_lower.endswith('_01.xml') and 'check' not in name_lower:
|
||||
# 申請書XML: 事業所情報を埋める
|
||||
data = _modify_apply_xml(data, jigyosho_seiri_mae, jigyosho_seiri_ato, jigyosho_bangou)
|
||||
zout.writestr(item, data)
|
||||
|
||||
return base64.b64encode(buf_out.getvalue()).decode()
|
||||
|
||||
|
||||
def _modify_apply_xml(xml_bytes: bytes, seiri_mae: str, seiri_ato: str, bangou: str) -> bytes:
|
||||
"""申請書XML の事業所情報を埋める"""
|
||||
try:
|
||||
text = xml_bytes.decode('utf-8')
|
||||
except Exception:
|
||||
text = xml_bytes.decode('shift_jis', errors='replace')
|
||||
text = _fill_empty_tag(text, '事業所整理記号_前', seiri_mae)
|
||||
text = _fill_empty_tag(text, '事業所整理記号_後', seiri_ato)
|
||||
text = _fill_empty_tag(text, '事業所番号', bangou)
|
||||
return text.encode('utf-8')
|
||||
|
||||
|
||||
def _modify_kousei_xml(
|
||||
xml_bytes: bytes,
|
||||
proc_id: str,
|
||||
proc_name: str,
|
||||
uketsuke_kikan_id: str,
|
||||
shinsei_shubetsu: str,
|
||||
shimei: str,
|
||||
shimei_furigana: str,
|
||||
yubin: str,
|
||||
jusho: str,
|
||||
teishutsusaki_id: str = "900API00000000001001001",
|
||||
teishutsusaki_name: str = "総務省,行政管理局,API",
|
||||
jusho_furigana: str = "トウキョウトスミダクコウトウバシ4チョウメ31バン10ゴウ",
|
||||
tel: str = "03-1234-5678",
|
||||
email: str = "test@example.com",
|
||||
) -> bytes:
|
||||
"""kousei.xml の必須フィールドをテキスト置換で埋める(名前空間非依存)"""
|
||||
try:
|
||||
text = xml_bytes.decode('utf-8')
|
||||
except Exception:
|
||||
text = xml_bytes.decode('shift_jis', errors='replace')
|
||||
|
||||
fills = [
|
||||
('受付行政機関ID', uketsuke_kikan_id),
|
||||
('手続ID', proc_id),
|
||||
('手続名称', proc_name),
|
||||
('申請種別', shinsei_shubetsu),
|
||||
('氏名フリガナ', shimei_furigana), # フリガナを先に(氏名より長いので誤置換防止)
|
||||
('氏名', shimei),
|
||||
('郵便番号', yubin),
|
||||
('住所', jusho),
|
||||
('提出先識別子', teishutsusaki_id),
|
||||
('提出先名称', teishutsusaki_name),
|
||||
('住所フリガナ', jusho_furigana),
|
||||
('電話番号', tel),
|
||||
('電子メールアドレス', email),
|
||||
]
|
||||
for tag, value in fills:
|
||||
text = _fill_empty_tag(text, tag, value)
|
||||
|
||||
# 申請書属性情報を </提出先情報> の直後に挿入(スケルトンに存在しないため)
|
||||
shinsei_attr = (
|
||||
'<申請書属性情報>'
|
||||
'<申請書様式ID>900A13800001000001</申請書様式ID>'
|
||||
'<申請書様式バージョン>0001</申請書様式バージョン>'
|
||||
'<申請書様式名称>APIテスト用手続(電子送達関係手続)(通)0001_01</申請書様式名称>'
|
||||
'<申請書ファイル名称>900A13800001000001_01.xml</申請書ファイル名称>'
|
||||
'</申請書属性情報>'
|
||||
)
|
||||
if '<申請書属性情報>' not in text:
|
||||
text = text.replace('</提出先情報>', '</提出先情報>' + shinsei_attr, 1)
|
||||
|
||||
return text.encode('utf-8')
|
||||
|
||||
|
||||
async def submit_post_apply(access_token: str, proc_id: str,
|
||||
file_name: str, file_data_b64: str) -> dict:
|
||||
"""
|
||||
電子送達利用申込み
|
||||
POST /post-apply {"proc_id": "...", "send_file": {"file_name": "...", "file_data": "base64..."}}
|
||||
Returns: {results: {arrive_id, arrive_date, proc_name, ...}}
|
||||
※電子送達はトライアル非対応のため X-eGovAPI-Trial は送信しない
|
||||
"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
f"{API_BASE}/post-apply",
|
||||
headers={
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"proc_id": proc_id,
|
||||
"send_file": {
|
||||
"file_name": file_name,
|
||||
"file_data": file_data_b64,
|
||||
},
|
||||
},
|
||||
timeout=120,
|
||||
)
|
||||
if not resp.is_success:
|
||||
try:
|
||||
err_body = resp.json()
|
||||
except Exception:
|
||||
err_body = resp.text
|
||||
raise RuntimeError(f"HTTP {resp.status_code}: {err_body}")
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def get_apply_list(access_token: str, limit: int = 100) -> dict:
|
||||
"""
|
||||
申請案件一覧取得
|
||||
GET /apply/lists
|
||||
Returns: {results: {apply_list: [...]}}
|
||||
"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
f"{API_BASE}/apply/lists",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
params={"limit": limit},
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def get_apply_detail(access_token: str, arrive_id: str) -> dict:
|
||||
"""
|
||||
申請案件取得(詳細)
|
||||
GET /apply/{arrive_id}
|
||||
"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
f"{API_BASE}/apply/{arrive_id}",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
@@ -29,6 +29,10 @@ class CallbackRequest(BaseModel):
|
||||
state: str
|
||||
|
||||
|
||||
class ApplyRequest(BaseModel):
|
||||
proc_id: str # 16文字の手続識別子
|
||||
|
||||
|
||||
class ConfigRequest(BaseModel):
|
||||
software_id: str
|
||||
api_key: str
|
||||
@@ -245,6 +249,18 @@ async def save_config(req: ConfigRequest):
|
||||
return {"message": "設定を保存しました"}
|
||||
|
||||
|
||||
@router.get("/config-info")
|
||||
async def get_config_info():
|
||||
"""現在の設定情報(APIキーは非表示)を返す"""
|
||||
import os
|
||||
cfg = egov_api.load_api_config()
|
||||
return {
|
||||
"software_id": cfg.get("client_id", ""),
|
||||
"redirect_uri": cfg.get("redirect_uri", ""),
|
||||
"environment": os.environ.get("EGOV_ENV", "production"),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/auth-url")
|
||||
async def get_auth_url():
|
||||
"""OAuth2 + PKCE 認可 URL を生成して返す"""
|
||||
@@ -370,3 +386,154 @@ async def debug_page():
|
||||
return {"browser_state": state.status, **diag}
|
||||
except Exception as e:
|
||||
return {"error": str(e), "status": state.status}
|
||||
|
||||
|
||||
# ── 申請書作成・一覧 エンドポイント ────────────────────────────
|
||||
|
||||
async def _ensure_valid_token() -> str:
|
||||
"""有効なアクセストークンを返す(期限切れ時は自動更新)"""
|
||||
cfg = egov_api.load_api_config()
|
||||
if not cfg["client_id"] or not cfg["client_secret"]:
|
||||
raise HTTPException(status_code=400, detail="e-Gov API 資格情報が未設定です")
|
||||
|
||||
# メモリが空の場合はファイルから expires_at を正しく復元
|
||||
if not state.access_token:
|
||||
saved = egov_api.load_tokens()
|
||||
if saved.get("access_token"):
|
||||
state.access_token = saved["access_token"]
|
||||
state.refresh_token = saved.get("refresh_token")
|
||||
state.token_expires_at = saved.get("expires_at") # float タイムスタンプをそのまま復元
|
||||
|
||||
# トークンが期限切れ → リフレッシュを試みる
|
||||
if not state.is_token_valid() and state.refresh_token:
|
||||
try:
|
||||
token_resp = await egov_api.refresh_tokens_request(
|
||||
state.refresh_token, cfg["client_id"], cfg["client_secret"]
|
||||
)
|
||||
state.set_tokens(token_resp)
|
||||
egov_api.save_tokens({
|
||||
"access_token": state.access_token,
|
||||
"refresh_token": state.refresh_token,
|
||||
"expires_at": state.token_expires_at,
|
||||
})
|
||||
except Exception as e:
|
||||
state.access_token = None
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail=f"トークン更新失敗: {e}。再度 GビズIDログイン が必要です。"
|
||||
)
|
||||
|
||||
if not state.access_token:
|
||||
raise HTTPException(status_code=401, detail="認証が必要です。GビズID でログインしてください。")
|
||||
|
||||
if not state.is_token_valid():
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="アクセストークンが期限切れです。再度 GビズIDログイン してください。"
|
||||
)
|
||||
|
||||
return state.access_token
|
||||
|
||||
|
||||
@router.post("/test-apply")
|
||||
async def test_apply(req: ApplyRequest):
|
||||
"""
|
||||
テスト申請書作成:スケルトン(ひな形)を取得してそのまま申請送信する。
|
||||
到達番号(arrive_id)を返す。
|
||||
"""
|
||||
import re
|
||||
if not re.fullmatch(r"[A-Za-z0-9]{16}", req.proc_id):
|
||||
raise HTTPException(status_code=400, detail="proc_id は半角英数字16文字です")
|
||||
|
||||
access_token = await _ensure_valid_token()
|
||||
|
||||
# 1. スケルトン取得
|
||||
try:
|
||||
skel_resp = await egov_api.get_procedure_skeleton(access_token, req.proc_id)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"スケルトン取得失敗: {e}")
|
||||
|
||||
results = skel_resp.get("results", {})
|
||||
file_data_b64 = results.get("file_data", "")
|
||||
if not file_data_b64:
|
||||
raise HTTPException(status_code=502, detail="スケルトンデータが空です")
|
||||
|
||||
# 2. 申請送信(スケルトンをそのまま送信)
|
||||
# 電子申請手続 → POST /apply(トライアルモード)
|
||||
# 電子送達手続 → POST /post-apply(トライアル非対応のため自動フォールバック)
|
||||
# 電子送達手続の場合は kousei.xml に必須フィールドを埋めてから送信する
|
||||
file_name = f"{req.proc_id}.zip"
|
||||
apply_resp = None
|
||||
api_type = "電子申請"
|
||||
try:
|
||||
apply_resp = await egov_api.submit_apply(
|
||||
access_token, req.proc_id, file_name, file_data_b64, trial=True
|
||||
)
|
||||
except RuntimeError as e:
|
||||
err_str = str(e)
|
||||
if "対象手続ではありません" in err_str or "post-apply" in err_str.lower():
|
||||
# 電子送達利用申込み手続 → kousei.xml 修正 → /post-apply
|
||||
api_type = "電子送達利用申込み"
|
||||
# 公式手続一覧 egov_applapi_testproclist.xlsx より正しい手続名称
|
||||
proc_name = "APIテスト用手続(電子送達関係手続)(通)0001/APIテスト用手続(電子送達関係手続)(通)0001"
|
||||
# スケルトン kousei.xml に必須フィールドを埋める
|
||||
try:
|
||||
modified_file_data = egov_api.modify_skeleton_for_post_apply(
|
||||
file_data_b64=file_data_b64,
|
||||
proc_id=req.proc_id,
|
||||
proc_name=proc_name,
|
||||
uketsuke_kikan_id="100900", # 電子送達関係手続の受付行政機関ID
|
||||
shinsei_shubetsu="新規申請",
|
||||
shimei="テスト タロウ",
|
||||
shimei_furigana="テスト タロウ",
|
||||
yubin="1300022",
|
||||
jusho="東京都墨田区江東橋4丁目31番10号",
|
||||
)
|
||||
except Exception as emx:
|
||||
raise HTTPException(status_code=502, detail=f"スケルトン修正失敗: {emx}")
|
||||
try:
|
||||
apply_resp = await egov_api.submit_post_apply(
|
||||
access_token, req.proc_id, file_name, modified_file_data
|
||||
)
|
||||
except Exception as e2:
|
||||
raise HTTPException(status_code=502, detail=f"電子送達利用申込み失敗: {e2}")
|
||||
else:
|
||||
raise HTTPException(status_code=502, detail=f"申請送信失敗: {err_str}")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"申請送信失敗: {e}")
|
||||
|
||||
res = apply_resp.get("results", {})
|
||||
return {
|
||||
"arrive_id": res.get("arrive_id", ""),
|
||||
"arrive_date": res.get("arrive_date", ""),
|
||||
"proc_name": res.get("proc_name", ""),
|
||||
"proc_id": req.proc_id,
|
||||
"api_type": api_type,
|
||||
"raw": res,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/apply-list")
|
||||
async def apply_list():
|
||||
"""申請案件一覧取得"""
|
||||
access_token = await _ensure_valid_token()
|
||||
|
||||
try:
|
||||
resp = await egov_api.get_apply_list(access_token)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"申請案件一覧取得失敗: {e}")
|
||||
|
||||
results = resp.get("results", {})
|
||||
apply_list_data = results.get("apply_list", [])
|
||||
|
||||
items = []
|
||||
for item in apply_list_data:
|
||||
items.append({
|
||||
"arrive_id": item.get("arrive_id", ""),
|
||||
"arrive_date": item.get("arrive_date", ""),
|
||||
"proc_id": item.get("proc_id", ""),
|
||||
"proc_name": item.get("proc_name", ""),
|
||||
"status": item.get("status", ""),
|
||||
"sub_status": item.get("sub_status", ""),
|
||||
})
|
||||
return {"apply_list": items, "total": len(items)}
|
||||
|
||||
@@ -2,9 +2,54 @@ from fastapi import APIRouter, HTTPException, Query
|
||||
from decimal import Decimal
|
||||
from typing import Dict, Any, List, Optional
|
||||
from app.modules.trial_balance.service import fetch_trial_balance
|
||||
from app.core.database import get_connection
|
||||
|
||||
router = APIRouter(prefix="/trial-balance", tags=["試算表"])
|
||||
|
||||
@router.get("/fiscal-years", summary="仕訳データから会計年度一覧取得(5月期)")
|
||||
def get_fiscal_years():
|
||||
REIWA_OFFSET = 2018 # 令和元年 = 2019年
|
||||
|
||||
with get_connection() as conn, conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT
|
||||
MIN(entry_date)::date AS min_date,
|
||||
MAX(entry_date)::date AS max_date
|
||||
FROM journal_entries
|
||||
WHERE is_deleted = false
|
||||
""")
|
||||
row = cur.fetchone()
|
||||
|
||||
if not row or not row["min_date"]:
|
||||
return []
|
||||
|
||||
min_date = row["min_date"]
|
||||
max_date = row["max_date"]
|
||||
|
||||
def fiscal_end_year(d):
|
||||
return d.year + 1 if d.month >= 6 else d.year
|
||||
|
||||
start_fy = fiscal_end_year(min_date)
|
||||
end_fy = fiscal_end_year(max_date)
|
||||
|
||||
result = []
|
||||
for fy in range(end_fy, start_fy - 1, -1):
|
||||
reiwa = fy - REIWA_OFFSET
|
||||
if reiwa > 0:
|
||||
wareki = f"令和{reiwa}年"
|
||||
elif reiwa == 0:
|
||||
wareki = "平成31/令和元年"
|
||||
else:
|
||||
wareki = f"平成{fy - 1988}年"
|
||||
label = f"{wareki}({fy}年)5月期"
|
||||
result.append({
|
||||
"label": label,
|
||||
"date_from": f"{fy - 1}-06-01",
|
||||
"date_to": f"{fy}-05-31",
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
@router.get("", summary="試算表(全科目)")
|
||||
def get_trial_balance(
|
||||
fiscal_year: Optional[int] = Query(None, description="会計年度(例: 2025)"),
|
||||
|
||||
@@ -97,3 +97,53 @@ def get_trial_balance(
|
||||
rows = cur.fetchall()
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
@router.get("/fiscal-years", summary="仕訳データから会計年度一覧取得(5月期)")
|
||||
def get_fiscal_years():
|
||||
"""
|
||||
仕訳の最小・最大日付から5月期の年度リストを生成して返す。
|
||||
例: 2025-06-01〜2026-05-31 → {"label": "令和8年(2026年)5月期", "date_from": "2025-06-01", "date_to": "2026-05-31"}
|
||||
"""
|
||||
REIWA_OFFSET = 2018 # 令和元年 = 2019年
|
||||
|
||||
with get_connection() as conn, conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT
|
||||
MIN(entry_date)::date AS min_date,
|
||||
MAX(entry_date)::date AS max_date
|
||||
FROM journal_entries
|
||||
WHERE is_deleted = false
|
||||
""")
|
||||
row = cur.fetchone()
|
||||
|
||||
if not row or not row["min_date"]:
|
||||
return []
|
||||
|
||||
min_date = row["min_date"]
|
||||
max_date = row["max_date"]
|
||||
|
||||
# 5月期の年度を計算: 決算年(end_year)は entry_date の月が6以上なら翌年、1〜5なら当年
|
||||
def fiscal_end_year(d):
|
||||
return d.year + 1 if d.month >= 6 else d.year
|
||||
|
||||
start_fy = fiscal_end_year(min_date)
|
||||
end_fy = fiscal_end_year(max_date)
|
||||
|
||||
result = []
|
||||
for fy in range(start_fy, end_fy + 1): # 小さい順(昇順)
|
||||
reiwa = fy - REIWA_OFFSET
|
||||
if reiwa > 0:
|
||||
wareki = f"令和{reiwa}年"
|
||||
elif reiwa == 0:
|
||||
wareki = "平成31/令和元年"
|
||||
else:
|
||||
wareki = f"平成{fy - 1988}年"
|
||||
label = f"{wareki}({fy}年)5月期"
|
||||
result.append({
|
||||
"label": label,
|
||||
"date_from": f"{fy - 1}-06-01",
|
||||
"date_to": f"{fy}-05-31",
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
Reference in New Issue
Block a user