// store — 全画面が読む単一のデータ（案件・進行・工程・TODO・ファイル・関係者）。
// P0'-1 単一データ化＋永続化 / P0'-4 工程は plan(予定日)＋date(確定日) を持つ。
// 画面側は adapter（boardRows / caseMeta / trackRows / caseList）で従来の行形に受け取る。

const MS_KEY = "marurou.store.v5";
const MS_TODAY = "6/16";
const msStep = (n, s, date, plan) => ({ n, s:s||"todo", date:date||null, plan:plan||null });
const msSteps = arr => arr.map(a => msStep(a[0], a[1], a[2], a[3]));
const MS_WORK_STEPS = ["見積","社内承認","受注","日程調整","実施","報告","請求","入金","完了"];
const MS_SURVEY_STEPS = ["受注","日程調整","実施","報告","見積","社内承認","請求","入金","完了"];
const MS_INS_STEPS = ["受付","申請","鑑定","認定","保険金請求","入金"];

/* ---- 種となる案件（10件）。visits/works/ins は進行＝トラック。id が全画面の突き合わせキー ---- */
function msSeed() {
  const C = [];
  C.push({
    id:"CASE-2471", name:"グリーンハイツ302", type:"賃貸", propKind:"賃貸マンション", order:"調査＋工事",
    accepted:"2026/05/24", owner:"佐々木（阿部チーム）", client:"コスモ管理", clientNote:"302管理会社",
    initial:"302天井から漏水・上階402が原因か", mode:"日程調整", dueDone:"07/10", closed:false,
    spots:[
      { kind:"原因", room:"402", detail:"キッチンの排水管", owner:"山本オーナー", isClient:false },
      { kind:"被害", room:"302", detail:"廊下の天井", owner:"コスモ管理", isClient:true },
    ],
    surveyTasks:[
      { name:"応急止水", state:"done", date:"5/26", memo:"原因箇所に止水テープ巻き" },
      { name:"原因特定", state:"done", date:"5/26", memo:"402キッチン排水管の劣化と特定" },
      { name:"被害対応", state:"done", date:"5/26", memo:"302廊下直下にバケツと吸水シート設置" },
    ],
    visits:[{ id:"TRK-2471-S1", no:1, kind:"調査", vendor:"及川設備", rooms:"402・302", title:"402・302 初回調査", status:"入金待ち", mk:"",
      steps:msSteps([["受注","done","5/24"],["日程調整","done","5/25"],["実施","done","5/26"],["報告","done","5/27"],["見積","done","5/28"],
        ["社内承認","done","5/28"],["請求","done","5/28"],["入金","waiting",null,"6/30"],["完了","todo"]]),
      // vendorCost: 精算-3(0061)。トラック直下の生値(detail.cost と同じ値。0050 rpc_seed 相当・既定null)。
      vendorCost:18000,
      detail:{ settle:"自費 内包", method:"自費 コスモ・月次窓口", payer:"コスモ管理", vendor:"及川設備", billed:35000, cost:18000, due:"6/30", paid:null, report:"5/27 共有済" } }],
    works:[
      { id:"TRK-2471-W1", room:"402", cause:"キッチンの排水管", work:"排水管交換", contractor:{kind:"self",sub:"確定"},
        state:"now", stall:false, status:"施工中", mk:"",
        steps:msSteps([["見積","done","5/29"],["社内承認","done","5/29"],["受注（山本）","done","5/30"],["日程調整","done","5/31","6/3"],
          ["実施","now",null,"6/3〜6/5"],["報告","todo",null,"6/5"],["請求","todo"],["入金","todo"],["完了","todo"]]),
        vendorCost:70000,
        detail:{ settle:"自費 内包（山本請求）", method:"自費 山本・月次案件", payer:"山本オーナー", vendor:"山田設備（下請）",
          billed:120000, cost:70000, due:null, paid:null,
          visits:[{ id:"SV1", plannedOn:"6/3", kind:"施工", note:"山田設備 402" }] } },
      { id:"TRK-2471-W2", room:"302", cause:"廊下の天井", work:"クロス張替 ほか", contractor:{kind:"self",sub:"確定"},
        state:"wait", stall:true, stallWhy:"保険認定が2日遅延", status:"着手待ち・保険認定待ち", mk:"delay", plan:"認定後着工（保険認定を待って日程調整）",
        steps:msSteps([["見積","done","5/29"],["社内承認","done","5/29"],["受注","done","5/30"],["保険認定待ち","waiting",null,"6/18"],
          ["日程調整","todo"],["実施","todo"],["報告","todo"],["保険金請求","todo"],["入金","todo"],["完了","todo"]]),
        vendorCost:154000,
        detail:{ settle:"保険連動", method:"保険 東都＋自費", payer:"東都火災", vendor:"丸建リフォーム（下請）",
          billed:237000, cost:154000, due:null, paid:null,
          visits:[{ id:"SV1", plannedOn:null, kind:"施工", note:"丸建 302 認定後に日程" }],
          items:[
            { id:"main", name:"クロス張替", vendor:"丸建リフォーム", amt:180000, gate:null, gateLabel:"（主作業）", state:"—", tone:"faint", ins:true },
            { id:"m1", name:"荷物移動", vendor:"引越センター", amt:15000, gate:5, gateLabel:"実施まで", state:"手配済", tone:"green", ins:false },
            { id:"m2", name:"ホテル代", vendor:"ビジネスホテル", amt:42000, gate:6, gateLabel:"報告まで", state:"金額未確定", tone:"warn", ins:true },
          ] } },
    ],
    ins:[{ id:"TRK-2471-I1", name:"東都火災", status:"認定待ち", mk:"delay", holder:"山本オーナー（402・原因者）", policy:"個人賠償責任保険",
      target:"302工事の明細2件に充当（クロス張替 ¥ 180,000 ＋ ホテル代 ¥ 42,000 ＝ ¥ 222,000）",
      targetShort:"302工事・¥ 222,000（クロス張替＋ホテル代）",
      steps:msSteps([["受付","done","5/28"],["申請","done","5/29"],["鑑定","skip","少額skip"],["認定","waiting",null,"6/18"],["保険金請求","todo"],["入金","todo"]]),
      detail:{ settle:"東都火災", method:"保険充当", payer:"東都火災", billed:222000, cost:0, approved:null, due:"7/5", paid:null,
        visits:[{ id:"SV1", plannedOn:null, kind:"見積調査", note:"302復旧をカバー" }] } }],
    reportTo:["コスモ管理","山本オーナー"],
    files:[
      { id:"F1", name:"調査報告書", date:"5/27", type:"報告書", sub:"調査", ctx:"調査", trkId:"TRK-2471-S1", stage:"issued", sharedTo:["コスモ管理"] },
      { id:"F2", name:"現場写真（402原因箇所）", date:"5/26", type:"写真", ctx:"調査", trkId:"TRK-2471-S1", stage:"received", count:4 },
      { id:"F3", name:"現場写真（302被害箇所）", date:"5/26", type:"写真", ctx:"調査", trkId:"TRK-2471-S1", stage:"received", count:3 },
      { id:"F4", name:"調査 見積書", date:"5/28", type:"見積書", sub:"調査", ctx:"調査", trkId:"TRK-2471-S1", stage:"issued", sharedTo:["コスモ管理"] },
      { id:"F5", name:"402工事 見積書", date:"5/29", type:"見積書", sub:"工事", ctx:"402工事", trkId:"TRK-2471-W1", stage:"issued", sharedTo:["山本オーナー"] },
      { id:"F6", name:"302工事 見積書", date:"5/29", type:"見積書", sub:"工事", ctx:"302工事", trkId:"TRK-2471-W2", stage:"issued", sharedTo:["コスモ管理"] },
      { id:"F7", name:"302工事 発注書", date:"5/30", type:"発注書", ctx:"302工事", trkId:"TRK-2471-W2", stage:"received" },
      { id:"F8", name:"調査費 請求書", date:"5/28", type:"請求書", ctx:"精算", trkId:"TRK-2471-S1", stage:"issued", sharedTo:["コスモ管理"] },
      { id:"F9", name:"保険申請書類", date:"5/29", type:"その他", ctx:"保険", trkId:"TRK-2471-I1", stage:"issued", sharedTo:["東都火災"] },
    ],
    parties:[
      { role:"依頼者", name:"コスモ管理（302）", wait:null, log:[
        {d:"5/30",dir:"in",t:"302工事の発注書を受領"},{d:"5/28",dir:"out",t:"調査報告書と302見積を送付"},
        {d:"5/24",dir:"in",t:"302天井からの漏水で調査依頼を受付"}]},
      { role:"原因者", name:"山本オーナー（402）", wait:null, log:[
        {d:"5/31",dir:"out",t:"402施工日程（6/3〜）を連絡"},{d:"5/30",dir:"in",t:"402工事の承認・発注を受領"},
        {d:"5/28",dir:"out",t:"原因調査結果と402見積を送付"},{d:"5/25",dir:"out",t:"402立入調査の日程調整"}]},
      { role:"保険", name:"東都火災", wait:2, log:[
        {d:"5/29",dir:"out",t:"保険申請書類を提出（302復旧・少額のため鑑定なし）"},{d:"5/28",dir:"out",t:"事故受付の連絡・書類確認"}]},
    ],
  });

  /* 以降9件：受付〜工事の各段階。詳細の粒度は本番より粗いが構造は同じ */
  const mk = o => {
    const c = { type:"賃貸", propKind:"賃貸マンション", order:"調査＋工事", owner:"立石（川野チーム）", mode:"日程調整",
      closed:false, surveyTasks:[], visits:[], works:[], ins:null, files:[], parties:[], reportTo:o.client?[o.client]:[], ...o };
    if(!c.parties.length && c.client) c.parties = [{ role:"依頼者", name:c.client, wait:null, log:[{ d:c.accepted.slice(5).replace(/^0/,""), dir:"in", t:"漏水の連絡を受付" }] }];
    return c;
  };
  C.push(mk({ id:"CASE-2460", name:"メイプル405", accepted:"2026/05/19", client:"アーク管理", dueDone:"07/26", order:"調査＋工事",
    initial:"屋上防水層の劣化・501天井に染み", propKind:"賃貸マンション",
    spots:[{ kind:"原因", room:"601", detail:"屋上の防水層", owner:"アーク管理", isClient:true },
      { kind:"被害", room:"501", detail:"洋室の天井", owner:"シーサイド管理", isClient:false }],
    works:[{ id:"TRK-2460-W1", room:"601", cause:"屋上の防水層", work:"防水改修", contractor:{kind:"self",sub:"確定"},
      state:"now", stall:false, status:"施工中", mk:"warn",
      steps:msSteps([["見積","done","5/26"],["社内承認","done","5/27"],["受注","done","5/28"],["日程調整","done","5/30","6/5"],
        ["実施","now",null,"6/5〜6/20"],["報告","todo",null,"6/22"],["請求","todo"],["入金","todo"],["完了","todo"]]),
      vendorCost:250000,
      detail:{ settle:"自費", method:"自費 アーク・月次窓口", payer:"アーク管理", vendor:"青木防水工業（下請）",
        billed:420000, cost:250000, due:null, paid:null,
        visits:[{ id:"SV1", plannedOn:"6/5", kind:"施工", note:"青木防水 601" }] } }],
    reportTo:["アーク管理","シーサイド管理"],
    files:[{ id:"F2460-1", name:"防水改修 見積書", date:"5/26", type:"見積書", sub:"工事", ctx:"601工事", trkId:"TRK-2460-W1", stage:"issued", sharedTo:["アーク管理"] }] }));
  C.push(mk({ id:"CASE-2477", name:"リバーサイド和泉201", accepted:"2026/06/07", client:"及川住宅", dueDone:"07/13", order:"調査",
    initial:"405給湯管から漏水・305台所の天井に滴下",
    spots:[{ kind:"原因", room:"405", detail:"給湯管", owner:"及川住宅", isClient:true },
      { kind:"被害", room:"305", detail:"台所の天井", owner:"及川住宅", isClient:true }],
    surveyTasks:[{ name:"応急止水", state:"done", date:"6/11", memo:"405の元栓を閉止" },
      { name:"原因特定", state:"done", date:"6/11", memo:"405給湯管の継手部から漏水" },
      { name:"被害対応", state:"todo", date:null, memo:"" }],
    visits:[{ id:"TRK-2477-S1", no:1, kind:"調査", vendor:"及川設備", rooms:"405", title:"405 原因調査", status:"報告書作成中", mk:"warn",
      steps:msSteps([["受注","done","6/7"],["日程調整","done","6/9","6/11"],["実施","done","6/11"],["報告","now",null,"6/17"],
        ["見積","todo",null,"6/20"],["社内承認","todo"],["請求","todo"],["入金","todo"],["完了","todo"]]),
      vendorCost:14000,
      detail:{ settle:"自費", method:"自費 及川住宅・月次案件", payer:"及川住宅", vendor:"及川設備", billed:28000, cost:14000, due:null, paid:null } }] }));
  C.push(mk({ id:"CASE-2455", name:"グランドハイツ302", accepted:"2026/05/13", client:"青葉オーナー", dueDone:"07/05", order:"工事",
    initial:"103洗面所の給水管から漏水・003テナント天井に被害",
    spots:[{ kind:"原因", room:"103", detail:"洗面所の給水管", owner:"青葉オーナー", isClient:true },
      { kind:"被害", room:"003", detail:"テナントの天井", owner:"青葉オーナー", isClient:true }],
    works:[{ id:"TRK-2455-W1", room:"103", cause:"洗面所の給水管", work:"給水管補修", contractor:{kind:"self",sub:"確定"},
      state:"now", stall:false, status:"報告待ち", mk:"delay",
      steps:msSteps([["見積","done","5/17"],["社内承認","done","5/18"],["受注","done","5/19"],["日程調整","done","5/21","5/28"],
        ["実施","done","5/28"],["報告","now",null,"6/12"],["請求","todo",null,"6/17"],["入金","todo"],["完了","todo"]]),
      vendorCost:52000,
      detail:{ settle:"自費", method:"自費 青葉・月次案件", payer:"青葉オーナー", vendor:"山田設備（下請）",
        billed:96000, cost:52000, due:"6/30", paid:null,
        visits:[{ id:"SV1", plannedOn:"5/28", kind:"施工", note:"山田設備 103" }] } }],
    reportTo:["青葉オーナー"],
    files:[{ id:"F2455-1", name:"給水管補修 見積書", date:"5/17", type:"見積書", sub:"工事", ctx:"103工事", trkId:"TRK-2455-W1", stage:"issued", sharedTo:["青葉オーナー"] }] }));
  C.push(mk({ id:"CASE-2462", name:"ヴィラ青葉台706", accepted:"2026/05/28", client:"青葉台管理", dueDone:"07/16", order:"調査＋工事",
    initial:"201から101へ漏水・原因未特定",
    spots:[{ kind:"原因", room:"201", detail:"未特定（調査中）", owner:"青葉台管理", isClient:true },
      { kind:"被害", room:"101", detail:"洋室の天井", owner:"青葉台管理", isClient:true }],
    surveyTasks:[{ name:"応急止水", state:"done", date:"6/13", memo:"201の水回りを使用停止" },
      { name:"原因特定", state:"todo", date:null, memo:"" },
      { name:"被害対応", state:"done", date:"6/13", memo:"101天井下に養生" }],
    visits:[{ id:"TRK-2462-S1", no:1, kind:"調査", vendor:"山田設備", rooms:"201・101", title:"201・101 調査", status:"実施中", mk:"",
      steps:msSteps([["受注","done","5/28"],["日程調整","done","6/13","6/18"],["実施","now",null,"6/18"],["報告","todo",null,"6/19"],
        ["見積","todo"],["社内承認","todo"],["請求","todo"],["入金","todo"],["完了","todo"]]),
      vendorCost:16000,
      detail:{ settle:"自費", method:"自費 青葉台・月次窓口", payer:"青葉台管理", vendor:"山田設備", billed:32000, cost:16000, due:null, paid:null } }] }));
  C.push(mk({ id:"CASE-2475", name:"シティタワー武蔵小杉1208", accepted:"2026/06/05", client:"プレミア管理", dueDone:"07/21", order:"調査＋工事",
    initial:"808から708へ漏水・内装復旧は保険で対応",
    spots:[{ kind:"原因", room:"808", detail:"洗濯機の給水ホース", owner:"プレミア管理", isClient:true },
      { kind:"被害", room:"708", detail:"リビングの天井と壁", owner:"プレミア管理", isClient:true }],
    ins:[{ id:"TRK-2475-I1", name:"日新火災", status:"鑑定調整中", mk:"", holder:"プレミア管理（建物）", policy:"施設賠償責任保険",
      target:"708 内装復旧に充当", targetShort:"708内装復旧・¥ 240,000",
      steps:msSteps([["受付","done","6/6"],["申請","done","6/9"],["鑑定","now",null,"6/23"],["認定","todo"],["保険金請求","todo"],["入金","todo"]]),
      detail:{ settle:"日新火災", method:"保険充当", payer:"日新火災", billed:240000, cost:0, approved:null, due:null, paid:null } }],
    reportTo:["プレミア管理"],
    files:[{ id:"F2475-1", name:"保険申請書類", date:"6/9", type:"その他", ctx:"保険", trkId:"TRK-2475-I1", stage:"issued", sharedTo:["日新火災"] }] }));
  C.push(mk({ id:"CASE-2449", name:"ロイヤルコート鶴見501", accepted:"2026/05/05", client:"日成管理", dueDone:"06/13", order:"調査＋工事",
    initial:"402から302へ漏水・天井復旧を保険で対応",
    spots:[{ kind:"原因", room:"402", detail:"給水管の継手", owner:"日成管理", isClient:true },
      { kind:"被害", room:"302", detail:"洋室の天井", owner:"日成管理", isClient:true }],
    ins:[{ id:"TRK-2449-I1", name:"全日本住宅保険", status:"入金済", mk:"", holder:"日成管理（建物）", policy:"施設賠償責任保険",
      target:"302 天井復旧に充当", targetShort:"302天井復旧・¥ 150,000",
      steps:msSteps([["受付","done","5/5"],["申請","done","5/7"],["鑑定","done","5/13"],["認定","done","5/26"],["保険金請求","done","5/28"],["入金","done","6/11"]]),
      detail:{ settle:"全日本住宅保険", method:"保険充当", payer:"全日本住宅保険", billed:150000, cost:88000, approved:150000, due:"6/11", paid:"6/11" } }],
    reportTo:["日成管理"],
    files:[{ id:"F2449-1", name:"天井復旧 請求書", date:"5/28", type:"請求書", ctx:"精算", trkId:"TRK-2449-I1", stage:"issued", sharedTo:["日成管理"] }] }));
  C.push(mk({ id:"CASE-2482", name:"パークサイド和田町105", accepted:"2026/06/11", client:"あおぞら管理", dueDone:"—", order:"調査",
    initial:"105洋室の壁に染み・原因未特定",
    spots:[{ kind:"原因", room:"—", detail:"未特定（調査前）", owner:"あおぞら管理", isClient:true },
      { kind:"被害", room:"105", detail:"洋室の壁", owner:"あおぞら管理", isClient:true }] }));
  C.push(mk({ id:"CASE-2468", name:"サンハイム神田403", accepted:"2026/06/03", client:"神田管理組合", dueDone:"—", order:"調査＋工事",
    initial:"403で漏電・303玄関の天井が崩落のおそれ", propKind:"分譲マンション", type:"分譲",
    spots:[{ kind:"原因", room:"403", detail:"未特定（緊急対応中）", owner:"神田管理組合", isClient:true },
      { kind:"被害", room:"303", detail:"玄関の天井", owner:"神田管理組合", isClient:true }] }));
  C.push(mk({ id:"CASE-2480", name:"コーポ綱島町12", accepted:"2026/06/10", client:"綱島居住者", dueDone:"—", order:"調査",
    initial:"102洋室の天井から滴下・調査日程を調整中",
    spots:[{ kind:"原因", room:"—", detail:"未特定（調査前）", owner:"綱島オーナー", isClient:false },
      { kind:"被害", room:"102", detail:"洋室の天井", owner:"綱島居住者", isClient:true }] }));

  /* TODO（案件に属する。trkId で進行に紐づく／無いものは進行外の単発） */
  const T = [
    ["CASE-2460","社内承認A（見積決裁）","自社","自社","承認","on",null,false,false,"06/19",null,"TRK-2460-W1",null],
    ["CASE-2460","止水前 応急対応","自社","自社","その他","on",null,true,false,"06/16",null,null,null],
    ["CASE-2460","認定確認（着工ゲート）","保険会社","他者","方針確認","warn",null,false,false,"06/23","日新海上",null,null],
    ["CASE-2460","社外承認B（復旧範囲）","依頼者","他者","承認","on",null,false,true,"06/18","シーサイド管理","TRK-2460-W1",null],
    ["CASE-2460","保険金請求（加入者手続）","加入者","他者","連絡","on",null,false,false,"06/24","加入者・山田",null,null],
    ["CASE-2460","調査報告書 提出","自社","自社","報告","warn",null,false,false,"06/17",null,null,null],
    ["CASE-2477","現地調査 日程連絡","自社","自社","連絡","on",null,false,false,"06/18",null,"TRK-2477-S1",null],
    ["CASE-2477","見積作成","業者","他者","見積","on",null,false,false,"06/20","及川設備","TRK-2477-S1","見積"],
    ["CASE-2477","生活支障 緊急対応","自社","自社","その他","on",null,true,false,"06/16",null,null,null],
    ["CASE-2455","施工完了報告 受領","業者","他者","報告","delay","normal",false,false,"06/12","山田設備","TRK-2455-W1","報告"],
    ["CASE-2455","復旧見積 提出","自社","自社","見積","on",null,false,false,"06/19",null,"TRK-2455-W1",null],
    ["CASE-2482","現地調査 実施","自社","自社","その他","on",null,false,false,"06/17",null,null,null],
    ["CASE-2482","見積 先方確認待ち","依頼者","他者","方針確認","on",null,false,false,"06/21","あおぞら管理",null,null],
    ["CASE-2468","漏電・天井崩落 緊急対応","自社","自社","その他","on",null,true,false,"06/16",null,null,null],
    ["CASE-2468","居住者 状況連絡","自社","自社","連絡","on",null,false,false,"06/17",null,null,null],
    ["CASE-2471","保険 認定の催促","保険会社","他者","連絡","delay","normal",false,false,"06/16","東都火災","TRK-2471-I1","認定"],
    ["CASE-2471","復旧見積 業者確認","業者","他者","見積","warn",null,false,false,"06/22","丸建リフォーム","TRK-2471-W2",null],
    ["CASE-2471","402 施工完了報告の受領","業者","他者","報告","on",null,false,false,"06/18","山田設備","TRK-2471-W1","報告"],
    ["CASE-2471","調査費の入金確認","依頼者","他者","入金精算","on",null,false,false,"06/30","コスモ管理","TRK-2471-S1","入金"],
    ["CASE-2449","請求書 送付","自社","自社","入金精算","on",null,false,false,"06/18",null,null,null],
    ["CASE-2449","入金 確認待ち","依頼者","他者","入金精算","on",null,false,false,"06/20","日成管理",null,null],
    ["CASE-2475","復旧範囲 社外承認","依頼者","他者","承認","on",null,false,true,"06/17","プレミア管理",null,null],
    ["CASE-2475","追加調査 実施","自社","自社","その他","on",null,false,false,"06/20",null,null,null],
    ["CASE-2480","現地調査 日程調整","自社","自社","連絡","on",null,false,false,"06/21",null,null,null],
    ["CASE-2462","調査報告書 提出","自社","自社","報告","warn",null,false,false,"06/19",null,"TRK-2462-S1","報告"],
    ["CASE-2462","保険 申請書類 加入者待ち","加入者","他者","連絡","on",null,false,false,"06/26","加入者・佐藤",null,null],
  ];
  /* 起票日：これ以前のファイルはこのTODOの信号にしない */
  const RAISED = {
    "調査費の入金確認":"5/28", "請求書 送付":"5/28", "入金 確認待ち":"5/28",
    "社内承認A（見積決裁）":"5/26", "社外承認B（復旧範囲）":"5/28",
    "施工完了報告 受領":"6/12", "復旧見積 業者確認":"6/13", "見積作成":"6/13",
    "現地調査 日程連絡":"6/9", "402 施工完了報告の受領":"6/14", "保険 認定の催促":"6/15",
  };
  const byId = {};
  C.forEach(c => { c.todos = []; byId[c.id] = c; });
  T.forEach((t,i) => {
    const c = byId[t[0]];
    if(!c) return;
    c.todos.push({ id:"TD"+(i+1), step:t[1], ball:t[2], category:t[3], btype:t[4], marker:t[5], sev:t[6],
      urgent:t[7], watch:t[8], due:t[9], dunning:t[10], trkId:t[11], railStep:t[12], raised:RAISED[t[1]]||MS_TODAY, done:false });
  });
  return { v:5, cases:C, seq:2483 };
}

