Compare commits

..

10 Commits

Author SHA1 Message Date
admin
bcec6822e9 chore: 年度リストを昇順に変更 2026-06-07 22:01:37 +09:00
admin
4a500ff5e9 chore: 年度リストを昇順に変更 2026-06-07 21:58:20 +09:00
admin
91632ea5b6 修正 2026-06-01 00:35:53 +09:00
admin
8c66d820c8 修正 2026-06-01 00:35:29 +09:00
admin
ebc87f96f9 修正 2026-05-22 23:05:38 +09:00
admin
e8690d130b 修正 2026-05-22 23:05:25 +09:00
admin
cea3016fbe 修正 2026-05-21 21:52:05 +09:00
admin
4ea283941c 修正 2026-05-21 00:35:55 +09:00
admin
da3aef25ce 修正 2026-05-20 17:45:09 +09:00
admin
63ac6bc99a feat: 給与所得の源泉徴収票機能を追加
- 新規モジュール: backend/app/payroll/withholding_slip/
  - schemas.py: 源泉徴収票データモデル
  - service.py: 年間集計・給与所得控除・控除額計算ロジック
  - router.py: POST /payroll/withholding-slip/generate
- frontend/withholding-slip.html: 票面プレビュー・印刷(3部)ページ
- frontend/payroll.html: 源泉徴収票メニューカード追加
- backend/app/main.py: ルーター登録
2026-05-20 17:10:08 +09:00
96 changed files with 25440 additions and 391 deletions

10
.env
View File

@@ -3,3 +3,13 @@ DB_PORT=55432
DB_NAME=njts_acct DB_NAME=njts_acct
DB_USER=njts_app DB_USER=njts_app
DB_PASSWORD=njts_app2025 DB_PASSWORD=njts_app2025
# e-Gov 電子申請 API v2
# EGOV_ENV=sandbox → 検証環境 (https://account2.sbx.e-gov.go.jp)
# EGOV_ENV=production または未設定 → 本番環境
EGOV_ENV=sandbox
# 検証環境用APIキー
EGOV_SOFTWARE_ID=K26iIqTxFxvvXjLj
EGOV_API_KEY=3wRp2qmJQRYKdUrBxSGPB8U4O4dH0sEu
EGOV_REDIRECT_URI=http://192.168.0.61:18000/egov.html

16
_check_compose.py Normal file
View File

@@ -0,0 +1,16 @@
import paramiko
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect('192.168.0.61', username='root', password='59911784', timeout=15)
# NAS上の実際のdocker-compose.ymlを確認
_, o, _ = c.exec_command('cat /volume1/docker/njts-accounting/docker-compose.yml')
print("=== docker-compose.yml ===")
print(o.read().decode())
# コンテナの全マウントを確認
_, o2, _ = c.exec_command('docker inspect njts-accounting-backend --format "{{range .Mounts}}{{.Source}} -> {{.Destination}}{{println}}{{end}}"')
print("=== マウント一覧 ===")
print(o2.read().decode())
c.close()

7
_check_containers.py Normal file
View File

@@ -0,0 +1,7 @@
import paramiko
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect('192.168.0.61', username='root', password='59911784', timeout=15)
_, o, _ = c.exec_command('cd /volume1/docker/njts-accounting && docker compose ps')
print(o.read().decode())
c.close()

15
_check_mount.py Normal file
View File

@@ -0,0 +1,15 @@
import paramiko
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect('192.168.0.61', username='root', password='59911784', timeout=15)
# コンテナ内の /app/frontend の内容確認
_, o, _ = c.exec_command('docker exec njts-accounting-backend ls -la /app/frontend/ | head -20')
print("=== コンテナ内 /app/frontend ===")
print(o.read().decode())
# コンテナのマウント情報
_, o2, _ = c.exec_command('docker inspect njts-accounting-backend --format "{{json .Mounts}}"')
import json
mounts = json.loads(o2.read().decode())
for m in mounts:
print(f" {m.get('Source')} -> {m.get('Destination')}")
c.close()

10
_check_nas_index.py Normal file
View File

@@ -0,0 +1,10 @@
import paramiko
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect('192.168.0.61', username='root', password='59911784', timeout=15)
_, o, _ = c.exec_command('grep -n "bank-statement" /volume1/docker/njts-accounting/frontend/index.html')
result = o.read().decode()
print("NAS index.html grep結果:", repr(result))
_, o2, _ = c.exec_command('wc -l /volume1/docker/njts-accounting/frontend/index.html')
print("行数:", o2.read().decode().strip())
c.close()

7
_check_nas_index2.py Normal file
View File

@@ -0,0 +1,7 @@
import paramiko
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect('192.168.0.61', username='root', password='59911784', timeout=15)
_, o, _ = c.exec_command('sed -n "187,205p" /volume1/docker/njts-accounting/frontend/index.html')
print(o.read().decode())
c.close()

7
_pw_test.py Normal file
View File

@@ -0,0 +1,7 @@
import subprocess, sys
result = subprocess.run(
["python", "-m", "playwright", "--version"],
capture_output=True, text=True
)
print("playwright version:", result.stdout.strip())
print("stderr:", result.stderr.strip()[:200] if result.stderr else "none")

View File

@@ -138,7 +138,14 @@ def run_auto_migration():
print("=" * 80 + "\n") print("=" * 80 + "\n")
conn.close() conn.close()
# ── ねんきんポータル テーブル作成 ──
try:
from app.modules.nenkin_portal.migration import create_nenkin_tables
create_nenkin_tables()
except Exception as e:
print(f"⚠️ ねんきんポータル テーブル作成エラー(無視): {e}")
except Exception as e: except Exception as e:
print(f"\n❌ 無法連接到數據庫或執行遷移: {e}") print(f"\n❌ 無法連接到數據庫或執行遷移: {e}")
print("\n提示:") print("\n提示:")

View File

@@ -2,9 +2,11 @@ from dotenv import load_dotenv
load_dotenv() load_dotenv()
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse, RedirectResponse from fastapi.responses import JSONResponse, RedirectResponse, Response
from pydantic import ValidationError from pydantic import ValidationError
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from starlette.datastructures import MutableHeaders
from starlette.types import ASGIApp, Receive, Scope, Send
# FastAPI アプリケーション作成 # FastAPI アプリケーション作成
app = FastAPI( app = FastAPI(
@@ -12,13 +14,37 @@ app = FastAPI(
) )
class SecurityHeadersMiddleware:
"""キャッシュ禁止・セキュリティヘッダーを全レスポンスに付与ASGIネイティブ実装"""
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
async def send_with_headers(message):
if message["type"] == "http.response.start":
headers = MutableHeaders(scope=message)
headers["Cache-Control"] = "no-store, no-cache, must-revalidate, private"
headers["Pragma"] = "no-cache"
headers["Expires"] = "0"
headers["X-Frame-Options"] = "DENY"
headers["X-Content-Type-Options"] = "nosniff"
headers["X-XSS-Protection"] = "1; mode=block"
await send(message)
await self.app(scope, receive, send_with_headers)
app.add_middleware(SecurityHeadersMiddleware)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=["*"],
allow_credentials=False, allow_credentials=False,
allow_methods=["*"], allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["*"], allow_headers=["Content-Type", "Authorization"],
) )
# ───────────────────────── # ─────────────────────────
@@ -97,6 +123,12 @@ app.include_router(cash.router)
from app.routers import file_upload from app.routers import file_upload
app.include_router(file_upload.router) app.include_router(file_upload.router)
from app.modules.egov.router import router as egov_router
app.include_router(egov_router)
from app.modules.bank_statements.router import router as bank_statements_router
app.include_router(bank_statements_router)
# ───────────────────────── # ─────────────────────────
# 給与モジュール (Payroll Module) # 給与モジュール (Payroll Module)
# ───────────────────────── # ─────────────────────────
@@ -118,6 +150,11 @@ app.include_router(payroll_bonus_router)
from app.payroll.vouchers.router import router as payroll_vouchers_router from app.payroll.vouchers.router import router as payroll_vouchers_router
app.include_router(payroll_vouchers_router) app.include_router(payroll_vouchers_router)
from app.payroll.withholding_slip.router import router as withholding_slip_router
app.include_router(withholding_slip_router)
from app.modules.nenkin_portal.router import router as nenkin_portal_router
app.include_router(nenkin_portal_router)
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
import os import os

View File

@@ -0,0 +1,41 @@
"""銀行明細テーブルのマイグレーション"""
import os
from psycopg import connect
def create_bank_statement_table():
conn = connect(
host=os.getenv("DB_HOST"),
port=int(os.getenv("DB_PORT", 5432)),
dbname=os.getenv("DB_NAME"),
user=os.getenv("DB_USER"),
password=os.getenv("DB_PASSWORD"),
)
with conn.cursor() as cur:
cur.execute("""
CREATE TABLE IF NOT EXISTS bank_statement_rows (
id SERIAL PRIMARY KEY,
tx_date DATE NOT NULL,
description TEXT NOT NULL DEFAULT '',
debit BIGINT NOT NULL DEFAULT 0,
credit BIGINT NOT NULL DEFAULT 0,
balance BIGINT,
memo TEXT NOT NULL DEFAULT '',
bank_name TEXT NOT NULL DEFAULT '',
import_batch_id TEXT NOT NULL,
row_hash TEXT NOT NULL,
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_bank_stmt_hash UNIQUE (row_hash)
)
""")
cur.execute("""
CREATE INDEX IF NOT EXISTS idx_bank_stmt_date
ON bank_statement_rows (tx_date)
""")
cur.execute("""
CREATE INDEX IF NOT EXISTS idx_bank_stmt_batch
ON bank_statement_rows (import_batch_id)
""")
conn.commit()
print("✓ bank_statement_rows テーブル確認完了")
conn.close()

View File

@@ -0,0 +1,437 @@
"""
銀行明細 CSV 取込・照会モジュール
"""
import csv
import hashlib
import io
import uuid
from datetime import datetime, date
from typing import Optional, List
from fastapi import APIRouter, HTTPException, UploadFile, File, Form
from pydantic import BaseModel
from app.core.database import get_connection
router = APIRouter(prefix="/bank-statements", tags=["銀行明細"])
# ── CSV 列名検出パターン ──────────────────────────────────────
_DATE_KEYS = ['日付', '取引日', '年月日', '取引年月日', 'date', '取引日付']
_DESC_KEYS = ['摘要', '内容', '取引内容', '支払先', '摘要/メモ', '入出金先内容',
'description', '取引先', 'お取引内容', '取引詳細']
_DEBIT_KEYS = ['お支払い金額', 'お支払金額', '支払額', '引出し', '出金', '引出額',
'借方', 'debit', '出金金額', '支払い金額', '引き出し金額']
_CREDIT_KEYS = ['お預かり金額', '預入れ', '預入', '入金', '預入金額',
'貸方', 'credit', '入金金額', 'お預り金額', '預け入れ金額']
_BALANCE_KEYS = ['残高', '差引残高', 'balance', '帳簿残高', '取引後残高']
_MEMO_KEYS = ['メモ', '備考', '通帳メモ', 'memo', '注記', '備考欄']
def _find_col(headers: List[str], patterns: List[str]) -> Optional[int]:
lowers = [h.strip().lower() for h in headers]
for pat in patterns:
p = pat.lower()
for i, h in enumerate(lowers):
if p in h:
return i
return None
def _parse_amount(s: str) -> int:
if not s:
return 0
cleaned = (s.strip()
.replace(',', '').replace('', '')
.replace('¥', '').replace('', '')
.replace(' ', '').replace('\u3000', ''))
if cleaned in ('', '-', '', '', '', ''):
return 0
try:
return int(float(cleaned))
except ValueError:
return 0
def _parse_date(s: str) -> Optional[date]:
s = (s.strip()
.replace('/', '-').replace('.', '-')
.replace('', '-').replace('', '-').replace('', ''))
for fmt in ['%Y-%m-%d', '%y-%m-%d']:
try:
return datetime.strptime(s, fmt).date()
except ValueError:
continue
s2 = s.replace('-', '')
if len(s2) == 8:
try:
return datetime.strptime(s2, '%Y%m%d').date()
except ValueError:
pass
if len(s2) == 6:
try:
return datetime.strptime('20' + s2, '%Y%m%d').date()
except ValueError:
pass
return None
def _detect_encoding(raw: bytes) -> str:
for enc in ['utf-8-sig', 'utf-8', 'shift_jis', 'cp932', 'euc-jp']:
try:
raw.decode(enc)
return enc
except (UnicodeDecodeError, LookupError):
continue
return 'utf-8'
def _make_hash(tx_date: str, description: str, debit: int,
credit: int, balance: Optional[int]) -> str:
s = f"{tx_date}|{description}|{debit}|{credit}|{balance}"
return hashlib.md5(s.encode()).hexdigest()
# ── 三菱UFJ MEISAI フォーマット対応 ──────────────────────────
# 行タイプ: "1"=口座ヘッダー, "2"=明細, "8"=フッター, "9"=合計
# 列: [タイプ, 日付, 摘要区分, 摘要詳細, 出金額, 入金額, 残高]
def _is_meisai_format(rows: List[List[str]]) -> bool:
"""三菱UFJ銀行 MEISAI フォーマット判定"""
type1 = any(r and r[0] == '1' for r in rows)
type2 = any(r and r[0] == '2' for r in rows)
return type1 and type2
def _parse_meisai_rows(rows: List[List[str]]) -> tuple:
"""MEISAI フォーマットをパース。(明細リスト, 銀行名ヒント) を返す"""
bank_hint = ''
parsed = []
for row in rows:
if not row:
continue
row_type = row[0]
if row_type == '1' and len(row) >= 3:
# ヘッダー行: [1, 支店コード, 支店名, ..., 科目, 口座番号, 口座名, ...]
branch = row[2].strip() if len(row) > 2 else ''
bank_hint = f"三菱UFJ銀行 {branch}" if branch else '三菱UFJ銀行'
elif row_type == '2' and len(row) >= 5:
# 明細行: [2, 日付, 摘要区分, 摘要詳細, 出金額, 入金額, 残高]
tx_date = _parse_date(row[1])
if tx_date is None:
continue
summary1 = row[2].strip() if len(row) > 2 else ''
summary2 = row[3].strip() if len(row) > 3 else ''
desc = f"{summary1} {summary2}".strip(' ') if summary2 else summary1
debit = _parse_amount(row[4]) if len(row) > 4 else 0
credit = _parse_amount(row[5]) if len(row) > 5 else 0
bal_str = row[6].strip() if len(row) > 6 else ''
balance = _parse_amount(bal_str) if bal_str else None
parsed.append({
'tx_date': tx_date.isoformat(),
'description': desc,
'debit': debit,
'credit': credit,
'balance': balance,
'memo': '',
'row_hash': _make_hash(tx_date.isoformat(), desc, debit, credit, balance),
})
return parsed, bank_hint
def _parse_csv_bytes(content: bytes) -> tuple:
"""CSVをパース。(明細リスト, 銀行名ヒント) を返す"""
enc = _detect_encoding(content)
text = content.decode(enc).replace('\x00', '').strip()
reader = csv.reader(io.StringIO(text))
rows = list(reader)
if not rows:
raise HTTPException(status_code=400, detail="CSVファイルが空です")
# MEISAI三菱UFJフォーマット優先検出
if _is_meisai_format(rows):
return _parse_meisai_rows(rows)
# ── 汎用ヘッダー行検出 ──
header_row = 0
for i, row in enumerate(rows[:15]):
row_str = ' '.join(row).lower()
if any(k.lower() in row_str for k in ['日付', '取引日', 'date', '年月日']):
header_row = i
break
headers = rows[header_row]
date_col = _find_col(headers, _DATE_KEYS)
desc_col = _find_col(headers, _DESC_KEYS)
debit_col = _find_col(headers, _DEBIT_KEYS)
credit_col = _find_col(headers, _CREDIT_KEYS)
balance_col = _find_col(headers, _BALANCE_KEYS)
memo_col = _find_col(headers, _MEMO_KEYS)
if date_col is None:
raise HTTPException(
status_code=400,
detail="日付列が見つかりません。CSVのヘッダー行に「日付」「取引日」等の列名が必要です。"
)
parsed = []
for row in rows[header_row + 1:]:
if not row or all(c.strip() == '' for c in row):
continue
if len(row) <= date_col:
continue
raw_date = row[date_col].strip()
if not raw_date:
continue
tx_date = _parse_date(raw_date)
if tx_date is None:
continue
def _safe(col: Optional[int]) -> str:
return row[col].strip() if col is not None and len(row) > col else ''
desc = _safe(desc_col)
debit = _parse_amount(_safe(debit_col))
credit = _parse_amount(_safe(credit_col))
bal_str = _safe(balance_col)
balance = _parse_amount(bal_str) if bal_str else None
memo = _safe(memo_col)
parsed.append({
'tx_date': tx_date.isoformat(),
'description': desc,
'debit': debit,
'credit': credit,
'balance': balance,
'memo': memo,
'row_hash': _make_hash(tx_date.isoformat(), desc, debit, credit, balance),
})
return parsed, ''
# ── エンドポイント ─────────────────────────────────────────────
@router.post("/preview", summary="CSVプレビュー重複チェック")
async def preview_csv(
file: UploadFile = File(...),
bank_name: str = Form(""),
):
"""CSVを解析し、重複チェック結果付きでプレビューを返すDBには保存しない"""
content = await file.read()
if len(content) > 10_000_000:
raise HTTPException(status_code=400, detail="ファイルサイズが大きすぎます上限10MB")
rows, bank_hint = _parse_csv_bytes(content)
if not rows:
raise HTTPException(status_code=400, detail="有効なデータ行がありません")
# 銀行名未入力の場合はCSVから自動検出した値を使用
if not bank_name.strip() and bank_hint:
bank_name = bank_hint
# DB上の既存ハッシュを取得して重複チェック
with get_connection() as conn, conn.cursor() as cur:
cur.execute("SELECT row_hash FROM bank_statement_rows")
existing_hashes = {r['row_hash'] for r in cur.fetchall()}
result = []
for row in rows:
row['status'] = 'duplicate' if row['row_hash'] in existing_hashes else 'new'
result.append(row)
new_count = sum(1 for r in result if r['status'] == 'new')
dup_count = sum(1 for r in result if r['status'] == 'duplicate')
return {
'total': len(result),
'new': new_count,
'duplicates': dup_count,
'bank_name': bank_name,
'rows': result,
}
class ImportRow(BaseModel):
tx_date: str
description: str
debit: int
credit: int
balance: Optional[int] = None
memo: str = ''
row_hash: str = ''
class ImportRequest(BaseModel):
bank_name: str = ''
rows: List[ImportRow]
@router.post("/import", summary="CSV取込確定")
def import_rows(req: ImportRequest):
"""プレビュー確認後、新規行のみDBに取込む"""
if not req.rows:
raise HTTPException(status_code=400, detail="取込データがありません")
batch_id = str(uuid.uuid4())
inserted = 0
skipped = 0
with get_connection() as conn, conn.cursor() as cur:
for row in req.rows:
# row_hash が空の場合は再計算
rh = row.row_hash or _make_hash(
row.tx_date, row.description, row.debit, row.credit, row.balance
)
cur.execute("""
INSERT INTO bank_statement_rows
(tx_date, description, debit, credit, balance,
memo, bank_name, import_batch_id, row_hash)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (row_hash) DO NOTHING
""", (
row.tx_date, row.description,
row.debit, row.credit, row.balance,
row.memo, req.bank_name, batch_id, rh,
))
if cur.rowcount > 0:
inserted += 1
else:
skipped += 1
conn.commit()
return {
'batch_id': batch_id,
'inserted': inserted,
'skipped': skipped,
}
@router.get("", summary="明細一覧取得")
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 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)
where_clause = ("WHERE " + " AND ".join(conditions)) if conditions else ""
with get_connection() as conn, conn.cursor() as cur:
# 明細行
cur.execute(f"""
SELECT id,
TO_CHAR(tx_date, 'YYYY-MM-DD') AS tx_date,
description,
debit::bigint,
credit::bigint,
balance::bigint,
memo,
bank_name,
import_batch_id
FROM bank_statement_rows
{where_clause}
ORDER BY tx_date, id
LIMIT %s OFFSET %s
""", params + [limit, offset])
rows = cur.fetchall()
# 月別集計
cur.execute(f"""
SELECT
TO_CHAR(tx_date, 'YYYY-MM') AS ym,
SUM(debit)::bigint AS total_debit,
SUM(credit)::bigint AS total_credit,
COUNT(*)::int AS count
FROM bank_statement_rows
{where_clause}
GROUP BY ym
ORDER BY ym
""", params)
monthly = cur.fetchall()
# 総件数
cur.execute(
f"SELECT COUNT(*)::int AS cnt FROM bank_statement_rows {where_clause}",
params
)
total = cur.fetchone()['cnt']
# 銀行名リスト
cur.execute("""
SELECT DISTINCT bank_name
FROM bank_statement_rows
WHERE bank_name != ''
ORDER BY bank_name
""")
banks = [r['bank_name'] for r in cur.fetchall()]
return {
'total': total,
'rows': rows,
'monthly': monthly,
'banks': banks,
}
@router.get("/batches", summary="取込バッチ一覧")
def list_batches():
with get_connection() as conn, conn.cursor() as cur:
cur.execute("""
SELECT
import_batch_id,
bank_name,
MIN(tx_date)::text AS date_from,
MAX(tx_date)::text AS date_to,
COUNT(*)::int AS count,
MIN(imported_at)::text AS imported_at
FROM bank_statement_rows
GROUP BY import_batch_id, bank_name
ORDER BY MIN(imported_at) DESC
LIMIT 100
""")
return cur.fetchall()
@router.delete("/batch/{batch_id}", summary="バッチ削除")
def delete_batch(batch_id: str):
with get_connection() as conn, conn.cursor() as cur:
cur.execute(
"DELETE FROM bank_statement_rows WHERE import_batch_id = %s",
(batch_id,)
)
deleted = cur.rowcount
conn.commit()
return {'deleted': deleted}

View File

View File

@@ -0,0 +1,478 @@
"""
e-Gov 電子申請 API v2 クライアント
OAuth2 + PKCE フローによる認証と電子送達ファイルのダウンロード
"""
import hashlib
import base64
import secrets
import os
import json
import time
import re
import io
import zipfile
from pathlib import Path
from typing import Optional
import httpx
# ── エンドポイント定数 ─────────────────────────────────────────
# EGOV_ENV=sandbox で検証環境、省略または production で本番環境を使用
_ENV = os.environ.get("EGOV_ENV", "production").lower()
if _ENV == "sandbox":
AUTH_BASE = "https://account2.sbx.e-gov.go.jp/auth"
API_BASE = "https://api2.sbx.e-gov.go.jp/shinsei/v2"
else:
AUTH_BASE = "https://account.e-gov.go.jp/auth"
API_BASE = "https://api.e-gov.go.jp/shinsei/v2"
AUTH_URL = f"{AUTH_BASE}/auth"
TOKEN_URL = f"{AUTH_BASE}/token"
LOGOUT_URL = f"{AUTH_BASE}/logout"
EGOV_DATA_DIR = Path("/app/egov_data")
TOKENS_PATH = EGOV_DATA_DIR / "tokens.json"
CONFIG_PATH = EGOV_DATA_DIR / "api_config.json"
# ── 資格情報 ──────────────────────────────────────────────────
def load_api_config() -> dict:
"""API設定を config ファイルから読み込む(環境変数でも上書き可)"""
cfg = {}
if CONFIG_PATH.exists():
try:
with open(CONFIG_PATH) as f:
cfg = json.load(f)
except Exception:
pass
# 環境変数が優先
client_id = os.environ.get("EGOV_SOFTWARE_ID") or cfg.get("software_id", "")
client_secret = os.environ.get("EGOV_API_KEY") or cfg.get("api_key", "")
redirect_uri = os.environ.get("EGOV_REDIRECT_URI") or cfg.get("redirect_uri",
"http://192.168.0.61:18000/egov.html")
return {
"client_id": client_id,
"client_secret": client_secret,
"redirect_uri": redirect_uri,
}
def save_api_config(software_id: str, api_key: str,
redirect_uri: str = "http://192.168.0.61:18000/egov.html"):
"""API設定をファイルに保存"""
EGOV_DATA_DIR.mkdir(parents=True, exist_ok=True)
with open(CONFIG_PATH, "w") as f:
json.dump({"software_id": software_id,
"api_key": api_key,
"redirect_uri": redirect_uri}, f)
# ── トークン永続化 ────────────────────────────────────────────
def load_tokens() -> dict:
if TOKENS_PATH.exists():
try:
with open(TOKENS_PATH) as f:
return json.load(f)
except Exception:
pass
return {}
def save_tokens(tokens: dict):
EGOV_DATA_DIR.mkdir(parents=True, exist_ok=True)
with open(TOKENS_PATH, "w") as f:
json.dump(tokens, f)
def delete_tokens():
if TOKENS_PATH.exists():
TOKENS_PATH.unlink()
# ── PKCE ─────────────────────────────────────────────────────
def generate_pkce() -> tuple[str, str]:
"""code_verifier と code_challenge (S256) を生成"""
verifier = secrets.token_urlsafe(48) # 64文字 (43-128の範囲内)
digest = hashlib.sha256(verifier.encode()).digest()
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
return verifier, challenge
# ── OAuth2 フロー ─────────────────────────────────────────────
def build_auth_url(client_id: str, redirect_uri: str,
challenge: str, state: str) -> str:
"""OAuth2 認可URL を生成"""
from urllib.parse import urlencode, quote
params = {
"client_id": client_id,
"response_type": "code",
"scope": "openid offline_access",
"redirect_uri": redirect_uri,
"state": state,
"code_challenge": challenge,
"code_challenge_method": "S256",
}
# quote_via=quote を使うことでスペースを + ではなく %20 にエンコードする
return f"{AUTH_URL}?" + urlencode(params, quote_via=quote)
async def exchange_code(code: str, client_id: str, client_secret: str,
redirect_uri: str, verifier: str) -> dict:
"""認可コード → アクセストークン・リフレッシュトークン取得"""
cred = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
async with httpx.AsyncClient() as client:
resp = await client.post(
TOKEN_URL,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Authorization": f"Basic {cred}",
},
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"code_verifier": verifier,
},
timeout=30,
)
resp.raise_for_status()
return resp.json()
async def refresh_tokens_request(refresh_token: str, client_id: str,
client_secret: str) -> dict:
"""リフレッシュトークンでアクセストークン再取得"""
cred = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
async with httpx.AsyncClient() as client:
resp = await client.post(
TOKEN_URL,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Authorization": f"Basic {cred}",
},
data={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
},
timeout=30,
)
resp.raise_for_status()
return resp.json()
async def do_logout(refresh_token: str, client_id: str, client_secret: str):
"""ログアウト(トークン無効化)"""
cred = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
async with httpx.AsyncClient() as client:
try:
await client.post(
LOGOUT_URL,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Authorization": f"Basic {cred}",
},
data={"refresh_token": refresh_token},
timeout=15,
)
except Exception:
pass
# ── 電子送達 API ──────────────────────────────────────────────
async def get_post_list(access_token: str, date_from: str, date_to: str,
limit: int = 50) -> dict:
"""
電子送達一覧取得
GET /post/lists?date_from=YYYY-MM-DD&date_to=YYYY-MM-DD&limit=N&offset=1
"""
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{API_BASE}/post/lists",
headers={"Authorization": f"Bearer {access_token}"},
params={"date_from": date_from, "date_to": date_to,
"limit": limit, "offset": 1},
timeout=30,
)
resp.raise_for_status()
return resp.json()
async def get_post_file(access_token: str, post_id: str) -> dict:
"""
電子送達取得(通知文書ファイルを base64 で返す)
GET /post/{post_id}
Returns: {notice_data_name, file_data (base64), file_name_list}
"""
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{API_BASE}/post/{post_id}",
headers={"Authorization": f"Bearer {access_token}"},
timeout=60,
)
resp.raise_for_status()
return resp.json()
async def mark_post_done(access_token: str, post_id: str) -> dict:
"""
電子送達取得完了登録
POST /post {"post_id": "..."}
"""
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{API_BASE}/post",
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
},
json={"post_id": post_id},
timeout=30,
)
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()

View File

