This commit is contained in:
admin
2026-05-21 00:35:55 +09:00
parent da3aef25ce
commit 4ea283941c
18 changed files with 2530 additions and 1 deletions

View File

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

View File

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

View File

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