/* ---- 永続化つきストア ---- */
/* 【ページング1段目】工程レール(stepId)の取得の見届け。MStore.ensureCaseSteps が使う。
   localStorage には載せない ─ 「いまこのタブで取りに行ったか」だけの話で、
   次に開いたときは必ず取り直す(stepId 自体も保存対象外。行231の注記と同じ理由)。
     msStepsFetch … 案件ID → 進行中の取得(同じ案件で二重に飛ばさない)
     msStepsTried … 取得に成功した案件(埋まらない工程を延々と読みに行かない) */
const msStepsFetch = new Map();
const msStepsTried = new Set();
/* 画面の案件 id(CASE-番号)の空きを探す(H1・9/25 検証の指摘)。
   rpc_seed の CASE-番号は「未完了の全件中の位置」(2400 + idx)で振られるので、受付が 1 件増える・
   案件が閉じるたびに同じ案件の番号がずれ、同じ番号が別の案件を指すようになる。画面(開いている案件詳細・
   TODO 詳細・精算画面)は案件を id で持つので、**一度画面に出した案件の id は付け替えない**。
   サーバから新しく入る案件の番号が画面の別の案件と重なるときだけ、ここで重ならない番号を探す。
     want … サーバが振った id(空いていればそのまま使う)
     used … 使えない id(画面にある案件の id と、ここまでに振った id)
     dir  … 重なったときに探す向き。-1 = 小さい番号へ(新しい案件。一覧は番号の小さいほうが新しい)、
            +1 = 大きい番号へ(「もっと見る」・検索で足す古い案件) */
function msFreeCaseId(want, used, dir){
  if (want && !used.has(want)) return want;
  const m = /^CASE-(\d+)$/.exec(String(want || ""));
  const base = m ? Number(m[1]) : 2400;
  if (dir < 0) for (let k = base - 1; k >= 1; k -= 1) if (!used.has("CASE-" + k)) return "CASE-" + k;
  // 大きい番号へ(小さい番号を使い切ったときも。重ねるよりはよい)
  for (let k = base + 1; ; k += 1) if (!used.has("CASE-" + k)) return "CASE-" + k;
}
/* 案件の版 a が b より古いか(9/25 検証の指摘 第 2 回)。版は updated_at の時刻(YYYY-MM-DDTHH:MI:SS.USZ ─
   rpc_seed・rpc_apply_case_changes。版トリガが必ず前へ進める)なので、同じ形どうしなら文字列の大小が新旧になる。
   形が違う・分からないときは「古い」と言わない(従来どおり)。app/write/mstore-adapter.js の isOlderVersion と同じ。 */