@@ -0,0 +1,539 @@
import base64
import secrets
import time
from datetime import datetime, timedelta
from pathlib import Path
from fastapi import APIRouter, HTTPException, BackgroundTasks
from fastapi.responses import FileResponse
from pydantic import BaseModel
from app.modules.egov.state import state, EGovStatus
from app.modules.egov.scraper import run_login, run_download
import app.modules.egov.egov_api as egov_api
router = APIRouter(prefix="/egov", tags=["e-Gov"])
class LoginRequest(BaseModel):
user_id: str
password: str
class MfaRequest(BaseModel):
mfa_code: str
class CallbackRequest(BaseModel):
code: str
state: str
class ApplyRequest(BaseModel):
proc_id: str # 16文字の手続識別子
class ConfigRequest(BaseModel):
software_id: str
api_key: str
redirect_uri: str = "http://192.168.0.61:18000/egov.html"
@router.get("/status")
def get_status():
import os
return {
"status": state.status,
"message": state.message,
"screenshot": state.screenshot_b64,
"last_download": state.last_download,
"token_valid": state.is_token_valid(),
"environment": os.environ.get("EGOV_ENV", "production"),
}
@router.post("/login-start")
async def login_start(req: LoginRequest, background_tasks: BackgroundTasks):
if state.status in (EGovStatus.STARTING, EGovStatus.WAITING_MFA):
raise HTTPException(status_code=409, detail="ログイン処理が既に実行中です")
await state.close_browser()
state.reset()
background_tasks.add_task(run_login, req.user_id, req.password)
return {"message": "ログイン処理を開始しました"}
@router.post("/login-mfa")
async def login_mfa(req: MfaRequest):
if state.status != EGovStatus.WAITING_MFA:
raise HTTPException(status_code=409, detail="MFA 入力待ち状態ではありません")
if not req.mfa_code.isdigit() or len(req.mfa_code) != 6:
raise HTTPException(status_code=400, detail="認証コードは 6 桁の数字です")
state.mfa_code = req.mfa_code
state.mfa_event.set()
return {"message": "認証コードを送信しました"}
@router.post("/logout")
async def logout():
# API トークンがあればサーバー側でも無効化
if state.refresh_token:
try:
cfg = egov_api.load_api_config()
await egov_api.do_logout(
state.refresh_token,
cfg["client_id"],
cfg["client_secret"],
)
except Exception:
pass
egov_api.delete_tokens()
await state.close_browser()
state.reset()
cookies = Path("/app/egov_data/cookies.json")
if cookies.exists():
cookies.unlink()
return {"message": "ログアウトしました"}
@router.post("/download-start")
async def download_start(background_tasks: BackgroundTasks):
if state.status == EGovStatus.DOWNLOADING:
raise HTTPException(status_code=409, detail="ダウンロードが既に実行中です")
# API トークンが有効なら API 方式で実行
if state.is_token_valid() or state.refresh_token:
background_tasks.add_task(_api_download)
return {"message": "ダウンロードを開始しました (API 方式)"}
# 保存済みトークンを確認
saved = egov_api.load_tokens()
if saved.get("access_token"):
state.set_tokens(saved)
if state.is_token_valid() or saved.get("refresh_token"):
state.refresh_token = saved.get("refresh_token")
background_tasks.add_task(_api_download)
return {"message": "ダウンロードを開始しました (API 方式・保存トークン)"}
# Playwright セッションがあるなら旧方式
if state.status == EGovStatus.LOGGED_IN and state._page is not None:
background_tasks.add_task(run_download)
return {"message": "ダウンロードを開始しました (Playwright 方式)"}
raise HTTPException(
status_code=401,
detail="認証が必要です。/egov/auth-url でGビズID認証を行ってください。"
)
async def _api_download():
"""e-Gov API v2 を使って電子送達ファイルをダウンロード"""
state.status = EGovStatus.DOWNLOADING
state.message = "e-Gov API で電子送達を確認中..."
try:
cfg = egov_api.load_api_config()
if not cfg["client_id"] or not cfg["client_secret"]:
raise RuntimeError("e-Gov API の資格情報が設定されていません。/egov/config で設定してください。")
# トークン更新(期限切れの場合)
if not state.is_token_valid() and state.refresh_token:
state.message = "アクセストークンを更新中..."
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,
})
# 電子送達一覧を取得(直近 90 日間)
date_to = datetime.now().strftime("%Y-%m-%d")
date_from = (datetime.now() - timedelta(days=90)).strftime("%Y-%m-%d")
state.message = f"電子送達一覧を取得中 ({date_from} {date_to})..."
list_resp = await egov_api.get_post_list(state.access_token, date_from, date_to)
# OpenAPI仕様: レスポンスは results.post_list に配列
results = list_resp.get("results", {})
post_list = results.get("post_list", [])
# post_list は電子送達ヘッダー一覧。各要素に notice_data_list があり
# そこに個々の post_id が含まれる
# フラットなリストに変換
posts = []
for item in post_list:
for nd in item.get("notice_data_list", []):
posts.append({
"post_id": nd.get("post_id", ""),
"notice_data_name": nd.get("notice_data_name", ""),
"notice_issue_date": nd.get("notice_issue_date", ""),
"download_expired_date": nd.get("download_expired_date", ""),
"download_date": nd.get("download_date"),
"title": item.get("title", ""),
"issuer_organization": item.get("issuer_organization", ""),
})
if not posts:
state.status = EGovStatus.LOGGED_IN
state.message = f"電子送達はありません ({date_from} {date_to})"
return
# 未取得のものdownload_date が空)を優先
pending = [p for p in posts if not p.get("download_date")]
targets = pending if pending else posts
egov_data = Path("/app/egov_data")
egov_data.mkdir(parents=True, exist_ok=True)
downloaded = []
for post in targets:
post_id = post.get("post_id", "")
if not post_id:
continue
state.message = f"通知ファイルを取得中 (post_id={post_id})..."
file_resp = await egov_api.get_post_file(state.access_token, post_id)
# OpenAPI仕様: レスポンスは results.file_data / results.notice_data_name
file_results = file_resp.get("results", {})
file_data_b64 = file_results.get("file_data", "")
file_name_list = file_results.get("file_name_list", [])
first_name = file_name_list[0].get("file_name") if file_name_list else None
filename = (file_results.get("notice_data_name")
or first_name
or f"egov_{post_id}.zip")
if not file_data_b64:
continue
import base64 as b64mod
file_bytes = b64mod.b64decode(file_data_b64)
save_path = egov_data / filename
save_path.write_bytes(file_bytes)
# 取得完了登録
try:
await egov_api.mark_post_done(state.access_token, post_id)
except Exception:
pass
downloaded.append({"filename": filename, "path": str(save_path),
"size": len(file_bytes), "post_id": post_id})
break # 1件ずつ
if downloaded:
state.last_download = downloaded[0]
state.status = EGovStatus.LOGGED_IN
state.message = (f"ダウンロード完了: {downloaded[0]['filename']} "
f"({downloaded[0]['size']:,} bytes) "
f"/ 全 {len(posts)}")
else:
state.status = EGovStatus.LOGGED_IN
state.message = "ダウンロード可能なファイルがありませんでした"
except Exception as e:
state.status = EGovStatus.ERROR
state.message = f"API ダウンロードエラー: {e}"
# ── OAuth2 / e-Gov API エンドポイント ──────────────────────────
@router.post("/config")
async def save_config(req: ConfigRequest):
"""e-Gov API 資格情報を保存"""
egov_api.save_api_config(req.software_id, req.api_key, req.redirect_uri)
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 を生成して返す"""
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 の資格情報が未設定です。先に /egov/config を呼び出してください。"
)
verifier, challenge = egov_api.generate_pkce()
oauth_state = secrets.token_urlsafe(16)
state._pkce_verifier = verifier
state._oauth_state = oauth_state
auth_url = egov_api.build_auth_url(
cfg["client_id"],
cfg["redirect_uri"],
challenge,
oauth_state,
)
return {"auth_url": auth_url, "state": oauth_state}
@router.post("/callback")
async def oauth_callback(req: CallbackRequest):
"""
e-Gov 認可コードを受け取りアクセストークンへ交換する。
egov.html の JavaScript から呼び出される。
"""
if not state._pkce_verifier:
raise HTTPException(status_code=400, detail="PKCE セッションが見つかりません。再度 /egov/auth-url から開始してください。")
if req.state != state._oauth_state:
raise HTTPException(status_code=400, detail="state パラメータが一致しませんCSRF防止")
cfg = egov_api.load_api_config()
try:
token_resp = await egov_api.exchange_code(
req.code,
cfg["client_id"],
cfg["client_secret"],
cfg["redirect_uri"],
state._pkce_verifier,
)
except Exception as e:
raise HTTPException(status_code=502, detail=f"トークン取得失敗: {e}")
state.set_tokens(token_resp)
state._pkce_verifier = None
state._oauth_state = None
state.status = EGovStatus.LOGGED_IN
state.message = "e-Gov API 認証完了"
# ファイルに永続化
egov_api.save_tokens({
"access_token": state.access_token,
"refresh_token": state.refresh_token,
"expires_at": state.token_expires_at,
})
return {"message": "認証完了しました", "status": "logged_in"}
@router.get("/downloads/latest")
async def download_latest():
"""最後にダウンロードしたファイルを返す"""
if not state.last_download:
raise HTTPException(status_code=404, detail="ダウンロード済みファイルがありません")
from pathlib import Path
path = Path(state.last_download["path"])
if not path.exists():
raise HTTPException(status_code=404, detail="ファイルが見つかりません")
return FileResponse(
path=str(path),
filename=state.last_download["filename"],
media_type="application/octet-stream",
)
@router.get("/debug-page")
async def debug_page():
"""現在のブラウザページの詳細診断情報(デバッグ用)"""
if state._page is None:
return {"error": "ブラウザセッションなし", "status": state.status}
try:
page = state._page
diag = await page.evaluate("""
() => {
const b = document.body ? document.body.innerText : '(empty)';
return {
url: location.href,
title: document.title,
hasLogout: b.includes('ログアウト'),
hasMyPage: b.includes('マイページ'),
hasDelivery: b.includes('電子送達'),
bodyLen: b.length,
bodyPreview: b.slice(0, 600).replace(/\\n+/g, ' | '),
links: Array.from(document.querySelectorAll('a')).map(a => ({
text: a.textContent.trim().slice(0, 50),
href: a.href,
imgAlts: Array.from(a.querySelectorAll('img')).map(img => img.alt).join(','),
})).slice(0, 30),
buttons: Array.from(document.querySelectorAll('button, input[type="submit"], input[type="button"], [role="button"]')).map(el => ({
tag: el.tagName,
text: (el.textContent || el.value || '').trim().slice(0, 50),
id: el.id,
cls: el.className.slice(0, 60),
})).slice(0, 20),
imgs: Array.from(document.querySelectorAll('img[alt]')).map(img => ({
alt: img.alt,
src: img.src.slice(0, 80),
})).slice(0, 20),
inputs: Array.from(document.querySelectorAll('input, select')).map(el => ({
type: el.type,
name: el.name,
id: el.id,
})).slice(0, 20),
};
}
""")
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)}

View File

@@ -0,0 +1,517 @@
"""
e-Gov ログイン Playwright スクレイパー
GビズID プレミアム (TOTP) によるログインを自動化します。
"""
import asyncio
import json
import base64
from pathlib import Path
from app.modules.egov.state import state, EGovStatus
# ── 定数 ─────────────────────────────────────────────────────
EGOV_TOP_URL = "https://shinsei.e-gov.go.jp/"
EGOV_DATA_DIR = Path("/app/egov_data")
COOKIES_PATH = EGOV_DATA_DIR / "cookies.json"
# e-Gov ログインリンク
SEL_LOGIN_LINK = 'a:has-text("ログイン"), button:has-text("ログイン")'
SEL_GBIZID_BTN = 'a:has-text("GビズID"), button:has-text("GビズID"), [alt*="GビズID"]'
# GビズID 認証フォームKeycloak 標準 DOM
SEL_USERNAME = '#username, input[name="username"]'
SEL_PASSWORD = '#password, input[name="password"]'
SEL_SUBMIT = 'input[type="submit"], button[type="submit"]'
# TOTP フォームKeycloak 標準 DOM
SEL_OTP = '#otp, input[name="otp"], #totp, input[name="totp"]'
# ログイン済み判定キーワード(「ログアウト」ボタンは認証済みページにのみ存在)
LOGGEDIN_MARKERS = ["ログアウト", "logout"]
# MFA 待機タイムアウト(秒)
MFA_TIMEOUT = 300
# ── メイン処理 ───────────────────────────────────────────────
async def run_login(user_id: str, password: str):
"""e-Gov ログイン処理FastAPI BackgroundTask として実行)"""
try:
from playwright.async_api import async_playwright # 遅延import
# 既存ブラウザセッションを閉じてからリスタート
await state.close_browser()
state.status = EGovStatus.STARTING
state.message = "ブラウザを起動中..."
EGOV_DATA_DIR.mkdir(parents=True, exist_ok=True)
# context manager ではなく .start() で起動 → 手動で lifetime を管理
pw = await async_playwright().start()
browser = await pw.chromium.launch(
headless=True,
args=["--no-sandbox", "--disable-dev-shm-usage"],
)
context = await _load_context(browser)
page = await context.new_page()
page.set_default_timeout(30_000)
# ── e-Gov トップにアクセス ──────────────────────
state.message = "e-Gov にアクセス中..."
await page.goto(EGOV_TOP_URL, wait_until="domcontentloaded")
await _screenshot(page)
# ── セッション復元チェック ──────────────────────
if await _is_logged_in(page):
await _save_cookies(context)
# ブラウザを閉じずに保持download で再利用)
state._playwright = pw
state._browser = browser
state._context = context
state._page = page
state.status = EGovStatus.LOGGED_IN
state.message = "ログイン済み(セッション復元)"
return
# ── ログイン → GビズID へ誘導 ──────────────────
state.message = "GビズID ログインページへ移動中..."
await _navigate_to_gbizid(page)
await _screenshot(page)
# ── ナビゲーション後の状態を確認SSO 自動ログインの検出)──────
state.message = "ログイン画面を確認中..."
await page.wait_for_timeout(2000)
await _screenshot(page)
# Keycloak SSO が有効で e-Gov に自動リダイレクトされた場合
if await _is_logged_in(page):
await _save_cookies(context)
state._playwright = pw
state._browser = browser
state._context = context
state._page = page
state.status = EGovStatus.LOGGED_IN
state.message = "ログイン完了SSO自動ログイン"
return
# ── ID / パスワード入力(フォームがある場合のみ)─────────────
state.message = "GビズID・パスワードを入力中..."
username_visible = False
try:
await page.wait_for_selector(SEL_USERNAME, timeout=12_000)
username_visible = True
except Exception:
pass # OTP フォームのみ表示される場合(記憶されたセッション)
if username_visible:
await page.fill(SEL_USERNAME, user_id)
await page.fill(SEL_PASSWORD, password)
await _screenshot(page)
await page.locator(SEL_SUBMIT).first.click()
else:
# OTP フォームが表示されているか確認
otp_showing = await page.evaluate(
"() => !!document.querySelector('#otp, input[name=\"otp\"], #totp, input[name=\"totp\"]')"
)
if not otp_showing:
current_url = page.url
await _screenshot(page)
# ブラウザを保持: /egov/debug-page でページ状態を診断できるよう state._page を設定
state._playwright = pw
state._browser = browser
state._context = context
state._page = page
state.status = EGovStatus.ERROR
state.message = (
f"GビズID ログインフォームが見つかりません。"
f"現在URL: {current_url} |"
f" /egov/debug-page で詳細を確認してください。"
)
return
# OTP フォームのみ → ID/PW ステップをスキップして次へ
# ── TOTP ページを待機 ───────────────────────────
state.message = "認証コード入力ページへ移動中..."
try:
await page.wait_for_selector(SEL_OTP, timeout=12_000)
await _screenshot(page)
except Exception:
await page.wait_for_load_state("domcontentloaded")
await _screenshot(page)
if await _is_logged_in(page):
await _save_cookies(context)
state._playwright = pw
state._browser = browser
state._context = context
state._page = page
state.status = EGovStatus.LOGGED_IN
state.message = "ログイン完了MFA なし)"
return
await state.close_browser()
raise RuntimeError(
"認証コードページへの遷移に失敗しました。"
"GビズID またはパスワードをご確認ください。"
)
# ── ユーザーの MFA 入力を待つ ───────────────────
state.status = EGovStatus.WAITING_MFA
state.message = "GビズID アプリの認証コード6桁を入力してください"
try:
await asyncio.wait_for(state.mfa_event.wait(), timeout=MFA_TIMEOUT)
except asyncio.TimeoutError:
await state.close_browser()
state.status = EGovStatus.ERROR
state.message = f"タイムアウト: {MFA_TIMEOUT}秒以内に認証コードが入力されませんでした"
return
# ── MFA コードを送信 ────────────────────────────
mfa_code = state.mfa_code
state.message = "認証コードを送信中..."
await page.fill(SEL_OTP, mfa_code)
await page.locator(SEL_SUBMIT).first.click()
# ── ログイン完了を確認 ──────────────────────────
await page.wait_for_load_state("domcontentloaded", timeout=15_000)
try:
await page.wait_for_load_state("networkidle", timeout=10_000)
except Exception:
pass
await _screenshot(page)
if await _is_logged_in(page):
await _save_cookies(context)
# ブラウザを閉じずに保持
state._playwright = pw
state._browser = browser
state._context = context
state._page = page
state.status = EGovStatus.LOGGED_IN
state.message = "ログイン完了"
else:
await state.close_browser()
state.status = EGovStatus.ERROR
state.message = "ログインに失敗しました(認証コードが間違っている可能性があります)"
except Exception as exc:
await state.close_browser()
state.status = EGovStatus.ERROR
state.message = f"エラー: {exc}"
# ── 電子送達ダウンロード ──────────────────────────────────────
DOWNLOADS_DIR = EGOV_DATA_DIR / "downloads"
SEL_DOWNLOAD_BTN = 'button:has-text("通知ファイルをダウンロード"), a:has-text("通知ファイルをダウンロード")'
async def run_download():
"""電子送達一覧から最初のファイルをダウンロードするBackgroundTask"""
try:
if state.status != EGovStatus.LOGGED_IN:
state.status = EGovStatus.ERROR
state.message = "ログインしてからダウンロードを実行してください"
return
if state._page is None:
state.status = EGovStatus.ERROR
state.message = "ブラウザセッションがありません。再ログインしてください"
return
state.status = EGovStatus.DOWNLOADING
DOWNLOADS_DIR.mkdir(parents=True, exist_ok=True)
page = state._page
# ─────────────────────────────────────────────────────
# 診断ステージ1: goto 前のページ状態(ログイン直後のページを確認)
# ─────────────────────────────────────────────────────
try:
pre = await page.evaluate("""
() => {
const b = document.body ? document.body.innerText : '';
return {
url: location.href,
hasLogout: b.includes('ログアウト'),
hasMyPage: b.includes('マイページ'),
hasDelivery: b.includes('電子送達'),
bodyLen: b.length,
};
}
""")
state.message = (
f"[診断1/goto前] url={pre['url']} "
f"ログアウト={pre['hasLogout']} マイページ={pre['hasMyPage']} "
f"電子送達={pre['hasDelivery']} bodyLen={pre['bodyLen']}"
)
except Exception as e:
state.message = f"[診断1エラー] {e}"
pre = {"hasLogout": False}
await _screenshot(page)
# ── 電子送達一覧へ移動 ──────────────────────────
await page.goto(
"https://shinsei.e-gov.go.jp/top/message/delivery-list",
wait_until="domcontentloaded",
)
try:
await page.wait_for_load_state("networkidle", timeout=15_000)
except Exception:
pass
# ─────────────────────────────────────────────────────
# 診断ステージ2: delivery-list ページの詳細状態を収集
# ─────────────────────────────────────────────────────
post = await page.evaluate("""
() => {
const b = document.body ? document.body.innerText : '(empty)';
return {
url: location.href,
title: document.title,
hasLogout: b.includes('ログアウト'),
hasMyPage: b.includes('マイページ'),
hasDelivery: b.includes('電子送達'),
bodyLen: b.length,
bodyPreview: b.slice(0, 300).replace(/\\n+/g, '|'),
links: Array.from(document.querySelectorAll('a')).map(a => ({
t: a.textContent.trim().slice(0, 30),
h: a.href
})).slice(0, 20),
};
}
""")
await _screenshot(page)
state.message = (
f"[診断2/goto後] title={post['title']} url={post['url']} "
f"ログアウト={post['hasLogout']} マイページ={post['hasMyPage']} "
f"電子送達={post['hasDelivery']} bodyLen={post['bodyLen']} "
f"body={post['bodyPreview'][:200]}"
)
# 認証状態チェック
if not post['hasLogout'] and not post['hasMyPage']:
state.status = EGovStatus.ERROR
state.message = (
f"[未認証] delivery-listが未認証状態です。"
f" goto前: ログアウト={pre.get('hasLogout')} "
f" goto後: ログアウト={post['hasLogout']} マイページ={post['hasMyPage']}"
f" body={post['bodyPreview'][:150]}"
f" links={post['links'][:10]}"
)
return
current_url = post['url']
# ── 通知タイトル(【...】形式)が現れるまで待機 ──
state.message = "[診断2OK] delivery-listのアイテムを待機中..."
try:
await page.wait_for_function(
"() => Array.from(document.querySelectorAll('a'))"
".some(a => a.textContent.includes('') && a.href && !a.href.startsWith('javascript'))",
timeout=15_000,
)
except Exception:
pass
await _screenshot(page)
# ── 最初のアイテムリンクをJSで取得 ─────────────
detail_href = await page.evaluate("""
() => {
const titleLinks = Array.from(document.querySelectorAll('a')).filter(a =>
a.textContent.trim().startsWith('') &&
a.href && !a.href.startsWith('javascript')
);
if (titleLinks.length > 0) return titleLinks[0].href;
const detailLinks = Array.from(document.querySelectorAll('a[href]')).filter(a =>
a.href.includes('delivery-detail') || a.href.includes('delivery/detail')
);
if (detailLinks.length > 0) return detailLinks[0].href;
return null;
}
""")
if not detail_href:
all_info = await page.evaluate("""
() => {
const b = document.body ? document.body.innerText : '';
return {
hrefs: Array.from(document.querySelectorAll('a[href]')).map(a => a.href).slice(0,10),
bodyPreview: b.slice(0, 300).replace(/\\n+/g, '|'),
};
}
""")
state.status = EGovStatus.ERROR
state.message = (
f"電子送達アイテムなし。URL={current_url} "
f"body={all_info['bodyPreview'][:200]} "
f"links={all_info['hrefs']}"
)
await _screenshot(page)
return
# ── 詳細ページへ移動 ────────────────────────────
state.message = "詳細ページへ移動中..."
await page.goto(detail_href, wait_until="domcontentloaded")
try:
await page.wait_for_load_state("networkidle", timeout=10_000)
except Exception:
pass
await _screenshot(page)
# ── 通知ファイルをダウンロード ──────────────────
state.message = "ダウンロードボタンを探しています..."
try:
async with page.expect_download(timeout=60_000) as dl_info:
await page.locator(SEL_DOWNLOAD_BTN).first.click(timeout=15_000)
download = await dl_info.value
filename = download.suggested_filename or "egov_download"
save_path = DOWNLOADS_DIR / filename
await download.save_as(str(save_path))
file_size = save_path.stat().st_size
await _save_cookies(state._context)
state.last_download = {
"filename": filename,
"path": str(save_path),
"size": file_size,
}
state.status = EGovStatus.LOGGED_IN
state.message = f"ダウンロード完了: {filename} ({file_size:,} bytes)"
await _screenshot(page)
except Exception as e:
state.status = EGovStatus.ERROR
state.message = f"ダウンロードに失敗しました: {e}"
await _screenshot(page)
except Exception as exc:
state.status = EGovStatus.ERROR
state.message = f"エラー: {exc}"
# ── プライベートヘルパー ─────────────────────────────────────
async def _navigate_to_gbizid(page):
"""ログインページ → GビズID 選択まで自動遷移"""
# アプローチ1: /top/login へ直接移動
try:
await page.goto(
"https://shinsei.e-gov.go.jp/top/login",
wait_until="domcontentloaded",
)
# Angular レンダリング完了を待機
try:
await page.wait_for_load_state("networkidle", timeout=8_000)
except Exception:
pass
await page.wait_for_timeout(2000)
except Exception:
pass
# /top/login にたどり着けなかった場合: ヘッダーのログインリンクをクリック
if "login" not in page.url and "keycloak" not in page.url and "gbizid" not in page.url:
try:
await page.locator(SEL_LOGIN_LINK).first.click(timeout=8_000)
await page.wait_for_load_state("domcontentloaded")
await page.wait_for_timeout(3000)
except Exception:
pass
await _screenshot(page)
# すでに Keycloak/GビズID ページにいる場合はスキップ
if any(k in page.url for k in ("keycloak", "gbizid", "accounts.g-biz", "gbiz.go.jp")):
return
# GビズID ボタンをクリック(複数セレクタで試行)
gbizid_selectors = [
'a:has-text("GビズID")',
'button:has-text("GビズID")',
'img[alt*="GビズID"]',
'[alt*="GビズID"]',
'a[href*="gbizid"]',
'a[href*="keycloak"]',
'a[href*="gbiz"]',
'[class*="gbiz"]',
'[id*="gbiz"]',
]
clicked = False
for sel in gbizid_selectors:
try:
el = page.locator(sel).first
await el.wait_for(timeout=3_000, state="visible")
await el.click()
await page.wait_for_load_state("domcontentloaded")
clicked = True
break
except Exception:
continue
if not clicked:
# JS で全 a/button/[role=button] をスキャンして GビズID 関連を探す
try:
clicked_js = await page.evaluate("""
() => {
const all = Array.from(document.querySelectorAll('a, button, [role="button"]'));
for (const el of all) {
const t = (el.textContent || '').trim();
const h = el.getAttribute('href') || '';
const alts = Array.from(el.querySelectorAll('img'))
.map(img => img.alt || '').join(' ');
if (t.includes('GビズID') || t.includes('gBizID') || t.includes('Gビズ')
|| h.includes('gbizid') || h.includes('keycloak') || h.includes('gbiz')
|| alts.includes('GビズID') || alts.includes('ビズID')) {
el.click();
return el.tagName + ':' + t.slice(0, 30) + ':' + h.slice(0, 60);
}
}
return null;
}
""")
if clicked_js:
await page.wait_for_load_state("domcontentloaded")
await page.wait_for_timeout(2000)
except Exception:
pass
async def _is_logged_in(page) -> bool:
try:
content = await page.content()
return any(m in content for m in LOGGEDIN_MARKERS)
except Exception:
return False
async def _load_context(browser):
"""保存済み Cookie からセッションを復元"""
if COOKIES_PATH.exists():
try:
with open(COOKIES_PATH) as f:
storage = json.load(f)
return await browser.new_context(storage_state=storage)
except Exception:
pass
return await browser.new_context()
async def _save_cookies(context):
"""セッション Cookie をファイルに保存"""
EGOV_DATA_DIR.mkdir(parents=True, exist_ok=True)
storage = await context.storage_state()
with open(COOKIES_PATH, "w") as f:
json.dump(storage, f)
async def _screenshot(page):
"""ページのスクリーンショットを base64 で state に保存"""
try:
data = await page.screenshot(type="jpeg", quality=55)
state.screenshot_b64 = base64.b64encode(data).decode()
except Exception:
pass

View File

@@ -0,0 +1,85 @@
import asyncio
import time
from enum import Enum
from typing import Optional, Any
class EGovStatus(str, Enum):
IDLE = "idle"
STARTING = "starting"
WAITING_MFA = "waiting_mfa"
LOGGED_IN = "logged_in"
DOWNLOADING = "downloading"
ERROR = "error"
class EGovState:
def __init__(self):
self.status: EGovStatus = EGovStatus.IDLE
self.message: str = ""
self.mfa_event: asyncio.Event = asyncio.Event()
self.mfa_code: Optional[str] = None
self.screenshot_b64: Optional[str] = None
self.last_download: Optional[dict] = None # {filename, path, size}
# ブラウザセッションPlaywright 方式, 後方互換用)
self._playwright: Optional[Any] = None
self._browser: Optional[Any] = None
self._context: Optional[Any] = None
self._page: Optional[Any] = None
# ── OAuth2 / e-Gov API 方式 ──────────────────────────────
self.access_token: Optional[str] = None
self.refresh_token: Optional[str] = None
self.token_expires_at: Optional[float] = None # unix timestamp
self._pkce_verifier: Optional[str] = None
self._oauth_state: Optional[str] = None
def is_token_valid(self) -> bool:
"""アクセストークンが有効かどうかを返す60秒マージン"""
if not self.access_token:
return False
if self.token_expires_at and time.time() >= self.token_expires_at - 60:
return False
return True
def set_tokens(self, token_response: dict):
"""トークンレスポンスをステートにセット"""
self.access_token = token_response.get("access_token")
self.refresh_token = token_response.get("refresh_token")
expires_in = token_response.get("expires_in", 300)
self.token_expires_at = time.time() + int(expires_in)
def reset(self):
self.status = EGovStatus.IDLE
self.message = ""
self.mfa_event = asyncio.Event()
self.mfa_code = None
self.screenshot_b64 = None
self.last_download = None
self.access_token = None
self.refresh_token = None
self.token_expires_at = None
self._pkce_verifier = None
self._oauth_state = None
async def close_browser(self):
"""ブラウザを閉じてリソースを解放"""
try:
if self._browser:
await self._browser.close()
except Exception:
pass
try:
if self._playwright:
await self._playwright.stop()
except Exception:
pass
self._playwright = None
self._browser = None
self._context = None
self._page = None
# モジュールレベルのシングルトン
state = EGovState()

View File

@@ -0,0 +1,119 @@
"""
ねんきんポータル DB テーブル作成マイグレーション
"""
import os
from psycopg import connect
def create_nenkin_tables():
conn = connect(
host=os.getenv("DB_HOST"),
port=int(os.getenv("DB_PORT", 5432)),
dbname=os.getenv("DB_NAME"),
user=os.getenv("DB_USER"),
password=os.getenv("DB_PASSWORD"),
)
with conn.cursor() as cur:
# ──────────────────────────────────
# 文書ヘッダーマスター(全種別共通)
# ──────────────────────────────────
cur.execute("""
CREATE TABLE IF NOT EXISTS nenkin_documents (
id SERIAL PRIMARY KEY,
document_no VARCHAR(50) UNIQUE NOT NULL,
document_type VARCHAR(10) NOT NULL,
document_type_name VARCHAR(100),
zip_filename VARCHAR(255),
office_name VARCHAR(200),
office_number VARCHAR(20),
office_kigo VARCHAR(50),
nenkin_jimusho VARCHAR(100),
purpose_year INTEGER,
purpose_month INTEGER,
issue_date DATE,
uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# ──────────────────────────────────
# 社会保険料額情報 (SHAKAI)
# ──────────────────────────────────
cur.execute("""
CREATE TABLE IF NOT EXISTS nenkin_shakai_hokenryo (
id SERIAL PRIMARY KEY,
document_id INTEGER REFERENCES nenkin_documents(id) ON DELETE CASCADE,
kenkou_hokenryo NUMERIC,
kousei_nenkin_hokenryo NUMERIC,
kodomo_kyoshutsukin NUMERIC,
total NUMERIC,
nofu_kigen_date DATE,
office_postcode VARCHAR(10),
office_address VARCHAR(300)
)
""")
# ──────────────────────────────────
# 保険料納入告知額・領収済額通知書 (NOUNYU)
# ──────────────────────────────────
cur.execute("""
CREATE TABLE IF NOT EXISTS nenkin_nounyu_tsuchi (
id SERIAL PRIMARY KEY,
document_id INTEGER REFERENCES nenkin_documents(id) ON DELETE CASCADE,
kokuchi_kenkou NUMERIC,
kokuchi_kounen NUMERIC,
kokuchi_kodomo NUMERIC,
kokuchi_total NUMERIC,
kokuchi_nofu_kigen DATE,
ryoshu_kenkou NUMERIC,
ryoshu_kounen NUMERIC,
ryoshu_kodomo NUMERIC,
ryoshu_total NUMERIC,
ryoshu_date DATE,
ryoshu_nofu_year INTEGER,
ryoshu_nofu_month INTEGER,
office_postcode VARCHAR(10),
office_address VARCHAR(300),
office_name_line1 VARCHAR(200),
office_name_line2 VARCHAR(200)
)
""")
# ──────────────────────────────────
# 賞与保険料算出内訳書 ヘッダー (SHOYO)
# ──────────────────────────────────
cur.execute("""
CREATE TABLE IF NOT EXISTS nenkin_shoyo_header (
id SERIAL PRIMARY KEY,
document_id INTEGER REFERENCES nenkin_documents(id) ON DELETE CASCADE,
jin_in_num INTEGER,
menjyo_hokenryo_ritsu VARCHAR(50),
page_num INTEGER
)
""")
# ──────────────────────────────────
# 賞与保険料算出内訳書 明細 (SHOYO)
# ──────────────────────────────────
cur.execute("""
CREATE TABLE IF NOT EXISTS nenkin_shoyo_detail (
id SERIAL PRIMARY KEY,
document_id INTEGER REFERENCES nenkin_documents(id) ON DELETE CASCADE,
header_id INTEGER REFERENCES nenkin_shoyo_header(id) ON DELETE CASCADE,
seiri_num VARCHAR(20),
shimei VARCHAR(100),
shori_ymd VARCHAR(20),
hassei_ymd VARCHAR(20),
hyojun_shoyo_kenpo NUMERIC,
hyojun_shoyo_kounen NUMERIC,
kenpo_hongetsu NUMERIC,
kenpo_zengetsu NUMERIC,
kounen_hongetsu NUMERIC,
kounen_zengetsu NUMERIC,
page_num INTEGER
)
""")
conn.commit()
conn.close()
print("✓ ねんきんポータル テーブル作成完了")

View File

@@ -0,0 +1,281 @@
"""
ねんきんポータル API ルーター
"""
from fastapi import APIRouter, UploadFile, File, HTTPException
from app.core.database import get_connection
from app.modules.nenkin_portal.service import parse_zip
router = APIRouter(prefix="/nenkin", tags=["ねんきんポータル"])
# ──────────────────────────────────────
# POST /nenkin/upload
# ──────────────────────────────────────
@router.post("/upload", summary="社会保険電子文書 ZIPアップロード")
async def upload_nenkin_zip(file: UploadFile = File(...)):
if not file.filename.lower().endswith('.zip'):
raise HTTPException(status_code=400, detail="ZIPファイルを選択してください")
data = await file.read()
try:
parsed = parse_zip(data, file.filename)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=400, detail=f"ZIPの解析に失敗しました: {e}")
doc_no = parsed['document_no']
if not doc_no:
raise HTTPException(status_code=400, detail="文書番号が取得できませんでした")
with get_connection() as conn, conn.cursor() as cur:
# 重複チェック
cur.execute(
"SELECT id FROM nenkin_documents WHERE document_no = %s",
(doc_no,)
)
if cur.fetchone():
raise HTTPException(
status_code=409,
detail=f"文書番号 {doc_no} は既に登録されています"
)
# nenkin_documents に挿入
cur.execute("""
INSERT INTO nenkin_documents
(document_no, document_type, document_type_name, zip_filename,
office_name, office_number, office_kigo, nenkin_jimusho,
purpose_year, purpose_month, issue_date)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING id
""", (
parsed['document_no'],
parsed['document_type'],
parsed['document_type_name'],
parsed['zip_filename'],
parsed['office_name'],
parsed['office_number'],
parsed['office_kigo'],
parsed['nenkin_jimusho'],
parsed['purpose_year'],
parsed['purpose_month'],
parsed['issue_date'],
))
doc_id = cur.fetchone()['id']
detail = parsed['detail']
if parsed['document_type'] == 'SHAKAI':
cur.execute("""
INSERT INTO nenkin_shakai_hokenryo
(document_id, kenkou_hokenryo, kousei_nenkin_hokenryo,
kodomo_kyoshutsukin, total, nofu_kigen_date,
office_postcode, office_address)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s)
""", (
doc_id,
detail['kenkou_hokenryo'],
detail['kousei_nenkin_hokenryo'],
detail['kodomo_kyoshutsukin'],
detail['total'],
detail['nofu_kigen_date'],
detail['office_postcode'],
detail['office_address'],
))
elif parsed['document_type'] == 'NOUNYU':
cur.execute("""
INSERT INTO nenkin_nounyu_tsuchi
(document_id,
kokuchi_kenkou, kokuchi_kounen, kokuchi_kodomo, kokuchi_total, kokuchi_nofu_kigen,
ryoshu_kenkou, ryoshu_kounen, ryoshu_kodomo, ryoshu_total, ryoshu_date,
ryoshu_nofu_year, ryoshu_nofu_month,
office_postcode, office_address, office_name_line1, office_name_line2)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
""", (
doc_id,
detail['kokuchi_kenkou'], detail['kokuchi_kounen'],
detail['kokuchi_kodomo'], detail['kokuchi_total'],
detail['kokuchi_nofu_kigen'],
detail['ryoshu_kenkou'], detail['ryoshu_kounen'],
detail['ryoshu_kodomo'], detail['ryoshu_total'],
detail['ryoshu_date'],
detail['ryoshu_nofu_year'], detail['ryoshu_nofu_month'],
detail['office_postcode'], detail['office_address'],
detail['office_name_line1'], detail['office_name_line2'],
))
elif parsed['document_type'] == 'SHOYO':
cur.execute("""
INSERT INTO nenkin_shoyo_header
(document_id, jin_in_num, menjyo_hokenryo_ritsu, page_num)
VALUES (%s,%s,%s,%s)
RETURNING id
""", (
doc_id,
detail['jin_in_num'],
detail['menjyo_hokenryo_ritsu'],
detail['page_num'],
))
header_id = cur.fetchone()['id']
for item in detail['items']:
cur.execute("""
INSERT INTO nenkin_shoyo_detail
(document_id, header_id, seiri_num, shimei, shori_ymd, hassei_ymd,
hyojun_shoyo_kenpo, hyojun_shoyo_kounen,
kenpo_hongetsu, kenpo_zengetsu,
kounen_hongetsu, kounen_zengetsu, page_num)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
""", (
doc_id, header_id,
item['seiri_num'], item['shimei'],
item['shori_ymd'], item['hassei_ymd'],
item['hyojun_shoyo_kenpo'], item['hyojun_shoyo_kounen'],
item['kenpo_hongetsu'], item['kenpo_zengetsu'],
item['kounen_hongetsu'], item['kounen_zengetsu'],
item['page_num'],
))
conn.commit()
return {
'id': doc_id,
'document_no': parsed['document_no'],
'document_type': parsed['document_type'],
'document_type_name': parsed['document_type_name'],
'message': f"{parsed['document_type_name']} を登録しました",
}
# ──────────────────────────────────────
# GET /nenkin/documents
# ──────────────────────────────────────
@router.get("/documents", summary="文書一覧取得")
def list_documents(doc_type: str = None, year: int = None, month: int = None):
conditions = []
params = []
if doc_type:
conditions.append("document_type = %s")
params.append(doc_type)
if year:
conditions.append("purpose_year = %s")
params.append(year)
if month:
conditions.append("purpose_month = %s")
params.append(month)
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
with get_connection() as conn, conn.cursor() as cur:
cur.execute(f"""
SELECT id, document_no, document_type, document_type_name,
zip_filename, office_name, office_number, office_kigo,
nenkin_jimusho, purpose_year, purpose_month,
issue_date, uploaded_at
FROM nenkin_documents
{where}
ORDER BY purpose_year DESC, purpose_month DESC, document_type, uploaded_at DESC
""", params)
rows = cur.fetchall()
result = []
for r in rows:
item = dict(r)
if item.get('issue_date'):
item['issue_date'] = item['issue_date'].isoformat()
if item.get('uploaded_at'):
item['uploaded_at'] = item['uploaded_at'].isoformat()
result.append(item)
return result
# ──────────────────────────────────────
# GET /nenkin/documents/{doc_id}
# ──────────────────────────────────────
@router.get("/documents/{doc_id}", summary="文書詳細取得")
def get_document(doc_id: int):
with get_connection() as conn, conn.cursor() as cur:
cur.execute("SELECT * FROM nenkin_documents WHERE id = %s", (doc_id,))
doc = cur.fetchone()
if not doc:
raise HTTPException(status_code=404, detail="文書が見つかりません")
doc = dict(doc)
if doc.get('issue_date'):
doc['issue_date'] = doc['issue_date'].isoformat()
if doc.get('uploaded_at'):
doc['uploaded_at'] = doc['uploaded_at'].isoformat()
detail = {}
dtype = doc['document_type']
if dtype == 'SHAKAI':
cur.execute(
"SELECT * FROM nenkin_shakai_hokenryo WHERE document_id = %s",
(doc_id,)
)
row = cur.fetchone()
if row:
detail = dict(row)
if detail.get('nofu_kigen_date'):
detail['nofu_kigen_date'] = detail['nofu_kigen_date'].isoformat()
elif dtype == 'NOUNYU':
cur.execute(
"SELECT * FROM nenkin_nounyu_tsuchi WHERE document_id = %s",
(doc_id,)
)
row = cur.fetchone()
if row:
detail = dict(row)
for df in ('kokuchi_nofu_kigen', 'ryoshu_date'):
if detail.get(df):
detail[df] = detail[df].isoformat()
elif dtype == 'SHOYO':
cur.execute(
"SELECT * FROM nenkin_shoyo_header WHERE document_id = %s",
(doc_id,)
)
hdr = cur.fetchone()
if hdr:
detail = dict(hdr)
cur.execute("""
SELECT * FROM nenkin_shoyo_detail
WHERE document_id = %s
ORDER BY CAST(NULLIF(TRIM(seiri_num), '') AS INTEGER) NULLS LAST
""", (doc_id,))
detail['items'] = [dict(r) for r in cur.fetchall()]
return {'document': doc, 'detail': detail}
# ──────────────────────────────────────
# GET /nenkin/filter-options
# ──────────────────────────────────────
@router.get("/filter-options", summary="フィルター選択肢取得")
def get_filter_options():
with get_connection() as conn, conn.cursor() as cur:
cur.execute("""
SELECT DISTINCT purpose_year, purpose_month
FROM nenkin_documents
WHERE purpose_year IS NOT NULL
ORDER BY purpose_year DESC, purpose_month DESC
""")
periods = [dict(r) for r in cur.fetchall()]
cur.execute("""
SELECT DISTINCT document_type, document_type_name
FROM nenkin_documents
ORDER BY document_type
""")
types = [dict(r) for r in cur.fetchall()]
return {'periods': periods, 'types': types}

