Compare commits
10 Commits
a071a11df3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcec6822e9 | ||
|
|
4a500ff5e9 | ||
|
|
91632ea5b6 | ||
|
|
8c66d820c8 | ||
|
|
ebc87f96f9 | ||
|
|
e8690d130b | ||
|
|
cea3016fbe | ||
|
|
4ea283941c | ||
|
|
da3aef25ce | ||
|
|
63ac6bc99a |
10
.env
10
.env
@@ -3,3 +3,13 @@ DB_PORT=55432
|
||||
DB_NAME=njts_acct
|
||||
DB_USER=njts_app
|
||||
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
16
_check_compose.py
Normal 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
7
_check_containers.py
Normal 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
15
_check_mount.py
Normal 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
10
_check_nas_index.py
Normal 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
7
_check_nas_index2.py
Normal 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
7
_pw_test.py
Normal 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")
|
||||
@@ -139,6 +139,13 @@ def run_auto_migration():
|
||||
|
||||
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:
|
||||
print(f"\n❌ 無法連接到數據庫或執行遷移: {e}")
|
||||
print("\n提示:")
|
||||
|
||||
@@ -2,9 +2,11 @@ from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from fastapi.responses import JSONResponse, RedirectResponse, Response
|
||||
from pydantic import ValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from starlette.datastructures import MutableHeaders
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
|
||||
# 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(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=False,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Content-Type", "Authorization"],
|
||||
)
|
||||
|
||||
# ─────────────────────────
|
||||
@@ -97,6 +123,12 @@ app.include_router(cash.router)
|
||||
from app.routers import file_upload
|
||||
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)
|
||||
# ─────────────────────────
|
||||
@@ -118,6 +150,11 @@ app.include_router(payroll_bonus_router)
|
||||
from app.payroll.vouchers.router import router as 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
|
||||
import os
|
||||
|
||||
0
backend/app/modules/bank_statements/__init__.py
Normal file
0
backend/app/modules/bank_statements/__init__.py
Normal file
41
backend/app/modules/bank_statements/migration.py
Normal file
41
backend/app/modules/bank_statements/migration.py
Normal 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()
|
||||
437
backend/app/modules/bank_statements/router.py
Normal file
437
backend/app/modules/bank_statements/router.py
Normal 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}
|
||||
0
backend/app/modules/egov/__init__.py
Normal file
0
backend/app/modules/egov/__init__.py
Normal file
478
backend/app/modules/egov/egov_api.py
Normal file
478
backend/app/modules/egov/egov_api.py
Normal 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()
|
||||
539
backend/app/modules/egov/router.py
Normal file
539
backend/app/modules/egov/router.py
Normal 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)}
|
||||
517
backend/app/modules/egov/scraper.py
Normal file
517
backend/app/modules/egov/scraper.py
Normal 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
|
||||
85
backend/app/modules/egov/state.py
Normal file
85
backend/app/modules/egov/state.py
Normal 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()
|
||||
0
backend/app/modules/nenkin_portal/__init__.py
Normal file
0
backend/app/modules/nenkin_portal/__init__.py
Normal file
119
backend/app/modules/nenkin_portal/migration.py
Normal file
119
backend/app/modules/nenkin_portal/migration.py
Normal 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("✓ ねんきんポータル テーブル作成完了")
|
||||
281
backend/app/modules/nenkin_portal/router.py
Normal file
281
backend/app/modules/nenkin_portal/router.py
Normal 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}
|
||||
351
backend/app/modules/nenkin_portal/service.py
Normal file
351
backend/app/modules/nenkin_portal/service.py
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,54 @@ from fastapi import APIRouter, HTTPException, Query
|
||||
from decimal import Decimal
|
||||
from typing import Dict, Any, List, Optional
|
||||
from app.modules.trial_balance.service import fetch_trial_balance
|
||||
from app.core.database import get_connection
|
||||
|
||||
router = APIRouter(prefix="/trial-balance", tags=["試算表"])
|
||||
|
||||
@router.get("/fiscal-years", summary="仕訳データから会計年度一覧取得(5月期)")
|
||||
def get_fiscal_years():
|
||||
REIWA_OFFSET = 2018 # 令和元年 = 2019年
|
||||
|
||||
with get_connection() as conn, conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT
|
||||
MIN(entry_date)::date AS min_date,
|
||||
MAX(entry_date)::date AS max_date
|
||||
FROM journal_entries
|
||||
WHERE is_deleted = false
|
||||
""")
|
||||
row = cur.fetchone()
|
||||
|
||||
if not row or not row["min_date"]:
|
||||
return []
|
||||
|
||||
min_date = row["min_date"]
|
||||
max_date = row["max_date"]
|
||||
|
||||
def fiscal_end_year(d):
|
||||
return d.year + 1 if d.month >= 6 else d.year
|
||||
|
||||
start_fy = fiscal_end_year(min_date)
|
||||
end_fy = fiscal_end_year(max_date)
|
||||
|
||||
result = []
|
||||
for fy in range(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="試算表(全科目)")
|
||||
def get_trial_balance(
|
||||
fiscal_year: Optional[int] = Query(None, description="会計年度(例: 2025)"),
|
||||
|
||||
@@ -12,6 +12,12 @@ class LoginRequest(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
username: str
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
@@ -66,39 +72,48 @@ async def login(request: LoginRequest):
|
||||
|
||||
@router.post("/register")
|
||||
async def register(request: LoginRequest):
|
||||
"""新規ユーザー登録"""
|
||||
try:
|
||||
if len(request.password) < 6:
|
||||
raise HTTPException(status_code=400, detail="パスワードは6文字以上である必要があります")
|
||||
"""新規ユーザー登録(無効化済み)"""
|
||||
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:
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
# ユーザーが既に存在するか確認
|
||||
cur.execute("SELECT id FROM users WHERE username = %s", (request.username,))
|
||||
if cur.fetchone():
|
||||
cur.execute(
|
||||
"SELECT id, password FROM users WHERE username = %s AND is_active = TRUE",
|
||||
(request.username,)
|
||||
)
|
||||
user = cur.fetchone()
|
||||
|
||||
if not user:
|
||||
cur.close()
|
||||
conn.close()
|
||||
raise HTTPException(status_code=400, detail="このユーザー名は既に使用されています")
|
||||
raise HTTPException(status_code=401, detail="ユーザーが見つかりません")
|
||||
|
||||
# ユーザーを登録
|
||||
hashed_password = hash_password(request.password)
|
||||
if user["password"] != hash_password(request.current_password):
|
||||
cur.close()
|
||||
conn.close()
|
||||
raise HTTPException(status_code=401, detail="現在のパスワードが正しくありません")
|
||||
|
||||
new_hashed = hash_password(request.new_password)
|
||||
cur.execute(
|
||||
"INSERT INTO users (username, password) VALUES (%s, %s) RETURNING id, username",
|
||||
(request.username, hashed_password)
|
||||
"UPDATE users SET password = %s WHERE username = %s",
|
||||
(new_hashed, request.username)
|
||||
)
|
||||
new_user = cur.fetchone()
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
"id": new_user["id"],
|
||||
"username": new_user["username"],
|
||||
"message": "ユーザー登録が完了しました"
|
||||
}
|
||||
return {"message": "パスワードを変更しました"}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"登録処理エラー: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"パスワード変更エラー: {str(e)}")
|
||||
|
||||
0
backend/app/payroll/withholding_slip/__init__.py
Normal file
0
backend/app/payroll/withholding_slip/__init__.py
Normal file
71
backend/app/payroll/withholding_slip/router.py
Normal file
71
backend/app/payroll/withholding_slip/router.py
Normal 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))
|
||||
95
backend/app/payroll/withholding_slip/schemas.py
Normal file
95
backend/app/payroll/withholding_slip/schemas.py
Normal 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
|
||||
388
backend/app/payroll/withholding_slip/service.py
Normal file
388
backend/app/payroll/withholding_slip/service.py
Normal 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
|
||||
@@ -97,3 +97,53 @@ def get_trial_balance(
|
||||
rows = cur.fetchall()
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
@router.get("/fiscal-years", summary="仕訳データから会計年度一覧取得(5月期)")
|
||||
def get_fiscal_years():
|
||||
"""
|
||||
仕訳の最小・最大日付から5月期の年度リストを生成して返す。
|
||||
例: 2025-06-01〜2026-05-31 → {"label": "令和8年(2026年)5月期", "date_from": "2025-06-01", "date_to": "2026-05-31"}
|
||||
"""
|
||||
REIWA_OFFSET = 2018 # 令和元年 = 2019年
|
||||
|
||||
with get_connection() as conn, conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT
|
||||
MIN(entry_date)::date AS min_date,
|
||||
MAX(entry_date)::date AS max_date
|
||||
FROM journal_entries
|
||||
WHERE is_deleted = false
|
||||
""")
|
||||
row = cur.fetchone()
|
||||
|
||||
if not row or not row["min_date"]:
|
||||
return []
|
||||
|
||||
min_date = row["min_date"]
|
||||
max_date = row["max_date"]
|
||||
|
||||
# 5月期の年度を計算: 決算年(end_year)は entry_date の月が6以上なら翌年、1〜5なら当年
|
||||
def fiscal_end_year(d):
|
||||
return d.year + 1 if d.month >= 6 else d.year
|
||||
|
||||
start_fy = fiscal_end_year(min_date)
|
||||
end_fy = fiscal_end_year(max_date)
|
||||
|
||||
result = []
|
||||
for fy in range(start_fy, end_fy + 1): # 古い順
|
||||
reiwa = fy - REIWA_OFFSET
|
||||
if reiwa > 0:
|
||||
wareki = f"令和{reiwa}年"
|
||||
elif reiwa == 0:
|
||||
wareki = "平成31/令和元年"
|
||||
else:
|
||||
wareki = f"平成{fy - 1988}年"
|
||||
label = f"{wareki}({fy}年)5月期"
|
||||
result.append({
|
||||
"label": label,
|
||||
"date_from": f"{fy - 1}-06-01",
|
||||
"date_to": f"{fy}-05-31",
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
@@ -6,6 +6,7 @@ colorama==0.4.6
|
||||
email-validator==2.3.0
|
||||
fastapi==0.124.4
|
||||
h11==0.16.0
|
||||
httpx==0.28.1
|
||||
idna==3.11
|
||||
openpyxl==3.1.2
|
||||
psycopg==3.3.2
|
||||
@@ -20,3 +21,4 @@ typing_extensions==4.15.0
|
||||
tzdata==2025.3
|
||||
uvicorn==0.38.0
|
||||
xlrd==2.0.1
|
||||
playwright==1.44.0
|
||||
|
||||
51
check_openapi.py
Normal file
51
check_openapi.py
Normal 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
63
create_bank_table.py
Normal 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
21
docker-compose.nas.yml
Normal 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
|
||||
@@ -13,7 +13,7 @@ server {
|
||||
}
|
||||
|
||||
# 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_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
BIN
docs/07nen252 (1).xlsx
Normal file
BIN
docs/07nen252 (1).xlsx
Normal file
Binary file not shown.
BIN
docs/1381260411286271_20260520233507.zip
Normal file
BIN
docs/1381260411286271_20260520233507.zip
Normal file
Binary file not shown.
BIN
docs/1381260411410876_20260520233324.zip
Normal file
BIN
docs/1381260411410876_20260520233324.zip
Normal file
Binary file not shown.
BIN
docs/1381260511867049_20260520233223.zip
Normal file
BIN
docs/1381260511867049_20260520233223.zip
Normal file
Binary file not shown.
87
docs/1381260511954054/202605200027318947.xml
Normal file
87
docs/1381260511954054/202605200027318947.xml
Normal 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>【事業主の皆さまへのご案内】<br/><br/>オンライン事業所年金情報サービスでは、社会保険料を口座振替で納付している事業主の方に「保険料納入告知額・領収済額通知書」の電子送付をしていますので、ぜひこの機会に電子送付の希望登録をお願いします。<br/><br/>また、オンライン事業所年金情報サービスを利用した被保険者データの受け取りは、これまで算定月・賞与支払予定月のみ可能でしたが、令和7年1月から希望する月で被保険者データを受け取ることができるようになりました。<br/><br/>詳細については、日本年金機構ホームページをご覧ください。<br/><a href="https://www.nenkin.go.jp/tokusetsu/online_jigyousho.html">https://www.nenkin.go.jp/tokusetsu/online_jigyousho.html</a><br/><br/>※上記は、既に保険料納入告知額・領収済額通知書の電子送付や被保険者データの送付希望月の登録を行っていただいている事業主の方にもご案内しています。</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
|
||||
SUkctAOYG+tshpaDkMVhsP/LWKrLIuZS9aVHB9+XDr5wUUQzs/rcTEiVMf/v9RuZ3lFZ91uObHQW
|
||||
RZuv3s27LMLgJm/3iJUWyLVjQV0jfHeG/qSIfJTihY68eId8zEVNdLu6FS/SeFX81UHJbhNOaFJH
|
||||
9wveJyLcd8Sdqtw09V31cvgHo1O4mMyYCB+97OfIPk6eRUaw6groo1dGJIX+rhkq7NWC0SOoDRmT
|
||||
hKRhC8epW/URlHa+uIvEtKomszXf2eCiJk1QQA==</SignatureValue><KeyInfo><X509Data><X509Certificate>MIIFKTCCBBGgAwIBAgIPODUzNDEzNDYzOTk4NzA5MA0GCSqGSIb3DQEBCwUAMEYxCzAJBgNVBAYT
|
||||
AkpQMRwwGgYDVQQKDBNKYXBhbmVzZSBHb3Zlcm5tZW50MRkwFwYDVQQLDBBPZmZpY2lhbFN0YXR1
|
||||
c0NBMB4XDTIyMDUzMTE1MDAwMFoXDTI3MDUzMTE0NTk1OVowgYkxCzAJBgNVBAYTAkpQMRwwGgYD
|
||||
VQQKDBNKYXBhbmVzZSBHb3Zlcm5tZW50MS8wLQYDVQQLDCZNaW5pc3RyeSBvZiBIZWFsdGgsIExh
|
||||
Ym91ciBhbmQgV2VsZmFyZTErMCkGA1UEAwwiUHJlc2lkZW50IG9mIEphcGFuIFBlbnNpb24gU2Vy
|
||||
dmljZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALFcx67Yj+R7aDJLXozKT+D6ajGM
|
||||
eSLL46Zu/KLrcADNR2/4/kvznDy+cC6ekizUtR3j9JPC0UHt1ECmUAvmyX/KKQToNfFRguqkH30X
|
||||
nCm0LgE0yzoocreCLHb4Q5HL82ThM0hICUQRhE5/nKn6BPVEMVIi4WvkrF56LIhsxh9yiqbQzcMh
|
||||
2gbKJ9qwbXlf2t57vR5HrXuQxVmzhwh2miI/8tGBDRte6xBmQQ9iMStx/M7pC2FWuULAThA9zIop
|
||||
S7G8M2c9Quz3da7rfSWQX+Cr29G2geym2wGwBiZSLOr9Haua6kyT/38i6kWnjFz+V7f90ETGGIzP
|
||||
0s4mEBboG/8CAwEAAaOCAc4wggHKMB8GA1UdIwQYMBaAFFIm8Y7mfjbpZKQk94zYbSJcqFWZMB0G
|
||||
A1UdDgQWBBTtZSAS2Of86iDljw/HbL63ufMtezAOBgNVHQ8BAf8EBAMCBsAwVwYDVR0gAQH/BE0w
|
||||
SzBJBgsCgziGjjEIAwEBbjA6MDgGCCsGAQUFBwIBFixodHRwczovL3d3dy5ncGtpLmdvLmpwL29z
|
||||
Y2EvY3BjcHMvaW5kZXguaHRtbDB0BgNVHREEbTBrpGkwZzELMAkGA1UEBhMCSlAxGDAWBgNVBAoM
|
||||
D+aXpeacrOWbveaUv+W6nDEYMBYGA1UECwwP5Y6a55Sf5Yq05YON55yBMSQwIgYDVQQDDBvml6Xm
|
||||
nKzlubTph5HmqZ/mp4vnkIbkuovplbcwTgYDVR0SBEcwRaRDMEExCzAJBgNVBAYTAkpQMRgwFgYD
|
||||
VQQKDA/ml6XmnKzlm73mlL/lupwxGDAWBgNVBAsMD+WumOiBt+iqjeiovOWxgDBZBgNVHR8EUjBQ
|
||||
ME6gTKBKpEgwRjELMAkGA1UEBhMCSlAxHDAaBgNVBAoME0phcGFuZXNlIEdvdmVybm1lbnQxGTAX
|
||||
BgNVBAsMEE9mZmljaWFsU3RhdHVzQ0EwDQYJKoZIhvcNAQELBQADggEBAJapGHTU0z30nXPccxUf
|
||||
JHriV0vVs8RZaSm5gwXZKJDdsEG/pfjDYze28aNVzL496zqmCZED0XIdZlI5vfWR2QY0xUwzldYG
|
||||
phx0pyG1OzlQN8W247S5XsJ3eYXqcgc0qO8399vtKSHVCa4OjvFH3kEn/IfmsxWBT+10roe4HyDK
|
||||
ddkT1eMGu8ccCc8WCd2SoEwQmCERtV85+Jbf0AQ95opqGUQWxAT+0n6x1Gyi9ggKyU7ifonxGUOY
|
||||
nvAEIb2U+rebwvNwRN8V9G2W/LNIkiV57/Hs6H8P8ugfoNGWeAU1pzD4MfC4zuJg8veLELs+G6Xh
|
||||
cG+ewK5GiwhySdvKexs=</X509Certificate><X509Certificate>MIIEBDCCAuygAwIBAgIPNzI4MDQyMzYwNDg2MzczMA0GCSqGSIb3DQEBCwUAMEYxCzAJBgNVBAYT
|
||||
AkpQMRwwGgYDVQQKDBNKYXBhbmVzZSBHb3Zlcm5tZW50MRkwFwYDVQQLDBBPZmZpY2lhbFN0YXR1
|
||||
c0NBMB4XDTE5MDgyMzE1MDAwMFoXDTI5MDgyMzE0NTk1OVowRjELMAkGA1UEBhMCSlAxHDAaBgNV
|
||||
BAoME0phcGFuZXNlIEdvdmVybm1lbnQxGTAXBgNVBAsMEE9mZmljaWFsU3RhdHVzQ0EwggEiMA0G
|
||||
CSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDkaCKUqxDTGx50NrhjVYKvvAYTlrmidWexGpna7JXx
|
||||
SQFi0umtGe904ZbZOwSld8gxXqRJCxLx64ZUtPG25hHgYnCfxhkIA+p3nomgkwGrauSHsdBU9co9
|
||||
ioKUAHPWpsp1CR2EFhE13NBBX2anxnqxil1KvInGOOl/Hf9mJ5Jz85oLgZHWxMDtRrKL2fPgXZ72
|
||||
+YHv5QsyqZwFy3J9qPnQueUM7lSoYNidlyiIyhbtC8/68OH513nnksv+w65qEuXJh/LNj/y0ruAB
|
||||
V3EyjHbd4oWyf7HHD4JO8ITa5ABMt2kSc91IxaSeJDBvD0rUbf4bOSnshTyQaN/gJL7Wc4xZAgMB
|
||||
AAGjge4wgeswHQYDVR0OBBYEFFIm8Y7mfjbpZKQk94zYbSJcqFWZMA4GA1UdDwEB/wQEAwIBBjBO
|
||||
BgNVHREERzBFpEMwQTELMAkGA1UEBhMCSlAxGDAWBgNVBAoMD+aXpeacrOWbveaUv+W6nDEYMBYG
|
||||
A1UECwwP5a6Y6IG36KqN6Ki85bGAMFkGA1UdHwRSMFAwTqBMoEqkSDBGMQswCQYDVQQGEwJKUDEc
|
||||
MBoGA1UECgwTSmFwYW5lc2UgR292ZXJubWVudDEZMBcGA1UECwwQT2ZmaWNpYWxTdGF0dXNDQTAP
|
||||
BgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQCjy1OYTCLtF1mViU3uLdA2BtbOH+qa
|
||||
RqQkhJTT5Mbqblwhp+I1TaHwO7kT0Tnw74f4e2nkUd5ByMyodojFk3+/Xj1CMG56dYjDkC8GBR8+
|
||||
QdrEzDF4o6nui9xqMfzyevMFasaRdMSyWpeSjCquMJjkgc5l691Rwn2ixoEUol+tfhwmKVkQttUq
|
||||
Bwu5ofnwTF814RTMh4IJFQL4UloXRkRBduNh+xLvWf7sBjirI7FrLfkvkkz7YAS1ToGkEGSb+CU3
|
||||
8GHXwH3oul8/5SUZriNXUexKwLqzCjjqUsca2bDDP8A7myX7zdMNKWMhweSbOpedzaR+gF2Lrsx4
|
||||
wU0AP+AY</X509Certificate></X509Data></KeyInfo></Signature></DOC>
|
||||
148
docs/1381260511954054/kagami.xsl
Normal file
148
docs/1381260511954054/kagami.xsl
Normal 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,1,2)"/>
|
||||
<xsl:value-of select="format-number(substring(DATE,3,2),'##')"/>
|
||||
<xsl:value-of select="substring(DATE,5,1)"/>
|
||||
<xsl:value-of select="format-number(substring(DATE,6,2),'##')"/>
|
||||
<xsl:value-of select="substring(DATE,8,1)"/>
|
||||
<xsl:value-of select="format-number(substring(DATE,9,2),'##')"/>
|
||||
<xsl:value-of select="substring(DATE,11,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(' ',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>
|
||||
733
docs/1381260511954054/yoshiki_03_shoyo_002.xsl
Normal file
733
docs/1381260511954054/yoshiki_03_shoyo_002.xsl
Normal 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 <= $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 <= $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 >= $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">&nbsp;&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">&nbsp;&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,'<br/>')">
|
||||
<xsl:variable name="sub" select="substring-before($str,'<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,'<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">&nbsp;&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>
|
||||
@@ -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>賞与保険料算出内訳書の概要や見方については日本年金機構ホームページをご確認ください。<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></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>
|
||||
@@ -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",""
|
||||
|
@@ -0,0 +1,2 @@
|
||||
"納付目的年月","処理年月日","整理番号","表示","被保険者氏名","発生年月日(賞与支払年月日)","標準賞与額_健保","標準賞与額_厚年","健康保険料_本月額","健康保険料_前月以前額","厚生年金保険料_本月額","厚生年金保険料_前月以前額"
|
||||
"令和8年4月","R080501","2"," 4","張 翔鶴","R080430","4300","1500","503,100.0","","274,500.00",""
|
||||
|
32
docs/MEISAI20260531234440.csv
Normal file
32
docs/MEISAI20260531234440.csv
Normal 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"
|
||||
|
BIN
docs/denshishinsei-guide_0813.pdf
Normal file
BIN
docs/denshishinsei-guide_0813.pdf
Normal file
Binary file not shown.
BIN
docs/e-Gov 電子申請サービス 電子申請 API API 利用ガイド.pdf
Normal file
BIN
docs/e-Gov 電子申請サービス 電子申請 API API 利用ガイド.pdf
Normal file
Binary file not shown.
BIN
docs/e-Gov検証環境ログイン方法.xlsx
Normal file
BIN
docs/e-Gov検証環境ログイン方法.xlsx
Normal file
Binary file not shown.
7384
docs/openapi.json
Normal file
7384
docs/openapi.json
Normal file
File diff suppressed because it is too large
Load Diff
7384
docs/openapi_検証.json
Normal file
7384
docs/openapi_検証.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
docs/三菱UFJ_APIのアカウント資料.xlsx
Normal file
BIN
docs/三菱UFJ_APIのアカウント資料.xlsx
Normal file
Binary file not shown.
BIN
docs/振込-三菱UFJ銀行.pdf
Normal file
BIN
docs/振込-三菱UFJ銀行.pdf
Normal file
Binary file not shown.
BIN
docs/最終確認試験用データ情報(TID_202606010049)_1版.xlsx
Normal file
BIN
docs/最終確認試験用データ情報(TID_202606010049)_1版.xlsx
Normal file
Binary file not shown.
BIN
docs/最終確認試験用データ情報(TID_202606010049)_1版.zip
Normal file
BIN
docs/最終確認試験用データ情報(TID_202606010049)_1版.zip
Normal file
Binary file not shown.
BIN
egov_guide.pdf
Normal file
BIN
egov_guide.pdf
Normal file
Binary file not shown.
BIN
final_test_spec_post.xlsx
Normal file
BIN
final_test_spec_post.xlsx
Normal file
Binary file not shown.
@@ -2,9 +2,11 @@
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<script src="js/auth.js"></script>
|
||||
<title>科目マスタ</title>
|
||||
<link rel="stylesheet" href="css/style.css" />
|
||||
<link rel="stylesheet" href="/css/mobile.css" />
|
||||
</head>
|
||||
<body>
|
||||
<h1>科目マスタ</h1>
|
||||
|
||||
1208
frontend/bank-statement.html
Normal file
1208
frontend/bank-statement.html
Normal file
File diff suppressed because it is too large
Load Diff
62
frontend/chat/Caddyfile
Normal file
62
frontend/chat/Caddyfile
Normal 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
|
||||
}
|
||||
1
frontend/chat/caddy_config/caddy/autosave.json
Normal file
1
frontend/chat/caddy_config/caddy/autosave.json
Normal 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}]}}}}}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"status": "valid",
|
||||
"termsOfServiceAgreed": true,
|
||||
"location": "https://acme-staging-v02.api.letsencrypt.org/acme/acct/254799123"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
-----BEGIN EC PRIVATE KEY-----
|
||||
MHcCAQEEIKj+HhAaYqX1lWm4mLP2Et8b5rxVmCuGgMZpfvT/KEN4oAoGCCqGSM49
|
||||
AwEHoUQDQgAED9JJ499KtFgQS78wE5cxUs3iQoBq75s3my0knVRJah0H3VwwOtJq
|
||||
vKMmgyX4vUT0fDTYfgrvrLALcJ9QT4Bx9Q==
|
||||
-----END EC PRIVATE KEY-----
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"status": "valid",
|
||||
"termsOfServiceAgreed": true,
|
||||
"location": "https://acme-v02.api.letsencrypt.org/acme/acct/2926552736"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
-----BEGIN EC PRIVATE KEY-----
|
||||
MHcCAQEEIDs4tIRZUhyrIoIgZSO6ZBAYg+HzW0l0j2OlVoMzJY6ioAoGCCqGSM49
|
||||
AwEHoUQDQgAEv375la4g5tzk4LddVNaLZ7jBelpGzkADYERdZzELLLFL0NEciAa1
|
||||
Lus7P6OxGoc30AsCw34co3SXMhWEp3gsAg==
|
||||
-----END EC PRIVATE KEY-----
|
||||
1
frontend/chat/caddy_data/caddy/instance.uuid
Normal file
1
frontend/chat/caddy_data/caddy/instance.uuid
Normal file
@@ -0,0 +1 @@
|
||||
268cd375-73e5-4ccb-88ca-c0051a74b1f2
|
||||
1
frontend/chat/caddy_data/caddy/last_clean.json
Normal file
1
frontend/chat/caddy_data/caddy/last_clean.json
Normal file
@@ -0,0 +1 @@
|
||||
{"tls":{"timestamp":"2026-01-01T11:51:10.149840886Z","instance_id":"268cd375-73e5-4ccb-88ca-c0051a74b1f2"}}
|
||||
68
frontend/chat/certs/fullchain.pem
Normal file
68
frontend/chat/certs/fullchain.pem
Normal 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-----
|
||||
5
frontend/chat/certs/key.pem
Normal file
5
frontend/chat/certs/key.pem
Normal file
@@ -0,0 +1,5 @@
|
||||
-----BEGIN EC PRIVATE KEY-----
|
||||
MHcCAQEEIPjpQxKBjFFc8UvRnWDDKX2OP+ypSHXGYZ2cpRgPBP3koAoGCCqGSM49
|
||||
AwEHoUQDQgAEOp7tnlqOPWi6GswskLGaQ7J8pjU5YQjlNgPXVYr93VWTWp5oBt+Q
|
||||
1EPZoLSErJ05xzVgYTzzPf5Z92jk+crZsg==
|
||||
-----END EC PRIVATE KEY-----
|
||||
62
frontend/chat/docker-compose.yml
Normal file
62
frontend/chat/docker-compose.yml
Normal 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
379
frontend/css/mobile.css
Normal 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
1103
frontend/egov.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,8 +2,15 @@
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<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>
|
||||
<link rel="stylesheet" href="css/style.css" />
|
||||
<link rel="stylesheet" href="/css/mobile.css" />
|
||||
<script src="js/auth.js"></script>
|
||||
<style>
|
||||
body {
|
||||
@@ -79,6 +86,103 @@
|
||||
.logout-btn:hover {
|
||||
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>
|
||||
</head>
|
||||
<body>
|
||||
@@ -86,10 +190,45 @@
|
||||
<h1>NJTS</h1>
|
||||
<div class="user-info">
|
||||
<span>ユーザー: <strong id="username">読み込み中...</strong></span>
|
||||
<button class="change-pw-btn" onclick="openChangePw()">
|
||||
パスワード変更
|
||||
</button>
|
||||
<button class="logout-btn" onclick="logout()">ログアウト</button>
|
||||
</div>
|
||||
</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">
|
||||
<section class="card">
|
||||
<h2>会計システム</h2>
|
||||
@@ -118,6 +257,31 @@
|
||||
<p>給与・賞与の計算や従業員管理を行います。</p>
|
||||
<p><a class="btn" href="payroll.html">給与システムを開く</a></p>
|
||||
</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>
|
||||
|
||||
<script>
|
||||
@@ -129,18 +293,67 @@
|
||||
}
|
||||
});
|
||||
|
||||
// dropdown toggle
|
||||
const toggle = document.getElementById("accountingToggle");
|
||||
const menu = document.getElementById("accountingMenu");
|
||||
toggle.addEventListener("click", (e) => {
|
||||
menu.style.display = menu.style.display === "block" ? "none" : "block";
|
||||
});
|
||||
// click outside to close
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!toggle.contains(e.target) && !menu.contains(e.target)) {
|
||||
menu.style.display = "none";
|
||||
// パスワード変更モーダル
|
||||
function openChangePw() {
|
||||
document.getElementById("cpCurrent").value = "";
|
||||
document.getElementById("cpNew").value = "";
|
||||
document.getElementById("cpConfirm").value = "";
|
||||
const msg = document.getElementById("changePwMsg");
|
||||
msg.style.display = "none";
|
||||
document.getElementById("changePwModal").classList.add("active");
|
||||
}
|
||||
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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<script src="js/auth.js"></script>
|
||||
<link rel="stylesheet" href="/css/mobile.css" />
|
||||
<title>修正仕訳入力</title>
|
||||
<style>
|
||||
body {
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<script src="js/auth.js"></script>
|
||||
<link rel="stylesheet" href="/css/mobile.css" />
|
||||
<title>仕訳入力</title>
|
||||
<style>
|
||||
body {
|
||||
@@ -255,22 +257,20 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<button
|
||||
onclick="location.href = 'index.html'"
|
||||
class="back-button"
|
||||
<a
|
||||
href="index.html"
|
||||
style="
|
||||
display: inline-block;
|
||||
margin: 0 0 20px 0;
|
||||
padding: 8px 16px;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 6px 16px;
|
||||
background: #757575;
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
"
|
||||
>← メニューへ戻る</a
|
||||
>
|
||||
← 首ページに戻る
|
||||
</button>
|
||||
<h2>仕訳入力</h2>
|
||||
|
||||
<div class="row" style="margin-bottom: 12px">
|
||||
@@ -861,7 +861,19 @@
|
||||
<div class="search-buttons">
|
||||
<button onclick="searchJournals()">🔍 検索</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>
|
||||
|
||||
@@ -2828,7 +2840,8 @@
|
||||
// 複数行フォームへのコピー
|
||||
function copyJournalToDetailForm(data) {
|
||||
document.getElementById("detailEntryDate").value = data.entry_date;
|
||||
document.getElementById("detailDescription").value = data.description || "";
|
||||
document.getElementById("detailDescription").value =
|
||||
data.description || "";
|
||||
|
||||
// 既存行をクリア
|
||||
document.getElementById("detailLinesTbody").innerHTML = "";
|
||||
@@ -2844,10 +2857,14 @@
|
||||
accountInput.dataset.accountId = acc.account_id;
|
||||
}
|
||||
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) {
|
||||
tr.querySelector(".detail-credit").value = Number(line.credit).toLocaleString();
|
||||
tr.querySelector(".detail-credit").value = Number(
|
||||
line.credit,
|
||||
).toLocaleString();
|
||||
}
|
||||
if (line.line_description) {
|
||||
tr.querySelector(".detail-line-memo").value = line.line_description;
|
||||
@@ -2877,58 +2894,121 @@
|
||||
|
||||
// 新UIでは簡易的にフォームに反映(最初の借方行の科目 + 最初の貸方行を取引手段に設定)
|
||||
if (data.lines && data.lines.length > 0) {
|
||||
// 借方と貸方を分類
|
||||
const debitLines = data.lines.filter((l) => Number(l.debit) > 0);
|
||||
const creditLines = data.lines.filter((l) => Number(l.credit) > 0);
|
||||
// 借方と貸方を分類
|
||||
const debitLines = data.lines.filter((l) => Number(l.debit) > 0);
|
||||
const creditLines = data.lines.filter((l) => Number(l.credit) > 0);
|
||||
|
||||
// 仮払/仮受消費税行を除外して主要な科目を特定
|
||||
const mainDebit = debitLines.find((l) => {
|
||||
const acc = accounts.find((a) => a.account_id === l.account_id);
|
||||
return (
|
||||
acc &&
|
||||
!acc.account_name.includes("仮払消費税") &&
|
||||
!acc.account_name.includes("仮受消費税")
|
||||
// 仮払/仮受消費税行を除外して主要な科目を特定
|
||||
const mainDebit = debitLines.find((l) => {
|
||||
const acc = accounts.find((a) => a.account_id === l.account_id);
|
||||
return (
|
||||
acc &&
|
||||
!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 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 methodAcc = accounts.find(
|
||||
(a) => a.account_id === mainCredit.account_id,
|
||||
);
|
||||
});
|
||||
|
||||
// 税額行を検出
|
||||
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) {
|
||||
// 支出(仮払消費税あり)
|
||||
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(mainCredit.credit);
|
||||
document.getElementById("entryAmount").value =
|
||||
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(
|
||||
'input[name="entryType"][value="expense"]',
|
||||
).checked = true;
|
||||
const kamokuAcc = accounts.find(
|
||||
(a) => a.account_id === mainDebit.account_id,
|
||||
);
|
||||
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 (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}`;
|
||||
@@ -2936,116 +3016,47 @@
|
||||
"entryMethodInput",
|
||||
).dataset.accountId = methodAcc.account_id;
|
||||
}
|
||||
const totalAmount = Number(mainCredit.credit);
|
||||
document.getElementById("entryAmount").value =
|
||||
totalAmount.toLocaleString();
|
||||
document.getElementById("entryTaxAmount").value = Number(
|
||||
taxDebitLine.debit,
|
||||
document.getElementById("entryAmount").value = Number(
|
||||
mainDebit.debit,
|
||||
).toLocaleString();
|
||||
} else if (taxCreditLine) {
|
||||
// 収入(仮受消費税あり)
|
||||
} else {
|
||||
document.querySelector(
|
||||
'input[name="entryType"][value="income"]',
|
||||
).checked = true;
|
||||
const kamokuAcc = accounts.find(
|
||||
const kamokuAccC = accounts.find(
|
||||
(a) => a.account_id === mainCredit.account_id,
|
||||
);
|
||||
const methodAcc = accounts.find(
|
||||
(a) => a.account_id === mainDebit.account_id,
|
||||
);
|
||||
if (kamokuAcc) {
|
||||
if (kamokuAccC) {
|
||||
document.getElementById("entryAccountInput").value =
|
||||
`${kamokuAcc.account_code} ${kamokuAcc.account_name}`;
|
||||
`${kamokuAccC.account_code} ${kamokuAccC.account_name}`;
|
||||
document.getElementById(
|
||||
"entryAccountInput",
|
||||
).dataset.accountId = kamokuAcc.account_id;
|
||||
).dataset.accountId = kamokuAccC.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,
|
||||
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();
|
||||
} 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");
|
||||
dateInput.focus();
|
||||
const dateInput = document.getElementById("entryDate");
|
||||
dateInput.focus();
|
||||
|
||||
showAlert(
|
||||
`仕訳を複製しました。\n\n※ このコピーは新規仕訳として独立した記録になります。\n※ 日付と摘要を必要に応じて修正してから登録してください。`,
|
||||
"success",
|
||||
);
|
||||
showAlert(
|
||||
`仕訳を複製しました。\n\n※ このコピーは新規仕訳として独立した記録になります。\n※ 日付と摘要を必要に応じて修正してから登録してください。`,
|
||||
"success",
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<script src="js/auth.js"></script>
|
||||
<link rel="stylesheet" href="/css/mobile.css" />
|
||||
<title>仕訳一覧</title>
|
||||
<style>
|
||||
body {
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<script src="js/auth.js"></script>
|
||||
<link rel="stylesheet" href="/css/mobile.css" />
|
||||
<title>仕訳詳細</title>
|
||||
<style>
|
||||
body {
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<script src="js/auth.js"></script>
|
||||
<title>仕訳入力</title>
|
||||
<link rel="stylesheet" href="css/style.css" />
|
||||
<link rel="stylesheet" href="/css/mobile.css" />
|
||||
</head>
|
||||
<body>
|
||||
<h1>仕訳入力</h1>
|
||||
|
||||
@@ -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() {
|
||||
const user = localStorage.getItem("currentUser");
|
||||
if (!user) {
|
||||
// 用户未登录,重定向到登录页面
|
||||
window.location.href = "/login.html";
|
||||
window.location.replace("/login.html");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 获取当前登录的用户信息
|
||||
function getCurrentUser() {
|
||||
const user = localStorage.getItem("currentUser");
|
||||
return user ? JSON.parse(user) : null;
|
||||
}
|
||||
|
||||
// 保存用户信息(登录时使用)
|
||||
function setCurrentUser(userData) {
|
||||
localStorage.setItem("currentUser", JSON.stringify(userData));
|
||||
}
|
||||
|
||||
// 注销用户
|
||||
function logout() {
|
||||
localStorage.removeItem("currentUser");
|
||||
window.location.href = "/login.html";
|
||||
sessionStorage.clear();
|
||||
// replace() を使うことでログイン前の履歴に戻れなくなる
|
||||
window.location.replace("/login.html");
|
||||
}
|
||||
|
||||
// 在页面加载时检查认证(仅在非登录页面使用)
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const currentPage = window.location.pathname;
|
||||
|
||||
// 排除登录页面的检查
|
||||
if (!currentPage.includes("login.html")) {
|
||||
if (!window.location.pathname.includes("login.html")) {
|
||||
checkAuth();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
const dateFrom = document.getElementById("dateFrom").value;
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<script src="js/auth.js"></script>
|
||||
<title>元帳</title>
|
||||
<link rel="stylesheet" href="css/style.css" />
|
||||
<link rel="stylesheet" href="/css/mobile.css" />
|
||||
</head>
|
||||
<body>
|
||||
<h1 id="account-title">元帳</h1>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>NJTS 会計システム - ログイン</title>
|
||||
<link rel="stylesheet" href="/css/mobile.css" />
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
@@ -108,16 +109,6 @@
|
||||
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 {
|
||||
padding: 12px;
|
||||
border-radius: 5px;
|
||||
@@ -138,32 +129,6 @@
|
||||
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 {
|
||||
display: none;
|
||||
text-align: center;
|
||||
@@ -181,7 +146,7 @@
|
||||
</div>
|
||||
|
||||
<!-- ログインフォーム -->
|
||||
<div id="loginForm" class="form active">
|
||||
<div id="loginForm">
|
||||
<div id="messageLogin" class="message"></div>
|
||||
|
||||
<div class="form-group">
|
||||
@@ -208,47 +173,8 @@
|
||||
<button class="btn-login" onclick="handleLogin()">ログイン</button>
|
||||
</div>
|
||||
|
||||
<div class="toggle-form">
|
||||
新規登録?
|
||||
<a onclick="toggleForm()">アカウント作成</a>
|
||||
</div>
|
||||
|
||||
<div class="loading" id="loginLoading">処理中...</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>
|
||||
|
||||
<script>
|
||||
@@ -330,82 +256,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRegister() {
|
||||
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キーでログイン/登録
|
||||
// EnterキーでログインできるようにEnterキーをバインド
|
||||
document.addEventListener("keypress", (e) => {
|
||||
if (e.key === "Enter") {
|
||||
const loginForm = document.getElementById("loginForm");
|
||||
if (loginForm.classList.contains("active")) {
|
||||
handleLogin();
|
||||
} else {
|
||||
handleRegister();
|
||||
}
|
||||
handleLogin();
|
||||
}
|
||||
});
|
||||
|
||||
// ログインページから後退ボタンで離脱できないようにする
|
||||
// (ログアウト後に戻るボタンでメニューが見えてしまう問題の対策)
|
||||
history.pushState(null, "", window.location.href);
|
||||
window.addEventListener("popstate", function () {
|
||||
history.pushState(null, "", window.location.href);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
929
frontend/nenkin-portal.html
Normal file
929
frontend/nenkin-portal.html
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -2,9 +2,11 @@
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<script src="js/auth.js"></script>
|
||||
<title>期首残高入力</title>
|
||||
<link rel="stylesheet" href="css/style.css" />
|
||||
<link rel="stylesheet" href="/css/mobile.css" />
|
||||
</head>
|
||||
<body>
|
||||
<h1>期首残高入力</h1>
|
||||
@@ -54,21 +56,20 @@
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
<button
|
||||
onclick="location.href = 'index.html'"
|
||||
<a
|
||||
href="index.html"
|
||||
style="
|
||||
display: inline-block;
|
||||
margin: 0 0 20px 0;
|
||||
padding: 8px 16px;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 6px 16px;
|
||||
background: #757575;
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
"
|
||||
>← メニューへ戻る</a
|
||||
>
|
||||
← メインメニューに戻る
|
||||
</button>
|
||||
|
||||
<script src="js/api.js"></script>
|
||||
<script src="js/opening_balance.js"></script>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>給与管理 - 月次給与計算</title>
|
||||
<link rel="stylesheet" href="/css/style.css" />
|
||||
<link rel="stylesheet" href="/css/mobile.css" />
|
||||
<style>
|
||||
.container {
|
||||
max-width: 1440px;
|
||||
@@ -1236,6 +1237,7 @@
|
||||
? ((_child / _stdRemun) * 100).toFixed(3)
|
||||
: null;
|
||||
const _socialSub = _health + _care + _pension + _emp + _child;
|
||||
const _healthCareChild = _health + _care + _child;
|
||||
const _taxable = _totalPay - _socialSub;
|
||||
|
||||
const html = `
|
||||
@@ -1327,6 +1329,10 @@
|
||||
: "令和8年5月支払分(4月分保険料)より控除開始 → 当月は対象外"
|
||||
}
|
||||
</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>所得税 <small style="color:#666;">(甲欄${dependentCount}人)</small>:</span>
|
||||
@@ -1371,6 +1377,15 @@
|
||||
const splitRightSalary = document.getElementById("splitRightSalary");
|
||||
if (splitRightSalary) splitRightSalary.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) {
|
||||
alert("給与明細の取得に失敗しました");
|
||||
console.error(error);
|
||||
@@ -1386,7 +1401,7 @@
|
||||
|
||||
// 編集フォームを表示
|
||||
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>
|
||||
<form id="editPayrollForm" onsubmit="savePayrollEdit(event, ${payrollId})">
|
||||
<div class="detail-section">
|
||||
@@ -1782,14 +1797,18 @@
|
||||
// 源泉税控除対象人数を計算(給与と同じロジック)
|
||||
const bDependentCount = employee.dependents
|
||||
? 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;
|
||||
const birthDate = new Date(d.birth_date);
|
||||
const targetYear = bonus.bonus_year;
|
||||
const yearEnd = new Date(targetYear, 11, 31);
|
||||
let age = yearEnd.getFullYear() - birthDate.getFullYear();
|
||||
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--;
|
||||
}
|
||||
return age >= 16;
|
||||
@@ -1837,6 +1856,7 @@
|
||||
? ((_bChild / _bTotal) * 100).toFixed(4)
|
||||
: null;
|
||||
const _bSocialSub = _bHealth + _bCare + _bPension + _bEmp + _bChild;
|
||||
const _bHealthCareChild = _bHealth + _bCare + _bChild;
|
||||
const _bTaxable = _bTotal - _bSocialSub;
|
||||
|
||||
const html = `
|
||||
@@ -1901,6 +1921,10 @@
|
||||
: "令和8年5月支払分(4月分保険料)より控除開始 → 当賞与は対象外"
|
||||
}
|
||||
</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>所得税 <small style="color:#666;">(甲欄${bDependentCount}人)</small>:</span>
|
||||
@@ -1943,6 +1967,15 @@
|
||||
const splitRightBonus = document.getElementById("splitRightBonus");
|
||||
if (splitRightBonus) splitRightBonus.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) {
|
||||
alert("賞与明細の取得に失敗しました");
|
||||
console.error(error);
|
||||
@@ -1955,7 +1988,7 @@
|
||||
const bonus = await response.json();
|
||||
|
||||
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>
|
||||
<form id="editBonusForm" onsubmit="saveBonusEdit(event, ${bonusId})">
|
||||
<div class="detail-section">
|
||||
@@ -1968,10 +2001,10 @@
|
||||
<div class="form-group">
|
||||
<label>賞与種別</label>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2450,7 +2483,7 @@
|
||||
const bonusPaymentDate = 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)}日`;
|
||||
if (currentKey && currentKey !== currentKey_new) {
|
||||
@@ -2615,19 +2648,33 @@
|
||||
|
||||
// CSVデータを生成
|
||||
let csvContent =
|
||||
"データ,従業員コード,従業員名,年月,支給額,控除額,差引支給額\n";
|
||||
"データ,従業員コード,従業員名,年月,支給額,社会保険,所得税,控除額合計,差引支給額\n";
|
||||
|
||||
// 給与データ
|
||||
if (result.data.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) {
|
||||
(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`;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="stylesheet" href="/css/style.css" />
|
||||
<link rel="stylesheet" href="/css/mobile.css" />
|
||||
<style>
|
||||
body {
|
||||
font-family: "Noto Sans JP", Arial, sans-serif;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>給与管理 - 給与設定</title>
|
||||
<link rel="stylesheet" href="/css/style.css" />
|
||||
<link rel="stylesheet" href="/css/mobile.css" />
|
||||
<style>
|
||||
.employee-select {
|
||||
margin-bottom: 20px;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>給与管理 - 設定</title>
|
||||
<link rel="stylesheet" href="/css/style.css" />
|
||||
<link rel="stylesheet" href="/css/mobile.css" />
|
||||
<style>
|
||||
.nav-tabs {
|
||||
display: flex;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>給与管理システム</title>
|
||||
<link rel="stylesheet" href="/css/style.css" />
|
||||
<link rel="stylesheet" href="/css/mobile.css" />
|
||||
<style>
|
||||
.menu-grid {
|
||||
display: grid;
|
||||
@@ -55,21 +56,19 @@
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<button
|
||||
onclick="location.href = '../index.html'"
|
||||
class="back-button"
|
||||
<a
|
||||
href="index.html"
|
||||
style="
|
||||
padding: 8px 16px;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
display: inline-block;
|
||||
padding: 6px 16px;
|
||||
background: #757575;
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
"
|
||||
>← メニューへ戻る</a
|
||||
>
|
||||
← メインメニューに戻る
|
||||
</button>
|
||||
|
||||
<h1>給与管理システム</h1>
|
||||
<p>従業員の給与計算、扶養管理、保険料・税金の管理を行います</p>
|
||||
@@ -110,6 +109,25 @@
|
||||
<h2>税率・保険料設定</h2>
|
||||
<p>社会保険料率、所得税率表の管理を行います</p>
|
||||
</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
|
||||
@@ -135,6 +153,10 @@
|
||||
<strong>月次給与計算</strong
|
||||
>で勤怠情報を入力し、給与を計算・承認します
|
||||
</li>
|
||||
<li>
|
||||
<strong>源泉徴収票</strong
|
||||
>で年末に給与所得の源泉徴収票(本人・区役所・税務署の3部)を発行します
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<script src="js/auth.js"></script>
|
||||
<title>試算表</title>
|
||||
<link rel="stylesheet" href="css/style.css" />
|
||||
<link rel="stylesheet" href="/css/mobile.css" />
|
||||
<style>
|
||||
body {
|
||||
font-family: "MS Gothic", "Meiryo", sans-serif;
|
||||
@@ -264,22 +267,20 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<button
|
||||
onclick="location.href = 'index.html'"
|
||||
class="back-button"
|
||||
<a
|
||||
href="index.html"
|
||||
style="
|
||||
display: inline-block;
|
||||
margin: 0 0 20px 0;
|
||||
padding: 8px 16px;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 6px 16px;
|
||||
background: #757575;
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
"
|
||||
>← メニューへ戻る</a
|
||||
>
|
||||
← 首ページに戻る
|
||||
</button>
|
||||
|
||||
<h2>残高試算表(貸借・損益)</h2>
|
||||
|
||||
@@ -291,6 +292,20 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<input type="date" id="dateFrom" min="1900-01-01" max="2099-12-31" />
|
||||
|
||||
|
||||
1048
frontend/withholding-slip.html
Normal file
1048
frontend/withholding-slip.html
Normal file
File diff suppressed because it is too large
Load Diff
74
nas_deploy.py
Normal file
74
nas_deploy.py
Normal 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
23
nas_restart.py
Normal 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
62
read_openapi.py
Normal 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
94
test_org_id.py
Normal 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
89
test_proc_name.py
Normal 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
BIN
testproclist.xlsx
Normal file
Binary file not shown.
Reference in New Issue
Block a user