const MS_VERSION_TS = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/;
function msOlderVersion(a, b){
  return typeof a === "string" && typeof b === "string" && a.length === b.length
    && MS_VERSION_TS.test(a) && MS_VERSION_TS.test(b) && a < b;
}
const MStore = (function(){
  let state = null, subs = [];
  // 詳細取得(将来の0051)の基準値はメモリだけに置く。MStore.update()は
  // state全体をlocalStorageへ保存するため、案件を開いた時に補っただけの
  // building/partiesまで控えへ戻すと、盤面容量をまた増やしてしまう。
  // 基準値と同じ間は保存から除外し、利用者が編集した差分は従来どおり残す。
  const detailHydrations = new Map();
  const detailRequests = new Map();
  const detailStatus = new Map();
  // 管-12「TODO種別」(0026 todo_types)の文言。案件詳細の個別取得
  // (rpc_case_detail_context・0078)に相乗りで届く**テナントのマスタ**で、
  // 案件ごとの値ではない。画面(Screens.jsx scMicro)は読むだけ・書き込み経路が
  // 無いので、case と同じ state には入れずメモリだけに置く
  // (= localStorage の控えを膨らませない。detailHydrations と同じ考え方)。
  let detailTodoTypes = null;
  // K18-18: 依頼者規定(rpc_case_detail_context の clientRules・0106)。依頼者の**取引先の値**で、
  // 案件の値ではない。案件の state にも localStorage の控えにも写さず、案件IDごとにメモリにだけ置く
  // (写すと取引先を直したときに案件側が古いまま残る ─ docs/21 K18-18)。帯(ClientRulesBand.jsx)が読むだけ。
  const detailClientRules = new Map();
  // K18-20: トラックごとの先方担当(rpc_case_detail_context の trackContacts・0112)。案件ID → 配列。
  // 案件単位の関係者(c.parties = case_actors)とは別に置く: parties は宛先の解決(LINE 0072・contactDbId 0078)と
  // 書き込み翻訳(actor.* / contact.log)の元なので、担当者を混ぜると「案件の関係者」として actor.add に倒れる。
  // 案件の state にも localStorage の控えにも写さずメモリにだけ置く。K-28(0117)の付け替えは TrackContactPicker.jsx が
  // 書き込みキューへ track.contact を直接積み、ここは setTrackContact で控えを差し替えるだけ(差分の翻訳は通らない)。
  const detailTrackContacts = new Map();
  // H1(9/25): 案件をサーバの値で差し替えた回数(案件ID → 回数)。案件詳細・TODO 詳細の読み直し
  // (useEffect)がこれを見て、開いたままでも建物・関係者・工程の stepId などを取り直す。
  const caseRevs = new Map();
  // 建物・関係者の取得で版が合わず、この案件を読み直した時刻(案件ID → ms)。読み直しを繰り返さない。
  const detailHealedAt = new Map();
  let detailReqSeq = 0;
  // いま画面で開いている案件の id(案件詳細・精算画面・TODO 詳細。app.jsx が setViewingCaseIds で知らせる)。
  // 起動時の差し替え(replaceCasesFromSeed)が、直近 N 件から外れた案件でも開いている画面のものは残す。
  let viewingIds = new Set();
  /* 案件を差し替えたら、その案件について開いたときに補った控えを捨てる。
     捨てないと「取得済み」のまま、差し替えた案件に建物・関係者・stepId が無い状態で止まる。 */
  const forgetCaseDetail = caseId => {
    detailStatus.delete(caseId); detailRequests.delete(caseId); detailHydrations.delete(caseId);
    detailClientRules.delete(caseId); detailTrackContacts.delete(caseId);
    msStepsTried.delete(caseId); msStepsFetch.delete(caseId);
    caseRevs.set(caseId, (caseRevs.get(caseId) || 0) + 1);
  };
  const clone = v => v == null ? v : JSON.parse(JSON.stringify(v));
  const sameJson = (a, b) => JSON.stringify(a) === JSON.stringify(b);
  const strip = s => {
    const out = JSON.parse(JSON.stringify(s, (k,v) => k==="url" ? undefined : v));
    for (const c of (out.cases || [])) {
      const base = detailHydrations.get(c.id);
      if (base) {
        if (sameJson(c.building, base.building)) delete c.building;
        if (sameJson(c.parties, base.parties)) delete c.parties;
      }
      // 盤面データの第2案(0075): claimDbId/lineDbId(精算-2・0045)は
      // rpc_case_detail_context の 'claims' が案件を開く/精算画面を開くたびに
      // 合流するだけの内部id ─ 画面のどこにも書き込み経路が無い(mergeClaims
      // 参照)ため、building/partiesのような「利用者が編集したら残す」判定は
      // 要らず、常に控えから外す(次回開いたときに再取得すれば足りる)。
      for (const t of [...(c.visits||[]), ...(c.works||[]), ...(c.ins||[])]) {
        delete t.claimDbId; delete t.lineDbId;
      }
    }
    return out;
  };
  const load = () => {
    // ① 起動時に渡された実データ(index.html の同期取得が window に置く)を最優先で見る。
    //    localStorage は容量上限(盤面データは 11MB 相当)で書けないことがあり、
    //    そこだけを頼りにすると**実データが読めているのにモックで初期化**されてしまう。
    const w = typeof window !== "undefined" ? window.__MARUROU_SEED__ : null;
    if (w && w.v === 5 && Array.isArray(w.cases) && w.cases.length) return w;
    // ② 前回の写し。
    try {
      const raw = localStorage.getItem(MS_KEY);
      if(raw){ const p = JSON.parse(raw); if(p && p.v===5 && Array.isArray(p.cases) && p.cases.length) return p; }
    } catch(e) {}
    // ③ どちらも無ければモック。**この状態で画面を出してはいけない** ─
    //    marurou-boot.js が実データに差し替えるか、差し替えられなければ画面を止める。
    return msSeed();
  };
  const save = () => { try { localStorage.setItem(MS_KEY, JSON.stringify(strip(state))); } catch(e) {} };
  const emit = () => subs.forEach(f => f(state));
  return {
    get(){ if(!state) state = load(); return state; },
    /* 受付で選ぶための名簿(依頼先・担当者)。実データを読めていないときは空。
       空のときは受付画面が自由入力に落ちる ─ 黙って選択肢が消えるのではなく、
       「マスタ未取得」と画面に出す(Intake.jsx)。 */
    masters(){ const s = this.get(); return { orgs: s.orgs || [], users: s.users || [] }; },
    /* 自分が誰か(seedのme)。開発モードやモックでは null。
       **表示の出し分け専用** ─ 管理の実効権限は rpc_admin_* の中で毎回確かめる。 */
    me(){ return this.get().me || null; },
    sub(f){ subs.push(f); return () => { subs = subs.filter(x => x!==f); }; },
    update(fn){ const n = JSON.parse(JSON.stringify(this.get())); fn(n); state = n; save(); emit(); return n; },
    case(id){ return this.get().cases.find(c => c.id===id) || null; },
    updateCase(id, fn){ return this.update(n => { const c = n.cases.find(x => x.id===id); if(c) fn(c); }); },
    setCase(id, next){ return this.update(n => { const i = n.cases.findIndex(x => x.id===id); if(i>=0) n.cases[i] = { ...n.cases[i], ...next }; }); },
    /* 共有：ファイルの sharedTo(表示名)に相手を足す。加えて sharedToActorIds
       (case_actors.id・宛先のID)と shareChannel(使った手段)も持たせる
       ─ この2つが揃って初めて mstore-diff.js の diffFileShares が share.log に
       翻訳できる(0044_file_shares.sql【宿題】)。どちらか無いまま呼ばれても
       表示名だけは今までどおり残す(画面のprint済みの見た目は壊さない)が、
       その場合は保存側が「宛先を選び直してください」と unsupported を出す
       (ここでは推測で埋めない)。 */
  shareFile(caseId, fileId, names, { actorIds, channel } = {}){ return this.updateCase(caseId, c => { const f = c.files.find(x=>x.id===fileId); if(!f) return;
    f.sharedTo = [...new Set([...(f.sharedTo||[]), ...names])];
    if (Array.isArray(actorIds) && actorIds.length) {
      f.sharedToActorIds = [...new Set([...(f.sharedToActorIds||[]), ...actorIds])];
    }
    if (channel) f.shareChannel = channel;
    f.stage = "issued"; }); },
    /**
     * 共有履歴(rpc_list_file_shares)を読み直して案件へ反映する。案件を開いたときに
     * 呼ぶ(CaseDetailV25.jsx。loadCaseSteps→mergeCaseStepsと同じ並び)。
     * **これは利用者の編集ではない** ─ mergeCaseStepsと同じ理由で
     * window.__MARUROU_APPLYING_REMOTE__ を立てる(立てないと、次の保存が
     * この反映まで差分に含めて「宛先を選び直してください」を出す)。
     *
     * ファイルの実体(Storage)はまだ無く(ファイル・帳票-2待ち)、DBのfile_sharesは
     * 「何を」を file_label(自由文。sendShare時の f.type)でしか持たない。ローカルの
     * files[] と結びつけるときは type が一致するものへ反映する(scMicroが書類を
     * type で束ねているのと同じ粒度)。一致する書類が無い行(型が変わった・
     * 別デバイスでまだこの書類を起票していない等)は無理に付けず、
     * c.fileShareLog に生のまま残す(黙って捨てない・05 §5.5)。
     *
     * H7(9/25 本番の不具合): 共有の記録は**自社の書類(発行・下書き)を共有した**事実で、
     * 相手から受け取った書類(受領物・添付 attachments)とは関係が無い。以前は種類が同じなら
     * 受領物まで「確定・共有済み」に書き換えていたため、別の端末(や再読込後)で添付の一覧が
     * 先に届くと、業者から受領した見積書が「確定」になり共有先まで付いた(届く順で結果が変わる)。
     *   ・受領物(stage "received")と実体のある添付(storagePath)は書き換えない
     *   ・記録にトラック(trackId)があれば、そのトラックの書類にだけ付ける(トラックが
     *     この端末で分からないときは付けない ─ 別のトラックの書類に付けるより付けない方が正直)
     *   ・トラックの無い古い記録は、今までどおり種類で突き合わせる(受領物は除く)
     */
    mergeFileShares(caseId, rows){
      if (!Array.isArray(rows)) return this.get();
      const prev = window.__MARUROU_APPLYING_REMOTE__;
      window.__MARUROU_APPLYING_REMOTE__ = true;
      try {
        return this.update(n => {
          const c = n.cases.find(x => x.id===caseId);
          if (!c) return;
          c.fileShareLog = rows;
          const insList = !c.ins ? [] : (Array.isArray(c.ins) ? c.ins : [c.ins]);
          const allTracks = [...(c.visits||[]), ...(c.works||[]), ...insList];
          const localTrkId = (dbTrackId) => {
            const t = allTracks.find(x => x && x.dbId===dbTrackId);
            return t ? t.id : null;
          };
          const ownDoc = x => x && x.stage !== "received" && !x.storagePath;
          for (const r of rows) {
            if (!r || !r.fileLabel) continue;
            const trk = r.trackId ? localTrkId(r.trackId) : null;
            if (r.trackId && !trk) continue;
            const matches = (c.files||[]).filter(x => ownDoc(x) && x.type===r.fileLabel
              && (!r.trackId || x.trkId===trk));
            for (const f of matches) {
              if (r.recipientName) f.sharedTo = [...new Set([...(f.sharedTo||[]), r.recipientName])];
              if (r.caseActorId) f.sharedToActorIds = [...new Set([...(f.sharedToActorIds||[]), r.caseActorId])];
              if (r.channel && !f.shareChannel) f.shareChannel = r.channel;
              if (f.stage !== "issued") f.stage = "issued";
            }
          }
        });
      } finally { window.__MARUROU_APPLYING_REMOTE__ = prev; }
    },
    /**
     * 添付ファイルを1件足す(ファイル・帳票-2)。呼び出し側(CaseDetailV25.jsx)が
     * 先に marurou-cloud.js の uploadAttachment で Storage へ実体を送り終えてから
     * 呼ぶこと ─ meta.storagePath が無いと mstore-diff.js の diffFileAttachments が
     * 「まだ保存できません」に落とす(実体の無いメタデータだけは保存しない)。
     * dbId はまだ無い(サーバの採番待ち。保存が通ったら
     * mstore-adapter.js onResult が書き戻す ─ todo.add と同じ形)。
     * 画面側のid(戻り値)は削除(removeAttachment)のときの引数に使う。
     */
    addAttachment(caseId, meta = {}){
      const id = "ATT-" + Date.now() + "-" + Math.random().toString(36).slice(2, 7);
      this.updateCase(caseId, c => {
        c.files = Array.isArray(c.files) ? c.files : [];
        c.files.push({
          id, dbId: null,
          name: meta.fileName || meta.name || "", type: meta.kind || "その他",
          date: (() => { const d = new Date(); return `${d.getMonth()+1}/${d.getDate()}`; })(),
          storagePath: meta.storagePath || null, mime: meta.mime || null,
          sizeBytes: meta.sizeBytes == null ? null : Number(meta.sizeBytes),
          trkId: meta.trkId || null, stepId: meta.stepId || null, stage: "received",
        });
      });
      return id;
    },
    /**
     * 添付ファイルを1件消す(ファイル・帳票-2・論理削除)。dbId を持つ行は
     * mstore-diff.js の diffFileAttachments が attachment.remove へ翻訳する。
     * dbId がまだ無い行(アップロード直後・保存が往復する前)は、サーバに
     * 何も無いのでローカルから消すだけでよい(diffFileSharesの
     * 「宛先が減っただけ」と同じ判断)。
     */
    removeAttachment(caseId, fileId){
      return this.updateCase(caseId, c => {
        c.files = (c.files || []).filter(f => f.id !== fileId);
      });
    },
    /**
     * 添付ファイル一覧(rpc_list_attachments)を読み直して案件へ反映する
     * (ファイル・帳票-2)。案件を開いたときに呼ぶ(CaseDetailV25.jsx。
     * mergeFileSharesと同じ並び)。**これは利用者の編集ではない** ─ mergeFileShares
     * と同じ理由で window.__MARUROU_APPLYING_REMOTE__ を立てる(立てないと、
     * 次の保存がこの反映まで差分に含めてしまう)。
     *
     * dbId(attachments.id)で突き合わせる ─ 既に画面にある行(このセッション中に
     * 自分でアップロードして dbId が付いた行・前回のmergeで足した行)は上書きせず
     * そのまま、無い行だけ files[] へ追加する(mergeCaseStepsのように「同じ添字」
     * には頼れない ─ 添付は行の対応がIDでしか取れない)。
     */
    mergeAttachments(caseId, rows){
      if (!Array.isArray(rows)) return this.get();
      const prev = window.__MARUROU_APPLYING_REMOTE__;
      window.__MARUROU_APPLYING_REMOTE__ = true;
      try {
        return this.update(n => {
          const c = n.cases.find(x => x.id===caseId);
          if (!c) return;
          c.files = Array.isArray(c.files) ? c.files : [];
          const known = new Set(c.files.map(f => f && f.dbId).filter(Boolean));
          // トラックのdbId(uuid) → 画面内の局所id(TRK-...等)。attachments.track_id は
          // dbId でしか持たないため、files[].trkId(画面の局所id)へ変換する
          // (mergeCaseStepsのtrack突き合わせと同じ考え方)。
          const insList = !c.ins ? [] : (Array.isArray(c.ins) ? c.ins : [c.ins]);
          const allTracks = [...(c.visits||[]), ...(c.works||[]), ...insList];
          const localTrkId = (dbTrackId) => {
            if (!dbTrackId) return null;
            const t = allTracks.find(x => x && x.dbId===dbTrackId);
            return t ? t.id : null;
          };
          // H7: 添付(attachments)は相手から受け取った書類・写真で、いつ読み直しても「受領」。
          // 以前の読み直し(mergeFileShares)が「確定・共有済み」に書き換えた控えが端末に残って
          // いることがあるので、既に画面にある行もここで受領に戻し、付いてしまった共有先を外す
          // (共有の記録そのものは c.fileShareLog に残る ─ 外すのは受領物への誤った付け先だけ)。
          const listed = new Set(rows.filter(r => r && r.id).map(r => r.id));
          for (const f of c.files) {
            if (!f || !f.dbId || !listed.has(f.dbId)) continue;
            if (f.stage !== "received") f.stage = "received";
            delete f.sharedTo; delete f.sharedToActorIds; delete f.shareChannel;
          }
          for (const r of rows) {
            if (!r || !r.id || known.has(r.id)) continue;
            known.add(r.id);
            c.files.push({
              id: "ATT-" + r.id, dbId: r.id,
              name: r.fileName || "", type: r.kind || "その他",
              date: (r.capturedOn || r.createdAt || "").slice(5, 10).replace("-", "/"),
              storagePath: r.storagePath || null, mime: r.mime || null,
              sizeBytes: r.sizeBytes == null ? null : Number(r.sizeBytes),
              trkId: localTrkId(r.trackId), stepId: r.stepId || null, stage: "received",
            });
          }
        });
      } finally { window.__MARUROU_APPLYING_REMOTE__ = prev; }
    },
    /**
     * 帳票の発行・確定を1件記録する(K-7・決-8・0084)。帳票の中身(PDF)は作らない ─
     * 残すのは「いつ・誰に・何を発行したか / いつ確定にしたか」だけ。
     * action は "issued"(発行)か "finalized"(確定)の2語だけ。
     * dbId はまだ無い(サーバの採番待ち。保存が通ったら mstore-adapter.js の
     * onResult が docIssueId を書き戻す ─ addAttachment と同じ形)。
     * 宛先はIDが分かるときだけ actorId を渡す(名前からIDは推測しない)。
     */
    addDocIssue(caseId, meta = {}){
      const id = "DOC-" + Date.now() + "-" + Math.random().toString(36).slice(2, 7);
      const d = new Date();
      this.updateCase(caseId, c => {
        c.docIssues = Array.isArray(c.docIssues) ? c.docIssues : [];
        c.docIssues.unshift({
          id, dbId: null,
          kind: meta.kind || "書類",
          action: meta.action === "finalized" ? "finalized" : "issued",
          to: meta.to || null, actorId: meta.actorId || null,
          note: meta.note || null, trkId: meta.trkId || null,
          at: `${d.getMonth()+1}/${d.getDate()}`,
          by: meta.by || null,
        });
      });
      return id;
    },
    /**
     * 帳票の発行記録(rpc_list_doc_issues)を読み直して案件へ反映する(K-7・0084)。
     * 案件を開いたときに呼ぶ(mergeAttachmentsと同じ並び)。**これは利用者の編集
     * ではない** ─ mergeAttachmentsと同じ理由で window.__MARUROU_APPLYING_REMOTE__
     * を立てる(立てないと、次の保存がこの反映まで差分に含めて同じ行をもう一度
     * doc.log で送ってしまう)。
     * dbId(doc_issues.id)で突き合わせる ─ 既に画面にある行(このセッション中に
     * 自分で記録して dbId が付いた行・前回のmergeで足した行)は足さない。
     */
    mergeDocIssues(caseId, rows){
      if (!Array.isArray(rows)) return this.get();
      const prev = window.__MARUROU_APPLYING_REMOTE__;
      window.__MARUROU_APPLYING_REMOTE__ = true;
      try {
        return this.update(n => {
          const c = n.cases.find(x => x.id===caseId);
          if (!c) return;
          c.docIssues = Array.isArray(c.docIssues) ? c.docIssues : [];
          const known = new Set(c.docIssues.map(d => d && d.dbId).filter(Boolean));
          // トラックのdbId(uuid) → 画面内の局所id。mergeAttachments と同じ考え方。
          const insList = !c.ins ? [] : (Array.isArray(c.ins) ? c.ins : [c.ins]);
          const allTracks = [...(c.visits||[]), ...(c.works||[]), ...insList];
          const localTrkId = (dbTrackId) => {
            if (!dbTrackId) return null;
            const t = allTracks.find(x => x && x.dbId===dbTrackId);
            return t ? t.id : null;
          };
          for (const r of rows) {
            if (!r || !r.id || known.has(r.id)) continue;
            known.add(r.id);
            c.docIssues.push({
              id: "DOC-" + r.id, dbId: r.id,
              kind: r.docKind || "書類",
              action: r.action === "finalized" ? "finalized" : "issued",
              to: r.issuedTo || null, actorId: r.caseActorId || null,
              note: r.note || null, trkId: localTrkId(r.trackId),
              at: (r.issuedAt || "").slice(5, 10).replace("-", "/"),
              by: r.issuedByName || null,
            });
          }
          // 新しい順(サーバの並びと同じ)に揃える。at は表示用の M/D なので、
          // 並び替えの鍵には使わない ─ dbIdを持つ行はサーバの並びで来ており、
          // 楽観行(dbId無し)は必ず今この場で足した最新なので先頭に残す。
          c.docIssues.sort((a, b) => (a.dbId ? 1 : 0) - (b.dbId ? 1 : 0));
        });
      } finally { window.__MARUROU_APPLYING_REMOTE__ = prev; }
    },
    /**
     * 案件詳細の軽量コンテキストを案件を開いた時に反映する契約。
     * 将来の0051は { building, parties } を返す。このデータは一覧seedから
     * 外してよい詳細情報で、取得の基準値はメモリにだけ保持する。
     * そのため remote の反映だけではlocalStorageを膨らませず、反映後に
     * 利用者が編集した building/parties は保存差分として従来どおり残る。
     * 未接続/形の違う応答は成功扱いせず、画面の既存データを維持する。
     */
    mergeCaseDetailContext(caseId, payload){
      if (!payload || typeof payload !== "object") return { applied:false, reason:"unavailable" };
      const hasBuilding = Object.prototype.hasOwnProperty.call(payload, "building");
      const hasParties = Object.prototype.hasOwnProperty.call(payload, "parties");
      if (!hasBuilding || !Array.isArray(payload.parties)) {
        return { applied:false, reason:"unavailable" };
      }
      const c0 = this.case(caseId);
      if (!c0) return { applied:false, reason:"case-not-found" };
      const req = arguments[2] || null;
      const currentReq = detailRequests.get(caseId);
      if (req && (!currentReq || currentReq.id !== req.id)) return { applied:false, reason:"stale" };
      if (req && payload.version && req.version && payload.version !== req.version)
        return { applied:false, reason:"version-conflict" };
      const building = clone(payload.building);
      // parties の各行には phone・contactDbId(0078)も載る。どちらも画面から
      // 編集できない読み取り専用のキーなので、dirty 判定(下)には何も足さない ─
      // 「画面で編集していなければ丸ごと差し替える」という parties の規則のまま、
      // サーバの最新値がそのまま入る。
      const parties = clone(payload.parties);
      // 管-12 TODO種別(0078)。案件ではなくテナントのマスタなので、
      // dirty 判定にも c.parties にも混ぜず、モジュール内の控えへ置くだけ。
      const todoTypesApplied = Array.isArray(payload.todoTypes);
      if (todoTypesApplied) detailTodoTypes = clone(payload.todoTypes);
      // K18-18(0106): 依頼者規定。キーが無い応答(0106 より前の DB)では控えを作らない = 帯は出ない。
      // 依頼者の無い案件は null(帯は出ない)。案件の state には入れない(上の detailClientRules の注記)。
      if (Object.prototype.hasOwnProperty.call(payload, "clientRules")) {
        detailClientRules.set(caseId, payload.clientRules && typeof payload.clientRules === "object"
          ? clone(payload.clientRules) : null);
      }
      // K18-20(0112): トラックごとの先方担当。キーが無い応答(0112 より前の DB)では控えを作らない
      // (= トラック詳細に出さず、TODO 詳細の宛先の初期値も今までどおり)。
      if (Array.isArray(payload.trackContacts)) detailTrackContacts.set(caseId, clone(payload.trackContacts));
      // 盤面データの第2案(0075): claims は [{trackId,claimDbId,lineDbId}] で
      // 届く(rpc_case_detail_context)。trackId(=tracks.id=各トラックのdbId)を
      // キーにしたMapへ変換し、下のupdate内でvisits/works/insの該当トラックへ
      // フラットに合流する(Payment.jsx/mstore-diff.jsが読む形と同じ)。
      const claimsByTrackId = new Map();
      if (Array.isArray(payload.claims)) {
        for (const row of payload.claims) {
          if (row && row.trackId) claimsByTrackId.set(row.trackId, row);
        }
      }
      const base = detailHydrations.get(caseId);
      const snapshot = req && req.snapshot;
      const buildingDirty = base ? !sameJson(c0.building, base.building) : (snapshot && !sameJson(c0.building, snapshot.building));
      const partiesDirty = base ? !sameJson(c0.parties, base.parties) : (snapshot && !sameJson(c0.parties, snapshot.parties));
      if (!buildingDirty && !partiesDirty) detailHydrations.set(caseId, { building:clone(building), parties:clone(parties) });
      const prev = window.__MARUROU_APPLYING_REMOTE__;
      window.__MARUROU_APPLYING_REMOTE__ = true;
      try {
        this.update(n => {
          const c = n.cases.find(x => x.id === caseId);
          if (!c) return;
          if (!buildingDirty) c.building = building;
          if (!partiesDirty) c.parties = parties;
          // claimDbId/lineDbIdは画面に書き込み経路が無い(rg済み)ため、
          // building/partiesのような dirty 判定は挟まず常に最新へ揃える
          // (strip()が毎回控えから外すので、次のhydrateで必ず作り直される)。
          if (claimsByTrackId.size) {
            for (const t of [...(c.visits||[]), ...(c.works||[]), ...(c.ins||[])]) {
              const row = t && t.dbId && claimsByTrackId.get(t.dbId);
              if (!row) continue;
              t.claimDbId = row.claimDbId || null;
              t.lineDbId = row.lineDbId || null;
            }
          }
        });
      } finally { window.__MARUROU_APPLYING_REMOTE__ = prev; }
      return { applied:true, buildingApplied:!buildingDirty, partiesApplied:!partiesDirty,
        claimsApplied: claimsByTrackId.size > 0, todoTypesApplied };
    },
    /* 管-12「TODO種別」(0026 todo_types)の文言。まだ1件も取れていない間は
       空配列を返す ─ 呼び出し側(Screens.jsx scMicro)は「該当codeが無ければ
       固定文言(SC_MICRO)のまま」なので、空でも画面は今までどおり出る。 */
    todoTypes(){ return detailTodoTypes || []; },
    /* K18-18: 案件の依頼者規定(0106 の clientRules)。個別取得がまだ(または 0106 より前の応答)なら
       undefined、依頼者の無い案件は null。{ orgId, name, serviceArea, tradeTerms, firstResponseNotes,
       clientSystemName } を写しで返す(呼び出し側が書き換えても控えは変わらない)。 */
    clientRules(caseId){ return detailClientRules.has(caseId) ? clone(detailClientRules.get(caseId)) : undefined; },
    /* K18-20: トラックごとの先方担当(0112 の trackContacts)。個別取得がまだ(または 0112 より前の応答)なら
       undefined。trackDbId(tracks.id)を渡せばそのトラックの分だけ(無ければ [])。各行は { trackId, side
       ('業者'|'保険会社'), relationId, contactDbId, orgDbId, name, orgName, title, phone }。写しで返す。 */
    trackContacts(caseId, trackDbId){
      if (!detailTrackContacts.has(caseId)) return undefined;
      const all = detailTrackContacts.get(caseId) || [];
      return clone(trackDbId ? all.filter(x => x && x.trackId === trackDbId) : all);
    },
    /* K-28(0117): 先方担当を付け替えた直後の控えの差し替え(保存は TrackContactPicker.jsx が track.contact を積む)。
       (trackDbId, side) の行を rows(行・配列・null=外す)に置き換え、前の行(配列の写し)を返す。保存が残らなかったら
       呼び出し側が返り値で戻す。個別取得がまだ(控えが無い)なら何もせず undefined。 */
    setTrackContact(caseId, trackDbId, side, rows){
      if (!detailTrackContacts.has(caseId) || !trackDbId || !side) return undefined;
      const all = detailTrackContacts.get(caseId) || [];
      const hit = x => x && x.trackId === trackDbId && x.side === side;
      const prev = clone(all.filter(hit));
      const next = rows == null ? [] : (Array.isArray(rows) ? rows : [rows]);
      detailTrackContacts.set(caseId, [...all.filter(x => !hit(x)), ...clone(next)]);
      return prev;
    },
    detailContextStatus(caseId){ return detailStatus.get(caseId) || { status:"idle" }; },
    ensureCaseDetailContext(caseId, dbId){
      if (!caseId || !dbId || !window.MarurouCloud || !window.MarurouCloud.loadCaseDetailContext)
        return Promise.reject(new Error("案件詳細の追加情報を取得できません"));
      const old = detailStatus.get(caseId);
      if (old && old.status === "ready") return Promise.resolve(old);
      if (old && old.status === "loading" && detailRequests.get(caseId)) return detailRequests.get(caseId).promise;
      const c = this.case(caseId);
      // 要求の id は案件をまたいで一意にする(H1)。案件を差し替えると控え(detailRequests/detailStatus)を
      // 捨てるので、案件ごとの連番だと捨てる前の古い要求と新しい要求の id が同じになり、古い要求の失敗が
      // 新しい要求の状態を上書きしてしまう。
      const req = { id:++detailReqSeq, version:c && c.version,
        snapshot:{ building:clone(c && c.building), parties:clone(c && c.parties) } };
      const promise = Promise.resolve().then(() => window.MarurouCloud.loadCaseDetailContext(dbId)).then(async payload => {
        let result = this.mergeCaseDetailContext(caseId, payload, req);
        if (!result.applied && result.reason === "version-conflict") {
          // H1(9/25): 画面の案件の版がサーバと違う。以前はここで「再読込が必要です」のまま止まり、
          // 再試行しても同じ古い版で問い合わせるので直らなかった。直す:
          //   ① 自分の保存がまだ届いていないだけなら、届くのを待つ(届くと版が書き戻る)
          //   ② それでも違えば画面の案件が古い(別の端末で更新された)。この案件だけサーバの値に
          //      読み直してから取り直す。送信待ち(まだ届いていない自分の変更)がある案件は読み直さない。
          const healed = await this.healCaseVersion(caseId, dbId, payload, req);
          if (healed && healed.promise) return healed.promise;
          if (healed && healed.result) result = healed.result;
        }
        if (!result.applied) {
          const error = new Error(result.reason === "version-conflict" ? "案件の版が変わったため再読込が必要です" : "案件詳細の追加情報を反映できません");
          if (detailRequests.get(caseId) && detailRequests.get(caseId).id === req.id) {
            detailRequests.delete(caseId); detailStatus.set(caseId, { status:"error", id:req.id, error });
          }
          throw error;
        }
        detailStatus.set(caseId, { status:"ready", id:req.id, result });
        detailRequests.delete(caseId);
        return result;
      }).catch(err => {
        if (detailRequests.get(caseId) && detailRequests.get(caseId).id === req.id) {
          detailRequests.delete(caseId); detailStatus.set(caseId, { status:"error", id:req.id, error:err });
        }
        throw err;
      });
      detailRequests.set(caseId, { id:req.id, promise });
      detailStatus.set(caseId, { status:"loading", id:req.id });
      return promise;
    },
    /* 建物・関係者の取得で版が合わなかったときの立て直し(上の ensureCaseDetailContext から呼ぶ)。
       戻り値: { result } … 自分の保存を待ったら版が合い、届いた内容をそのまま当てた
               { promise } … 案件をサーバの値に読み直し、取り直した
               null       … 立て直せない(従来どおり「取得できませんでした」) */
    async healCaseVersion(caseId, dbId, payload, req){
      const w = window.MarurouWrite;
      // ① 自分の保存がまだ届いていない(送信中)。届けば版が書き戻る(app/write/mstore-adapter.js)。
      if (w && typeof w.idle === "function" && w.status && w.status().pending > 0) {
        let timer = null;
        await Promise.race([w.idle(), new Promise(r => { timer = setTimeout(r, 10000); })]);
        clearTimeout(timer);
        const now = this.case(caseId);
        if (now && payload && now.version === payload.version) {
          const again = this.mergeCaseDetailContext(caseId, payload, { ...req, version: now.version });
          if (again.applied) return { result: again };
        }
      }
      // ② 画面の案件が古い。短い間に何度も読み直さない(直らないときに往復を繰り返さない)。
      const last = detailHealedAt.get(caseId) || 0;
      if (Date.now() - last < 10000) return null;
      if (w && typeof w.hasPending === "function" && w.hasPending(dbId)) return null;
      // 9/25 検証の指摘(第 2 回): 読み直しの前の版(送信の係・画面の案件)を控える。読み直し(rpc_seed の 1 件)が
      // 返る前に同じ案件への自分の保存が送られて通ると、返ってきた写しはその保存より古い。以前は送信待ちの有無
      // しか見ずに当てたので、画面の値と版が黙って前に戻り、次の保存が「他の人が先に更新」で捨てられた(H1)。
      const mark = () => {
        const k = this.case(caseId);
        const qv = w && w.queue && typeof w.queue.versionOf === "function" ? w.queue.versionOf(dbId) : null;
        return JSON.stringify([qv || null, (k && k.version) || null]);
      };
      const before = mark();
      const fresh = await this.fetchServerCase(dbId).catch(() => null);
      if (!fresh) return null;
      // 待っている間に自分の変更を積んだら、読み直すと画面からその変更が消えるのでやめる
      if (w && typeof w.hasPending === "function" && w.hasPending(dbId)) return null;
      detailHealedAt.set(caseId, Date.now());
      const cur = this.case(caseId);
      if (!cur) return null;
      if (mark() !== before || msOlderVersion(fresh.version, cur.version)) {
        // 読み直しの最中に版が進んだ(自分の保存が通って版が書き戻った)か、写しが画面より古い。写しは当てず、
        // 建物・関係者を今の版で取り直す(今の要求は捨てる)。それでも合わなければ、上の「短い間に何度も
        // 読み直さない」で止まる(従来どおり取得できないと返す)。
        detailRequests.delete(caseId); detailStatus.delete(caseId);
        return { promise: this.ensureCaseDetailContext(caseId, dbId) };
      }
      if (!this.applyServerCase(fresh)) return null;
      // 送信の係が覚えている版(この画面で前に保存したときの版)も読み直した版にそろえる。
      // そろえないと次の保存が古い版で送られ、読み直した直後なのに「他の人が先に更新」になる。
      if (w && w.queue && typeof w.queue.setVersion === "function") w.queue.setVersion(dbId, fresh.version || null);
      return { promise: this.ensureCaseDetailContext(caseId, dbId) };
    },
    /* サーバから案件 1 件を取る(rpc_seed の 1 件指定。ensureCaseLoaded・409 の読み直しと同じ経路)。 */
    async fetchServerCase(dbId){
      if (!dbId) return null;
      let seed;
      if (window.MarurouCloud) {
        seed = await window.MarurouCloud.loadSeed({ caseId:dbId });
      } else {
        const res = await fetch("/api/seed?case=" + encodeURIComponent(dbId), { cache:"no-store" });
        if (!res.ok) return null;
        seed = await res.json();
      }
      return ((seed && seed.cases) || []).find(c => c && c.dbId === dbId) || null;
    },
    /* H1: この案件をサーバの値で差し替えた回数。案件詳細・TODO 詳細の useEffect の依存に入れると、
       差し替えのあと開いたままでも建物・関係者・工程の stepId などを取り直す。 */
    caseRev(caseId){ return caseRevs.get(caseId) || 0; },
    /* いま画面で開いている案件の id を知らせる(app.jsx)。起動時の差し替えで開いている画面を消さないため。 */
    setViewingCaseIds(ids){ viewingIds = new Set((ids || []).filter(Boolean)); },
    /**
     * サーバの案件(rpc_seed の 1 件)で画面の案件を差し替える(H1・9/25)。
     * 409 の読み直し(app/write/mstore-adapter.js)・建物関係者の取得で版が合わないときに使う。
     *  ・突き合わせは dbId。画面の id は**今のまま**残す(CASE-番号は全件中の位置で振られるので、
     *    サーバの 1 件の id は画面の別の案件の id と重なることがある。開いている画面・送信待ちも壊さない)
     *  ・開いたときに補った控え(建物・関係者の基準・依頼者規定・先方担当・工程の stepId 取得済み)は捨て、
     *    caseRev を進めて取り直させる
     *  ・利用者の編集ではないので __MARUROU_APPLYING_REMOTE__ を立てる(差分に拾わせない)
     * 画面に無い案件は足さない(null を返す)。
     */
    applyServerCase(fresh){
      if (!fresh || !fresh.dbId) return null;
      const at = (this.get().cases || []).find(c => c && c.dbId === fresh.dbId);
      if (!at) return null;
      forgetCaseDetail(at.id);
      const prev = window.__MARUROU_APPLYING_REMOTE__;
      window.__MARUROU_APPLYING_REMOTE__ = true;
      try {
        return this.update(n => {
          const i = n.cases.findIndex(c => c && c.id === at.id);
          if (i >= 0) n.cases[i] = { ...clone(fresh), id: at.id };
        });
      } finally { window.__MARUROU_APPLYING_REMOTE__ = prev; }
    },
    /**
     * 起動時(app/ui/marurou-boot.js)に、読み込んだ直近 N 件で画面の案件を差し替える(H1・9/25)。
     *
     * 以前は「案件の件数が同じなら差し替えない」作りで、前回の控え(localStorage)をそのまま使っていた。
     * 保存が通った版を控えに書き戻していなかったこともあり、再読込のあと古い版で送って
     * 「他の人が先に更新」で捨てられ、別の端末で直した値も見えなかった。**件数に関係なく**サーバの値にする。
     *  ・keepDbIds の案件(送信待ちがある・この画面を開いてから保存した)は画面の値を残す。サーバの値は
     *    その変更を含まない古い写しのことがあり、差し替えると送った・送る変更が画面から消える。
     *  ・**画面にすでにある案件(dbId が一致)の id は付け替えない**(9/25 検証の指摘)。CASE-番号は全件中の
     *    位置で振られるので、受付が 1 件増えるだけで全件の番号が 1 つずれる。差し替えの前に控えから開いた
     *    案件詳細・TODO 詳細・精算画面は案件を id で持っているので、付け替えると別の案件にすり替わる
     *    (そのまま入力すると別の案件を直してしまう)。applyServerCase と同じ方針。
     *    サーバから新しく入る案件は、番号が画面の案件(差し替えの前にあったものすべて)と重なるときだけ、
     *    重ならない番号を振る(msFreeCaseId)。
     *  ・直近 N 件の外の案件は、keepDbIds のものと、いま画面で開いている案件(setViewingCaseIds)だけ残す
     *    (ほかは開くときに ensureCaseLoaded が取り直す。開いている画面を消さない)。
     *  ・差し替えた案件は、開いたときに補った控えを捨てて取り直させる。
     * @returns {{replaced:number, kept:number}}
     */
    replaceCasesFromSeed(seedCases, opts){
      const keep = new Set((opts && opts.keepDbIds) || []);
      const cur = this.get().cases || [];
      const byDb = new Map(cur.filter(c => c && c.dbId).map(c => [c.dbId, c]));
      // 差し替えの前に画面にあった(実データの)案件の id。その案件以外には振らない ─ 画面から外れる案件の
      // id も、開いている画面が指したままのことがあるので使わない。モック(dbId が無い)は対象外。
      const used = new Set(cur.filter(c => c && c.dbId && c.id).map(c => c.id));
      const next = [], seen = new Set(), touched = new Set(), fresh = [];
      let replaced = 0, kept = 0;
      for (const sc of seedCases || []) {
        if (!sc) continue;
        if (sc.dbId) { if (seen.has(sc.dbId)) continue; seen.add(sc.dbId); }
        const lc = sc.dbId ? byDb.get(sc.dbId) : null;
        if (lc && keep.has(sc.dbId)) {
          kept += 1;
          next.push(lc);
        } else if (lc) {
          replaced += 1;
          next.push(lc.id === sc.id ? sc : { ...sc, id: lc.id });
          touched.add(lc.id);
        } else {
          replaced += 1;
          fresh.push(next.length);
          next.push(sc);
        }
      }
      // 新しく入る案件の id。後ろ(古い案件)から振る: 重なったら小さい番号へ探すので、前(新しい案件)ほど
      // 小さい番号になり、番号順の並び(盤面)が崩れない。
      for (let i = fresh.length - 1; i >= 0; i -= 1) {
        const k = fresh[i], sc = next[k];
        const id = msFreeCaseId(sc.id, used, -1);
        used.add(id);
        if (id !== sc.id) next[k] = { ...sc, id };
        touched.add(id);
      }
      const ids = new Set(next.map(c => c.id));
      for (const lc of cur) {
        if (!lc || !lc.dbId || seen.has(lc.dbId) || ids.has(lc.id)) continue;
        if (!keep.has(lc.dbId) && !viewingIds.has(lc.id)) continue;
        kept += 1; next.push(lc); ids.add(lc.id);
      }
      for (const id of touched) forgetCaseDetail(id);
      const prev = window.__MARUROU_APPLYING_REMOTE__;
      window.__MARUROU_APPLYING_REMOTE__ = true;
      try { this.update(n => { n.cases = next; }); }
      finally { window.__MARUROU_APPLYING_REMOTE__ = prev; }
      return { replaced, kept };
    },
    /**
     * 調査チェックリスト(応急止水/原因特定/被害対応)を案件を開いた時に反映する。
     * 案件を開いたときに呼ぶ(CaseDetailV25.jsx。loadCaseSteps→mergeCaseSteps・
     * listFileShares→mergeFileSharesと同じ並び)。rpc_case_survey_tasks(0060)は
     * 行が無い項目を返さない(=未着手)ので、対応する項目だけを上書きし、
     * 3件の並び・個数は変えない(store.jsx/Intake.jsxの既定と同じ形を保つ)。
     * **これは利用者の編集ではない** ─ mergeCaseSteps/mergeFileSharesと同じ理由で
     * window.__MARUROU_APPLYING_REMOTE__ を立てる(立てないと、次の保存がこの
     * 反映まで差分に含めて survey.update を送ってしまう)。
     */
    mergeSurveyTasks(caseId, rows){
      if (!Array.isArray(rows) || !rows.length) return this.get();
      const NAME_BY_KEY = { stop_water: "応急止水", find_cause: "原因特定", damage: "被害対応" };
      // YYYY-MM-DD → 画面の日付表記(M/D)。uiDateToIso が読める形に揃える
      // (mergeCaseStepsのisoToMdと同じ規則)。
      const isoToMd = (iso) => {
        if (iso == null || iso === "") return null;
        const m = /^(\d{4})-(\d{1,2})-(\d{1,2})/.exec(String(iso).trim());
        if (!m) return String(iso).trim();
        // K18-49: M/D だと別の年に読まれる日だけ YYYY/M/D(規則は FormParts.jsx mrDateToUi = uiDateToIso と同じ)。
        return window.mrDateToUi ? window.mrDateToUi(new Date(+m[1], +m[2] - 1, +m[3])) : Number(m[2]) + "/" + Number(m[3]);
      };
      const prev = window.__MARUROU_APPLYING_REMOTE__;
      window.__MARUROU_APPLYING_REMOTE__ = true;
      try {
        return this.update(n => {
          const c = n.cases.find(x => x.id === caseId);
          if (!c || !Array.isArray(c.surveyTasks)) return;
          for (const r of rows) {
            const name = NAME_BY_KEY[r && r.key];
            const t = name && c.surveyTasks.find(x => x.name === name);
            if (!t) continue;
            t.state = r.state || "todo";
            t.date = isoToMd(r.doneOn);
            t.memo = r.memo || "";
          }
        });
      } finally { window.__MARUROU_APPLYING_REMOTE__ = prev; }
    },
    /**
     * 案件対象部屋(case_target_rooms)の構造化列(原因種別・立入方法・鍵情報・
     * 居住可否・備考)を案件を開いた時に反映する(盤面DB-2・0063・
     * rpc_case_target_rooms)。rpc_seed/server.pyのspots(kind/room/detail/isLivable)
     * はそのままにし、対応する既存の d.spots 行へ causeKind/entryMethod/keyInfo/note
     * だけを足す(mergeSurveyTasksと同じ「対応する項目だけ上書きし、並び・個数は
     * 変えない」考え方)。spotsにはdbIdが無いため、突き合わせは(room, stance)の
     * 自然キー(0029のspots.updateと同じ)。
     * **これは利用者の編集ではない** ─ 他のmerge*と同じ理由で
     * window.__MARUROU_APPLYING_REMOTE__を立てる(立てないと、次の保存がこの
     * 反映まで差分に含めてspots.updateを送ってしまう)。
     */
    mergeCaseTargetRoomDetails(caseId, rows){
      if (!Array.isArray(rows) || !rows.length) return this.get();
      const prev = window.__MARUROU_APPLYING_REMOTE__;
      window.__MARUROU_APPLYING_REMOTE__ = true;
      try {
        return this.update(n => {
          const c = n.cases.find(x => x.id === caseId);
          if (!c || !Array.isArray(c.spots)) return;
          for (const r of rows) {
            if (!r) continue;
            const room = r.room || "";
            const s = c.spots.find(x => x.room === room && x.kind === r.stance);
            if (!s) continue;
            s.causeKind = r.causeKind || "";
            s.entryMethod = r.entryMethod || "";
            s.keyInfo = r.keyInfo || "";
            s.note = r.note || "";
          }
        });
      } finally { window.__MARUROU_APPLYING_REMOTE__ = prev; }
    },
    /**
     * 訪問予定・実績一覧(rpc_track_visits)を案件を開いたときに読み直して
     * 各トラックのdetail.visitsへ反映する(精算-10・0073)。案件を開いたときに呼ぶ
     * (CaseDetailV25.jsx。mergeAttachmentsと同じ並び)。**これは利用者の編集ではない**
     * ─ mergeAttachmentsと同じ理由で window.__MARUROU_APPLYING_REMOTE__ を立てる
     * (立てないと、次の保存がこの反映まで差分に含めてvisit.planを送ってしまう)。
     *
     * dbId(site_visits.id)で突き合わせる ─ 既に画面にある行(このセッション中に
     * 自分で追加してdbIdが付いた行・前回のmergeで足した行)は上書きせず、無い行だけ
     * 対応するトラックのdetail.visitsへ追加する(mergeAttachmentsのfiles[]突き合わせと
     * 同じ考え方)。行の trackId(dbId・uuid)→画面内の局所トラックidの変換は
     * mergeAttachmentsのlocalTrkIdと同型。
     */
    mergeTrackVisits(caseId, rows){
      if (!Array.isArray(rows) || !rows.length) return this.get();
      const prev = window.__MARUROU_APPLYING_REMOTE__;
      window.__MARUROU_APPLYING_REMOTE__ = true;
      try {
        return this.update(n => {
          const c = n.cases.find(x => x.id === caseId);
          if (!c) return;
          const insList = !c.ins ? [] : (Array.isArray(c.ins) ? c.ins : [c.ins]);
          const allTracks = [...(c.visits||[]), ...(c.works||[]), ...insList];
          const byTrack = new Map();
          for (const r of rows) {
            if (!r || !r.id || !r.trackId) continue;
            if (!byTrack.has(r.trackId)) byTrack.set(r.trackId, []);
            byTrack.get(r.trackId).push(r);
          }
          for (const t of allTracks) {
            if (!t || !t.dbId) continue;
            const list = byTrack.get(t.dbId);
            if (!list) continue;
            t.detail = t.detail || {};
            t.detail.visits = Array.isArray(t.detail.visits) ? t.detail.visits : [];
            const known = new Set(t.detail.visits.map(v => v && v.dbId).filter(Boolean));
            for (const r of list) {
              if (known.has(r.id)) continue;
              known.add(r.id);
              t.detail.visits.push({
                id: "SV-" + r.id, dbId: r.id,
                plannedOn: r.plannedOn || null, kind: r.kind || "", note: r.note || "",
                status: r.status || "planned", visitedOn: r.visitedOn || null,
              });
            }
          }
        });
      } finally { window.__MARUROU_APPLYING_REMOTE__ = prev; }
    },
  /* 受付。**既存の案件と同じ形の案件を作る**のがここの仕事。

     以前は accepted と spots を埋めていなかった。どちらも画面が無条件に読む:
       ・caseMeta() が c.accepted.slice(5) → 受け付けた瞬間に例外
       ・案件詳細が d.spots.map(...)       → 開いた瞬間に例外
     しかも caseMeta が落ちるのは update() → emit() → msSync() の中なので、
     例外が addCase の呼び出し元まで飛び、**受付画面ごと止まる**。
     「名前だけ先に登録して後から埋める」は現場で普通に起きるので、
     既定値は seed が返す案件のキーを全部埋める(欠けたキーを作らない)。 */
  addCase(payload){
      let id = null;
      const d = new Date(), p2 = x => String(x).padStart(2,"0");
      const today = d.getFullYear()+"/"+p2(d.getMonth()+1)+"/"+p2(d.getDate());
      this.update(n => {
        // 起動のたびに seq はサーバの値(9000)に戻るが、画面の案件の id は付け替えない(H1)ので、
        // 前に受付した案件が CASE-9000 のまま残っていることがある。重ならない番号を振る。
        id = msFreeCaseId("CASE-" + n.seq, new Set((n.cases || []).map(c => c && c.id)), 1);
        n.seq = Number(id.slice(5)) + 1;
        n.cases.unshift({ name:"（名称未設定）", accepted:today, type:"賃貸", propKind:"賃貸マンション",
          order:"調査＋工事", owner:"立石（川野チーム）", mode:"日程調整", client:"未登録",
          initial:"", clientNote:"", slackUrl:null, driveUrl:null, building:null,
          // K18-02(0093/0094): 重要事項・状況・次回連絡事項。rpc_seed と同じキー・同じ空表現 ""
          // (forms.test.js が「実データの案件と同じキー」を見張る)。
          keyNotes:"", situation:"", nextContactNote:"",
          // K18-13(0105): 漏水状況。受付では未設定(rpc_seed と同じキー・同じ空表現 "")。
          leakStatus:"",
          // K18-17(0110): 受付チャネル。受付(Intake.jsx)は payload で必ず上書きする。名前だけの
          // addCase でも rpc_seed と同じキー・同じ空表現 ""(空は case.create に送らない ─ INTAKE_MAP)。
          intakeChannel:"",
          // 決-21(0050): 副担当・営業担当。受付では未設定(seed と同じキー・同じ空表現「—」。forms.test.js が見張る)
          sub:"—", subDbId:null, sales:"—", salesDbId:null,
          // 盤面・ダッシュボード-12(0049・決-20)。受け付けたばかりの案件は工程も
          // 関係者もまだ無いので、どちらも超過しようがない ─ 素直にnull。
          planOverDays:null, reportOverDays:null,
          // K18-05(0098): 案件の完了の導出。受け付けたばかりの案件はトラックが無いので、どちらも false
          // (rpc_seed と同じキー。forms.test.js が「実データの案件と同じキー」を見張る)。
          derivedDone:false, derivedEnded:false,
          closed:false, spots:[], surveyTasks:[], visits:[], works:[], ins:null, files:[], parties:[],
          todos:[], dueDone:"—", reportTo:[], ...payload, id });
      });
      return id;
    },
    reset(){ msStepsFetch.clear(); msStepsTried.clear(); state = msSeed(); save(); emit(); return state; },

    /**
     * その案件の工程レールが **DBの工程行(track_steps)と結び付いているか**。
     * 【ページング1段目】盤面データから stepId を外した(0030_rail_paging.sql)ため、
     * 案件を開いた直後のレールは stepId を持っていない ─ rpc_case_steps() が
     * 届いて mergeCaseSteps が走るまでの数百ms〜数秒、レールは「画面には出るが
     * DBのどの行か分からない」状態にある。
     * dbId を持つトラックの工程が1つでも stepId を欠いていれば false。
     * (工程が0本のトラック・dbIdがまだ無いトラック(＋追加の送信前)は対象外 ─
     *  結び付ける相手がそもそも無いので、待っても埋まらない)
     */
    caseStepsBound(caseId){
      const c = this.get().cases.find(x => x.id === caseId);
      if (!c) return false;
      const insList = !c.ins ? [] : (Array.isArray(c.ins) ? c.ins : [c.ins]);
      for (const t of [...(c.works||[]), ...(c.visits||[]), ...insList]) {
        if (!t || !t.dbId || !Array.isArray(t.steps) || !t.steps.length) continue;
        if (t.steps.some(s => s && !s.stepId)) return false;
      }
      return true;
    },

    /**
     * 工程レールが結び付くまで待てるようにする(【ページング1段目】の見届け)。
     *
     * 【なぜ要るのか ─ 2026-09-07 実測】
     * rpc_case_steps() は案件を開いた**後**に届く。届く前に工程を完了にすると
     * stepId が無く、書き込み層(app/write/mstore-diff.js の diffSteps)が
     * 「工程『◯◯』はDBの工程行と結び付いていないため保存できません」として
     * 捨てる ─ **画面は完了に見えるのにDBには残らない**。
     * rpc_case_steps を8秒遅らせて完了を押すと、E2E P0-2 の失敗
     * (unsupported: steps.◯◯ / status / state / stall)がそのまま出る。
     * 速い機械では素通りし、遅い回線・大きい案件・実行順で落ちる ─ だから
     * 「案件を開いたら1回読む」だけでは足りず、**工程を触る直前に待てる**
     * 必要がある(呼び出し側は CaseDetailV25.jsx の awaitStepIds)。
     *
     * ・同じ案件の要求は1本にまとめる(モーダルを開く/押す で二重に飛ばさない)
     * ・既に結び付いていれば通信しない(ふつうはここで即座に返る)
     * ・一度成功したのにまだ埋まらない工程は、DB側に行が無いということなので
     *   繰り返し読みに行かない(呼び出し側は従来どおり進み、書き込み層が理由を出す)
     * ・失敗したときは覚えない ─ 次に触ったときにもう一度取りに行く
     *
     * @returns {Promise<boolean>} 結び付いた状態で返せたか
     */
    ensureCaseSteps(caseId, dbId){
      if (this.caseStepsBound(caseId)) return Promise.resolve(true);
      const inflight = msStepsFetch.get(caseId);
      if (inflight) return inflight;
      const c = this.get().cases.find(x => x.id === caseId);
      const id = dbId || (c && c.dbId);
      const cloud = window.MarurouCloud;
      // 開発モード(Supabase未接続)・案件がまだDBに無い・取得済みで埋まらない
      // ものは、待っても変わらない。呼び出し側を止めない。
      if (!id || !cloud || !cloud.loadCaseSteps || msStepsTried.has(caseId)) return Promise.resolve(false);
      const p = cloud.loadCaseSteps(id).then(stepsByTrack => {
        msStepsFetch.delete(caseId);
        msStepsTried.add(caseId);
        MStore.mergeCaseSteps(caseId, stepsByTrack || {});
        return MStore.caseStepsBound(caseId);
      }).catch(err => {
        msStepsFetch.delete(caseId);
        throw err;
      });
      msStepsFetch.set(caseId, p);
      return p;
    },
    /* トラックを増やした(＋調査/工事/保険)ときは、いま覚えている「取得済み」を捨てる。
       新しいトラックの工程行はその取得より後にDBへできたので、覚えたままだと
       二度と取りに行かない。ふつうは track.create の応答から書き戻す
       (app/write/mstore-adapter.js の onApplied)ので通信は起きないが、
       書き戻しが届かなかったときの取り直し口をここに残す。 */
    invalidateCaseSteps(caseId){ msStepsTried.delete(caseId); msStepsFetch.delete(caseId); },
    /**
     * 案件を開いたときに rpc_case_steps() で補った工程の残り項目
     * (stepId/factKey/gate/skipReason + 0036 assigneeUserId/needsAssignee
     *  + 0038 plannedOn + 0065 waitingReason/waitingSince)を、その案件の
     * トラックへ書き戻す(0030_rail_paging.sql【ページング1段目】)。
     * stepsByTrack は {trackId(dbId): [{n, stepId, ...}, ...]}。
     * 配列の並び(step_no順)は rpc_seed の rail と rpc_case_steps で揃えてあるので、
     * **同じ添字**でマージする(工程名の一致には頼らない ─ この経路はまだ
     * factKeyを持っていないので名前一致が使えない。05 §5.3と同じ理由の裏返し)。
     * 該当が無い・形が崩れているトラック/工程は黙って読み飛ばす
     * (実データ表示は止めない。呼び出し側が失敗を警告として出す)。
     */
    mergeCaseSteps(caseId, stepsByTrack){
      if (!stepsByTrack) return this.get();
      // **これは利用者の編集ではない。** 盤面データから外した項目(0030 rpc_case_steps)を
      // 案件を開いたときに補うだけ。印を立てないと書き込み層が差分として拾い、
      // 全工程が「見送り」に見えて「保存できません」の警告が並ぶ(2026-09-04 の E2E P0-4 が検出)。
      // 起動時の差し替え(marurou-boot.js)と同じ扱いにする。
      const prev = window.__MARUROU_APPLYING_REMOTE__;
      window.__MARUROU_APPLYING_REMOTE__ = true;
      try {
      return this.update(n => {
        const c = n.cases.find(x => x.id===caseId);
        if (!c) return;
        const insList = !c.ins ? [] : (Array.isArray(c.ins) ? c.ins : [c.ins]);
        // YYYY-MM-DD → 画面の予定日表記(M/D)。uiDateToIso が読める形に揃える。
        const isoToMd = (iso) => {
          if (iso == null || iso === "") return null;
          const m = /^(\d{4})-(\d{1,2})-(\d{1,2})/.exec(String(iso).trim());
          if (!m) return String(iso).trim();
          // K18-49: 半年より先・去年の秋冬の日は M/D だと別の年に読まれ、そのまま保存し直すと 1 年ずれる。
          // その日だけ YYYY/M/D にする(規則は FormParts.jsx mrDateToUi = uiDateToIso と同じ)。
          return window.mrDateToUi ? window.mrDateToUi(new Date(+m[1], +m[2] - 1, +m[3])) : Number(m[2]) + "/" + Number(m[3]);
        };
        for (const t of [...(c.works||[]), ...(c.visits||[]), ...insList]) {
          const extra = t && t.dbId && stepsByTrack[t.dbId];
          if (!extra || !Array.isArray(extra) || !Array.isArray(t.steps)) continue;
          t.steps.forEach((s, i) => {
            const e = extra[i];
            if (!s || !e) return;
            s.stepId = e.stepId ?? null;
            s.factKey = e.factKey ?? null;
            s.gate = !!e.gate;
            s.skipReason = e.skipReason ?? null;
            // 0036: 工程の担当。決-10 の needsAssignee はサーバ生成列のまま写す。
            s.assigneeUserId = e.assigneeUserId ?? null;
            s.needsAssignee = !!e.needsAssignee;
            // 0038: 予定日(planned_on)。UIは plan、RPCは plannedOn。
            // due_on(自動計算の期限)とは別 ─ 混同すると滞留判定が壊れる。
            s.plannedOn = e.plannedOn ?? null;
            s.plan = isoToMd(e.plannedOn);
            // 0065(案件詳細-16): 相手待ち。waitingSince(いつから)の非NULLが
            // 「いま相手待ちか」の唯一の信号(supabase/migrations/0065冒頭の
            // 設計判断1)。完了・打ち切り済みの工程を「待ち」に化けさせない
            // (dRecalc/msRailはs==="done"/"skip"を優先して見るため実害は無いが、
            // 二重の意味を持たせないためここでも明示的にガードする)。
            s.waitingReason = e.waitingReason ?? null;
            s.waitingSince = isoToMd(e.waitingSince);
            if (e.waitingSince && s.s !== "done" && s.s !== "skip") s.s = "waiting";
          });
        }
      });
      } finally { window.__MARUROU_APPLYING_REMOTE__ = prev; }
    },
  };
})();