View File

@@ -0,0 +1,351 @@
"""
ねんきんポータル ZIPパーサー
対応文書種別:
SHAKAI - 社会保険料額情報
NOUNYU - 保険料納入告知額・領収済額通知書
SHOYO - 賞与保険料算出内訳書
"""
import zipfile
import io
import re
import xml.etree.ElementTree as ET
from datetime import date
from typing import Optional, Dict, Any
# ──────────────────────────────────────
# 元号オフセット
# ──────────────────────────────────────
GENGOU_OFFSETS = {
'令和': 2018, 'R': 2018,
'平成': 1988, 'H': 1988,
'昭和': 1925, 'S': 1925,
'大正': 1911, 'T': 1911,
'明治': 1867, 'M': 1867,
}
# XSLファイル名 → 文書種別
XSL_TYPE_MAP = {
'yoshiki_04_shakai_003.xsl': 'SHAKAI',
'yoshiki_29_zumitsu_001.xsl': 'NOUNYU',
'yoshiki_03_shoyo_002.xsl': 'SHOYO',
}
TYPE_NAMES = {
'SHAKAI': '社会保険料額情報',
'NOUNYU': '保険料納入告知額・領収済額通知書',
'SHOYO': '賞与保険料算出内訳書',
'HIHOKEN': '被保険者データ',
'ZOGENS': '保険料増減内訳書',
}
# ──────────────────────────────────────
# ユーティリティ
# ──────────────────────────────────────
def gengou_to_western(gengou: str, year_str) -> Optional[int]:
try:
offset = GENGOU_OFFSETS.get(str(gengou), 0)
return int(str(year_str)) + offset if offset else None
except Exception:
return None
def make_date(gengou: str, year_str, month_str, day_str='1') -> Optional[date]:
try:
western = gengou_to_western(gengou, year_str)
if not western:
return None
return date(western, int(month_str), int(day_str or 1))
except Exception:
return None
def parse_reki_date(s: str) -> Optional[date]:
"""R080430 → date(2026,4,30)"""
if not s or len(s) < 7:
return None
prefix = s[0].upper()
try:
yy = int(s[1:3])
mm = int(s[3:5])
dd = int(s[5:7])
offset = GENGOU_OFFSETS.get(prefix, 0)
if not offset:
return None
return date(yy + offset, mm, dd)
except Exception:
return None
def parse_amount(s) -> Optional[float]:
"""'94,054' / '\\247,066' → float"""
if s is None:
return None
cleaned = str(s).replace(',', '').replace('\\', '').replace('', '').replace('¥', '').strip()
if not cleaned:
return None
try:
return float(cleaned)
except Exception:
return None
def get_text(elem, tag: str) -> str:
if elem is None:
return ''
child = elem.find(tag)
return child.text.strip() if child is not None and child.text else ''
# ──────────────────────────────────────
# 文書種別の自動判定
# ──────────────────────────────────────
def detect_doc_type(names: list) -> str:
for name in names:
basename = name.split('/')[-1]
if basename in XSL_TYPE_MAP:
return XSL_TYPE_MAP[basename]
return 'UNKNOWN'
def get_document_no(zf, names: list) -> str:
"""封筒XML から文書番号を取得"""
envelope_name = next(
(n for n in names if re.match(r'.*/\d+\.xml$', n)),
None
)
if not envelope_name:
return ''
with zf.open(envelope_name) as f:
etree = ET.parse(f)
elem = etree.getroot().find('.//DOCNO')
return elem.text.strip() if elem is not None and elem.text else ''
# ──────────────────────────────────────
# メインエントリー
# ──────────────────────────────────────
def parse_zip(zip_bytes: bytes, zip_filename: str) -> Dict[str, Any]:
zf = zipfile.ZipFile(io.BytesIO(zip_bytes))
names = zf.namelist()
doc_type = detect_doc_type(names)
if doc_type == 'SHAKAI':
return _parse_shakai(zf, names, zip_filename)
elif doc_type == 'NOUNYU':
return _parse_nounyu(zf, names, zip_filename)
elif doc_type == 'SHOYO':
return _parse_shoyo(zf, names, zip_filename)
else:
raise ValueError(f"未対応の文書種別ですXSLが不明: {[n.split('/')[-1] for n in names]}")
# ──────────────────────────────────────
# 社会保険料額情報 (SHAKAI)
# ──────────────────────────────────────
def _parse_shakai(zf, names: list, zip_filename: str) -> Dict[str, Any]:
xml_name = next(
n for n in names
if '社会保険料額情報' in n.split('/')[-1] and n.endswith('.xml')
)
with zf.open(xml_name) as f:
root = ET.parse(f).getroot()
lst = root.find('shakaiHokenRyougakuList')
header = lst.find('header')
jigyosho_name = get_text(header, 'jigyoshoName').replace('<br/>', ' ')
jigyosho_num = get_text(header, 'jigyoshoNum')
jigyosho_kigo = get_text(header, 'jigyoshoSeiriKigo')
nenkin_jimusho = get_text(header, 'nenkinJimusho1')
nofu_gengou = get_text(header, 'nouhuMokutekiYearGengou')
nofu_year = get_text(header, 'nouhuMokutekiYear')
nofu_month = get_text(header, 'nouhuMokutekiMonth')
hakkou_gengou = get_text(lst, 'hakkouYmdGengou')
hakkou_year = get_text(lst, 'hakkouYmdYear')
hakkou_month = get_text(lst, 'hakkouYmdMonth')
hakkou_day = get_text(lst, 'hakkouYmdDate')
nofu_kigen_gengou = get_text(lst, 'nouhuKigenGengou')
nofu_kigen_year = get_text(lst, 'nouhuKigenYear')
nofu_kigen_month = get_text(lst, 'nouhuKigenMonth')
nofu_kigen_day = get_text(lst, 'nouhuKigenDate')
return {
'document_no': get_document_no(zf, names),
'document_type': 'SHAKAI',
'document_type_name': TYPE_NAMES['SHAKAI'],
'zip_filename': zip_filename,
'office_name': jigyosho_name,
'office_number': jigyosho_num,
'office_kigo': jigyosho_kigo,
'nenkin_jimusho': nenkin_jimusho,
'purpose_year': gengou_to_western(nofu_gengou, nofu_year),
'purpose_month': int(nofu_month) if nofu_month else None,
'issue_date': make_date(hakkou_gengou, hakkou_year, hakkou_month, hakkou_day),
'detail': {
'kenkou_hokenryo': parse_amount(get_text(lst, 'kenkouHokenRyou')),
'kousei_nenkin_hokenryo': parse_amount(get_text(lst, 'kouseiNenkinHokenRyou')),
'kodomo_kyoshutsukin': parse_amount(get_text(lst, 'kodomoKosodateKyoshutsuKin')),
'total': parse_amount(get_text(lst, 'total')),
'nofu_kigen_date': make_date(nofu_kigen_gengou, nofu_kigen_year, nofu_kigen_month, nofu_kigen_day),
'office_postcode': get_text(lst, 'jigyoushoPostCode'),
'office_address': get_text(lst, 'jigyoushoShozaichi').replace('<br/>', ' '),
}
}
# ──────────────────────────────────────
# 保険料納入告知額・領収済額通知書 (NOUNYU)
# ──────────────────────────────────────
def _parse_nounyu(zf, names: list, zip_filename: str) -> Dict[str, Any]:
xml_name = next(
n for n in names
if '保険料納入告知額' in n.split('/')[-1] and n.endswith('.xml')
)
with zf.open(xml_name) as f:
root = ET.parse(f).getroot()
lst = root.find('hokenRyouRyoshuzumigakutsuchiShoList')
header = lst.find('header')
jigyosho_num = get_text(header, 'jigyoshoNum')
jigyosho_kigo = get_text(header, 'jigyoshoSeiriKigo')
nenkin_jimusho = get_text(header, 'nenkinJimusho1')
nofu_gengou = get_text(header, 'nouhuMokutekiYearGengou')
nofu_year = get_text(header, 'nouhuMokutekiYear')
nofu_month = get_text(header, 'nouhuMokutekiMonth')
hakkou_gengou = get_text(lst, 'hakkouYmdGengou')
hakkou_year = get_text(lst, 'hakkouYmdYear')
hakkou_month = get_text(lst, 'hakkouYmdMonth')
hakkou_day = get_text(lst, 'hakkouYmdDate')
nofu_kigen_gengou = get_text(lst, 'nouhuKigenGengou')
nofu_kigen_year = get_text(lst, 'nouhuKigenYear')
nofu_kigen_month = get_text(lst, 'nouhuKigenMonth')
nofu_kigen_day = get_text(lst, 'nouhuKigenDate')
ryoshu_gengou = get_text(lst, 'ryoshuDtGengo')
ryoshu_year = get_text(lst, 'ryoshuDtYear')
ryoshu_month = get_text(lst, 'ryoshuDtMonth')
ryoshu_day = get_text(lst, 'ryoshuDtDate')
ryoshu_nofu_gengou = get_text(lst, 'ryoshuNouhuMokutekiYmGengou')
ryoshu_nofu_year = get_text(lst, 'ryoshuNouhuMokutekiYmYear')
ryoshu_nofu_month = get_text(lst, 'ryoshuNouhuMokutekiYmMonth')
name1 = get_text(lst, 'jigyoshoNameLine1')
name2 = get_text(lst, 'jigyoshoNameLine2')
office_name = (name1 + ' ' + name2).strip()
return {
'document_no': get_document_no(zf, names),
'document_type': 'NOUNYU',
'document_type_name': TYPE_NAMES['NOUNYU'],
'zip_filename': zip_filename,
'office_name': office_name,
'office_number': jigyosho_num,
'office_kigo': jigyosho_kigo,
'nenkin_jimusho': nenkin_jimusho,
'purpose_year': gengou_to_western(nofu_gengou, nofu_year),
'purpose_month': int(nofu_month) if nofu_month else None,
'issue_date': make_date(hakkou_gengou, hakkou_year, hakkou_month, hakkou_day),
'detail': {
'kokuchi_kenkou': parse_amount(get_text(lst, 'kenkouHokenRyou')),
'kokuchi_kounen': parse_amount(get_text(lst, 'kouseiNenkinHokenRyou')),
'kokuchi_kodomo': parse_amount(get_text(lst, 'kodomoKosodateKyoshutsuKin')),
'kokuchi_total': parse_amount(get_text(lst, 'total')),
'kokuchi_nofu_kigen': make_date(nofu_kigen_gengou, nofu_kigen_year, nofu_kigen_month, nofu_kigen_day),
'ryoshu_kenkou': parse_amount(get_text(lst, 'ryoshuKenkouHokenRyou')),
'ryoshu_kounen': parse_amount(get_text(lst, 'ryoshuKouseiNenkinHokenRyou')),
'ryoshu_kodomo': parse_amount(get_text(lst, 'ryoshuKodomoKosodateKyoshutsuKin')),
'ryoshu_total': parse_amount(get_text(lst, 'ryoshuTotal')),
'ryoshu_date': make_date(ryoshu_gengou, ryoshu_year, ryoshu_month, ryoshu_day),
'ryoshu_nofu_year': gengou_to_western(ryoshu_nofu_gengou, ryoshu_nofu_year),
'ryoshu_nofu_month': int(ryoshu_nofu_month) if ryoshu_nofu_month else None,
'office_postcode': get_text(lst, 'jigyoushoPostCode'),
'office_address': (get_text(lst, 'jigyoushoShozaichiLine1') + ' ' + get_text(lst, 'jigyoushoShozaichiLine2')).strip(),
'office_name_line1': name1,
'office_name_line2': name2,
}
}
# ──────────────────────────────────────
# 賞与保険料算出内訳書 (SHOYO)
# ──────────────────────────────────────
def _parse_shoyo(zf, names: list, zip_filename: str) -> Dict[str, Any]:
# メインXML.xmlかつ「賞与保険料算出内訳書」を含むもの
xml_name = next(
n for n in names
if '賞与保険料算出内訳書' in n.split('/')[-1]
and n.endswith('.xml')
)
with zf.open(xml_name) as f:
root = ET.parse(f).getroot()
shoyo_list = root.find('shouyoList')
header = shoyo_list.find('header')
jigyosho_name = get_text(header, 'jigyoshoName')
jigyosho_num = get_text(header, 'jigyoshoNum')
jigyosho_kigo = get_text(header, 'jigyoshoSeiriKigo')
nenkin_jimusho = get_text(header, 'nenkinJimusho2').replace('\u3000', ' ').strip()
nofu_gengou = get_text(header, 'nouhuMokutekiYearGengou')
nofu_year = get_text(header, 'nouhuMokutekiYear')
nofu_month = get_text(header, 'nouhuMokutekiMonth')
jin_in = get_text(header, 'jinInNum')
menjyo = get_text(header, 'menjyoHokenRyouRitu')
# 各被保険者の明細
items = []
for uchiwake in shoyo_list.findall('uchiwake'):
hyojun = uchiwake.find('hyoujyunShouyoGaku')
kenpo_ryou = uchiwake.find('kenKouHokenRyou')
kounen_ryou = uchiwake.find('kouseiNenkinHokenRyou')
hassei_ymd = get_text(hyojun, 'hasseiYMD') if hyojun is not None else ''
parsed_hassei = parse_reki_date(hassei_ymd)
items.append({
'seiri_num': get_text(uchiwake, 'seiriNum').strip(),
'shimei': get_text(uchiwake, 'shimei'),
'shori_ymd': get_text(uchiwake, 'shoriYMD'),
'hassei_ymd': hassei_ymd,
'hassei_date_iso': parsed_hassei.isoformat() if parsed_hassei else None,
'hyojun_shoyo_kenpo': parse_amount(get_text(hyojun, 'getsuGakuKenpo') if hyojun is not None else ''),
'hyojun_shoyo_kounen': parse_amount(get_text(hyojun, 'getsuGakuKounen') if hyojun is not None else ''),
'kenpo_hongetsu': parse_amount(get_text(kenpo_ryou, 'hongetsuGaku') if kenpo_ryou is not None else ''),
'kenpo_zengetsu': parse_amount(get_text(kenpo_ryou, 'zengetsuIzenMonth') if kenpo_ryou is not None else ''),
'kounen_hongetsu': parse_amount(get_text(kounen_ryou, 'hongetsuGaku') if kounen_ryou is not None else ''),
'kounen_zengetsu': parse_amount(get_text(kounen_ryou, 'zengetsuIzenMonth') if kounen_ryou is not None else ''),
'page_num': int(get_text(uchiwake, 'pageNum') or 0),
})
return {
'document_no': get_document_no(zf, names),
'document_type': 'SHOYO',
'document_type_name': TYPE_NAMES['SHOYO'],
'zip_filename': zip_filename,
'office_name': jigyosho_name,
'office_number': jigyosho_num,
'office_kigo': jigyosho_kigo,
'nenkin_jimusho': nenkin_jimusho,
'purpose_year': gengou_to_western(nofu_gengou, nofu_year),
'purpose_month': int(nofu_month) if nofu_month else None,
'issue_date': None,
'detail': {
'jin_in_num': int(jin_in) if jin_in else 0,
'menjyo_hokenryo_ritsu': menjyo,
'page_num': int(get_text(shoyo_list, 'pageNum') or 1),
'items': items,
}
}

View File

