import { createServer as createHttpServer } from "node:http";
import { mkdir, readFile, writeFile, appendFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { extname, join, normalize } from "node:path";
import { fileURLToPath } from "node:url";
import crypto from "node:crypto";
import { pool, dbSelect, dbSelectOne, dbInsert, dbUpdate, dbDelete, dbSearchJson, dbSearchJsonPaged } from "./db.mjs";
import { getActor, hasRole, requireRole } from "./auth.mjs";
import {
  makeCredentialStore, hashPassword, verifyPassword, validatePasswordPolicy,
  generateTotpSecret, generateTotp, verifyTotp, issueSession, isLocked, isPasswordExpired,
  PW_MIN_LENGTH, PW_MAX_AGE_DAYS, MAX_FAILED_ATTEMPTS, LOCK_DURATION_MIN,
} from "./credentials.mjs";

// Workers等のバンドル環境では import.meta.url が無いため固定パスにフォールバック
//（メモリ保存モードでは静的配信はアセット側が担い、このパスは実アクセスされない）。
const coreWebRoot = import.meta.url ? fileURLToPath(new URL("../web", import.meta.url)) : "/core/web";

// GOV_STORAGE=memory: ファイルを読み書きせずプロセスメモリのみで動作する。
// ディスクが使えない・揮発してよい実行環境（デモ環境等）向け。再起動でデータは消える。
const MEMORY_ONLY = process.env.GOV_STORAGE === "memory";

export async function createServer(systemConfig) {

const root = systemConfig.root;
const publicRoot = join(root, "apps", "web");
const dataRoot = join(root, "apps", "api", "data");
// systemConfig.catalog: バンドル環境向けにカタログを直接渡せる（未指定ならファイルから読む）
const catalog = systemConfig.catalog ?? JSON.parse(await readFile(join(dataRoot, "catalog.json"), "utf8"));
const recordsFile = join(dataRoot, "records.json");
const linkApplicationsFile = join(dataRoot, "link-applications.json");
const auditFile = join(dataRoot, "audit-log.json");
const auditNdjsonFile = join(dataRoot, "audit-log.ndjson");
const reportJobsFile = join(dataRoot, "report-jobs.json");
const restrictionsFile = join(dataRoot, "restrictions.json");
const eucJobsFile = join(dataRoot, "euc-jobs.json");
// 帳票プレビューの差し込み用ドメインデータ（国保基準で抽出されたcoreの名残。
// 国保以外のシステムではファイルが存在せず readJson が [] を返すだけで無害）
const kokuhoHouseholdsFile = join(dataRoot, "kokuho-households.json");
const kokuhoInsuredFile = join(dataRoot, "kokuho-insured.json");
const kokuhoLeviesFile = join(dataRoot, "kokuho-levies.json");
const certificatesFile = join(dataRoot, "certificates.json");
const aliasesFile = join(dataRoot, "aliases.json");
const notifyRegistrationsFile = join(dataRoot, "notify-registrations.json");
const notificationsFile = join(dataRoot, "notifications.json");
const alertRulesFile = join(dataRoot, "alert-rules.json");
const eucTemplatesFile = join(dataRoot, "euc-templates.json");
const batchJobsFile = join(dataRoot, "batch-jobs.json");
const batchSchedulesFile = join(dataRoot, "batch-schedules.json");
const searchHistoryFile = join(dataRoot, "search-history.json");
const recordLocksFile = join(dataRoot, "record-locks.json");
const attachmentsFile = join(dataRoot, "attachments.json");
const recordActionsFile = join(dataRoot, "record-actions.json");
const reportHistoryFile = join(dataRoot, "report-history.json");
const dunningTargetsFile = join(dataRoot, "dunning-targets.json");
const dunningFeeFile = join(dataRoot, "dunning-fee.json");
const installmentPlansFile = join(dataRoot, "installment-plans.json");
const prescriptionFile = join(dataRoot, "prescription.json");
const transferRequestsFile = join(dataRoot, "transfer-requests.json");
const paymentSlipsFile = join(dataRoot, "payment-slips.json");
const householdsFile = join(dataRoot, "households.json");
const destinationsFile = join(dataRoot, "destinations.json");
const contactsFile = join(dataRoot, "contacts.json");
const municipalitiesFile = join(dataRoot, "municipalities.json");
const jutogaiAtenaFile = join(dataRoot, "jutogai-atena.json");
const medicalInstitutionsFile = join(dataRoot, "medical-institutions.json");
const infoInquiryFile = join(dataRoot, "info-inquiry.json");
const fukuhonFile = join(dataRoot, "fukuhon.json");
const mynumberMasterFile = join(dataRoot, "mynumber-master.json");
const pmhFile = join(dataRoot, "pmh.json");
const commentsFile = join(dataRoot, "comments.json");
const announcementsFile = join(dataRoot, "announcements.json");

const credentialsFile = join(dataRoot, "credentials.json");
const credentialStore = makeCredentialStore(credentialsFile);
const officialSealsFile = join(dataRoot, "official-seals.json");
const mastersFile = join(dataRoot, "masters.json");
const bankAccountsFile = join(dataRoot, "bank-accounts.json");
const bankMasterFile = join(dataRoot, "bank-master.json");
const linkErrorsFile = join(dataRoot, "link-errors.json");
const reportCatalogFile = join(dataRoot, "report-catalog.json");
const accessControlFile = join(dataRoot, "access-control.json");
const countersFile = join(dataRoot, "counters.json");
const noticesFile = join(dataRoot, "notices.json");

// 年度（4/1基準）。受付番号の採番・年度別フィルタに使う。
function fiscalYearOf(date = new Date()) {
  return date.getMonth() + 1 >= 4 ? date.getFullYear() : date.getFullYear() - 1;
}

// 年齢計算（生年月日＋基準日）。全業務の台帳画面で共通利用する横断ロジック。
// ・満年齢: 誕生日が到来していれば加齢（誕生日当日は加齢済み）。
// ・legalAge: 年齢計算ニ関スル法律に基づく「達齢」。誕生日の前日に加齢（4/1生まれが学齢で前学年になる等の判定に使う）。
// ・ageInMonths: 月齢（乳幼児健診・予防接種で使用）。
// ・schoolAge: 学齢（その年度末＝翌3/1基準ではなく、4/1時点で達している年齢。就学・学年判定）。
function parseYmd(s) {
  const m = String(s ?? "").match(/^(\d{4})-(\d{1,2})-(\d{1,2})/);
  if (!m) return null;
  const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
  const dt = new Date(Date.UTC(y, mo - 1, d));
  if (dt.getUTCFullYear() !== y || dt.getUTCMonth() !== mo - 1 || dt.getUTCDate() !== d) return null; // 実在日チェック
  return { y, m: mo, d, dt };
}
function fullYears(b, a) { // 満年齢（誕生日到来で加齢）
  let age = a.y - b.y;
  if (a.m < b.m || (a.m === b.m && a.d < b.d)) age -= 1;
  return age;
}
function computeAge(birthDate, asOf) {
  const b = parseYmd(birthDate);
  const a = asOf ? parseYmd(asOf) : (() => { const n = new Date(); return parseYmd(n.toISOString().slice(0, 10)); })();
  if (!b) return { error: "生年月日(birthDate)はYYYY-MM-DDで指定してください" };
  if (!a) return { error: "基準日(asOfDate)はYYYY-MM-DDで指定してください" };
  if (a.dt < b.dt) return { error: "基準日が生年月日より前です" };
  const age = fullYears(b, a);
  // 達齢（前日加齢）: 基準日の翌日で満年齢を取ると、誕生日前日に1つ繰り上がる
  const aPlus1 = parseYmd(new Date(a.dt.getTime() + 86400000).toISOString().slice(0, 10));
  const legalAge = fullYears(b, aPlus1);
  // 月齢
  let months = (a.y - b.y) * 12 + (a.m - b.m);
  if (a.d < b.d) months -= 1;
  // 学齢: 当該年度の4/1現在の満年齢。学年 = 学齢 - 6（小1=6歳）。
  // 4/1生まれは4/1が誕生日のため満年齢で当年度算入（＝前学年）、4/2生まれは満年齢が1つ下＝翌年度になり、就学事務の扱いと一致する。
  const fy = a.m >= 4 ? a.y : a.y - 1;
  const apr1 = parseYmd(`${fy}-04-01`);
  const schoolAge = fullYears(b, apr1);
  return {
    birthDate: `${b.y}-${String(b.m).padStart(2, "0")}-${String(b.d).padStart(2, "0")}`,
    asOfDate: `${a.y}-${String(a.m).padStart(2, "0")}-${String(a.d).padStart(2, "0")}`,
    age, legalAge, ageInMonths: months, schoolAge, schoolGrade: schoolAge - 6, fiscalYear: fy,
  };
}
// 指定年齢に達する日（年齢計算ニ関スル法律: 誕生日の前日）。節目年齢の基準日算出に使う。
function reachDate(birthDate, targetAge) {
  const b = parseYmd(birthDate);
  if (!b || !Number.isInteger(targetAge)) return null;
  const anniv = new Date(Date.UTC(b.y + targetAge, b.m - 1, b.d)); // 誕生日
  const reached = new Date(anniv.getTime() - 86400000); // その前日＝達する日
  return reached.toISOString().slice(0, 10);
}

// 検査値の基準範囲判定（健診・検診で共通）。検査項目ごとに年齢・性別別の基準値(下限/上限)を設定し、
// 値が基準値未満/基準範囲内/基準値超過のいずれかを判定する。生物学的性別で基準値が異なる項目に対応。
// ranges: [{ sex?, ageMin?, ageMax?, low?, high? }] のうち sex(性別)と age(年齢)に最初に合致するものを採用。
function resolveRange(ranges, sex, age) {
  if (!Array.isArray(ranges)) return null;
  return ranges.find((r) => {
    if (r.sex != null && String(r.sex) !== String(sex)) return false;
    if (r.ageMin != null && !(age >= Number(r.ageMin))) return false;
    if (r.ageMax != null && !(age <= Number(r.ageMax))) return false;
    return true;
  }) ?? null;
}
function judgeExam(item) {
  const value = Number(item.value);
  if (!Number.isFinite(value)) return { name: item.name ?? null, error: "value(検査値)が数値ではありません" };
  // 単一の low/high か、年齢・性別別 ranges のいずれか
  let low = item.low, high = item.high, applied = null;
  if (item.ranges) {
    const r = resolveRange(item.ranges, item.sex, item.age != null ? Number(item.age) : null);
    if (!r) return { name: item.name ?? null, value, judgment: "unset", band: "none", note: "該当する基準値が未設定" };
    low = r.low; high = r.high; applied = r;
  }
  if (low == null && high == null) {
    return { name: item.name ?? null, value, judgment: "unset", band: "none", note: "基準値が未設定" };
  }
  let judgment = "within", band = "normal";
  if (low != null && value < Number(low)) { judgment = "below"; band = "low"; } // 基準値未満
  else if (high != null && value > Number(high)) { judgment = "above"; band = "high"; } // 基準値超過
  return {
    name: item.name ?? null, value, judgment, band,
    low: low != null ? Number(low) : null, high: high != null ? Number(high) : null,
    outOfRange: judgment !== "within", // 色分け用（true=要強調）
    ...(applied ? { appliedRange: applied } : {}),
  };
}

// 延滞金・還付加算金の計算（地方税法準拠。税・国保・介護・後期高齢・各手当の収納/還付で共通）。
// 日数は納期限/起算日の翌日から基準日まで（= 基準日 - 起算日）。端数処理・税率は自治体ごとに年度設定。
function daysBetween(fromYmd, toYmd) {
  const f = parseYmd(fromYmd), t = parseYmd(toYmd);
  if (!f || !t) return null;
  return Math.round((t.dt.getTime() - f.dt.getTime()) / 86400000);
}
function floorUnit(v, unit) { return unit > 0 ? Math.floor(v / unit) * unit : Math.floor(v); }
// 延滞金: 本税×率×日数/365。納期限翌日〜1ヶ月は rate1（軽減）、以降 rate2。減免率も適用可。
function computeEntaikin(input = {}) {
  const honzei = Number(input.honzei);
  if (!Number.isFinite(honzei)) return { error: "honzei(本税/滞納額)が数値ではありません" };
  if (!parseYmd(input.nokigen) || !parseYmd(input.kijunbi)) return { error: "nokigen(納期限)・kijunbi(計算基準日)はYYYY-MM-DDで指定してください" };
  const cfg = {
    honzeiCutUnit: input.honzeiCutUnit ?? 1000, // 本税の切捨単位（1000円未満切捨）
    minHonzei: input.minHonzei ?? 2000,          // これ未満は延滞金なし
    entaiCutUnit: input.entaiCutUnit ?? 100,     // 延滞金額の切捨単位（100円未満切捨）
    minEntai: input.minEntai ?? 1000,            // 確定額がこれ未満なら全額切捨（徴収しない）
  };
  const rate1 = Number(input.rate1) || 0; // 納期限翌日〜1ヶ月の年率(%)
  const rate2 = Number(input.rate2 ?? input.rate1) || 0; // 1ヶ月経過後の年率(%)
  const base = floorUnit(honzei, cfg.honzeiCutUnit);
  const totalDays = daysBetween(input.nokigen, input.kijunbi);
  if (totalDays == null || totalDays <= 0 || base < cfg.minHonzei) {
    return { entaikin: 0, base, days: Math.max(0, totalDays ?? 0), reason: base < cfg.minHonzei ? "本税が最低額未満" : "滞納日数なし" };
  }
  // 1ヶ月を経過する日 = 納期限の翌月応当日（民法143条: 応当日が無い月は末日にクランプ）。
  const nk = parseYmd(input.nokigen);
  let ny = nk.y, nm = nk.m + 1; // 翌月(1-based)
  if (nm > 12) { nm = 1; ny += 1; }
  const lastDayNext = new Date(Date.UTC(ny, nm, 0)).getUTCDate(); // 翌月の末日
  const oneMonth = new Date(Date.UTC(ny, nm - 1, Math.min(nk.d, lastDayNext)));
  const daysTo1Month = Math.round((oneMonth.getTime() - nk.dt.getTime()) / 86400000);
  const days1 = Math.min(totalDays, daysTo1Month);
  const days2 = Math.max(0, totalDays - daysTo1Month);
  let entai = base * (rate1 / 100) * (days1 / 365) + base * (rate2 / 100) * (days2 / 365);
  const genmenRate = Number(input.genmenRate) || 0; // 換価猶予等の減免割合(0〜1)
  if (genmenRate > 0) entai *= (1 - genmenRate);
  entai = floorUnit(entai, cfg.entaiCutUnit);
  if (entai < cfg.minEntai) entai = 0;
  return { entaikin: entai, base, days: totalDays, days1, days2, rate1, rate2, genmenRate,
    breakdown: { period1: floorUnit(base * (rate1 / 100) * (days1 / 365), 1), period2: floorUnit(base * (rate2 / 100) * (days2 / 365), 1) } };
}
// 還付加算金: 還付額×率×日数/365。起算日翌日〜支払決定日(基準日)まで。
function computeKanpuKasan(input = {}) {
  const kanpu = Number(input.kanpugaku);
  if (!Number.isFinite(kanpu)) return { error: "kanpugaku(還付額)が数値ではありません" };
  if (!parseYmd(input.kisanbi) || !parseYmd(input.kijunbi)) return { error: "kisanbi(起算日)・kijunbi(基準日)はYYYY-MM-DDで指定してください" };
  const cfg = {
    kanpuCutUnit: input.kanpuCutUnit ?? 10000, // 還付額の切捨単位（1万円未満切捨）
    kasanCutUnit: input.kasanCutUnit ?? 100,   // 加算金額の切捨単位
    minKasan: input.minKasan ?? 1000,          // 確定額がこれ未満なら全額切捨
  };
  const rate = Number(input.rate) || 0; // 還付加算金の年率(%)
  const base = floorUnit(kanpu, cfg.kanpuCutUnit);
  const days = daysBetween(input.kisanbi, input.kijunbi);
  if (days == null || days <= 0 || base < cfg.kanpuCutUnit) {
    return { kasan: 0, base, days: Math.max(0, days ?? 0), reason: base < cfg.kanpuCutUnit ? "還付額が最低額未満" : "日数なし" };
  }
  let kasan = base * (rate / 100) * (days / 365);
  kasan = floorUnit(kasan, cfg.kasanCutUnit);
  if (kasan < cfg.minKasan) kasan = 0;
  return { kasan, base, days, rate };
}

// 月数/年数を加算した日付（YYYY-MM-DD）。月末日は加算先の末日にクランプ（民法143条）。
function addMonthsYmd(ymd, months) {
  const d = parseYmd(ymd);
  if (!d) return null;
  const total = (d.y * 12 + (d.m - 1)) + Number(months);
  const ny = Math.floor(total / 12), nm = (total % 12) + 1;
  const lastDay = new Date(Date.UTC(ny, nm, 0)).getUTCDate();
  const day = Math.min(d.d, lastDay);
  return `${ny}-${String(nm).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}
// 分納計画の期別スケジュール生成: 対象額を分割回数で按分（端数は初回へ）、頻度(月数)ごとに納期限を設定。
function buildInstallmentSchedule({ total = 0, count = 1, firstDueDate, intervalMonths = 1 } = {}) {
  const amount = Math.max(0, Number(total) || 0);
  const n = Math.max(1, Math.floor(Number(count) || 1));
  const per = Math.floor(amount / n);
  const remainder = amount - per * n; // 端数は初回に寄せる
  const schedule = [];
  for (let i = 0; i < n; i++) {
    schedule.push({
      期別番号: i + 1,
      納付額: i === 0 ? per + remainder : per,
      納期限: firstDueDate ? addMonthsYmd(firstDueDate, i * Number(intervalMonths)) : null,
      納付済額: 0, 収納年月日: null, status: "未収",
    });
  }
  return schedule;
}

// 過誤納金の充当（横断）: 過誤納金(過払い)を未納期別へ充当順位どおりに充当し、残りを還付額として返す。
// 既定の充当順位は納期限の古い順。期別ごとに未納額まで充当する。
function computeAppropriation({ 過誤納金額 = 0, 未納期別 = [], 順位 = "古い順" } = {}) {
  let remaining = Math.max(0, Number(過誤納金額) || 0);
  const items = (Array.isArray(未納期別) ? 未納期別 : []).map((k, i) => ({
    期別: k.期別 ?? i + 1, 未納額: Math.max(0, Number(k.未納額) || 0), 納期限: k.納期限 ?? null,
  }));
  // 充当順位: 古い順(納期限昇順)。納期限が無いものは入力順を維持。
  if (順位 === "古い順") {
    items.sort((a, b) => (a.納期限 && b.納期限) ? (a.納期限 < b.納期限 ? -1 : a.納期限 > b.納期限 ? 1 : 0) : 0);
  }
  const 充当明細 = items.map((k) => {
    const applied = Math.min(remaining, k.未納額);
    remaining -= applied;
    return { 期別: k.期別, 納期限: k.納期限, 未納額: k.未納額, 充当額: applied, 充当後残: k.未納額 - applied };
  });
  return {
    過誤納金額: Math.max(0, Number(過誤納金額) || 0),
    充当合計: 充当明細.reduce((s, d) => s + d.充当額, 0),
    還付額: remaining, // 充当しきれず残った分は還付
    充当明細, 順位,
    note: "過誤納金を未納期別へ充当順位(既定:古い順)で充当し、残りを還付額として算出。",
  };
}

// ── 住民記録システム標準仕様書準拠の「あいまい検索」正規化（異体字・正字の包含は仕様上対象外）──
//   氏名カナ/氏名で以下を同一視: 全角半角・ひらがな/カタカナ・拗音促音の大小・濁点半濁点の有無・
//   長音/記号/空白の無視。異体字や正字を包含する検索（辺→邊邉等）は標準仕様の対象外なので展開しない。
const _SMALL_KANA = { "ァ": "ア", "ィ": "イ", "ゥ": "ウ", "ェ": "エ", "ォ": "オ", "ャ": "ヤ", "ュ": "ユ", "ョ": "ヨ", "ッ": "ツ", "ヮ": "ワ", "ヵ": "カ", "ヶ": "ケ" };
function normalizeKanaFuzzy(s) {
  if (s == null) return "";
  let t = String(s).normalize("NFKC");                                  // 全角半角・互換文字
  t = t.replace(/[ぁ-ゖ]/g, (c) => String.fromCharCode(c.charCodeAt(0) + 0x60)); // ひらがな→カタカナ
  t = t.normalize("NFD").replace(/[゙゚]/g, "").normalize("NFC"); // 濁点・半濁点を除去し清音化
  t = t.replace(/[ァィゥェォャュョッヮヵヶ]/g, (c) => _SMALL_KANA[c] || c); // 拗音・促音の大→小を同一視
  t = t.replace(/[ー‐-―−・·\s,.，．。、]/g, "");   // 長音・記号・空白を無視
  return t.toUpperCase();                                                // ラテンの大小を同一視
}
// 氏名関連項目に対する住基準拠あいまい検索（部分一致／前方一致）。異体字・正字の包含は行わない。
const _DEFAULT_NAME_FIELDS = ["氏名カナ", "氏名", "氏名漢字", "フリガナ", "kana", "name", "氏名フリガナ"];
function fuzzySearchRecords({ query, records = [], fields, mode = "contains" } = {}) {
  const nq = normalizeKanaFuzzy(query);
  if (!nq) return { error: "query が必要です" };
  if (!Array.isArray(records)) return { error: "records[] が必要です" };
  const cols = Array.isArray(fields) && fields.length ? fields : _DEFAULT_NAME_FIELDS;
  const matches = [];
  for (const r of records) {
    const hit = [];
    for (const f of cols) {
      if (r == null || r[f] == null) continue;
      const nv = normalizeKanaFuzzy(r[f]);
      const ok = mode === "prefix" ? nv.startsWith(nq) : nv.includes(nq);
      if (ok) hit.push(f);
    }
    if (hit.length) matches.push({ ...r, _一致項目: hit });
  }
  return {
    kind: "住民記録標準仕様準拠あいまい検索", query, 正規化クエリ: nq, mode,
    対象項目: cols, 対象件数: records.length, 一致件数: matches.length, matches,
    note: "全角半角・かな種別・拗音促音の大小・濁点半濁点・長音/記号/空白を同一視。異体字・正字の包含展開は標準仕様の対象外のため行わない。",
  };
}

// ── 保存期間（法令年限）経過情報の物理削除（個人番号利用事務は個人番号・関連情報も削除）─────
//   各情報の保存期限（直接指定 or 起算日＋法令年限[年]で算定）を基準日と比較し、経過分を抽出。
//   purge時は経過分を物理削除対象として返し、個人番号利用情報は個人番号等を除去（マスキング不可・削除）。
function computeRetention({ records = [], asOf, action = "scan" } = {}) {
  if (!Array.isArray(records)) return { error: "records[] が必要です" };
  const base = parseYmd(asOf) ? asOf : new Date().toISOString().slice(0, 10);
  const baseDt = parseYmd(base).dt;
  const judged = records.map((r) => {
    // 保存期限: 直接指定があれば優先、無ければ 起算日＋法令年限(年) で算定
    let limit = r.保存期限 ?? r.保存期限日 ?? null;
    if (!limit && (r.起算日 || r.基準日) && (r.法令年限 != null || r.保存年数 != null)) {
      const years = Number(r.法令年限 ?? r.保存年数);
      limit = Number.isFinite(years) ? addMonthsYmd(r.起算日 ?? r.基準日, years * 12) : null;
    }
    const lp = parseYmd(limit);
    const expired = lp ? lp.dt < baseDt : false; // 保存期限を過ぎた=経過
    return { id: r.id ?? null, 保存期限: limit, 経過: expired, 個人番号利用: !!r.個人番号利用 };
  });
  const expired = judged.filter((j) => j.経過);
  const result = {
    kind: "保存期間経過情報の物理削除", action, 基準日: base,
    対象件数: records.length, 経過件数: expired.length,
    経過情報: expired,
    note: "法令年限及び業務上必要な期間(保存期間)を経過した情報を抽出。保存期限は直接指定または起算日＋法令年限(年)で算定。",
  };
  if (action === "purge") {
    // 物理削除: 経過情報を削除対象として返し、個人番号利用事務の情報は個人番号・関連情報を除去
    const expiredIds = new Set(expired.map((e) => e.id));
    result.削除済件数 = expired.length;
    result.削除済 = records.filter((r) => expiredIds.has(r.id ?? null) && (r.id ?? null) !== null).map((r) => {
      const { 個人番号, マイナンバー, 個人番号関連情報, ...rest } = r;
      return r.個人番号利用 ? { ...rest, 個人番号: null, 個人番号削除済: true } : rest;
    });
    result.残存 = records.filter((r) => !(expiredIds.has(r.id ?? null) && (r.id ?? null) !== null));
    result.note += " purge: 経過情報を物理削除し、個人番号利用事務は個人番号及び関連情報を削除。";
  }
  return result;
}

// ── データ登録・修正時のデータ型・桁数チェック（エラー）。各項目のデータ型・桁数はデータ要件に準拠 ──
//   spec[]: {field, type:数値|整数|日付|文字列|カナ|英数, maxLength?, minLength?, required?}。日付項目は実在日チェック。
function computeFieldCheck({ spec = [], record = {} } = {}) {
  if (!Array.isArray(spec) || !spec.length) return { error: "spec[]{field,type,maxLength,required} が必要です" };
  const errors = [];
  const typeOk = (type, v) => {
    const s = String(v);
    switch (String(type)) {
      case "数値": return /^[-+]?\d+(\.\d+)?$/.test(s);
      case "整数": return /^[-+]?\d+$/.test(s);
      case "日付": return parseYmd(s) != null || parseYmd(`${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}`) != null;
      case "カナ": return /^[ァ-ヶー　 ]+$/.test(s);
      case "英数": return /^[0-9A-Za-z]+$/.test(s);
      case "文字列": return true;
      default: return true;
    }
  };
  for (const sp of spec) {
    const f = sp.field; if (f == null) continue;
    const has = Object.prototype.hasOwnProperty.call(record, f) && record[f] !== "" && record[f] != null;
    if (!has) {
      if (sp.required) errors.push({ field: f, 種別: "必須", message: `「${f}」は必須項目です` });
      continue;
    }
    const v = record[f];
    if (sp.type && !typeOk(sp.type, v)) errors.push({ field: f, 種別: "データ型", message: `「${f}」は${sp.type}で入力してください` });
    const len = [...String(v)].length;
    if (sp.maxLength != null && len > Number(sp.maxLength)) errors.push({ field: f, 種別: "桁数", message: `「${f}」は${sp.maxLength}桁以内で入力してください(現在${len}桁)` });
    if (sp.minLength != null && len < Number(sp.minLength)) errors.push({ field: f, 種別: "桁数", message: `「${f}」は${sp.minLength}桁以上で入力してください(現在${len}桁)` });
  }
  return { kind: "データ型・桁数チェック", valid: errors.length === 0, errorCount: errors.length, errors,
    note: "登録・修正時に各管理項目のデータ型・桁数をデータ要件に準拠して検証。日付項目は実在日をチェック。" };
}

// ── 氏名・住所等の印刷域桁数を超過したものを抽出し、超過内容を記載したリストを出力（帳票発行時）──
//   印刷域={項目:印刷可能桁数}。各レコードの値が印刷域桁数を超える項目を、超過桁数とともに列挙。
function computePrintOverflow({ records = [], 印刷域 = {} } = {}) {
  if (!Array.isArray(records)) return { error: "records[] が必要です" };
  const limits = 印刷域 && typeof 印刷域 === "object" ? 印刷域 : {};
  if (!Object.keys(limits).length) return { error: "印刷域{項目:印刷可能桁数} が必要です" };
  const list = [];
  for (const r of records) {
    const 超過項目 = [];
    for (const [f, max] of Object.entries(limits)) {
      if (r == null || r[f] == null) continue;
      const v = String(r[f]); const len = [...v].length; const lim = Number(max);
      if (Number.isFinite(lim) && len > lim) 超過項目.push({ 項目: f, 値: v, 桁数: len, 印刷域桁数: lim, 超過桁数: len - lim });
    }
    if (超過項目.length) list.push({ id: r.id ?? null, 超過項目 });
  }
  return { kind: "印刷域桁数超過リスト", 対象件数: records.length, 超過件数: list.length, リスト: list,
    note: "氏名・住所等の値が印刷域桁数を超過したレコードを、超過内容(項目・桁数・超過桁数)とともに帳票発行用リストとして出力。" };
}

// ── 帳票の対象者情報に応じた敬称付与・文言の付加/置換（法人→御中・個人→様等。付与文言は設定変更可）──
function computeHonorific({ 対象者種別, 名称 = "", 敬称設定, 文言置換 = [] } = {}) {
  const defaults = { 法人: "御中", 個人: "様", 世帯: "様", 団体: "御中", 様方: "様方" };
  const map = { ...defaults, ...(敬称設定 && typeof 敬称設定 === "object" ? 敬称設定 : {}) };
  const 敬称 = Object.prototype.hasOwnProperty.call(map, String(対象者種別)) ? map[String(対象者種別)] : "様";
  let body = String(名称 ?? "");
  for (const r of (Array.isArray(文言置換) ? 文言置換 : [])) {
    if (r && r.from != null) body = body.split(String(r.from)).join(String(r.to ?? ""));
  }
  return { kind: "敬称付与・文言置換", 対象者種別: 対象者種別 ?? null, 適用敬称: 敬称,
    表示名称: body ? `${body} ${敬称}` : 敬称,
    note: "対象者種別に応じた敬称(既定 法人→御中/個人→様、設定で変更可)を付与し、文言の付加・置換も行う。" };
}

// ── 一覧表示できる上限を超える検索条件はエラーとし、検索条件の再設定を促す仕組み ─────────────
function computeSearchLimit({ 件数, 上限 = 1000 } = {}) {
  const n = Number(件数), lim = Number(上限);
  if (!Number.isFinite(n) || n < 0) return { error: "件数(検索結果件数または推定件数) が必要です" };
  const over = Number.isFinite(lim) && lim > 0 && n > lim;
  return { kind: "一覧表示上限チェック", 件数: n, 上限: Number.isFinite(lim) ? lim : null, 上限超過: over,
    エラー: over,
    メッセージ: over ? `検索結果が一覧表示上限(${lim}件)を超えています。検索条件を再設定してください。` : null,
    note: "取得件数が一覧表示できる上限を超える場合はエラーとし、検索条件の再設定を促す。" };
}

// ── 金融機関マスタの一括更新（全国銀行協会フォーマットの様式に基づく項目を取込）─────────────
//   金融機関コード(4桁)＋店舗コードで一意。有効開始/終了日、指定金融区分、電子納付対応有無、
//   本店支店区分、手形交換所番号、店舗郵便番号等の全銀協マスタ項目を正規化・検証して返す。
function normalizeBankMasterRow(r = {}) {
  const s = (v) => (v == null ? null : String(v).trim());
  return {
    金融機関コード: (s(r.金融機関コード ?? r.bankCode) ?? "").replace(/[^0-9]/g, ""),
    店舗コード: (s(r.店舗コード ?? r.branchCode) ?? "").replace(/[^0-9]/g, ""),
    金融機関名カナ: s(r.金融機関名カナ ?? r.bankKana) ?? "",
    金融機関名: s(r.金融機関名 ?? r.bankName) ?? "",
    店舗名カナ: s(r.店舗名カナ ?? r.branchKana) ?? "",
    店舗名: s(r.店舗名 ?? r.branchName) ?? "",
    金融機関有効開始日: s(r.金融機関有効開始日), 金融機関有効終了日: s(r.金融機関有効終了日),
    店舗有効開始日: s(r.店舗有効開始日), 店舗有効終了日: s(r.店舗有効終了日),
    指定金融区分コード: s(r.指定金融区分コード), 電子納付対応有無コード: s(r.電子納付対応有無コード),
    本店支店区分: s(r.本店支店区分), 手形交換所番号: s(r.手形交換所番号), 店舗郵便番号: s(r.店舗郵便番号),
  };
}
function validateBankMasterRow(row, i) {
  const errs = [];
  if (!/^\d{4}$/.test(row.金融機関コード)) errs.push(`行${i + 1}: 金融機関コードは4桁の数字`);
  if (row.店舗コード && !/^\d{3}$/.test(row.店舗コード)) errs.push(`行${i + 1}: 店舗コードは3桁の数字`);
  for (const [k, v] of Object.entries(row)) {
    if (k.endsWith("日") && v && !parseYmd(v)) errs.push(`行${i + 1}: ${k}が不正な日付(${v})`);
  }
  return errs;
}

// 口座番号は下4桁以外をマスクして返す（全桁は提供API経由のみ・要ADMIN・監査記録）
function maskAccount(account) {
  const num = String(account.accountNumber ?? "");
  return { ...account, accountNumber: num ? `****${num.slice(-4)}` : "" };
}

if (!MEMORY_ONLY) await mkdir(dataRoot, { recursive: true });
const memoryFiles = new Map();

async function readJson(file, fallback) {
  if (memoryFiles.has(file)) return structuredClone(memoryFiles.get(file));
  if (MEMORY_ONLY) return fallback;
  try {
    return JSON.parse(await readFile(file, "utf8"));
  } catch (error) {
    if (error.code === "ENOENT") return fallback;
    if (["EPERM", "EACCES"].includes(error.code)) return fallback;
    throw error;
  }
}

async function writeJson(file, value) {
  if (MEMORY_ONLY) { memoryFiles.set(file, structuredClone(value)); return; }
  try {
    await writeFile(file, `${JSON.stringify(value, null, 2)}\n`, "utf8");
  } catch (error) {
    if (!["EPERM", "EACCES"].includes(error.code)) throw error;
    memoryFiles.set(file, structuredClone(value));
  }
}

function responseHeaders(res, type, extra = {}) {
  const headers = {
    "content-type": type,
    "cache-control": "no-store",
    "x-content-type-options": "nosniff",
    "x-frame-options": "DENY",
    "referrer-policy": "no-referrer",
    "content-security-policy": "default-src 'self'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'",
  };
  if (res._requestId) headers["x-request-id"] = res._requestId;
  const corsOrigin = process.env.CORS_ORIGIN || "";
  if (corsOrigin) {
    headers["access-control-allow-origin"] = corsOrigin;
    headers["access-control-allow-headers"] = "content-type, authorization, x-user-roles";
    headers["access-control-allow-methods"] = "GET, POST, PUT, DELETE, OPTIONS";
    headers["access-control-max-age"] = "86400";
  }
  if (process.env.NODE_ENV === "production") headers["strict-transport-security"] = "max-age=31536000; includeSubDomains";
  return { ...headers, ...extra };
}

function send(res, status, payload, type = "application/json; charset=utf-8") {
  const body = typeof payload === "string" ? payload : JSON.stringify(payload, null, 2);
  res.writeHead(status, responseHeaders(res, type));
  res.end(body);
}

function sendRaw(res, status, payload, type, extra = {}) {
  res.writeHead(status, responseHeaders(res, type, extra));
  res.end(payload);
}

async function buildReportPdf(html) {
  let puppeteer;
  try {
    ({ default: puppeteer } = await import("puppeteer-core"));
  } catch {
    const error = new Error("PDF report output requires optional dependency puppeteer-core");
    error.statusCode = 501;
    throw error;
  }

  const executablePath = process.env.CHROME_PATH;
  if (!executablePath) {
    const error = new Error("PDF report output requires CHROME_PATH to point to a Chromium/Chrome executable");
    error.statusCode = 501;
    throw error;
  }

  let browser;
  try {
    browser = await puppeteer.launch({ executablePath, args: ["--no-sandbox", "--disable-setuid-sandbox"] });
    const page = await browser.newPage();
    await page.setContent(html, { waitUntil: "networkidle0" });
    return await page.pdf({ format: "A4", printBackground: true });
  } finally {
    if (browser) await browser.close();
  }
}


async function parseBody(req) {
  const maxBody = 1_048_576; // 1 MiB limit
  const chunks = [];
  let bodySize = 0;
  for await (const chunk of req) {
    bodySize += chunk.length;
    if (bodySize > maxBody) { const e = new Error("request body too large"); e.statusCode = 413; throw e; }
    chunks.push(chunk);
  }
  if (!chunks.length) return {};
  try {
    return JSON.parse(Buffer.concat(chunks).toString("utf8"));
  } catch {
    const error = new Error("request body must be valid JSON");
    error.statusCode = 400;
    throw error;
  }
}

// Q-01: append-only NDJSON で監査ログを記録（旧来の毎リクエスト全文 read/write は O(N) で本番不可）
const AUDIT_MEMORY_CAP = 10_000;
const auditMemory = [];

async function writeAuditRow(row) {
  if (MEMORY_ONLY) {
    auditMemory.push(row);
    if (auditMemory.length > AUDIT_MEMORY_CAP) auditMemory.shift();
    return;
  }
  try {
    await appendFile(auditNdjsonFile, JSON.stringify(row) + "\n", "utf8");
  } catch (err) {
    if (!["EPERM", "EACCES"].includes(err.code)) throw err;
    auditMemory.push(row);
    if (auditMemory.length > AUDIT_MEMORY_CAP) auditMemory.shift();
  }
}

// 0390045/0390046: 操作ログに機能ID・操作端末・端末識別子を記録
function terminalInfo(req) {
  return {
    terminal: req.socket?.remoteAddress ?? "unknown",
    userAgent: String(req.headers["user-agent"] ?? "").slice(0, 200),
  };
}

async function audit(req, action, detail, functionId = null) {
  const actor = await getActor(req);
  await writeAuditRow({
    id: crypto.randomUUID(),
    at: new Date().toISOString(),
    actor: actor.id,
    actorRoles: actor.roles,
    action,
    functionId,
    ...terminalInfo(req),
    detail,
  });
}

// 認証イベント用（ログイン成否は認証前のため actor を明示）
async function auditAuth(req, action, actorId, detail) {
  await writeAuditRow({
    id: crypto.randomUUID(),
    at: new Date().toISOString(),
    actor: actorId ?? "anonymous",
    actorRoles: [],
    action,
    functionId: "0390050",
    ...terminalInfo(req),
    detail,
  });
}

async function readAuditAll() {
  const legacy = await readJson(auditFile, []);
  let ndjson = [];
  // メモリ保存モードでは監査行は auditMemory のみ（ファイルを読まない。
  // Workers等ではfsがスタブでENOENT以外のエラーを投げ、500の原因になる）
  if (!MEMORY_ONLY) try {
    const text = await readFile(auditNdjsonFile, "utf8");
    for (const line of text.split("\n")) {
      if (!line) continue;
      try { ndjson.push(JSON.parse(line)); } catch { /* skip malformed line */ }
    }
  } catch (err) {
    if (err.code !== "ENOENT") throw err;
  }
  const combined = [...legacy, ...ndjson, ...auditMemory];
  combined.sort((a, b) => (b.at ?? "").localeCompare(a.at ?? ""));
  return combined;
}

function validateRecord(domain, body) {
  if (!domain || !/^[a-zA-Z0-9_-]+$/.test(domain)) {
    const error = new Error("domain must be an ASCII slug");
    error.statusCode = 400;
    throw error;
  }
  const payload = body.payload ?? body;
  if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
    const error = new Error("payload must be an object");
    error.statusCode = 400;
    throw error;
  }
  return {
    domain,
    status: body.status ?? "draft",
    payload,
  };
}

function filterCatalog(section, query) {
  const source = catalog[section];
  if (!Array.isArray(source)) return undefined;
  const q = query.get("q")?.trim().toLowerCase();
  if (!q) return source;
  return source.filter((item) => `${item.id} ${item.name} ${item.description} ${item.sheet}`.toLowerCase().includes(q));
}

function restrictionApplies(restriction, record) {
  if (restriction.status === "inactive") return false;
  return restriction.recordId === record.id || restriction.domain === record.domain || restriction.scope === "domain";
}

function maskRecord(actor, restrictions, record) {
  const applied = restrictions.filter((restriction) => restrictionApplies(restriction, record));
  if (!applied.length || hasRole(actor, "SUPPORT_ACCESS")) return record;
  return {
    id: record.id,
    domain: record.domain,
    status: record.status,
    createdAt: record.createdAt,
    updatedAt: record.updatedAt,
    restricted: true,
    restrictionCategories: [...new Set(applied.map((restriction) => restriction.category))],
    payload: {
      subject: "抑止対象",
      description: "支援措置・抑止により詳細は非表示です。",
    },
  };
}

async function serveStatic(req, res, pathname) {
  // メモリ保存モードでは静的配信はアセット層（Workers assets等）の担当。fsに触れない
  if (MEMORY_ONLY) return false;
  const target = pathname === "/" ? "/index.html" : pathname;
  let filePath = normalize(join(publicRoot, target));
  if (!filePath.startsWith(publicRoot) || !existsSync(filePath)) {
    const coreFile = normalize(join(coreWebRoot, target));
    if (coreFile.startsWith(coreWebRoot) && existsSync(coreFile)) { filePath = coreFile; }
    else return false;
  }
  const ext = extname(filePath);
  const textMime = {
    ".html": "text/html; charset=utf-8",
    ".js": "text/javascript; charset=utf-8",
    ".css": "text/css; charset=utf-8",
    ".svg": "image/svg+xml",
    ".json": "application/json; charset=utf-8",
    ".webmanifest": "application/manifest+json; charset=utf-8",
    ".txt": "text/plain; charset=utf-8",
    ".xml": "application/xml; charset=utf-8",
  };
  const binaryMime = {
    ".png": "image/png",
    ".jpg": "image/jpeg",
    ".jpeg": "image/jpeg",
    ".gif": "image/gif",
    ".webp": "image/webp",
    ".ico": "image/x-icon",
    ".woff": "font/woff",
    ".woff2": "font/woff2",
    ".ttf": "font/ttf",
    ".otf": "font/otf",
  };
  if (textMime[ext]) {
    send(res, 200, await readFile(filePath, "utf8"), textMime[ext]);
  } else if (binaryMime[ext]) {
    sendRaw(res, 200, await readFile(filePath), binaryMime[ext]);
  } else {
    sendRaw(res, 200, await readFile(filePath), "application/octet-stream");
  }
  return true;
}

function eucNeedsTwoApprovals(body) {
  return Boolean(body.includeSensitive || body.includeMyNumber || body.outputFields?.includes?.("my_number"));
}

function approvalCounts(job) {
  const approved = job.approvals.filter((approval) => approval.action === "APPROVE");
  return {
    approvedCount: approved.length,
    requiredApprovals: job.requiredApprovals,
    requiresSecondApproval: approved.length < job.requiredApprovals && job.status === "QUEUED",
  };
}

function csvEscape(value) {
  const text = String(value ?? "");
  return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
}

// 0390006: CSV パース（RFC4180 準拠の簡易実装、ダブルクオート対応）
function parseCsv(text) {
  const rows = [];
  let row = [], field = "", inQuotes = false;
  const src = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
  for (let i = 0; i < src.length; i++) {
    const c = src[i];
    if (inQuotes) {
      if (c === '"') {
        if (src[i + 1] === '"') { field += '"'; i++; }
        else inQuotes = false;
      } else field += c;
    } else if (c === '"') inQuotes = true;
    else if (c === ",") { row.push(field); field = ""; }
    else if (c === "\n") { row.push(field); rows.push(row); row = []; field = ""; }
    else field += c;
  }
  if (field !== "" || row.length) { row.push(field); rows.push(row); }
  return rows.filter((r) => r.length > 1 || (r.length === 1 && r[0] !== ""));
}

const MASTER_COLUMNS = ["code", "name", "sortOrder", "active", "validFrom", "validUntil"];

async function buildEucCsv(job) {
  const records = await readJson(recordsFile, []);
  const fields = job.outputFields?.length ? job.outputFields : ["id", "domain", "status"];
  const rows = [fields.join(",")];
  for (const record of records) {
    rows.push(fields.map((field) => csvEscape(field in record ? record[field] : record.payload?.[field])).join(","));
  }
  return `${rows.join("\n")}\n`;
}

function htmlEscape(value) {
  return String(value ?? "")
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#39;");
}

// 0390007/0390008: 電子公印の選択・検証（支所単位・適用期間対応）
function selectActiveSeal(seals, { branch = null, at = null } = {}) {
  const now = at ? new Date(at) : new Date();
  const candidates = seals.filter((s) => {
    if (!s.active) return false;
    if (s.validFrom && new Date(s.validFrom) > now) return false;
    if (s.validUntil && new Date(s.validUntil) < now) return false;
    return true;
  });
  return candidates.find((s) => branch && s.branch === branch)
    ?? candidates.find((s) => !s.branch)
    ?? candidates[0]
    ?? null;
}

function validateSealImage(imageData) {
  if (typeof imageData !== "string" || !imageData) return "imageData は data URI 文字列が必要です";
  if (!/^data:image\/(png|jpeg|svg\+xml);base64,/.test(imageData)) {
    return "imageData は data:image/(png|jpeg|svg+xml);base64, 形式が必要です";
  }
  if (imageData.length > 512 * 1024) return "imageData は 512KB 以下にしてください";
  return null;
}

// 0390033-0390036: 帳票への電子公印・発行年月日・発行番号の印字
function buildSealBlock(sealOptions = {}) {
  const { seal = null, issueNumber = "", issueDate = null, omitSeal = false } = sealOptions;
  const dateStr = issueDate ?? new Date().toISOString().slice(0, 10);
  const sealHtml = (!omitSeal && seal && seal.imageData)
    ? "<img class=\"official-seal\" src=\"" + htmlEscape(seal.imageData) + "\" alt=\"" + htmlEscape((seal.kind ?? seal.name) ?? "公印") + "\" />"
    : "<span class=\"seal-omitted\">（公印省略）</span>";
  // 任意の固定文言（例:「この印は黒色です」）を公印の下に印字
  const fixedNoteHtml = (!omitSeal && seal && seal.fixedNote)
    ? "<div class=\"seal-note\" style=\"font-size:12px;color:#5b6670;text-align:center\">" + htmlEscape(seal.fixedNote) + "</div>" : "";
  const style = "<style>"
    + ".issue-block{display:flex;justify-content:space-between;align-items:flex-end;margin-top:40px;border-top:1px solid #cfd8dc;padding-top:16px}"
    + ".issue-meta{width:auto;margin:0;border-collapse:collapse}.issue-meta th{background:none;border:none;text-align:right;padding:2px 8px}.issue-meta td{border:none;padding:2px 8px}"
    + ".seal-area{width:96px;height:96px;display:flex;align-items:center;justify-content:center}"
    + ".official-seal{max-width:96px;max-height:96px}.seal-omitted{color:#5b6670;font-size:13px}"
    + "</style>";
  return style + "<div class=\"issue-block\">"
    + "<table class=\"issue-meta\">"
    + "<tr><th>発行年月日</th><td>" + htmlEscape(dateStr) + "</td></tr>"
    + (issueNumber ? "<tr><th>発行番号</th><td>" + htmlEscape(issueNumber) + "</td></tr>" : "")
    + "</table>"
    + "<div class=\"seal-area\">" + sealHtml + fixedNoteHtml + "</div>"
    + "</div>";
}

function buildReportHtml(job, catalog, domainData = {}, sealOptions = {}) {
  const report = catalog.reports?.find?.((item) => item.id === job.reportId);
  const title = report?.name ?? job.reportId;
  const CSS = "body{font-family:system-ui,sans-serif;margin:32px;color:#172026}"
    + "h1{font-size:24px;margin-bottom:8px}h2{font-size:18px;margin:24px 0 8px;border-bottom:2px solid #cfd8dc;padding-bottom:4px}"
    + ".meta{color:#5b6670;margin-bottom:24px}table{border-collapse:collapse;width:100%;margin-bottom:16px}"
    + "th,td{border:1px solid #cfd8dc;padding:8px 10px;text-align:left;vertical-align:top}"
    + "th{background:#f3f6f8}.badge{display:inline-block;padding:2px 8px;border-radius:3px;font-size:12px}"
    + ".enrolled{background:#e8f5e9;color:#2e7d32}.levied{background:#fff3e0;color:#e65100}"
    + ".partial{background:#e3f2fd;color:#1565c0}.paid{background:#e8f5e9;color:#2e7d32}";
  const payloadRows = Object.entries(job.requestPayload ?? {})
    .map(([key, value]) => "<tr><th>" + htmlEscape(key) + "</th><td>" + htmlEscape(typeof value === "object" ? JSON.stringify(value) : value) + "</td></tr>")
    .join("");

  const { households = [], insured = [], levies = [] } = domainData;
  let domainSection = "";
  if (households.length > 0 || levies.length > 0) {
    const totalLevyAmount = levies.reduce((s, l) => s + (l.amount ?? 0), 0);
    const totalPaidAmount = levies.reduce((s, l) => s + (l.paidAmount ?? 0), 0);
    domainSection += "<h2>国民健康保険 概要</h2>"
      + "<table><tr><th>加入世帯数</th><td>" + households.length + " 世帯</td></tr>"
      + "<tr><th>被保険者数</th><td>" + insured.length + " 人</td></tr>"
      + "<tr><th>賦課総額</th><td>" + totalLevyAmount.toLocaleString("ja-JP") + " 円</td></tr>"
      + "<tr><th>収納済額</th><td>" + totalPaidAmount.toLocaleString("ja-JP") + " 円</td></tr></table>";
    if (households.length > 0) {
      domainSection += "<h2>加入世帯一覧</h2><table>"
        + "<tr><th>世帯番号</th><th>世帯主</th><th>住所</th><th>ステータス</th></tr>"
        + households.slice(0, 50).map((h) => "<tr>"
            + "<td>" + htmlEscape(h.householdNumber ?? "") + "</td>"
            + "<td>" + htmlEscape(h.householderName ?? "") + "</td>"
            + "<td>" + htmlEscape(h.address ?? "") + "</td>"
            + "<td><span class=\"badge " + htmlEscape(h.status ?? "") + "\">" + htmlEscape(h.status ?? "") + "</span></td>"
            + "</tr>").join("")
        + (households.length > 50 ? "<tr><td colspan=\"4\">… 他 " + (households.length - 50) + " 件</td></tr>" : "")
        + "</table>";
    }
  }

  return "<!doctype html>"
    + "<html lang=\"ja\"><head><meta charset=\"utf-8\">"
    + "<title>" + htmlEscape(title) + "</title>"
    + "<style>" + CSS + "</style>"
    + "</head><body>"
    + "<h1>" + htmlEscape(title) + "</h1>"
    + "<p class=\"meta\">reportId=" + htmlEscape(job.reportId) + " / jobId=" + htmlEscape(job.id) + " / 生成日時=" + htmlEscape(job.generatedAt ?? new Date().toISOString()) + "</p>"
    + (payloadRows ? "<h2>リクエストパラメータ</h2><table>" + payloadRows + "</table>" : "")
    + domainSection
    + buildSealBlock(sealOptions)
    + "</body></html>";
}
// In-memory rate limiter (200 req/min per IP, configurable via RATE_LIMIT env)
// POST/PUT/DELETE 系は別バケット (RATE_LIMIT_POST, default 20 req/min)
const rlMap = new Map();
const rlMapPost = new Map();
const rlWindow = 60_000;
const rlLimit = Number(process.env.RATE_LIMIT ?? 200);
const rlLimitPost = Number(process.env.RATE_LIMIT_POST ?? 20);
function rlCheck(map, ip, limit) {
  const now = Date.now();
  const entry = map.get(ip) ?? { count: 0, reset: now + rlWindow };
  if (now > entry.reset) { entry.count = 0; entry.reset = now + rlWindow; }
  entry.count++;
  map.set(ip, entry);
  if (map.size > 10_000) { for (const [k, v] of map) { if (Date.now() > v.reset) map.delete(k); } }
  return entry.count <= limit;
}
function checkRateLimit(ip, method) {
  if (!rlCheck(rlMap, ip, rlLimit)) return false; // global bucket
  if (["POST", "PUT", "DELETE"].includes(method)) return rlCheck(rlMapPost, ip, rlLimitPost); // write bucket
  return true;
}


// Server-Sent Events — 接続クライアント管理
const sseClients = new Set();
function sseBroadcast(event, data) {
  const msg = `event: ${event}
data: ${JSON.stringify(data)}

`;
  for (const client of sseClients) {
    try { client.write(msg); } catch { sseClients.delete(client); }
  }
}
// ── 認証エンドポイント ──────────────────────────────────────
// パスワード認証 + TOTP 二要素認証 + アカウントロック（デジタル庁 0390050/非機能#6/#7）
const LOCK_DURATION_MS = LOCK_DURATION_MIN * 60_000;

async function handlePublicAuth(req, res, pathname) {
  if (pathname === "/api/auth/policy" && req.method === "GET") {
    send(res, 200, {
      minLength: PW_MIN_LENGTH,
      maxAgeDays: PW_MAX_AGE_DAYS,
      maxFailedAttempts: MAX_FAILED_ATTEMPTS,
      lockDurationMinutes: LOCK_DURATION_MIN,
      mfaSupported: true,
    });
    return true;
  }

  if (pathname === "/api/auth/login" && req.method === "POST") {
    const body = await parseBody(req);
    const userId = String(body.userId ?? "").trim();
    const password = String(body.password ?? "");
    const user = userId ? await credentialStore.findByUserId(userId) : null;

    if (!user) {
      await auditAuth(req, "auth.login.fail", userId || null, { reason: "unknown-user" });
      send(res, 401, { error: "ユーザーIDまたはパスワードが正しくありません" });
      return true;
    }
    if (isLocked(user)) {
      await auditAuth(req, "auth.login.locked", userId, { lockedUntil: user.lockedUntil });
      send(res, 423, { error: "アカウントがロックされています。しばらくしてから再度お試しください", lockedUntil: user.lockedUntil });
      return true;
    }

    const ok = verifyPassword(password, user.salt, user.hash);
    if (!ok) {
      const failedAttempts = (user.failedAttempts ?? 0) + 1;
      const willLock = failedAttempts >= MAX_FAILED_ATTEMPTS;
      await credentialStore.update(userId, (u) => ({
        ...u,
        failedAttempts: willLock ? 0 : failedAttempts,
        lockedUntil: willLock ? new Date(Date.now() + LOCK_DURATION_MS).toISOString() : u.lockedUntil ?? null,
      }));
      await auditAuth(req, willLock ? "auth.login.lockout" : "auth.login.fail", userId, { failedAttempts, willLock });
      send(res, willLock ? 423 : 401, willLock
        ? { error: `認証に${MAX_FAILED_ATTEMPTS}回失敗したためアカウントをロックしました`, lockDurationMinutes: LOCK_DURATION_MIN }
        : { error: "ユーザーIDまたはパスワードが正しくありません", remainingAttempts: MAX_FAILED_ATTEMPTS - failedAttempts });
      return true;
    }

    if (user.totpEnabled) {
      const totp = String(body.totp ?? "").trim();
      if (!totp) {
        send(res, 200, { mfaRequired: true });
        return true;
      }
      if (!verifyTotp(user.totpSecret, totp)) {
        const failedAttempts = (user.failedAttempts ?? 0) + 1;
        const willLock = failedAttempts >= MAX_FAILED_ATTEMPTS;
        await credentialStore.update(userId, (u) => ({
          ...u,
          failedAttempts: willLock ? 0 : failedAttempts,
          lockedUntil: willLock ? new Date(Date.now() + LOCK_DURATION_MS).toISOString() : u.lockedUntil ?? null,
        }));
        await auditAuth(req, "auth.login.mfa_fail", userId, { failedAttempts });
        send(res, 401, { error: "二要素認証コードが正しくありません" });
        return true;
      }
    }

    await credentialStore.update(userId, (u) => ({
      ...u, failedAttempts: 0, lockedUntil: null, lastLoginAt: new Date().toISOString(),
    }));
    const sessionUser = { id: user.userId, name: user.name, department: user.department, roles: user.roles };
    const token = issueSession(sessionUser);
    await auditAuth(req, "auth.login.success", userId, { mfa: Boolean(user.totpEnabled) });
    send(res, 200, {
      token,
      user: sessionUser,
      passwordExpired: isPasswordExpired(user),
      mustChangePassword: Boolean(user.mustChangePassword) || isPasswordExpired(user),
    });
    return true;
  }

  return false;
}

const server = createHttpServer(async (req, res) => {
  res._requestId = crypto.randomUUID().replace(/-/g, "").slice(0, 12);
  const _ip = req.socket?.remoteAddress ?? "unknown";
  if (!checkRateLimit(_ip, req.method)) { send(res, 429, { error: "Too Many Requests", retryAfter: 60 }); return; }
  const cl = parseInt(req.headers["content-length"] ?? "0", 10);
  if (cl > 1_048_576) { send(res, 413, { error: "Request Entity Too Large" }); return; }
  if (req.method === "OPTIONS") {
    const co = process.env.CORS_ORIGIN || "";
    res.writeHead(204, {
      "access-control-allow-origin": co || "*",
      "access-control-allow-headers": "content-type, authorization, x-user-roles",
      "access-control-allow-methods": "GET, POST, PUT, DELETE, OPTIONS",
      "access-control-max-age": "86400",
      "x-request-id": res._requestId,
    });
    return res.end();
  }
  try {
    const url = new URL(req.url ?? "/", "http://localhost");
    const { pathname, searchParams } = url;

    // ── 認証エンドポイント（actor 不要 / ログイン前にアクセス可能） ──────
    if (await handlePublicAuth(req, res, pathname)) return;

    const actor = await getActor(req);

    if (pathname === "/health" || pathname === "/api/health") {
      return send(res, 200, {
        ok: true,
        system: catalog.system,
        db: pool ? "connected" : "json-fallback",
        uptime: Math.floor(process.uptime()),
        version: "1.0.0",
        storage: {
          records: existsSync(recordsFile),
          audit: existsSync(auditFile) || existsSync(auditNdjsonFile),
          reportJobs: existsSync(reportJobsFile),
          restrictions: existsSync(restrictionsFile),
          eucJobs: existsSync(eucJobsFile),
          kokuhoInsured: existsSync(kokuhoInsuredFile),
          kokuhoHouseholds: existsSync(kokuhoHouseholdsFile),
          kokuhoLevies: existsSync(kokuhoLeviesFile),
        },
        at: new Date().toISOString(),
      });
    }

    
    // SSE エンドポイント
    if (pathname === "/api/events" && req.method === "GET") {
      // メモリ保存モード（Workers等）では長寿命ストリームが維持できず
      // EventSourceが再接続ループになるため、204で「再接続不要」を伝えて止める
      if (MEMORY_ONLY) {
        res.writeHead(204);
        return res.end();
      }
      const co = process.env.CORS_ORIGIN || "";
      res.writeHead(200, {
        "content-type": "text/event-stream",
        "cache-control": "no-cache",
        "connection": "keep-alive",
        "x-request-id": res._requestId,
        ...co ? { "access-control-allow-origin": co } : {},
      });
      res.write(": connected\n\n");
      sseClients.add(res);
      req.on("close", () => sseClients.delete(res));
      return;
    }
    if (pathname === "/api/me") return send(res, 200, actor);
    if (pathname === "/api/catalog") return send(res, 200, catalog);

    // ── 認証管理（要ログイン） ─────────────────────────────────
    if (pathname === "/api/auth/register" && req.method === "POST") {
      requireRole(actor, "ADMIN"); // 0390048: 個人単位ID/PW を管理者が登録
      const body = await parseBody(req);
      const userId = String(body.userId ?? "").trim();
      if (!userId) return send(res, 400, { error: "userId は必須です" });
      if (await credentialStore.findByUserId(userId)) return send(res, 409, { error: "そのユーザーIDは既に存在します" });
      const policyErrors = validatePasswordPolicy(body.password);
      if (policyErrors.length) return send(res, 400, { error: policyErrors.join(" / ") });
      const { salt, hash } = hashPassword(String(body.password));
      const user = {
        userId,
        name: String(body.name ?? userId),
        department: String(body.department ?? ""),
        roles: Array.isArray(body.roles) ? body.roles : ["CLERK"],
        salt, hash,
        passwordChangedAt: new Date().toISOString(),
        mustChangePassword: body.mustChangePassword !== false,
        failedAttempts: 0,
        lockedUntil: null,
        totpEnabled: false,
        totpSecret: null,
        createdAt: new Date().toISOString(),
        createdBy: actor.id,
      };
      await credentialStore.upsert(user);
      await audit(req, "auth.register", { userId, roles: user.roles }, "0390048");
      const { salt: _s, hash: _h, totpSecret: _t, ...safe } = user;
      return send(res, 201, safe);
    }

    if (pathname === "/api/auth/change-password" && req.method === "POST") {
      const body = await parseBody(req);
      const user = await credentialStore.findByUserId(actor.id);
      if (!user) return send(res, 404, { error: "資格情報が見つかりません" });
      if (!verifyPassword(String(body.currentPassword ?? ""), user.salt, user.hash)) {
        await audit(req, "auth.change_password.fail", { userId: actor.id }, "0390048");
        return send(res, 401, { error: "現在のパスワードが正しくありません" });
      }
      const policyErrors = validatePasswordPolicy(body.newPassword);
      if (policyErrors.length) return send(res, 400, { error: policyErrors.join(" / ") });
      if (verifyPassword(String(body.newPassword), user.salt, user.hash)) {
        return send(res, 400, { error: "新しいパスワードは現在のものと異なる必要があります" });
      }
      const { salt, hash } = hashPassword(String(body.newPassword));
      await credentialStore.update(actor.id, (u) => ({
        ...u, salt, hash, passwordChangedAt: new Date().toISOString(), mustChangePassword: false,
      }));
      await audit(req, "auth.change_password", { userId: actor.id }, "0390048");
      return send(res, 200, { ok: true });
    }

    if (pathname === "/api/auth/2fa/setup" && req.method === "POST") {
      const user = await credentialStore.findByUserId(actor.id);
      if (!user) return send(res, 404, { error: "資格情報が見つかりません" });
      const secret = generateTotpSecret();
      await credentialStore.update(actor.id, (u) => ({ ...u, totpSecretPending: secret }));
      const issuer = encodeURIComponent(catalog.system?.title ?? "MunicipalSystem");
      const label = encodeURIComponent(`${issuer}:${actor.id}`);
      await audit(req, "auth.2fa.setup", { userId: actor.id }, "0390050");
      return send(res, 200, {
        secret,
        otpauthUri: `otpauth://totp/${label}?secret=${secret}&issuer=${issuer}&period=30&digits=6`,
        sampleCode: generateTotp(secret),
      });
    }

    if (pathname === "/api/auth/2fa/enable" && req.method === "POST") {
      const body = await parseBody(req);
      const user = await credentialStore.findByUserId(actor.id);
      if (!user || !user.totpSecretPending) return send(res, 400, { error: "先に /api/auth/2fa/setup を実行してください" });
      if (!verifyTotp(user.totpSecretPending, String(body.totp ?? ""))) {
        return send(res, 401, { error: "二要素認証コードが正しくありません" });
      }
      await credentialStore.update(actor.id, (u) => {
        const { totpSecretPending, ...rest } = u;
        return { ...rest, totpEnabled: true, totpSecret: totpSecretPending };
      });
      await audit(req, "auth.2fa.enable", { userId: actor.id }, "0390050");
      return send(res, 200, { ok: true, totpEnabled: true });
    }

    if (pathname === "/api/auth/2fa/disable" && req.method === "POST") {
      const user = await credentialStore.findByUserId(actor.id);
      if (!user) return send(res, 404, { error: "資格情報が見つかりません" });
      await credentialStore.update(actor.id, (u) => ({ ...u, totpEnabled: false, totpSecret: null }));
      await audit(req, "auth.2fa.disable", { userId: actor.id }, "0390050");
      return send(res, 200, { ok: true, totpEnabled: false });
    }

    if (pathname === "/api/auth/users" && req.method === "GET") {
      requireRole(actor, "ADMIN");
      const users = (await credentialStore.readAll()).map(({ salt, hash, totpSecret, totpSecretPending, ...safe }) => ({
        ...safe, locked: isLocked(safe), passwordExpired: isPasswordExpired(safe),
      }));
      return send(res, 200, users);
    }

    const unlockMatch = pathname.match(/^\/api\/auth\/users\/([^/]+)\/unlock$/);
    if (unlockMatch && req.method === "POST") {
      requireRole(actor, "ADMIN"); // 0390049: アクセス権限の付与・スケジュール変更
      const targetId = decodeURIComponent(unlockMatch[1]);
      const updated = await credentialStore.update(targetId, (u) => ({ ...u, failedAttempts: 0, lockedUntil: null }));
      if (!updated) return send(res, 404, { error: "ユーザーが見つかりません" });
      await audit(req, "auth.unlock", { userId: targetId }, "0390049");
      return send(res, 200, { ok: true });
    }

    // ── システム管理者による既存利用者のパスワード初期化（リセット。次回ログイン時に変更必須）──
    const resetPwMatch = pathname.match(/^\/api\/auth\/users\/([^/]+)\/reset-password$/);
    if (resetPwMatch && req.method === "POST") {
      requireRole(actor, "ADMIN"); // 操作者IDのパスワードを管理者が初期化
      const targetId = decodeURIComponent(resetPwMatch[1]);
      const body = await parseBody(req);
      const newPassword = String(body.password ?? "");
      const policyErrors = validatePasswordPolicy(newPassword);
      if (policyErrors.length) return send(res, 400, { error: policyErrors.join(" / ") });
      const { salt, hash } = hashPassword(newPassword);
      const updated = await credentialStore.update(targetId, (u) => ({
        ...u, salt, hash, passwordChangedAt: new Date().toISOString(),
        mustChangePassword: true, failedAttempts: 0, lockedUntil: null,
      }));
      if (!updated) return send(res, 404, { error: "ユーザーが見つかりません" });
      await audit(req, "auth.reset_password", { userId: targetId, by: actor.id }, "0390048");
      return send(res, 200, { ok: true, userId: targetId, mustChangePassword: true,
        note: "管理者によるパスワード初期化。対象者は次回ログイン時にパスワード変更が必須。" });
    }

    // ── 電子公印マスタ管理（0390007/0390008） ───────────────────
    if (pathname === "/api/official-seals" && req.method === "GET") {
      requireRole(actor, "CLERK");
      const seals = await readJson(officialSealsFile, []);
      return send(res, 200, seals.map(({ imageData, ...meta }) => ({ ...meta, hasImage: Boolean(imageData) })));
    }

    if (pathname === "/api/official-seals" && req.method === "POST") {
      requireRole(actor, "ADMIN");
      const body = await parseBody(req);
      if (!body.name) return send(res, 400, { error: "name は必須です" });
      const imgError = validateSealImage(body.imageData);
      if (imgError) return send(res, 400, { error: imgError });
      const seals = await readJson(officialSealsFile, []);
      const seal = {
        id: crypto.randomUUID(),
        name: String(body.name),
        // 公印の種類（発行者印/職務代理者印/証明書専用印/カード券面用印 等）を選択可能にする
        kind: body.kind ? String(body.kind) : null,
        branch: body.branch ? String(body.branch) : null,
        imageData: String(body.imageData),
        active: body.active !== false,
        validFrom: body.validFrom ?? null,
        validUntil: body.validUntil ?? null,
        // 帳票へ印字する任意の固定文言（例:「この印は黒色です」「公印省略」）
        fixedNote: body.fixedNote ? String(body.fixedNote) : null,
        createdBy: actor.id,
        createdAt: new Date().toISOString(),
      };
      seals.unshift(seal);
      await writeJson(officialSealsFile, seals);
      await audit(req, "official-seal.create", { id: seal.id, name: seal.name, branch: seal.branch }, "0390007");
      const { imageData, ...meta } = seal;
      return send(res, 201, { ...meta, hasImage: true });
    }

    const sealMatch = pathname.match(/^\/api\/official-seals\/([^/]+)$/);
    if (sealMatch) {
      const id = sealMatch[1];
      const seals = await readJson(officialSealsFile, []);
      const idx = seals.findIndex((s) => s.id === id);
      if (idx === -1) return send(res, 404, { error: "official seal not found" });

      if (req.method === "GET") {
        requireRole(actor, "CLERK");
        return send(res, 200, seals[idx]);
      }
      if (req.method === "PUT") {
        requireRole(actor, "ADMIN");
        const body = await parseBody(req);
        if (body.imageData !== undefined) {
          const imgError = validateSealImage(body.imageData);
          if (imgError) return send(res, 400, { error: imgError });
        }
        const editable = ["name", "branch", "imageData", "active", "validFrom", "validUntil"];
        const patch = {};
        for (const k of editable) if (body[k] !== undefined) patch[k] = body[k];
        seals[idx] = { ...seals[idx], ...patch, updatedAt: new Date().toISOString(), updatedBy: actor.id };
        await writeJson(officialSealsFile, seals);
        await audit(req, "official-seal.update", { id, fields: Object.keys(patch) }, "0390007");
        const { imageData, ...meta } = seals[idx];
        return send(res, 200, { ...meta, hasImage: Boolean(imageData) });
      }
      if (req.method === "DELETE") {
        requireRole(actor, "ADMIN");
        seals.splice(idx, 1);
        await writeJson(officialSealsFile, seals);
        await audit(req, "official-seal.delete", { id }, "0390007");
        return send(res, 204, {});
      }
    }

    // ── コードマスタ管理（0390005: CRUD / 0390006: CSV一括入出力） ───────────
    if (pathname === "/api/masters" && req.method === "GET") {
      requireRole(actor, "CLERK");
      const all = await readJson(mastersFile, []);
      const types = [...new Set(all.map((m) => m.masterType))].sort()
        .map((t) => ({ masterType: t, count: all.filter((m) => m.masterType === t).length }));
      return send(res, 200, types);
    }

    // CSV エクスポート: /api/masters/:type/export.csv
    const masterExportMatch = pathname.match(/^\/api\/masters\/([^/]+)\/export\.csv$/);
    if (masterExportMatch && req.method === "GET") {
      requireRole(actor, "CLERK");
      const type = decodeURIComponent(masterExportMatch[1]);
      const items = (await readJson(mastersFile, [])).filter((m) => m.masterType === type);
      const lines = [MASTER_COLUMNS.join(",")];
      for (const m of items) lines.push(MASTER_COLUMNS.map((c) => csvEscape(m[c])).join(","));
      await audit(req, "master.export", { masterType: type, count: items.length }, "0390006");
      return sendRaw(res, 200, "﻿" + lines.join("\r\n") + "\r\n", "text/csv; charset=utf-8", {
        "content-disposition": "attachment; filename=\"master-" + encodeURIComponent(type) + ".csv\"",
      });
    }

    // CSV インポート: /api/masters/:type/import （code 一致で upsert）
    const masterImportMatch = pathname.match(/^\/api\/masters\/([^/]+)\/import$/);
    if (masterImportMatch && req.method === "POST") {
      requireRole(actor, "ADMIN");
      const type = decodeURIComponent(masterImportMatch[1]);
      const body = await parseBody(req);
      const csv = typeof body === "string" ? body : (body.csv ?? "");
      if (!csv) return send(res, 400, { error: "csv 本文が必要です（{ csv: \"...\" } またはCSV文字列）" });
      const rows = parseCsv(csv);
      if (!rows.length) return send(res, 400, { error: "CSV が空です" });
      const header = rows[0].map((h) => h.trim());
      const codeIdx = header.indexOf("code");
      if (codeIdx === -1) return send(res, 400, { error: "CSV ヘッダに code 列が必要です" });
      const all = await readJson(mastersFile, []);
      let inserted = 0, updated = 0;
      for (const r of rows.slice(1)) {
        const rec = {};
        header.forEach((h, i) => { rec[h] = r[i] ?? ""; });
        if (!rec.code) continue;
        const idx = all.findIndex((m) => m.masterType === type && m.code === rec.code);
        const base = {
          masterType: type,
          code: String(rec.code),
          name: String(rec.name ?? ""),
          sortOrder: rec.sortOrder !== undefined && rec.sortOrder !== "" ? Number(rec.sortOrder) : 0,
          active: rec.active === undefined || rec.active === "" ? true : !/^(false|0|no)$/i.test(String(rec.active)),
          validFrom: rec.validFrom || null,
          validUntil: rec.validUntil || null,
        };
        if (idx === -1) {
          all.push({ id: crypto.randomUUID(), ...base, createdBy: actor.id, createdAt: new Date().toISOString() });
          inserted++;
        } else {
          all[idx] = { ...all[idx], ...base, updatedBy: actor.id, updatedAt: new Date().toISOString() };
          updated++;
        }
      }
      await writeJson(mastersFile, all);
      await audit(req, "master.import", { masterType: type, inserted, updated }, "0390006");
      return send(res, 200, { masterType: type, inserted, updated, total: inserted + updated });
    }

    if (pathname === "/api/masters" && req.method === "POST") {
      // 後方互換: /api/masters に masterType 込みで POST
      requireRole(actor, "ADMIN");
      const body = await parseBody(req);
      if (!body.masterType || !body.code) return send(res, 400, { error: "masterType と code は必須です" });
      const all = await readJson(mastersFile, []);
      if (all.some((m) => m.masterType === body.masterType && m.code === body.code)) {
        return send(res, 409, { error: "同一 masterType/code が既に存在します" });
      }
      const item = {
        id: crypto.randomUUID(), masterType: String(body.masterType), code: String(body.code),
        name: String(body.name ?? ""), sortOrder: Number(body.sortOrder ?? 0),
        active: body.active !== false, validFrom: body.validFrom ?? null, validUntil: body.validUntil ?? null,
        attributes: body.attributes ?? {}, createdBy: actor.id, createdAt: new Date().toISOString(),
      };
      all.push(item);
      await writeJson(mastersFile, all);
      await audit(req, "master.create", { masterType: item.masterType, code: item.code }, "0390005");
      return send(res, 201, item);
    }

    const masterTypeMatch = pathname.match(/^\/api\/masters\/([^/]+)$/);
    if (masterTypeMatch && req.method === "GET") {
      requireRole(actor, "CLERK");
      const type = decodeURIComponent(masterTypeMatch[1]);
      const items = (await readJson(mastersFile, []))
        .filter((m) => m.masterType === type)
        .sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
      return send(res, 200, items);
    }
    if (masterTypeMatch && req.method === "POST") {
      requireRole(actor, "ADMIN");
      const type = decodeURIComponent(masterTypeMatch[1]);
      const body = await parseBody(req);
      if (!body.code) return send(res, 400, { error: "code は必須です" });
      const all = await readJson(mastersFile, []);
      if (all.some((m) => m.masterType === type && m.code === body.code)) {
        return send(res, 409, { error: "同一 code が既に存在します" });
      }
      const item = {
        id: crypto.randomUUID(), masterType: type, code: String(body.code),
        name: String(body.name ?? ""), sortOrder: Number(body.sortOrder ?? 0),
        active: body.active !== false, validFrom: body.validFrom ?? null, validUntil: body.validUntil ?? null,
        attributes: body.attributes ?? {}, createdBy: actor.id, createdAt: new Date().toISOString(),
      };
      all.push(item);
      await writeJson(mastersFile, all);
      await audit(req, "master.create", { masterType: type, code: item.code }, "0390005");
      return send(res, 201, item);
    }

    const masterItemMatch = pathname.match(/^\/api\/masters\/([^/]+)\/([^/]+)$/);
    if (masterItemMatch) {
      const type = decodeURIComponent(masterItemMatch[1]);
      const id = masterItemMatch[2];
      const all = await readJson(mastersFile, []);
      const idx = all.findIndex((m) => m.id === id && m.masterType === type);
      if (idx === -1) return send(res, 404, { error: "master entry not found" });
      if (req.method === "GET") { requireRole(actor, "CLERK"); return send(res, 200, all[idx]); }
      if (req.method === "PUT") {
        requireRole(actor, "ADMIN");
        const body = await parseBody(req);
        const editable = ["name", "sortOrder", "active", "validFrom", "validUntil", "attributes"];
        const patch = {};
        for (const k of editable) if (body[k] !== undefined) patch[k] = body[k];
        all[idx] = { ...all[idx], ...patch, updatedBy: actor.id, updatedAt: new Date().toISOString() };
        await writeJson(mastersFile, all);
        await audit(req, "master.update", { masterType: type, code: all[idx].code }, "0390005");
        return send(res, 200, all[idx]);
      }
      if (req.method === "DELETE") {
        requireRole(actor, "ADMIN");
        const [removed] = all.splice(idx, 1);
        await writeJson(mastersFile, all);
        await audit(req, "master.delete", { masterType: type, code: removed.code }, "0390005");
        return send(res, 204, {});
      }
    }


    const catalogMatch = pathname.match(/^\/api\/catalog\/([^/]+)$/);
    if (catalogMatch) {
      const rows = filterCatalog(catalogMatch[1], searchParams);
      if (!rows) return send(res, 404, { error: "catalog section not found" });
      return send(res, 200, rows);
    }

    if (pathname === "/api/audit") {
      requireRole(actor, "AUDITOR");
      const auditLimit = Math.min(Math.max(parseInt(searchParams.get("limit") ?? "200", 10), 1), 500);
      const auditOffset = Math.max(parseInt(searchParams.get("offset") ?? "0", 10), 0);
      const auditSearch = (searchParams.get("search") ?? "").toLowerCase();
      let all = await readAuditAll();
      if (auditSearch) all = all.filter((r) => JSON.stringify(r).toLowerCase().includes(auditSearch));
      return send(res, 200, { total: all.length, data: all.slice(auditOffset, auditOffset + auditLimit) });
    }

    if (pathname === "/api/restrictions" && req.method === "GET") {
      requireRole(actor, "SUPPORT_ACCESS");
      return send(res, 200, await readJson(restrictionsFile, []));
    }

    if (pathname === "/api/restrictions" && req.method === "POST") {
      requireRole(actor, "SUPPORT_ACCESS");
      const body = await parseBody(req);
      const restrictions = await readJson(restrictionsFile, []);
      const item = {
        id: crypto.randomUUID(),
        recordId: body.recordId ?? null,
        domain: body.domain ?? null,
        category: body.category ?? "SUPPORT_MEASURE",
        scope: body.scope ?? "record",
        status: "active",
        startDate: body.startDate ?? new Date().toISOString().slice(0, 10),
        endDate: body.endDate ?? null,
        note: body.note ?? "",
        createdAt: new Date().toISOString(),
        createdBy: actor.id,
      };
      restrictions.unshift(item);
      await writeJson(restrictionsFile, restrictions);
      await audit(req, "restriction.create", { id: item.id, recordId: item.recordId, domain: item.domain });
      return send(res, 201, item);
    }

    const restrictionMatch = pathname.match(/^\/api\/restrictions\/([^/]+)$/);
    if (restrictionMatch && req.method === "DELETE") {
      requireRole(actor, "SUPPORT_ACCESS");
      const restrictions = await readJson(restrictionsFile, []);
      const index = restrictions.findIndex((item) => item.id === restrictionMatch[1]);
      if (index === -1) return send(res, 404, { error: "restriction not found" });
      restrictions[index] = { ...restrictions[index], status: "inactive", endedAt: new Date().toISOString(), endedBy: actor.id };
      await writeJson(restrictionsFile, restrictions);
      await audit(req, "restriction.delete", { id: restrictionMatch[1] });
      return send(res, 200, restrictions[index]);
    }

    if (pathname === "/api/records" && req.method === "GET") {
      // ページネーション対応
      const recLimit = Math.min(Math.max(parseInt(searchParams.get("limit") ?? "50", 10), 1), 200);
      const recOffset = Math.max(parseInt(searchParams.get("offset") ?? "0", 10), 0);
      // ?domain=&status= フィルタ
      const filterDomain = searchParams.get("domain") ?? "";
      const filterStatus = searchParams.get("status") ?? "";
      const filterSearch = (searchParams.get("search") ?? "").toLowerCase();
      const records = await readJson(recordsFile, []);
      const restrictions = await readJson(restrictionsFile, []);
      let filtered = records;
      if (filterDomain) filtered = filtered.filter((r) => r.domain === filterDomain);
      if (filterStatus) filtered = filtered.filter((r) => r.status === filterStatus);
      // ?fiscalYear= 年度別フィルタ（過年度照会）
      const filterFiscalYear = searchParams.get("fiscalYear") ?? "";
      if (filterFiscalYear) filtered = filtered.filter((r) => String(r.fiscalYear ?? "") === filterFiscalYear);
      if (filterSearch) filtered = filtered.filter((r) => {
        const text = [r.payload?.subject, r.payload?.description, r.id, r.domain].filter(Boolean).join(" ").toLowerCase();
        return text.includes(filterSearch);
      });
      const sortKey = searchParams.get("sort") ?? "updated_desc";
      const _ts = (r) => r.updatedAt ?? r.createdAt ?? "";
      const _subj = (r) => (r.payload?.subject ?? "");
      if (sortKey === "updated_asc") filtered = [...filtered].sort((a, b) => _ts(a).localeCompare(_ts(b)));
      else if (sortKey === "subject") filtered = [...filtered].sort((a, b) => _subj(a).localeCompare(_subj(b), "ja"));
      else filtered = [...filtered].sort((a, b) => _ts(b).localeCompare(_ts(a)));
      const masked = filtered.map((record) => maskRecord(actor, restrictions, record));
      return send(res, 200, { total: masked.length, data: masked.slice(recOffset, recOffset + recLimit), limit: recLimit, offset: recOffset });
    }

    const recordMatch = pathname.match(/^\/api\/records\/([^/]+)(?:\/([^/]+))?$/);
    if (recordMatch) {
      const [, domain, id] = recordMatch;
      const records = await readJson(recordsFile, []);
      const restrictions = await readJson(restrictionsFile, []);

      if (req.method === "GET" && !id) {
        return send(res, 200, records
          .filter((item) => item.domain === domain)
          .map((record) => maskRecord(actor, restrictions, record)));
      }

      if (req.method === "POST" && !id) {
        requireRole(actor, "CLERK");
        const input = validateRecord(domain, await parseBody(req));
        // 入力チェック（ワーニング型）: 同一ドメイン×同一件名は確認の上で続行できる
        const subject = input.payload?.subject ?? "";
        const dupe = subject && records.find((r) => r.domain === domain && (r.payload?.subject ?? "") === subject);
        if (dupe && searchParams.get("confirm") !== "1") {
          return send(res, 409, {
            error: `WARNING_CONFIRM: 同一件名のレコードが既に存在します（${dupe.receiptNumber ?? dupe.id}）。続行する場合は確認してください。`,
            warnings: [{ code: "DUPLICATE_SUBJECT", recordId: dupe.id }],
          });
        }
        // 受付番号の自動付番（年度×ドメイン連番）と年度の自動付与
        const counters = await readJson(countersFile, {});
        const fiscalYear = fiscalYearOf();
        const counterKey = `${domain}:${fiscalYear}`;
        counters[counterKey] = (counters[counterKey] ?? 0) + 1;
        await writeJson(countersFile, counters);
        const receiptNumber = `${domain.slice(0, 4).toUpperCase()}-${fiscalYear}-${String(counters[counterKey]).padStart(6, "0")}`;
        const item = {
          id: crypto.randomUUID(),
          receiptNumber,
          qrValue: `GOVR|${domain}|${receiptNumber}`, // 二次元コード値（帳票・出力ファイルに格納して照合に使う）
          fiscalYear,
          ...input,
          createdAt: new Date().toISOString(),
          updatedAt: new Date().toISOString(),
          createdBy: actor.id,
        };
        records.unshift(item);
        await writeJson(recordsFile, records);
        await audit(req, "record.create", { domain, id: item.id, receiptNumber: item.receiptNumber });
        return send(res, 201, item);
      }

      const index = records.findIndex((item) => item.id === id && item.domain === domain);
      if (index === -1) return send(res, 404, { error: "record not found" });

      if (req.method === "GET") {
        return send(res, 200, maskRecord(actor, restrictions, records[index]));
      }

      if (req.method === "PUT") {
        requireRole(actor, "CLERK");
        const input = validateRecord(domain, await parseBody(req));
        const item = {
          ...records[index],
          ...input,
          updatedAt: new Date().toISOString(),
          updatedBy: actor.id,
        };
        records[index] = item;
        await writeJson(recordsFile, records);
        await audit(req, "record.update", { domain, id });
        return send(res, 200, item);
      }

      if (req.method === "DELETE") {
        requireRole(actor, "CLERK");
        const [item] = records.splice(index, 1);
        await writeJson(recordsFile, records);
        await audit(req, "record.delete", { domain, id });
        return send(res, 200, item);
      }
    }

    if (pathname === "/api/reports/jobs" && req.method === "GET") {
      const rjLimit = Math.min(Math.max(parseInt(searchParams.get("limit") ?? "50", 10), 1), 200);
      const rjOffset = Math.max(parseInt(searchParams.get("offset") ?? "0", 10), 0);
      const rjSearch = (searchParams.get("search") ?? "").toLowerCase();
      // Q-02: COUNT(*) OVER() で 1 クエリ化（旧来は paginated + count の 2 発行だった）
      const dbResult = await dbSearchJsonPaged("public.task_queue", { type: "report" }, rjSearch, { limit: rjLimit, offset: rjOffset });
      if (dbResult !== null) {
        const data = dbResult.data.map((r) => r.payload ?? r);
        return send(res, 200, { total: dbResult.total, data, limit: rjLimit, offset: rjOffset });
      }
      let all = await readJson(reportJobsFile, []);
      if (rjSearch) all = all.filter((r) => JSON.stringify(r).toLowerCase().includes(rjSearch));
      return send(res, 200, { total: all.length, data: all.slice(rjOffset, rjOffset + rjLimit), limit: rjLimit, offset: rjOffset });
    }

    const reportMatch = pathname.match(/^\/api\/reports\/([^/]+)\/preview$/);
    if (reportMatch && req.method === "POST") {
      const body = await parseBody(req);
      const job = {
        id: crypto.randomUUID(),
        reportId: reportMatch[1],
        status: "previewed",
        requestPayload: body,
        generatedAt: new Date().toISOString(),
        generatedBy: actor.id,
        format: "html-preview",
        note: "PDF座標確定前の帳票プレビューです。設計書の帳票要件をもとにテンプレートへ差し替えてください。",
      };
      job.resultUrl = `/api/reports/jobs/${job.id}/download`;
      // DB-first, JSON fallback
      const dbRJ = await dbInsert("public.task_queue", { id: job.id, type: "report", status: job.status, payload: job, createdBy: actor.id });
      if (dbRJ === null) {
        const jobs = await readJson(reportJobsFile, []);
        jobs.unshift(job);
        await writeJson(reportJobsFile, jobs.slice(0, 300));
      }
      await audit(req, "report.preview", { reportId: job.reportId, jobId: job.id });
      return send(res, 200, job);
    }

    const reportDownloadMatch = pathname.match(/^\/api\/reports\/jobs\/([^/]+)\/download$/);
    if (reportDownloadMatch && req.method === "GET") {
      requireRole(actor, "REPORT_VIEWER");
      // DB-first, JSON fallback
      const dbRDL = await dbSelectOne("public.task_queue", reportDownloadMatch[1]);
      const job = (dbRDL?.payload ?? null) ?? (await readJson(reportJobsFile, [])).find((item) => item.id === reportDownloadMatch[1]);
      if (!job) return send(res, 404, { error: "report job not found" });
      // 0390033-0390036: 発行年月日（既定=システム日付）・発行番号（自動採番）・電子公印を解決
      const pq = job.requestPayload ?? {};
      const _seals = await readJson(officialSealsFile, []);
      const _omitSeal = pq.omitSeal === true || String(searchParams.get("seal") ?? "") === "omit";
      const _seal = _omitSeal ? null : selectActiveSeal(_seals, { branch: pq.branch ?? null });
      const _issueDate = pq.issueDate ?? new Date().toISOString().slice(0, 10);
      const _issueNumber = pq.issueNumber ?? (job.reportId + "-" + Date.now().toString(36).toUpperCase());
      const sealOptions = { seal: _seal, issueNumber: _issueNumber, issueDate: _issueDate, omitSeal: _omitSeal || !_seal };
      await audit(req, "report.download", { reportId: job.reportId, jobId: job.id, issueNumber: _issueNumber, seal: _seal?.id ?? "(omitted)" }, "0390046");
      const [households, insured, levies] = await Promise.all([
        readJson(kokuhoHouseholdsFile, []),
        readJson(kokuhoInsuredFile, []),
        readJson(kokuhoLeviesFile, []),
      ]);
      const html = await buildReportHtml(job, catalog, { households, insured, levies }, sealOptions);
      if (process.env.REPORT_FORMAT === "pdf") {
        const pdf = await buildReportPdf(html);
        return sendRaw(res, 200, pdf, "application/pdf", {
          "content-disposition": "attachment; filename=\"report-" + job.id + ".pdf\"",
        });
      }
      return sendRaw(res, 200, html, "text/html; charset=utf-8", {
        "content-disposition": "inline; filename=\"report-" + job.id + ".html\"",
      });
    }

    if (pathname === "/api/euc/jobs" && req.method === "GET") {
      requireRole(actor, "REPORT_VIEWER");
      const ejLimit = Math.min(Math.max(parseInt(searchParams.get("limit") ?? "50", 10), 1), 200);
      const ejOffset = Math.max(parseInt(searchParams.get("offset") ?? "0", 10), 0);
      const ejSearch = (searchParams.get("search") ?? "").toLowerCase();
      // Q-02: COUNT(*) OVER() で 1 クエリ化
      const dbResult = await dbSearchJsonPaged("public.task_queue", { type: "euc" }, ejSearch, { limit: ejLimit, offset: ejOffset });
      if (dbResult !== null) {
        const data = dbResult.data.map((r) => r.payload ?? r);
        return send(res, 200, { total: dbResult.total, data, limit: ejLimit, offset: ejOffset });
      }
      let all = await readJson(eucJobsFile, []);
      if (ejSearch) all = all.filter((r) => JSON.stringify(r).toLowerCase().includes(ejSearch));
      return send(res, 200, { total: all.length, data: all.slice(ejOffset, ejOffset + ejLimit), limit: ejLimit, offset: ejOffset });
    }

    if (pathname === "/api/euc/query" && req.method === "POST") {
      requireRole(actor, "REPORT_REQUESTER");
      const body = await parseBody(req);
      const requiredApprovals = eucNeedsTwoApprovals(body) ? 2 : 1;
      const job = {
        id: crypto.randomUUID(),
        status: requiredApprovals === 1 ? "DONE" : "QUEUED",
        requesterUserId: actor.id,
        requestedAt: new Date().toISOString(),
        outputFields: body.outputFields ?? ["id", "domain", "status"],
        includeSensitive: Boolean(body.includeSensitive || body.includeMyNumber),
        requiredApprovals,
        approvals: requiredApprovals === 1 ? [{ approverUserId: actor.id, action: "APPROVE", at: new Date().toISOString(), comment: "auto-approved" }] : [],
        resultUrl: null,
      };
      if (requiredApprovals === 1) job.resultUrl = `/api/euc/jobs/${job.id}/download`;
      // DB-first, JSON fallback
      const dbEQ = await dbInsert("public.task_queue", { id: job.id, type: "euc", status: job.status, payload: job, createdBy: actor.id });
      if (dbEQ === null) {
        const jobs = await readJson(eucJobsFile, []);
        jobs.unshift(job);
        await writeJson(eucJobsFile, jobs);
      }
      await audit(req, "euc.query", { id: job.id, requiredApprovals, status: job.status });
      return send(res, 201, { ...job, ...approvalCounts(job) });
    }

    const eucApprovalMatch = pathname.match(/^\/api\/euc\/([^/]+)\/approve$/);
    if (eucApprovalMatch && req.method === "POST") {
      requireRole(actor, "REPORT_APPROVER");
      const body = await parseBody(req);
      const action = body.action === "REJECT" ? "REJECT" : "APPROVE";
      // DB-first, JSON fallback
      const dbEA = await dbSelectOne("public.task_queue", eucApprovalMatch[1]);
      let job, eucJobsArr, _eucIdx;
      if (dbEA !== null) {
        job = dbEA.payload ?? dbEA;
      } else {
        eucJobsArr = await readJson(eucJobsFile, []);
        _eucIdx = eucJobsArr.findIndex((j) => j.id === eucApprovalMatch[1]);
        if (_eucIdx === -1) return send(res, 404, { error: "euc job not found" });
        job = eucJobsArr[_eucIdx];
      }
      if (!job) return send(res, 404, { error: "euc job not found" });
      if (job.requesterUserId === actor.id) return send(res, 409, { error: "self approval is not allowed" });
      if (job.approvals.some((approval) => approval.approverUserId === actor.id)) {
        return send(res, 409, { error: "duplicate approval is not allowed" });
      }
      const approval = { approverUserId: actor.id, action, at: new Date().toISOString(), comment: body.comment ?? null };
      job.approvals.push(approval);
      if (action === "REJECT") {
        job.status = "FAILED";
        job.resultUrl = null;
      } else if (job.approvals.filter((item) => item.action === "APPROVE").length >= job.requiredApprovals) {
        job.status = "DONE";
        job.resultUrl = `/api/euc/jobs/${job.id}/download`;
        job.completedAt = new Date().toISOString();
      }
      if (dbEA !== null) {
        await dbUpdate("public.task_queue", job.id, { status: job.status, payload: job, updatedAt: new Date().toISOString() });
      } else {
        eucJobsArr[_eucIdx] = job;
        await writeJson(eucJobsFile, eucJobsArr);
      }
      await audit(req, `euc.${action.toLowerCase()}`, { id: job.id, status: job.status, approver: actor.id });
      // SSE
      sseBroadcast("euc.approval", { id: job.id, status: job.status, action, approver: actor.id });
      return send(res, 200, { ...job, ...approvalCounts(job) });
    }

    const eucDownloadMatch = pathname.match(/^\/api\/euc\/jobs\/([^/]+)\/download$/);
    if (eucDownloadMatch && req.method === "GET") {
      requireRole(actor, "REPORT_VIEWER");
      // DB-first, JSON fallback
      const dbEDL = await dbSelectOne("public.task_queue", eucDownloadMatch[1]);
      const job = (dbEDL?.payload ?? null) ?? (await readJson(eucJobsFile, [])).find((item) => item.id === eucDownloadMatch[1]);
      if (!job) return send(res, 404, { error: "euc job not found" });
      if (job.status !== "DONE") return send(res, 409, { error: "euc job is not ready" });
      await audit(req, "euc.download", { id: job.id, fields: job.outputFields });
      res.writeHead(200, {
        "content-type": "text/csv; charset=utf-8",
        "cache-control": "no-store",
        "x-content-type-options": "nosniff",
        "x-frame-options": "DENY",
        "referrer-policy": "no-referrer",
        "content-disposition": `attachment; filename="euc-${job.id}.csv"`,
      });
      return res.end(await buildEucCsv(job));
    }

    // ── 証明書交付・コンビニ交付（juki水準: 証明書発行 + 本人通知 + 抑止連動）──
    if (pathname === "/api/certificates/conveni/status" && req.method === "GET") {
      requireRole(actor, "CLERK");
      const certs = await readJson(certificatesFile, []);
      const conv = certs.filter((c) => c.channel === "convenience");
      return send(res, 200, {
        partner: "J-LIS / 自治体中間サーバ",
        linkState: "CONNECTED",
        serviceHours: "6:30-23:00",
        checkedAt: new Date().toISOString(),
        totals: {
          total: conv.length,
          issued: conv.filter((c) => c.status === "issued").length,
          refused: conv.filter((c) => c.status === "refused").length,
        },
      });
    }

    if (pathname === "/api/certificates" && req.method === "GET") {
      requireRole(actor, "CLERK");
      return send(res, 200, await readJson(certificatesFile, []));
    }

    if (pathname === "/api/certificates" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const body = await parseBody(req);
      const recordId = body.recordId ?? body.subjectId;
      if (!recordId) return send(res, 400, { error: "recordId (or subjectId) required" });
      const requesterType = String(body.requesterType ?? "SELF").toUpperCase();
      const thirdParty = ["THIRD_PARTY", "PROXY", "DELEGATE"].includes(requesterType);
      const certs = await readJson(certificatesFile, []);
      const cert = {
        id: crypto.randomUUID(),
        recordId,
        domain: body.domain ?? null,
        formId: body.formId ?? "default",
        requesterType,
        channel: "window",
        fee: 300,
        status: "issued",
        verifyToken: "V" + crypto.randomUUID().replace(/-/g, "").slice(0, 10).toUpperCase(),
        // 本人通知制度: 第三者・代理人請求は本人へ通知
        honninNotified: thirdParty,
        issuedBy: actor.id,
        issuedAt: new Date().toISOString(),
      };
      certs.unshift(cert);
      await writeJson(certificatesFile, certs);
      await audit(req, "certificate.issue", { id: cert.id, recordId, requesterType, honninNotified: cert.honninNotified });
      // 本人通知制度: 第三者・代理人請求で本人が事前登録済みなら通知記録を発出
      if (thirdParty) {
        const regs = await readJson(notifyRegistrationsFile, []);
        if (regs.some((r) => r.recordId === recordId && r.status === "ACTIVE")) {
          const notes = await readJson(notificationsFile, []);
          const note = { id: crypto.randomUUID(), recordId, issueId: cert.id, requesterType, notifiedAt: new Date().toISOString(), channel: "POSTAL", status: "NOTIFIED" };
          notes.unshift(note);
          await writeJson(notificationsFile, notes);
          await audit(req, "notification.create", { id: note.id, recordId });
          cert.notificationId = note.id;
        }
      }
      return send(res, 201, cert);
    }

    if (pathname === "/api/certificates/conveni" && req.method === "POST") {
      // J-LIS 中間サーバからの交付要求。支援措置・抑止対象は REFUSED。
      const body = await parseBody(req);
      const recordId = body.recordId ?? body.subjectId;
      if (!recordId) return send(res, 400, { error: "recordId (or subjectId) required" });
      const restrictions = await readJson(restrictionsFile, []);
      const restricted = restrictions.some((r) =>
        r.status !== "inactive" &&
        (r.recordId === recordId || (r.domain && r.domain === (body.domain ?? null))));
      const certs = await readJson(certificatesFile, []);
      const cert = {
        id: crypto.randomUUID(),
        recordId,
        domain: body.domain ?? null,
        formId: body.formId ?? "default",
        storeCode: body.storeCode ?? "JLIS-STORE",
        channel: "convenience",
        requestedAt: new Date().toISOString(),
      };
      if (restricted) {
        cert.status = "refused";
        cert.reason = "支援措置・抑止対象のためコンビニ交付を停止しています。";
      } else {
        cert.status = "issued";
        cert.fee = 200;
        cert.verifyToken = "V" + crypto.randomUUID().replace(/-/g, "").slice(0, 10).toUpperCase();
      }
      certs.unshift(cert);
      await writeJson(certificatesFile, certs);
      await audit(req, "certificate.conveni", { id: cert.id, recordId, status: cert.status });
      return send(res, cert.status === "issued" ? 201 : 200, cert);
    }

    // ── 通称・旧氏管理（juki水準 SCR-103相当: 汎用 alias サイドテーブル）──
    if (pathname === "/api/aliases" && req.method === "GET") {
      requireRole(actor, "CLERK");
      const all = await readJson(aliasesFile, []);
      const rid = new URL(req.url, "http://x").searchParams.get("recordId");
      return send(res, 200, rid ? all.filter((a) => a.recordId === rid) : all);
    }
    if (pathname === "/api/aliases" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const body = await parseBody(req);
      if (!body.recordId || !body.valueKanji) return send(res, 400, { error: "recordId and valueKanji required" });
      const all = await readJson(aliasesFile, []);
      const alias = {
        id: crypto.randomUUID(),
        recordId: body.recordId,
        kind: body.kind === "FORMER_FAMILY" ? "FORMER_FAMILY" : "ALIAS",
        valueKanji: body.valueKanji,
        valueKana: body.valueKana ?? "",
        validFrom: body.validFrom ?? new Date().toISOString().slice(0, 10),
        validTo: null,
        createdBy: actor.id,
        createdAt: new Date().toISOString(),
      };
      all.unshift(alias);
      await writeJson(aliasesFile, all);
      await audit(req, "alias.create", { id: alias.id, recordId: alias.recordId, kind: alias.kind });
      return send(res, 201, alias);
    }
    const aliasDeleteMatch = pathname.match(/^\/api\/aliases\/([^/]+)$/);
    if (aliasDeleteMatch && req.method === "DELETE") {
      requireRole(actor, "CLERK");
      const all = await readJson(aliasesFile, []);
      const target = all.find((a) => a.id === aliasDeleteMatch[1]);
      if (!target) return send(res, 404, { error: "alias not found" });
      target.validTo = new Date().toISOString().slice(0, 10);
      await writeJson(aliasesFile, all);
      await audit(req, "alias.delete", { id: target.id });
      return send(res, 200, target);
    }

    // ── 本人通知制度（juki水準 SCR-801相当: 事前登録 + 通知記録）──
    if (pathname === "/api/notify/registrations" && req.method === "GET") {
      requireRole(actor, "CLERK");
      return send(res, 200, await readJson(notifyRegistrationsFile, []));
    }
    if (pathname === "/api/notify/registrations" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const body = await parseBody(req);
      if (!body.recordId) return send(res, 400, { error: "recordId required" });
      const regs = await readJson(notifyRegistrationsFile, []);
      if (regs.some((r) => r.recordId === body.recordId && r.status === "ACTIVE")) {
        return send(res, 409, { error: "already registered" });
      }
      const now = new Date();
      const expires = new Date(now.getTime());
      expires.setFullYear(expires.getFullYear() + Number(body.years || 3));
      const reg = {
        id: crypto.randomUUID(),
        recordId: body.recordId,
        registeredAt: now.toISOString(),
        expiresAt: expires.toISOString().slice(0, 10),
        status: "ACTIVE",
        note: body.note || "",
        createdBy: actor.id,
      };
      regs.unshift(reg);
      await writeJson(notifyRegistrationsFile, regs);
      await audit(req, "notify-registration.create", { id: reg.id, recordId: reg.recordId });
      return send(res, 201, reg);
    }
    const notifyRegMatch = pathname.match(/^\/api\/notify\/registrations\/([^/]+)$/);
    if (notifyRegMatch && req.method === "DELETE") {
      requireRole(actor, "CLERK");
      const regs = await readJson(notifyRegistrationsFile, []);
      const reg = regs.find((r) => r.id === notifyRegMatch[1]);
      if (!reg) return send(res, 404, { error: "registration not found" });
      reg.status = "INACTIVE";
      reg.endedAt = new Date().toISOString();
      await writeJson(notifyRegistrationsFile, regs);
      await audit(req, "notify-registration.delete", { id: reg.id });
      return send(res, 200, reg);
    }
    if (pathname === "/api/notifications" && req.method === "GET") {
      requireRole(actor, "CLERK");
      return send(res, 200, await readJson(notificationsFile, []));
    }

    // ── エラー・アラート設定 / アクセスログ分析（juki水準 SCR-A04相当）──
    {
      const ALERT_DEFAULTS = { nightAccessEnabled: true, nightStartHour: 22, nightEndHour: 6, bulkAccessEnabled: true, bulkAccessThreshold: 100 };
      if (pathname === "/api/alert-rules" && req.method === "GET") {
        requireRole(actor, "CLERK");
        return send(res, 200, await readJson(alertRulesFile, ALERT_DEFAULTS));
      }
      if (pathname === "/api/alert-rules" && req.method === "PUT") {
        requireRole(actor, "ADMIN");
        const body = await parseBody(req);
        const next = { ...ALERT_DEFAULTS, ...(await readJson(alertRulesFile, ALERT_DEFAULTS)) };
        for (const k of ["nightAccessEnabled", "bulkAccessEnabled"]) if (typeof body[k] === "boolean") next[k] = body[k];
        for (const k of ["nightStartHour", "nightEndHour", "bulkAccessThreshold"]) if (Number.isFinite(Number(body[k]))) next[k] = Number(body[k]);
        await writeJson(alertRulesFile, next);
        await audit(req, "alert-rules.update", next);
        return send(res, 200, next);
      }
      if (pathname === "/api/alerts" && req.method === "GET") {
        requireRole(actor, "CLERK");
        const rules = await readJson(alertRulesFile, ALERT_DEFAULTS);
        const logs = await readAuditAll();
        const alerts = [];
        if (rules.nightAccessEnabled) {
          for (const log of logs) {
            const hour = new Date(log.at).getHours();
            const night = rules.nightStartHour > rules.nightEndHour
              ? (hour >= rules.nightStartHour || hour < rules.nightEndHour)
              : (hour >= rules.nightStartHour && hour < rules.nightEndHour);
            if (night) alerts.push({ type: "NIGHT_ACCESS", severity: "WARN", actor: log.actor, at: log.at, message: `深夜時間帯(${rules.nightStartHour}時〜${rules.nightEndHour}時)のアクセス: ${log.action}` });
          }
        }
        if (rules.bulkAccessEnabled) {
          const counts = {};
          for (const log of logs) counts[log.actor] = (counts[log.actor] || 0) + 1;
          for (const [a, c] of Object.entries(counts)) {
            if (c > rules.bulkAccessThreshold) alerts.push({ type: "BULK_ACCESS", severity: "WARN", actor: a, count: c, message: `大量操作・検索の疑い: ${a} が ${c} 件(閾値 ${rules.bulkAccessThreshold})の操作を実施` });
          }
        }
        return send(res, 200, { rules, total: alerts.length, alerts });
      }
    }

    // ── EUC設計（juki水準 SCR-A01相当: 抽出テンプレート）──
    if (pathname === "/api/euc-templates" && req.method === "GET") {
      requireRole(actor, "CLERK");
      return send(res, 200, await readJson(eucTemplatesFile, []));
    }
    if (pathname === "/api/euc-templates" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const body = await parseBody(req);
      if (!body.name || !Array.isArray(body.outputFields) || body.outputFields.length === 0) {
        return send(res, 400, { error: "name and outputFields (>=1) required" });
      }
      const includeMyNumber = Boolean(body.includeMyNumber) || body.outputFields.includes("myNumber");
      const tpls = await readJson(eucTemplatesFile, []);
      const tpl = { id: crypto.randomUUID(), name: body.name, domain: body.domain || "main", outputFields: body.outputFields, includeMyNumber, requiresSecondApproval: includeMyNumber, createdBy: actor.id, createdAt: new Date().toISOString() };
      tpls.unshift(tpl);
      await writeJson(eucTemplatesFile, tpls);
      await audit(req, "euc-template.create", { id: tpl.id, name: tpl.name, includeMyNumber });
      return send(res, 201, tpl);
    }
    const eucTplMatch = pathname.match(/^\/api\/euc-templates\/([^/]+)$/);
    if (eucTplMatch && req.method === "DELETE") {
      requireRole(actor, "CLERK");
      const tpls = await readJson(eucTemplatesFile, []);
      const next = tpls.filter((t) => t.id !== eucTplMatch[1]);
      if (next.length === tpls.length) return send(res, 404, { error: "template not found" });
      await writeJson(eucTemplatesFile, next);
      await audit(req, "euc-template.delete", { id: eucTplMatch[1] });
      return send(res, 204, {});
    }

    // ── バッチ管理（juki水準 標準仕様書9バッチ相当）──
    {
      const BATCH_TYPES = [
        { type: "RECONCILE", name: "整合性確認バッチ", description: "業務レコードと抑止情報の整合性確認" },
        { type: "AGGREGATE", name: "集計バッチ", description: "業務レコードの状態別集計" },
        { type: "AUDIT_ANALYSIS", name: "アクセスログ分析バッチ", description: "監査ログの操作者別集計" },
        { type: "CS_IMPORT", name: "連携電文取込バッチ", description: "外部連携電文の取込(参照実装)" },
      ];
      if (pathname === "/api/batch-jobs" && req.method === "GET") {
        requireRole(actor, "CLERK");
        return send(res, 200, { types: BATCH_TYPES, history: (await readJson(batchJobsFile, [])).slice(0, 100) });
      }
      const batchRunMatch = pathname.match(/^\/api\/batch-jobs\/([A-Z_]+)\/run$/);
      if (batchRunMatch && req.method === "POST") {
        requireRole(actor, "CLERK");
        const def = BATCH_TYPES.find((b) => b.type === batchRunMatch[1]);
        if (!def) return send(res, 404, { error: "unknown batch type" });
        const startedAt = new Date().toISOString();
        const records = await readJson(recordsFile, []);
        let processed = 0;
        const details = {};
        if (def.type === "AGGREGATE") {
          for (const r of records) { const k = r.status || "unknown"; details[k] = (details[k] || 0) + 1; }
          processed = records.length;
        } else if (def.type === "AUDIT_ANALYSIS") {
          const logs = await readAuditAll();
          for (const l of logs) { const k = l.actor || "unknown"; details[k] = (details[k] || 0) + 1; }
          processed = logs.length;
        } else if (def.type === "RECONCILE") {
          const restrictions = await readJson(restrictionsFile, []);
          processed = records.length;
          details.restricted = restrictions.filter((x) => x.status !== "inactive").length;
        } else {
          processed = 0;
          details.applied = 0;
        }
        const job = { jobId: crypto.randomUUID(), type: def.type, name: def.name, status: "DONE", startedAt, finishedAt: new Date().toISOString(), processed, details, executedBy: actor.id };
        const hist = await readJson(batchJobsFile, []);
        hist.unshift(job);
        await writeJson(batchJobsFile, hist);
        await audit(req, "batch.run", { type: def.type, processed });
        return send(res, 202, job);
      }

      // ── 定期バッチのスケジュール管理（年次/月次/日次/夜間処理の予約・次回実行日算出）──
      const _nextRun = (cycle, from) => {
        const base = from ? new Date(from) : new Date();
        const d = new Date(base.getFullYear(), base.getMonth(), base.getDate());
        if (cycle === "年次") { d.setFullYear(d.getFullYear() + 1); }
        else if (cycle === "月次") { d.setMonth(d.getMonth() + 1, 1); }
        else { d.setDate(d.getDate() + 1); } // 日次・夜間
        return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
      };
      // 起動方式（スケジュール管理）: 直接起動/年月日時分/毎日/毎週○曜日/毎月XX日/毎月末 の次回実行日時を算定
      const _triggerTypes = ["直接起動", "年月日時分", "毎日", "毎週", "毎月", "毎月末"];
      const _nextTrigger = (trigger, from) => {
        const now = from ? new Date(from) : new Date();
        const [hh, mm] = String(trigger.時刻 ?? trigger.time ?? "00:00").split(":").map((x) => Number(x) || 0);
        const at = (y, mo, day) => new Date(y, mo, day, hh, mm, 0);
        const lastDay = (y, mo) => new Date(y, mo + 1, 0).getDate();
        switch (trigger.type) {
          case "直接起動": return now; // 即時起動
          case "年月日時分": { const dt = new Date(trigger.datetime ?? trigger.日時); return isNaN(dt) ? null : dt; }
          case "毎日": { let c = at(now.getFullYear(), now.getMonth(), now.getDate()); if (c <= now) c = at(now.getFullYear(), now.getMonth(), now.getDate() + 1); return c; }
          case "毎週": {
            const dow = Number(trigger.曜日 ?? trigger.dow); if (!(dow >= 0 && dow <= 6)) return null;
            let add = (dow - now.getDay() + 7) % 7;
            let c = at(now.getFullYear(), now.getMonth(), now.getDate() + add);
            if (c <= now) c = at(now.getFullYear(), now.getMonth(), now.getDate() + add + 7);
            return c;
          }
          case "毎月": {
            const day = Number(trigger.日 ?? trigger.day); if (!(day >= 1 && day <= 31)) return null;
            const mk = (y, mo) => at(y, mo, Math.min(day, lastDay(y, mo)));
            let c = mk(now.getFullYear(), now.getMonth());
            if (c <= now) { const nm = now.getMonth() + 1; c = mk(now.getFullYear() + Math.floor(nm / 12), nm % 12); }
            return c;
          }
          case "毎月末": {
            const mk = (y, mo) => at(y, mo, lastDay(y, mo));
            let c = mk(now.getFullYear(), now.getMonth());
            if (c <= now) { const nm = now.getMonth() + 1; c = mk(now.getFullYear() + Math.floor(nm / 12), nm % 12); }
            return c;
          }
          default: return null;
        }
      };
      if (pathname === "/api/batch-jobs/schedules" && req.method === "GET") {
        requireRole(actor, "CLERK");
        return send(res, 200, await readJson(batchSchedulesFile, []));
      }
      if (pathname === "/api/batch-jobs/schedules" && req.method === "POST") {
        requireRole(actor, "CLERK");
        const body = await parseBody(req);
        const cycles = ["年次", "月次", "日次", "夜間"];
        const hasTrigger = body.trigger && _triggerTypes.includes(body.trigger.type);
        if (!body.name || (!cycles.includes(body.cycle) && !hasTrigger)) {
          return send(res, 400, { error: `name と cycle(${cycles.join("/")}) または trigger.type(${_triggerTypes.join("/")}) が必須です` });
        }
        let cycle = null, trigger = null, nextRunAt = null;
        if (hasTrigger) {
          trigger = body.trigger;
          const nr = _nextTrigger(trigger, body.baseDate);
          if (!nr) return send(res, 400, { error: `trigger(${trigger.type})の指定が不正です(時刻/曜日/日/日時を確認)` });
          nextRunAt = nr.toISOString();
        } else {
          cycle = body.cycle;
          nextRunAt = _nextRun(cycle, body.baseDate);
        }
        const list = await readJson(batchSchedulesFile, []);
        const item = {
          id: crypto.randomUUID(), name: String(body.name), cycle, trigger, 起動方式: trigger ? trigger.type : cycle,
          batchType: body.batchType ?? null, baseDate: body.baseDate ?? null,
          nextRunAt, enabled: body.enabled !== false,
          createdAt: new Date().toISOString(), by: actor.id,
        };
        list.unshift(item);
        await writeJson(batchSchedulesFile, list);
        await audit(req, "batch-schedule.create", { name: item.name, 起動方式: item.起動方式 });
        return send(res, 201, item);
      }
      const batchSchedDelMatch = pathname.match(/^\/api\/batch-jobs\/schedules\/([^/]+)$/);
      if (batchSchedDelMatch && req.method === "DELETE") {
        requireRole(actor, "CLERK");
        const list = await readJson(batchSchedulesFile, []);
        const idx = list.findIndex((x) => x.id === batchSchedDelMatch[1]);
        if (idx < 0) return send(res, 404, { error: "スケジュールが見つかりません" });
        const [removed] = list.splice(idx, 1);
        await writeJson(batchSchedulesFile, list);
        await audit(req, "batch-schedule.delete", { name: removed.name });
        return send(res, 200, { deleted: true, name: removed.name });
      }
    }

    // ── ダッシュボード集約（juki水準 SCR-002相当）──
    if (pathname === "/api/overview" && req.method === "GET") {
      requireRole(actor, "CLERK");
      const records = await readJson(recordsFile, []);
      const restrictions = await readJson(restrictionsFile, []);
      const certificates = await readJson(certificatesFile, []);
      const aliases = await readJson(aliasesFile, []);
      const notifyRegs = await readJson(notifyRegistrationsFile, []);
      const notifications = await readJson(notificationsFile, []);
      const eucTpls = await readJson(eucTemplatesFile, []);
      const batchJobs = await readJson(batchJobsFile, []);
      const byStatus = {};
      for (const r of records) { const k = r.status || "unknown"; byStatus[k] = (byStatus[k] || 0) + 1; }
      return send(res, 200, {
        records: { total: records.length, byStatus },
        restrictions: restrictions.filter((x) => x.status !== "inactive").length,
        certificates: certificates.length,
        conveni: certificates.filter((c) => c.channel === "convenience").length,
        aliases: aliases.filter((a) => !a.validTo).length,
        notifyRegistrations: notifyRegs.filter((r) => r.status === "ACTIVE").length,
        notifications: notifications.length,
        eucTemplates: eucTpls.length,
        batchJobs: batchJobs.length,
      });
    }

    // ── EUC実行（juki水準: 保存テンプレートからの抽出実行）──
    {
      const eucTplRunMatch = pathname.match(/^\/api\/euc-templates\/([^/]+)\/run$/);
      if (eucTplRunMatch && req.method === "POST") {
        requireRole(actor, "CLERK");
        const tpls = await readJson(eucTemplatesFile, []);
        const tpl = tpls.find((t) => t.id === eucTplRunMatch[1]);
        if (!tpl) return send(res, 404, { error: "template not found" });
        const needsApproval = Boolean(tpl.requiresSecondApproval);
        const records = await readJson(recordsFile, []);
        await audit(req, "euc-template.run", { id: tpl.id, name: tpl.name, needsApproval });
        return send(res, 202, {
          jobId: crypto.randomUUID(),
          templateId: tpl.id,
          templateName: tpl.name,
          outputFields: tpl.outputFields,
          estimatedRows: records.length,
          status: needsApproval ? "QUEUED" : "DONE",
          requiresSecondApproval: needsApproval,
          requiredApprovals: needsApproval ? 2 : 1,
          resultUrl: needsApproval ? null : "/euc/result.csv",
        });
      }
    }

    // ── レコードCSV出力（juki水準 検索CSV相当, 機能0040079相当）──
    if (pathname === "/api/records-export" && req.method === "GET") {
      requireRole(actor, "CLERK");
      const records = await readJson(recordsFile, []);
      const restrictions = await readJson(restrictionsFile, []);
      const filterDomain = searchParams.get("domain") || "";
      let rows = records;
      if (filterDomain) rows = rows.filter((r) => r.domain === filterDomain);
      const masked = rows.map((r) => maskRecord(actor, restrictions, r));
      const cols = [["id", "ID"], ["domain", "業務領域"], ["status", "状態"], ["subject", "件名"], ["receiptNumber", "受付番号"], ["qrValue", "二次元コード値"], ["createdAt", "作成日時"]];
      const esc = (v) => { const s = String(v ?? ""); return /[",\n]/.test(s) ? `"${s.replaceAll('"', '""')}"` : s; };
      const pick = (r, k) => (k === "subject" ? (r.payload && r.payload.subject) : r[k]);
      const header = cols.map(([, label]) => label).join(",");
      const lines = masked.map((r) => cols.map(([k]) => esc(pick(r, k))).join(","));
      const csv = "﻿" + [header, ...lines].join("\r\n") + "\r\n";
      await audit(req, "records.export", { count: masked.length, domain: filterDomain || null });
      res.writeHead(200, {
        "content-type": "text/csv; charset=utf-8",
        "content-disposition": 'attachment; filename="records.csv"',
        "cache-control": "no-store",
      });
      return res.end(csv);
    }

    // ── 操作履歴の照会（いつ=at/from/to・どこで=terminal・誰が=actor・どうした=action・誰を/何を=detail・機能ID でフィルタ）──
    if (pathname === "/api/audit-log" && req.method === "GET") {
      requireRole(actor, "AUDITOR");
      const sp = new URL(req.url, "http://x").searchParams;
      const actorQ = (sp.get("actor") ?? "").trim();      // 誰が
      const actionQ = (sp.get("action") ?? "").trim();    // どうした
      const fidQ = (sp.get("functionId") ?? "").trim();
      const termQ = (sp.get("terminal") ?? "").trim();    // どこで
      const targetQ = (sp.get("対象") ?? sp.get("target") ?? "").trim(); // 誰を/何を
      const from = (sp.get("from") ?? "").trim();         // いつ(開始)
      const to = (sp.get("to") ?? "").trim();             // いつ(終了)
      const limit = Math.min(Math.max(Number(sp.get("limit") ?? 200), 1), 2000);
      let logs = await readAuditAll();
      if (actorQ) logs = logs.filter((r) => String(r.actor ?? "").includes(actorQ));
      if (actionQ) logs = logs.filter((r) => String(r.action ?? "").includes(actionQ));
      if (fidQ) logs = logs.filter((r) => String(r.functionId ?? "") === fidQ);
      if (termQ) logs = logs.filter((r) => String(r.terminal ?? "").includes(termQ));
      if (targetQ) logs = logs.filter((r) => JSON.stringify(r.detail ?? "").includes(targetQ));
      if (from) logs = logs.filter((r) => String(r.at ?? "") >= from);
      if (to) logs = logs.filter((r) => String(r.at ?? "") <= to + (to.length <= 10 ? "T23:59:59" : ""));
      const total = logs.length;
      const rows = logs.slice(-limit).reverse(); // 新しい順
      await audit(req, "audit-log.query", { hit: total });
      return send(res, 200, { total, count: rows.length, items: rows.map((r) => ({ いつ: r.at, どこで: r.terminal ?? null, 誰が: r.actor, どうした: r.action, 誰を何を: r.detail ?? null, 機能ID: r.functionId ?? null })) });
    }

    // ── 監査ログCSV出力（操作ログ提出用）──
    if (pathname === "/api/audit-export" && req.method === "GET") {
      requireRole(actor, "AUDITOR");
      const logs = await readAuditAll();
      const cols = [["at", "日時"], ["actor", "操作者"], ["action", "操作"], ["functionId", "機能ID"], ["terminal", "端末"], ["detail", "詳細"]];
      const esc = (v) => {
        const s = typeof v === "object" && v !== null ? JSON.stringify(v) : String(v ?? "");
        return /[",\n]/.test(s) ? `"${s.replaceAll('"', '""')}"` : s;
      };
      const header = cols.map(([, label]) => label).join(",");
      const lines = logs.map((r) => cols.map(([k]) => esc(r[k])).join(","));
      const csv = "﻿" + [header, ...lines].join("\r\n") + "\r\n";
      res.writeHead(200, {
        "content-type": "text/csv; charset=utf-8",
        "content-disposition": 'attachment; filename="audit-log.csv"',
        "cache-control": "no-store",
      });
      return res.end(csv);
    }

    // ── レコード一括登録（パンチデータ取込・一括入力の参照実装）──
    if (pathname === "/api/records-bulk" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const body = await parseBody(req);
      const domain = body.domain;
      const items = Array.isArray(body.items) ? body.items : [];
      if (!domain || !/^[a-zA-Z0-9_-]+$/.test(domain)) return send(res, 400, { error: "valid domain required" });
      if (items.length === 0) return send(res, 400, { error: "items (>=1) required" });
      const records = await readJson(recordsFile, []);
      const created = [];
      const errors = [];
      for (let i = 0; i < items.length; i++) {
        const it = items[i];
        if (!it || typeof it !== "object" || Array.isArray(it)) { errors.push({ index: i, error: "item must be an object" }); continue; }
        const rec = { id: crypto.randomUUID(), domain, status: it.status || "draft", payload: it.payload || it, createdAt: new Date().toISOString(), createdBy: actor.id };
        records.push(rec);
        created.push(rec.id);
      }
      await writeJson(recordsFile, records);
      await audit(req, "records.bulk-create", { domain, created: created.length, errors: errors.length });
      return send(res, 201, { created: created.length, ids: created, errors });
    }

    // ── レコードの一括更新（抽出した対象に対し、payload/status をまとめて更新する。マスタ更新は /api/masters/:type/import）──
    if (pathname === "/api/records-bulk/update" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const body = await parseBody(req);
      const domain = body.domain;
      const updates = Array.isArray(body.updates) ? body.updates : [];
      if (!domain || !/^[a-zA-Z0-9_-]+$/.test(domain)) return send(res, 400, { error: "valid domain required" });
      if (updates.length === 0) return send(res, 400, { error: "updates (>=1) required" });
      const records = await readJson(recordsFile, []);
      let updated = 0; const notFound = [];
      // updates: [{id, payload?, status?}] / もしくは {filter:{...}, set:{...}} で条件一括更新
      if (body.filter && body.set && typeof body.set === "object") {
        const f = body.filter;
        for (let i = 0; i < records.length; i++) {
          if (records[i].domain !== domain) continue;
          const p = records[i].payload || {};
          if (Object.entries(f).every(([k, v]) => String(p[k] ?? records[i][k]) === String(v))) {
            records[i] = { ...records[i], payload: { ...p, ...body.set }, updatedAt: new Date().toISOString(), updatedBy: actor.id };
            updated++;
          }
        }
      } else {
        for (const u of updates) {
          const idx = records.findIndex((r) => r.id === u.id && r.domain === domain);
          if (idx < 0) { notFound.push(u.id); continue; }
          records[idx] = { ...records[idx],
            ...(u.payload ? { payload: { ...records[idx].payload, ...u.payload } } : {}),
            ...(u.status ? { status: u.status } : {}), updatedAt: new Date().toISOString(), updatedBy: actor.id };
          updated++;
        }
      }
      await writeJson(recordsFile, records);
      await audit(req, "records.bulk-update", { domain, updated, notFound: notFound.length });
      return send(res, 200, { updated, notFound, note: "抽出対象に対するレコードの一括更新（id指定 または filter/set 条件一括）" });
    }

    // ── レコード状態遷移ワークフロー（業務レコードのライフサイクル管理）──
    {
      const RECORD_TRANSITIONS = { draft: ["active", "cancelled"], active: ["suspended", "closed"], suspended: ["active", "closed"], closed: [], cancelled: [] };
      const transitionMatch = pathname.match(/^\/api\/records\/([^/]+)\/([^/]+)\/transition$/);
      if (transitionMatch && req.method === "POST") {
        requireRole(actor, "CLERK");
        const body = await parseBody(req);
        const toStatus = String(body.toStatus || "");
        const records = await readJson(recordsFile, []);
        const idx = records.findIndex((r) => r.id === transitionMatch[2] && r.domain === transitionMatch[1]);
        if (idx === -1) return send(res, 404, { error: "record not found" });
        const from = records[idx].status || "draft";
        const allowed = RECORD_TRANSITIONS[from] ?? [];
        if (!allowed.includes(toStatus)) {
          return send(res, 409, { error: "invalid transition", from, to: toStatus, allowed });
        }
        records[idx] = { ...records[idx], status: toStatus, updatedAt: new Date().toISOString(), updatedBy: actor.id };
        await writeJson(recordsFile, records);
        await audit(req, "record.transition", { id: records[idx].id, from, to: toStatus });
        return send(res, 200, records[idx]);
      }
    }

    // ── 電子決裁（決裁欄）: 申請済みレコードの承認/差戻し ──
    {
      const approvalMatch = pathname.match(/^\/api\/records\/([^/]+)\/([^/]+)\/approval$/);
      if (approvalMatch && req.method === "POST") {
        requireRole(actor, "ADMIN");
        const body = await parseBody(req);
        const decision = body.decision === "reject" ? "reject" : "approve";
        const records = await readJson(recordsFile, []);
        const idx = records.findIndex((r) => r.id === approvalMatch[2] && r.domain === approvalMatch[1]);
        if (idx === -1) return send(res, 404, { error: "record not found" });
        if (records[idx].status !== "submitted") {
          return send(res, 409, { error: "approval requires submitted status", status: records[idx].status });
        }
        const entry = { by: actor.id, name: actor.name, decision, comment: body.comment ?? "", at: new Date().toISOString() };
        records[idx] = {
          ...records[idx],
          approvals: [...(records[idx].approvals ?? []), entry],
          status: decision === "approve" ? "approved" : "draft", // 差戻しは下書きへ
          updatedAt: entry.at,
          updatedBy: actor.id,
        };
        await writeJson(recordsFile, records);
        await audit(req, "record.approval", { id: records[idx].id, decision });
        return send(res, 200, records[idx]);
      }
    }

    // ── 公金受取口座の管理（照会はマスク表示。全桁は提供APIのみ・要ADMIN・監査記録）──
    if (pathname === "/api/bank-accounts" && req.method === "GET") {
      requireRole(actor, "CLERK");
      const ownerId = searchParams.get("ownerId") ?? "";
      const all = await readJson(bankAccountsFile, []);
      return send(res, 200, all.filter((a) => !ownerId || a.ownerId === ownerId).map(maskAccount));
    }
    if (pathname === "/api/bank-accounts" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const body = await parseBody(req);
      for (const key of ["ownerId", "bankName", "accountNumber"]) {
        if (!body[key] || !String(body[key]).trim()) return send(res, 400, { error: `${key} is required` });
      }
      const all = await readJson(bankAccountsFile, []);
      const item = {
        id: crypto.randomUUID(),
        ownerId: String(body.ownerId),
        ownerName: body.ownerName ?? "",
        bankName: String(body.bankName),
        branchName: body.branchName ?? "",
        accountType: body.accountType === "checking" ? "checking" : "savings",
        accountNumber: String(body.accountNumber),
        purpose: body.purpose ?? "",
        registeredBy: actor.id,
        createdAt: new Date().toISOString(),
      };
      all.unshift(item);
      await writeJson(bankAccountsFile, all);
      await audit(req, "bankaccount.register", { id: item.id, ownerId: item.ownerId });
      return send(res, 201, maskAccount(item));
    }
    {
      const bankMatch = pathname.match(/^\/api\/bank-accounts\/([^/]+?)(\/provide|\/suspend|\/resume)?$/);
      if (bankMatch && req.method === "DELETE" && !bankMatch[2]) {
        requireRole(actor, "CLERK");
        const all = await readJson(bankAccountsFile, []);
        const idx = all.findIndex((a) => a.id === bankMatch[1]);
        if (idx === -1) return send(res, 404, { error: "bank account not found" });
        const [removed] = all.splice(idx, 1);
        await writeJson(bankAccountsFile, all);
        await audit(req, "bankaccount.delete", { id: removed.id, ownerId: removed.ownerId });
        return send(res, 200, maskAccount(removed));
      }
      // 口座振替の停止・再開（停止開始日/終了日つき。振替対象判定は status と期間で行う）
      if (bankMatch && req.method === "POST" && (bankMatch[2] === "/suspend" || bankMatch[2] === "/resume")) {
        requireRole(actor, "CLERK");
        const all = await readJson(bankAccountsFile, []);
        const idx = all.findIndex((a) => a.id === bankMatch[1]);
        if (idx === -1) return send(res, 404, { error: "bank account not found" });
        if (bankMatch[2] === "/suspend") {
          const body = await parseBody(req);
          all[idx].suspendFrom = body.from ?? new Date().toISOString().slice(0, 10);
          all[idx].suspendTo = body.to ?? "";
          all[idx].debitStatus = "停止中";
        } else {
          all[idx].suspendFrom = "";
          all[idx].suspendTo = "";
          all[idx].debitStatus = "有効";
        }
        await writeJson(bankAccountsFile, all);
        await audit(req, bankMatch[2] === "/suspend" ? "bankaccount.suspend" : "bankaccount.resume", { id: all[idx].id });
        return send(res, 200, maskAccount(all[idx]));
      }
      // 他システム提供（公金受取口座の対象事務向け）。全桁を返すため ADMIN＋監査必須
      if (bankMatch && req.method === "GET" && bankMatch[2]) {
        requireRole(actor, "ADMIN");
        const all = await readJson(bankAccountsFile, []);
        const account = all.find((a) => a.id === bankMatch[1]);
        if (!account) return send(res, 404, { error: "bank account not found" });
        await audit(req, "bankaccount.provide", { id: account.id, ownerId: account.ownerId });
        return send(res, 200, account);
      }
    }

    // ── 決定・変更ワークフロー ──
    // 認定/変更/停止/再開/廃止/却下の決定を、理由・遡及適用日(effectiveDate)・決定者つきで記録する。
    // 変更決定は changes を payload にマージし、変更前の payload を履歴として保持する（遡及更正に対応）。
    {
      const decisionMatch = pathname.match(/^\/api\/records\/([^/]+)\/([^/]+)\/decision$/);
      if (decisionMatch && req.method === "POST") {
        requireRole(actor, "CLERK");
        const body = await parseBody(req);
        const kinds = ["認定", "変更", "停止", "再開", "廃止", "却下"];
        if (!kinds.includes(body.kind)) {
          return send(res, 400, { error: `kind(${kinds.join("/")}) is required` });
        }
        const records = await readJson(recordsFile, []);
        const idx = records.findIndex((r) => r.id === decisionMatch[2] && r.domain === decisionMatch[1]);
        if (idx === -1) return send(res, 404, { error: "record not found" });
        const entry = {
          kind: body.kind,
          reason: body.reason ?? "",
          effectiveDate: body.effectiveDate ?? new Date().toISOString().slice(0, 10), // 過去日=遡及適用
          by: actor.id,
          name: actor.name,
          at: new Date().toISOString(),
        };
        const record = { ...records[idx] };
        record.decisions = [...(record.decisions ?? []), entry];
        record.decisionStatus = body.kind;
        if (body.changes && typeof body.changes === "object" && !Array.isArray(body.changes)) {
          record.payloadHistory = [...(record.payloadHistory ?? []), { at: entry.at, effectiveDate: entry.effectiveDate, payload: record.payload }];
          record.payload = { ...record.payload, ...body.changes };
        }
        if (body.kind === "認定") record.status = "approved";
        else if (body.kind === "却下") record.status = "draft";
        record.updatedAt = entry.at;
        record.updatedBy = actor.id;
        records[idx] = record;
        await writeJson(recordsFile, records);
        await audit(req, "record.decision", { id: record.id, kind: body.kind, effectiveDate: entry.effectiveDate });
        return send(res, 200, record);
      }
    }

    // ── 通知書・案内文書の一括出力（対象レコードへ件名/本文を差し込み生成）──
    if (pathname === "/api/notices/bulk" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const body = await parseBody(req);
      const kind = body.kind === "guide" ? "guide" : "notice";
      if (!body.title || !String(body.title).trim()) return send(res, 400, { error: "title is required" });
      const records = await readJson(recordsFile, []);
      let targets = records;
      if (body.domain) targets = targets.filter((r) => r.domain === body.domain);
      if (body.status) targets = targets.filter((r) => r.status === body.status);
      if (body.fiscalYear) targets = targets.filter((r) => String(r.fiscalYear ?? "") === String(body.fiscalYear));
      if (!targets.length) return send(res, 404, { error: "対象レコードがありません" });
      const fill = (text, r) => String(text ?? "")
        .replaceAll("{subject}", r.payload?.subject ?? "")
        .replaceAll("{receiptNumber}", r.receiptNumber ?? "")
        .replaceAll("{fiscalYear}", String(r.fiscalYear ?? ""));
      const all = await readJson(noticesFile, []);
      const batchId = crypto.randomUUID();
      const issued = targets.map((r) => ({
        id: crypto.randomUUID(),
        batchId,
        kind,
        recordId: r.id,
        domain: r.domain,
        receiptNumber: r.receiptNumber ?? "",
        title: fill(body.title, r),
        body: fill(body.body, r),
        qrValue: `GOVN|${r.domain}|${r.receiptNumber ?? r.id}`, // 二次元コード値（カスタマーバーコード相当・出力ファイルに格納）
        status: "issued",
        issuedBy: actor.id,
        issuedAt: new Date().toISOString(),
      }));
      all.unshift(...issued);
      await writeJson(noticesFile, all);
      await audit(req, "notice.bulk", { batchId, kind, count: issued.length, domain: body.domain ?? "" });
      return send(res, 201, { batchId, kind, count: issued.length });
    }
    if (pathname === "/api/notices" && req.method === "GET") {
      requireRole(actor, "CLERK");
      const all = await readJson(noticesFile, []);
      const noticeRecordId = searchParams.get("recordId") ?? "";
      const noticeKind = searchParams.get("kind") ?? "";
      let noticeList = all;
      if (noticeRecordId) noticeList = noticeList.filter((n) => n.recordId === noticeRecordId);
      if (noticeKind) noticeList = noticeList.filter((n) => n.kind === noticeKind);
      return send(res, 200, noticeList.slice(0, 200));
    }
    // 封入・発送委託向けのCSV出力（Excel互換BOM付き）
    if (pathname === "/api/notices-export" && req.method === "GET") {
      requireRole(actor, "CLERK");
      const all = await readJson(noticesFile, []);
      const header = "id,kind,domain,recordId,receiptNumber,qrValue,title,issuedAt";
      const lines = all.map((n) =>
        [n.id, n.kind, n.domain, n.recordId, n.receiptNumber, n.qrValue ?? "", JSON.stringify(n.title ?? ""), n.issuedAt].join(","));
      const csv = "﻿" + [header, ...lines].join("\n") + "\n";
      await audit(req, "notice.export", { count: all.length });
      return sendRaw(res, 200, Buffer.from(csv, "utf8"), "text/csv; charset=utf-8");
    }

    // ── レコードのコメント（業務メモ・申し送り）──
    {
      const commentMatch = pathname.match(/^\/api\/records\/([^/]+)\/([^/]+)\/comments$/);
      if (commentMatch && req.method === "GET") {
        requireRole(actor, "CLERK");
        const all = await readJson(commentsFile, []);
        return send(res, 200, all.filter((c) => c.domain === commentMatch[1] && c.recordId === commentMatch[2]));
      }
      if (commentMatch && req.method === "POST") {
        requireRole(actor, "CLERK");
        const body = await parseBody(req);
        if (!body.text || !String(body.text).trim()) return send(res, 400, { error: "text required" });
        const records = await readJson(recordsFile, []);
        if (!records.some((r) => r.id === commentMatch[2] && r.domain === commentMatch[1])) {
          return send(res, 404, { error: "record not found" });
        }
        const all = await readJson(commentsFile, []);
        const comment = { id: crypto.randomUUID(), domain: commentMatch[1], recordId: commentMatch[2], text: String(body.text), author: actor.id, createdAt: new Date().toISOString() };
        all.unshift(comment);
        await writeJson(commentsFile, all);
        await audit(req, "record.comment", { recordId: comment.recordId });
        return send(res, 201, comment);
      }
    }

    // ── 契約終了時データ提供（全データエクスポート, BAT-013相当）──
    if (pathname === "/api/export-all" && req.method === "GET") {
      requireRole(actor, "ADMIN");
      const bundle = {
        exportedAt: new Date().toISOString(),
        exportedBy: actor.id,
        records: await readJson(recordsFile, []),
        restrictions: await readJson(restrictionsFile, []),
        certificates: await readJson(certificatesFile, []),
        aliases: await readJson(aliasesFile, []),
        notifyRegistrations: await readJson(notifyRegistrationsFile, []),
        notifications: await readJson(notificationsFile, []),
        eucTemplates: await readJson(eucTemplatesFile, []),
        batchJobs: await readJson(batchJobsFile, []),
        comments: await readJson(commentsFile, []),
      };
      bundle.counts = Object.fromEntries(Object.entries(bundle).filter(([, v]) => Array.isArray(v)).map(([k, v]) => [k, v.length]));
      await audit(req, "data.export-all", { counts: bundle.counts });
      res.writeHead(200, {
        "content-type": "application/json; charset=utf-8",
        "content-disposition": 'attachment; filename="export-all.json"',
        "cache-control": "no-store",
      });
      return res.end(JSON.stringify(bundle, null, 2));
    }

    // ── お知らせ（運用担当者向けシステム周知ボード）──
    if (pathname === "/api/announcements" && req.method === "GET") {
      requireRole(actor, "CLERK");
      const all = await readJson(announcementsFile, []);
      return send(res, 200, all.filter((a) => a.status !== "deleted"));
    }
    if (pathname === "/api/announcements" && req.method === "POST") {
      requireRole(actor, "ADMIN");
      const body = await parseBody(req);
      if (!body.title || !String(body.title).trim()) return send(res, 400, { error: "title required" });
      const all = await readJson(announcementsFile, []);
      const item = { id: crypto.randomUUID(), title: String(body.title), body: String(body.body || ""), level: ["info", "warning", "critical"].includes(body.level) ? body.level : "info", status: "active", createdBy: actor.id, createdAt: new Date().toISOString() };
      all.unshift(item);
      await writeJson(announcementsFile, all);
      await audit(req, "announcement.create", { id: item.id, level: item.level });
      return send(res, 201, item);
    }
    const announceMatch = pathname.match(/^\/api\/announcements\/([^/]+)$/);
    if (announceMatch && req.method === "DELETE") {
      requireRole(actor, "ADMIN");
      const all = await readJson(announcementsFile, []);
      const item = all.find((a) => a.id === announceMatch[1]);
      if (!item) return send(res, 404, { error: "announcement not found" });
      item.status = "deleted";
      item.deletedAt = new Date().toISOString();
      await writeJson(announcementsFile, all);
      await audit(req, "announcement.delete", { id: item.id });
      return send(res, 204, {});
    }

    // ── システム情報（運用・監視用）──
    if (pathname === "/api/system-info" && req.method === "GET") {
      requireRole(actor, "CLERK");
      const counts = {};
      for (const [k, f] of [["records", recordsFile], ["restrictions", restrictionsFile], ["certificates", certificatesFile], ["aliases", aliasesFile], ["batchJobs", batchJobsFile], ["comments", commentsFile], ["announcements", announcementsFile]]) {
        try { counts[k] = (await readJson(f, [])).length; } catch { counts[k] = 0; }
      }
      return send(res, 200, {
        node: process.version,
        platform: process.platform,
        pid: process.pid,
        uptimeSeconds: Math.floor(process.uptime()),
        serverTime: new Date().toISOString(),
        dataCounts: counts,
      });
    }

    // ── オンライン申請受付（市民オンライン手続きポータル等からのインバウンド連携）──
    // 来庁不要の住民申請を外部ポータルから受信し、職員の受付簿に取り込む。
    if (pathname === "/api/link/application/inbound" && req.method === "POST") {
      const expected = process.env.LINK_TOKEN || "portal-link-dev-token";
      if (req.headers["x-link-token"] !== expected) return send(res, 401, { error: "invalid link token" });
      const body = await parseBody(req);
      const applicant = body.applicant && typeof body.applicant === "object" ? body.applicant : {};
      if (!body.procedureType || !applicant.name) return send(res, 400, { error: "procedureType and applicant.name required" });
      const all = await readJson(linkApplicationsFile, []);
      const externalId = body.externalId ? String(body.externalId) : null;
      const source = String(body.source || "citizen-portal");
      const dupe = externalId ? all.find((a) => a.source === source && a.externalId === externalId) : null;
      if (dupe) return send(res, 200, { ok: true, id: dupe.id, status: dupe.status, duplicate: true });
      const now = new Date().toISOString();
      const item = {
        id: crypto.randomUUID(),
        source,
        externalId,
        receiptNumber: body.receiptNumber ? String(body.receiptNumber) : null,
        procedureType: String(body.procedureType),
        applicant: { residentId: applicant.residentId ? String(applicant.residentId) : null, name: String(applicant.name) },
        payload: body.payload && typeof body.payload === "object" ? body.payload : {},
        status: "RECEIVED",
        receivedAt: now,
        updatedAt: now,
        history: [{ status: "RECEIVED", at: now }],
      };
      all.unshift(item);
      await writeJson(linkApplicationsFile, all);
      await audit(req, "link.inbound", { id: item.id, source: item.source, procedureType: item.procedureType });
      return send(res, 201, { ok: true, id: item.id, status: item.status });
    }
    if (pathname === "/api/link/applications" && req.method === "GET") {
      requireRole(actor, "CLERK");
      const all = await readJson(linkApplicationsFile, []);
      return send(res, 200, all);
    }
    const linkStatusMatch = pathname.match(/^\/api\/link\/applications\/([^/]+)\/status$/);
    if (linkStatusMatch && req.method === "POST") {
      requireRole(actor, "CLERK");
      const body = await parseBody(req);
      const next = String(body.status || "");
      if (!["RECEIVED", "PROCESSING", "COMPLETED", "REJECTED"].includes(next)) return send(res, 400, { error: "invalid status" });
      const all = await readJson(linkApplicationsFile, []);
      const item = all.find((a) => a.id === linkStatusMatch[1]);
      if (!item) return send(res, 404, { error: "application not found" });
      item.status = next;
      item.updatedAt = new Date().toISOString();
      item.history.push({ status: next, at: item.updatedAt });
      await writeJson(linkApplicationsFile, all);
      await audit(req, "link.status", { id: item.id, status: next });
      // 市民ポータルへ処理状況をコールバック通知（任意・ベストエフォート）
      const portalBase = process.env.PORTAL_CALLBACK_BASE;
      if (portalBase && item.externalId) {
        try {
          const ctrl = new AbortController();
          const cbTimer = setTimeout(() => ctrl.abort(), 4000);
          await fetch(`${portalBase.replace(/\/$/, "")}/api/link/callback`, {
            method: "POST",
            headers: { "content-type": "application/json", "x-link-token": process.env.LINK_TOKEN || "portal-link-dev-token" },
            body: JSON.stringify({ externalId: item.externalId, status: next }),
            signal: ctrl.signal,
          });
          clearTimeout(cbTimer);
        } catch { /* 通知失敗は無視（状態更新自体は成功） */ }
      }
      return send(res, 200, item);
    }

    // ── オンライン申請受付簿のCSV出力（職員の処理・提出用, CLERK）──
    if (pathname === "/api/link/applications/export" && req.method === "GET") {
      requireRole(actor, "CLERK");
      const all = await readJson(linkApplicationsFile, []);
      const cols = [["receivedAt", "受付日時"], ["status", "状態"], ["receiptNumber", "受付番号"], ["procedureType", "手続き種別"], ["applicantName", "申請者"], ["residentId", "住民ID"], ["source", "連携元"], ["externalId", "外部ID"], ["id", "受付ID"]];
      const esc = (v) => {
        const s = typeof v === "object" && v !== null ? JSON.stringify(v) : String(v ?? "");
        return /[",\n]/.test(s) ? `"${s.replaceAll('"', '""')}"` : s;
      };
      const rows = all.map((a) => ({ ...a, applicantName: a.applicant && a.applicant.name, residentId: a.applicant && a.applicant.residentId }));
      const header = cols.map(([, label]) => label).join(",");
      const lines = rows.map((r) => cols.map(([k]) => esc(r[k])).join(","));
      const csv = "\ufeff" + [header, ...lines].join("\r\n") + "\r\n";
      res.writeHead(200, {
        "content-type": "text/csv; charset=utf-8",
        "content-disposition": 'attachment; filename="link-applications.csv"',
        "cache-control": "no-store",
      });
      return res.end(csv);
    }

    // ── 中間サーバ照会（システム間連携）: 業務システムのバックエンドが中間サーバへ照会する。
    //    機関トークンはサーバ側でのみ保持（CHUUKAN_AGENCY_TOKEN）。ブラウザには渡さない。
    if (pathname === "/api/linkage/query" && req.method === "POST") {
      const body = await parseBody(req);
      const { agency, procedure, atena, queries } = body || {};
      if (!agency || !atena || !Array.isArray(queries) || !queries.length)
        return send(res, 400, { error: "agency, atena, queries は必須です" });
      const base = process.env.CHUUKAN_BASE || "http://localhost:4290";
      const token = process.env.CHUUKAN_AGENCY_TOKEN || "demo-linkage-token";
      const results = [];
      for (const q of queries.slice(0, 5)) {
        try {
          const r = await fetch(`${base}/api/v1/query`, {
            method: "POST",
            headers: { "content-type": "application/json", "x-agency-token": token },
            body: JSON.stringify({ requester: agency, atena, holder: q.holder, item: q.item, purpose: procedure }),
            signal: AbortSignal.timeout(4000),
          });
          const data = await r.json().catch(() => ({}));
          results.push(r.ok
            ? { holder: q.holder, item: q.item, status: r.status, value: data.value, fiscalYear: data.fiscalYear }
            : { holder: q.holder, item: q.item, status: r.status, error: data.error || `HTTP ${r.status}` });
        } catch {
          results.push({ holder: q.holder, item: q.item, status: 0, error: `中間サーバに接続できません（${base}）` });
        }
      }
      return send(res, 200, { base, results });
    }

    // ── 共通インタフェース電文の取込（標準準拠のデータ連携: ヘッダ/データ/トレイラ構成）──
    //   トレイラの件数とデータ件数の整合チェック＋必須項目検証を行い、取込結果を返す（全業務共通）。
    //   異なるインタフェースの電文は、呼び出し側で共通IFへ変換のうえ本機能で取り込む。
    if (pathname === "/api/link/denbun/import" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const body = await parseBody(req);
      const recordType = String(body.recordType || "");
      const data = Array.isArray(body.dataRecords) ? body.dataRecords : null;
      if (!recordType || !data) return send(res, 400, { error: "recordType と dataRecords[] が必須です（共通インタフェース電文）" });
      const required = Array.isArray(body.requiredFields) ? body.requiredFields : [];
      const declaredCount = body.trailer && body.trailer.recordCount != null ? Number(body.trailer.recordCount) : data.length;
      const errors = [];
      data.forEach((rec, i) => {
        const r = rec && typeof rec === "object" ? rec : {};
        const missing = required.filter((f) => r[f] === undefined || r[f] === null || r[f] === "");
        if (missing.length) errors.push({ row: i + 1, missing });
      });
      const countMatches = declaredCount === data.length;
      const imported = countMatches ? data.length - errors.length : 0;
      const result = {
        recordType, hasHeader: !!body.header, hasTrailer: !!body.trailer,
        total: data.length, declaredCount, 件数整合: countMatches,
        imported, errorCount: errors.length, errors: errors.slice(0, 50),
        status: !countMatches ? "件数不整合エラー" : errors.length ? "一部エラー（エラー行除外）" : "正常取込",
        note: "共通インタフェース電文(ヘッダ/データ/トレイラ)の取込: 件数整合チェック＋必須項目検証。異なるIFは共通IFへ変換のうえ本機能で取込。",
      };
      await audit(req, "link.denbun-import", { recordType, total: data.length, imported, errors: errors.length });
      return send(res, 200, result);
    }

    // ── 検索履歴（標準準拠: 利用者ID単位で検索ダイアログ設定値を一定件数保存し、再検索・継続利用できる）──
    //   操作者ごと・検索対象(scope)ごとに直近の検索条件を上限件数まで保持し、同一条件は最新へ繰り上げる。
    const SEARCH_HISTORY_LIMIT = 20;
    if (pathname === "/api/search-history" && req.method === "GET") {
      requireRole(actor, "CLERK");
      const scope = searchParams.get("scope");
      const all = await readJson(searchHistoryFile, []);
      const mine = all.filter((h) => h.userId === actor.id && (!scope || h.scope === scope));
      return send(res, 200, mine.slice(0, SEARCH_HISTORY_LIMIT));
    }
    if (pathname === "/api/search-history" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const body = await parseBody(req);
      const conditions = body.conditions && typeof body.conditions === "object" ? body.conditions : null;
      if (!conditions) return send(res, 400, { error: "conditions（検索条件オブジェクト）が必須です" });
      const scope = String(body.scope || "default");
      const all = await readJson(searchHistoryFile, []);
      const key = JSON.stringify(conditions);
      // 同一利用者・同一scope・同一条件の既存履歴は除去して最新へ繰り上げ（重複排除）
      const rest = all.filter((h) => !(h.userId === actor.id && h.scope === scope && JSON.stringify(h.conditions) === key));
      const item = { id: crypto.randomUUID(), userId: actor.id, scope, conditions, at: new Date().toISOString() };
      rest.unshift(item);
      // 利用者×scope ごとに一定件数(上限)まで保持
      const counts = {};
      const capped = rest.filter((h) => {
        const k = `${h.userId}|${h.scope}`;
        counts[k] = (counts[k] || 0) + 1;
        return counts[k] <= SEARCH_HISTORY_LIMIT;
      });
      await writeJson(searchHistoryFile, capped);
      return send(res, 201, { ...item, limit: SEARCH_HISTORY_LIMIT, note: "検索履歴は利用者ID×検索対象ごとに直近20件まで保存し、選択して再検索・継続利用できる" });
    }
    const searchHistDelMatch = pathname.match(/^\/api\/search-history\/([^/]+)$/);
    if (searchHistDelMatch && req.method === "DELETE") {
      requireRole(actor, "CLERK");
      const all = await readJson(searchHistoryFile, []);
      const idx = all.findIndex((h) => h.id === searchHistDelMatch[1] && h.userId === actor.id);
      if (idx < 0) return send(res, 404, { error: "検索履歴が見つかりません" });
      all.splice(idx, 1);
      await writeJson(searchHistoryFile, all);
      return send(res, 200, { deleted: true });
    }

    // ── 排他制御（標準準拠: 他の職員が異動処理中の同一対象は閲覧以外不可。排他情報は他職員でも照会・解除可）──
    if (pathname === "/api/locks" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const body = await parseBody(req);
      if (!body.recordType || !body.recordId) return send(res, 400, { error: "recordType, recordId が必須です" });
      const rt = String(body.recordType), rid = String(body.recordId);
      const list = await readJson(recordLocksFile, []);
      const existing = list.find((l) => l.recordType === rt && l.recordId === rid);
      if (existing && existing.lockedBy !== actor.id) {
        return send(res, 409, { error: "他の職員が処理中のため排他制御中です", lockedBy: existing.lockedBy, lockedAt: existing.lockedAt });
      }
      if (existing) return send(res, 200, { ...existing, reentrant: true });
      const item = { id: crypto.randomUUID(), recordType: rt, recordId: rid, lockedBy: actor.id, lockedAt: new Date().toISOString() };
      list.unshift(item);
      await writeJson(recordLocksFile, list);
      await audit(req, "lock.acquire", { recordType: rt, recordId: rid });
      return send(res, 201, item);
    }
    if (pathname === "/api/locks" && req.method === "GET") {
      requireRole(actor, "CLERK"); // 排他情報は他の職員でも照会可
      const rt = searchParams.get("recordType"), rid = searchParams.get("recordId");
      const list = await readJson(recordLocksFile, []);
      if (rt && rid) {
        const lk = list.find((l) => l.recordType === rt && l.recordId === rid);
        return send(res, 200, { locked: !!lk, lock: lk ?? null, editableBy: lk ? lk.lockedBy : null });
      }
      return send(res, 200, list);
    }
    const lockDelMatch = pathname.match(/^\/api\/locks\/([^/]+)$/);
    if ((lockDelMatch || pathname === "/api/locks") && req.method === "DELETE") {
      requireRole(actor, "CLERK"); // 他の職員でも解除可
      const list = await readJson(recordLocksFile, []);
      let idx = -1;
      if (lockDelMatch) idx = list.findIndex((l) => l.id === lockDelMatch[1]);
      else {
        const rt = searchParams.get("recordType"), rid = searchParams.get("recordId");
        idx = list.findIndex((l) => l.recordType === rt && l.recordId === rid);
      }
      if (idx < 0) return send(res, 404, { error: "排他情報が見つかりません" });
      const [removed] = list.splice(idx, 1);
      await writeJson(recordLocksFile, list);
      await audit(req, "lock.release", { recordType: removed.recordType, recordId: removed.recordId, by: actor.id });
      return send(res, 200, { released: true, recordType: removed.recordType, recordId: removed.recordId });
    }

    // ── 宛名ラベル・窓あき封筒対応の宛名シート出力（指定条件の対象者の送付先情報を印字レイアウト化）──
    if (pathname === "/api/atena-labels" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const body = await parseBody(req);
      const targets = Array.isArray(body.targets) ? body.targets : [];
      if (targets.length === 0) return send(res, 400, { error: "targets[]（宛名・送付先情報）が必須です" });
      const formats = ["宛名ラベル", "窓あき封筒", "宛名シート", "宛名シール", "宛名状"];
      const format = formats.includes(body.format) ? body.format : "宛名ラベル";
      const honorific = body.honorific ?? "様";
      const labels = targets.map((t) => {
        const postal = t.postalCode ? `〒${String(t.postalCode).replace(/[^0-9]/g, "").replace(/^(\d{3})(\d{4})$/, "$1-$2")}` : "";
        return {
          郵便番号: postal, 住所: t.address ?? "",
          氏名: `${t.name ?? ""}${t.name ? `　${honorific}` : ""}`,
          部署: t.dept ?? null,
          // 窓あき封筒は住所・氏名が窓位置に来るよう行構成を返す
          windowLines: format === "窓あき封筒" ? [postal, t.address ?? "", `${t.name ?? ""}　${honorific}`] : undefined,
        };
      });
      await audit(req, "atena.labels", { format, count: labels.length });
      return send(res, 200, { format, count: labels.length, labels, note: "指定条件の対象者の送付先情報を宛名ラベル/窓あき封筒対応宛名シートとして出力" });
    }

    // ── 文書・イメージ添付管理（標準準拠: 申請書類・写真・スキャナ取込画像等の添付情報を登録・修正・削除・照会）──
    //   実ファイルバイト列ではなく添付情報（ファイル名・種別・対象・メモ）を対象レコードに紐づけて管理する。
    if (pathname === "/api/attachments" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const body = await parseBody(req);
      if (!body.recordType || !body.recordId || !body.fileName) {
        return send(res, 400, { error: "recordType, recordId, fileName が必須です" });
      }
      const list = await readJson(attachmentsFile, []);
      const item = {
        id: crypto.randomUUID(), recordType: String(body.recordType), recordId: String(body.recordId),
        fileName: String(body.fileName), contentType: body.contentType ?? null,
        kind: body.kind ?? "添付", source: body.source ?? null, // source 例: スキャナ/写真/アップロード
        note: body.note ?? "", at: new Date().toISOString(), by: actor.id,
      };
      list.unshift(item);
      await writeJson(attachmentsFile, list);
      await audit(req, "attachment.add", { recordType: item.recordType, recordId: item.recordId, fileName: item.fileName });
      return send(res, 201, item);
    }
    if (pathname === "/api/attachments" && req.method === "GET") {
      requireRole(actor, "CLERK");
      const rt = searchParams.get("recordType"), rid = searchParams.get("recordId");
      const list = await readJson(attachmentsFile, []);
      const filtered = list.filter((a) => (!rt || a.recordType === rt) && (!rid || a.recordId === rid));
      return send(res, 200, filtered);
    }
    const attachMatch = pathname.match(/^\/api\/attachments\/([^/]+)$/);
    if (attachMatch && (req.method === "PUT" || req.method === "DELETE")) {
      requireRole(actor, "CLERK");
      const list = await readJson(attachmentsFile, []);
      const idx = list.findIndex((a) => a.id === attachMatch[1]);
      if (idx < 0) return send(res, 404, { error: "添付が見つかりません" });
      if (req.method === "DELETE") {
        const [removed] = list.splice(idx, 1);
        await writeJson(attachmentsFile, list);
        await audit(req, "attachment.delete", { id: removed.id, fileName: removed.fileName });
        return send(res, 200, { deleted: true, id: removed.id });
      }
      const body = await parseBody(req);
      list[idx] = { ...list[idx], ...("fileName" in body ? { fileName: String(body.fileName) } : {}),
        ...("note" in body ? { note: String(body.note) } : {}), ...("kind" in body ? { kind: body.kind } : {}),
        updatedAt: new Date().toISOString(), updatedBy: actor.id };
      await writeJson(attachmentsFile, list);
      await audit(req, "attachment.update", { id: list[idx].id });
      return send(res, 200, list[idx]);
    }

    // ── 返戻・保留・取下げ等の処理アクション管理（申請書類不備の返戻/保留登録・解除、申請の取下げ登録）──
    //   対象レコードに対し返戻/保留/取下げ等のアクションを事由・日付・付帯情報つきで登録し、解除・修正できる。
    if (pathname === "/api/record-actions" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const body = await parseBody(req);
      const actions = ["返戻", "保留", "取下げ", "再送付", "公示送達"];
      if (!body.recordType || !body.recordId || !actions.includes(body.action)) {
        return send(res, 400, { error: `recordType, recordId, action(${actions.join("/")}) が必須です` });
      }
      const list = await readJson(recordActionsFile, []);
      const item = {
        id: crypto.randomUUID(), recordType: String(body.recordType), recordId: String(body.recordId),
        action: body.action, reason: body.reason ?? "", date: body.date ?? new Date().toISOString().slice(0, 10),
        detail: body.detail && typeof body.detail === "object" ? body.detail : {}, // 不備書類種別・調査記録・再送理由・公示送達事由等
        status: "有効", at: new Date().toISOString(), by: actor.id,
      };
      list.unshift(item);
      await writeJson(recordActionsFile, list);
      await audit(req, "record-action.register", { recordType: item.recordType, recordId: item.recordId, action: item.action });
      return send(res, 201, item);
    }
    if (pathname === "/api/record-actions" && req.method === "GET") {
      requireRole(actor, "CLERK");
      const rt = searchParams.get("recordType"), rid = searchParams.get("recordId"), act = searchParams.get("action");
      const list = await readJson(recordActionsFile, []);
      return send(res, 200, list.filter((a) => (!rt || a.recordType === rt) && (!rid || a.recordId === rid) && (!act || a.action === act)));
    }
    const recActMatch = pathname.match(/^\/api\/record-actions\/([^/]+)$/);
    if (recActMatch && (req.method === "PUT" || req.method === "DELETE")) {
      requireRole(actor, "CLERK");
      const list = await readJson(recordActionsFile, []);
      const idx = list.findIndex((a) => a.id === recActMatch[1]);
      if (idx < 0) return send(res, 404, { error: "処理アクションが見つかりません" });
      if (req.method === "DELETE") { // 解除
        list[idx] = { ...list[idx], status: "解除", releasedAt: new Date().toISOString(), releasedBy: actor.id };
        await writeJson(recordActionsFile, list);
        await audit(req, "record-action.release", { id: list[idx].id, action: list[idx].action });
        return send(res, 200, { released: true, id: list[idx].id });
      }
      const body = await parseBody(req); // 修正（調査記録・再送理由等）
      list[idx] = { ...list[idx], ...("reason" in body ? { reason: String(body.reason) } : {}),
        ...(body.detail && typeof body.detail === "object" ? { detail: { ...list[idx].detail, ...body.detail } } : {}),
        updatedAt: new Date().toISOString(), updatedBy: actor.id };
      await writeJson(recordActionsFile, list);
      await audit(req, "record-action.update", { id: list[idx].id });
      return send(res, 200, list[idx]);
    }

    // ── 帳票発行履歴（紙・データで出力した場合のみ履歴を作成。プレビューは履歴化しない。再出力・保存年限後削除に対応）──
    if (pathname === "/api/report-history" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const body = await parseBody(req);
      if (!body.reportName) return send(res, 400, { error: "reportName が必須です" });
      if (body.preview === true) return send(res, 200, { logged: false, note: "プレビュー表示は発行履歴を作成しません" });
      const outputs = ["紙", "データ", "PDF", "コンビニ"];
      const list = await readJson(reportHistoryFile, []);
      const item = {
        id: crypto.randomUUID(), reportName: String(body.reportName),
        recordType: body.recordType ?? null, recordId: body.recordId ?? null,
        output: outputs.includes(body.output) ? body.output : "紙",
        content: body.content ?? null, // 再出力用に出力時点の内容を保持
        issuedAt: new Date().toISOString(), issuedBy: actor.id,
        invalidated: false,
      };
      list.unshift(item);
      await writeJson(reportHistoryFile, list);
      await audit(req, "report-history.log", { reportName: item.reportName, output: item.output });
      const { content, ...meta } = item;
      return send(res, 201, { ...meta, hasContent: content != null });
    }
    if (pathname === "/api/report-history" && req.method === "GET") {
      requireRole(actor, "CLERK");
      const name = searchParams.get("reportName"), rid = searchParams.get("recordId");
      const from = searchParams.get("from"), to = searchParams.get("to");
      const list = await readJson(reportHistoryFile, []);
      const filtered = list.filter((h) => (!name || h.reportName === name) && (!rid || h.recordId === rid)
        && (!from || h.issuedAt >= from) && (!to || h.issuedAt <= to))
        .map(({ content, ...m }) => ({ ...m, hasContent: content != null }));
      return send(res, 200, filtered);
    }
    const reportReoutputMatch = pathname.match(/^\/api\/report-history\/([^/]+)\/reoutput$/);
    if (reportReoutputMatch && req.method === "GET") {
      requireRole(actor, "CLERK");
      const list = await readJson(reportHistoryFile, []);
      const h = list.find((x) => x.id === reportReoutputMatch[1]);
      if (!h) return send(res, 404, { error: "発行履歴が見つかりません" });
      await audit(req, "report-history.reoutput", { id: h.id, reportName: h.reportName });
      return send(res, 200, { reportName: h.reportName, output: h.output, content: h.content,
        note: "出力した時点の帳票と同じ内容で再出力", reissuedAt: new Date().toISOString() });
    }
    const reportHistDelMatch = pathname.match(/^\/api\/report-history\/([^/]+)$/);
    if (reportHistDelMatch && req.method === "DELETE") {
      requireRole(actor, "CLERK"); // 文書保存年限到達後の削除
      const list = await readJson(reportHistoryFile, []);
      const idx = list.findIndex((x) => x.id === reportHistDelMatch[1]);
      if (idx < 0) return send(res, 404, { error: "発行履歴が見つかりません" });
      const [removed] = list.splice(idx, 1);
      await writeJson(reportHistoryFile, list);
      await audit(req, "report-history.delete", { id: removed.id });
      return send(res, 200, { deleted: true, id: removed.id });
    }

    // ── 年齢計算（横断）: 各台帳画面で「生年月日と基準日（システム日付/処理日）から計算した年齢」を表示する共通要件 ──
    // 満年齢・達齢（前日加齢）・月齢・学齢を返す。年齢区分(min/max)の範囲判定、年齢到達日の算出にも対応。
    if (pathname === "/api/age" && req.method === "GET") {
      requireRole(actor, "CLERK");
      const r = computeAge(searchParams.get("birthDate"), searchParams.get("asOfDate") ?? undefined);
      if (r.error) return send(res, 400, { error: r.error });
      const min = searchParams.get("min"), max = searchParams.get("max");
      if (min != null || max != null) {
        r.inRange = (min == null || r.age >= Number(min)) && (max == null || r.age <= Number(max));
        r.range = { min: min != null ? Number(min) : null, max: max != null ? Number(max) : null };
      }
      const reachAge = searchParams.get("reachAge");
      if (reachAge != null) r.reachDate = reachDate(r.birthDate, Number(reachAge));
      return send(res, 200, r);
    }
    if (pathname === "/api/age" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const body = await parseBody(req);
      const asOfDate = body.asOfDate ?? undefined;
      // バッチ: items[] で複数対象者を一括計算（一覧画面での年齢一括表示）
      if (Array.isArray(body.items)) {
        const results = body.items.map((it) => {
          const r = computeAge(it.birthDate, it.asOfDate ?? asOfDate);
          return { id: it.id ?? null, ...r };
        });
        return send(res, 200, { count: results.length, results });
      }
      const r = computeAge(body.birthDate, asOfDate);
      if (r.error) return send(res, 400, { error: r.error });
      if (body.checkRange && typeof body.checkRange === "object") {
        const { min, max } = body.checkRange;
        r.inRange = (min == null || r.age >= Number(min)) && (max == null || r.age <= Number(max));
        r.range = { min: min ?? null, max: max ?? null };
      }
      if (Array.isArray(body.reachAges)) {
        r.reachDates = body.reachAges.map((n) => ({ age: Number(n), date: reachDate(r.birthDate, Number(n)) }));
      }
      return send(res, 200, r);
    }

    // ── 検査値の基準範囲判定（横断）: 健診・検診で検査項目ごとに年齢・性別別の基準値(下限/上限)を設定し、
    // 健診結果が基準値未満/基準範囲内/基準値超過のどれかを判定。色分け用 outOfRange を返す。生物学的性別差に対応 ──
    if (pathname === "/api/exam/judge" && req.method === "GET") {
      requireRole(actor, "CLERK");
      const item = { name: searchParams.get("name"), value: searchParams.get("value"),
        low: searchParams.get("low") ?? undefined, high: searchParams.get("high") ?? undefined,
        sex: searchParams.get("sex") ?? undefined, age: searchParams.get("age") ?? undefined };
      const r = judgeExam(item);
      if (r.error) return send(res, 400, { error: r.error });
      return send(res, 200, r);
    }
    if (pathname === "/api/exam/judge" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const body = await parseBody(req);
      // バッチ: 複数検査項目を一括判定（健診結果参照画面で全項目の基準値超過を色分け）
      if (Array.isArray(body.items)) {
        const results = body.items.map(judgeExam);
        const flagged = results.filter((x) => x.outOfRange).length;
        return send(res, 200, { count: results.length, flagged, results });
      }
      const r = judgeExam(body);
      if (r.error) return send(res, 400, { error: r.error });
      return send(res, 200, r);
    }

    // ── 延滞金の計算（横断）: 本税×率×日数/365。納期限翌日〜1ヶ月は軽減率、以降は通常率。減免率・端数処理は設定可 ──
    if (pathname === "/api/delinquency/entaikin" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const body = await parseBody(req);
      const r = computeEntaikin(body || {});
      if (r.error) return send(res, 400, { error: r.error });
      await audit(req, "delinquency.entaikin", { entaikin: r.entaikin, days: r.days });
      return send(res, 200, r);
    }
    // ── 還付加算金の計算（横断）: 還付額×率×日数/365。起算日翌日〜基準日 ──
    if (pathname === "/api/delinquency/kanpu-kasan" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const body = await parseBody(req);
      const r = computeKanpuKasan(body || {});
      if (r.error) return send(res, 400, { error: r.error });
      await audit(req, "delinquency.kanpu-kasan", { kasan: r.kasan, days: r.days });
      return send(res, 200, r);
    }

    // ── 督促・催告管理（横断）: 滞納者から督促/催告対象者を抽出・登録し、発行対象一覧の確認・引抜(発送前除外)を行う ──
    // 督促手数料の設定(金額・有効/無効)も管理。収納(税/国保/介護/後期高齢/国民年金/各手当)で共通。
    if (pathname === "/api/dunning/targets" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const body = await parseBody(req);
      const kinds = ["督促", "催告"];
      if (!kinds.includes(body.kind)) return send(res, 400, { error: `kind は ${kinds.join("/")} のいずれかが必須です` });
      // 一括登録(items[]) と 単件 の両対応
      const inputs = Array.isArray(body.items) ? body.items : [body];
      if (!inputs.length) return send(res, 400, { error: "対象が空です" });
      const list = await readJson(dunningTargetsFile, []);
      const created = inputs.map((it) => {
        const item = {
          id: crypto.randomUUID(), kind: body.kind,
          recordType: it.recordType ?? body.recordType ?? null, recordId: it.recordId ?? null,
          宛名番号: it.宛名番号 ?? null, 滞納額: Number(it.滞納額) || 0,
          納期限: it.納期限 ?? null, 督促手数料: Number(it.督促手数料) || 0,
          status: "対象", // 対象→(発行)→引抜
          extractedAt: new Date().toISOString(), by: actor.id,
        };
        list.unshift(item);
        return item;
      });
      await writeJson(dunningTargetsFile, list);
      await audit(req, "dunning.extract", { kind: body.kind, count: created.length });
      return send(res, 201, { kind: body.kind, count: created.length, items: created });
    }
    if (pathname === "/api/dunning/targets" && req.method === "GET") {
      requireRole(actor, "CLERK");
      const kind = searchParams.get("kind"), status = searchParams.get("status") ?? "対象";
      const rt = searchParams.get("recordType");
      const list = await readJson(dunningTargetsFile, []);
      // 既定では発行対象(status=対象)のみ＝発行対象一覧。status=all で全件。
      const filtered = list.filter((t) => (!kind || t.kind === kind) && (!rt || t.recordType === rt)
        && (status === "all" || t.status === status));
      return send(res, 200, filtered);
    }
    const dunningWithdrawMatch = pathname.match(/^\/api\/dunning\/targets\/([^/]+)\/withdraw$/);
    if (dunningWithdrawMatch && req.method === "POST") {
      // 引抜: 発送前に入金・宛名異動・死亡等で送付対象から除外。引抜後は発行対象一覧に出ない(発行履歴は作成しない)。
      requireRole(actor, "CLERK");
      const body = await parseBody(req);
      const reasons = ["入金", "宛名異動", "死亡", "繰上徴収", "その他"];
      const list = await readJson(dunningTargetsFile, []);
      const idx = list.findIndex((t) => t.id === dunningWithdrawMatch[1]);
      if (idx < 0) return send(res, 404, { error: "督促・催告対象が見つかりません" });
      list[idx] = { ...list[idx], status: "引抜",
        withdrawReason: reasons.includes(body.reason) ? body.reason : "その他",
        withdrawnAt: new Date().toISOString(), withdrawnBy: actor.id };
      await writeJson(dunningTargetsFile, list);
      await audit(req, "dunning.withdraw", { id: list[idx].id, kind: list[idx].kind, reason: list[idx].withdrawReason });
      return send(res, 200, { withdrawn: true, id: list[idx].id });
    }
    if (pathname === "/api/dunning/fee" && req.method === "GET") {
      requireRole(actor, "CLERK");
      const fee = await readJson(dunningFeeFile, { 督促: { amount: 0, enabled: false }, 催告: { amount: 0, enabled: false } });
      return send(res, 200, fee);
    }
    if (pathname === "/api/dunning/fee" && req.method === "POST") {
      requireRole(actor, "ADMIN"); // 督促手数料の金額・有効/無効はマスタ設定
      const body = await parseBody(req);
      const kinds = ["督促", "催告"];
      if (!kinds.includes(body.kind)) return send(res, 400, { error: `kind は ${kinds.join("/")} のいずれかが必須です` });
      const fee = await readJson(dunningFeeFile, { 督促: { amount: 0, enabled: false }, 催告: { amount: 0, enabled: false } });
      fee[body.kind] = { amount: Number(body.amount) || 0, enabled: body.enabled !== false, updatedAt: new Date().toISOString(), by: actor.id };
      await writeJson(dunningFeeFile, fee);
      await audit(req, "dunning.fee-config", { kind: body.kind, amount: fee[body.kind].amount, enabled: fee[body.kind].enabled });
      return send(res, 200, fee);
    }

    // ── 分納（分割納付）誓約管理（横断）: 滞納額(延滞金・督促手数料含む可)を分割回数・頻度で期別に按分し、各期の収納状況を管理 ──
    if (pathname === "/api/installment/plans" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const body = await parseBody(req);
      // 分納を伴わない誓約も可（count未指定/0は誓約のみ）
      const withoutInstallment = body.分割回数 == null || Number(body.分割回数) <= 0;
      const total = Number(body.対象額 ?? body.total) || 0; // 延滞金・督促手数料を含めた額を渡せる
      const schedule = withoutInstallment ? [] : buildInstallmentSchedule({
        total, count: body.分割回数, firstDueDate: body.初回納期限 ?? body.firstDueDate, intervalMonths: body.頻度月数 ?? body.intervalMonths ?? 1 });
      const list = await readJson(installmentPlansFile, []);
      const plan = {
        id: crypto.randomUUID(),
        recordType: body.recordType ?? null, recordId: body.recordId ?? null, 宛名番号: body.宛名番号 ?? null,
        誓約者: body.誓約者 ?? null, 分納誓約年月日: body.分納誓約年月日 ?? new Date().toISOString().slice(0, 10),
        対象額: total, 分割回数: withoutInstallment ? 0 : Math.floor(Number(body.分割回数)), 頻度月数: Number(body.頻度月数 ?? body.intervalMonths ?? 1),
        誓約のみ: withoutInstallment, schedule, status: "履行中",
        createdAt: new Date().toISOString(), by: actor.id,
      };
      list.unshift(plan);
      await writeJson(installmentPlansFile, list);
      await audit(req, "installment.plan-create", { id: plan.id, 分割回数: plan.分割回数, 対象額: total });
      return send(res, 201, plan);
    }
    if (pathname === "/api/installment/plans" && req.method === "GET") {
      requireRole(actor, "CLERK"); // 分納対象者の収納状況一覧
      const rt = searchParams.get("recordType"), status = searchParams.get("status");
      const list = await readJson(installmentPlansFile, []);
      return send(res, 200, list.filter((p) => (!rt || p.recordType === rt) && (!status || p.status === status)));
    }
    const instGetMatch = pathname.match(/^\/api\/installment\/plans\/([^/]+)$/);
    if (instGetMatch && req.method === "GET") {
      requireRole(actor, "CLERK"); // 分納誓約の収納状況照会（進捗・残額）
      const list = await readJson(installmentPlansFile, []);
      const plan = list.find((p) => p.id === instGetMatch[1]);
      if (!plan) return send(res, 404, { error: "分納計画が見つかりません" });
      const 収納済 = plan.schedule.reduce((s, k) => s + (Number(k.納付済額) || 0), 0);
      return send(res, 200, { ...plan, 収納済額: 収納済, 残額: Math.max(0, plan.対象額 - 収納済) });
    }
    const instPayMatch = pathname.match(/^\/api\/installment\/plans\/([^/]+)\/pay$/);
    if (instPayMatch && req.method === "POST") {
      requireRole(actor, "CLERK");
      const body = await parseBody(req);
      const list = await readJson(installmentPlansFile, []);
      const idx = list.findIndex((p) => p.id === instPayMatch[1]);
      if (idx < 0) return send(res, 404, { error: "分納計画が見つかりません" });
      const k = list[idx].schedule.find((x) => x.期別番号 === Number(body.期別番号));
      if (!k) return send(res, 400, { error: "期別番号が不正です" });
      k.納付済額 = (Number(k.納付済額) || 0) + (Number(body.納付額) || 0);
      k.収納年月日 = body.収納年月日 ?? new Date().toISOString().slice(0, 10);
      k.status = k.納付済額 >= k.納付額 ? "完納" : "一部";
      if (list[idx].schedule.every((x) => x.status === "完納")) list[idx].status = "完了";
      await writeJson(installmentPlansFile, list);
      await audit(req, "installment.pay", { id: list[idx].id, 期別番号: k.期別番号 });
      return send(res, 200, list[idx]);
    }

    // ── 時効管理（横断）: 起算日＋時効期間で完成予定日を算出。滞納処分(督促/催告/差押/執行停止)で更新(中断)・完成猶予を反映。到来予定/完成一覧 ──
    if (pathname === "/api/prescription" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const body = await parseBody(req);
      if (!parseYmd(body.起算日)) return send(res, 400, { error: "起算日はYYYY-MM-DDで指定してください" });
      const years = Number(body.時効期間年) || 5; // 地方税の徴収権は5年
      const list = await readJson(prescriptionFile, []);
      const item = {
        id: crypto.randomUUID(), recordType: body.recordType ?? null, recordId: body.recordId ?? null,
        起算日: body.起算日, 時効期間年: years, events: [],
        時効完成予定日: addMonthsYmd(body.起算日, years * 12),
        createdAt: new Date().toISOString(), by: actor.id,
      };
      list.unshift(item);
      await writeJson(prescriptionFile, list);
      await audit(req, "prescription.create", { id: item.id, 時効完成予定日: item.時効完成予定日 });
      return send(res, 201, item);
    }
    const presEventMatch = pathname.match(/^\/api\/prescription\/([^/]+)\/event$/);
    if (presEventMatch && req.method === "POST") {
      requireRole(actor, "CLERK");
      const body = await parseBody(req);
      const kinds = ["更新", "完成猶予"]; // 更新=中断(督促/差押で起算日リセット)、完成猶予=猶予期間延長
      if (!kinds.includes(body.種別)) return send(res, 400, { error: `種別 は ${kinds.join("/")} のいずれかが必須です` });
      const list = await readJson(prescriptionFile, []);
      const item = list.find((p) => p.id === presEventMatch[1]);
      if (!item) return send(res, 404, { error: "時効情報が見つかりません" });
      const eventDate = body.年月日 ?? new Date().toISOString().slice(0, 10);
      item.events.push({ 種別: body.種別, 事由: body.事由 ?? null, 年月日: eventDate, 猶予月数: Number(body.猶予月数) || 0 });
      if (body.種別 === "更新") {
        item.起算日 = eventDate; // 時効更新（中断）: 起算日をイベント日にリセット
        item.時効完成予定日 = addMonthsYmd(eventDate, item.時効期間年 * 12);
      } else { // 完成猶予: 完成予定日を猶予月数分延長
        item.時効完成予定日 = addMonthsYmd(item.時効完成予定日, Number(body.猶予月数) || 0);
      }
      await writeJson(prescriptionFile, list);
      await audit(req, "prescription.event", { id: item.id, 種別: body.種別, 時効完成予定日: item.時効完成予定日 });
      return send(res, 200, item);
    }
    if (pathname === "/api/prescription" && req.method === "GET") {
      requireRole(actor, "CLERK");
      const today = searchParams.get("asOf") ?? new Date().toISOString().slice(0, 10);
      const withinMonths = searchParams.get("within") != null ? Number(searchParams.get("within")) : null;
      const list = await readJson(prescriptionFile, []);
      const horizon = withinMonths != null ? addMonthsYmd(today, withinMonths) : null;
      const enriched = list.map((p) => ({ ...p,
        時効完成: p.時効完成予定日 <= today, // 時効完成者
        到来予定: horizon != null && p.時効完成予定日 > today && p.時効完成予定日 <= horizon })); // 到来予定者
      const filter = searchParams.get("filter"); // "完成" | "到来予定"
      const out = filter === "完成" ? enriched.filter((p) => p.時効完成)
        : filter === "到来予定" ? enriched.filter((p) => p.到来予定) : enriched;
      return send(res, 200, out);
    }

    // ── 過誤納金の充当（横断）: 過払い(過誤納金)を未納期別へ充当順位どおり充当し残りを還付額として算出 ──
    if (pathname === "/api/appropriation" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const body = await parseBody(req);
      const r = computeAppropriation(body || {});
      await audit(req, "appropriation", { 充当合計: r.充当合計, 還付額: r.還付額 });
      return send(res, 200, r);
    }

    // ── 口座振替管理（横断）: 振替依頼情報の作成・一覧、振替結果の取込(完了/資金不足=不能)、不能/エラー一覧、再振替 ──
    if (pathname === "/api/account-transfer/requests" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const body = await parseBody(req);
      const inputs = Array.isArray(body.items) ? body.items : [body];
      if (!inputs.length) return send(res, 400, { error: "対象が空です" });
      const list = await readJson(transferRequestsFile, []);
      const created = inputs.map((it) => {
        const item = {
          id: crypto.randomUUID(), recordType: it.recordType ?? body.recordType ?? null, recordId: it.recordId ?? null,
          宛名番号: it.宛名番号 ?? null, 調定年月: it.調定年月 ?? null, 請求額: Number(it.請求額) || 0,
          金融機関: it.金融機関 ?? null, 口座番号末尾: it.口座番号 ? String(it.口座番号).slice(-4) : null,
          振替結果: "未処理", // 未処理→完了 or 不能(資金不足/口座不一致等)
          retryOf: it.retryOf ?? null, createdAt: new Date().toISOString(), by: actor.id,
        };
        list.unshift(item);
        return item;
      });
      await writeJson(transferRequestsFile, list);
      await audit(req, "account-transfer.request", { count: created.length });
      return send(res, 201, { count: created.length, items: created });
    }
    if (pathname === "/api/account-transfer/requests" && req.method === "GET") {
      requireRole(actor, "CLERK"); // 作成した振替依頼情報の一覧
      const status = searchParams.get("振替結果"), rt = searchParams.get("recordType");
      const list = await readJson(transferRequestsFile, []);
      return send(res, 200, list.filter((t) => (!status || t.振替結果 === status) && (!rt || t.recordType === rt)));
    }
    if (pathname === "/api/account-transfer/results" && req.method === "POST") {
      // 振替結果の取込: results[]={id,振替結果(完了/不能),事由}
      requireRole(actor, "CLERK");
      const body = await parseBody(req);
      const results = Array.isArray(body.results) ? body.results : [];
      const list = await readJson(transferRequestsFile, []);
      let updated = 0;
      for (const r of results) {
        const t = list.find((x) => x.id === r.id);
        if (!t) continue;
        t.振替結果 = ["完了", "不能"].includes(r.振替結果) ? r.振替結果 : "不能";
        t.事由 = r.事由 ?? (t.振替結果 === "不能" ? "資金不足" : null); // 資金不足/口座不一致等
        t.結果取込日 = new Date().toISOString();
        updated++;
      }
      await writeJson(transferRequestsFile, list);
      await audit(req, "account-transfer.results", { updated });
      return send(res, 200, { updated });
    }
    if (pathname === "/api/account-transfer/results" && req.method === "GET") {
      // 振替不能・消込エラー結果の一覧（既定は不能のみ）
      requireRole(actor, "CLERK");
      const status = searchParams.get("振替結果") ?? "不能";
      const list = await readJson(transferRequestsFile, []);
      return send(res, 200, list.filter((t) => status === "all" ? t.結果取込日 : t.振替結果 === status));
    }
    const retryMatch = pathname.match(/^\/api\/account-transfer\/requests\/([^/]+)\/retry$/);
    if (retryMatch && req.method === "POST") {
      // 再振替データの作成（不能分を再依頼）
      requireRole(actor, "CLERK");
      const list = await readJson(transferRequestsFile, []);
      const orig = list.find((t) => t.id === retryMatch[1]);
      if (!orig) return send(res, 404, { error: "振替依頼が見つかりません" });
      const retry = { ...orig, id: crypto.randomUUID(), 振替結果: "未処理", 事由: null, 結果取込日: undefined,
        retryOf: orig.id, createdAt: new Date().toISOString(), by: actor.id };
      list.unshift(retry);
      await writeJson(transferRequestsFile, list);
      await audit(req, "account-transfer.retry", { id: retry.id, retryOf: orig.id });
      return send(res, 201, retry);
    }

    // ── 納付書の発行（横断）: 個別/合算(複数期別を1枚)/指定金額(期別額によらず)・延滞金や督促料の出力有無・コンビニ/指定納期限・再発行 ──
    if (pathname === "/api/payment-slips" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const body = await parseBody(req);
      const modes = ["個別", "合算", "指定金額"]; // 指定金額=一部内入金収納にも対応
      const mode = modes.includes(body.mode) ? body.mode : "個別";
      const kibetsu = (Array.isArray(body.期別) ? body.期別 : []).map((k) => ({
        期別: k.期別 ?? null, 額: Math.max(0, Number(k.額) || 0), 納期限: k.納期限 ?? null }));
      if (mode !== "指定金額" && !kibetsu.length) return send(res, 400, { error: "期別[] が必要です" });
      const 延滞金 = body.延滞金出力 ? (Number(body.延滞金) || 0) : 0; // 延滞金は出力有無を選択
      const 督促料 = body.督促料出力 ? (Number(body.督促料) || 0) : 0; // 督促料も出力有無を選択
      const 指定納期限 = body.指定納期限 ?? null, コンビニ取扱期限 = body.コンビニ取扱期限 ?? null;
      const list = await readJson(paymentSlipsFile, []);
      const baseSlip = (本体額, 内訳) => ({
        id: crypto.randomUUID(), recordType: body.recordType ?? null, recordId: body.recordId ?? null,
        mode, 本体額, 延滞金, 督促料, 合計額: 本体額 + 延滞金 + 督促料,
        納期限: 指定納期限, コンビニ取扱期限, 期別内訳: 内訳, 再発行回数: 0,
        issuedAt: new Date().toISOString(), by: actor.id,
      });
      let slips;
      if (mode === "合算") { // 複数期別をまとめて1枚
        const 合算額 = kibetsu.reduce((s, k) => s + k.額, 0);
        slips = [baseSlip(合算額, kibetsu)];
      } else if (mode === "指定金額") { // 期別未納額のうち指定した金額（期別額によらず入力）
        slips = [baseSlip(Math.max(0, Number(body.指定金額) || 0), kibetsu.length ? kibetsu : null)];
      } else { // 個別: 期別ごとに1枚
        slips = kibetsu.map((k) => { const s = baseSlip(k.額, [k]); s.納期限 = 指定納期限 ?? k.納期限; return s; });
      }
      for (const s of slips) list.unshift(s);
      await writeJson(paymentSlipsFile, list);
      await audit(req, "payment-slip.issue", { mode, count: slips.length });
      return send(res, 201, { mode, count: slips.length, slips });
    }
    if (pathname === "/api/payment-slips" && req.method === "GET") {
      requireRole(actor, "CLERK");
      const rt = searchParams.get("recordType"), rid = searchParams.get("recordId");
      const list = await readJson(paymentSlipsFile, []);
      return send(res, 200, list.filter((s) => (!rt || s.recordType === rt) && (!rid || s.recordId === rid)));
    }
    const slipReissueMatch = pathname.match(/^\/api\/payment-slips\/([^/]+)\/reissue$/);
    if (slipReissueMatch && req.method === "POST") {
      requireRole(actor, "CLERK"); // 随時、納付書の再発行
      const list = await readJson(paymentSlipsFile, []);
      const idx = list.findIndex((s) => s.id === slipReissueMatch[1]);
      if (idx < 0) return send(res, 404, { error: "納付書が見つかりません" });
      list[idx].再発行回数 = (list[idx].再発行回数 || 0) + 1;
      list[idx].最終再発行日 = new Date().toISOString();
      await writeJson(paymentSlipsFile, list);
      await audit(req, "payment-slip.reissue", { id: list[idx].id, 再発行回数: list[idx].再発行回数 });
      return send(res, 200, list[idx]);
    }

    // ── 世帯構成員の照会（横断）: 住基連携で対象者(宛名番号)の世帯構成員一覧と、構成員個人の情報を相互に参照する ──
    // ── 送付先情報の管理（登録・修正・削除・照会）と一覧確認 ───────────────────────────
    if (pathname === "/api/destinations" && req.method === "GET") {
      requireRole(actor, "CLERK"); // 送付先情報を一覧で確認（対象者別フィルタ可）
      const owner = searchParams.get("対象者ID") ?? searchParams.get("宛名番号");
      const all = await readJson(destinationsFile, []);
      const list = owner ? all.filter((d) => String(d.対象者ID ?? "") === String(owner) || String(d.宛名番号 ?? "") === String(owner)) : all;
      return send(res, 200, { 件数: list.length, 送付先一覧: list });
    }
    if (pathname === "/api/destinations" && req.method === "POST") {
      requireRole(actor, "CLERK"); // 送付先情報の登録
      const body = await parseBody(req);
      if (!body.送付先名 && !body.送付先住所) return send(res, 400, { error: "送付先名 または 送付先住所 が必要です" });
      const all = await readJson(destinationsFile, []);
      const item = {
        id: crypto.randomUUID(), 対象者ID: body.対象者ID ?? null, 宛名番号: body.宛名番号 ?? null,
        送付先名: body.送付先名 ?? null, 送付先郵便番号: body.送付先郵便番号 ?? null, 送付先住所: body.送付先住所 ?? null,
        送付先利用業務: body.送付先利用業務 ?? null, 使用開始日: body.使用開始日 ?? null, 使用終了日: body.使用終了日 ?? null,
        createdAt: new Date().toISOString(), by: actor.id,
      };
      all.unshift(item);
      await writeJson(destinationsFile, all);
      await audit(req, "destination.create", { id: item.id, 対象者ID: item.対象者ID });
      return send(res, 201, item);
    }
    const destItemMatch = pathname.match(/^\/api\/destinations\/([^/]+)$/);
    if (destItemMatch) {
      const all = await readJson(destinationsFile, []);
      const idx = all.findIndex((d) => d.id === destItemMatch[1]);
      if (req.method === "GET") { // 照会
        requireRole(actor, "CLERK");
        if (idx === -1) return send(res, 404, { error: "送付先が見つかりません" });
        return send(res, 200, all[idx]);
      }
      if (req.method === "PUT") { // 修正
        requireRole(actor, "CLERK");
        if (idx === -1) return send(res, 404, { error: "送付先が見つかりません" });
        const body = await parseBody(req);
        for (const k of ["送付先名", "送付先郵便番号", "送付先住所", "送付先利用業務", "使用開始日", "使用終了日", "対象者ID", "宛名番号"]) {
          if (body[k] !== undefined) all[idx][k] = body[k];
        }
        all[idx].updatedAt = new Date().toISOString();
        await writeJson(destinationsFile, all);
        await audit(req, "destination.update", { id: all[idx].id });
        return send(res, 200, all[idx]);
      }
      if (req.method === "DELETE") { // 削除
        requireRole(actor, "CLERK");
        if (idx === -1) return send(res, 404, { error: "送付先が見つかりません" });
        const [removed] = all.splice(idx, 1);
        await writeJson(destinationsFile, all);
        await audit(req, "destination.delete", { id: removed.id });
        return send(res, 200, { deleted: true, id: removed.id });
      }
    }

    // ── 連絡先情報の管理（登録・修正・削除・照会）と一覧確認 ───────────────────────────
    if (pathname === "/api/contacts" && req.method === "GET") {
      requireRole(actor, "CLERK"); // 連絡先情報を一覧で確認（対象者別フィルタ可）
      const owner = searchParams.get("対象者ID") ?? searchParams.get("宛名番号");
      const all = await readJson(contactsFile, []);
      const list = owner ? all.filter((d) => String(d.対象者ID ?? "") === String(owner) || String(d.宛名番号 ?? "") === String(owner)) : all;
      return send(res, 200, { 件数: list.length, 連絡先一覧: list });
    }
    if (pathname === "/api/contacts" && req.method === "POST") {
      requireRole(actor, "CLERK"); // 連絡先情報の登録
      const body = await parseBody(req);
      if (!body.連絡先名 && !body.連絡先電話番号) return send(res, 400, { error: "連絡先名 または 連絡先電話番号 が必要です" });
      const all = await readJson(contactsFile, []);
      const item = {
        id: crypto.randomUUID(), 対象者ID: body.対象者ID ?? null, 宛名番号: body.宛名番号 ?? null,
        連絡先名: body.連絡先名 ?? null, 連絡先電話番号: body.連絡先電話番号 ?? null,
        連絡先内線番号: body.連絡先内線番号 ?? null, 連絡先FAX番号: body.連絡先FAX番号 ?? null,
        連絡優先順: body.連絡優先順 ?? null, 連絡先備考: body.連絡先備考 ?? null,
        createdAt: new Date().toISOString(), by: actor.id,
      };
      all.unshift(item);
      await writeJson(contactsFile, all);
      await audit(req, "contact.create", { id: item.id, 対象者ID: item.対象者ID });
      return send(res, 201, item);
    }
    const contactItemMatch = pathname.match(/^\/api\/contacts\/([^/]+)$/);
    if (contactItemMatch) {
      const all = await readJson(contactsFile, []);
      const idx = all.findIndex((d) => d.id === contactItemMatch[1]);
      if (req.method === "GET") { // 照会
        requireRole(actor, "CLERK");
        if (idx === -1) return send(res, 404, { error: "連絡先が見つかりません" });
        return send(res, 200, all[idx]);
      }
      if (req.method === "PUT") { // 修正
        requireRole(actor, "CLERK");
        if (idx === -1) return send(res, 404, { error: "連絡先が見つかりません" });
        const body = await parseBody(req);
        for (const k of ["連絡先名", "連絡先電話番号", "連絡先内線番号", "連絡先FAX番号", "連絡優先順", "連絡先備考", "対象者ID", "宛名番号"]) {
          if (body[k] !== undefined) all[idx][k] = body[k];
        }
        all[idx].updatedAt = new Date().toISOString();
        await writeJson(contactsFile, all);
        await audit(req, "contact.update", { id: all[idx].id });
        return send(res, 200, all[idx]);
      }
      if (req.method === "DELETE") { // 削除
        requireRole(actor, "CLERK");
        if (idx === -1) return send(res, 404, { error: "連絡先が見つかりません" });
        const [removed] = all.splice(idx, 1);
        await writeJson(contactsFile, all);
        await audit(req, "contact.delete", { id: removed.id });
        return send(res, 200, { deleted: true, id: removed.id });
      }
    }

    // ── 汎用CRUDストア（登録・修正・削除・照会・一覧確認）。管理項目を柔軟保持 ──────────────
    const handleCrudStore = async (basePath, file, opts = {}) => {
      if (pathname === basePath && req.method === "GET") { // 一覧確認（対象者別フィルタ可）
        requireRole(actor, "CLERK");
        const o = searchParams.get("対象者ID") ?? searchParams.get("宛名番号") ?? searchParams.get("市区町村コード");
        const all = await readJson(file, []);
        const list = o ? all.filter((d) => [d.対象者ID, d.宛名番号, d.市区町村コード].map((x) => String(x ?? "")).includes(String(o))) : all;
        send(res, 200, { 件数: list.length, [opts.listKey ?? "一覧"]: list }); return true;
      }
      if (pathname === basePath && req.method === "POST") { // 登録
        requireRole(actor, "CLERK");
        const b = await parseBody(req);
        if (opts.required && !opts.required.some((k) => b && b[k] != null && b[k] !== "")) {
          send(res, 400, { error: `${opts.required.join(" または ")} のいずれかが必要です` }); return true;
        }
        const all = await readJson(file, []);
        const { id: _i, createdAt: _c, ...fields } = b || {};
        const item = { id: crypto.randomUUID(), ...fields, createdAt: new Date().toISOString(), by: actor.id };
        all.unshift(item);
        await writeJson(file, all);
        await audit(req, `${opts.audit}.create`, { id: item.id });
        send(res, 201, item); return true;
      }
      const m = pathname.match(new RegExp(`^${basePath.replace(/\//g, "\\/")}\\/([^/]+)$`));
      if (m) {
        const all = await readJson(file, []);
        const idx = all.findIndex((d) => d.id === m[1]);
        const nf = () => { send(res, 404, { error: `${opts.label ?? "対象"}が見つかりません` }); return true; };
        if (req.method === "GET") { requireRole(actor, "CLERK"); return idx === -1 ? nf() : (send(res, 200, all[idx]), true); }
        if (req.method === "PUT") {
          requireRole(actor, "CLERK"); if (idx === -1) return nf();
          const b = await parseBody(req); const { id: _i, createdAt: _c, ...patch } = b || {};
          all[idx] = { ...all[idx], ...patch, updatedAt: new Date().toISOString() };
          await writeJson(file, all); await audit(req, `${opts.audit}.update`, { id: all[idx].id });
          send(res, 200, all[idx]); return true;
        }
        if (req.method === "DELETE") {
          requireRole(actor, "CLERK"); if (idx === -1) return nf();
          const [removed] = all.splice(idx, 1);
          await writeJson(file, all); await audit(req, `${opts.audit}.delete`, { id: removed.id });
          send(res, 200, { deleted: true, id: removed.id }); return true;
        }
      }
      return false;
    };
    // 全国の自治体情報（他自治体/保険者情報）の管理（登録・修正・削除・照会）
    if (await handleCrudStore("/api/municipalities", municipalitiesFile,
      { listKey: "自治体一覧", required: ["市区町村コード", "自治体名", "保険者番号", "他自治体情報"], audit: "municipality", label: "自治体情報" })) return;
    // 住登外者における宛名情報の管理（登録・修正・削除・照会・履歴管理）
    if (await handleCrudStore("/api/jutogai-atena", jutogaiAtenaFile,
      { listKey: "宛名一覧", required: ["宛名番号", "氏名", "住登外者宛名番号"], audit: "jutogai-atena", label: "住登外宛名情報" })) return;
    // 医療機関情報の管理（登録・修正・削除・照会。事業ごとに管理可）
    if (await handleCrudStore("/api/medical-institutions", medicalInstitutionsFile,
      { listKey: "医療機関一覧", required: ["医療機関コード", "医療機関番号", "医療機関名称", "医療機関名"], audit: "medical-institution", label: "医療機関情報" })) return;

    if (pathname === "/api/household" && req.method === "GET") {
      requireRole(actor, "CLERK"); // 世帯情報を一覧で確認
      const list = await readJson(householdsFile, []);
      const 世帯一覧 = list.map((h) => ({
        世帯番号: h.世帯番号, 人数: h.members.length,
        世帯主: (h.members.find((m) => m.世帯主) ?? {}).氏名 ?? null, updatedAt: h.updatedAt,
      }));
      return send(res, 200, { 世帯数: 世帯一覧.length, 世帯一覧 });
    }
    if (pathname === "/api/household" && req.method === "POST") {
      requireRole(actor, "CLERK"); // 世帯構成の登録（住基連携の参照実装）
      const body = await parseBody(req);
      if (!body.世帯番号 || !Array.isArray(body.members)) return send(res, 400, { error: "世帯番号 と members[] が必要です" });
      const list = await readJson(householdsFile, []);
      const idx = list.findIndex((h) => h.世帯番号 === String(body.世帯番号));
      const household = {
        世帯番号: String(body.世帯番号),
        members: body.members.map((m) => ({
          宛名番号: m.宛名番号 != null ? String(m.宛名番号) : null, 氏名: m.氏名 ?? null,
          続柄: m.続柄 ?? null, 生年月日: m.生年月日 ?? null, 世帯主: m.世帯主 === true,
          ...(m.個人情報 && typeof m.個人情報 === "object" ? { 個人情報: m.個人情報 } : {}),
        })),
        updatedAt: new Date().toISOString(), by: actor.id,
      };
      if (idx >= 0) list[idx] = household; else list.unshift(household);
      await writeJson(householdsFile, list);
      await audit(req, "household.upsert", { 世帯番号: household.世帯番号, members: household.members.length });
      return send(res, 201, household);
    }
    if (pathname === "/api/household/members" && req.method === "GET") {
      requireRole(actor, "CLERK");
      const atena = searchParams.get("宛名番号"), setai = searchParams.get("世帯番号");
      const member = searchParams.get("member"); // member指定時はその構成員個人の情報を相互参照
      const list = await readJson(householdsFile, []);
      let household = null;
      if (setai) household = list.find((h) => h.世帯番号 === String(setai));
      else if (atena) household = list.find((h) => h.members.some((m) => m.宛名番号 === String(atena)));
      else return send(res, 400, { error: "宛名番号 または 世帯番号 を指定してください" });
      if (!household) return send(res, 404, { error: "世帯が見つかりません" });
      if (member) { // 世帯構成員一覧から個人を選び個人情報を相互参照
        const m = household.members.find((x) => x.宛名番号 === String(member));
        if (!m) return send(res, 404, { error: "構成員が見つかりません" });
        return send(res, 200, { 世帯番号: household.世帯番号, member: m });
      }
      return send(res, 200, { 世帯番号: household.世帯番号, 世帯構成員一覧: household.members, 人数: household.members.length });
    }

    // ── 情報照会の記録・一覧（横断）: マイナンバー制度(情報提供NW)の情報照会の要求情報・取込結果・提供データを記録し一覧確認する ──
    if (pathname === "/api/info-inquiry" && req.method === "POST") {
      requireRole(actor, "CLERK"); // 情報照会の要求を記録
      const body = await parseBody(req);
      if (!body.照会先 && !body.事務手続) return send(res, 400, { error: "照会先 または 事務手続 が必要です" });
      const list = await readJson(infoInquiryFile, []);
      const item = {
        id: crypto.randomUUID(), 宛名番号: body.宛名番号 ?? null, 事務手続: body.事務手続 ?? null,
        照会先: body.照会先 ?? null, 要求項目: Array.isArray(body.要求項目) ? body.要求項目 : [],
        状態: "照会中", 取込結果: null, 提供データ: null,
        要求日時: new Date().toISOString(), by: actor.id,
      };
      list.unshift(item);
      await writeJson(infoInquiryFile, list);
      await audit(req, "info-inquiry.request", { id: item.id, 照会先: item.照会先 });
      return send(res, 201, item);
    }
    const inquiryResultMatch = pathname.match(/^\/api\/info-inquiry\/([^/]+)\/result$/);
    if (inquiryResultMatch && req.method === "POST") {
      requireRole(actor, "CLERK"); // 取込結果・提供データを記録
      const body = await parseBody(req);
      const list = await readJson(infoInquiryFile, []);
      const item = list.find((x) => x.id === inquiryResultMatch[1]);
      if (!item) return send(res, 404, { error: "情報照会が見つかりません" });
      const results = ["取込済", "該当なし", "エラー"];
      item.取込結果 = results.includes(body.取込結果) ? body.取込結果 : "取込済";
      item.状態 = item.取込結果 === "エラー" ? "エラー" : "完了";
      item.提供データ = body.提供データ ?? null;
      item.取込日時 = new Date().toISOString();
      await writeJson(infoInquiryFile, list);
      await audit(req, "info-inquiry.result", { id: item.id, 取込結果: item.取込結果 });
      return send(res, 200, item);
    }
    if (pathname === "/api/info-inquiry" && req.method === "GET") {
      requireRole(actor, "CLERK"); // 要求情報・取込結果・提供データを一覧で確認
      const atena = searchParams.get("宛名番号"), 状態 = searchParams.get("状態"), 取込結果 = searchParams.get("取込結果");
      const list = await readJson(infoInquiryFile, []);
      return send(res, 200, list.filter((x) => (!atena || x.宛名番号 === String(atena))
        && (!状態 || x.状態 === 状態) && (!取込結果 || x.取込結果 === 取込結果)));
    }
    const inquiryGetMatch = pathname.match(/^\/api\/info-inquiry\/([^/]+)$/);
    if (inquiryGetMatch && req.method === "GET") {
      requireRole(actor, "CLERK");
      const list = await readJson(infoInquiryFile, []);
      const item = list.find((x) => x.id === inquiryGetMatch[1]);
      if (!item) return send(res, 404, { error: "情報照会が見つかりません" });
      return send(res, 200, item);
    }

    // ── 副本登録（横断）: マイナンバー制度の中間サーバー・団体内統合宛名へ副本登録用データを作成し連携する ──
    if (pathname === "/api/fukuhon" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const body = await parseBody(req);
      if (!body.データ種別) return send(res, 400, { error: "データ種別（標準レイアウト）が必要です" });
      const 連携先 = ["中間サーバー", "団体内統合宛名"].includes(body.連携先) ? body.連携先 : "中間サーバー";
      const list = await readJson(fukuhonFile, []);
      const item = {
        id: crypto.randomUUID(), recordType: body.recordType ?? null, recordId: body.recordId ?? null,
        宛名番号: body.宛名番号 ?? null, データ種別: String(body.データ種別), 連携先,
        住登外: body.住登外 === true, // 住登外対象者も副本登録対象とできる
        副本データ: body.副本データ && typeof body.副本データ === "object" ? body.副本データ : {},
        状態: "作成済", // 作成済→連携済（連携記録）
        連携日時: null, createdAt: new Date().toISOString(), by: actor.id,
      };
      list.unshift(item);
      await writeJson(fukuhonFile, list);
      await audit(req, "fukuhon.create", { id: item.id, データ種別: item.データ種別, 連携先 });
      return send(res, 201, item);
    }
    const fukuhonLinkMatch = pathname.match(/^\/api\/fukuhon\/([^/]+)\/link$/);
    if (fukuhonLinkMatch && req.method === "POST") {
      requireRole(actor, "CLERK"); // 副本登録データを中間サーバー等へ連携（記録）
      const list = await readJson(fukuhonFile, []);
      const item = list.find((x) => x.id === fukuhonLinkMatch[1]);
      if (!item) return send(res, 404, { error: "副本登録データが見つかりません" });
      item.状態 = "連携済"; item.連携日時 = new Date().toISOString();
      await writeJson(fukuhonFile, list);
      await audit(req, "fukuhon.link", { id: item.id, 連携先: item.連携先 });
      return send(res, 200, item);
    }
    const fukuhonCorrectMatch = pathname.match(/^\/api\/fukuhon\/([^/]+)$/);
    if (fukuhonCorrectMatch && req.method === "PUT") {
      requireRole(actor, "CLERK"); // 副本登録を行う上で連携項目の内容を修正
      const body = await parseBody(req);
      const list = await readJson(fukuhonFile, []);
      const item = list.find((x) => x.id === fukuhonCorrectMatch[1]);
      if (!item) return send(res, 404, { error: "副本登録データが見つかりません" });
      if (body.副本データ && typeof body.副本データ === "object") item.副本データ = { ...item.副本データ, ...body.副本データ };
      item.updatedAt = new Date().toISOString();
      await writeJson(fukuhonFile, list);
      await audit(req, "fukuhon.correct", { id: item.id });
      return send(res, 200, item);
    }
    if (pathname === "/api/fukuhon" && req.method === "GET") {
      requireRole(actor, "CLERK");
      const atena = searchParams.get("宛名番号"), 状態 = searchParams.get("状態"), 種別 = searchParams.get("データ種別");
      const list = await readJson(fukuhonFile, []);
      return send(res, 200, list.filter((x) => (!atena || x.宛名番号 === String(atena))
        && (!状態 || x.状態 === 状態) && (!種別 || x.データ種別 === String(種別))));
    }
    // ── 中間サーバーの突合用ファイルを用いた副本データの整合性確認 ──────────────────────
    if (pathname === "/api/fukuhon/reconcile" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const body = await parseBody(req);
      const 突合ファイル = Array.isArray(body?.突合ファイル) ? body.突合ファイル : null;
      if (!突合ファイル) return send(res, 400, { error: "突合ファイル[]{宛名番号,データ種別,副本データ} が必要です" });
      const 種別 = body.データ種別 ? String(body.データ種別) : null;
      const stored = (await readJson(fukuhonFile, [])).filter((x) => !種別 || x.データ種別 === 種別);
      const keyOf = (r) => `${r.宛名番号 ?? ""}|${r.データ種別 ?? ""}`;
      const storedMap = new Map(stored.map((r) => [keyOf(r), r]));
      const tsMap = new Map(突合ファイル.map((r) => [keyOf(r), r]));
      const 一致 = [], 不一致 = [], 副本のみ = [], 突合のみ = [];
      for (const [k, s] of storedMap) {
        const t = tsMap.get(k);
        if (!t) { 副本のみ.push({ key: k, 宛名番号: s.宛名番号 }); continue; }
        const sd = s.副本データ || {}, td = t.副本データ || {};
        const fields = new Set([...Object.keys(sd), ...Object.keys(td)]);
        const 差分 = [];
        for (const f of fields) if (String(sd[f] ?? "") !== String(td[f] ?? "")) 差分.push({ 項目: f, 副本: sd[f] ?? null, 突合: td[f] ?? null });
        if (差分.length) 不一致.push({ key: k, 宛名番号: s.宛名番号, 差分 }); else 一致.push({ key: k, 宛名番号: s.宛名番号 });
      }
      for (const [k, t] of tsMap) if (!storedMap.has(k)) 突合のみ.push({ key: k, 宛名番号: t.宛名番号 });
      const 整合 = 不一致.length === 0 && 副本のみ.length === 0 && 突合のみ.length === 0;
      await audit(req, "fukuhon.reconcile", { 整合, 不一致: 不一致.length, 副本のみ: 副本のみ.length, 突合のみ: 突合のみ.length });
      return send(res, 200, { kind: "副本データの整合性確認(中間サーバー突合用ファイル)", 整合,
        一致件数: 一致.length, 不一致件数: 不一致.length, 不一致, 副本のみ, 突合のみ,
        note: "格納済の副本データと中間サーバー突合用ファイルを宛名番号×データ種別で突合し、不一致(値差分)・副本のみ・突合のみを検出。" });
    }

    // ── マイナンバー配信マスタ・独自マスタの取込（横断）: 情報提供ネットワークシステムより提供されるマスタを取り込み記録する ──
    if (pathname === "/api/mynumber/master-import" && req.method === "POST") {
      requireRole(actor, "ADMIN"); // マスタ取込は管理者
      const body = await parseBody(req);
      const kinds = ["配信マスタ", "独自マスタ"];
      if (!kinds.includes(body.種別)) return send(res, 400, { error: `種別 は ${kinds.join("/")} のいずれかが必須です` });
      const records = Array.isArray(body.records) ? body.records : [];
      const list = await readJson(mynumberMasterFile, []);
      const item = {
        id: crypto.randomUUID(), 種別: body.種別, 件数: records.length,
        records, 提供元: "情報提供ネットワークシステム",
        取込日時: new Date().toISOString(), by: actor.id,
      };
      list.unshift(item);
      await writeJson(mynumberMasterFile, list);
      await audit(req, "mynumber.master-import", { 種別: item.種別, 件数: item.件数 });
      const { records: _r, ...meta } = item;
      return send(res, 201, meta);
    }
    if (pathname === "/api/mynumber/master-import" && req.method === "GET") {
      requireRole(actor, "CLERK"); // 取込履歴・状況の確認
      const 種別 = searchParams.get("種別");
      const list = await readJson(mynumberMasterFile, []);
      return send(res, 200, list.filter((x) => !種別 || x.種別 === 種別).map(({ records, ...m }) => ({ ...m, 件数: records.length })));
    }

    // ── PMH(公的医療情報基盤)オンライン資格確認（横断）: 受給資格情報/医療券情報をPMHに登録し、処理通番を基に登録状況を照会 ──
    // 照会はデジタル庁「登録結果取得に関するAPI設計書」準拠。返却された照会結果(コード・内容)を確認できる。
    if (pathname === "/api/pmh/register" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const body = await parseBody(req);
      const list = await readJson(pmhFile, []);
      const 処理通番 = body.処理通番 ? String(body.処理通番) : `PMH${Date.now()}${Math.floor(Math.random() * 1000)}`;
      const item = {
        処理通番, 情報種別: body.情報種別 ?? "受給資格情報", recordType: body.recordType ?? null, recordId: body.recordId ?? null,
        宛名番号: body.宛名番号 ?? null, payload: body.payload && typeof body.payload === "object" ? body.payload : {},
        登録状況: "登録要求済", 照会結果: null, registeredAt: new Date().toISOString(), by: actor.id,
      };
      list.unshift(item);
      await writeJson(pmhFile, list);
      await audit(req, "pmh.register", { 処理通番, 情報種別: item.情報種別 });
      return send(res, 201, item);
    }
    // 処理通番を基に登録状況を照会（登録結果取得API準拠）。照会結果コード・内容を返す。
    if (pathname === "/api/pmh/registration-status" && req.method === "GET") {
      requireRole(actor, "CLERK");
      const 処理通番 = searchParams.get("処理通番");
      if (!処理通番) return send(res, 400, { error: "処理通番 が必要です" });
      const list = await readJson(pmhFile, []);
      const item = list.find((x) => x.処理通番 === String(処理通番));
      if (!item) return send(res, 404, { error: "処理通番に該当する登録要求が見つかりません", 照会結果: { コード: "E404", 内容: "該当なし" } });
      // 登録結果取得API: 登録状況に応じたコード・内容を返す（デモは登録要求済→登録完了として返却）
      const 照会結果 = item.照会結果 ?? { コード: "0", 内容: "登録完了" };
      return send(res, 200, { 処理通番: item.処理通番, 情報種別: item.情報種別, 登録状況: item.登録状況, 照会結果 });
    }
    // 照会結果(コード・内容)の取り込み・確定（中間サーバー/PMHからの結果反映）
    if (pathname === "/api/pmh/registration-status" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const body = await parseBody(req);
      if (!body.処理通番) return send(res, 400, { error: "処理通番 が必要です" });
      const list = await readJson(pmhFile, []);
      const item = list.find((x) => x.処理通番 === String(body.処理通番));
      if (!item) return send(res, 404, { error: "処理通番に該当する登録要求が見つかりません" });
      item.照会結果 = { コード: body.コード ?? "0", 内容: body.内容 ?? "登録完了" };
      item.登録状況 = item.照会結果.コード === "0" ? "登録完了" : "エラー";
      await writeJson(pmhFile, list);
      await audit(req, "pmh.status-result", { 処理通番: item.処理通番, コード: item.照会結果.コード });
      return send(res, 200, item);
    }

    // ── 住民記録標準仕様準拠の「あいまい検索」（氏名カナ/氏名・異体字正字の包含は対象外）─────
    if (pathname === "/api/fuzzy-search" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const body = await parseBody(req);
      const result = fuzzySearchRecords(body || {});
      if (result.error) return send(res, 400, result);
      await audit(req, "fuzzy-search", { query: result.query, 一致件数: result.一致件数, 対象件数: result.対象件数 });
      return send(res, 200, result);
    }
    // 正規化結果の確認用（あいまい検索の同一視ルールを可視化）
    if (pathname === "/api/fuzzy-search/normalize" && req.method === "GET") {
      requireRole(actor, "CLERK");
      const q = searchParams.get("q") ?? "";
      return send(res, 200, { input: q, normalized: normalizeKanaFuzzy(q) });
    }

    // ── 保存期間（法令年限）経過情報の物理削除（scan=抽出のみ / purge=削除） ─────────────
    if (pathname === "/api/retention/scan" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const body = await parseBody(req);
      const result = computeRetention({ ...(body || {}), action: "scan" });
      if (result.error) return send(res, 400, result);
      await audit(req, "retention.scan", { 対象件数: result.対象件数, 経過件数: result.経過件数, 基準日: result.基準日 });
      return send(res, 200, result);
    }
    if (pathname === "/api/retention/purge" && req.method === "POST") {
      requireRole(actor, "ADMIN");
      const body = await parseBody(req);
      const result = computeRetention({ ...(body || {}), action: "purge" });
      if (result.error) return send(res, 400, result);
      await audit(req, "retention.purge", { 削除済件数: result.削除済件数, 基準日: result.基準日 });
      return send(res, 200, result);
    }
    // ── データ登録・修正時のデータ型・桁数チェック（エラー） ─────────────────────────
    if (pathname === "/api/field-check" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const body = await parseBody(req);
      const result = computeFieldCheck(body || {});
      if (result.error) return send(res, 400, result);
      await audit(req, "field-check", { errorCount: result.errorCount, valid: result.valid });
      return send(res, 200, result);
    }
    // ── 金融機関マスタの一括更新（全国銀行協会フォーマット様式に基づく項目を取込・upsert）──────
    if (pathname === "/api/bank-master" && req.method === "GET") {
      requireRole(actor, "CLERK");
      const all = await readJson(bankMasterFile, []);
      return send(res, 200, all);
    }
    // 金融機関マスタの個別 登録/修正(upsert)：全銀協フォーマット項目を1件単位で登録・修正
    if (pathname === "/api/bank-master" && req.method === "POST") {
      requireRole(actor, "ADMIN");
      const body = await parseBody(req);
      const row = normalizeBankMasterRow(body || {});
      const errs = validateBankMasterRow(row, 0);
      if (errs.length) return send(res, 400, { error: "全銀協フォーマットの検証エラー", errors: errs });
      const all = await readJson(bankMasterFile, []);
      const key = `${row.金融機関コード}-${row.店舗コード || ""}`;
      const idx = all.findIndex((r) => `${r.金融機関コード}-${r.店舗コード || ""}` === key);
      const created = idx < 0;
      if (created) all.push({ ...row, createdAt: new Date().toISOString() });
      else all[idx] = { ...all[idx], ...row, updatedAt: new Date().toISOString() };
      await writeJson(bankMasterFile, all);
      await audit(req, "bank-master.upsert", { key, created });
      return send(res, created ? 201 : 200, all[created ? all.length - 1 : idx]);
    }
    // 金融機関マスタの個別 削除/照会： /api/bank-master/:金融機関コード-:店舗コード
    const bankMasterItemMatch = pathname.match(/^\/api\/bank-master\/(\d{4})-(\d{0,3})$/);
    if (bankMasterItemMatch && (req.method === "DELETE" || req.method === "GET")) {
      const key = `${bankMasterItemMatch[1]}-${bankMasterItemMatch[2]}`;
      const all = await readJson(bankMasterFile, []);
      const idx = all.findIndex((r) => `${r.金融機関コード}-${r.店舗コード || ""}` === key);
      if (idx < 0) return send(res, 404, { error: "金融機関マスタが見つかりません" });
      if (req.method === "GET") { requireRole(actor, "CLERK"); return send(res, 200, all[idx]); }
      requireRole(actor, "ADMIN");
      const [removed] = all.splice(idx, 1);
      await writeJson(bankMasterFile, all);
      await audit(req, "bank-master.delete", { key });
      return send(res, 200, { deleted: true, key, removed });
    }
    if (pathname === "/api/bank-master/bulk-update" && req.method === "POST") {
      requireRole(actor, "ADMIN");
      const body = await parseBody(req);
      const rows = Array.isArray(body?.records) ? body.records : null;
      if (!rows) return send(res, 400, { error: "records[]{金融機関コード,店舗コード,…全銀協マスタ項目} が必要です" });
      const normalized = rows.map(normalizeBankMasterRow);
      const errors = normalized.flatMap((row, i) => validateBankMasterRow(row, i));
      if (errors.length) return send(res, 400, { error: "全銀協フォーマットの検証エラー", errors });
      const all = await readJson(bankMasterFile, []);
      const keyOf = (r) => `${r.金融機関コード}-${r.店舗コード || ""}`;
      const index = new Map(all.map((r, i) => [keyOf(r), i]));
      let created = 0, updated = 0;
      for (const row of normalized) {
        const k = keyOf(row);
        if (index.has(k)) { all[index.get(k)] = { ...all[index.get(k)], ...row, updatedAt: new Date().toISOString() }; updated++; }
        else { index.set(k, all.length); all.push({ ...row, createdAt: new Date().toISOString() }); created++; }
      }
      await writeJson(bankMasterFile, all);
      await audit(req, "bank-master.bulk-update", { 取込件数: normalized.length, created, updated });
      return send(res, 200, { kind: "金融機関マスタ一括更新(全国銀行協会フォーマット様式)", 取込件数: normalized.length, created, updated, 総件数: all.length });
    }
    // ── 連携用データの取込/作成/送信エラーの記録・確認と、エラー対応後の再処理（リトライ）─────────
    if (pathname === "/api/link-errors" && req.method === "GET") {
      requireRole(actor, "CLERK");
      const all = await readJson(linkErrorsFile, []);
      const st = searchParams.get("状態") ?? searchParams.get("status");
      return send(res, 200, st ? all.filter((e) => e.状態 === st) : all);
    }
    if (pathname === "/api/link-errors" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const body = await parseBody(req);
      const kinds = ["取込", "作成", "送信"];
      const 種別 = kinds.includes(body.種別) ? body.種別 : null;
      if (!種別 || !body.エラー内容) return send(res, 400, { error: "種別(取込/作成/送信) と エラー内容 が必要です" });
      const all = await readJson(linkErrorsFile, []);
      const item = {
        id: crypto.randomUUID(), 種別, 対象: body.対象 ?? null, エラー内容: String(body.エラー内容),
        データ: body.データ ?? null, 状態: "未対応", 再処理回数: 0,
        発生日時: new Date().toISOString(), by: actor.id,
      };
      all.unshift(item);
      await writeJson(linkErrorsFile, all);
      await audit(req, "link-error.record", { 種別, 対象: item.対象 });
      return send(res, 201, item);
    }
    const linkErrRetryMatch = pathname.match(/^\/api\/link-errors\/([^/]+)\/retry$/);
    if (linkErrRetryMatch && req.method === "POST") {
      requireRole(actor, "CLERK");
      const body = await parseBody(req);
      const all = await readJson(linkErrorsFile, []);
      const item = all.find((e) => e.id === linkErrRetryMatch[1]);
      if (!item) return send(res, 404, { error: "エラー記録が見つかりません" });
      // エラー対応後の再処理: 成功なら解決、失敗なら再度未対応で回数加算
      const 成功 = body.result !== "失敗" && body.成功 !== false;
      item.再処理回数 = (item.再処理回数 ?? 0) + 1;
      item.状態 = 成功 ? "再処理済(解決)" : "再処理失敗";
      item.最終再処理日時 = new Date().toISOString();
      if (body.エラー内容) item.エラー内容 = String(body.エラー内容);
      await writeJson(linkErrorsFile, all);
      await audit(req, "link-error.retry", { id: item.id, 状態: item.状態, 再処理回数: item.再処理回数 });
      return send(res, 200, item);
    }
    // ── 帳票カタログ: 出力可能な帳票の一覧表示・選択と、帳票単位の出力有無の設定 ─────────────
    if (pathname === "/api/report-catalog" && req.method === "GET") {
      requireRole(actor, "CLERK");
      const all = await readJson(reportCatalogFile, []);
      // 出力する帳票を選択する画面向け: 既定は出力有効(enabled)のみ。?all=1 で全件。
      const showAll = searchParams.get("all") === "1";
      const items = showAll ? all : all.filter((r) => r.出力有無 !== false);
      return send(res, 200, { 出力可能帳票数: items.length, reports: items });
    }
    if (pathname === "/api/report-catalog" && req.method === "POST") {
      requireRole(actor, "ADMIN");
      const body = await parseBody(req);
      if (!body.帳票名 && !body.reportName) return send(res, 400, { error: "帳票名 が必要です" });
      const all = await readJson(reportCatalogFile, []);
      const item = {
        id: crypto.randomUUID(), 帳票名: String(body.帳票名 ?? body.reportName),
        区分: body.区分 ?? body.category ?? null, 様式コード: body.様式コード ?? null,
        出力有無: body.出力有無 !== false, createdAt: new Date().toISOString(),
      };
      all.push(item);
      await writeJson(reportCatalogFile, all);
      await audit(req, "report-catalog.register", { 帳票名: item.帳票名 });
      return send(res, 201, item);
    }
    const reportCatalogItemMatch = pathname.match(/^\/api\/report-catalog\/([^/]+)$/);
    if (reportCatalogItemMatch && req.method === "PUT") {
      requireRole(actor, "ADMIN");
      const body = await parseBody(req);
      const all = await readJson(reportCatalogFile, []);
      const idx = all.findIndex((r) => r.id === reportCatalogItemMatch[1]);
      if (idx === -1) return send(res, 404, { error: "帳票が見つかりません" });
      // 帳票単位の出力有無の設定（及び区分・様式の更新）
      if (body.出力有無 !== undefined) all[idx].出力有無 = body.出力有無 !== false;
      for (const k of ["帳票名", "区分", "様式コード"]) if (body[k] !== undefined) all[idx][k] = body[k];
      all[idx].updatedAt = new Date().toISOString();
      await writeJson(reportCatalogFile, all);
      await audit(req, "report-catalog.update", { id: all[idx].id, 出力有無: all[idx].出力有無 });
      return send(res, 200, all[idx]);
    }

    // ── 一覧表示上限を超える検索条件はエラーとし検索条件の再設定を促す ──────────────────
    if (pathname === "/api/search-limit/check" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const body = await parseBody(req);
      const result = computeSearchLimit(body || {});
      if (result.error) return send(res, 400, result);
      return send(res, 200, result);
    }
    // ── 帳票の対象者情報に応じた敬称付与・文言の付加/置換 ─────────────────────────────
    if (pathname === "/api/honorific" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const body = await parseBody(req);
      return send(res, 200, computeHonorific(body || {}));
    }
    // ── 氏名・住所等の印刷域桁数を超過したもののリスト出力（帳票発行時）──────────────────
    if (pathname === "/api/print-overflow" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const body = await parseBody(req);
      const result = computePrintOverflow(body || {});
      if (result.error) return send(res, 400, result);
      await audit(req, "print-overflow", { 対象件数: result.対象件数, 超過件数: result.超過件数 });
      return send(res, 200, result);
    }
    // ── 事務分掌による利用者ごとの表示・閲覧項目及び実施処理の制御（事務分掌×項目/処理の可否マトリクス）──
    if (pathname === "/api/access-control" && req.method === "GET") {
      requireRole(actor, "CLERK");
      const all = await readJson(accessControlFile, []);
      const d = searchParams.get("事務分掌") ?? searchParams.get("division");
      return send(res, 200, d ? (all.find((x) => x.事務分掌 === d) ?? null) : all);
    }
    if (pathname === "/api/access-control" && req.method === "POST") {
      requireRole(actor, "ADMIN");
      const body = await parseBody(req);
      const 事務分掌 = body.事務分掌 ?? body.division;
      if (!事務分掌) return send(res, 400, { error: "事務分掌 が必要です" });
      const all = await readJson(accessControlFile, []);
      const entry = {
        事務分掌: String(事務分掌),
        表示可能項目: Array.isArray(body.表示可能項目) ? body.表示可能項目.map(String) : [],
        実施可能処理: Array.isArray(body.実施可能処理) ? body.実施可能処理.map(String) : [],
        updatedAt: new Date().toISOString(), by: actor.id,
      };
      const idx = all.findIndex((x) => x.事務分掌 === entry.事務分掌);
      if (idx >= 0) all[idx] = entry; else all.push(entry);
      await writeJson(accessControlFile, all);
      await audit(req, "access-control.set", { 事務分掌: entry.事務分掌, 項目数: entry.表示可能項目.length, 処理数: entry.実施可能処理.length });
      return send(res, idx >= 0 ? 200 : 201, entry);
    }
    // 事務分掌の利用者が、特定の項目を表示・閲覧でき、特定の処理を実施できるかを判定
    if (pathname === "/api/access-control/check" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const body = await parseBody(req);
      const 事務分掌 = body.事務分掌 ?? body.division;
      if (!事務分掌) return send(res, 400, { error: "事務分掌 が必要です" });
      const all = await readJson(accessControlFile, []);
      const entry = all.find((x) => x.事務分掌 === String(事務分掌));
      const result = { 事務分掌: String(事務分掌), 定義あり: !!entry };
      if (body.項目 !== undefined) result.表示可否 = !!entry && entry.表示可能項目.includes(String(body.項目));
      if (body.処理 !== undefined) result.処理可否 = !!entry && entry.実施可能処理.includes(String(body.処理));
      return send(res, 200, result);
    }

    if (systemConfig.handle) {
      const _ctx = { req, res, pathname, searchParams, actor, send, sendRaw, parseBody, readJson, writeJson, audit, maskRecord, restrictionApplies, requireRole, hasRole, dataRoot, restrictionsFile, crypto, dbSelect, dbSelectOne, dbInsert, dbUpdate, dbDelete };
      if (await systemConfig.handle(_ctx)) return;
    }

    if (await serveStatic(req, res, pathname)) return;
    send(res, 404, { error: "not found" });
  } catch (error) {
    send(res, error.statusCode ?? 500, { error: error.message });
  }
});

  return server;
}