function useMStore(){
  const [s, setS] = React.useState(MStore.get());
  React.useEffect(() => MStore.sub(setS), []);
  return s;
}

/* ---- adapter：画面が従来どおりの行形で受け取る ---- */
/* 工程(steps)が無いトラックで落ちないようにする。

   これらは msSync 経由で update() のたびに走るので、**トラック1本の形が違うだけで
   例外が編集した本人まで飛び、アプリごと止まる**。工程がまだ1つも無いトラック
   (受注直後・移行の取りこぼし)は普通に在りうるので、無ければ「工程が無い」として
   扱う ─ 画面には「完了ではない」トラックとして出る。 */
const msSteps_ = t => (t && Array.isArray(t.steps)) ? t.steps : [];
/* 入金額・請求額の引数を読む(recordPayment / setBilled)。空は blank(入金額は 0・請求額は null)。
   以前は文字から数字以外を消してから読んでいた(H3・9/25: -5000 → 5000・12.5 → 125)。
   画面(CaseEdit.jsx の EdMoney)は読み終えた数値を渡すので、文字で来たものは直さずにそのまま読み、
   読めない・負・1 円単位でないものは投げる(黙って別の金額にしない)。 */
function msMoneyArg_(v, label, blank){
  if(v===null || v===undefined || v==="") return blank;
  const t = String(v).trim();
  const n = typeof v==="number" ? v : (/^-?\d+(\.\d+)?$/.test(t) ? Number(t) : NaN);
  if(!Number.isFinite(n)) throw new Error(label+"が数字として読めません");
  if(n < 0) throw new Error(label+"に負の数は入れられません");
  if(!Number.isInteger(n)) throw new Error(label+"は1円単位の整数で入力してください");
  return n;
}
const msAllDone = t => { const s = msSteps_(t); return s.length>0 && s.every(x => x.s==="done" || x.s==="skip"); };
const msOpen = t => msSteps_(t).find(s => s.s!=="done" && s.s!=="skip");
const msStage = c => {
  if(c.closed) return "完了";
  const works = c.works||[], visits = c.visits||[];
  if(works.length && works.every(msAllDone) && visits.every(msAllDone)) return "完了";
  if(works.some(w => msSteps_(w).some(s => /受注/.test(s.n) && s.s==="done"))) return "工事";
  if(visits.length) return "調査";
  return "受付";
};
/* 保険(ins)は1案件1本のつもりだったが、読み取り側が配列で返すことがある
   (1案件に複数の保険金請求が付く)。どちらの形でも受けられるようにしておく ─
   ここで形を決め打ちすると、読み取り側が変わった日にアプリ全体が止まる。 */