@@ -2,9 +2,54 @@ from fastapi import APIRouter, HTTPException, Query
from decimal import Decimal from decimal import Decimal
from typing import Dict, Any, List, Optional from typing import Dict, Any, List, Optional
from app.modules.trial_balance.service import fetch_trial_balance from app.modules.trial_balance.service import fetch_trial_balance
from app.core.database import get_connection
router = APIRouter(prefix="/trial-balance", tags=["試算表"]) 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(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
@router.get("", summary="試算表(全科目)") @router.get("", summary="試算表(全科目)")
def get_trial_balance( def get_trial_balance(
fiscal_year: Optional[int] = Query(None, description="会計年度(例: 2025"), fiscal_year: Optional[int] = Query(None, description="会計年度(例: 2025"),

View File

@@ -12,6 +12,12 @@ class LoginRequest(BaseModel):
password: str password: str
class ChangePasswordRequest(BaseModel):
username: str
current_password: str
new_password: str
class LoginResponse(BaseModel): class LoginResponse(BaseModel):
id: int id: int
username: str username: str
@@ -66,39 +72,48 @@ async def login(request: LoginRequest):
@router.post("/register") @router.post("/register")
async def register(request: LoginRequest): async def register(request: LoginRequest):
"""新規ユーザー登録""" """新規ユーザー登録(無効化済み)"""
raise HTTPException(status_code=403, detail="新規ユーザー登録は現在受け付けていません")
@router.post("/change-password")
async def change_password(request: ChangePasswordRequest):
"""パスワード変更"""
if len(request.new_password) < 8:
raise HTTPException(status_code=400, detail="新しいパスワードは8文字以上である必要があります")
try: try:
if len(request.password) < 6:
raise HTTPException(status_code=400, detail="パスワードは6文字以上である必要があります")
conn = get_connection() conn = get_connection()
cur = conn.cursor() cur = conn.cursor()
# ユーザーが既に存在するか確認 cur.execute(
cur.execute("SELECT id FROM users WHERE username = %s", (request.username,)) "SELECT id, password FROM users WHERE username = %s AND is_active = TRUE",
if cur.fetchone(): (request.username,)
)
user = cur.fetchone()
if not user:
cur.close() cur.close()
conn.close() conn.close()
raise HTTPException(status_code=400, detail="このユーザー名は既に使用されています") raise HTTPException(status_code=401, detail="ユーザーが見つかりません")
# ユーザーを登録 if user["password"] != hash_password(request.current_password):
hashed_password = hash_password(request.password) cur.close()
conn.close()
raise HTTPException(status_code=401, detail="現在のパスワードが正しくありません")
new_hashed = hash_password(request.new_password)
cur.execute( cur.execute(
"INSERT INTO users (username, password) VALUES (%s, %s) RETURNING id, username", "UPDATE users SET password = %s WHERE username = %s",
(request.username, hashed_password) (new_hashed, request.username)
) )
new_user = cur.fetchone()
conn.commit() conn.commit()
cur.close() cur.close()
conn.close() conn.close()
return { return {"message": "パスワードを変更しました"}
"id": new_user["id"],
"username": new_user["username"],
"message": "ユーザー登録が完了しました"
}
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"登録処理エラー: {str(e)}") raise HTTPException(status_code=500, detail=f"パスワード変更エラー: {str(e)}")

View File

@@ -0,0 +1,71 @@
"""
給与所得の源泉徴収票 APIルーター
"""
from fastapi import APIRouter, HTTPException, Query
from typing import List, Optional
import logging
from .schemas import WithholdingSlipRequest, WithholdingSlipResponse
from .service import get_withholding_slips
from app.core.database import get_connection
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/payroll/withholding-slip", tags=["Payroll - Withholding Slip"])
@router.post("/generate", response_model=WithholdingSlipResponse)
def generate_withholding_slips(request: WithholdingSlipRequest):
"""
指定年度・従業員の給与所得の源泉徴収票データを生成する
"""
if not request.employee_ids:
raise HTTPException(status_code=400, detail="従業員IDを指定してください")
if request.tax_year < 2000 or request.tax_year > 2099:
raise HTTPException(status_code=400, detail="年度が不正です")
try:
slips = get_withholding_slips(request.tax_year, request.employee_ids)
return WithholdingSlipResponse(
tax_year=request.tax_year,
slips=slips,
total_count=len(slips)
)
except Exception as e:
logger.error(f"源泉徴収票生成エラー: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/employees")
def get_employees_for_slip():
"""アクティブな従業員リストを取得(源泉徴収票用)"""
try:
with get_connection() as conn:
with conn.cursor() as cur:
cur.execute("""
SELECT employee_id, employee_code, name, name_kana
FROM employees
WHERE is_active = true
ORDER BY employee_code
""")
rows = cur.fetchall()
result = []
for row in rows:
if isinstance(row, dict):
result.append({
"id": row["employee_id"],
"code": row["employee_code"],
"name": row["name"],
"name_kana": row.get("name_kana", "")
})
else:
result.append({
"id": row[0],
"code": row[1],
"name": row[2],
"name_kana": row[3] if len(row) > 3 else ""
})
return {"employees": result, "total": len(result)}
except Exception as e:
logger.error(f"従業員リスト取得エラー: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -0,0 +1,95 @@
"""
給与所得の源泉徴収票 スキーマ定義
"""
from pydantic import BaseModel
from typing import Optional, List
from decimal import Decimal
class DependentInfo(BaseModel):
"""扶養親族情報"""
name: str
name_kana: Optional[str] = None
relationship: Optional[str] = None
birth_date: Optional[str] = None
is_spouse: bool = False
is_disabled: bool = False
income_amount: Decimal = Decimal("0")
my_number: Optional[str] = None
deduction_type: Optional[str] = None # 一般/特定/老人(同居)/老人(その他)
deduction_amount: Decimal = Decimal("0")
class WithholdingSlipData(BaseModel):
"""源泉徴収票データ"""
# 従業員情報
employee_id: int
employee_code: str
employee_name: str
employee_name_kana: Optional[str] = None
employee_address: Optional[str] = None
employee_birth_date: Optional[str] = None
my_number: Optional[str] = None
# 対象年
tax_year: int
# 支払金額
total_payment: Decimal = Decimal("0") # 年間総支給額(通勤手当含む)
total_payment_excl_commute: Decimal = Decimal("0") # 通勤手当を除く支払金額(源泉徴収票記載用)
# 給与所得控除後の金額
kyuyo_shotoku: Decimal = Decimal("0")
# 社会保険料等の金額
social_insurance_total: Decimal = Decimal("0")
health_insurance_total: Decimal = Decimal("0")
care_insurance_total: Decimal = Decimal("0")
pension_total: Decimal = Decimal("0")
employment_insurance_total: Decimal = Decimal("0")
# 基礎控除
basic_deduction: Decimal = Decimal("0")
# 配偶者控除
has_spouse_deduction: bool = False
spouse_deduction_amount: Decimal = Decimal("0")
spouse_name: Optional[str] = None
spouse_name_kana: Optional[str] = None
spouse_income: Decimal = Decimal("0")
spouse_deduction_type: Optional[str] = None # 配偶者控除/配偶者特別控除
# 扶養親族
dependents_over16: List[DependentInfo] = [] # 16歳以上控除対象扶養親族
dependents_under16: List[DependentInfo] = [] # 16歳未満扶養親族
dependent_deduction_total: Decimal = Decimal("0")
# 所得控除の額の合計額
total_deduction_amount: Decimal = Decimal("0")
# 源泉徴収税額
income_tax_withheld: Decimal = Decimal("0")
# 月数内訳
months_with_salary: int = 0
months_with_bonus: int = 0
# 摘要
remarks: Optional[str] = None
class WithholdingSlipRequest(BaseModel):
"""源泉徴収票生成リクエスト"""
tax_year: int
employee_ids: List[int]
company_name: Optional[str] = None
company_address: Optional[str] = None
company_phone: Optional[str] = None
company_my_number: Optional[str] = None
class WithholdingSlipResponse(BaseModel):
"""源泉徴収票レスポンス"""
tax_year: int
slips: List[WithholdingSlipData]
total_count: int

View File

@@ -0,0 +1,388 @@
"""
給与所得の源泉徴収票 計算サービス
"""
from decimal import Decimal, ROUND_DOWN
from datetime import date
from typing import List, Optional, Dict, Any
import logging
from app.core.database import get_connection
from .schemas import WithholdingSlipData, DependentInfo
logger = logging.getLogger(__name__)
# 通勤手当の非課税限度額月額150,000円
COMMUTE_TAX_FREE_MONTHLY = Decimal("150000")
def calc_kyuyo_shotoku(annual_income: Decimal) -> Decimal:
"""
給与所得控除後の金額を計算する(年末調整用)
給与所得控除額を差し引いた後、1円未満切り捨て
"""
income = int(annual_income)
if income <= 550_000:
# 給与所得は0
return Decimal("0")
elif income <= 1_625_000:
kojo = 550_000
elif income <= 1_800_000:
# income * 40% - 100,000 だが1円単位で計算
kojo = int(income * 0.4) - 100_000
elif income <= 3_600_000:
kojo = int(income * 0.3) + 80_000
elif income <= 6_600_000:
kojo = int(income * 0.2) + 440_000
elif income <= 8_500_000:
kojo = int(income * 0.1) + 1_100_000
else:
kojo = 1_950_000
shotoku = max(0, income - kojo)
return Decimal(str(shotoku))
def calc_basic_deduction(tax_year: int) -> Decimal:
"""
基礎控除額を計算する
令和7年(2025年)以降: 580,000円
令和2年(2020年)〜令和6年(2024年): 480,000円
"""
if tax_year >= 2025:
return Decimal("580000")
elif tax_year >= 2020:
return Decimal("480000")
else:
return Decimal("380000")
def get_age_at_year_end(birth_date: date, tax_year: int) -> int:
"""その年の12月31日時点の年齢を計算"""
year_end = date(tax_year, 12, 31)
age = year_end.year - birth_date.year
if (year_end.month, year_end.day) < (birth_date.month, birth_date.day):
age -= 1
return age
def calc_dependent_deduction(dep: Dict[str, Any], tax_year: int) -> tuple:
"""
扶養控除額を計算する
Returns: (deduction_type, deduction_amount)
"""
birth_date = dep.get("birth_date")
if birth_date is None:
return ("一般", Decimal("380000"))
age = get_age_at_year_end(birth_date, tax_year)
if age >= 70:
# 老人扶養親族 (同居老親等かどうかは不明なのでその他として扱う)
return ("老人扶養", Decimal("480000"))
elif 19 <= age <= 22:
# 特定扶養親族
return ("特定扶養", Decimal("630000"))
elif age >= 16:
# 一般扶養親族
return ("一般扶養", Decimal("380000"))
else:
# 16歳未満 - 控除なし
return ("年少", Decimal("0"))
def calc_spouse_deduction(spouse_income: Decimal, employee_income: Decimal, spouse_age: Optional[int]) -> tuple:
"""
配偶者控除・配偶者特別控除を計算する
Returns: (deduction_type, deduction_amount)
"""
spouse_inc = int(spouse_income)
emp_inc = int(employee_income)
# 納税者の合計所得金額が1,000万円超の場合は配偶者控除なし
# 給与所得として概算 (ここでは支払金額から控除後を算出)
emp_shotoku = int(calc_kyuyo_shotoku(employee_income))
if emp_shotoku > 10_000_000:
return (None, Decimal("0"))
# 配偶者の合計所得が48万円収入103万円相当以下 → 配偶者控除
if spouse_inc <= 480_000:
if spouse_age and spouse_age >= 70:
return ("老人配偶者控除", Decimal("480000"))
return ("配偶者控除", Decimal("380000"))
# 配偶者特別控除(配偶者の合計所得 48万1円〜133万円
elif spouse_inc <= 1_330_000:
# 段階的な配偶者特別控除
if spouse_inc <= 950_000:
return ("配偶者特別控除", Decimal("380000"))
elif spouse_inc <= 1_000_000:
return ("配偶者特別控除", Decimal("360000"))
elif spouse_inc <= 1_050_000:
return ("配偶者特別控除", Decimal("310000"))
elif spouse_inc <= 1_100_000:
return ("配偶者特別控除", Decimal("260000"))
elif spouse_inc <= 1_150_000:
return ("配偶者特別控除", Decimal("210000"))
elif spouse_inc <= 1_200_000:
return ("配偶者特別控除", Decimal("160000"))
elif spouse_inc <= 1_250_000:
return ("配偶者特別控除", Decimal("110000"))
elif spouse_inc <= 1_300_000:
return ("配偶者特別控除", Decimal("60000"))
else:
return ("配偶者特別控除", Decimal("30000"))
else:
return (None, Decimal("0"))
def get_withholding_slip_data(tax_year: int, employee_id: int) -> Optional[WithholdingSlipData]:
"""
指定年度・従業員の源泉徴収票データを計算して返す
"""
with get_connection() as conn:
with conn.cursor() as cur:
# 1. 従業員情報を取得
cur.execute(
"""
SELECT employee_id, employee_code, name, name_kana,
address, birth_date, my_number
FROM employees
WHERE employee_id = %s
""",
(employee_id,)
)
emp = cur.fetchone()
if not emp:
return None
# 2. 月次給与データを集計(その年のすべての月)
cur.execute(
"""
SELECT
COUNT(*) as month_count,
COALESCE(SUM(total_payment), 0) as total_payment,
COALESCE(SUM(commute_allowance), 0) as total_commute,
COALESCE(SUM(health_insurance), 0) as health_ins,
COALESCE(SUM(care_insurance), 0) as care_ins,
COALESCE(SUM(pension_insurance), 0) as pension_ins,
COALESCE(SUM(employment_insurance), 0) as emp_ins,
COALESCE(SUM(income_tax), 0) as income_tax
FROM monthly_payroll
WHERE employee_id = %s
AND payroll_year = %s
""",
(employee_id, tax_year)
)
salary_summary = cur.fetchone()
# 3. 賞与データを集計(その年のすべての賞与)
cur.execute(
"""
SELECT
COUNT(*) as bonus_count,
COALESCE(SUM(total_bonus), 0) as total_bonus,
COALESCE(SUM(health_insurance), 0) as health_ins,
COALESCE(SUM(care_insurance), 0) as care_ins,
COALESCE(SUM(pension_insurance), 0) as pension_ins,
COALESCE(SUM(employment_insurance), 0) as emp_ins,
COALESCE(SUM(income_tax), 0) as income_tax
FROM bonus_payments
WHERE employee_id = %s
AND bonus_year = %s
""",
(employee_id, tax_year)
)
bonus_summary = cur.fetchone()
# 4. 扶養親族情報を取得その年の12月31日時点で有効なもの
cur.execute(
"""
SELECT d.dependent_id, d.name, d.relationship, d.birth_date,
d.is_spouse, d.is_disabled, d.income_amount, d.mynumber
FROM dependents d
WHERE d.employee_id = %s
AND d.valid_from <= %s
AND (d.valid_to IS NULL OR d.valid_to >= %s)
ORDER BY d.is_spouse DESC, d.birth_date
""",
(employee_id,
date(tax_year, 12, 31),
date(tax_year, 12, 31))
)
dep_rows = cur.fetchall()
# 5. 集計データを整理
s = salary_summary or {}
b = bonus_summary or {}
def to_dec(v) -> Decimal:
if v is None:
return Decimal("0")
return Decimal(str(v))
salary_total = to_dec(s.get("total_payment", 0))
salary_commute = to_dec(s.get("total_commute", 0))
bonus_total = to_dec(b.get("total_bonus", 0))
# 通勤手当の非課税額月150,000円上限は支払金額から除外
# 簡易計算:通勤手当全額を非課税として除外(厳密には上限チェックが必要)
commute_taxable = Decimal("0") # 通勤手当は非課税として全額除外
# 源泉徴収票の「支払金額」= 給与+賞与の総支給額(通勤手当を除く)
# 実務上は通勤手当を除いた金額を記載することが多い
total_pay_for_slip = salary_total - salary_commute + bonus_total
# 社会保険料合計
health_ins = to_dec(s.get("health_ins", 0)) + to_dec(b.get("health_ins", 0))
care_ins = to_dec(s.get("care_ins", 0)) + to_dec(b.get("care_ins", 0))
pension_ins = to_dec(s.get("pension_ins", 0)) + to_dec(b.get("pension_ins", 0))
emp_ins = to_dec(s.get("emp_ins", 0)) + to_dec(b.get("emp_ins", 0))
social_ins_total = health_ins + care_ins + pension_ins + emp_ins
# 源泉徴収税額合計
income_tax_total = to_dec(s.get("income_tax", 0)) + to_dec(b.get("income_tax", 0))
# 6. 給与所得控除後の金額を計算
kyuyo_shotoku = calc_kyuyo_shotoku(total_pay_for_slip)
# 7. 基礎控除
basic_deduction = calc_basic_deduction(tax_year)
# 8. 扶養親族の処理
birth_date_emp = emp.get("birth_date") if isinstance(emp, dict) else None
dependents_over16 = []
dependents_under16 = []
dependent_deduction_total = Decimal("0")
spouse_info = None
spouse_deduction_amount = Decimal("0")
has_spouse_deduction = False
spouse_deduction_type = None
for dep in dep_rows:
if isinstance(dep, dict):
d = dep
else:
d = {
"dependent_id": dep[0], "name": dep[1], "relationship": dep[2],
"birth_date": dep[3], "is_spouse": dep[4], "is_disabled": dep[5],
"income_amount": dep[6], "mynumber": dep[7]
}
dep_birth = d.get("birth_date")
dep_age = get_age_at_year_end(dep_birth, tax_year) if dep_birth else None
dep_income = to_dec(d.get("income_amount", 0))
if d.get("is_spouse"):
# 配偶者処理
spouse_age = dep_age
sp_type, sp_amount = calc_spouse_deduction(dep_income, total_pay_for_slip, spouse_age)
if sp_type:
has_spouse_deduction = True
spouse_deduction_amount = sp_amount
spouse_deduction_type = sp_type
spouse_deduction_total = sp_amount
else:
spouse_deduction_total = Decimal("0")
spouse_info = DependentInfo(
name=d.get("name", ""),
relationship="配偶者",
birth_date=str(dep_birth) if dep_birth else None,
is_spouse=True,
is_disabled=bool(d.get("is_disabled", False)),
income_amount=dep_income,
my_number=d.get("mynumber"),
deduction_type=sp_type,
deduction_amount=spouse_deduction_amount
)
else:
# 扶養親族
dep_type, dep_amount = calc_dependent_deduction(d, tax_year)
dep_obj = DependentInfo(
name=d.get("name", ""),
relationship=d.get("relationship", ""),
birth_date=str(dep_birth) if dep_birth else None,
is_spouse=False,
is_disabled=bool(d.get("is_disabled", False)),
income_amount=dep_income,
my_number=d.get("mynumber"),
deduction_type=dep_type,
deduction_amount=dep_amount
)
if dep_age is not None and dep_age < 16:
dependents_under16.append(dep_obj)
else:
dependents_over16.append(dep_obj)
dependent_deduction_total += dep_amount
# 9. 所得控除の額の合計額
total_deduction_amount = (
social_ins_total
+ basic_deduction
+ spouse_deduction_amount
+ dependent_deduction_total
)
# 10. 摘要(社会保険料の内訳)
remarks_parts = []
if health_ins > 0:
remarks_parts.append(f"健康保険料{int(health_ins):,}")
if care_ins > 0:
remarks_parts.append(f"介護保険料{int(care_ins):,}")
if pension_ins > 0:
remarks_parts.append(f"厚生年金{int(pension_ins):,}")
if emp_ins > 0:
remarks_parts.append(f"雇用保険{int(emp_ins):,}")
remarks = " ".join(remarks_parts) if remarks_parts else None
months_with_salary = int(s.get("month_count", 0)) if s else 0
months_with_bonus = int(b.get("bonus_count", 0)) if b else 0
return WithholdingSlipData(
employee_id=emp.get("employee_id") if isinstance(emp, dict) else emp[0],
employee_code=emp.get("employee_code", "") if isinstance(emp, dict) else emp[1],
employee_name=emp.get("name", "") if isinstance(emp, dict) else emp[2],
employee_name_kana=emp.get("name_kana") if isinstance(emp, dict) else emp[3],
employee_address=emp.get("address") if isinstance(emp, dict) else emp[4],
employee_birth_date=str(emp.get("birth_date")) if (isinstance(emp, dict) and emp.get("birth_date")) else None,
my_number=emp.get("my_number") if isinstance(emp, dict) else emp[6],
tax_year=tax_year,
total_payment=salary_total + bonus_total,
total_payment_excl_commute=total_pay_for_slip,
kyuyo_shotoku=kyuyo_shotoku,
social_insurance_total=social_ins_total,
health_insurance_total=health_ins,
care_insurance_total=care_ins,
pension_total=pension_ins,
employment_insurance_total=emp_ins,
basic_deduction=basic_deduction,
has_spouse_deduction=has_spouse_deduction,
spouse_deduction_amount=spouse_deduction_amount,
spouse_name=spouse_info.name if spouse_info else None,
spouse_name_kana=spouse_info.name_kana if spouse_info else None,
spouse_income=spouse_info.income_amount if spouse_info else Decimal("0"),
spouse_deduction_type=spouse_deduction_type,
dependents_over16=dependents_over16,
dependents_under16=dependents_under16,
dependent_deduction_total=dependent_deduction_total,
total_deduction_amount=total_deduction_amount,
income_tax_withheld=income_tax_total,
months_with_salary=months_with_salary,
months_with_bonus=months_with_bonus,
remarks=remarks,
)
def get_withholding_slips(tax_year: int, employee_ids: List[int]) -> List[WithholdingSlipData]:
"""複数従業員の源泉徴収票データを取得"""
results = []
for eid in employee_ids:
try:
slip = get_withholding_slip_data(tax_year, eid)
if slip:
results.append(slip)
except Exception as e:
logger.error(f"源泉徴収票計算エラー employee_id={eid}: {e}")
return results

View File

@@ -97,3 +97,53 @@ def get_trial_balance(
rows = cur.fetchall() rows = cur.fetchall()
return rows 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

View File

@@ -6,6 +6,7 @@ colorama==0.4.6
email-validator==2.3.0 email-validator==2.3.0
fastapi==0.124.4 fastapi==0.124.4
h11==0.16.0 h11==0.16.0
httpx==0.28.1
idna==3.11 idna==3.11
openpyxl==3.1.2 openpyxl==3.1.2
psycopg==3.3.2 psycopg==3.3.2
@@ -20,3 +21,4 @@ typing_extensions==4.15.0
tzdata==2025.3 tzdata==2025.3
uvicorn==0.38.0 uvicorn==0.38.0
xlrd==2.0.1 xlrd==2.0.1
playwright==1.44.0

51
check_openapi.py Normal file
View File

@@ -0,0 +1,51 @@
import json
with open(r'docs/openapi_検証.json', encoding='utf-8') as f:
api = json.load(f)
def resolve_ref(ref):
parts = ref.lstrip('#/').split('/')
obj = api
for p in parts:
obj = obj[p]
return obj
def resolve_schema(schema, depth=0):
if depth > 3:
return schema
if '$ref' in schema:
return resolve_schema(resolve_ref(schema['$ref']), depth+1)
if schema.get('type') == 'array' and 'items' in schema:
schema = dict(schema)
schema['items'] = resolve_schema(schema['items'], depth+1)
if 'properties' in schema:
schema = dict(schema)
schema['properties'] = {k: resolve_schema(v, depth+1) for k, v in schema['properties'].items()}
return schema
# /post/lists の200レスポンス
resp = api['paths']['/post/lists']['get']['responses']['200']
schema = resolve_schema(resp['content']['application/json']['schema'])
print('=== GET /post/lists Response 200 ===')
print(json.dumps(schema, ensure_ascii=False, indent=2)[:3000])
print()
# /post/{post_id} の200レスポンス
resp2 = api['paths']['/post/{post_id}']['get']['responses']['200']
schema2 = resolve_schema(resp2['content']['application/json']['schema'])
print('=== GET /post/{post_id} Response 200 ===')
print(json.dumps(schema2, ensure_ascii=False, indent=2)[:4000])
print()
# serversの確認
print('=== Servers ===')
print(json.dumps(api.get('servers', []), ensure_ascii=False, indent=2))
# componentsのparametersでよく使うものを確認
print()
print('=== Key Parameters ===')
for key in ['Authorization', 'date_from', 'date_to', 'limit_50', 'offset']:
param = api['components']['parameters'].get(key, {})
print(f'{key}: in={param.get("in")}, name={param.get("name")}, schema={param.get("schema")}')

63
create_bank_table.py Normal file
View File

@@ -0,0 +1,63 @@
"""銀行明細テーブルを直接作成する"""
import paramiko
HOST = "192.168.0.61"
USER = "root"
PASS = "59911784"
SQL = """
CREATE TABLE IF NOT EXISTS bank_statement_rows (
id SERIAL PRIMARY KEY,
tx_date DATE NOT NULL,
description TEXT NOT NULL DEFAULT '',
debit BIGINT NOT NULL DEFAULT 0,
credit BIGINT NOT NULL DEFAULT 0,
balance BIGINT,
memo TEXT NOT NULL DEFAULT '',
bank_name TEXT NOT NULL DEFAULT '',
import_batch_id TEXT NOT NULL,
row_hash TEXT NOT NULL,
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_bank_stmt_hash UNIQUE (row_hash)
);
CREATE INDEX IF NOT EXISTS idx_bank_stmt_date ON bank_statement_rows (tx_date);
CREATE INDEX IF NOT EXISTS idx_bank_stmt_batch ON bank_statement_rows (import_batch_id);
"""
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(HOST, username=USER, password=PASS, timeout=15)
cmd = f"""docker exec njts-accounting-backend python -c "
import os, psycopg
conn = psycopg.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'))
cur = conn.cursor()
cur.execute(\\\"\\\"\\\"
CREATE TABLE IF NOT EXISTS bank_statement_rows (
id SERIAL PRIMARY KEY,
tx_date DATE NOT NULL,
description TEXT NOT NULL DEFAULT '',
debit BIGINT NOT NULL DEFAULT 0,
credit BIGINT NOT NULL DEFAULT 0,
balance BIGINT,
memo TEXT NOT NULL DEFAULT '',
bank_name TEXT NOT NULL DEFAULT '',
import_batch_id TEXT NOT NULL,
row_hash TEXT NOT NULL,
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_bank_stmt_hash UNIQUE (row_hash)
)
\\\"\\\"\\\")
cur.execute('CREATE INDEX IF NOT EXISTS idx_bank_stmt_date ON bank_statement_rows (tx_date)')
cur.execute('CREATE INDEX IF NOT EXISTS idx_bank_stmt_batch ON bank_statement_rows (import_batch_id)')
conn.commit()
print('テーブル作成完了')
conn.close()
"
"""
_, o, e = client.exec_command(cmd, timeout=30)
print(o.read().decode())
err = e.read().decode()
if err: print("STDERR:", err)
client.close()

21
docker-compose.nas.yml Normal file
View File

@@ -0,0 +1,21 @@
services:
backend:
build:
context: .
dockerfile: Dockerfile
container_name: njts-accounting-backend
working_dir: /app
volumes:
- ./backend:/app
command: uvicorn app.main:app --host 0.0.0.0 --port 8000
ports:
- "18000:8000"
env_file:
- .env
environment:
DB_HOST: 192.168.0.61
DB_PORT: 55432
DB_NAME: njts_acct
DB_USER: njts_app
DB_PASSWORD: njts_app2025
restart: unless-stopped

View File

@@ -13,7 +13,7 @@ server {
} }
# Proxy all API requests to backend # Proxy all API requests to backend
location ~ ^/(accounts|journals|ledger|trial-balance|opening-balances|general-ledger|payroll|users|month-locks|year-locks|cash|file-upload) { location ~ ^/(accounts|journals|ledger|trial-balance|opening-balances|general-ledger|payroll|users|month-locks|year-locks|cash|file-upload|egov) {
proxy_pass http://backend:18080; proxy_pass http://backend:18080;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;

BIN
docs/07nen252 (1).xlsx Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,87 @@
<?xml version="1.0" encoding="UTF-8"?><?xml-stylesheet type="text/xsl" href="kagami.xsl" ?><DOC>
<FRONT>
<SECRECY>親展</SECRECY>
<STAMP></STAMP>
</FRONT>
<BODY ID="DOCBODY">
<STYLESHEET>kagami.xsl</STYLESHEET>
<DOCNO>202605200027318947</DOCNO>
<DATE>令和8年5月20日</DATE>
<TO>
<NAME/>
<HONORIFC/>
</TO>
<AUTHOR>
<NAME>日本年金機構理事長</NAME>
<AFF>日本年金機構</AFF>
</AUTHOR>
<TITLE>日本年金機構からのお知らせ</TITLE>
<MAINTXT>
<P>電子送付希望をいただいた賞与保険料算出内訳書を送付します。以下のファイルをご確認ください。</P>
<P>なお、ファイルにはPDF形式が含まれる場合があります。</P>
</MAINTXT>
<APPENDIX>
<DOCLINK>賞与保険料算出内訳書_令和8年4月分(202605200027318947).xml</DOCLINK>
<APPTITLE>賞与保険料算出内訳書_令和8年4月分.xml</APPTITLE>
</APPENDIX>
<MAINTXT2>
<P>CSV形式のファイルを合わせて送付しますので、必要に応じてご活用ください。</P>
</MAINTXT2>
<APPENDIX2>
<DOCLINK>賞与保険料算出内訳書_令和8年4月分_1_ヘッダー(202605200027318947).csv</DOCLINK>
<APPTITLE>賞与保険料算出内訳書_令和8年4月分_1_ヘッダー.csv</APPTITLE>
</APPENDIX2>
<APPENDIX2>
<DOCLINK>賞与保険料算出内訳書_令和8年4月分_2_内訳(202605200027318947).csv</DOCLINK>
<APPTITLE>賞与保険料算出内訳書_令和8年4月分_2_内訳.csv</APPTITLE>
</APPENDIX2>
<MAINTXT3>
<P>【事業主の皆さまへのご案内】&lt;br/&gt;&lt;br/&gt;オンライン事業所年金情報サービスでは、社会保険料を口座振替で納付している事業主の方に「保険料納入告知額・領収済額通知書」の電子送付をしていますので、ぜひこの機会に電子送付の希望登録をお願いします。&lt;br/&gt;&lt;br/&gt;また、オンライン事業所年金情報サービスを利用した被保険者データの受け取りは、これまで算定月・賞与支払予定月のみ可能でしたが、令和7年1月から希望する月で被保険者データを受け取ることができるようになりました。&lt;br/&gt;&lt;br/&gt;詳細については、日本年金機構ホームページをご覧ください。&lt;br/&gt;&lt;a href="https://www.nenkin.go.jp/tokusetsu/online_jigyousho.html"&gt;https://www.nenkin.go.jp/tokusetsu/online_jigyousho.html&lt;/a&gt;&lt;br/&gt;&lt;br/&gt;※上記は、既に保険料納入告知額・領収済額通知書の電子送付や被保険者データの送付希望月の登録を行っていただいている事業主の方にもご案内しています。</P>
</MAINTXT3>
</BODY>
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#" Id="mhlw.go.jp"><SignedInfo><CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/><SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/><Reference URI="#DOCBODY"><Transforms><Transform Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/></Transforms><DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/><DigestValue>kz6HKaSL017rn++5J8seilL/xWG8cAPeePZkufBJwuQ=</DigestValue></Reference><Reference URI="yoshiki_03_shoyo_002.xsl"><DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/><DigestValue>kIv90NbjStXFCBW8Xch7MB/B/u7ryi8JyNGlCWTxqpQ=</DigestValue></Reference><Reference URI="賞与保険料算出内訳書_令和8年4月分(202605200027318947).xml"><DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/><DigestValue>Nb2dzuuBriFQwaOFg1d4/MYPi25u944Wv2M2gVhuhpk=</DigestValue></Reference><Reference URI="賞与保険料算出内訳書_令和8年4月分_1_ヘッダー(202605200027318947).csv"><DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/><DigestValue>YEkZRD1HOwh5VgACxlE/WVJiDHjeOn90ow9QRUEf4uc=</DigestValue></Reference><Reference URI="賞与保険料算出内訳書_令和8年4月分_2_内訳(202605200027318947).csv"><DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/><DigestValue>PEzU/pDPTyMB6nn245NqWLWp6sBoLTkk3Q8ZwQ1uPIw=</DigestValue></Reference></SignedInfo><SignatureValue>qEcnefe23+rBZ+MpPw3qeEvePnoOJMtQyGJL/Bwjtaz+WPXVCdO6y+qPZJbjWetUo6iwi1TLrIwO&#13;
SUkctAOYG+tshpaDkMVhsP/LWKrLIuZS9aVHB9+XDr5wUUQzs/rcTEiVMf/v9RuZ3lFZ91uObHQW&#13;
RZuv3s27LMLgJm/3iJUWyLVjQV0jfHeG/qSIfJTihY68eId8zEVNdLu6FS/SeFX81UHJbhNOaFJH&#13;
9wveJyLcd8Sdqtw09V31cvgHo1O4mMyYCB+97OfIPk6eRUaw6groo1dGJIX+rhkq7NWC0SOoDRmT&#13;
hKRhC8epW/URlHa+uIvEtKomszXf2eCiJk1QQA==</SignatureValue><KeyInfo><X509Data><X509Certificate>MIIFKTCCBBGgAwIBAgIPODUzNDEzNDYzOTk4NzA5MA0GCSqGSIb3DQEBCwUAMEYxCzAJBgNVBAYT&#13;
AkpQMRwwGgYDVQQKDBNKYXBhbmVzZSBHb3Zlcm5tZW50MRkwFwYDVQQLDBBPZmZpY2lhbFN0YXR1&#13;
c0NBMB4XDTIyMDUzMTE1MDAwMFoXDTI3MDUzMTE0NTk1OVowgYkxCzAJBgNVBAYTAkpQMRwwGgYD&#13;
VQQKDBNKYXBhbmVzZSBHb3Zlcm5tZW50MS8wLQYDVQQLDCZNaW5pc3RyeSBvZiBIZWFsdGgsIExh&#13;
Ym91ciBhbmQgV2VsZmFyZTErMCkGA1UEAwwiUHJlc2lkZW50IG9mIEphcGFuIFBlbnNpb24gU2Vy&#13;
dmljZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALFcx67Yj+R7aDJLXozKT+D6ajGM&#13;
eSLL46Zu/KLrcADNR2/4/kvznDy+cC6ekizUtR3j9JPC0UHt1ECmUAvmyX/KKQToNfFRguqkH30X&#13;
nCm0LgE0yzoocreCLHb4Q5HL82ThM0hICUQRhE5/nKn6BPVEMVIi4WvkrF56LIhsxh9yiqbQzcMh&#13;
2gbKJ9qwbXlf2t57vR5HrXuQxVmzhwh2miI/8tGBDRte6xBmQQ9iMStx/M7pC2FWuULAThA9zIop&#13;
S7G8M2c9Quz3da7rfSWQX+Cr29G2geym2wGwBiZSLOr9Haua6kyT/38i6kWnjFz+V7f90ETGGIzP&#13;
0s4mEBboG/8CAwEAAaOCAc4wggHKMB8GA1UdIwQYMBaAFFIm8Y7mfjbpZKQk94zYbSJcqFWZMB0G&#13;
A1UdDgQWBBTtZSAS2Of86iDljw/HbL63ufMtezAOBgNVHQ8BAf8EBAMCBsAwVwYDVR0gAQH/BE0w&#13;
SzBJBgsCgziGjjEIAwEBbjA6MDgGCCsGAQUFBwIBFixodHRwczovL3d3dy5ncGtpLmdvLmpwL29z&#13;
Y2EvY3BjcHMvaW5kZXguaHRtbDB0BgNVHREEbTBrpGkwZzELMAkGA1UEBhMCSlAxGDAWBgNVBAoM&#13;
D+aXpeacrOWbveaUv+W6nDEYMBYGA1UECwwP5Y6a55Sf5Yq05YON55yBMSQwIgYDVQQDDBvml6Xm&#13;
nKzlubTph5HmqZ/mp4vnkIbkuovplbcwTgYDVR0SBEcwRaRDMEExCzAJBgNVBAYTAkpQMRgwFgYD&#13;
VQQKDA/ml6XmnKzlm73mlL/lupwxGDAWBgNVBAsMD+WumOiBt+iqjeiovOWxgDBZBgNVHR8EUjBQ&#13;
ME6gTKBKpEgwRjELMAkGA1UEBhMCSlAxHDAaBgNVBAoME0phcGFuZXNlIEdvdmVybm1lbnQxGTAX&#13;
BgNVBAsMEE9mZmljaWFsU3RhdHVzQ0EwDQYJKoZIhvcNAQELBQADggEBAJapGHTU0z30nXPccxUf&#13;
JHriV0vVs8RZaSm5gwXZKJDdsEG/pfjDYze28aNVzL496zqmCZED0XIdZlI5vfWR2QY0xUwzldYG&#13;
phx0pyG1OzlQN8W247S5XsJ3eYXqcgc0qO8399vtKSHVCa4OjvFH3kEn/IfmsxWBT+10roe4HyDK&#13;
ddkT1eMGu8ccCc8WCd2SoEwQmCERtV85+Jbf0AQ95opqGUQWxAT+0n6x1Gyi9ggKyU7ifonxGUOY&#13;
nvAEIb2U+rebwvNwRN8V9G2W/LNIkiV57/Hs6H8P8ugfoNGWeAU1pzD4MfC4zuJg8veLELs+G6Xh&#13;
cG+ewK5GiwhySdvKexs=</X509Certificate><X509Certificate>MIIEBDCCAuygAwIBAgIPNzI4MDQyMzYwNDg2MzczMA0GCSqGSIb3DQEBCwUAMEYxCzAJBgNVBAYT&#13;
AkpQMRwwGgYDVQQKDBNKYXBhbmVzZSBHb3Zlcm5tZW50MRkwFwYDVQQLDBBPZmZpY2lhbFN0YXR1&#13;
c0NBMB4XDTE5MDgyMzE1MDAwMFoXDTI5MDgyMzE0NTk1OVowRjELMAkGA1UEBhMCSlAxHDAaBgNV&#13;
BAoME0phcGFuZXNlIEdvdmVybm1lbnQxGTAXBgNVBAsMEE9mZmljaWFsU3RhdHVzQ0EwggEiMA0G&#13;
CSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDkaCKUqxDTGx50NrhjVYKvvAYTlrmidWexGpna7JXx&#13;
SQFi0umtGe904ZbZOwSld8gxXqRJCxLx64ZUtPG25hHgYnCfxhkIA+p3nomgkwGrauSHsdBU9co9&#13;
ioKUAHPWpsp1CR2EFhE13NBBX2anxnqxil1KvInGOOl/Hf9mJ5Jz85oLgZHWxMDtRrKL2fPgXZ72&#13;
+YHv5QsyqZwFy3J9qPnQueUM7lSoYNidlyiIyhbtC8/68OH513nnksv+w65qEuXJh/LNj/y0ruAB&#13;
V3EyjHbd4oWyf7HHD4JO8ITa5ABMt2kSc91IxaSeJDBvD0rUbf4bOSnshTyQaN/gJL7Wc4xZAgMB&#13;
AAGjge4wgeswHQYDVR0OBBYEFFIm8Y7mfjbpZKQk94zYbSJcqFWZMA4GA1UdDwEB/wQEAwIBBjBO&#13;
BgNVHREERzBFpEMwQTELMAkGA1UEBhMCSlAxGDAWBgNVBAoMD+aXpeacrOWbveaUv+W6nDEYMBYG&#13;
A1UECwwP5a6Y6IG36KqN6Ki85bGAMFkGA1UdHwRSMFAwTqBMoEqkSDBGMQswCQYDVQQGEwJKUDEc&#13;
MBoGA1UECgwTSmFwYW5lc2UgR292ZXJubWVudDEZMBcGA1UECwwQT2ZmaWNpYWxTdGF0dXNDQTAP&#13;
BgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQCjy1OYTCLtF1mViU3uLdA2BtbOH+qa&#13;
RqQkhJTT5Mbqblwhp+I1TaHwO7kT0Tnw74f4e2nkUd5ByMyodojFk3+/Xj1CMG56dYjDkC8GBR8+&#13;
QdrEzDF4o6nui9xqMfzyevMFasaRdMSyWpeSjCquMJjkgc5l691Rwn2ixoEUol+tfhwmKVkQttUq&#13;
Bwu5ofnwTF814RTMh4IJFQL4UloXRkRBduNh+xLvWf7sBjirI7FrLfkvkkz7YAS1ToGkEGSb+CU3&#13;
8GHXwH3oul8/5SUZriNXUexKwLqzCjjqUsca2bDDP8A7myX7zdMNKWMhweSbOpedzaR+gF2Lrsx4&#13;
wU0AP+AY</X509Certificate></X509Data></KeyInfo></Signature></DOC>

View File

@@ -0,0 +1,148 @@
<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" xmlns="http://www.w3.org/1999/xhtml" version="1.0">
<xsl:output method="xml" indent="yes" omit-xml-declaration="yes" doctype-public="-//W3C/DTD XHTML 1.0 Strict//EN" doctype-system="http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"/>
<xsl:template match="DOC">
<xsl:apply-templates select="BODY"/>
</xsl:template>
<xsl:template match="BODY">
<html>
<head>
<title><xsl:value-of select="TITLE"/></title>
</head>
<body>
<p align="right"><xsl:value-of select="DOCNO"/></p>
<xsl:if test="DATE!=''">
<p align="right">
<!--
<xsl:value-of select="substring(DATE&#x2C;1&#x2C;2)"/>
<xsl:value-of select="format-number(substring(DATE&#x2C;3&#x2C;2)&#x2C;'##')"/>
<xsl:value-of select="substring(DATE&#x2C;5&#x2C;1)"/>
<xsl:value-of select="format-number(substring(DATE&#x2C;6&#x2C;2)&#x2C;'##')"/>
<xsl:value-of select="substring(DATE&#x2C;8&#x2C;1)"/>
<xsl:value-of select="format-number(substring(DATE&#x2C;9&#x2C;2)&#x2C;'##')"/>
<xsl:value-of select="substring(DATE&#x2C;11&#x2C;1)"/>
-->
<xsl:value-of select="DATE"/>
</p>
</xsl:if>
<xsl:apply-templates select="TO"/>
<xsl:apply-templates select="AUTHOR"/>
<xsl:apply-templates select="TITLE"/>
<xsl:apply-templates select="MAINTXT"/>
<xsl:apply-templates select="APPENDIX"/>
<br/>
<br/>
<xsl:apply-templates select="MAINTXT2"/>
<xsl:apply-templates select="APPENDIX2"/>
<br/>
<br/>
<xsl:apply-templates select="MAINTXT3"/>
</body>
</html>
</xsl:template>
<xsl:template match="TO" >
<xsl:if test="AFF!='' and NAME!=''">
<p>
<xsl:value-of select="AFF" disable-output-escaping="no"/>
</p>
</xsl:if>
<xsl:if test="NAME!=''">
<p>
<xsl:value-of select="NAME" disable-output-escaping="no"/>
<xsl:if test="HONORIFC!='' and NAME!=''">
<xsl:value-of select="concat(' '&#x2C;HONORIFC)" />
</xsl:if>
</p>
</xsl:if>
</xsl:template>
<xsl:template match="AUTHOR" >
<p align="right">
<xsl:value-of select="AFF" />
</p>
<xsl:if test="NAME!=''">
<p align="right">
<xsl:value-of select="NAME" />
</p>
</xsl:if>
</xsl:template>
<xsl:template match="TITLE">
<p align="center">
<xsl:value-of select="."/>
</p>
</xsl:template>
<xsl:template match="MAINTXT">
<xsl:for-each select=".">
<xsl:apply-templates select="P"/>
</xsl:for-each>
</xsl:template>
<xsl:template match="MAINTXT2">
<xsl:for-each select=".">
<xsl:apply-templates select="P"/>
</xsl:for-each>
</xsl:template>
<xsl:template match="MAINTXT3">
<xsl:for-each select=".">
<xsl:apply-templates select="P"/>
</xsl:for-each>
</xsl:template>
<xsl:template match="P">
<p>
<xsl:value-of disable-output-escaping="yes" select="."/>
</p>
</xsl:template>
<xsl:template match="APPENDIX">
<xsl:for-each select=".">
<xsl:if test="DOCLINK">
<xsl:apply-templates select="DOCLINK"/>
<br/>
</xsl:if>
<xsl:if test="not(DOCLINK)">
<xsl:apply-templates select="APPTITLE"/>
<br/>
</xsl:if>
</xsl:for-each>
</xsl:template>
<xsl:template match="APPENDIX2">
<xsl:for-each select=".">
<xsl:apply-templates select="DOCLINK"/>
<br/>
</xsl:for-each>
</xsl:template>
<xsl:template match="DOCLINK">
<a>
<xsl:attribute name="href">
<xsl:value-of select="." />
</xsl:attribute>
<xsl:attribute name="target">_blank</xsl:attribute>
<xsl:value-of select="../APPTITLE" />
<xsl:value-of select="@REF" />
</a>
</xsl:template>
<xsl:template match="APPTITLE">
<span>
<xsl:attribute name="style">
font-weight:bold; text-decoration:underline;
</xsl:attribute>
<xsl:value-of select="." />
</span>
</xsl:template>
</xsl:stylesheet>

View File

@@ -0,0 +1,733 @@
<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:template match="ShouyoHokenRyouSanshutsuUchiwakeShoList">
<html>
<!-- HTMLヘッダ出力 -->
<xsl:call-template name="htmlHeader"/>
<body style="word-break:break-all;">
<xsl:variable name="itemLineNum" select="38"/>
<xsl:variable name="listCount" select="count(shouyoList)"/>
<!-- 表面出力 -->
<xsl:call-template name="frontPage">
<xsl:with-param name="listCount" select="$listCount"/>
<xsl:with-param name="itemLineNum" select="$itemLineNum"/>
</xsl:call-template>
<!-- 裏面出力 -->
<xsl:call-template name="backPage"/>
</body>
</html>
</xsl:template>
<xsl:template name="frontPage">
<xsl:param name="listNum" select="1"/>
<xsl:param name="listCount"/>
<xsl:param name="pageNum" select="1"/>
<xsl:param name="itemLineNum"/>
<xsl:if test="$listNum &lt;= $listCount">
<xsl:variable name="uchiwakeNum" select="count(shouyoList[$listNum]/uchiwake)"/>
<xsl:variable name="maxPage" select="ceiling($uchiwakeNum div $itemLineNum)"/>
<xsl:call-template name="frontList">
<xsl:with-param name="listNum" select="$listNum"/>
<xsl:with-param name="totalPageNum" select="$pageNum"/>
<xsl:with-param name="maxPage" select="$maxPage"/>
<xsl:with-param name="itemLineNum" select="$itemLineNum"/>
</xsl:call-template>
<xsl:call-template name="frontPage">
<xsl:with-param name="listNum" select="$listNum + 1"/>
<xsl:with-param name="listCount" select="$listCount"/>
<xsl:with-param name="pageNum" select="$pageNum + $maxPage"/>
<xsl:with-param name="itemLineNum" select="$itemLineNum"/>
</xsl:call-template>
</xsl:if>
</xsl:template>
<xsl:template name="frontList">
<xsl:param name="listNum"/>
<xsl:param name="pageNum" select="1"/>
<xsl:param name="totalPageNum"/>
<xsl:param name="maxPage"/>
<xsl:param name="itemLineNum"/>
<xsl:variable name="startItem" select="(($pageNum - 1) * $itemLineNum) + 1"/>
<xsl:variable name="endItem" select="$itemLineNum * $pageNum"/>
<xsl:if test="$pageNum &lt;= $maxPage">
<div>
<table class="Rterritory" cellpadding="3" cellspacing="0">
<colgroup>
<col width="975px" />
<col width="434px" />
<col width="40px" />
<col width="24px" />
<col width="16px" />
</colgroup>
<!-- ページ番号出力 -->
<xsl:variable name="outPageNum">
<xsl:choose>
<xsl:when test="shouyoList[$listNum]/uchiwake[$startItem]/pageNum and shouyoList[$listNum]/uchiwake[$startItem]/pageNum != ''"><xsl:value-of select="shouyoList[$listNum]/uchiwake[$startItem]/pageNum"/></xsl:when>
<xsl:when test="shouyoList[$listNum]/pageNum and shouyoList[$listNum]/pageNum != ''"><xsl:value-of select="shouyoList[$listNum]/pageNum"/></xsl:when>
<xsl:otherwise><xsl:value-of select="$totalPageNum"/></xsl:otherwise>
</xsl:choose>
</xsl:variable>
<xsl:call-template name="page">
<xsl:with-param name="outPageNum" select="$outPageNum"/>
</xsl:call-template>
<!-- お知らせ出力 -->
<xsl:call-template name="oshirase">
<xsl:with-param name="listNum" select="$listNum"/>
</xsl:call-template>
</table>
<!-- タイトル出力 -->
<xsl:call-template name="title">
<xsl:with-param name="listNum" select="$listNum"/>
</xsl:call-template>
<!-- 内訳出力 -->
<table class="uchiwake" cellpadding="0" cellspacing="0">
<colgroup>
<col width="85px"/>
<col width="75px"/>
<col width="60px"/>
<col width="200px"/>
<col width="90px"/>
<col width="70px"/>
<col width="70px"/>
<col width="160px"/>
<col width="160px"/>
<col width="160px"/>
<col width="160px"/>
</colgroup>
<xsl:call-template name="itemHeader"/>
<xsl:call-template name="itemBody">
<xsl:with-param name="listNum" select="$listNum"/>
<xsl:with-param name="line" select="$startItem"/>
<xsl:with-param name="end" select="$endItem"/>
</xsl:call-template>
</table>
</div>
<xsl:call-template name="frontList">
<xsl:with-param name="listNum" select="$listNum"/>
<xsl:with-param name="pageNum" select="$pageNum + 1"/>
<xsl:with-param name="totalPageNum" select="$totalPageNum + 1"/>
<xsl:with-param name="maxPage" select="$maxPage"/>
<xsl:with-param name="itemLineNum" select="$itemLineNum"/>
</xsl:call-template>
</xsl:if>
</xsl:template>
<xsl:template name="page">
<xsl:param name="outPageNum"/>
<tr>
<td align="right" colspan="3" style="font-size: 12px"><xsl:value-of select="$outPageNum"/></td>
<td align="right" style="font-size: 12px"></td>
<td></td>
</tr>
</xsl:template>
<xsl:template name="oshirase">
<xsl:param name="listNum"/>
<tr>
<td></td>
<td colspan="4" class="oshirase solid" style="height:115px;line-height:11px;">
<xsl:call-template name="loop">
<xsl:with-param name="str" select="shouyoList[$listNum]/header/oshirase"/>
</xsl:call-template>
</td>
</tr>
</xsl:template>
<xsl:template name="title">
<xsl:param name="listNum"/>
<xsl:variable name="header" select="shouyoList[$listNum]/header"/>
<table class="title" cellspacing="0" cellpadding="0">
<colgroup>
<col width="440px" />
<col width="150px" />
<col width="40px" />
<col width="418px" />
<col width="440px" />
</colgroup>
<tr>
<td></td>
<td>
<p class="txtJust"><span></span><span></span><span></span><span></span></p>
</td>
<td rowspan="2"></td>
<td class="mainTitle" rowspan="2">
<p class="txtJust"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></p>
</td>
<td align="right"><xsl:value-of select="$header/nenkinJimusho1"/></td>
</tr>
<tr>
<td></td>
<td>
<p class="txtJust"><span></span><span></span><span></span><span></span><span></span><span></span></p>
</td>
<td align="right"><xsl:value-of select="$header/nenkinJimusho2"/></td>
</tr>
</table>
<table class="header" cellpadding="0" cellspacing="0">
<colgroup>
<col width="14" span="2" />
<col width="15" span="6" />
<col width="14" span="13" />
<col width="14" span="14" />
<col width="14" span="14" />
<col width="14" span="13" />
<col width="14" span="11" />
<col width="14" span="9" />
<col width="14" span="13" />
<col width="14" span="11" />
</colgroup>
<tr>
<td colspan="3" align="right"><xsl:value-of select="$header/sakuseiNendoGengou"/></td>
<td colspan="2"><xsl:if test="$header/sakuseiNendo = '1'"></xsl:if><xsl:if test="$header/sakuseiNendo != '1'"><xsl:value-of select="$header/sakuseiNendo"/></xsl:if></td>
<td colspan="3" align="left">年度</td>
<td colspan="1">  </td>
<td colspan="3" align="right"><xsl:value-of select="$header/nouhuMokutekiYearGengou"/></td>
<td colspan="2" align="right"><xsl:if test="$header/nouhuMokutekiYear = '1'"></xsl:if><xsl:if test="$header/nouhuMokutekiYear != '1'"><xsl:value-of select="$header/nouhuMokutekiYear"/></xsl:if></td>
<td colspan="2"></td>
<td colspan="2"><xsl:value-of select="$header/nouhuMokutekiMonth"/></td>
<td colspan="3" align="left">月分</td>
<td colspan="6">事業所名</td>
<td colspan="32" align="left"><xsl:value-of select="$header/jigyoshoName"/></td>
<td colspan="10">事業所整理記号</td>
<td colspan="5"><xsl:value-of select="$header/jigyoshoSeiriKigo"/></td>
<td colspan="7">事業所番号</td>
<td colspan="4"><xsl:value-of select="$header/jigyoshoNum"/></td>
<td colspan="4">人員</td>
<td colspan="5"><xsl:value-of select="$header/jinInNum"/></td>
<td colspan="8">免除保険料率</td>
<td colspan="4"><xsl:value-of select="$header/menjyoHokenRyouRate"/></td>
</tr>
</table>
</xsl:template>
<xsl:template name="itemHeader">
<tr>
<td rowspan="2" class="midashiS_C allline">処理年月日</td>
<td rowspan="2" class="midashiS_C allline">整 理 番 号</td>
<td rowspan="2" class="midashiS_C allline">表 示</td>
<td rowspan="2" class="midashiS_C allline">氏      名</td>
<td rowspan="2" class="midashiS_C allline">発生年月日<br/><font class="small">(賞与支払年月日)</font></td>
<td colspan="2" class="midashiS_C allline">標 準 賞 与 額</td>
<td colspan="2" class="midashiS_C allline">健   康   保   険   料</td>
<td colspan="2" class="midashiS_C allline">厚  生  年  金  保  険  料</td>
</tr>
<tr>
<td class="midashiS_C allline">健保<font class="small">[千円]</font></td>
<td class="midashiS_C allline">厚年<font class="small">[千円]</font></td>
<td class="midashiS_C allline">本  月  額<font class="small">[円]</font></td>
<td class="midashiS_C allline">前 月 以 前 額<font class="small">[円]</font></td>
<td class="midashiS_C allline">本  月  額<font class="small">[円]</font></td>
<td class="midashiS_C allline">前 月 以 前 額<font class="small">[円]</font></td>
</tr>
</xsl:template>
<xsl:template name="itemBody">
<xsl:param name="listNum"/>
<xsl:param name="line" select="1"/>
<xsl:param name="end"/>
<xsl:if test="$end &gt;= $line">
<xsl:variable name="contents" select="shouyoList[$listNum]/uchiwake[$line]"/>
<tr>
<td class="normalM_C allline"><xsl:value-of select="$contents/shoriYMD" /></td>
<td class="normalM_C allline"><xsl:value-of select="$contents/seiriNum" /></td>
<td class="normalM_C allline">
<xsl:call-template name="halfSpace">
<xsl:with-param name="str" select="$contents/hyouji" />
</xsl:call-template>
</td>
<td class="normalM_C allline" align="left"><xsl:value-of select="$contents/shimei" /></td>
<td class="normalM_C allline"><xsl:value-of select="$contents/hyoujyunShouyoGaku/hasseiYMD" /></td>
<td class="normalM_C allline"><xsl:value-of select="$contents/hyoujyunShouyoGaku/getsuGakuKenpo" /></td>
<td class="normalM_C allline"><xsl:value-of select="$contents/hyoujyunShouyoGaku/getsuGakuKounen" /></td>
<td class="normalM_C allline" align="right"><xsl:if test="$contents/kenKouHokenRyou/hongetsuGaku != ''"><xsl:value-of select="format-number($contents/kenKouHokenRyou/hongetsuGaku, '###,###,###,##0.0')" /><xsl:text disable-output-escaping="yes">&amp;nbsp;&amp;nbsp;</xsl:text></xsl:if></td>
<td class="normalM_C allline" align="right"><xsl:if test="$contents/kenKouHokenRyou/zengetsuIzenKingaku != ''"><xsl:value-of select="format-number($contents/kenKouHokenRyou/zengetsuIzenKingaku, '###,###,###,##0.0')" /><xsl:text disable-output-escaping="yes">&amp;nbsp;&amp;nbsp;</xsl:text></xsl:if></td>
<td class="normalM_C allline" align="right"><xsl:if test="$contents/kouseiNenkinHokenRyou/hongetsuGaku != ''"><xsl:value-of select="format-number($contents/kouseiNenkinHokenRyou/hongetsuGaku, '###,###,###,##0.00')" /></xsl:if></td>
<td class="normalM_C allline" align="right"><xsl:if test="$contents/kouseiNenkinHokenRyou/zengetsuIzenKingaku != ''"><xsl:value-of select="format-number($contents/kouseiNenkinHokenRyou/zengetsuIzenKingaku, '###,###,###,##0.00')" /></xsl:if></td>
</tr>
<xsl:call-template name="itemBody">
<xsl:with-param name="listNum" select="$listNum"/>
<xsl:with-param name="line" select="$line + 1"/>
<xsl:with-param name="end" select="$end"/>
</xsl:call-template>
</xsl:if>
</xsl:template>
<xsl:template name="backPage">
<xsl:if test="uragamiFlg = 'true'">
<div class="ura">
<table class="ura title">
<colgroup>
<col width="330px" />
<col width="150px" />
<col width="40px" />
<col width="438px" />
<col width="330px" />
</colgroup>
<tr>
<td></td>
<td>
<p class="txtJust"><span></span><span></span><span></span><span></span></p>
</td>
<td rowspan="2"></td>
<td class="mainTitle" rowspan="2">
<p class="txtJust"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></p>
</td>
<td rowspan="2"></td>
</tr>
<tr>
<td></td>
<td>
<p class="txtJust"><span></span><span></span><span></span><span></span><span></span><span></span></p>
</td>
</tr>
</table>
<table class="ura">
<colgroup>
<col width="1288px" />
</colgroup>
<tr>
<td class="ura normalM_L">
1.表題の下には、年度、年月分、事業所名称、年金事務所名、事業所整理記号、事業所番号、人員及び頁を記入してあります。
<br/>  なお、事業所名称が長い時は25字まで記入してあります。また人員には保険料計算時の現存被保険者数(健保法118条該当者、厚年法適用除外者、育児休業取得者、
<br/>  産前産後休業取得者を含む。)を記入してあります。
</td>
</tr>
<tr>
<td class="ura normalM_L">
2.賞与保険料算出内訳書には、健康保険料と厚生年金保険料別に被保険者個人ごとの被保険者賞与支払届等をもとに算出した賞与保険料を記入してあります。
<br/>  なお、「二以上事業所勤務被保険者賞与保険料登録票」で登録された賞与保険料については出力されません。
</td>
</tr>
<tr>
<td class="ura">
<table>
<colgroup>
<col width="200px"/>
<col width="1088px"/>
</colgroup>
<tr>
<td class="ura normalM_L allline">(1)処理年月日</td>
<td class="ura normalM_L allline">「処理年月日」欄は年金事務所で事務処理上使用する欄です。</td>
</tr>
<tr>
<td class="ura normalM_L allline">(2)整理番号</td>
<td class="ura normalM_L allline">被保険者整理番号を記入してあります。</td>
</tr>
<tr>
<td class="ura normalM_L allline">(3)表示</td>
<td class="ura normalM_L allline">
①#:「随時保険料」の対象となった事を示してあります。<br/>
②2: 厚生年金保険法適用除外者を示してあります。<br/>
③3: 健康保険法適用除外者を示してあります。<br/>
④4: 介護保険料徴収者を示してあります。
</td>
</tr>
<tr>
<td class="ura normalM_L allline">(4)資格</td>
<td class="ura normalM_L allline">
①発生年月日 (賞与支払年月日):賞与が支払われた年月日を記入してあります。<br/>
②標準賞与額:発生年月日現在における標準賞与額を健康保険と厚生年金保険別に記入してあります。<br/>
</td>
</tr>
<tr>
<td class="ura normalM_L allline">(5)「健康保険料」及び「厚生年金保険料」の欄</td>
<td class="ura normalM_L allline">
①本 月 額:発生年月日(賞与支払年月日)が当月の賞与保険料(標準賞与額×保険料率)を、被保険者賞与支払届は符号なしで、被保険者<br/>
       賞与支払届(取消)は「-」を付して記入してあります。<br/>
②前月以前額:発生年月日(賞与支払年月日)が前月以前の賞与保険料(標準賞与額×保険料率)を、被保険者賞与支払届は符号なしで、被保<br/>
       険者賞与支払届(取消)は「-」を付して記入してあります。</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="ura normalM_L">
3.記載内容について、わからないことがあるときは管轄の年金事務所に照会して下さい。
</td>
</tr>
</table>
</div>
</xsl:if>
</xsl:template>
<xsl:template name="htmlHeader">
<head>
<META http-equiv="X-UA-Compatible" content="IE=11" />
<title>賞与保険料算出内訳書</title>
<style type="text/css">
<!-- reset.css Start -->
/* http://meyerweb.com/eric/tools/css/reset/
v2.0 | 20110126
License: none (public domain)
*/
html, body, div, span, applet, object, iframe,
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
a, abbr, acronym, address, big, cite, code,
del, dfn, em, img, ins, kbd, q, s, samp,
small, strike, strong, sub, sup, tt, var,
b, u, i, center,
dl, dt, dd, ol, ul, li,
fieldset, form, label, legend,
table, caption, tbody, tfoot, thead, tr, th, td,
article, aside, canvas, details, embed,
figure, figcaption, footer, header, hgroup,
menu, nav, output, ruby, section, summary,
time, mark, audio, video {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
/*vertical-align: baseline; */
}
/* HTML5 display-role reset for older browsers */
article, aside, details, figcaption, figure,
footer, header, hgroup, menu, nav, section {
display: block;
}
body {
line-height: 1;
}
ol, ul {
list-style: none;
}
blockquote, q {
quotes: none;
}
blockquote:before, blockquote:after,
q:before, q:after {
content: '';
content: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}
<!-- reset.css End -->
body {
font-family: "Yu Mincho", serif;
}
table.title {
border-collapse: collapse;
font-size: 16px;
text-align: right;
width: 1489px;
table-layout: fixed;
margin: 40px;
margin-top: 20px;
}
table.header {
border-collapse: collapse;
font-size: 12px;
text-align: right;
width: 1489px;
table-layout: fixed;
margin: 40px;
margin-bottom: 10px;
}
table.uchiwake {
border-collapse: collapse;
font-size: 12px;
text-align: right;
width: 1489px;
table-layout: fixed;
margin-left: 40px;
margin-right: 40px;
margin-bottom: 40px;
}
td {
padding-top: 2px;
padding-left: 5px;
padding-right: 5px;
}
table.ura {
border-collapse: collapse;
text-align: left;
width: 1288px;
}
/* テーブル属性 */
div { /*外枠*/
border: 1px solid;
width: 1560px;
height: 1075px;
margin: 50px;
margin-bottom: 100px;
}
div.ura {
width: 1360px;
padding-top: 50px;
padding-left: 100px;
padding-right: 100px;
}
td.solid { /* 全ての線を表示 */
border-top: 2px solid;
border-right: 2px solid;
border-bottom: 2px solid;
border-left: 2px solid;
}
td.tdTopLeft2px {
border-top: 2px solid;
border-right: 2px solid;
border-bottom: 2px solid;
border-left: 2px solid;
}
td.tdBottomLeft2px {
border-top: 2px solid;
border-right: 2px solid;
border-bottom: 2px solid;
border-left: 2px solid;
}
td.tdBottomRight2px {
border-right: 2px solid;
border-bottom: 2px solid;
border-left: 2px solid;
}
td.tdTopRight2px {
border-top: 2px solid;
border-right: 2px solid;
border-bottom: 2px solid;
border-left: 2px solid;
}
td.tdTop2px {
border-top: 2px solid;
border-right: 2px solid;
border-bottom: 2px solid;
border-left: 2px solid;
}
td.tdLeft2px {
border-top: 2px solid;
border-right: 2px solid;
border-bottom: 2px solid;
border-left: 2px solid;
}
td.tdLeftOnly2px {
border-left: 2px solid;
}
td.tdRightOnly2px {
border-right: 2px solid;
}
td.tdRight2px {
border-right: 2px solid;
border-left: 2px solid;
}
td.tdRight2pxSolid {
border-top: 2px solid;
border-right: 2px solid;
border-left: 2px solid;
border-bottom: 2px solid;
}
td.tdBottom2px {
border-top: none;
border-right: 2px solid;
border-bottom: 2px solid;
border-left: 2px solid;
}
td.tdTopBottomNone {
border-top: none; /* 上線なし */
border-bottom: none; /* 下線なし */
border-right: 2px solid;
border-left: 2px solid;
}
td.tdTop1px {
border-top: 1px solid; /* 上線1px */
}
td.tdBottom1px {
border-bottom: 1px solid; /* 下線1px */
}
td.allline {
border-top: 1px solid #000000;
border-left: 1px solid #000000;
border-right: 1px solid #000000;
border-bottom: 1px solid #000000;
}
td.linetb {
border-top: 1px solid #000000;
border-bottom: 1px solid #000000;
}
td.linetrb {
border-top: 1px solid #000000;
border-right: 1px solid #000000;
border-bottom: 1px solid #000000;
}
td.linetlb {
border-top: 1px solid #000000;
border-left: 1px solid #000000;
border-bottom: 1px solid #000000;
}
td.linetlr {
border-top: 1px solid #000000;
border-left: 1px solid #000000;
border-right: 1px solid #000000;
}
td.linelrb {
border-left: 1px solid #000000;
border-right: 1px solid #000000;
border-bottom: 1px solid #000000;
}
td.linetl {
border-top: 1px solid #000000;
border-left: 1px solid #000000;
}
td.linetr {
border-top: 1px solid #000000;
border-right: 1px solid #000000;
}
td.linerb {
border-right: 1px solid #000000;
border-bottom: 1px solid #000000;
}
td.linelb {
border-left: 1px solid #000000;
border-bottom: 1px solid #000000;
}
td.linet {
border-top: 1px solid #000000;
}
td.lineb {
border-bottom: 1px solid #000000;
}
td.liner {
border-right: 1px solid #000000;
}
td.linel {
border-left: 1px solid #000000;
}
td.linelr {
border-left: 1px solid #000000;
border-right: 1px solid #000000;
}
.mainTitle {
font-size: 28px;
vertical-align: middle;
}
.normalS_C {
font-size: 7pt;
}
.normalSS_C {
font-size: 6pt;
}
.normalSM_C {
font-size: 7.5pt;
}
.normalSM_TC {
font-size: 7.5pt;
vertical-align: top;
}
.normalML_C {
font-size: 8.5pt;
text-align: center;
}
.normalL_C {
font-size: 9pt;
}
.normalL_TL {
font-size: 9pt;
vertical-align : top;
text-align : left;
}
.normalL_TR {
font-size: 9pt;
vertical-align: top;
text-align: right;
}
.normalLL_LG {
font-size: 12pt;
text-align: left;
}
.txtJust {
display: flex;
justify-content: space-between;
}
/* 見出し項目 */
.oshirase {
font-size: 7.5pt;
line-height: 11px;
vertical-align: top;
text-align: left;
word-wrap: break-word;
overflow-wrap: break-word;
}
table.Rterritory {
width: 230px;
table-layout: fixed;
margin: 40px;
margin-top: 20px;
margin-bottom: 0px;
}
font.small {
font-size: 10px;
}
.midashiS_C { /* 見出し項目 */
font-size: 14px;
text-align: center;
height: 20px;
}
.headerM_C { /* ヘッダデータ(文字項目) */
font-size: 10pt;
text-align: center;
height: 50px;
}
.normalM_C { /* 明細データ(文字項目) */
font-size: 10pt;
height: 18px;
}
.ura {
font-size: 12pt;
line-height: 20px;
padding: 5px;
}
.ura_title {
font-size: 12pt;
}
.ura_normalM_C { /* 明細データ(文字項目) */
text-align: center;
}
</style>
</head>
</xsl:template>
<!--br対応-->
<xsl:template name="loop">
<xsl:param name="str"/>
<xsl:choose>
<xsl:when test="contains($str,'&lt;br/>')">
<xsl:variable name="sub" select="substring-before($str,'&lt;br/>')"/>
<xsl:value-of select="$sub" disable-output-escaping="yes" />
<br/>
<xsl:call-template name="loop">
<xsl:with-param name="str" select="substring-after($str,'&lt;br/>')"/>
</xsl:call-template>
</xsl:when>
<xsl:otherwise>
<xsl:value-of select="$str" disable-output-escaping="yes" />
</xsl:otherwise>
</xsl:choose>
</xsl:template>
<!--半角スペース対応-->
<xsl:template name="halfSpace">
<xsl:param name="str"/>
<xsl:choose>
<xsl:when test="contains($str,' ')">
<xsl:variable name="sub" select="substring-before($str,' ')"/>
<xsl:value-of select="$sub" disable-output-escaping="yes" />
<xsl:text disable-output-escaping="yes">&amp;nbsp;&amp;nbsp;</xsl:text>
<xsl:call-template name="halfSpace">
<xsl:with-param name="str" select="substring-after($str,' ')"/>
</xsl:call-template>
</xsl:when>
<xsl:otherwise>
<xsl:value-of select="$str" disable-output-escaping="yes" />
</xsl:otherwise>
</xsl:choose>
</xsl:template>
</xsl:stylesheet>

View File

@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?><?xml-stylesheet type="text/xsl" href="yoshiki_03_shoyo_002.xsl" ?>
<ShouyoHokenRyouSanshutsuUchiwakeShoList>
<shouyoList>
<header>
<jigyoshoName>新日本テクノソリューションズ 株式会社</jigyoshoName>
<jigyoshoNum>23090</jigyoshoNum>
<jigyoshoSeiriKigo>41-シサメ</jigyoshoSeiriKigo>
<jinInNum>2</jinInNum>
<nenkinJimusho2>   墨田    年金事務所</nenkinJimusho2>
<nouhuMokutekiMonth>4</nouhuMokutekiMonth>
<nouhuMokutekiYear>8</nouhuMokutekiYear>
<nouhuMokutekiYearGengou>令和</nouhuMokutekiYearGengou>
<oshirase>賞与保険料算出内訳書の概要や見方については日本年金機構ホームページをご確認ください。&lt;br/&gt;【概要・見方】&lt;br/&gt;&lt;a href="https://www.nenkin.go.jp/denshibenri/online_jigyousho/denshidata/tsuchisho.html#cms04"&gt;https://www.nenkin.go.jp/denshibenri/online_jigyousho/denshidata/tsuchisho.html#cms04&lt;/a&gt;</oshirase>
<pageNum>1</pageNum>
<sakuseiNendo>8</sakuseiNendo>
<sakuseiNendoGengou>令和</sakuseiNendoGengou>
</header>
<pageNum>1</pageNum>
<uchiwake>
<hyouji> 4</hyouji>
<hyoujyunShouyoGaku>
<getsuGakuKenpo>4300</getsuGakuKenpo>
<getsuGakuKounen>1500</getsuGakuKounen>
<hasseiYMD>R080430</hasseiYMD>
</hyoujyunShouyoGaku>
<kenKouHokenRyou>
<hongetsuGaku>503100.0</hongetsuGaku>
<zengetsuIzenMonth></zengetsuIzenMonth>
</kenKouHokenRyou>
<kouseiNenkinHokenRyou>
<hongetsuGaku>274500.00</hongetsuGaku>
<zengetsuIzenMonth></zengetsuIzenMonth>
</kouseiNenkinHokenRyou>
<pageNum>1</pageNum>
<seiriNum>2</seiriNum>
<shimei>張 翔鶴</shimei>
<shoriYMD>R080501</shoriYMD>
</uchiwake>
<uragamiFlg>false</uragamiFlg>
</shouyoList>
<uragamiFlg>true</uragamiFlg>
</ShouyoHokenRyouSanshutsuUchiwakeShoList>

View File

@@ -0,0 +1,2 @@
"ページ番号","機構からのお知らせ","年金事務所名1","年金事務所名2","作成年度元号","作成年度","納付目的年元号","納付目的年","納付目的月","事業所名称","事業所整理記号","事業所番号","人員","免除保険料率"
"1","賞与保険料算出内訳書の概要や見方については日本年金機構ホームページをご確認ください。<br/>【概要・見方】<br/><a href=""https://www.nenkin.go.jp/denshibenri/online_jigyousho/denshidata/tsuchisho.html#cms04"">https://www.nenkin.go.jp/denshibenri/online_jigyousho/denshidata/tsuchisho.html#cms04</a>","","   墨田    年金事務所","令和","8","令和","8","4","新日本テクノソリューションズ 株式会社","41-シサメ","23090","2",""
1 ページ番号 機構からのお知らせ 年金事務所名1 年金事務所名2 作成年度元号 作成年度 納付目的年元号 納付目的年 納付目的月 事業所名称 事業所整理記号 事業所番号 人員 免除保険料率
2 1 賞与保険料算出内訳書の概要や見方については日本年金機構ホームページをご確認ください。<br/>【概要・見方】<br/><a href="https://www.nenkin.go.jp/denshibenri/online_jigyousho/denshidata/tsuchisho.html#cms04">https://www.nenkin.go.jp/denshibenri/online_jigyousho/denshidata/tsuchisho.html#cms04</a>    墨田    年金事務所 令和 8 令和 8 4 新日本テクノソリューションズ 株式会社 41-シサメ 23090 2

View File

@@ -0,0 +1,2 @@
"納付目的年月","処理年月日","整理番号","表示","被保険者氏名","発生年月日(賞与支払年月日)","標準賞与額_健保","標準賞与額_厚年","健康保険料_本月額","健康保険料_前月以前額","厚生年金保険料_本月額","厚生年金保険料_前月以前額"
"令和8年4月","R080501","2"," 4","張 翔鶴","R080430","4300","1500","503,100.0","","274,500.00",""
1 納付目的年月 処理年月日 整理番号 表示 被保険者氏名 発生年月日(賞与支払年月日) 標準賞与額_健保 標準賞与額_厚年 健康保険料_本月額 健康保険料_前月以前額 厚生年金保険料_本月額 厚生年金保険料_前月以前額
2 令和8年4月 R080501 2 4 張 翔鶴 R080430 4300 1500 503,100.0 274,500.00

View File

@@ -0,0 +1,32 @@
"1","082","錦糸町支店","1","0","普通","0788058","新日本テクノソリューションズ 株式会社","2026.4.1-2026.5.31","全明細","2026.5.31","23:44","","",""
"2","2026.4.15","振込1","カ)ニユ-ソリユ-シヨンズ","0","630000","17158067"
"2","2026.4.20","口座振替3","チユウタイキヨウカケキン","41000","0","17117067"
"2","2026.4.28","口座振替3","ZHゼイリシホウシユウ","11000","0","17106067"
"2","2026.4.30","社会保険料","","247066","0","16859001"
"2","2026.4.30","振込1","スズヨシンワ-ト(カ","0","1606000","18465001"
"2","2026.4.30","振込BZ2","カ)エフケ-エス","170000","0","18295001"
"2","2026.4.30","為替手数料","フリコミ テスウリヨウ","660","0","18294341"
"2","2026.4.30","振込BZ1","オウ カク","353499","0","17940842"
"2","2026.4.30","為替手数料","フリコミ テスウリヨウ","330","0","17940512"
"2","2026.4.30","振込BZ1","チヨウ シヨウカク","4026825","0","13913687"
"2","2026.4.30","為替手数料","フリコミ テスウリヨウ","330","0","13913357"
"2","2026.5.1","カ-ド","","1000000","0","12913357"
"2","2026.5.7","カ-ド","","1000000","0","11913357"
"2","2026.5.15","振込1","カ)ニユ-ソリユ-シヨンズ","0","630000","12543357"
"2","2026.5.18","口座振替3","チユウタイキヨウカケキン","41000","0","12502357"
"2","2026.5.18","振込1","エンタツ (カ","0","4620000","17122357"
"2","2026.5.20","振込BZ1","オウ カク","2787776","0","14334581"
"2","2026.5.20","為替手数料","フリコミ テスウリヨウ","330","0","14334251"
"2","2026.5.28","口座振替3","ZHゼイリシホウシユウ","11000","0","14323251"
"2","2026.5.29","振込BZ2","カ)エフケ-エス","170000","0","14153251"
"2","2026.5.29","為替手数料","フリコミ テスウリヨウ","660","0","14152591"
"2","2026.5.29","振込BZ2","チヨウシヨウカク","350781","0","13801810"
"2","2026.5.29","為替手数料","フリコミ テスウリヨウ","660","0","13801150"
"2","2026.5.29","振込BZ1","オウ カク","353091","0","13448059"
"2","2026.5.29","為替手数料","フリコミ テスウリヨウ","330","0","13447729"
"2","2026.5.29","振込BZ2","エンタツ (カ","824340","0","12623389"
"2","2026.5.29","為替手数料","フリコミ テスウリヨウ","660","0","12622729"
"2","2026.5.29","振込1","スズヨシンワ-ト(カ","0","1694000","14316729"
"2","2026.5.29","振込1","エンタツ (カ","0","825000","15141729"
"8"
"9","23","6","","11391338","10005000","16528067","15141729"
1 1 082 錦糸町支店 1 0 普通 0788058 新日本テクノソリューションズ 株式会社 2026.4.1-2026.5.31 全明細 2026.5.31 23:44
2 2 2026.4.15 振込1 カ)ニユ-ソリユ-シヨンズ 0 630000 17158067
3 2 2026.4.20 口座振替3 チユウタイキヨウカケキン 41000 0 17117067
4 2 2026.4.28 口座振替3 ZHゼイリシホウシユウ 11000 0 17106067
5 2 2026.4.30 社会保険料 247066 0 16859001
6 2 2026.4.30 振込1 スズヨシンワ-ト(カ 0 1606000 18465001
7 2 2026.4.30 振込BZ2 カ)エフケ-エス 170000 0 18295001
8 2 2026.4.30 為替手数料 フリコミ テスウリヨウ 660 0 18294341
9 2 2026.4.30 振込BZ1 オウ カク 353499 0 17940842
10 2 2026.4.30 為替手数料 フリコミ テスウリヨウ 330 0 17940512
11 2 2026.4.30 振込BZ1 チヨウ シヨウカク 4026825 0 13913687
12 2 2026.4.30 為替手数料 フリコミ テスウリヨウ 330 0 13913357
13 2 2026.5.1 カ-ド 1000000 0 12913357
14 2 2026.5.7 カ-ド 1000000 0 11913357
15 2 2026.5.15 振込1 カ)ニユ-ソリユ-シヨンズ 0 630000 12543357
16 2 2026.5.18 口座振替3 チユウタイキヨウカケキン 41000 0 12502357
17 2 2026.5.18 振込1 エンタツ (カ 0 4620000 17122357
18 2 2026.5.20 振込BZ1 オウ カク 2787776 0 14334581
19 2 2026.5.20 為替手数料 フリコミ テスウリヨウ 330 0 14334251
20 2 2026.5.28 口座振替3 ZHゼイリシホウシユウ 11000 0 14323251
21 2 2026.5.29 振込BZ2 カ)エフケ-エス 170000 0 14153251
22 2 2026.5.29 為替手数料 フリコミ テスウリヨウ 660 0 14152591
23 2 2026.5.29 振込BZ2 チヨウシヨウカク 350781 0 13801810
24 2 2026.5.29 為替手数料 フリコミ テスウリヨウ 660 0 13801150
25 2 2026.5.29 振込BZ1 オウ カク 353091 0 13448059
26 2 2026.5.29 為替手数料 フリコミ テスウリヨウ 330 0 13447729
27 2 2026.5.29 振込BZ2 エンタツ (カ 824340 0 12623389
28 2 2026.5.29 為替手数料 フリコミ テスウリヨウ 660 0 12622729
29 2 2026.5.29 振込1 スズヨシンワ-ト(カ 0 1694000 14316729
30 2 2026.5.29 振込1 エンタツ (カ 0 825000 15141729
31 8
32 9 23 6 11391338 10005000 16528067 15141729

Binary file not shown.

Binary file not shown.

7384
docs/openapi.json Normal file

File diff suppressed because it is too large Load Diff

7384
docs/openapi_検証.json Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

BIN
egov_guide.pdf Normal file

Binary file not shown.

BIN
faq.pdf Normal file

Binary file not shown.

BIN
final_test_spec_post.xlsx Normal file

Binary file not shown.

View File

@@ -2,9 +2,11 @@
<html lang="ja"> <html lang="ja">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="js/auth.js"></script> <script src="js/auth.js"></script>
<title>科目マスタ</title> <title>科目マスタ</title>
<link rel="stylesheet" href="css/style.css" /> <link rel="stylesheet" href="css/style.css" />
<link rel="stylesheet" href="/css/mobile.css" />
</head> </head>
<body> <body>
<h1>科目マスタ</h1> <h1>科目マスタ</h1>

1208
frontend/bank-statement.html Normal file

File diff suppressed because it is too large Load Diff

62
frontend/chat/Caddyfile Normal file
View File

@@ -0,0 +1,62 @@
chat.njts.co.jp:2015 {
tls /certs/chat.njts.co.jp/fullchain.pem /certs/chat.njts.co.jp/key.pem
reverse_proxy rocketchat:3000
}
nas.njts.co.jp:2015 {
tls /certs/nas.njts.co.jp/fullchain.pem /certs/nas.njts.co.jp/key.pem
reverse_proxy 192.168.0.61:5666
}
synas.njts.co.jp:2015 {
tls /certs/synas.njts.co.jp/fullchain.pem /certs/synas.njts.co.jp/key.pem
reverse_proxy 192.168.0.193:5000
}
meet.njts.co.jp:2015 {
tls /certs/meet.njts.co.jp/fullchain.pem /certs/meet.njts.co.jp/key.pem
reverse_proxy jitsi-web-1:80
}
matrix.njts.co.jp:2015 {
tls /certs/matrix.njts.co.jp/fullchain.pem /certs/matrix.njts.co.jp/key.pem
reverse_proxy matrix-synapse:8008
}
element.njts.co.jp:2015 {
tls /certs/element.njts.co.jp/fullchain.pem /certs/element.njts.co.jp/key.pem
reverse_proxy element-web:80
}
router.njts.co.jp:2015 {
tls /certs/router.njts.co.jp/fullchain.pem /certs/router.njts.co.jp/key.pem
reverse_proxy 192.168.0.1:80
}
cmr.njts.co.jp:2015 {
basicauth {
zhangxianghe $2a$14$a00NihP7NRLzd3j5EvFa2uFYXHJ.NoWyCcgKA3NKpUG22607HN/Ri
}
tls /certs/cmr.njts.co.jp/fullchain.pem /certs/cmr.njts.co.jp/key.pem
reverse_proxy 192.168.0.61:5000
}
openclaw.njts.co.jp:2015 {
tls /certs/openclaw.njts.co.jp/fullchain.pem /certs/openclaw.njts.co.jp/key.pem
reverse_proxy 192.168.0.61:18789
}
chatai.njts.co.jp:2015 {
tls /certs/chatai.njts.co.jp/fullchain.pem /certs/chatai.njts.co.jp/key.pem
reverse_proxy 192.168.0.61:3000
}
sys.njts.co.jp:2015 {
tls /certs/sys.njts.co.jp/fullchain.pem /certs/sys.njts.co.jp/key.pem
reverse_proxy 192.168.0.61:18000
}

View File

@@ -0,0 +1 @@
{"apps":{"http":{"servers":{"srv0":{"listen":[":8443"],"routes":[{"handle":[{"handler":"subroute","routes":[{"handle":[{"handler":"reverse_proxy","upstreams":[{"dial":"rocketchat:3000"}]}]}]}],"match":[{"host":["chat.njts.co.jp"]}],"terminal":true}]}}}}}

View File

@@ -0,0 +1,5 @@
{
"status": "valid",
"termsOfServiceAgreed": true,
"location": "https://acme-staging-v02.api.letsencrypt.org/acme/acct/254799123"
}

View File

@@ -0,0 +1,5 @@
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIKj+HhAaYqX1lWm4mLP2Et8b5rxVmCuGgMZpfvT/KEN4oAoGCCqGSM49
AwEHoUQDQgAED9JJ499KtFgQS78wE5cxUs3iQoBq75s3my0knVRJah0H3VwwOtJq
vKMmgyX4vUT0fDTYfgrvrLALcJ9QT4Bx9Q==
-----END EC PRIVATE KEY-----

View File

@@ -0,0 +1,5 @@
{
"status": "valid",
"termsOfServiceAgreed": true,
"location": "https://acme-v02.api.letsencrypt.org/acme/acct/2926552736"
}

View File

@@ -0,0 +1,5 @@
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIDs4tIRZUhyrIoIgZSO6ZBAYg+HzW0l0j2OlVoMzJY6ioAoGCCqGSM49
AwEHoUQDQgAEv375la4g5tzk4LddVNaLZ7jBelpGzkADYERdZzELLLFL0NEciAa1
Lus7P6OxGoc30AsCw34co3SXMhWEp3gsAg==
-----END EC PRIVATE KEY-----

View File

@@ -0,0 +1 @@
268cd375-73e5-4ccb-88ca-c0051a74b1f2

View File

@@ -0,0 +1 @@
{"tls":{"timestamp":"2026-01-01T11:51:10.149840886Z","instance_id":"268cd375-73e5-4ccb-88ca-c0051a74b1f2"}}

View File

@@ -0,0 +1,68 @@
-----BEGIN CERTIFICATE-----
MIID9jCCA3ygAwIBAgIQcfzZIhLrRJHPhJLzeOAN5DAKBggqhkjOPQQDAzBLMQsw
CQYDVQQGEwJBVDEQMA4GA1UEChMHWmVyb1NTTDEqMCgGA1UEAxMhWmVyb1NTTCBF
Q0MgRG9tYWluIFNlY3VyZSBTaXRlIENBMB4XDTI1MTIzMTAwMDAwMFoXDTI2MDMz
MTIzNTk1OVowGjEYMBYGA1UEAxMPY2hhdC5uanRzLmNvLmpwMFkwEwYHKoZIzj0C
AQYIKoZIzj0DAQcDQgAEOp7tnlqOPWi6GswskLGaQ7J8pjU5YQjlNgPXVYr93VWT
Wp5oBt+Q1EPZoLSErJ05xzVgYTzzPf5Z92jk+crZsqOCAnEwggJtMB8GA1UdIwQY
MBaAFA9r5kvOOUeu9n6QHnnwMJGSyF+jMB0GA1UdDgQWBBQsF22yoIoKX6aCGL6f
2veSYHlq1TAOBgNVHQ8BAf8EBAMCB4AwDAYDVR0TAQH/BAIwADATBgNVHSUEDDAK
BggrBgEFBQcDATBJBgNVHSAEQjBAMDQGCysGAQQBsjEBAgJOMCUwIwYIKwYBBQUH
AgEWF2h0dHBzOi8vc2VjdGlnby5jb20vQ1BTMAgGBmeBDAECATCBiAYIKwYBBQUH
AQEEfDB6MEsGCCsGAQUFBzAChj9odHRwOi8vemVyb3NzbC5jcnQuc2VjdGlnby5j
b20vWmVyb1NTTEVDQ0RvbWFpblNlY3VyZVNpdGVDQS5jcnQwKwYIKwYBBQUHMAGG
H2h0dHA6Ly96ZXJvc3NsLm9jc3Auc2VjdGlnby5jb20wggEEBgorBgEEAdZ5AgQC
BIH1BIHyAPAAdgAOV5S8866pPjMbLJkHs/eQ35vCPXEyJd0hqSWsYcVOIQAAAZtz
uvUJAAAEAwBHMEUCIHjB8YtQ9aDlxSRut60eoDQ/h7fraLoPxLOEsBB/Hu/AAiEA
5USOxiL99VrBdF4HTcJoM2AomuA+MvmpfVcsr1bXUqQAdgDRbqmlaAd+ZjWgPzel
3bwDpTxBEhTUiBj16TGzI8uVBAAAAZtzuvXXAAAEAwBHMEUCIBGuQL/+LnZdckCW
jGHUMPwztijW/8gfzcK8j3ULmm6FAiEAzI2YGnTwt5rYKIVaVU9nZGuATFlx9gtB
iiz2Roppz9UwGgYDVR0RBBMwEYIPY2hhdC5uanRzLmNvLmpwMAoGCCqGSM49BAMD
A2gAMGUCMQDb3BnY/YT49vWBxF1/h4eX26Ayn+5AEzn7ZcDpSVl20pAwJsqRjn7f
hodKgvtO3Y0CMDAkUJFzHKY4VP2oVhtldYIgfveAauS3YYGFEY25NVnVI224NSt6
CiUqiJf/0Pjpdw==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIDhTCCAwygAwIBAgIQI7dt48G7KxpRlh4I6rdk6DAKBggqhkjOPQQDAzCBiDEL
MAkGA1UEBhMCVVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNl
eSBDaXR5MR4wHAYDVQQKExVUaGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMT
JVVTRVJUcnVzdCBFQ0MgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMjAwMTMw
MDAwMDAwWhcNMzAwMTI5MjM1OTU5WjBLMQswCQYDVQQGEwJBVDEQMA4GA1UEChMH
WmVyb1NTTDEqMCgGA1UEAxMhWmVyb1NTTCBFQ0MgRG9tYWluIFNlY3VyZSBTaXRl
IENBMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAENkFhFytTJe2qypTk1tpIV+9QuoRk
gte7BRvWHwYk9qUznYzn8QtVaGOCMBBfjWXsqqivl8q1hs4wAYl03uNOXgFu7iZ7
zFP6I6T3RB0+TR5fZqathfby47yOCZiAJI4go4IBdTCCAXEwHwYDVR0jBBgwFoAU
OuEJhtTPGcKWdnRJdtzgNcZjY5owHQYDVR0OBBYEFA9r5kvOOUeu9n6QHnnwMJGS
yF+jMA4GA1UdDwEB/wQEAwIBhjASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdJQQW
MBQGCCsGAQUFBwMBBggrBgEFBQcDAjAiBgNVHSAEGzAZMA0GCysGAQQBsjEBAgJO
MAgGBmeBDAECATBQBgNVHR8ESTBHMEWgQ6BBhj9odHRwOi8vY3JsLnVzZXJ0cnVz
dC5jb20vVVNFUlRydXN0RUNDQ2VydGlmaWNhdGlvbkF1dGhvcml0eS5jcmwwdgYI
KwYBBQUHAQEEajBoMD8GCCsGAQUFBzAChjNodHRwOi8vY3J0LnVzZXJ0cnVzdC5j
b20vVVNFUlRydXN0RUNDQWRkVHJ1c3RDQS5jcnQwJQYIKwYBBQUHMAGGGWh0dHA6
Ly9vY3NwLnVzZXJ0cnVzdC5jb20wCgYIKoZIzj0EAwMDZwAwZAIwJHBUDwHJQN3I
VNltVMrICMqYQ3TYP/TXqV9t8mG5cAomG2MwqIsxnL937Gewf6WIAjAlrauksO6N
UuDdDXyd330druJcZJx0+H5j5cFOYBaGsKdeGW7sCMaR2PsDFKGllas=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIID0zCCArugAwIBAgIQVmcdBOpPmUxvEIFHWdJ1lDANBgkqhkiG9w0BAQwFADB7
MQswCQYDVQQGEwJHQjEbMBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYD
VQQHDAdTYWxmb3JkMRowGAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEhMB8GA1UE
AwwYQUFBIENlcnRpZmljYXRlIFNlcnZpY2VzMB4XDTE5MDMxMjAwMDAwMFoXDTI4
MTIzMTIzNTk1OVowgYgxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpOZXcgSmVyc2V5
MRQwEgYDVQQHEwtKZXJzZXkgQ2l0eTEeMBwGA1UEChMVVGhlIFVTRVJUUlVTVCBO
ZXR3b3JrMS4wLAYDVQQDEyVVU0VSVHJ1c3QgRUNDIENlcnRpZmljYXRpb24gQXV0
aG9yaXR5MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEGqxUWqn5aCPnetUkb1PGWthL
q8bVttHmc3Gu3ZzWDGH926CJA7gFFOxXzu5dP+Ihs8731Ip54KODfi2X0GHE8Znc
JZFjq38wo7Rw4sehM5zzvy5cU7Ffs30yf4o043l5o4HyMIHvMB8GA1UdIwQYMBaA
FKARCiM+lvEH7OKvKe+CpX/QMKS0MB0GA1UdDgQWBBQ64QmG1M8ZwpZ2dEl23OA1
xmNjmjAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zARBgNVHSAECjAI
MAYGBFUdIAAwQwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5j
b20vQUFBQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNAYIKwYBBQUHAQEEKDAmMCQG
CCsGAQUFBzABhhhodHRwOi8vb2NzcC5jb21vZG9jYS5jb20wDQYJKoZIhvcNAQEM
BQADggEBABns652JLCALBIAdGN5CmXKZFjK9Dpx1WywV4ilAbe7/ctvbq5AfjJXy
ij0IckKJUAfiORVsAYfZFhr1wHUrxeZWEQff2Ji8fJ8ZOd+LygBkc7xGEJuTI42+
FsMuCIKchjN0djsoTI0DQoWz4rIjQtUfenVqGtF8qmchxDM6OW1TyaLtYiKou+JV
bJlsQ2uRl9EMC5MCHdK8aXdJ5htN978UeAOwproLtOGFfy/cQjutdAFI3tZs4RmY
CV4Ks2dH/hzg1cEo70qLRDEmBDeNiXQ2Lu+lIg+DdEmSx/cQwgwp+7e9un/jX9Wf
8qn0dNW44bOwgeThpWOjzOoEeJBuv/c=
-----END CERTIFICATE-----

View File

@@ -0,0 +1,5 @@
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIPjpQxKBjFFc8UvRnWDDKX2OP+ypSHXGYZ2cpRgPBP3koAoGCCqGSM49
AwEHoUQDQgAEOp7tnlqOPWi6GswskLGaQ7J8pjU5YQjlNgPXVYr93VWTWp5oBt+Q
1EPZoLSErJ05xzVgYTzzPf5Z92jk+crZsg==
-----END EC PRIVATE KEY-----

View File

@@ -0,0 +1,62 @@
version: "3.8"
services:
rocketchat:
image: registry.rocket.chat/rocketchat/rocket.chat:8.2.1
container_name: rocketchat
restart: unless-stopped
environment:
MONGO_URL: mongodb://rocketchat-mongo:27017/rocketchat?replicaSet=rs0
MONGO_OPLOG_URL: mongodb://rocketchat-mongo:27017/local?replicaSet=rs0
ROOT_URL: https://chat.njts.co.jp
PORT: 3000
depends_on:
- mongo
networks:
- chat
mongo:
image: mongo:8.0.13
container_name: rocketchat-mongo
restart: unless-stopped
command: mongod --replSet rs0 --oplogSize 128
volumes:
- mongo-data:/data/db
networks:
- chat
mongo-init:
image: mongo:8.0.13
restart: "no"
depends_on:
- mongo
command: >
mongosh --host mongo:27017 --eval
"rs.initiate({_id:'rs0',members:[{_id:0,host:'mongo:27017'}]})"
networks:
- chat
caddy:
image: caddy:2
container_name: caddy
restart: unless-stopped
ports:
- "2015:2015"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- /etc/caddy/certs:/certs:ro
- caddy_data:/data
- caddy_config:/config
networks:
- chat
- shared_net
volumes:
mongo-data:
caddy_data:
caddy_config:
networks:
chat:
shared_net:
external: true

379
frontend/css/mobile.css Normal file
View File

@@ -0,0 +1,379 @@
/* ==================================================================
mobile.css — iOS Safari・Android Chrome レスポンシブ対応
ブレークポイント: 767px 以下をモバイルとして扱う
PC のレイアウトは変更しない (screen media query で完全に隔離)
================================================================== */
/* ── iOS Safari フォントブースト無効化 ── */
html {
-webkit-text-size-adjust: 100%;
}
@media screen and (max-width: 767px) {
/* ── body 余白を縮小 ── */
body {
padding: 12px !important;
margin: 8px !important;
}
/* ── h1・h2 フォントサイズ ── */
h1 {
font-size: 1.35rem !important;
}
h2 {
font-size: 1.1rem !important;
}
/* ================================================================
テーブル: 横スクロール
会計・給与テーブルは列数が多く、横スクロールが最適解
================================================================ */
table {
display: block;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
max-width: 100%;
}
/* テーブルを包む overflow:hidden ラッパーも解放する */
.doc-table-wrap {
overflow-x: auto !important;
}
/* ================================================================
フォーム入力: 16px で iOS の自動ズームを防ぐ
================================================================ */
input[type="text"],
input[type="date"],
input[type="number"],
input[type="password"],
input[type="email"],
input[type="tel"],
select,
textarea {
font-size: 16px !important;
max-width: 100%;
box-sizing: border-box;
}
/* ================================================================
ボタン: タップ領域を 44px 以上に (iOS HIG 推奨)
================================================================ */
button,
input[type="submit"],
input[type="button"],
.btn,
.upload-btn,
.back-link,
.logout-btn,
.change-pw-btn {
min-height: 44px;
touch-action: manipulation;
}
/* ================================================================
index.html
================================================================ */
/* 2カラムカードグリッド → 1カラム */
.grid {
grid-template-columns: 1fr !important;
}
/* ヘッダー: タイトルとユーザー情報を縦積み */
header {
flex-direction: column !important;
align-items: flex-start !important;
gap: 10px !important;
}
header h1 {
font-size: 18px !important;
}
.user-info {
flex-wrap: wrap !important;
width: 100% !important;
gap: 8px !important;
font-size: 13px !important;
}
/* カード内ボタン群: 縦積み・margin-left をリセット */
.card p:last-child {
display: flex !important;
flex-direction: column !important;
gap: 8px !important;
}
.btn {
margin-left: 0 !important;
display: block !important;
text-align: center !important;
box-sizing: border-box !important;
}
/* パスワード変更モーダル: 画面幅に合わせる */
.modal-box {
width: 92vw !important;
max-width: 92vw !important;
padding: 20px !important;
}
/* ================================================================
payroll.html (給与システムメニュー)
================================================================ */
.menu-grid {
grid-template-columns: 1fr !important;
}
.menu-card {
padding: 20px !important;
}
/* ================================================================
journal-entry.html / journal-edit.html (仕訳入力)
================================================================ */
/* 検索フォーム: 2カラムグリッド → 1カラム */
.search-form-grid {
grid-template-columns: 1fr !important;
gap: 12px !important;
}
.search-form-row,
.search-form-row-full {
flex-direction: column !important;
align-items: flex-start !important;
gap: 4px !important;
}
.search-form-row label,
.search-form-row-full label {
min-width: unset !important;
width: 100%;
}
.search-form-row input,
.search-form-row select,
.search-form-row-full input,
.search-form-row-full select {
width: 100% !important;
box-sizing: border-box !important;
}
/* 検索ボタン群 */
.search-buttons {
flex-wrap: wrap !important;
gap: 6px !important;
}
.search-buttons button {
flex: 1 1 auto !important;
min-width: 100px !important;
}
/* .row: 日付・摘要・勘定科目などの入力行 */
.row {
flex-direction: column !important;
align-items: flex-start !important;
gap: 6px !important;
width: 100%;
}
.row label {
min-width: unset !important;
}
.row input,
.row select {
width: 100% !important;
box-sizing: border-box !important;
}
/* ================================================================
trial-balance.html (試算表)
テキスト中心の inline 検索フォームを縦積みにする
================================================================ */
.search-form {
text-align: left !important;
padding: 12px !important;
}
/* trial-balance の検索フォームは label/input が直接子要素 */
.search-form > label {
display: block !important;
margin: 6px 0 2px !important;
}
.search-form > input[type="text"],
.search-form > input[type="date"],
.search-form > input[type="number"] {
display: block !important;
width: 100% !important;
margin: 0 0 8px 15px !important;
box-sizing: border-box !important;
}
.search-form > button {
display: inline-block !important;
margin: 4px 4px 0 0 !important;
width: auto !important;
min-width: 80px;
}
/* ================================================================
nenkin-portal.html (社会保険電子文書)
================================================================ */
.page-header {
flex-direction: column !important;
align-items: flex-start !important;
gap: 10px !important;
}
.upload-zone {
padding: 20px 16px !important;
}
/* フィルターバー: wrapping は既存スタイルで対応済み、幅を補完 */
.filter-bar {
gap: 8px !important;
}
.filter-select {
width: 100% !important;
box-sizing: border-box !important;
}
/* ================================================================
payroll-employees.html / payroll-settings.html (タブ UI)
================================================================ */
.nav-tabs {
flex-wrap: wrap !important;
justify-content: flex-start !important;
gap: 6px !important;
border-bottom: none !important;
}
.nav-tab {
padding: 8px 14px !important;
font-size: 0.85rem !important;
border-radius: 6px !important;
}
/* container の余白縮小 */
.container {
margin: 10px !important;
padding: 16px 12px !important;
border-radius: 10px !important;
}
/* ================================================================
payroll-settings.html (2/3カラムフォームグリッド → 1カラム)
================================================================ */
.form-row {
display: flex !important;
flex-direction: column !important;
gap: 10px !important;
}
.form-row-3 {
display: flex !important;
flex-direction: column !important;
gap: 10px !important;
}
.form-group {
width: 100%;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100% !important;
box-sizing: border-box !important;
}
/* ================================================================
payroll-calculation.html (月次給与計算)
================================================================ */
/* フィルター行のラベルを縦積み */
.filters {
display: flex !important;
flex-direction: column !important;
gap: 8px !important;
padding: 12px !important;
}
.filters label {
display: flex !important;
flex-direction: column !important;
gap: 4px !important;
margin-right: 0 !important;
width: 100%;
}
.filters input,
.filters select {
width: 100% !important;
box-sizing: border-box !important;
}
/* ── split-layout: PC は左右分割、モバイルは上下積み ── */
/* 左リスト・右明細を縦方向に並べ直す */
.split-layout {
flex-direction: column !important;
}
.split-left {
flex: none !important;
width: 100% !important;
max-width: none !important;
min-width: 0 !important;
max-height: none !important;
overflow-y: visible !important;
position: static !important;
}
.split-right {
width: 100% !important;
min-width: 0 !important;
border-left: none !important;
border-top: 2px solid #e0e8f0 !important;
padding-left: 0 !important;
padding-top: 16px !important;
margin-top: 8px !important;
max-height: none !important;
overflow-y: visible !important;
position: static !important;
}
/* ── タブ(給与計算・賞与計算・帳票出力)を折り返し ── */
.nav-tabs-calc {
flex-wrap: wrap !important;
gap: 6px !important;
border-bottom: none !important;
}
.nav-tab-calc {
padding: 8px 14px !important;
font-size: 0.9rem !important;
border-radius: 6px !important;
}
/* ── 新規計算モーダルinline style の width:800px を上書き) ── */
#calculateModal,
#bonusModal {
width: 92vw !important;
left: 4vw !important;
transform: none !important;
max-width: 92vw !important;
padding: 20px 16px !important;
box-sizing: border-box !important;
}
}

1103
frontend/egov.html Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -2,8 +2,15 @@
<html lang="ja"> <html lang="ja">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
http-equiv="Cache-Control"
content="no-cache, no-store, must-revalidate"
/>
<meta http-equiv="Pragma" content="no-cache" />
<title>NJTS 会計システム - メイン</title> <title>NJTS 会計システム - メイン</title>
<link rel="stylesheet" href="css/style.css" /> <link rel="stylesheet" href="css/style.css" />
<link rel="stylesheet" href="/css/mobile.css" />
<script src="js/auth.js"></script> <script src="js/auth.js"></script>
<style> <style>
body { body {
@@ -79,6 +86,103 @@
.logout-btn:hover { .logout-btn:hover {
background: #c82333; background: #c82333;
} }
.change-pw-btn {
padding: 8px 12px;
background: #6c757d;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
}
.change-pw-btn:hover {
background: #545b62;
}
/* パスワード変更モーダル */
.modal-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
justify-content: center;
align-items: center;
}
.modal-overlay.active {
display: flex;
}
.modal-box {
background: white;
border-radius: 10px;
padding: 32px;
width: 360px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
}
.modal-box h3 {
margin: 0 0 20px;
}
.modal-box .form-group {
margin-bottom: 14px;
}
.modal-box label {
display: block;
margin-bottom: 5px;
font-size: 13px;
font-weight: 600;
color: #333;
}
.modal-box input[type="password"] {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 5px;
font-size: 14px;
box-sizing: border-box;
}
.modal-box .btn-row {
display: flex;
gap: 10px;
margin-top: 20px;
}
.modal-box .btn-row button {
flex: 1;
padding: 10px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
}
.modal-box .btn-submit {
background: #007bff;
color: white;
}
.modal-box .btn-submit:hover {
background: #0056b3;
}
.modal-box .btn-cancel {
background: #e9ecef;
color: #333;
}
.modal-box .btn-cancel:hover {
background: #d0d5db;
}
.modal-msg {
padding: 8px 12px;
border-radius: 4px;
margin-bottom: 12px;
display: none;
font-size: 13px;
}
.modal-msg.error {
background: #fee;
color: #c33;
border: 1px solid #fcc;
}
.modal-msg.success {
background: #efe;
color: #3a3;
border: 1px solid #cfc;
}
</style> </style>
</head> </head>
<body> <body>
@@ -86,10 +190,45 @@
<h1>NJTS</h1> <h1>NJTS</h1>
<div class="user-info"> <div class="user-info">
<span>ユーザー: <strong id="username">読み込み中...</strong></span> <span>ユーザー: <strong id="username">読み込み中...</strong></span>
<button class="change-pw-btn" onclick="openChangePw()">
パスワード変更
</button>
<button class="logout-btn" onclick="logout()">ログアウト</button> <button class="logout-btn" onclick="logout()">ログアウト</button>
</div> </div>
</header> </header>
<!-- パスワード変更モーダル -->
<div class="modal-overlay" id="changePwModal">
<div class="modal-box">
<h3>🔒 パスワード変更</h3>
<div class="modal-msg" id="changePwMsg"></div>
<div class="form-group">
<label>現在のパスワード</label>
<input
type="password"
id="cpCurrent"
placeholder="現在のパスワード"
/>
</div>
<div class="form-group">
<label>新しいパスワード8文字以上</label>
<input type="password" id="cpNew" placeholder="新しいパスワード" />
</div>
<div class="form-group">
<label>新しいパスワード(確認)</label>
<input type="password" id="cpConfirm" placeholder="もう一度入力" />
</div>
<div class="btn-row">
<button class="btn-submit" onclick="submitChangePw()">
変更する
</button>
<button class="btn-cancel" onclick="closeChangePw()">
キャンセル
</button>
</div>
</div>
</div>
<main class="grid"> <main class="grid">
<section class="card"> <section class="card">
<h2>会計システム</h2> <h2>会計システム</h2>
@@ -118,6 +257,31 @@
<p>給与・賞与の計算や従業員管理を行います。</p> <p>給与・賞与の計算や従業員管理を行います。</p>
<p><a class="btn" href="payroll.html">給与システムを開く</a></p> <p><a class="btn" href="payroll.html">給与システムを開く</a></p>
</section> </section>
<section class="card">
<h2>🏦 銀行明細照会</h2>
<p>
銀行のCSVデータを取込み、明細を管理します。重複チェック付きで安全に取込め、月別・銀行別の検索・印刷が可能です。
</p>
<p>
<a class="btn" href="bank-statement.html" style="background: #1565c0"
>銀行明細を開く</a
>
</p>
</section>
<section class="card">
<h2>🏛 e-Gov 電子送達</h2>
<p>
年金事務所からの電子送達書類(社会保険通知など)を取得・管理します。<br />
GビズIDでe-Govにログインし、毎月の電子通知CSVを確認できます。
</p>
<p>
<a class="btn" href="egov.html" style="background: #6f42c1"
>e-Gov 連携を開く</a
>
</p>
</section>
</main> </main>
<script> <script>
@@ -129,18 +293,67 @@
} }
}); });
// dropdown toggle // パスワード変更モーダル
const toggle = document.getElementById("accountingToggle"); function openChangePw() {
const menu = document.getElementById("accountingMenu"); document.getElementById("cpCurrent").value = "";
toggle.addEventListener("click", (e) => { document.getElementById("cpNew").value = "";
menu.style.display = menu.style.display === "block" ? "none" : "block"; document.getElementById("cpConfirm").value = "";
}); const msg = document.getElementById("changePwMsg");
// click outside to close msg.style.display = "none";
document.addEventListener("click", (e) => { document.getElementById("changePwModal").classList.add("active");
if (!toggle.contains(e.target) && !menu.contains(e.target)) { }
menu.style.display = "none"; function closeChangePw() {
document.getElementById("changePwModal").classList.remove("active");
}
function showChangePwMsg(text, isError) {
const msg = document.getElementById("changePwMsg");
msg.textContent = text;
msg.className = "modal-msg " + (isError ? "error" : "success");
msg.style.display = "block";
}
async function submitChangePw() {
const current = document.getElementById("cpCurrent").value;
const newPw = document.getElementById("cpNew").value;
const confirm = document.getElementById("cpConfirm").value;
const user = getCurrentUser();
if (!current || !newPw || !confirm) {
showChangePwMsg("すべての項目を入力してください", true);
return;
} }
}); if (newPw.length < 8) {
showChangePwMsg("新しいパスワードは8文字以上にしてください", true);
return;
}
if (newPw !== confirm) {
showChangePwMsg("新しいパスワードが一致しません", true);
return;
}
try {
const res = await fetch("/api/users/change-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: user.username,
current_password: current,
new_password: newPw,
}),
});
const data = await res.json();
if (res.ok) {
showChangePwMsg(
"パスワードを変更しました。再ログインしてください",
false,
);
setTimeout(() => {
logout();
}, 2000);
} else {
showChangePwMsg(data.detail || "変更に失敗しました", true);
}
} catch (e) {
showChangePwMsg("エラー: " + e.message, true);
}
}
</script> </script>
</body> </body>
</html> </html>