const msIns = c => !c.ins ? [] : (Array.isArray(c.ins) ? c.ins : [c.ins]);
const msTracks = c => [
  ...(c.visits||[]).map(t => ({ t, kind:"survey" })),
  ...(c.works||[]).map(t => ({ t, kind:"work" })),
  ...msIns(c).map(t => ({ t, kind:"ins" })),
];
/* ==== トラックの状態(K18-06・0092/0096/0098) ====
   rpc_seed(0098)は各トラック(visit/work/ins)に trackStatus(進行中|保留|終了)/ outcome / onHold /
   holdReason / resumePlannedOn / endReason / endedOn を載せる。画面(CaseDetailV25 act.trackState)も
   同じ 7 キーを書き換え、mstore-diff.js が track.hold / track.end / track.resume に翻訳する。
   進行中(または未取得)は「状態の帯」を出さない。SF フェーズ(t.status・legacy_phase)は
   補助表示に落とす(列は残す)。 */
/* 保留・終了のときだけ状態の組を返す(進行中・未取得は null)。 */
const msTstate = t => (t && (t.trackStatus === "保留" || t.trackStatus === "終了"))
  ? { status: t.trackStatus, outcome: t.outcome || null, holdReason: t.holdReason || null,
      resumePlannedOn: t.resumePlannedOn || null, endReason: t.endReason || null, endedOn: t.endedOn || null }
  : null;
const msMd = iso => (iso && /^\d{4}-\d{2}-\d{2}$/.test(iso)) ? iso.slice(5).replace("-", "/") : (iso || "");
/* 進行表・進行一覧の短い札。保留=再開予定日 / 終了=終了結果。 */
const msTstateLabel = t => {
  const ts = msTstate(t);
  if (!ts) return null;
  if (ts.status === "保留") return "保留" + (ts.resumePlannedOn ? "（再開 " + msMd(ts.resumePlannedOn) + "）" : "");
  return "終了・" + (ts.outcome || "");
};
const msRail = t => msSteps_(t).map(s => [s.n, s.s, s.date || (s.s==="waiting" ? (s.plan?("〜"+s.plan):"待ち") : s.s==="now" && s.plan ? s.plan : "")]);

/* ==== 盤面・ダッシュボード-12(0049・決-20): SLA監視「画面の色とキュー」 ====
   施主判断 決-20: 予定日(planned_on)超過・報告リズム超過を赤/黄で出し、
   「遅れている」一覧に並べる。Slack通知は含めない。

   判定(赤=delay/黄=warn)は**ここに1か所へ集約する**(依頼どおり)。
   rpc_seed/server.pyは案件ごとに signed int を2つ返すだけ(判定はしない):
     planOverDays   = 今日 − (未完了・非スキップ工程でいちばん近い/過ぎているplanned_on)
     reportOverDays = 取引先の報告リズム別の超過日数(決-24・0083 → 決-36・0088)。
                      「密に」・**リズム未設定**・その他の語彙 = 今日 − 最終報告日 /
                      「節目ごと」= 工程完了後に報告が無ければ黄の帯に収めた日数 /
                      「随時」= null。
   どちらも「該当が無ければnull」(未着手・一度も報告していない、を遅れと混同しない)。
   しきい値はテナント設定(rpc_seedのtenant.slaキー)から読む。取得できない
   (モック・オフライン・テナント未解決)ときは、この既定値にフォールバックする ─
   supabase/migrations/0088_report_rhythm_weekly.sqlの列既定値と同じにしてある
   (報告の2つは決-36で「会社の既定=週一」= 黄5日・赤7日。0083の「『密に』専用の2/3」
    から意味が広がり、取引先ごとの取り決めが無い相手にもこの既定が効く)。 */
const SLA_DEFAULTS = { delayOverDays:1, criticalOverDays:7, warnSoonDays:2, reportWarnDays:5, reportOverDays:7 };
function slaConfig(state){ return Object.assign({}, SLA_DEFAULTS, (state && state.tenant && state.tenant.sla) || {}); }
/* 予定日(planOverDays)の判定。既存の期限(due_on)マーカー(private._seed_marker)と
   同じ式 ─ over>=delayOverDaysで超過(赤)、over>=-warnSoonDaysで予兆(黄)。
   over が null(該当工程が無い)ならnull(=「遅れている」に該当しない)。 */
function slaDueMarker(over, sla, opts){
  const c = sla || SLA_DEFAULTS;
  if(over==null) return null;
  let m = null;
  if(over >= c.delayOverDays) m = "delay";
  else if(over >= -c.warnSoonDays) m = "warn";
  // ⑪: 相手待ち中は赤にせず黄止まり(統合担当の設計)。待ちの事実は waitingSince 非NULL。
  if(m === "delay" && opts && opts.waiting) return "warn";
  return m;
}
/* 報告リズム(reportOverDays)の判定。overは「経過日数」そのものなので常に0以上 ─
   予定日と違って「残り日数」の概念が無いため、しきい値との素直な2段比較にする
   (0049冒頭【設計判断】4)。over>=reportOverDaysで超過(赤)、
   over>=reportWarnDaysで予兆(黄)。
   決-36(0088): この式自体はリズムを見ない ─ どのリズムを既定へ倒すかは
   rpc_seed / server.py の report_over 側の仕事で、ここへ来る over は
   「判定対象だけが数値・対象外はnull」に整形済み。0083 では未設定が常にnull
   (=判定なし)だったが、0088 からは未設定も会社の既定(週一)で数値が入る。 */
function slaReportMarker(over, sla){
  const c = sla || SLA_DEFAULTS;
  if(over==null) return null;
  if(over >= c.reportOverDays) return "delay";
  if(over >= c.reportWarnDays) return "warn";
  return null;
}
const SLA_RANK = { delay:2, warn:1 };
/* 案件1件ぶんの判定。予定日・報告リズムの両方が該当するときは悪いほう
   (delay > warn、同点なら超過日数が大きいほう)を1件のバッジとして勝たせる。
   どちらも該当しなければnull(=「遅れている」一覧に出さない)。 */
function slaCaseStatus(c, sla){
  const waiting = caseHasWaiting(c);
  const planMarker = slaDueMarker(c.planOverDays, sla, { waiting });
  const reportMarker = slaReportMarker(c.reportOverDays, sla);
  const cands = [];
  if(planMarker) cands.push({ reasonKind:"plan", marker:planMarker, overDays:c.planOverDays });
  if(reportMarker) cands.push({ reasonKind:"report", marker:reportMarker, overDays:c.reportOverDays });
  if(!cands.length) return null;
  cands.sort((a,b) => (SLA_RANK[b.marker]-SLA_RANK[a.marker]) || (b.overDays-a.overDays));
  return cands[0];
}
/* 案件に相手待ちの工程があるか(0065: waitingSince 非NULL)。slaDueMarker の黄止まり判定用。 */
function caseHasWaiting(c){
  if(!c) return false;
  return msTracks(c).some(({ t }) => {
    const o = msOpen(t);
    return !!(o && (o.waitingSince || o.s === "waiting"));
  });
}
/* 相手待ちの一行表示(⑪)。呼び元が待ち確定のときだけ使う。理由・いつから欠ければ仮置き。 */
function waitDisplay(reason, since){
  const r = (reason && String(reason).trim()) ? String(reason).trim() : "（理由なし）";
  const s = since || "—";
  return "相手待ち: " + r + "(" + s + ")";
}

/* K18-13(0105): 漏水状況(cases.leak_status)の語彙と「継続中」の規則。
   選択肢は SF の picklist の 8 値(docs/03-screens.md:187)。『漏水なし』(SF 20 件)を残すか『解消』に
   寄せるかは池田さんの答え待ち(docs/18 E-13(2))なので、答えが出るまで 8 値とも出す。
   「継続中」= いまも漏れている(常時・時々)。盤面・ダッシュボード・案件詳細はこの 1 か所を読む
   (rpc_seed / server.py は判定しない ─ planOverDays の赤/黄と同じ方針)。 */
const LEAK_STATUS_OPTIONS = ["常時","時々","一回のみで再発なし","雨の日のみ","解消","不明","結露","漏水なし"];
const LEAK_CONTINUING = ["常時","時々"];
function isLeakContinuing(c){ return !!c && LEAK_CONTINUING.indexOf(c.leakStatus) >= 0; }

/* K18-14/15(0107/0108): 案件対象部屋(spots)の 居住制限(被害側 3 値)と 利用制限(原因側 複数選択 6 値)。
   語彙の正は DB の CHECK(0107)。書き込み層(mstore-diff.js LIVING_RESTRICTIONS / USAGE_RESTRICTIONS)と同じ並び。
   「生活できず避難」= 住めない(進め方 P1 先行工事の判定値・骨子 10-6)。赤で出す。進め方が空の保険精算の
   復旧工事には DB が P1 を入れ、入っている進め方は変えない(K18-16・0111・施主 9/24)。下の unlivableApproachHint。
   受付(Intake.jsx)・案件詳細(CaseDetailV25.jsx)・tests/ui/ はここを読む。 */
const LIVING_RESTRICTION_OPTIONS = ["制限なし","一部制限あり","生活できず避難"];
const LIVING_UNLIVABLE = "生活できず避難";
const USAGE_RESTRICTION_OPTIONS = ["制限なし","排水制限中","給水停止中","給湯停止中","洗濯機使用制限中","浴室使用制限中"];
/** 利用制限の複数選択を 1 つ切り替えた結果(語彙の順)。「制限なし」を選ぶと他を外し、制限ありを選ぶと
    「制限なし」を外す ─ DB のトリガ(private._ctr_restrictions_sync)が揃える形と同じにしておく
    (画面の値と再読込後の値が食い違わないように)。語彙に無い値は落とす。 */
function toggleUsageRestriction(cur, v){
  const set = new Set(Array.isArray(cur) ? cur : []);
  if (set.has(v)) set.delete(v);
  else if (v === "制限なし") { set.clear(); set.add(v); }
  else { set.delete("制限なし"); set.add(v); }
  return USAGE_RESTRICTION_OPTIONS.filter(x => set.has(x));
}

/* K18-16(0111): 住めない部屋がある案件の、保険精算の復旧工事(被害箇所工事)の「進め方は P1 が目安」の注意。
   DB は進め方が空の保険精算の工事トラックに初期値を入れる(復旧工事で住めない部屋があれば P1・ほかは依頼者の規定 ─
   0111 private.f_track_default_approach)。入っている進め方(規定・手入力・移行)は変えないので、P1 以外なら
   画面が知らせて人が決める(施主 9/24「おすすめに合わせて」)。
   住めない部屋 = 案件の対象部屋(spots)のうち立場が 被害/両方 で、居住制限が「生活できず避難」(0108 の livingRestriction)。
   トラックの部屋の指定は seed に無いので案件単位で見る(DB も部屋の指定が無いトラックは案件単位)。
   rpc_seed は今、工事トラックの進め方(approach)と区分(workCategory)を載せていない。載っていれば使い
   (P1 なら注意を出さない・原因箇所工事は対象外)、無ければ進め方は「不明」として保険精算の工事トラックに注意を出す
   (P1 が入っていても「P1 が目安」は正しい)。終了したトラックには出さない。
   返り値: null(出さない)/ { rooms:[室番号], approach:'P1'|'P2'|'P3'|null(空)|undefined(不明), needsAttention } */
function unlivableApproachHint(c, w){
  if(!c || !w) return null;
  if(!w.detail || w.detail.method !== "保険精算") return null;
  if(w.workCategory && w.workCategory !== "被害箇所工事") return null;
  if(w.trackStatus === "終了") return null;
  const rooms = (Array.isArray(c.spots) ? c.spots : [])
    .filter(s => s && (s.kind === "被害" || s.kind === "両方") && s.livingRestriction === LIVING_UNLIVABLE)
    .map(s => s.room || "—");
  if(!rooms.length) return null;
  const known = Object.prototype.hasOwnProperty.call(w, "approach");
  const approach = known ? (w.approach || null) : undefined;
  return { rooms, approach, needsAttention: approach !== "P1" };
}