View File

@@ -2,7 +2,9 @@
<html lang="ja"> <html lang="ja">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="js/auth.js"></script> <script src="js/auth.js"></script>
<link rel="stylesheet" href="/css/mobile.css" />
<title>修正仕訳入力</title> <title>修正仕訳入力</title>
<style> <style>
body { body {

View File

@@ -2,7 +2,9 @@
<html lang="ja"> <html lang="ja">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="js/auth.js"></script> <script src="js/auth.js"></script>
<link rel="stylesheet" href="/css/mobile.css" />
<title>仕訳入力</title> <title>仕訳入力</title>
<style> <style>
body { body {
@@ -255,22 +257,20 @@
</style> </style>
</head> </head>
<body> <body>
<button <a
onclick="location.href = 'index.html'" href="index.html"
class="back-button"
style=" style="
display: inline-block;
margin: 0 0 20px 0; margin: 0 0 20px 0;
padding: 8px 16px; padding: 6px 16px;
background: #007bff; background: #757575;
color: white; color: #fff;
border: none;
border-radius: 4px; border-radius: 4px;
cursor: pointer; text-decoration: none;
font-size: 14px; font-size: 14px;
" "
>← メニューへ戻る</a
> >
← 首ページに戻る
</button>
<h2>仕訳入力</h2> <h2>仕訳入力</h2>
<div class="row" style="margin-bottom: 12px"> <div class="row" style="margin-bottom: 12px">
@@ -861,7 +861,19 @@
<div class="search-buttons"> <div class="search-buttons">
<button onclick="searchJournals()">🔍 検索</button> <button onclick="searchJournals()">🔍 検索</button>
<button onclick="printSearchResults()">🖨️ 印刷</button> <button onclick="printSearchResults()">🖨️ 印刷</button>
<button onclick="clearSearchConditions()" style="background: #6c757d; color: white; border: none; padding: 6px 14px; border-radius: 4px; cursor: pointer;">🔄 条件クリア</button> <button
onclick="clearSearchConditions()"
style="
background: #6c757d;
color: white;
border: none;
padding: 6px 14px;
border-radius: 4px;
cursor: pointer;
"
>
🔄 条件クリア
</button>
</div> </div>
</div> </div>
@@ -2828,7 +2840,8 @@
// 複数行フォームへのコピー // 複数行フォームへのコピー
function copyJournalToDetailForm(data) { function copyJournalToDetailForm(data) {
document.getElementById("detailEntryDate").value = data.entry_date; document.getElementById("detailEntryDate").value = data.entry_date;
document.getElementById("detailDescription").value = data.description || ""; document.getElementById("detailDescription").value =
data.description || "";
// 既存行をクリア // 既存行をクリア
document.getElementById("detailLinesTbody").innerHTML = ""; document.getElementById("detailLinesTbody").innerHTML = "";
@@ -2844,10 +2857,14 @@
accountInput.dataset.accountId = acc.account_id; accountInput.dataset.accountId = acc.account_id;
} }
if (Number(line.debit) > 0) { if (Number(line.debit) > 0) {
tr.querySelector(".detail-debit").value = Number(line.debit).toLocaleString(); tr.querySelector(".detail-debit").value = Number(
line.debit,
).toLocaleString();
} }
if (Number(line.credit) > 0) { if (Number(line.credit) > 0) {
tr.querySelector(".detail-credit").value = Number(line.credit).toLocaleString(); tr.querySelector(".detail-credit").value = Number(
line.credit,
).toLocaleString();
} }
if (line.line_description) { if (line.line_description) {
tr.querySelector(".detail-line-memo").value = line.line_description; tr.querySelector(".detail-line-memo").value = line.line_description;
@@ -2877,58 +2894,121 @@
// 新UIでは簡易的にフォームに反映最初の借方行の科目 + 最初の貸方行を取引手段に設定) // 新UIでは簡易的にフォームに反映最初の借方行の科目 + 最初の貸方行を取引手段に設定)
if (data.lines && data.lines.length > 0) { if (data.lines && data.lines.length > 0) {
// 借方と貸方を分類 // 借方と貸方を分類
const debitLines = data.lines.filter((l) => Number(l.debit) > 0); const debitLines = data.lines.filter((l) => Number(l.debit) > 0);
const creditLines = data.lines.filter((l) => Number(l.credit) > 0); const creditLines = data.lines.filter((l) => Number(l.credit) > 0);
// 仮払/仮受消費税行を除外して主要な科目を特定 // 仮払/仮受消費税行を除外して主要な科目を特定
const mainDebit = debitLines.find((l) => { const mainDebit = debitLines.find((l) => {
const acc = accounts.find((a) => a.account_id === l.account_id); const acc = accounts.find((a) => a.account_id === l.account_id);
return ( return (
acc && acc &&
!acc.account_name.includes("仮払消費税") && !acc.account_name.includes("仮払消費税") &&
!acc.account_name.includes("仮受消費税") !acc.account_name.includes("仮受消費税")
);
});
const mainCredit = creditLines.find((l) => {
const acc = accounts.find((a) => a.account_id === l.account_id);
return (
acc &&
!acc.account_name.includes("仮払消費税") &&
!acc.account_name.includes("仮受消費税")
);
});
// 税額行を検出
const taxDebitLine = debitLines.find((l) => {
const acc = accounts.find((a) => a.account_id === l.account_id);
return acc && acc.account_name.includes("仮払消費税");
});
const taxCreditLine = creditLines.find((l) => {
const acc = accounts.find((a) => a.account_id === l.account_id);
return acc && acc.account_name.includes("仮受消費税");
});
if (mainDebit && mainCredit) {
// 支出パターン: 借方=科目, 貸方=取引手段
if (taxDebitLine) {
// 支出(仮払消費税あり)
document.querySelector(
'input[name="entryType"][value="expense"]',
).checked = true;
const kamokuAcc = accounts.find(
(a) => a.account_id === mainDebit.account_id,
); );
}); const methodAcc = accounts.find(
const mainCredit = creditLines.find((l) => { (a) => a.account_id === mainCredit.account_id,
const acc = accounts.find((a) => a.account_id === l.account_id);
return (
acc &&
!acc.account_name.includes("仮払消費税") &&
!acc.account_name.includes("仮受消費税")
); );
}); if (kamokuAcc) {
document.getElementById("entryAccountInput").value =
// 税額行を検出 `${kamokuAcc.account_code} ${kamokuAcc.account_name}`;
const taxDebitLine = debitLines.find((l) => { document.getElementById("entryAccountInput").dataset.accountId =
const acc = accounts.find((a) => a.account_id === l.account_id); kamokuAcc.account_id;
return acc && acc.account_name.includes("仮払消費税"); }
}); if (methodAcc) {
const taxCreditLine = creditLines.find((l) => { document.getElementById("entryMethodInput").value =
const acc = accounts.find((a) => a.account_id === l.account_id); `${methodAcc.account_code} ${methodAcc.account_name}`;
return acc && acc.account_name.includes("仮受消費税"); document.getElementById("entryMethodInput").dataset.accountId =
}); methodAcc.account_id;
}
if (mainDebit && mainCredit) { const totalAmount = Number(mainCredit.credit);
// 支出パターン: 借方=科目, 貸方=取引手段 document.getElementById("entryAmount").value =
if (taxDebitLine) { totalAmount.toLocaleString();
// 支出(仮払消費税あり) document.getElementById("entryTaxAmount").value = Number(
taxDebitLine.debit,
).toLocaleString();
} else if (taxCreditLine) {
// 収入(仮受消費税あり)
document.querySelector(
'input[name="entryType"][value="income"]',
).checked = true;
const kamokuAcc = accounts.find(
(a) => a.account_id === mainCredit.account_id,
);
const methodAcc = accounts.find(
(a) => a.account_id === mainDebit.account_id,
);
if (kamokuAcc) {
document.getElementById("entryAccountInput").value =
`${kamokuAcc.account_code} ${kamokuAcc.account_name}`;
document.getElementById("entryAccountInput").dataset.accountId =
kamokuAcc.account_id;
}
if (methodAcc) {
document.getElementById("entryMethodInput").value =
`${methodAcc.account_code} ${methodAcc.account_name}`;
document.getElementById("entryMethodInput").dataset.accountId =
methodAcc.account_id;
}
const totalAmount = Number(mainDebit.debit);
document.getElementById("entryAmount").value =
totalAmount.toLocaleString();
document.getElementById("entryTaxAmount").value = Number(
taxCreditLine.credit,
).toLocaleString();
} else {
// 消費税なし - 借方科目が費用/資産 → 支出、それ以外は収入
const kamokuAccD = accounts.find(
(a) => a.account_id === mainDebit.account_id,
);
const isExpense =
kamokuAccD &&
(kamokuAccD.account_code.startsWith("8") ||
kamokuAccD.account_code.startsWith("1") ||
kamokuAccD.account_code.startsWith("2") ||
kamokuAccD.account_code.startsWith("3") ||
kamokuAccD.account_code.startsWith("4"));
if (isExpense) {
document.querySelector( document.querySelector(
'input[name="entryType"][value="expense"]', 'input[name="entryType"][value="expense"]',
).checked = true; ).checked = true;
const kamokuAcc = accounts.find( document.getElementById("entryAccountInput").value =
(a) => a.account_id === mainDebit.account_id, `${kamokuAccD.account_code} ${kamokuAccD.account_name}`;
); document.getElementById("entryAccountInput").dataset.accountId =
kamokuAccD.account_id;
const methodAcc = accounts.find( const methodAcc = accounts.find(
(a) => a.account_id === mainCredit.account_id, (a) => a.account_id === mainCredit.account_id,
); );
if (kamokuAcc) {
document.getElementById("entryAccountInput").value =
`${kamokuAcc.account_code} ${kamokuAcc.account_name}`;
document.getElementById(
"entryAccountInput",
).dataset.accountId = kamokuAcc.account_id;
}
if (methodAcc) { if (methodAcc) {
document.getElementById("entryMethodInput").value = document.getElementById("entryMethodInput").value =
`${methodAcc.account_code} ${methodAcc.account_name}`; `${methodAcc.account_code} ${methodAcc.account_name}`;
@@ -2936,116 +3016,47 @@
"entryMethodInput", "entryMethodInput",
).dataset.accountId = methodAcc.account_id; ).dataset.accountId = methodAcc.account_id;
} }
const totalAmount = Number(mainCredit.credit); document.getElementById("entryAmount").value = Number(
document.getElementById("entryAmount").value = mainDebit.debit,
totalAmount.toLocaleString();
document.getElementById("entryTaxAmount").value = Number(
taxDebitLine.debit,
).toLocaleString(); ).toLocaleString();
} else if (taxCreditLine) { } else {
// 収入(仮受消費税あり)
document.querySelector( document.querySelector(
'input[name="entryType"][value="income"]', 'input[name="entryType"][value="income"]',
).checked = true; ).checked = true;
const kamokuAcc = accounts.find( const kamokuAccC = accounts.find(
(a) => a.account_id === mainCredit.account_id, (a) => a.account_id === mainCredit.account_id,
); );
const methodAcc = accounts.find( if (kamokuAccC) {
(a) => a.account_id === mainDebit.account_id,
);
if (kamokuAcc) {
document.getElementById("entryAccountInput").value = document.getElementById("entryAccountInput").value =
`${kamokuAcc.account_code} ${kamokuAcc.account_name}`; `${kamokuAccC.account_code} ${kamokuAccC.account_name}`;
document.getElementById( document.getElementById(
"entryAccountInput", "entryAccountInput",
).dataset.accountId = kamokuAcc.account_id; ).dataset.accountId = kamokuAccC.account_id;
} }
if (methodAcc) { document.getElementById("entryMethodInput").value =
document.getElementById("entryMethodInput").value = `${kamokuAccD.account_code} ${kamokuAccD.account_name}`;
`${methodAcc.account_code} ${methodAcc.account_name}`; document.getElementById("entryMethodInput").dataset.accountId =
document.getElementById( kamokuAccD.account_id;
"entryMethodInput", document.getElementById("entryAmount").value = Number(
).dataset.accountId = methodAcc.account_id; mainDebit.debit,
}
const totalAmount = Number(mainDebit.debit);
document.getElementById("entryAmount").value =
totalAmount.toLocaleString();
document.getElementById("entryTaxAmount").value = Number(
taxCreditLine.credit,
).toLocaleString(); ).toLocaleString();
} else {
// 消費税なし - 借方科目が費用/資産 → 支出、それ以外は収入
const kamokuAccD = accounts.find(
(a) => a.account_id === mainDebit.account_id,
);
const isExpense =
kamokuAccD &&
(kamokuAccD.account_code.startsWith("8") ||
kamokuAccD.account_code.startsWith("1") ||
kamokuAccD.account_code.startsWith("2") ||
kamokuAccD.account_code.startsWith("3") ||
kamokuAccD.account_code.startsWith("4"));
if (isExpense) {
document.querySelector(
'input[name="entryType"][value="expense"]',
).checked = true;
document.getElementById("entryAccountInput").value =
`${kamokuAccD.account_code} ${kamokuAccD.account_name}`;
document.getElementById(
"entryAccountInput",
).dataset.accountId = kamokuAccD.account_id;
const methodAcc = accounts.find(
(a) => a.account_id === mainCredit.account_id,
);
if (methodAcc) {
document.getElementById("entryMethodInput").value =
`${methodAcc.account_code} ${methodAcc.account_name}`;
document.getElementById(
"entryMethodInput",
).dataset.accountId = methodAcc.account_id;
}
document.getElementById("entryAmount").value = Number(
mainDebit.debit,
).toLocaleString();
} else {
document.querySelector(
'input[name="entryType"][value="income"]',
).checked = true;
const kamokuAccC = accounts.find(
(a) => a.account_id === mainCredit.account_id,
);
if (kamokuAccC) {
document.getElementById("entryAccountInput").value =
`${kamokuAccC.account_code} ${kamokuAccC.account_name}`;
document.getElementById(
"entryAccountInput",
).dataset.accountId = kamokuAccC.account_id;
}
document.getElementById("entryMethodInput").value =
`${kamokuAccD.account_code} ${kamokuAccD.account_name}`;
document.getElementById(
"entryMethodInput",
).dataset.accountId = kamokuAccD.account_id;
document.getElementById("entryAmount").value = Number(
mainDebit.debit,
).toLocaleString();
}
document.getElementById("entryTaxRate").value = "0";
document.getElementById("entryTaxAmount").value = "";
} }
document.getElementById("entryTaxRate").value = "0";
document.getElementById("entryTaxAmount").value = "";
} }
} }
}
// ページ上部の仕訳入力フォームにスクロール // ページ上部の仕訳入力フォームにスクロール
window.scrollTo({ top: 0, behavior: "smooth" }); window.scrollTo({ top: 0, behavior: "smooth" });
const dateInput = document.getElementById("entryDate"); const dateInput = document.getElementById("entryDate");
dateInput.focus(); dateInput.focus();
showAlert( showAlert(
`仕訳を複製しました。\n\n※ このコピーは新規仕訳として独立した記録になります。\n※ 日付と摘要を必要に応じて修正してから登録してください。`, `仕訳を複製しました。\n\n※ このコピーは新規仕訳として独立した記録になります。\n※ 日付と摘要を必要に応じて修正してから登録してください。`,
"success", "success",
); );
} }
// -------------------------------------------------- // --------------------------------------------------

View File

@@ -2,7 +2,9 @@
<html lang="ja"> <html lang="ja">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="js/auth.js"></script> <script src="js/auth.js"></script>
<link rel="stylesheet" href="/css/mobile.css" />
<title>仕訳一覧</title> <title>仕訳一覧</title>
<style> <style>
body { body {

View File

@@ -2,7 +2,9 @@
<html lang="ja"> <html lang="ja">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="js/auth.js"></script> <script src="js/auth.js"></script>
<link rel="stylesheet" href="/css/mobile.css" />
<title>仕訳詳細</title> <title>仕訳詳細</title>
<style> <style>
body { body {

View File

@@ -2,9 +2,11 @@
<html lang="ja"> <html lang="ja">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="js/auth.js"></script> <script src="js/auth.js"></script>
<title>仕訳入力</title> <title>仕訳入力</title>
<link rel="stylesheet" href="css/style.css" /> <link rel="stylesheet" href="css/style.css" />
<link rel="stylesheet" href="/css/mobile.css" />
</head> </head>
<body> <body>
<h1>仕訳入力</h1> <h1>仕訳入力</h1>

View File

@@ -1,41 +1,59 @@
/** /**
* 认证和会话管理 * 認証・セッション管理
*/ */
// 检查用户是否已登录 // ──────────────────────────────────────
// ページ描画前に即座に認証確認(フラッシュ防止)
// ──────────────────────────────────────
(function () {
if (!window.location.pathname.includes("login.html")) {
if (!localStorage.getItem("currentUser")) {
// ページが見えてしまわないよう即座に非表示にしてからリダイレクト
document.documentElement.style.visibility = "hidden";
window.location.replace("/login.html");
}
}
})();
// bfcache戻る/進むボタン)対応
// ブラウザがキャッシュからページを復元した場合も認証を再確認する
window.addEventListener("pageshow", function (event) {
if (!window.location.pathname.includes("login.html")) {
if (!localStorage.getItem("currentUser")) {
// bfcacheから復元されたページを即座に非表示にしてリダイレクト
document.documentElement.style.visibility = "hidden";
window.location.replace("/login.html");
}
}
});
function checkAuth() { function checkAuth() {
const user = localStorage.getItem("currentUser"); const user = localStorage.getItem("currentUser");
if (!user) { if (!user) {
// 用户未登录,重定向到登录页面 window.location.replace("/login.html");
window.location.href = "/login.html";
return false; return false;
} }
return true; return true;
} }
// 获取当前登录的用户信息
function getCurrentUser() { function getCurrentUser() {
const user = localStorage.getItem("currentUser"); const user = localStorage.getItem("currentUser");
return user ? JSON.parse(user) : null; return user ? JSON.parse(user) : null;
} }
// 保存用户信息(登录时使用)
function setCurrentUser(userData) { function setCurrentUser(userData) {
localStorage.setItem("currentUser", JSON.stringify(userData)); localStorage.setItem("currentUser", JSON.stringify(userData));
} }
// 注销用户
function logout() { function logout() {
localStorage.removeItem("currentUser"); localStorage.removeItem("currentUser");
window.location.href = "/login.html"; sessionStorage.clear();
// replace() を使うことでログイン前の履歴に戻れなくなる
window.location.replace("/login.html");
} }
// 在页面加载时检查认证(仅在非登录页面使用)
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
const currentPage = window.location.pathname; if (!window.location.pathname.includes("login.html")) {
// 排除登录页面的检查
if (!currentPage.includes("login.html")) {
checkAuth(); checkAuth();
} }
}); });

View File

@@ -254,6 +254,41 @@ function initTrialBalance() {
} }
} }
// 年度ドロップダウンを読み込む
async function loadFiscalYears() {
try {
const res = await fetch("/trial-balance/fiscal-years");
if (!res.ok) return;
const years = await res.json();
const sel = document.getElementById("fiscalYear");
if (!sel) return;
years.forEach((fy) => {
const opt = document.createElement("option");
opt.value = JSON.stringify({
date_from: fy.date_from,
date_to: fy.date_to,
});
opt.textContent = fy.label;
sel.appendChild(opt);
});
} catch (e) {
console.warn("年度一覧取得失敗:", e);
}
}
loadFiscalYears();
// 年度選択 → 開始・終了日を自動セット(ユーザーは自由に変更可能)
document.getElementById("fiscalYear").addEventListener("change", function () {
if (!this.value) return;
try {
const { date_from, date_to } = JSON.parse(this.value);
document.getElementById("dateFrom").value = date_from;
document.getElementById("dateTo").value = date_to;
} catch (e) {
/* ignore */
}
});
// 檢索按鈕的事件 // 檢索按鈕的事件
document.getElementById("btnSearch").addEventListener("click", () => { document.getElementById("btnSearch").addEventListener("click", () => {
const dateFrom = document.getElementById("dateFrom").value; const dateFrom = document.getElementById("dateFrom").value;

View File

@@ -2,9 +2,11 @@
<html lang="ja"> <html lang="ja">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="js/auth.js"></script> <script src="js/auth.js"></script>
<title>元帳</title> <title>元帳</title>
<link rel="stylesheet" href="css/style.css" /> <link rel="stylesheet" href="css/style.css" />
<link rel="stylesheet" href="/css/mobile.css" />
</head> </head>
<body> <body>
<h1 id="account-title">元帳</h1> <h1 id="account-title">元帳</h1>

View File

@@ -4,6 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NJTS 会計システム - ログイン</title> <title>NJTS 会計システム - ログイン</title>
<link rel="stylesheet" href="/css/mobile.css" />
<style> <style>
* { * {
margin: 0; margin: 0;
@@ -108,16 +109,6 @@
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4); box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
} }
.btn-register {
background: transparent;
color: #667eea;
border: 2px solid #667eea;
}
.btn-register:hover {
background: #f0f0ff;
}
.message { .message {
padding: 12px; padding: 12px;
border-radius: 5px; border-radius: 5px;
@@ -138,32 +129,6 @@
border: 1px solid #cfc; border: 1px solid #cfc;
} }
.toggle-form {
text-align: center;
margin-top: 20px;
font-size: 14px;
color: #666;
}
.toggle-form a {
color: #667eea;
text-decoration: none;
cursor: pointer;
font-weight: 500;
}
.toggle-form a:hover {
text-decoration: underline;
}
.form {
display: none;
}
.form.active {
display: block;
}
.loading { .loading {
display: none; display: none;
text-align: center; text-align: center;
@@ -181,7 +146,7 @@
</div> </div>
<!-- ログインフォーム --> <!-- ログインフォーム -->
<div id="loginForm" class="form active"> <div id="loginForm">
<div id="messageLogin" class="message"></div> <div id="messageLogin" class="message"></div>
<div class="form-group"> <div class="form-group">
@@ -208,47 +173,8 @@
<button class="btn-login" onclick="handleLogin()">ログイン</button> <button class="btn-login" onclick="handleLogin()">ログイン</button>
</div> </div>
<div class="toggle-form">
新規登録?
<a onclick="toggleForm()">アカウント作成</a>
</div>
<div class="loading" id="loginLoading">処理中...</div> <div class="loading" id="loginLoading">処理中...</div>
</div> </div>
<!-- 登録フォーム -->
<div id="registerForm" class="form">
<div id="messageRegister" class="message"></div>
<div class="form-group">
<label for="regUsername">ユーザー名</label>
<input
type="text"
id="regUsername"
placeholder="ユーザー名を入力"
required
/>
</div>
<div class="form-group">
<label for="regPassword">パスワード</label>
<input
type="password"
id="regPassword"
placeholder="6文字以上で設定"
required
/>
</div>
<div class="button-group">
<button class="btn-login" onclick="handleRegister()">登録</button>
<button class="btn-register" onclick="toggleForm()">
キャンセル
</button>
</div>
<div class="loading" id="registerLoading">処理中...</div>
</div>
</div> </div>
<script> <script>
@@ -330,82 +256,19 @@
} }
} }
async function handleRegister() { // EnterキーでログインできるようにEnterキーをバインド
const username = document.getElementById("regUsername").value;
const password = document.getElementById("regPassword").value;
if (!username || !password) {
showMessage(
"messageRegister",
"ユーザー名とパスワードを入力してください",
true,
);
return;
}
if (password.length < 6) {
showMessage(
"messageRegister",
"パスワードは6文字以上である必要があります",
true,
);
return;
}
const loadingEl = document.getElementById("registerLoading");
loadingEl.style.display = "block";
try {
const response = await fetch("/api/users/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ username, password }),
});
const data = await response.json();
if (response.ok) {
showMessage(
"messageRegister",
"登録が完了しました。ログインしてください",
false,
);
setTimeout(() => {
toggleForm();
document.getElementById("loginUsername").value = username;
document.getElementById("loginPassword").value = "";
}, 2000);
} else {
showMessage(
"messageRegister",
data.detail || "登録に失敗しました",
true,
);
}
} catch (error) {
showMessage(
"messageRegister",
"エラーが発生しました: " + error.message,
true,
);
} finally {
loadingEl.style.display = "none";
}
}
// Enterキーでログイン/登録
document.addEventListener("keypress", (e) => { document.addEventListener("keypress", (e) => {
if (e.key === "Enter") { if (e.key === "Enter") {
const loginForm = document.getElementById("loginForm"); handleLogin();
if (loginForm.classList.contains("active")) {
handleLogin();
} else {
handleRegister();
}
} }
}); });
// ログインページから後退ボタンで離脱できないようにする
// (ログアウト後に戻るボタンでメニューが見えてしまう問題の対策)
history.pushState(null, "", window.location.href);
window.addEventListener("popstate", function () {
history.pushState(null, "", window.location.href);
});
</script> </script>
</body> </body>
</html> </html>

929
frontend/nenkin-portal.html Normal file
View File

@@ -0,0 +1,929 @@
<!doctype html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>社会保険電子文書ポータル</title>
<script src="js/auth.js"></script>
<link rel="stylesheet" href="css/style.css" />
<link rel="stylesheet" href="/css/mobile.css" />
<style>
body {
font-family:
system-ui,
-apple-system,
"Yu Gothic UI",
"Hiragino Kaku Gothic ProN",
"メイリオ",
sans-serif;
padding: 24px;
background: #f5f7fa;
}
.page-header {
max-width: 1100px;
margin: 0 auto 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.page-header h1 {
margin: 0;
font-size: 20px;
}
.back-link {
padding: 8px 16px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
text-decoration: none;
}
.back-link:hover {
background: #0056b3;
}
/* ── アップロードゾーン ── */
.upload-zone {
max-width: 1100px;
margin: 0 auto 24px;
border: 2px dashed #adb5bd;
border-radius: 10px;
padding: 28px 24px;
text-align: center;
background: #fff;
cursor: pointer;
transition:
border-color 0.2s,
background 0.2s;
}
.upload-zone.dragover {
border-color: #007bff;
background: #e8f4ff;
}
.upload-zone h3 {
margin: 0 0 8px;
font-size: 16px;
color: #495057;
}
.upload-zone p {
margin: 0;
color: #6c757d;
font-size: 13px;
}
.upload-zone input[type="file"] {
display: none;
}
.upload-btn {
display: inline-block;
margin-top: 12px;
padding: 8px 20px;
background: #007bff;
color: #fff;
border-radius: 5px;
font-size: 14px;
cursor: pointer;
border: none;
}
.upload-btn:hover {
background: #0056b3;
}
#upload-status {
max-width: 1100px;
margin: 0 auto 12px;
font-size: 14px;
min-height: 24px;
}
.status-ok {
color: #28a745;
font-weight: bold;
}
.status-err {
color: #dc3545;
font-weight: bold;
}
/* ── フィルターバー ── */
.filter-bar {
max-width: 1100px;
margin: 0 auto 16px;
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.type-tabs {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.type-tab {
padding: 5px 14px;
border-radius: 20px;
border: 1px solid #ced4da;
background: #fff;
cursor: pointer;
font-size: 13px;
color: #495057;
transition: all 0.15s;
}
.type-tab:hover {
border-color: #007bff;
color: #007bff;
}
.type-tab.active {
background: #007bff;
color: #fff;
border-color: #007bff;
}
.filter-select {
padding: 5px 10px;
border: 1px solid #ced4da;
border-radius: 5px;
font-size: 13px;
background: #fff;
}
/* ── 文書一覧テーブル ── */
.doc-table-wrap {
max-width: 1100px;
margin: 0 auto;
background: #fff;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.07);
overflow: hidden;
}
.doc-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.doc-table thead th {
background: #f8f9fa;
padding: 10px 12px;
text-align: left;
font-weight: 600;
border-bottom: 2px solid #dee2e6;
white-space: nowrap;
}
.doc-table tbody tr {
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background 0.1s;
}
.doc-table tbody tr:hover {
background: #f0f7ff;
}
.doc-table tbody tr.selected {
background: #dbeafe;
}
.doc-table tbody td {
padding: 10px 12px;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
font-weight: 600;
}
.badge-shakai {
background: #d1ecf1;
color: #0c5460;
}
.badge-nounyu {
background: #d4edda;
color: #155724;
}
.badge-shoyo {
background: #fff3cd;
color: #856404;
}
.badge-unknown {
background: #e2e3e5;
color: #383d41;
}
.no-data {
text-align: center;
color: #aaa;
padding: 40px;
}
/* ── 詳細パネル ── */
#detail-panel {
max-width: 1100px;
margin: 16px auto 0;
background: #fff;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.07);
padding: 24px;
display: none;
}
#detail-panel.visible {
display: block;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
}
.detail-header h2 {
margin: 0;
font-size: 18px;
}
.detail-close {
background: none;
border: 1px solid #ced4da;
border-radius: 4px;
padding: 4px 10px;
cursor: pointer;
font-size: 13px;
color: #6c757d;
}
.detail-close:hover {
background: #f8f9fa;
}
/* 事業所情報カード */
.info-cards {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 20px;
}
.info-card {
flex: 1;
min-width: 180px;
background: #f8f9fa;
border-radius: 6px;
padding: 12px 16px;
}
.info-card .label {
font-size: 11px;
color: #6c757d;
margin-bottom: 4px;
}
.info-card .value {
font-size: 14px;
font-weight: 600;
color: #212529;
}
/* 金額テーブル */
.amount-section {
margin-bottom: 20px;
}
.amount-section h3 {
font-size: 15px;
margin: 0 0 12px;
border-left: 3px solid #007bff;
padding-left: 8px;
}
.amount-grid {
display: grid;
gap: 10px;
}
.amount-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 14px;
background: #f8f9fa;
border-radius: 5px;
font-size: 14px;
}
.amount-row.total {
background: #e8f4ff;
font-weight: 700;
font-size: 15px;
}
.amount-row .amt {
font-family: monospace;
font-size: 15px;
}
/* 告知額 vs 領収済額 サイドバイサイド */
.nounyu-compare {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 20px;
}
@media (max-width: 640px) {
.nounyu-compare {
grid-template-columns: 1fr;
}
}
.nounyu-box {
border-radius: 8px;
padding: 16px;
}
.nounyu-box.kokuchi {
background: #fff3cd;
border: 1px solid #ffc107;
}
.nounyu-box.ryoshu {
background: #d4edda;
border: 1px solid #28a745;
}
.nounyu-box h4 {
margin: 0 0 12px;
font-size: 14px;
}
/* 賞与明細テーブル */
.shoyo-table-wrap {
overflow-x: auto;
}
.shoyo-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.shoyo-table th {
background: #f8f9fa;
padding: 8px 10px;
text-align: center;
border: 1px solid #dee2e6;
white-space: nowrap;
font-size: 12px;
}
.shoyo-table td {
padding: 7px 10px;
border: 1px solid #f0f0f0;
text-align: right;
}
.shoyo-table td.name {
text-align: left;
font-weight: 600;
}
.shoyo-table tfoot td {
background: #e8f4ff;
font-weight: 700;
border-top: 2px solid #007bff;
}
.shoyo-table .empty-cell {
color: #ccc;
text-align: center;
}
</style>
</head>
<body>
<div class="page-header">
<h1>社会保険電子文書ポータル</h1>
<a class="back-link" href="payroll.html">← 給与管理メニューに戻る</a>
</div>
<!-- アップロードゾーン -->
<div class="upload-zone" id="upload-zone">
<h3>📥 社会保険 ZIP ファイルをここにドロップ</h3>
<p>またはボタンから選択してください(複数ファイル対応)</p>
<input type="file" id="zip-input" accept=".zip" multiple />
<br />
<button
class="upload-btn"
onclick="document.getElementById('zip-input').click()"
>
ファイルを選択
</button>
</div>
<div id="upload-status"></div>
<!-- フィルターバー -->
<div class="filter-bar">
<div class="type-tabs" id="type-tabs">
<button class="type-tab active" data-type="">すべて</button>
<button class="type-tab" data-type="SHAKAI">社会保険料額情報</button>
<button class="type-tab" data-type="NOUNYU">納入告知・領収</button>
<button class="type-tab" data-type="SHOYO">賞与保険料算出</button>
</div>
<select class="filter-select" id="year-select">
<option value="">年度(すべて)</option>
</select>
<select class="filter-select" id="month-select">
<option value="">月(すべて)</option>
<option value="1">1月</option>
<option value="2">2月</option>
<option value="3">3月</option>
<option value="4">4月</option>
<option value="5">5月</option>
<option value="6">6月</option>
<option value="7">7月</option>
<option value="8">8月</option>
<option value="9">9月</option>
<option value="10">10月</option>
<option value="11">11月</option>
<option value="12">12月</option>
</select>
</div>
<!-- 文書一覧テーブル -->
<div class="doc-table-wrap">
<table class="doc-table">
<thead>
<tr>
<th>種別</th>
<th>対象年月</th>
<th>事業所名</th>
<th>整理記号・番号</th>
<th>年金事務所</th>
<th>発行日</th>
<th>登録日時</th>
</tr>
</thead>
<tbody id="doc-list-body">
<tr>
<td colspan="7" class="no-data">データを読み込んでいます...</td>
</tr>
</tbody>
</table>
</div>
<!-- 詳細パネル -->
<div id="detail-panel">
<div class="detail-header">
<h2 id="detail-title">詳細</h2>
<button class="detail-close" onclick="closeDetail()">✕ 閉じる</button>
</div>
<div id="detail-body"></div>
</div>
<script>
const API = "";
let currentFilter = { type: "", year: "", month: "" };
let selectedDocId = null;
// ──────────────────────────────────
// ページ初期化
// ──────────────────────────────────
document.addEventListener("DOMContentLoaded", () => {
checkAuth();
loadDocuments();
loadFilterOptions();
initUploadZone();
document.getElementById("type-tabs").addEventListener("click", (e) => {
const tab = e.target.closest(".type-tab");
if (!tab) return;
document
.querySelectorAll(".type-tab")
.forEach((t) => t.classList.remove("active"));
tab.classList.add("active");
currentFilter.type = tab.dataset.type;
loadDocuments();
});
document
.getElementById("year-select")
.addEventListener("change", (e) => {
currentFilter.year = e.target.value;
loadDocuments();
});
document
.getElementById("month-select")
.addEventListener("change", (e) => {
currentFilter.month = e.target.value;
loadDocuments();
});
});
// ──────────────────────────────────
// 文書一覧ロード
// ──────────────────────────────────
async function loadDocuments() {
const params = new URLSearchParams();
if (currentFilter.type) params.set("doc_type", currentFilter.type);
if (currentFilter.year) params.set("year", currentFilter.year);
if (currentFilter.month) params.set("month", currentFilter.month);
const url = `${API}/nenkin/documents${params.toString() ? "?" + params : ""}`;
try {
const res = await fetch(url);
const docs = await res.json();
renderDocList(docs);
} catch (e) {
document.getElementById("doc-list-body").innerHTML =
`<tr><td colspan="7" class="no-data" style="color:#dc3545">読み込みエラー: ${e.message}</td></tr>`;
}
}
function renderDocList(docs) {
const tbody = document.getElementById("doc-list-body");
if (!docs.length) {
tbody.innerHTML =
'<tr><td colspan="7" class="no-data">文書が登録されていません</td></tr>';
return;
}
tbody.innerHTML = docs
.map((d) => {
const period =
d.purpose_year && d.purpose_month
? `${d.purpose_year}${d.purpose_month}`
: "";
const issueDate = d.issue_date
? d.issue_date.replace(/-/g, "/")
: "";
const uploadedAt = d.uploaded_at
? d.uploaded_at.slice(0, 16).replace("T", " ")
: "";
const selected = d.id === selectedDocId ? "selected" : "";
return `<tr class="${selected}" onclick="loadDetail(${d.id})" data-id="${d.id}">
<td>${badgeHtml(d.document_type, d.document_type_name)}</td>
<td>${period}</td>
<td>${escHtml(d.office_name || "")}</td>
<td><small>${escHtml(d.office_kigo || "")} ${escHtml(d.office_number || "")}</small></td>
<td>${escHtml(d.nenkin_jimusho || "")}</td>
<td>${issueDate}</td>
<td><small>${uploadedAt}</small></td>
</tr>`;
})
.join("");
}
function badgeHtml(type, name) {
const cls =
{
SHAKAI: "badge-shakai",
NOUNYU: "badge-nounyu",
SHOYO: "badge-shoyo",
}[type] || "badge-unknown";
return `<span class="badge ${cls}">${escHtml(name || type)}</span>`;
}
// ──────────────────────────────────
// フィルター選択肢(年度)
// ──────────────────────────────────
async function loadFilterOptions() {
try {
const res = await fetch(`${API}/nenkin/filter-options`);
const data = await res.json();
const sel = document.getElementById("year-select");
const years = [
...new Set(data.periods.map((p) => p.purpose_year)),
].sort((a, b) => b - a);
years.forEach((y) => {
const opt = document.createElement("option");
opt.value = y;
opt.textContent = `${y}`;
sel.appendChild(opt);
});
} catch (_) {}
}
// ──────────────────────────────────
// 詳細ロード
// ──────────────────────────────────
async function loadDetail(docId) {
// 選択行のハイライト
selectedDocId = docId;
document.querySelectorAll(".doc-table tbody tr").forEach((tr) => {
tr.classList.toggle("selected", parseInt(tr.dataset.id) === docId);
});
try {
const res = await fetch(`${API}/nenkin/documents/${docId}`);
const data = await res.json();
renderDetail(data);
} catch (e) {
showDetailError(e.message);
}
}
function closeDetail() {
selectedDocId = null;
document.getElementById("detail-panel").classList.remove("visible");
document
.querySelectorAll(".doc-table tbody tr")
.forEach((tr) => tr.classList.remove("selected"));
}
function renderDetail({ document: doc, detail }) {
const panel = document.getElementById("detail-panel");
const title = document.getElementById("detail-title");
const body = document.getElementById("detail-body");
const period =
doc.purpose_year && doc.purpose_month
? `${doc.purpose_year}${doc.purpose_month}月分`
: "";
title.textContent = `${doc.document_type_name} ${period}`;
// 共通事業所カード
const infoHtml = `
<div class="info-cards">
<div class="info-card">
<div class="label">事業所名</div>
<div class="value">${escHtml(doc.office_name || "")}</div>
</div>
<div class="info-card">
<div class="label">事業所番号 / 整理記号</div>
<div class="value">${escHtml(doc.office_number || "")} / ${escHtml(doc.office_kigo || "")}</div>
</div>
<div class="info-card">
<div class="label">担当年金事務所</div>
<div class="value">${escHtml(doc.nenkin_jimusho || "")}</div>
</div>
${
doc.issue_date
? `<div class="info-card">
<div class="label">発行日</div>
<div class="value">${doc.issue_date.replace(/-/g, "/")}</div>
</div>`
: ""
}
</div>`;
let typeHtml = "";
if (doc.document_type === "SHAKAI") {
typeHtml = renderShakai(detail);
} else if (doc.document_type === "NOUNYU") {
typeHtml = renderNounyu(detail);
} else if (doc.document_type === "SHOYO") {
typeHtml = renderShoyo(detail);
}
body.innerHTML = infoHtml + typeHtml;
panel.classList.add("visible");
panel.scrollIntoView({ behavior: "smooth", block: "start" });
}
// ──────────────────────────────────
// 社会保険料額情報 (SHAKAI)
// ──────────────────────────────────
function renderShakai(d) {
const kigen = d.nofu_kigen_date
? d.nofu_kigen_date.replace(/-/g, "/")
: "";
const addr = [
d.office_postcode ? `${d.office_postcode}` : "",
d.office_address,
]
.filter(Boolean)
.join(" ");
return `
<div class="amount-section">
<h3>保険料額</h3>
<div class="amount-grid">
${amtRow("健康保険料", d.kenkou_hokenryo)}
${amtRow("厚生年金保険料", d.kousei_nenkin_hokenryo)}
${amtRow("子ども・子育て拠出金", d.kodomo_kyoshutsukin)}
${amtRow("合 計", d.total, true)}
</div>
</div>
<div class="info-cards">
<div class="info-card">
<div class="label">納付期限</div>
<div class="value">${kigen}</div>
</div>
${
addr
? `<div class="info-card" style="flex:2">
<div class="label">事業所所在地</div>
<div class="value">${escHtml(addr)}</div>
</div>`
: ""
}
</div>`;
}
// ──────────────────────────────────
// 保険料納入告知額・領収済額 (NOUNYU)
// ──────────────────────────────────
function renderNounyu(d) {
const kigen = d.kokuchi_nofu_kigen
? d.kokuchi_nofu_kigen.replace(/-/g, "/")
: "";
const ryoshuDt = d.ryoshu_date ? d.ryoshu_date.replace(/-/g, "/") : "";
const ryoshuPeriod =
d.ryoshu_nofu_year && d.ryoshu_nofu_month
? `${d.ryoshu_nofu_year}${d.ryoshu_nofu_month}月分`
: "";
return `
<div class="nounyu-compare">
<div class="nounyu-box kokuchi">
<h4>📋 今月の保険料告知額(納付期限: ${kigen}</h4>
<div class="amount-grid">
${amtRow("健康保険料", d.kokuchi_kenkou)}
${amtRow("厚生年金保険料", d.kokuchi_kounen)}
${amtRow("子ども・子育て拠出金", d.kokuchi_kodomo)}
${amtRow("合 計", d.kokuchi_total, true)}
</div>
</div>
<div class="nounyu-box ryoshu">
<h4>✅ 前月分領収済額(${ryoshuPeriod}・領収日: ${ryoshuDt}</h4>
<div class="amount-grid">
${amtRow("健康保険料", d.ryoshu_kenkou)}
${amtRow("厚生年金保険料", d.ryoshu_kounen)}
${amtRow("子ども・子育て拠出金", d.ryoshu_kodomo)}
${amtRow("合 計", d.ryoshu_total, true)}
</div>
</div>
</div>
<div class="info-cards">
<div class="info-card">
<div class="label">事業所所在地</div>
<div class="value">${escHtml([d.office_postcode ? "〒" + d.office_postcode : "", d.office_address].filter(Boolean).join(" "))}</div>
</div>
</div>`;
}
// ──────────────────────────────────
// 賞与保険料算出内訳書 (SHOYO)
// ──────────────────────────────────
function renderShoyo(d) {
if (!d || !d.items) return "<p>詳細データなし</p>";
const totKenpo = sum(d.items, "kenpo_hongetsu");
const totKounen = sum(d.items, "kounen_hongetsu");
const totHKenpo = sum(d.items, "hyojun_shoyo_kenpo");
const totHKounen = sum(d.items, "hyojun_shoyo_kounen");
const rows = d.items
.map((it) => {
const hassei = it.hassei_date_iso
? it.hassei_date_iso.replace(/-/g, "/")
: it.hassei_ymd || "";
return `<tr>
<td>${escHtml(it.seiri_num || "")}</td>
<td class="name">${escHtml(it.shimei || "")}</td>
<td>${hassei}</td>
<td>${fmtAmt(it.hyojun_shoyo_kenpo)}</td>
<td>${fmtAmt(it.hyojun_shoyo_kounen)}</td>
<td>${fmtAmt(it.kenpo_hongetsu)}</td>
<td>${it.kenpo_zengetsu ? fmtAmt(it.kenpo_zengetsu) : '<span class="empty-cell"></span>'}</td>
<td>${fmtAmt(it.kounen_hongetsu)}</td>
<td>${it.kounen_zengetsu ? fmtAmt(it.kounen_zengetsu) : '<span class="empty-cell"></span>'}</td>
</tr>`;
})
.join("");
return `
<div class="info-cards" style="margin-bottom:16px">
<div class="info-card">
<div class="label">対象人員</div>
<div class="value">${d.jin_in_num} 名</div>
</div>
${
d.menjyo_hokenryo_ritsu
? `<div class="info-card">
<div class="label">免除保険料率</div>
<div class="value">${escHtml(d.menjyo_hokenryo_ritsu)}</div>
</div>`
: ""
}
</div>
<div class="shoyo-table-wrap">
<table class="shoyo-table">
<thead>
<tr>
<th rowspan="2">#</th>
<th rowspan="2">氏名</th>
<th rowspan="2">賞与支払日</th>
<th colspan="2" style="background:#fff8e1">標準賞与額</th>
<th colspan="2" style="background:#e3f2fd">健康保険料</th>
<th colspan="2" style="background:#e8f5e9">厚生年金保険料</th>
</tr>
<tr>
<th style="background:#fff8e1">健保</th>
<th style="background:#fff8e1">厚年</th>
<th style="background:#e3f2fd">本月額</th>
<th style="background:#e3f2fd">前月以前</th>
<th style="background:#e8f5e9">本月額</th>
<th style="background:#e8f5e9">前月以前</th>
</tr>
</thead>
<tbody>${rows}</tbody>
<tfoot>
<tr>
<td colspan="3" style="text-align:center">合 計</td>
<td>${fmtAmt(totHKenpo)}</td>
<td>${fmtAmt(totHKounen)}</td>
<td>${fmtAmt(totKenpo)}</td>
<td></td>
<td>${fmtAmt(totKounen)}</td>
<td></td>
</tr>
</tfoot>
</table>
</div>`;
}
// ──────────────────────────────────
// アップロードゾーン
// ──────────────────────────────────
function initUploadZone() {
const zone = document.getElementById("upload-zone");
const input = document.getElementById("zip-input");
zone.addEventListener("dragover", (e) => {
e.preventDefault();
zone.classList.add("dragover");
});
zone.addEventListener("dragleave", () =>
zone.classList.remove("dragover"),
);
zone.addEventListener("drop", (e) => {
e.preventDefault();
zone.classList.remove("dragover");
const files = [...e.dataTransfer.files].filter((f) =>
f.name.endsWith(".zip"),
);
if (files.length) uploadFiles(files);
});
input.addEventListener("change", () => {
const files = [...input.files];
if (files.length) uploadFiles(files);
input.value = "";
});
}
async function uploadFiles(files) {
const status = document.getElementById("upload-status");
status.innerHTML = `<span style="color:#6c757d">アップロード中... (${files.length}件)</span>`;
let ok = 0,
skipped = 0,
errors = [];
for (const file of files) {
const fd = new FormData();
fd.append("file", file);
try {
const res = await fetch(`${API}/nenkin/upload`, {
method: "POST",
body: fd,
});
const json = await res.json();
if (res.status === 409) {
skipped++;
} else if (!res.ok) {
errors.push(`${file.name}: ${json.detail || res.statusText}`);
} else {
ok++;
}
} catch (e) {
errors.push(`${file.name}: ${e.message}`);
}
}
let msg = "";
if (ok > 0) msg += `<span class="status-ok">✓ ${ok}件登録完了</span> `;
if (skipped > 0)
msg += `<span style="color:#856404">⚠ ${skipped}件は既登録のためスキップ</span> `;
if (errors.length)
msg += `<span class="status-err">✗ エラー: ${errors.join(" / ")}</span>`;
status.innerHTML = msg;
if (ok > 0) {
loadDocuments();
loadFilterOptions();
}
}
// ──────────────────────────────────
// ユーティリティ
// ──────────────────────────────────
function escHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function fmtAmt(v) {
if (v == null || v === "") return '<span class="empty-cell"></span>';
return (
"¥" +
Number(v).toLocaleString("ja-JP", {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
})
);
}
function amtRow(label, value, isTotal = false) {
return `<div class="amount-row${isTotal ? " total" : ""}">
<span>${label}</span>
<span class="amt">${fmtAmt(value)}</span>
</div>`;
}
function sum(items, key) {
return items.reduce((acc, it) => acc + (Number(it[key]) || 0), 0);
}
function showDetailError(msg) {
const panel = document.getElementById("detail-panel");
document.getElementById("detail-body").innerHTML =
`<p style="color:#dc3545">エラー: ${escHtml(msg)}</p>`;
panel.classList.add("visible");
}
</script>
</body>
</html>