/* ==== H2(9/25): 工事を足すときの「工程の型」の有無 ====
   track.create(0109)は工事の工程を route_templates の (区分, 精算方式, 進め方) で引き、合う型が無いと
   **黙って工程 0 本の工事を作る**(ほかの端末で案件詳細が真っ白になっていた ─ S2-B2・S3-B1)。
   精算方式を送らなければ依頼者の規定(default_settlement_repair)を読むが、実データの依頼者の大半は規定が空。
   だから「工事を追加」(CaseP1.jsx AddRecordModal)で精算方式(保険精算なら進め方も)を選ばせ、
   型の無い組み合わせでは作らない。選んだ値は track.create の settlementType / approach で送る(mstore-diff.js)。
   表は route_templates(0003 の種・route_code 2〜11)と同じ。原因箇所工事・被害箇所工事で同じ組み合わせを持つ:
     自費精算(自社負担は自費精算の型を使う ─ 0109 の CASE)・工事内包 … 進め方なし
     保険精算 … 進め方 P1 / P2 / P3 のどれか
   「未定」には型が無い。DB の表との一致は tests/db/work_route_table.test.js が見る。 */
const WORK_ROUTE_SETTLEMENTS = ["自費精算","保険精算","工事内包","自社負担"];
const WORK_ROUTE_APPROACHES = ["P1","P2","P3"];
/** 精算方式・進め方で工程の型が引けるか。引けるなら null、引けないなら { need:'settlement'|'approach', reason }。 */
function workRouteGap(settlement, approach){
  if (WORK_ROUTE_SETTLEMENTS.indexOf(settlement) < 0) {
    return { need:"settlement", reason: settlement === "未定"
      ? "精算方式が「未定」のままでは工程の型が決まりません。精算方式を選んでください。"
      : "精算方式を選んでください。" };
  }
  if (settlement === "保険精算" && WORK_ROUTE_APPROACHES.indexOf(approach) < 0) {
    return { need:"approach", reason:"保険精算の工事は、進め方（P1〜P3）を選んでください。" };
  }
  return null;
}
/** 室番号の比べ方(全角→半角・空白と末尾の「号室」を落とす)。DB の private._norm_room_no の近似。 */
const msRoomKey = r => String(r == null ? "" : r).normalize("NFKC").replace(/\s+/g, "").replace(/号室?$/, "").toUpperCase();
/** 工事を足すときの進め方の初期値。DB の private.f_track_default_approach(0111)と同じ規則:
    保険精算でなければ null / 被害箇所工事で、住めない(生活できず避難)被害側の部屋(号室を書けばその部屋)が
    あれば P1 / ほかは依頼者の規定。返り値 { approach, why:'unlivable'|'requester'|null, rooms } */
function workApproachDefault(c, workKind, room, settlement, requesterApproach){
  if (settlement !== "保険精算") return { approach:null, why:null, rooms:[] };
  if ((workKind || "被害箇所工事") === "被害箇所工事") {
    const want = msRoomKey(room);
    const rooms = (c && Array.isArray(c.spots) ? c.spots : [])
      .filter(sp => sp && (sp.kind === "被害" || sp.kind === "両方") && sp.livingRestriction === LIVING_UNLIVABLE
                 && (!want || msRoomKey(sp.room) === want))
      .map(sp => sp.room || "—");
    if (rooms.length) return { approach:"P1", why:"unlivable", rooms };
  }
  const a = WORK_ROUTE_APPROACHES.indexOf(requesterApproach) >= 0 ? requesterApproach : null;
  return { approach:a, why: a ? "requester" : null, rooms:[] };
}
/** 案件の依頼者の規定(精算方式(復旧)・進め方)。名簿(seed の orgs)から引く。選び直した依頼先(clientDbId)を先に、
    無ければ名前で。同じ名前が 2 社以上あって規定が食い違えば分からない(null)として扱う(推測で埋めない)。
    名簿はログイン時の控えなので古いことがある(S6-C) ─ だから画面は規定を「初期値」にだけ使い、送るのは選んだ値。 */
function requesterWorkDefaults(c, orgs){
  const list = Array.isArray(orgs) ? orgs : [];
  let hits = [];
  if (c && c.clientDbId) hits = list.filter(o => o && o.id === c.clientDbId);
  if (!hits.length && c && c.client && c.client !== "—") hits = list.filter(o => o && o.name === c.client);
  if (!hits.length) return { settlement:null, approach:null, known:false };
  const pick = k => { const v = new Set(hits.map(o => o[k] || null)); return v.size === 1 ? [...v][0] : null; };
  return { settlement: pick("defaultSettlementRepair"), approach: pick("defaultApproach"), known:true };
}

/* ⑬: 調査チェックリスト(0060・3件)の進捗ラベル。seed に surveyTasks が無ければ null
   (rpc_case_survey_tasks は案件オープン時だけ・一覧では seed の有無だけ見る)。 */
function surveyProgressLabel(c){
  const tasks = c && c.surveyTasks;
  if(!Array.isArray(tasks) || !tasks.length) return null;
  const done = tasks.filter(t => t && t.state === "done").length;
  return "調査 " + done + "/" + tasks.length;
}

/* ==== 盤面・TODO-7(Codexから。docs/13-task-board.md): TODOの重要度(severity) ====
   Tasks.jsx/data.jsxに書かれていた固定文字列(severity:'critical'/'normal'/null)を
   実計算に置き換える。判定式は上のslaDueMarker/slaReportMarker(SLA監視・0049・
   決-20)に集約済みなので、ここでは**優先順位を決めるだけ**(二重に書かない)。

   優先順位(依頼どおり):
     1. 約束期日(0038 planned_on)超過        → critical
        工程ごとのplanned_onはrpc_seedが返さない(0049冒頭【3】・rail配列のplanキーは
        常にnull)。案件ごとの集約 case.planOverDays をslaDueMarkerへ通し、赤(delay)
        ならその案件の未完了TODOは重大として扱う ─ どの工程が約束を破ったかまでは
        区別できないが、「約束を破った案件のTODOは後回しにしない」という決-11の
        目的には十分。
     2. 既定期限(due_on)超過                 → over / critical
        todo.marker/todo.sev は rpc_seed が private._sla_marker で工程TODO・手動TODO
        の両方に既に計算済み(0049 todo_track/todo_task)。sev==='critical'ならそのまま
        critical(約束は守っていても既定期限が重大水準に達している)、そうでなければ
        over(「約束期日ほどではないが延びている」─ 旧'critical'/'normal'の2値には
        無かった中間段階)。
     3. 迫っている(黄)・報告リズム超過(決-24) → warn(論理和)
        約束日/既定期限のどちらかが予兆(warn)、または報告リズム超過
        (case.reportOverDays を slaReportMarker へ)のいずれかが立てば warn。
        報告リズム「随時」は据え置き(アラートにしない。docs/18-ikeda-requests.md 1-4・
        決-24「随時」=アラートなし)。
     4. それ以外(完了済みも含む)              → normal

   todo.marker が無い(手動起票のモック・テストの素データ)場合だけ、todo.due/
   todo.dueOn と today から同じ slaDueMarker に通す(todoOverDays)。工程進入日時
   (entered_at)起点のしきい値は P2-d(0024)で廃止済み(0049冒頭【1】)なので
   新設しない ─ 該当する期限が無ければ null のまま(「遅れている」扱いにしない)。 */
function todoOverDays(todo, today){
  if(!todo) return null;
  const raw = todo.dueOn != null ? todo.dueOn : todo.due;
  if(raw == null) return null;
  const t = today ? new Date(today) : new Date();
  if(isNaN(t.getTime())) return null;
  t.setHours(0,0,0,0);
  let y, mo, d;
  if(raw instanceof Date){
    if(isNaN(raw.getTime())) return null;
    y = raw.getFullYear(); mo = raw.getMonth(); d = raw.getDate();
  } else {
    const s = String(raw);
    if(s.indexOf("本日") === 0) return 0;   // 表示用の当日ラベル(モック互換)
    const m = s.match(/(\d{4})[/-](\d{1,2})[/-](\d{1,2})/);
    if(!m) return null;
    y = Number(m[1]); mo = Number(m[2]) - 1; d = Number(m[3]);
  }
  const due = new Date(y, mo, d);
  due.setHours(0,0,0,0);
  return Math.round((t - due) / 86400000);
}
function todoSeverity(todo, c, sla, today){
  if(!todo || todo.done) return "normal";
  const cfg = sla || SLA_DEFAULTS;
  const cc = c || {};
  const waiting = !!(todo.waiting || todo.waitingSince || (cc && caseHasWaiting(cc)));
  // 1. 約束期日(案件の集約 planOverDays)。待ち中は赤にせず黄止まり(⑪)。
  const planMk = cc.planOverDays != null ? slaDueMarker(cc.planOverDays, cfg, { waiting }) : null;
  if(planMk === "delay") return "critical";
  // 2. 既定期限。todo自身のmarker(rpc_seed計算済み)があればそれを使い、
  //    無ければ due/dueOn から同じ式で算出する(判定式は1か所)。
  const hasOwnMarker = todo.marker === "delay" || todo.marker === "warn" || todo.marker === "on";
  let dueMk = hasOwnMarker ? todo.marker : slaDueMarker(todoOverDays(todo, today), cfg, { waiting });
  if(waiting && dueMk === "delay") dueMk = "warn";
  const dueSev = hasOwnMarker ? todo.sev : null;
  if(dueMk === "delay") return dueSev === "critical" ? "critical" : "over";
  // 3. 迫っている・報告リズム超過(論理和)。「随時」は据え置き(決-24)。
  const reportMk = (cc.reportRhythm !== "随時" && cc.reportOverDays != null)
    ? slaReportMarker(cc.reportOverDays, cfg) : null;
  if(planMk === "warn" || dueMk === "warn" || reportMk) return "warn";
  return "normal";
}