View File

@@ -2,9 +2,11 @@
<html lang="ja"> <html lang="ja">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="js/auth.js"></script> <script src="js/auth.js"></script>
<title>期首残高入力</title> <title>期首残高入力</title>
<link rel="stylesheet" href="css/style.css" /> <link rel="stylesheet" href="css/style.css" />
<link rel="stylesheet" href="/css/mobile.css" />
</head> </head>
<body> <body>
<h1>期首残高入力</h1> <h1>期首残高入力</h1>
@@ -54,21 +56,20 @@
> >
保存 保存
</button> </button>
<button <a
onclick="location.href = 'index.html'" href="index.html"
style=" style="
display: inline-block;
margin: 0 0 20px 0; margin: 0 0 20px 0;
padding: 8px 16px; padding: 6px 16px;
background: #007bff; background: #757575;
color: white; color: #fff;
border: none;
border-radius: 4px; border-radius: 4px;
cursor: pointer; text-decoration: none;
font-size: 14px; font-size: 14px;
" "
>← メニューへ戻る</a
> >
← メインメニューに戻る
</button>
<script src="js/api.js"></script> <script src="js/api.js"></script>
<script src="js/opening_balance.js"></script> <script src="js/opening_balance.js"></script>

View File

@@ -12,6 +12,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>給与管理 - 月次給与計算</title> <title>給与管理 - 月次給与計算</title>
<link rel="stylesheet" href="/css/style.css" /> <link rel="stylesheet" href="/css/style.css" />
<link rel="stylesheet" href="/css/mobile.css" />
<style> <style>
.container { .container {
max-width: 1440px; max-width: 1440px;
@@ -1236,6 +1237,7 @@
? ((_child / _stdRemun) * 100).toFixed(3) ? ((_child / _stdRemun) * 100).toFixed(3)
: null; : null;
const _socialSub = _health + _care + _pension + _emp + _child; const _socialSub = _health + _care + _pension + _emp + _child;
const _healthCareChild = _health + _care + _child;
const _taxable = _totalPay - _socialSub; const _taxable = _totalPay - _socialSub;
const html = ` const html = `
@@ -1327,6 +1329,10 @@
: "令和8年5月支払分4月分保険料より控除開始 → 当月は対象外" : "令和8年5月支払分4月分保険料より控除開始 → 当月は対象外"
} }
</div> </div>
<div class="calc-note" style="background:#eef6ee; border-left:3px solid #28a745; padding:6px 10px; margin:6px 0; font-size:0.88em; color:#1a5c1a; line-height:1.7">
健康保険 ¥${_health.toLocaleString()} 介護保険 ¥${_care.toLocaleString()} 子ども・子育て支援金 ¥${_child.toLocaleString()} <strong>¥${_healthCareChild.toLocaleString()}</strong><br>
¥${_healthCareChild.toLocaleString()}(上記合計) 厚生年金 ¥${_pension.toLocaleString()}${" "}${_emp > 0 ? ` 雇用保険 ¥${_emp.toLocaleString()}` : ""} <strong>社会保険小計 ¥${_socialSub.toLocaleString()}</strong>
</div>
<div class="detail-row"><span style="font-weight:bold">社会保険小計:</span><span style="font-weight:bold">¥${_socialSub.toLocaleString()}</span></div> <div class="detail-row"><span style="font-weight:bold">社会保険小計:</span><span style="font-weight:bold">¥${_socialSub.toLocaleString()}</span></div>
<div class="detail-row"> <div class="detail-row">
<span>所得税 <small style="color:#666;">(甲欄${dependentCount}人)</small>:</span> <span>所得税 <small style="color:#666;">(甲欄${dependentCount}人)</small>:</span>
@@ -1371,6 +1377,15 @@
const splitRightSalary = document.getElementById("splitRightSalary"); const splitRightSalary = document.getElementById("splitRightSalary");
if (splitRightSalary) splitRightSalary.style.display = "block"; if (splitRightSalary) splitRightSalary.style.display = "block";
document.getElementById("payrollDetail").style.display = "block"; document.getElementById("payrollDetail").style.display = "block";
// モバイルでは明細パネルへ自動スクロール
if (window.innerWidth <= 767 && splitRightSalary) {
setTimeout(function () {
splitRightSalary.scrollIntoView({
behavior: "smooth",
block: "start",
});
}, 50);
}
} catch (error) { } catch (error) {
alert("給与明細の取得に失敗しました"); alert("給与明細の取得に失敗しました");
console.error(error); console.error(error);
@@ -1386,7 +1401,7 @@
// 編集フォームを表示 // 編集フォームを表示
const html = ` const html = `
<div style="position: fixed; top: 50px; left: 50%; transform: translateX(-50%); width: 800px; max-height: 90vh; overflow-y: auto; background: white; padding: 30px; border: 2px solid #007bff; border-radius: 10px; box-shadow: 0 4px 8px rgba(0,0,0,0.2); z-index: 1000;"> <div style="position: fixed; top: 50px; left: 50%; transform: translateX(-50%); width: min(800px, 92vw); max-height: 90vh; overflow-y: auto; background: white; padding: 30px; border: 2px solid #007bff; border-radius: 10px; box-shadow: 0 4px 8px rgba(0,0,0,0.2); z-index: 1000; box-sizing: border-box;">
<h2>給与データ編集</h2> <h2>給与データ編集</h2>
<form id="editPayrollForm" onsubmit="savePayrollEdit(event, ${payrollId})"> <form id="editPayrollForm" onsubmit="savePayrollEdit(event, ${payrollId})">
<div class="detail-section"> <div class="detail-section">
@@ -1782,14 +1797,18 @@
// 源泉税控除対象人数を計算(給与と同じロジック) // 源泉税控除対象人数を計算(給与と同じロジック)
const bDependentCount = employee.dependents const bDependentCount = employee.dependents
? employee.dependents.filter((d) => { ? employee.dependents.filter((d) => {
if (d.valid_to && new Date(d.valid_to) < new Date()) return false; if (d.valid_to && new Date(d.valid_to) < new Date())
return false;
if (!d.birth_date) return false; if (!d.birth_date) return false;
const birthDate = new Date(d.birth_date); const birthDate = new Date(d.birth_date);
const targetYear = bonus.bonus_year; const targetYear = bonus.bonus_year;
const yearEnd = new Date(targetYear, 11, 31); const yearEnd = new Date(targetYear, 11, 31);
let age = yearEnd.getFullYear() - birthDate.getFullYear(); let age = yearEnd.getFullYear() - birthDate.getFullYear();
const monthDiff = yearEnd.getMonth() - birthDate.getMonth(); const monthDiff = yearEnd.getMonth() - birthDate.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && yearEnd.getDate() < birthDate.getDate())) { if (
monthDiff < 0 ||
(monthDiff === 0 && yearEnd.getDate() < birthDate.getDate())
) {
age--; age--;
} }
return age >= 16; return age >= 16;
@@ -1837,6 +1856,7 @@
? ((_bChild / _bTotal) * 100).toFixed(4) ? ((_bChild / _bTotal) * 100).toFixed(4)
: null; : null;
const _bSocialSub = _bHealth + _bCare + _bPension + _bEmp + _bChild; const _bSocialSub = _bHealth + _bCare + _bPension + _bEmp + _bChild;
const _bHealthCareChild = _bHealth + _bCare + _bChild;
const _bTaxable = _bTotal - _bSocialSub; const _bTaxable = _bTotal - _bSocialSub;
const html = ` const html = `
@@ -1901,6 +1921,10 @@
: "令和8年5月支払分4月分保険料より控除開始 → 当賞与は対象外" : "令和8年5月支払分4月分保険料より控除開始 → 当賞与は対象外"
} }
</div> </div>
<div class="calc-note" style="background:#eef6ee; border-left:3px solid #28a745; padding:6px 10px; margin:6px 0; font-size:0.88em; color:#1a5c1a; line-height:1.7">
健康保険 ¥${_bHealth.toLocaleString()} 介護保険 ¥${_bCare.toLocaleString()} 子ども・子育て支援金 ¥${_bChild.toLocaleString()} <strong>¥${_bHealthCareChild.toLocaleString()}</strong><br>
¥${_bHealthCareChild.toLocaleString()}(上記合計) 厚生年金 ¥${_bPension.toLocaleString()}${" "}${_bEmp > 0 ? ` 雇用保険 ¥${_bEmp.toLocaleString()}` : ""} <strong>社会保険小計 ¥${_bSocialSub.toLocaleString()}</strong>
</div>
<div class="detail-row"><span style="font-weight:bold">社会保険小計:</span><span style="font-weight:bold">¥${_bSocialSub.toLocaleString()}</span></div> <div class="detail-row"><span style="font-weight:bold">社会保険小計:</span><span style="font-weight:bold">¥${_bSocialSub.toLocaleString()}</span></div>
<div class="detail-row"> <div class="detail-row">
<span>所得税 <small style="color:#666;">(甲欄${bDependentCount}人)</small>:</span> <span>所得税 <small style="color:#666;">(甲欄${bDependentCount}人)</small>:</span>
@@ -1943,6 +1967,15 @@
const splitRightBonus = document.getElementById("splitRightBonus"); const splitRightBonus = document.getElementById("splitRightBonus");
if (splitRightBonus) splitRightBonus.style.display = "block"; if (splitRightBonus) splitRightBonus.style.display = "block";
document.getElementById("bonusDetail").style.display = "block"; document.getElementById("bonusDetail").style.display = "block";
// モバイルでは明細パネルへ自動スクロール
if (window.innerWidth <= 767 && splitRightBonus) {
setTimeout(function () {
splitRightBonus.scrollIntoView({
behavior: "smooth",
block: "start",
});
}, 50);
}
} catch (error) { } catch (error) {
alert("賞与明細の取得に失敗しました"); alert("賞与明細の取得に失敗しました");
console.error(error); console.error(error);
@@ -1955,7 +1988,7 @@
const bonus = await response.json(); const bonus = await response.json();
const html = ` const html = `
<div style="position: fixed; top: 50px; left: 50%; transform: translateX(-50%); width: 600px; max-height: 90vh; overflow-y: auto; background: white; padding: 30px; border: 2px solid #007bff; border-radius: 10px; box-shadow: 0 4px 8px rgba(0,0,0,0.2); z-index: 1000;"> <div style="position: fixed; top: 50px; left: 50%; transform: translateX(-50%); width: min(600px, 92vw); max-height: 90vh; overflow-y: auto; background: white; padding: 30px; border: 2px solid #007bff; border-radius: 10px; box-shadow: 0 4px 8px rgba(0,0,0,0.2); z-index: 1000; box-sizing: border-box;">
<h2>賞与データ編集</h2> <h2>賞与データ編集</h2>
<form id="editBonusForm" onsubmit="saveBonusEdit(event, ${bonusId})"> <form id="editBonusForm" onsubmit="saveBonusEdit(event, ${bonusId})">
<div class="detail-section"> <div class="detail-section">
@@ -1968,10 +2001,10 @@
<div class="form-group"> <div class="form-group">
<label>賞与種別</label> <label>賞与種別</label>
<select name="bonus_type"> <select name="bonus_type">
<option value="夏季賞与" ${bonus.bonus_type === '夏季賞与' ? 'selected' : ''}>夏季賞与</option> <option value="夏季賞与" ${bonus.bonus_type === "夏季賞与" ? "selected" : ""}>夏季賞与</option>
<option value="冬季賞与" ${bonus.bonus_type === '冬季賞与' ? 'selected' : ''}>冬季賞与</option> <option value="冬季賞与" ${bonus.bonus_type === "冬季賞与" ? "selected" : ""}>冬季賞与</option>
<option value="決算賞与" ${bonus.bonus_type === '決算賞与' ? 'selected' : ''}>決算賞与</option> <option value="決算賞与" ${bonus.bonus_type === "決算賞与" ? "selected" : ""}>決算賞与</option>
<option value="その他" ${bonus.bonus_type === 'その他' ? 'selected' : ''}>その他</option> <option value="その他" ${bonus.bonus_type === "その他" ? "selected" : ""}>その他</option>
</select> </select>
</div> </div>
</div> </div>
@@ -2450,7 +2483,7 @@
const bonusPaymentDate = bonus.payment_date const bonusPaymentDate = bonus.payment_date
? (() => { ? (() => {
const d = new Date(bonus.payment_date); const d = new Date(bonus.payment_date);
return `${d.getFullYear()}${String(d.getMonth()+1).padStart(2,'0')}${String(d.getDate()).padStart(2,'0')}`; return `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}${String(d.getDate()).padStart(2, "0")}`;
})() })()
: `${bonus.bonus_year}${String(bonus.bonus_month).padStart(2, "0")}${getLastDayOfMonth(bonus.bonus_year, bonus.bonus_month)}`; : `${bonus.bonus_year}${String(bonus.bonus_month).padStart(2, "0")}${getLastDayOfMonth(bonus.bonus_year, bonus.bonus_month)}`;
if (currentKey && currentKey !== currentKey_new) { if (currentKey && currentKey !== currentKey_new) {
@@ -2615,19 +2648,33 @@
// CSVデータを生成 // CSVデータを生成
let csvContent = let csvContent =
"データ,従業員コード,従業員名,年月,支給額,控除額,差引支給額\n"; "データ,従業員コード,従業員名,年月,支給額,社会保険,所得税,控除額合計,差引支給額\n";
// 給与データ // 給与データ
if (result.data.salary) { if (result.data.salary) {
(result.data.salary || []).forEach((salary) => { (result.data.salary || []).forEach((salary) => {
csvContent += `給与,${salary.employee_code},${salary.employee_name},${salary.payroll_year}-${String(salary.payroll_month).padStart(2, "0")},${salary.total_payment},${salary.total_deduction},${salary.net_payment}\n`; const socialIns =
Number(salary.health_insurance || 0) +
Number(salary.care_insurance || 0) +
Number(salary.pension_insurance || 0) +
Number(salary.employment_insurance || 0) +
Number(salary.child_support || 0);
const incomeTax = Number(salary.income_tax || 0);
csvContent += `給与,${salary.employee_code},${salary.employee_name},${salary.payroll_year}-${String(salary.payroll_month).padStart(2, "0")},${salary.total_payment},${socialIns},${incomeTax},${salary.total_deduction},${salary.net_payment}\n`;
}); });
} }
// 賞与データ // 賞与データ
if (result.data.bonus) { if (result.data.bonus) {
(result.data.bonus || []).forEach((bonus) => { (result.data.bonus || []).forEach((bonus) => {
csvContent += `賞与,${bonus.employee_code},${bonus.employee_name},${bonus.bonus_year}-${String(bonus.bonus_month).padStart(2, "0")},${bonus.total_bonus},${bonus.total_deduction},${bonus.net_bonus}\n`; const socialIns =
Number(bonus.health_insurance || 0) +
Number(bonus.care_insurance || 0) +
Number(bonus.pension_insurance || 0) +
Number(bonus.employment_insurance || 0) +
Number(bonus.child_support || 0);
const incomeTax = Number(bonus.income_tax || 0);
csvContent += `賞与,${bonus.employee_code},${bonus.employee_name},${bonus.bonus_year}-${String(bonus.bonus_month).padStart(2, "0")},${bonus.total_bonus},${socialIns},${incomeTax},${bonus.total_deduction},${bonus.net_bonus}\n`;
}); });
} }

View File

@@ -12,6 +12,7 @@
rel="stylesheet" rel="stylesheet"
/> />
<link rel="stylesheet" href="/css/style.css" /> <link rel="stylesheet" href="/css/style.css" />
<link rel="stylesheet" href="/css/mobile.css" />
<style> <style>
body { body {
font-family: "Noto Sans JP", Arial, sans-serif; font-family: "Noto Sans JP", Arial, sans-serif;

View File

@@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>給与管理 - 給与設定</title> <title>給与管理 - 給与設定</title>
<link rel="stylesheet" href="/css/style.css" /> <link rel="stylesheet" href="/css/style.css" />
<link rel="stylesheet" href="/css/mobile.css" />
<style> <style>
.employee-select { .employee-select {
margin-bottom: 20px; margin-bottom: 20px;

View File

@@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>給与管理 - 設定</title> <title>給与管理 - 設定</title>
<link rel="stylesheet" href="/css/style.css" /> <link rel="stylesheet" href="/css/style.css" />
<link rel="stylesheet" href="/css/mobile.css" />
<style> <style>
.nav-tabs { .nav-tabs {
display: flex; display: flex;

View File

@@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>給与管理システム</title> <title>給与管理システム</title>
<link rel="stylesheet" href="/css/style.css" /> <link rel="stylesheet" href="/css/style.css" />
<link rel="stylesheet" href="/css/mobile.css" />
<style> <style>
.menu-grid { .menu-grid {
display: grid; display: grid;
@@ -55,21 +56,19 @@
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<button <a
onclick="location.href = '../index.html'" href="index.html"
class="back-button"
style=" style="
padding: 8px 16px; display: inline-block;
background: #007bff; padding: 6px 16px;
color: white; background: #757575;
border: none; color: #fff;
border-radius: 4px; border-radius: 4px;
cursor: pointer; text-decoration: none;
font-size: 14px; font-size: 14px;
" "
>← メニューへ戻る</a
> >
← メインメニューに戻る
</button>
<h1>給与管理システム</h1> <h1>給与管理システム</h1>
<p>従業員の給与計算、扶養管理、保険料・税金の管理を行います</p> <p>従業員の給与計算、扶養管理、保険料・税金の管理を行います</p>
@@ -110,6 +109,25 @@
<h2>税率・保険料設定</h2> <h2>税率・保険料設定</h2>
<p>社会保険料率、所得税率表の管理を行います</p> <p>社会保険料率、所得税率表の管理を行います</p>
</div> </div>
<div
class="menu-card"
onclick="location.href = 'withholding-slip.html'"
>
<div class="icon">📄</div>
<h2>源泉徴収票</h2>
<p>
給与所得の源泉徴収票を発行します(本人用・区役所用・税務署用 各1部
</p>
</div>
<div class="menu-card" onclick="location.href = 'nenkin-portal.html'">
<div class="icon">📋</div>
<h2>社会保険電子文書</h2>
<p>
日本年金機構からの電子文書(保険料通知・賞与内訳等)を管理します
</p>
</div>
</div> </div>
<div <div
@@ -135,6 +153,10 @@
<strong>月次給与計算</strong <strong>月次給与計算</strong
>で勤怠情報を入力し、給与を計算・承認します >で勤怠情報を入力し、給与を計算・承認します
</li> </li>
<li>
<strong>源泉徴収票</strong
>で年末に給与所得の源泉徴収票本人・区役所・税務署の3部を発行します
</li>
</ol> </ol>
</div> </div>
</div> </div>

View File

@@ -2,8 +2,11 @@
<html lang="ja"> <html lang="ja">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="js/auth.js"></script> <script src="js/auth.js"></script>
<title>試算表</title> <title>試算表</title>
<link rel="stylesheet" href="css/style.css" />
<link rel="stylesheet" href="/css/mobile.css" />
<style> <style>
body { body {
font-family: "MS Gothic", "Meiryo", sans-serif; font-family: "MS Gothic", "Meiryo", sans-serif;
@@ -264,22 +267,20 @@
</style> </style>
</head> </head>
<body> <body>
<button <a
onclick="location.href = 'index.html'" href="index.html"
class="back-button"
style=" style="
display: inline-block;
margin: 0 0 20px 0; margin: 0 0 20px 0;
padding: 8px 16px; padding: 6px 16px;
background: #007bff; background: #757575;
color: white; color: #fff;
border: none;
border-radius: 4px; border-radius: 4px;
cursor: pointer; text-decoration: none;
font-size: 14px; font-size: 14px;
" "
>← メニューへ戻る</a
> >
← 首ページに戻る
</button>
<h2>残高試算表(貸借・損益)</h2> <h2>残高試算表(貸借・損益)</h2>
@@ -291,6 +292,20 @@
</div> </div>
<div class="search-form"> <div class="search-form">
<label for="fiscalYear">年度:</label>
<select
id="fiscalYear"
style="
padding: 4px 8px;
border: 1px solid #ccc;
border-radius: 3px;
font-size: 13px;
min-width: 200px;
"
>
<option value="">-- 選択してください --</option>
</select>
<label for="dateFrom">開始年月日:</label> <label for="dateFrom">開始年月日:</label>
<input type="date" id="dateFrom" min="1900-01-01" max="2099-12-31" /> <input type="date" id="dateFrom" min="1900-01-01" max="2099-12-31" />

File diff suppressed because it is too large Load Diff

74
nas_deploy.py Normal file
View File

@@ -0,0 +1,74 @@
"""NASにファイルを転送してコンテナを再起動するスクリプト"""
import paramiko
import os
HOST = "192.168.0.61"
USER = "root"
PASS = "59911784"
NAS_BASE = "/volume1/docker/njts-accounting"
files = [
# (ローカルパス, NASパス)
(r"c:\workspace\njts-accounting-core\backend\app\modules\bank_statements\__init__.py",
f"{NAS_BASE}/backend/app/modules/bank_statements/__init__.py"),
(r"c:\workspace\njts-accounting-core\backend\app\modules\bank_statements\router.py",
f"{NAS_BASE}/backend/app/modules/bank_statements/router.py"),
(r"c:\workspace\njts-accounting-core\backend\app\modules\bank_statements\migration.py",
f"{NAS_BASE}/backend/app/modules/bank_statements/migration.py"),
(r"c:\workspace\njts-accounting-core\backend\app\db_auto_migration.py",
f"{NAS_BASE}/backend/app/db_auto_migration.py"),
(r"c:\workspace\njts-accounting-core\backend\app\main.py",
f"{NAS_BASE}/backend/app/main.py"),
# e-Gov モジュール
(r"c:\workspace\njts-accounting-core\backend\app\modules\egov\egov_api.py",
f"{NAS_BASE}/backend/app/modules/egov/egov_api.py"),
(r"c:\workspace\njts-accounting-core\backend\app\modules\egov\router.py",
f"{NAS_BASE}/backend/app/modules/egov/router.py"),
# フロントエンド
(r"c:\workspace\njts-accounting-core\frontend\bank-statement.html",
f"{NAS_BASE}/backend/frontend/bank-statement.html"),
(r"c:\workspace\njts-accounting-core\frontend\index.html",
f"{NAS_BASE}/backend/frontend/index.html"),
(r"c:\workspace\njts-accounting-core\frontend\journal-entry.html",
f"{NAS_BASE}/backend/frontend/journal-entry.html"),
(r"c:\workspace\njts-accounting-core\frontend\trial-balance.html",
f"{NAS_BASE}/backend/frontend/trial-balance.html"),
(r"c:\workspace\njts-accounting-core\frontend\opening_balance.html",
f"{NAS_BASE}/backend/frontend/opening_balance.html"),
(r"c:\workspace\njts-accounting-core\frontend\egov.html",
f"{NAS_BASE}/backend/frontend/egov.html"),
(r"c:\workspace\njts-accounting-core\frontend\payroll.html",
f"{NAS_BASE}/backend/frontend/payroll.html"),
]
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(HOST, username=USER, password=PASS, timeout=30)
print(f"SSH接続: {HOST}")
# ディレクトリ作成
stdin, stdout, stderr = client.exec_command(
f"mkdir -p {NAS_BASE}/backend/app/modules/bank_statements && mkdir -p {NAS_BASE}/backend/frontend"
)
stdout.read()
print("ディレクトリ作成完了")
# SFTPでファイル転送
sftp = client.open_sftp()
for local_path, remote_path in files:
sftp.put(local_path, remote_path)
print(f"転送: {os.path.basename(local_path)}{remote_path}")
sftp.close()
# コンテナ再起動
print("\nコンテナ再起動中...")
cmd = f"cd {NAS_BASE} && docker compose restart backend"
stdin, stdout, stderr = client.exec_command(cmd, timeout=60)
out = stdout.read().decode()
err = stderr.read().decode()
code = stdout.channel.recv_exit_status()
if out: print("stdout:", out)
if err: print("stderr:", err)
print(f"再起動完了 (exit={code})")
client.close()
print("完了!")

23
nas_restart.py Normal file
View File

@@ -0,0 +1,23 @@
"""NASのDockerコンテナをパスワード認証で再起動するスクリプト"""
import paramiko
HOST = "192.168.0.61"
USER = "root"
PASS = "59911784"
CMD = "cd /volume1/docker/njts-accounting && docker compose restart backend"
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(HOST, username=USER, password=PASS, timeout=30)
print(f"接続成功: {HOST}")
stdin, stdout, stderr = client.exec_command(CMD, timeout=60)
out = stdout.read().decode()
err = stderr.read().decode()
code = stdout.channel.recv_exit_status()
if out: print("stdout:", out)
if err: print("stderr:", err)
print("exit code:", code)
client.close()

62
read_openapi.py Normal file
View File

@@ -0,0 +1,62 @@
import json
with open(r"docs\openapi_検証.json", encoding="utf-8") as f:
api = json.load(f)
components = api.get("components", {}).get("schemas", {})
# results_apply の内容
ra = components.get("results_apply", {})
props = ra.get("properties", {})
print("=== results_apply ===")
for k, v in props.items():
desc = v.get("description", "")[:100]
print(f" {k}: {v.get('type','?')} - {desc}")
# apply/lists のパラメータ
print("\n=== /apply/lists parameters ===")
get = api["paths"]["/apply/lists"].get("get", {})
for p in get.get("parameters", []):
pname = p.get("name", "")
pdesc = p.get("description", "")[:60]
print(f" {pname} ({p.get('in','')}): {pdesc}")
# results_apply_lists
ral = components.get("results_apply_lists", {})
print("results_apply_lists props:", list(ral.get("properties", {}).keys())[:10])
TARGET_PATHS = ["/apply", "/apply/lists", "/apply/{arrive_id}", "/post/lists", "/post/{post_id}", "/procedure/{proc_id}"]
for path in TARGET_PATHS:
path_obj = api["paths"].get(path, {})
if not path_obj:
continue
print(f"\n=== {path} ===")
for method in ["get", "post", "put", "delete"]:
if method not in path_obj:
continue
details = path_obj[method]
if not isinstance(details, dict):
continue
summary = details.get("summary", "")
print(f" {method.upper()}: {summary}")
rb = details.get("requestBody", {})
if rb:
content = rb.get("content", {})
print(f" request content-types: {list(content.keys())}")
for ct, schema_wrap in content.items():
schema = schema_wrap.get("schema", {})
props = schema.get("properties", {})
required = schema.get("required", [])
print(f" required: {required}")
for pname, pdef in list(props.items())[:10]:
desc = str(pdef.get("description", ""))[:60]
print(f" {pname}: {pdef.get('type','?')} - {desc}")
resp200 = details.get("responses", {}).get("200", {})
resp_content = resp200.get("content", {})
if resp_content:
for ct, schema_wrap in resp_content.items():
schema = schema_wrap.get("schema", {})
results_schema = schema.get("properties", {}).get("results", {})
results_props = results_schema.get("properties", {})
print(f" response results props: {list(results_props.keys())[:10]}")

94
test_org_id.py Normal file
View File

@@ -0,0 +1,94 @@
import sys, os, base64, zipfile, io, re, json, asyncio
sys.path.insert(0, 'backend')
import httpx
import paramiko
def _fill_empty_tag(text, tag, value):
text = re.sub(r'<' + re.escape(tag) + r'\s*/>', f'<{tag}>{value}</{tag}>', text)
text = re.sub(r'<' + re.escape(tag) + r'\s*>\s*</' + re.escape(tag) + r'>', f'<{tag}>{value}</{tag}>', text)
return text
def modify_skeleton(file_data_b64, org_id, apply_type):
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':
text = data.decode('utf-8', errors='replace')
fills = [
('受付行政機関ID', org_id),
('手続ID', '900A013800001000'),
('手続名称', 'APIテスト用手続(電子送達関係手続)(通)0001'),
('申請種別', apply_type),
('氏名フリガナ', 'テスト タロウ'),
('氏名', 'テスト タロウ'),
('郵便番号', '1300022'),
('住所', '東京都墨田区江東橋4丁目31番10号'),
]
for tag, value in fills:
text = _fill_empty_tag(text, tag, value)
data = text.encode('utf-8')
zout.writestr(item, data)
return base64.b64encode(buf_out.getvalue()).decode()
async def test(org_id, access_token, file_data_b64):
modified = modify_skeleton(file_data_b64, org_id, '新規申請')
async with httpx.AsyncClient() as client:
r = await client.post(
'https://api2.sbx.e-gov.go.jp/shinsei/v2/post-apply',
headers={
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json',
},
json={
'proc_id': '900A013800001000',
'send_file': {
'file_name': '900A013800001000.zip',
'file_data': modified,
},
},
timeout=120,
)
print(f'org_id={org_id}: HTTP {r.status_code}')
try:
body = r.json()
if r.is_success:
print('SUCCESS:', json.dumps(body, ensure_ascii=False))
else:
report = body.get('report_list', [])
for rep in report:
print(' ERROR:', rep.get('content', ''))
if not report:
print(' BODY:', json.dumps(body, ensure_ascii=False)[:300])
except Exception:
print('BODY:', r.text[:500])
return r.status_code
async def main():
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('192.168.0.61', username='root', password='59911784')
stdin, stdout, stderr = ssh.exec_command('cat /volume1/docker/njts-accounting/backend/egov_data/tokens.json')
tokens = json.loads(stdout.read())
access_token = tokens['access_token']
ssh.close()
# スケルトン取得
resp = httpx.get(
'https://api2.sbx.e-gov.go.jp/shinsei/v2/procedure/900A013800001000',
headers={'Authorization': f'Bearer {access_token}'},
timeout=30
)
file_data_b64 = resp.json()['results']['file_data']
for org_id in ['100001', '100138', '100900', '100000', '100013']:
sc = await test(org_id, access_token, file_data_b64)
if sc == 200:
break
asyncio.run(main())

89
test_proc_name.py Normal file
View File

@@ -0,0 +1,89 @@
import re, json, httpx, paramiko, base64, zipfile, io, asyncio
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('192.168.0.61', username='root', password='59911784')
stdin, stdout, stderr = ssh.exec_command('cat /volume1/docker/njts-accounting/backend/egov_data/tokens.json')
tokens = json.loads(stdout.read())
access_token = tokens['access_token']
ssh.close()
resp = httpx.get(
'https://api2.sbx.e-gov.go.jp/shinsei/v2/procedure/900A013800001000',
headers={'Authorization': f'Bearer {access_token}'},
timeout=30
)
file_data_b64 = resp.json()['results']['file_data']
candidates = [
'APIテスト用手続(電子送達関係手続)(通)0001',
'APIテスト用手続(電子送達関係手続)(通)0001_01',
'電子送付開始手続き',
'【オンライン事業所年金情報サービス】電子送付開始手続き',
'APIテスト用手続(電子送達関係手続)',
'APIテスト用手続(電子送達関係手続)(通)',
'オンライン事業所年金情報サービス 電子送付開始手続き',
'APIテスト用手続(電子送達関係手続)(通)0001 電子送付開始手続き',
]
def fill_tag(text, tag, value):
text = re.sub(r'<' + re.escape(tag) + r'\s*/>', f'<{tag}>{value}</{tag}>', text)
text = re.sub(r'<' + re.escape(tag) + r'\s*>\s*</' + re.escape(tag) + r'>', f'<{tag}>{value}</{tag}>', text)
return text
def build_zip(file_data_b64, proc_name):
raw = base64.b64decode(file_data_b64)
buf = io.BytesIO()
with zipfile.ZipFile(io.BytesIO(raw)) as zin:
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zout:
for item in zin.infolist():
data = zin.read(item.filename)
if 'kousei' in item.filename.lower():
text = data.decode('utf-8', 'replace')
for tag, val in [
('受付行政機関ID', '100001'),
('手続ID', '900A013800001000'),
('手続名称', proc_name),
('申請種別', '新規申請'),
('氏名フリガナ', 'テスト タロウ'),
('氏名', 'テスト タロウ'),
('郵便番号', '1300022'),
('住所', '東京都墨田区江東橋4丁目31番10号'),
]:
text = fill_tag(text, tag, val)
data = text.encode('utf-8')
zout.writestr(item, data)
return base64.b64encode(buf.getvalue()).decode()
async def try_name(proc_name):
modified = build_zip(file_data_b64, proc_name)
async with httpx.AsyncClient() as client:
r = await client.post(
'https://api2.sbx.e-gov.go.jp/shinsei/v2/post-apply',
headers={'Authorization': f'Bearer {access_token}', 'Content-Type': 'application/json'},
json={'proc_id': '900A013800001000', 'send_file': {'file_name': '900A013800001000.zip', 'file_data': modified}},
timeout=60,
)
name_short = repr(proc_name[:35])
print(f'name={name_short}: HTTP {r.status_code}')
if r.is_success:
print(' SUCCESS:', json.dumps(r.json(), ensure_ascii=False)[:200])
else:
body = r.json()
errs = [rep.get('content', '') for rep in body.get('report_list', [])]
if errs:
for e in errs:
print(f' ERR: {e}')
else:
print(f' BODY: {json.dumps(body, ensure_ascii=False)[:200]}')
return r.status_code
async def main():
for name in candidates:
sc = await try_name(name)
if sc == 200:
break
asyncio.run(main())

BIN
testproclist.xlsx Normal file

Binary file not shown.