Object.assign(MStore, {
  /* 盤面・ダッシュボード-12(0049・決-20): 「遅れている」一覧。
     予定日(planOverDays)・報告リズム(reportOverDays)のいずれかが赤/黄の案件を、
     超過日数の大きい順に並べる。判定そのものは上のslaCaseStatus()に集約。
     完了済み(closed)の案件は対象外(打ち切った/終わった案件を「遅れ」に出さない)。
     読み込み済みの案件が対象(caseList/boardRowsと同じ制約 ─ ページング2段目で
     まだ読んでいない古い案件は、開くかfetchするまでここにも出てこない)。 */
  slaQueue(){
    const s = this.get();
    const sla = slaConfig(s);
    const out = [];
    (s.cases||[]).forEach(c => {
      if(c.closed) return;
      const st = slaCaseStatus(c, sla);
      if(!st) return;
      out.push({ caseId:c.id, dbId:c.dbId||null, prop:c.name, marker:st.marker, reasonKind:st.reasonKind, overDays:st.overDays,
        surveyLabel: surveyProgressLabel(c) });
    });
    out.sort((a,b) => b.overDays - a.overDays);
    return out;
  },
  /* TODO一覧・ダッシュボードが読む行 */
  boardRows(){
    const out = [];
    this.get().cases.forEach(c => (c.todos||[]).forEach(t => {
      if(t.done) return;
      // K18-05(0098): 保留中(再開予定日まで)・終了のトラックの工程は盤面の行にしない。
      // サーバ(rpc_seed の cs)と同じ規則 ─ seed は既に外して返すが、店に残った古い seed や
      // 書き込み直後の楽観更新でも規則が揃うよう、行を作る側でも見る。手動 TODO(kind=task)は
      // トラックではないので対象外(サーバの件数とも揃う)。
      if(t.kind === "step" && t.trkDbId){
        const hit = msTracks(c).find(({ t: trk }) => trk.dbId === t.trkDbId);
        if(hit && (hit.t.onHold || hit.t.trackStatus === "終了")) return;
      }
      // ⑪: 同じ工程名の step から相手待ちの理由・いつからを引く。
      let waitingReason = null, waitingSince = null, waiting = false;
      msTracks(c).forEach(({ t: trk }) => {
        (trk.steps || []).forEach(s => {
          if(waiting) return;
          if((s.n === t.step || s.n === t.railStep) && (s.waitingSince || s.s === "waiting")){
            waiting = true;
            waitingReason = s.waitingReason || null;
            waitingSince = s.waitingSince || null;
          }
        });
      });
      // 待ち中は赤マーカーを黄にキャップ(slaDueMarker と同じ方針・⑪)。
      const marker = (waiting && t.marker === "delay") ? "warn" : t.marker;
      out.push({ trk:t.trkId||"—", caseId:c.id, prop:c.name, intake:Number(c.id.slice(5))||0, step:t.step, ball:t.ball, raised:t.raised,
        category:t.category, btype:t.btype, marker, sev:t.sev, urgent:t.urgent, watch:t.watch,
        due:t.due, dunning:t.dunning, trkId:t.trkId, railStep:t.railStep, todoId:t.id,
        waiting, waitingReason, waitingSince,
        waitLabel: waiting ? waitDisplay(waitingReason, waitingSince) : null,
        surveyLabel: surveyProgressLabel(c),
        // K18-05(0098): 全トラックが終了しているのに閉じていない案件の印(盤面・案件一覧の
        // 「全部終了・閉じますか」)。閉じる操作は既存の closedFlag のまま。
        closeHint: !!c.derivedEnded && !c.closed,
        // K18-13(0105): 漏水が続いている案件(常時・時々)の印「継続中」。規則は isLeakContinuing の 1 か所。
        leakContinuing: isLeakContinuing(c),
        // 盤面の担当絞り込み用。案件本担当の表示名（括弧付きチーム名は落とす）。
        assignee:(c.owner||"").split("（")[0] || "—",
        // 決-21(追-4-3):「自分の案件」の絞り込みに副担当・営業担当も含める。
        // assignee(本担当)はBoardV4.jsxの集約軸(単一値の一致判定)がそのまま使うため
        // 形を変えない ─ この2つは dvIsMine(DashboardV6.jsx)の「自分」判定だけが読む。
        subAssignee:(c.sub||"").split("（")[0] || "",
        salesAssignee:(c.sales||"").split("（")[0] || "" });
    }));
    return out;
  },
  /* 案件一覧が読む行。**案件そのものから作る。**

     以前は画面側が boardRows()(=未完了TODOの一覧)から案件を集めていたため、
     **TODOが1件も無い案件が一覧にも検索にも出てこなかった**。
     673件中23件がそれで、しかも直近に受け付けた「調査調整中」「相談中」──
     いま動いている案件だった。受付から工程が生成されるまでの間、
     案件が画面から消えることになる。 */
  caseList(){
    const meta = this.caseMeta();
    return this.get().cases.map(c => {
      const m = meta[c.id] || {};
      const todos = (c.todos || []).filter(t => !t.done);
      return { id:c.id, dbId:c.dbId, name:c.name, prop:c.name, client:c.client, owner:c.owner,
        accepted:m.accepted, stage:m.stage, dueDone:m.dueDone, closed:!!c.closed,
        todoCount: todos.length,
        surveyLabel: surveyProgressLabel(c),
        // K18-05(0098): 案件の完了の導出(仕様 05:936)。derivedDone=全トラック終了かつ結果が完了/不要、
        // derivedEnded=全トラック終了(失注・適用外を含む)。closeHint は「終わっているのに閉じていない」。
        derivedDone: !!c.derivedDone, derivedEnded: !!c.derivedEnded,
        closeHint: !!c.derivedEnded && !c.closed,
        // K18-13(0105): 漏水状況と「継続中」(常時・時々)。TODO の無い案件でも案件一覧で印が出るように。
        leakStatus: c.leakStatus || "", leakContinuing: isLeakContinuing(c),
        // 案件一覧の絞り込み(⑩)用。表示名は括弧付きチーム名を落とす。
        propKind: c.propKind || "",
        ownerName:(c.owner||"").split("（")[0] || "",
        subName:(c.sub||"").split("（")[0] || "",
        salesName:(c.sales||"").split("（")[0] || "",
        // 期限がいちばん近い未完了TODO。無ければ案件の完了予定日
        nearDue: todos.map(t=>t.due).filter(Boolean).sort((a,b)=>
          (window.bvDueNum?window.bvDueNum(a):0)-(window.bvDueNum?window.bvDueNum(b):0))[0]
          || (c.dueDone && c.dueDone!=="—" ? c.dueDone : null) };
    });
  },
  /* 案件一覧の絞り込み(⑩)。担当は主/副/営業のどれかに一致すれば通す。
     lag: "delay"(赤) / "warn"(黄) / "none"(遅れなし) / null(指定なし)。
     propKind: 物件種別の完全一致。判定は slaCaseStatus に集約(二重に書かない)。 */
  filterCases({ person = null, lag = null, propKind = null } = {}){
    const s = this.get();
    const sla = slaConfig(s);
    const byId = new Map((s.cases||[]).map(c => [c.id, c]));
    return this.caseList().filter(row => {
      const c = byId.get(row.id);
      if(!c) return false;
      if(person && person !== "全員"){
        const names = [row.ownerName, row.subName, row.salesName].filter(Boolean);
        if(names.indexOf(person) < 0) return false;
      }
      if(propKind){
        if((c.propKind || "") !== propKind) return false;
      }
      if(lag){
        const st = slaCaseStatus(c, sla);
        if(lag === "none"){ if(st) return false; }
        else if(lag === "delay" || lag === "warn"){ if(!st || st.marker !== lag) return false; }
        else return false;
      }
      return true;
    });
  },
  /* 横断検索。案件そのものを引く(盤面の行を経由しない) */
  searchCases(q){
    const k = String(q || "").trim();
    const list = this.caseList();
    if(!k) return list;
    // ㉒: 受付番号(caseNo)も拾う。電話番号っぽい数字だけの語は案件側に無いことが多いので
    // SearchResults が取引先経由で辿る(ここでは名前・番号・依頼先・担当)。
    return list.filter(x =>
      [x.name, x.id, x.caseNo, x.client, x.owner, x.stage].filter(Boolean).join(" ").includes(k));
  },
  /* 案件一覧が読むメタ */
  caseMeta(){
    const m = {};
    // accepted は必ず入っている想定だが、**ここで例外を出さない**。
    // caseMeta は msSync 経由で update() のたびに走るので、1件でも欠けると
    // 例外が編集した本人まで飛んで画面が止まる(実際に受付で起きた)。
    // 欠けを隠すのではなく、空で通して tests/ui/store-contract.test.js に見つけさせる。
    this.get().cases.forEach(c => { m[c.id] = { accepted:String(c.accepted||"").slice(5), stage:msStage(c), client:c.client, owner:(c.owner||"").split("（")[0],
      // 決-21(追-4-3): 案件一覧(BoardV4.jsx)の補足表示。
      sub:(c.sub||"").split("（")[0], sales:(c.sales||"").split("（")[0],
      dueDone:c.dueDone||"—" }; });
    return m;
  },
  /* 進行一覧（調査・工事・保険）＋精算は請求／入金の工程から導出 */
  trackRows(){
    const out = [];
    this.get().cases.forEach(c => {
      msTracks(c).forEach(({ t, kind }) => {
        const o = msOpen(t), det = t.detail || {};
        // K-29: dbId(= tracks.id)も渡す。TODO 詳細は trk.dbId で連絡履歴を絞り(0027 の trackId)、
        // 連絡の記録に track_id を付ける(Screens.jsx scMicro / persistContact)。無いと全部が空振りする。
        // H2: 工程が 1 つも無いトラックは「完了」にしない(進める工程が無いだけ)。
        const noSteps = msSteps_(t).length === 0;
        out.push({ id:t.id, dbId:t.dbId || null, kind, caseId:c.id, prop:c.name,
          target: kind==="work" ? t.room : kind==="ins" ? (t.targetShort||t.target||"") : (t.rooms||""),
          work: kind==="work" ? t.work : undefined,
          vendor: kind==="ins" ? t.name : (det.vendor || t.vendor || "—"),
          holder:t.holder, policy:t.policy,
          step:o ? o.n : noSteps ? "工程なし" : "完了",
          state: noSteps ? "wait" : !o ? "done" : o.s==="waiting" ? "waiting" : o.s==="now" ? "now" : "wait",
          // K18-06: 保留・終了の行は新しい状態(trackStatus ほか・0098)を主役にし、SF フェーズ(legacy_phase=t.status)は
          // legacyPhase として残す(列は残す・表示の主役から降ろす)。
          status: msTstateLabel(t) || (kind==="ins" ? t.status : (t.status || (o ? o.n+"中" : "完了"))),
          legacyPhase: t.status || null,
          tstate: msTstate(t),
          // 待ち中は滞留(赤)を予兆(黄)にキャップ(⑪・slaDueMarker と同方針)。
          // 保留・終了の行は滞り色を出さない(02 §8.7。滞りの判定そのものは K18-05)。
          mk: msTstate(t) ? "" : (o && o.s === "waiting" && t.mk === "delay") ? "warn" : (t.mk || ""),
          due: (t.todoDue || (o && o.plan) || det.due || "—"),
          billed:det.billed||0, cost:det.cost||0, settleWay:det.settle||det.method||"—", payer:det.payer||"—",
          waitingReason: o ? (o.waitingReason || null) : null,
          waitingSince: o ? (o.waitingSince || null) : null,
          waitLabel: (o && o.s === "waiting") ? waitDisplay(o.waitingReason, o.waitingSince) : null,
          rail:msRail(t) });
      });
      msTracks(c).forEach(({ t, kind }) => {
        const det = t.detail || {};
        if(kind==="ins" || det.billed==null) return;
        // H2: 工程が「無い」(rpc_seed が null で返す)工事でも落ちない(msSync は update のたびに走る)。
        const steps = msSteps_(t);
        const bill = steps.find(s => /請求/.test(s.n)), paid = steps.find(s => s.n==="入金");
        const fix = steps.find(s => /見積|確定/.test(s.n));
        const rail = [["確定", fix && fix.s==="done" ? "done" : "todo", fix && fix.date || ""],
          ["請求", bill ? bill.s : "todo", bill && bill.date || ""],
          ["入金", paid ? paid.s : "todo", paid && (paid.date || (paid.s==="waiting" ? (paid.plan||"待ち") : "")) || ""],
          ["支払", paid && paid.s==="done" ? "done" : "todo", ""],
          ["完了", msAllDone(t) ? "done" : "todo", ""]];
        const o = rail.find(r => r[1]!=="done" && r[1]!=="skip");
        out.push({ id:t.id+"-P", kind:"settle", caseId:c.id, prop:c.name,
          target: kind==="work" ? (t.room+" "+t.work) : "調査費", vendor:det.payer||"—",
          step:o ? o[0] : "完了", state: !o ? "done" : o[1]==="waiting" ? "waiting" : o[1]==="now" ? "now" : "wait",
          status: !o ? "精算完了" : o[0]==="入金" ? "入金待ち" : o[0]+"準備", mk: paid && paid.s==="waiting" ? "" : "",
          due:det.due||"—", billed:det.billed||0, cost:det.cost||0, settleWay:det.method||det.settle||"—", payer:det.payer||"—", rail });
      });
    });
    return out;
  },
  /* 精算・入金が読む行。**1トラック = 精算明細1行**（DBの line_no=1）。

     保険はここに出さない。保険の金額は精算明細ではなく保険金請求(申請額・認定額)に
     あり、入金は保険から実務トラックへ「充当」される別の話だから
     （app/write/schema.js の CLAIM_FIELDS と SETTLEMENT_FIELDS が別なのと同じ理由）。

     金額がまだ無いトラックも出す。経理が探すのは「請求していないもの」で、
     金額の入っている行だけ出すと**まだ請求していない案件が締めから消える**。
     未完了案件の調査・工事トラック1,954本には全部 line_no=1 の明細があるので、
     ここに出した行はどれも settlement.update が当たる（無い行を出すと404になる）。 */
  settlementRows(caseId){
    const out = [];
    this.get().cases.forEach(c => {
      if(caseId && c.id!==caseId) return;
      [...(c.visits||[]).map(t=>({t,kind:"visit"})), ...(c.works||[]).map(t=>({t,kind:"work"}))]
        .forEach(({t,kind}) => {
          const det = t.detail || {}, steps = msSteps_(t);
          const bill = steps.find(s => /請求/.test(s.n)), pd = steps.find(s => s.n==="入金");
          const paidAmt = det.paidAmount==null ? null : det.paidAmount;
          const isPaid = det.paid ? true : (paidAmt!=null && paidAmt>0);
          out.push({ id:t.id, trackDbId:t.dbId||null, caseId:c.id, kind, prop:c.name, closed:!!c.closed,
            target: kind==="work" ? ((t.room?t.room+" ":"")+(t.work||"工事")) : "調査費",
            method: det.method || det.settle || "—", payer: det.payer || "—",
            billed: det.billed==null ? null : det.billed,
            paidAmount: paidAmt, paidOn: det.paidOn || null, paid: isPaid,
            due: det.due && det.due!=="—" ? det.due : null,
            billIssued: !!(bill && bill.s==="done"),
            status: isPaid ? "入金済"
                  : det.billed==null ? "金額未確定"
                  : (bill && bill.s==="done") || (pd && pd.s==="waiting") ? "入金待ち" : "請求準備" });
        });
    });
    return out;
  },
  /* 精算明細の1本を書き換える共通の入口。trackUiId は settlementRows() の id */
  updateSettlement(caseId, trackUiId, patch){
    return this.updateCase(caseId, c => {
      const t = [...(c.visits||[]), ...(c.works||[])].find(x => x.id===trackUiId);
      if(t) t.detail = { ...(t.detail||{}), ...patch };
    });
  },
  /* 入金の記録。経理が毎月これで締めるので、**金額を黙って直さない**。
     読めない値・負の値・1 円単位でない値はここで止める(msMoneyArg_)。通してしまうと請求総額が静かに狂う。

     **渡された欄だけを変える**(9/25)。読み込み(rpc_seed)は入金額・入金日を返さないので、
     読み込み直後の画面では入金額・入金日が空。以前は入金日だけを直しても入金額を
     「空 = 0」として一緒に書き、DB の入金額を消していた。
     amount の空は 0(入金の取り消し ─ paid_total は NOT NULL)、on の空は「入金日を消す」。 */
  recordPayment(caseId, trackUiId, patch){
    const p = patch || {};
    const out = {};
    if("amount" in p) out.paidAmount = msMoneyArg_(p.amount, "入金額", 0);
    if("on" in p) out.paidOn = p.on || null;
    return this.updateSettlement(caseId, trackUiId, out);
  },
  /* 請求額の確定(空は null = 金額未確定) */
  setBilled(caseId, trackUiId, amount){
    return this.updateSettlement(caseId, trackUiId, { billed: msMoneyArg_(amount, "請求額", null) });
  },
  /* TODOの完了・追加 */
  toggleTodo(caseId, todoId){ return this.updateCase(caseId, c => { const t = (c.todos||[]).find(x => x.id===todoId); if(t) t.done = !t.done; }); },
  addTodo(caseId, todo){ const d = new Date(); const raised = (d.getMonth()+1)+"/"+d.getDate();
    return this.updateCase(caseId, c => { c.todos = [...(c.todos||[]), { id:"TD"+Date.now(), marker:"on", sev:null, urgent:false, watch:false, category:todo.ball==="自社"?"自社":"他者", raised, done:false, ...todo }]; }); },

  /* ==== ページング2段目(決-1・0033) ====================================
     rpc_seed を直近N件だけで呼ぶようになった(marurou-boot.js)ので、
     ここから3つの穴を埋める:
       counts()/paging()  … 件数バッジ・見出し・「もっと見る」ボタンの出し分け
       loadMoreCases()     … 「もっと見る」
       searchCasesRemote() … 検索で直近N件に入らない案件を探す
       ensureCaseLoaded()  … 検索で見つけた案件を開くとき、1件だけ本体を足す
     クラウド接続(window.MarurouCloud)が無い開発モードでは /api/seed?… を直接叩く
     (app/write/mstore-adapter.js の refetchCase と同じ分岐)。 */

  /* 件数バッジが読む、サーバで数えた全件数。まだ取れていなければ null
     (画面側は「読み込み中」扱いにし、0件のように見せない)。 */
  counts(){ return this.get().counts || null; },
  /* 担当者別(自分の案件/自分が担当の工程・TODO)の内訳(C-4・0077・決-21)。
     rpc_open_counts の mine キーをそのまま返す ─ counts() が届いていない・
     サーバがmineを返していない(旧キャッシュ等)場合は null。
     表示側(ダッシュボードの「自分」既定・Cursor ㉑)はここを読むだけでよい。 */
  mine(){ return (this.get().counts || {}).mine || null; },
  /* 「もっと見る」の出し分け。 */
  paging(){ return this.get().paging || { hasMore:false, nextCursor:null }; },

  /* 続きのN件を取って、いま読み込んでいる案件配列に足す(重複はdbIdで弾く)。
     カーソルが無ければ何もしない(もう全部読み込んでいる)。 */
  async loadMoreCases(limit){
    const cur = this.paging();
    if (!cur.hasMore || !cur.nextCursor) return this.get();
    const n = limit || 50;
    let seed;
    if (window.MarurouCloud) {
      seed = await window.MarurouCloud.loadSeed({ limit:n, cursor:cur.nextCursor });
    } else {
      const res = await fetch("/api/seed?limit=" + n + "&cursor=" + encodeURIComponent(cur.nextCursor));
      if (!res.ok) throw new Error("サーバが " + res.status + " を返しました");
      seed = await res.json();
    }
    const added = (seed && seed.cases) || [];
    window.__MARUROU_APPLYING_REMOTE__ = true;
    try {
      return this.update(m => {
        const known = new Set(m.cases.map(c => c.dbId));
        // 画面の案件の id(CASE-番号)は付け替えないので、サーバの番号が画面の別の案件と重なることがある。
        // 重なる分だけ空いている番号を振る(msFreeCaseId・H1)。
        const used = new Set(m.cases.map(c => c.id));
        for (const c of added) {
          if (known.has(c.dbId)) continue;
          const id = msFreeCaseId(c.id, used, 1);
          used.add(id); known.add(c.dbId);
          m.cases.push(id === c.id ? c : { ...c, id });
        }
        m.paging = { hasMore: !!(seed && seed.hasMore), nextCursor: (seed && seed.nextCursor) || null };
      });
    } finally { window.__MARUROU_APPLYING_REMOTE__ = false; }
  },

  /* 直近N件に入らない未完了案件を、案件名・受付番号で探す(サーバ側)。
     いま読み込んでいる分の検索(searchCases)と画面側で合わせて使う。 */
  async searchCasesRemote(q){
    const k = String(q || "").trim();
    if (!k) return [];
    if (window.MarurouCloud) return (await window.MarurouCloud.searchCases(k)) || [];
    const res = await fetch("/api/case-search?q=" + encodeURIComponent(k));
    if (!res.ok) throw new Error("サーバが " + res.status + " を返しました");
    return (await res.json()) || [];
  },

  /* 検索で見つけた案件(dbId)を開けるようにする。既に読み込み済みならサーバへ
     問い合わせない。見つからなければ null(呼び出し側が「開けません」を出す)。 */
  async ensureCaseLoaded(dbId){
    if (!dbId) return null;
    const hit = this.get().cases.find(c => c.dbId === dbId);
    if (hit) return hit;
    let seed;
    if (window.MarurouCloud) {
      seed = await window.MarurouCloud.loadSeed({ caseId:dbId });
    } else {
      const res = await fetch("/api/seed?case=" + encodeURIComponent(dbId));
      if (!res.ok) throw new Error("サーバが " + res.status + " を返しました");
      seed = await res.json();
    }
    const found = (seed && seed.cases && seed.cases[0]) || null;
    if (!found) return null;
    window.__MARUROU_APPLYING_REMOTE__ = true;
    try {
      this.update(m => {
        if (m.cases.some(c => c.dbId === found.dbId)) return;
        // サーバの番号が画面の別の案件と重なるときは空いている番号を振る(loadMoreCases と同じ・H1)
        const id = msFreeCaseId(found.id, new Set(m.cases.map(c => c.id)), 1);
        m.cases.push(id === found.id ? found : { ...found, id });
      });
    } finally { window.__MARUROU_APPLYING_REMOTE__ = false; }
    // 呼び出し側はこの id で案件を開くので、画面に入った形(振り直した id)を返す
    return this.get().cases.find(c => c.dbId === found.dbId) || found;
  },
});

/* 旧グローバル名は store から供給し、変更ごとに同期する（真実は1つ） */
function msSync(){ window.BV_ROWS = MStore.boardRows(); window.BV_CASEMETA = MStore.caseMeta(); window.TR_ROWS = MStore.trackRows(); }
MStore.sub(msSync); msSync();

Object.assign(window, { MStore, useMStore, msSync, msSeed, msSteps, msStep, msAllDone, msOpen, msStage, msTracks, msIns, msRail, MS_TODAY, MS_WORK_STEPS, MS_SURVEY_STEPS, MS_INS_STEPS,
  // 盤面・ダッシュボード-12(0049・決-20)。判定関数はテスト(tests/ui/)と
  // 他画面(DashboardV6.jsx)から差し替え・参照できるようにwindowへ出す。
  SLA_DEFAULTS, slaConfig, slaDueMarker, slaReportMarker, slaCaseStatus,
  caseHasWaiting, waitDisplay, surveyProgressLabel,
  // K18-13(0105): 漏水状況の選択肢と「継続中」の規則。CaseDetailV25.jsx の select と tests/ui/ が読む。
  LEAK_STATUS_OPTIONS, isLeakContinuing,
  // K18-14/15(0107/0108): 居住制限・利用制限の選択肢と、利用制限の切り替え規則。
  LIVING_RESTRICTION_OPTIONS, LIVING_UNLIVABLE, USAGE_RESTRICTION_OPTIONS, toggleUsageRestriction,
  // K18-16(0111): 住めない部屋がある案件の復旧工事に「進め方は P1 が目安」を出すか。CaseDetailV25.jsx と tests/ui/ が読む。
  unlivableApproachHint,
  // H2(9/25): 工事を足すときの工程の型の有無と、精算方式・進め方の初期値。CaseP1.jsx AddRecordModal と tests が読む。
  WORK_ROUTE_SETTLEMENTS, WORK_ROUTE_APPROACHES, workRouteGap, workApproachDefault, requesterWorkDefaults,
  // 盤面・TODO-7。todoSeverityはtests/ui/todo-severity.test.jsとTasks.jsxから読む。
  todoOverDays, todoSeverity });
