← Files tochi-satei-kunARCHIVED FILE

skills/tochi-satei-kun/scripts/xlsx_gyosha_sheet.py

60.2 KB · Oct 5, 2026 · 18:30 UTC

↓ Download file

# Copyright 2026 Koichi Matsuda / SignalYield Advisory
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""分割:業者用シート描画(v1.2.7、Cowork 読み込み対策)"""
from xlsx_common import *
from version import ENGINE_VERSION


# ===== 業者用シート =====
def _write_gyosha_sheet(wb: Workbook, ctx: dict):
    ws = wb.create_sheet("業者用")
    # 列幅を冒頭で設定(Cowork 配布層 truncate 対策。末尾の再設定もそのまま残す)
    _adjust_col_widths(ws, [14, 10, 12, 16, 12, 14, 12, 16, 10, 10, 10, 10, 12, 10])
    # 印刷範囲を冒頭で暫定設定(Cowork 配布層 truncate 対策。
    # 末尾で `r` の正確値に上書きするが、truncate された場合に備えてマージン付き暫定値を先に置く)
    ws.print_area = "A1:N200"
    # グラフ専用シートを 業者用 の直後(インデックス 1)に作成
    graph_ws = wb.create_sheet("グラフ", 1)
    # グラフシートのタイトル
    _set(graph_ws, 1, 1, "■ 附属資料",
         font=Font(name="游ゴシック", size=14, bold=True, color="FFFFFF"),
         fill=TITLE_FILL,
         align=Alignment(horizontal="left", vertical="center"))
    graph_ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=14)
    graph_ws.row_dimensions[1].height = 28
    # 列幅をグラフ表示用に調整
    for col_letter in 'ABCDEFGHIJKLMN':
        graph_ws.column_dimensions[col_letter].width = 10
    # グラフ配置用の running row tracker
    ctx['_graph_ws'] = graph_ws
    ctx['_graph_row'] = 3  # タイトル(1)+空行(2)
    target = ctx["target"]
    asof = ctx["asof"]
    scope_log = ctx["scope_log"]
    rate_info = ctx["rate_info"]
    hed = ctx["hedonic"]
    cases = ctx["cases"]
    breakdown = ctx["breakdown"]
    assess = ctx["assess"]
    refs = ctx["refs"]
    standard_check = ctx["standard_check"]
    hijun_rows = ctx.get("hijun_rows", [])
    hijun_detail_rows = ctx.get("hijun_detail_rows", [])

    r = 1
    # 認証マーカー(A1)— ハルシネーション出力との判別用、INSTALL.md 検証チェックリスト参照
    ws.merge_cells(start_row=r, start_column=1, end_row=r, end_column=8)
    _set(ws, r, 1, f"tochi-satei-kun v{ENGINE_VERSION} 認証出力",
         font=Font(name="游ゴシック", size=8, italic=True, color="808080"),
         align=Alignment(horizontal="left", vertical="center"))
    r += 1

    # タイトル(A2)
    ws.merge_cells(start_row=r, start_column=1, end_row=r, end_column=8)
    _set(ws, r, 1, f"土地価格査定 業者用シート — {target.get('物件略号', '')} ({target['市区町村名']} {target.get('地区名', '')})",
         font=TITLE_FONT, fill=TITLE_FILL,
         align=Alignment(horizontal="left", vertical="center"))
    ws.row_dimensions[r].height = 28
    r += 2

    # ヘッダ:物件概要
    _section_header(ws, r, "■ 物件概要・スコープ")
    r += 1
    info = [
        ("査定時点", asof.isoformat()),
        ("所在", f"{target['都道府県名']} {target['市区町村名']} {target.get('地区名', '')}{target.get('丁目', '')}"),
        ("面積", f"{target['面積(㎡)']} ㎡"),
        ("最寄駅", f"{target.get('最寄駅:名称', '')} 徒歩{target.get('最寄駅:距離(分)', '')}分"),
        ("形状", target.get("土地の形状", "")),
        ("接道", f"{target.get('前面道路:種類', '')} 幅員{target.get('前面道路:幅員(m)', '')}m {target.get('前面道路:方位', '')}向"),
        ("用途地域", target.get("都市計画", "")),
        ("建ぺい率/容積率", f"{target.get('建ぺい率(%)', '')}% / {target.get('容積率(%)', '')}%"),
        ("使用事例件数", f"{scope_log['final_count']} 件 (IQR除外: {scope_log['iqr_removed']}件、市区町村単位・隣接拡張なし)"),
    ]
    for label, value in info:
        _set(ws, r, 1, label, font=LABEL_FONT, border=True)
        ws.merge_cells(start_row=r, start_column=2, end_row=r, end_column=8)
        _set(ws, r, 2, value, font=VALUE_FONT, border=True)
        r += 1
    r += 1

    # 査定価格
    _section_header(ws, r, "■ 査定価格")
    r += 1
    target_area = target["面積(㎡)"]
    ws.merge_cells(start_row=r, start_column=1, end_row=r, end_column=8)
    _set(ws, r, 1,
         _format_price_full(assess["central_total_price"], target_area),
         font=BIG_VALUE_FONT,
         align=Alignment(horizontal="left", vertical="center"))
    ws.row_dimensions[r].height = 36
    r += 1

    # 信頼度ラベル(高/中/中-低/低):n × 自由度調整済 R² × 期待符号整合性ベース
    # 中-低 は「構造問題」(**統計的に有意な**符号反転2件以上 or adj_R² が極端に低い)に限定。
    # 非有意(p≥0.10)な符号反転はノイズ範囲内とみなしカウントしない。
    if hed["ok"]:
        n = hed["n"]
        adj_r2 = hed["adj_r2"]
        EXPECTED_NEG = ("ln_area", "walk_min", "D_shidou", "D_fukuro", "D_fuseikei")
        SIG_P_THRESHOLD = 0.10  # この p 値未満の反転のみ「有意な反転」としてカウント
        coef = hed["coefficients"]
        sign_inconsistent = sum(
            1 for name in EXPECTED_NEG
            if name in coef and coef[name]["beta"] > 0 and coef[name]["p"] < SIG_P_THRESHOLD
        )
        sign_checked = sum(1 for name in EXPECTED_NEG if name in coef)
        if sign_inconsistent >= 2 or adj_r2 < 0.3:
            reasons = []
            if sign_inconsistent >= 2:
                reasons.append(f"有意な符号反転 {sign_inconsistent}/{sign_checked} 件(p<{SIG_P_THRESHOLD})")
            if adj_r2 < 0.3:
                reasons.append(f"adj R² = {adj_r2:.2f}(低水準)")
            conf_label = (f"モデル適合度:要注意(n = {n}, "
                          + ", ".join(reasons)
                          + " — 構造問題の可能性、要再確認)")
            conf_fill = P_HIGH_FILL
        elif n >= 20 and adj_r2 >= 0.45 and sign_inconsistent == 0:
            conf_label = (f"モデル適合度:良好(n = {n}, 自由度調整済 R² = {adj_r2:.2f}, "
                          f"有意な期待符号と全整合)")
            conf_fill = P_LOW_FILL
        else:
            reasons = []
            if n < 20:
                reasons.append(f"事例件数 n = {n} と少なめ")
            if adj_r2 < 0.45:
                reasons.append(f"adj R² = {adj_r2:.2f}(中程度)")
            if sign_inconsistent == 1:
                reasons.append(f"有意な符号反転 1/{sign_checked} 件")
            if not reasons:
                reasons.append(f"n = {n}, adj R² = {adj_r2:.2f}")
            conf_label = "モデル適合度:中程度(" + " / ".join(reasons) + ")"
            conf_fill = P_MID_FILL
    else:
        conf_label = "モデル適合度:参考情報(件数不足のため係数推定不能。顧客用シートは『参考情報』として出力)"
        conf_fill = P_HIGH_FILL
    ws.merge_cells(start_row=r, start_column=1, end_row=r, end_column=8)
    _set(ws, r, 1, conf_label,
         font=Font(name="游ゴシック", size=11, bold=True),
         fill=conf_fill, border=True,
         align=Alignment(horizontal="left", vertical="center"))
    ws.row_dimensions[r].height = 24
    r += 2

    # 2価格サマリ:採用査定価格 vs ヘドニック母集団予測(信頼度ラベル直下に配置)
    target_area_local = target["面積(㎡)"]
    central_unit = assess.get("central_unit_price")
    hed_pred = refs.get("hedonic_pred")

    _section_header(ws, r, "■ 2価格サマリ(採用査定価格 vs ヘドニック母集団予測の乖離)")
    r += 1
    for j, h in enumerate(["区分", "㎡単価", "総額", "採用査定との乖離率"]):
        _set(ws, r, j+1, h, font=LABEL_FONT,
             fill=PatternFill("solid", fgColor="D9E1F2"), border=True)
    r += 1

    # ① 土地比準(採用)
    _set(ws, r, 1, "① 土地比準(採用)",
         font=Font(name="游ゴシック", size=10, bold=True),
         fill=PRIMARY_FILL, border=True)
    if central_unit:
        _set(ws, r, 2, f"{int(central_unit):,}円",
             font=Font(name="游ゴシック", size=10, bold=True), fill=PRIMARY_FILL, border=True)
        _set(ws, r, 3, _format_jpy(central_unit * target_area_local),
             font=Font(name="游ゴシック", size=10, bold=True), fill=PRIMARY_FILL, border=True)
    _set(ws, r, 4, "—", font=VALUE_FONT, fill=PRIMARY_FILL, border=True)
    r += 1

    # ② ヘドニック母集団予測
    if hed_pred and central_unit:
        dev = (hed_pred - central_unit) / central_unit * 100
        abs_dev = abs(dev)
        if abs_dev <= 15:
            dev_fill = P_LOW_FILL
            dev_guide = "(15%以内:採用査定とヘドニック予測が概ね整合)"
        elif abs_dev <= 30:
            dev_fill = P_MID_FILL
            dev_guide = "(15〜30%:地域特性または個別事例の特殊性を確認すると良い)"
        else:
            dev_fill = P_HIGH_FILL
            dev_guide = ("※ 30%超:規範性の高い事例が母集団から外れている可能性。"
                         "事例選定と特徴量を再確認してください。")
        _set(ws, r, 1, "② ヘドニック母集団予測", font=VALUE_FONT, border=True)
        _set(ws, r, 2, f"{int(hed_pred):,}円", font=VALUE_FONT, border=True)
        _set(ws, r, 3, _format_jpy(hed_pred * target_area_local), font=VALUE_FONT, border=True)
        _set(ws, r, 4, f"{dev:+.1f}%", font=VALUE_FONT, fill=dev_fill, border=True)
        r += 1
        ws.merge_cells(start_row=r, start_column=1, end_row=r, end_column=8)
        _set(ws, r, 1, dev_guide,
             font=Font(name="游ゴシック", size=9, italic=True, color="595959"))
        r += 1
    elif central_unit:
        _set(ws, r, 1, "② ヘドニック母集団予測", font=VALUE_FONT, border=True)
        ws.merge_cells(start_row=r, start_column=2, end_row=r, end_column=4)
        _set(ws, r, 2, "(件数不足のため算出不能)", font=VALUE_FONT, fill=MISSING_FILL, border=True)
        r += 1
    r += 1

    # 価格レンジ(比準表の試算値の最大/中央/最小と一致)
    _section_header(ws, r, "■ 価格レンジ(比準表の試算値 最大/中央/最小)")
    r += 1
    rng = assess["range"]
    headers = ["区分", "総額", "㎡単価", "坪単価"]
    for j, h in enumerate(headers):
        _set(ws, r, j+1, h, font=LABEL_FONT, fill=PatternFill("solid", fgColor="D9E1F2"), border=True)
    r += 1
    for label, total, unit in [
        ("上限", rng["high_total"], rng["high_unit"]),
        ("中央", rng["central_total"], rng["central_unit"]),
        ("下限", rng["low_total"], rng["low_unit"]),
    ]:
        total_r = _round_3sig(total) if total else None
        unit_sqm_r = _round_3sig(unit) if unit else None
        unit_tsubo_r = _round_3sig(unit / 0.3025) if unit else None
        _set(ws, r, 1, label, font=LABEL_FONT, border=True)
        _set(ws, r, 2, f"{total_r:,}円" if total_r else "", font=VALUE_FONT, border=True)
        _set(ws, r, 3, f"{unit_sqm_r:,}円" if unit_sqm_r else "", font=VALUE_FONT, border=True)
        _set(ws, r, 4, f"{unit_tsubo_r:,}円" if unit_tsubo_r else "", font=VALUE_FONT, border=True)
        r += 1
    r += 1

    # 比準表(建付減価列を削除した8列構成)
    if hijun_rows:
        _insert_page_break(ws, r)
        _section_header(ws, r, "■ 比準表(標準画地の比準価格)")
        r += 1
        # 列構成(8列、建付減価削除済み):
        # 1=事例番号, 2=取引価格, 3=事情補正, 4=時点修正,
        # 5=標準化補正, 6=地域格差, 7=試算値, 8=標準画地の価格
        header_fill = PatternFill("solid", fgColor="D9E1F2")
        for j, h in enumerate(["事例番号", "取引価格(円/㎡)", "事情補正", "時点修正",
                               "標準化補正", "地域格差",
                               "試算値(円/㎡)", "標準画地の価格(円/㎡)"]):
            _set(ws, r, j+1, h, font=LABEL_FONT, fill=header_fill, border=True,
                 align=Alignment(horizontal="center", vertical="center", wrap_text=True))
        ws.row_dimensions[r].height = 32
        r += 1
        # 試算値の中央値を標準画地の価格に
        # v1.2.5: 試算値は correction.py 側で上位3桁に丸め済み。median 計算後も
        # (偶数件で平均が走るケースに備えて)上位3桁に再度丸めて精度を揃える。
        n_rows = len(hijun_rows)
        shisan_list = sorted(h["試算値"] for h in hijun_rows)
        if n_rows % 2 == 1:
            hijun_central = shisan_list[n_rows // 2]
        else:
            hijun_central = (shisan_list[n_rows // 2 - 1] + shisan_list[n_rows // 2]) / 2
        hijun_central = _round_3sig(hijun_central)

        block_start_row = r
        center_align = Alignment(horizontal="center", vertical="center")
        # 表示順を [top2, top1, top3] に並び替え(規範性の高い事例を中央に配置、視覚強調なし)
        # hijun_rows は [top1, top2, top3] の順で来る
        if len(hijun_rows) == 3:
            display_rows = [hijun_rows[1], hijun_rows[0], hijun_rows[2]]
        elif len(hijun_rows) == 2:
            display_rows = [hijun_rows[1], hijun_rows[0]]
        else:
            display_rows = hijun_rows
        for idx, h in enumerate(display_rows):
            # 色強調なし(位置で識別:中央=規範性の高い事例)
            fill = None
            font_top = VALUE_FONT
            label_font = LABEL_FONT
            top_row = r
            bot_row = r + 1
            # 補正項目の分子/分母(鑑定書様式)
            # exp(beta * (target - case)) は事例から査定対象への倍率なので上段に表示する。
            jijo_top, jijo_bot = _hijun_top_bottom(h["事情補正"], h.get("事情補正_適用", False))
            time_top, time_bot = _hijun_top_bottom(h["時点修正"], mode="top")
            hyo_top, hyo_bot = _hijun_top_bottom(h["標準化補正"], mode="top")
            chi_top, chi_bot = _hijun_top_bottom(h["地域格差"], mode="top")
            # 上行(分子):列 3=事情補正, 4=時点修正, 5=標準化補正, 6=地域格差
            _set(ws, top_row, 3, jijo_top, font=font_top, fill=fill, border=True, align=center_align)
            _set(ws, top_row, 4, time_top, font=font_top, fill=fill, border=True, align=center_align)
            _set(ws, top_row, 5, hyo_top, font=font_top, fill=fill, border=True, align=center_align)
            _set(ws, top_row, 6, chi_top, font=font_top, fill=fill, border=True, align=center_align)
            # 下行(分母)
            _set(ws, bot_row, 3, jijo_bot, font=font_top, fill=fill, border=True, align=center_align)
            _set(ws, bot_row, 4, time_bot, font=font_top, fill=fill, border=True, align=center_align)
            _set(ws, bot_row, 5, hyo_bot, font=font_top, fill=fill, border=True, align=center_align)
            _set(ws, bot_row, 6, chi_bot, font=font_top, fill=fill, border=True, align=center_align)
            # 2行マージ:事例番号(1), 取引価格(2), 試算値(7)
            for col in [1, 2, 7]:
                ws.merge_cells(start_row=top_row, start_column=col,
                               end_row=bot_row, end_column=col)
            # 事例番号 = MLITデータ番号(透明性のため、人為的ラベルではない)
            case_no_str = str(h.get("事例番号", "?"))
            _set(ws, top_row, 1, case_no_str, font=label_font, fill=fill, border=True,
                 align=center_align)
            _set(ws, top_row, 2, f"{int(h['取引価格']):,}",
                 font=font_top, fill=fill, border=True, align=center_align)
            _set(ws, top_row, 7, f"{int(round(h['試算値'])):,}",
                 font=font_top, fill=fill, border=True, align=center_align)
            r += 2
        block_end_row = r - 1
        # 標準画地の価格列(8列目)を全事例マージ
        ws.merge_cells(start_row=block_start_row, start_column=8,
                       end_row=block_end_row, end_column=8)
        _set(ws, block_start_row, 8, f"{int(round(hijun_central)):,}",
             font=Font(name="游ゴシック", size=12, bold=True, color="C00000"),
             border=True,
             align=Alignment(horizontal="center", vertical="center"))
        # 注釈
        ws.merge_cells(start_row=r, start_column=1, end_row=r, end_column=8)
        _set(ws, r, 1,
             "※ 事例番号 = MLITデータ原本の行番号。正本補正後単価の中央値 = 3事例の試算値の中央値。"
             "各補正は「分子/分母」形式(上段=分子、下段=分母)。「100/-」は補正非該当。"
             "標準化補正・地域格差は exp(β×(本物件-事例)) の事例→査定対象倍率を上段に表示。"
             "標準化補正=画地条件(規模, 形状, 方位, 袋地, 不整形)、"
             "地域格差=地域・街路・交通要因(道路幅員, 駅徒歩, 容積率, 私道, 地区平均, 駅平均)のヘドニック係数積。"
             "**中央行(2段目)=規範性の高い事例**(top1)、上下行は検証用の類似事例。",
             font=Font(name="游ゴシック", size=9, italic=True, color="595959"),
             align=Alignment(wrap_text=True, vertical="top"))
        ws.row_dimensions[r].height = 55
        r += 2

    # 比準表の内訳:取引事例の補修正率と地域格差率(鑑定実務標準フォーマット、9列)
    if hijun_detail_rows:
        _section_header(ws, r, "■ 比準表の内訳(取引事例の補修正率と地域格差率)",
                        end_col=9)
        r += 1

        # 2段ヘッダ:上段は「地域格差」のグループラベル
        hdr_fill = PatternFill("solid", fgColor="D9E1F2")
        col_labels_top = ["事例番号", "事情補正", "時点修正",
                          "標準化補正", "地域格差", "", "", "", ""]
        for j, h in enumerate(col_labels_top):
            _set(ws, r, j+1, h, font=LABEL_FONT, fill=hdr_fill, border=True,
                 align=Alignment(horizontal="center", vertical="center", wrap_text=True))
        # 地域格差は4区分+相乗積マージ(列5-9)— 相乗積=地域格差の積であることを明示
        ws.merge_cells(start_row=r, start_column=5, end_row=r, end_column=9)
        # 単独列は2段マージ(縦)— 相乗積は地域格差サブヘッダ配下なので除外
        for col in [1, 2, 3, 4]:
            ws.merge_cells(start_row=r, start_column=col, end_row=r+1, end_column=col)
        r += 1
        # 下段:地域格差の4細目+相乗積
        col_labels_bot = ["", "", "", "", "街路条件\n(総和)",
                          "交通接近条件\n(総和)", "環境条件\n(総和)", "行政的条件\n(総和)",
                          "相乗積\n(地域格差積)"]
        for j, h in enumerate(col_labels_bot):
            if h:  # 既にマージされていないセルのみ
                _set(ws, r, j+1, h, font=LABEL_FONT, fill=hdr_fill, border=True,
                     align=Alignment(horizontal="center", vertical="center", wrap_text=True))
        ws.row_dimensions[r-1].height = 18
        ws.row_dimensions[r].height = 30
        r += 1

        def _fmt_pct(v):
            """+X.X / ±0 / ▲X.X 形式"""
            if v is None:
                return "―"
            v = round(v, 1)
            if abs(v) < 0.05:
                return "±0"
            if v > 0:
                return f"+{v}"
            return f"▲{abs(v)}"

        def _filter_nonzero(items):
            """サブ項目のうち ±0(絶対値<0.05)を除外。
            v1.2.1: 「地区」エントリは β=0.81 と高インパクトの最重要要因なので、
            ±0でも常時表示する(事例と本物件の地区が同じことを白箱性として明示)。
            """
            keep = []
            for lbl, pct in items:
                if lbl.startswith("地区"):
                    keep.append((lbl, pct))  # 地区は常時表示
                elif abs(round(pct, 1)) >= 0.05:
                    keep.append((lbl, pct))
            return keep

        def _join_subitems(items, hide_zero=True):
            """[(label, pct), ...] を multi-line text に。±0は非表示(hide_zero=True)。"""
            if hide_zero:
                items = _filter_nonzero(items)
            if not items:
                return "標準的 ±0"
            return "\n".join(f"{lbl} {_fmt_pct(pct)}" for lbl, pct in items)

        cell_font = Font(name="游ゴシック", size=9)
        center_align = Alignment(horizontal="center", vertical="center", wrap_text=True)

        # 取引事例 行(事例番号で表示)— 中央に規範性の高い事例(top1)を配置
        if len(hijun_detail_rows) == 3:
            display_detail = [hijun_detail_rows[1], hijun_detail_rows[0], hijun_detail_rows[2]]
        elif len(hijun_detail_rows) == 2:
            display_detail = [hijun_detail_rows[1], hijun_detail_rows[0]]
        else:
            display_detail = hijun_detail_rows
        for idx, d in enumerate(display_detail):
            fill = None
            f = cell_font
            _set(ws, r, 1, d.get("事例番号", "?"), font=f, fill=fill, border=True, align=center_align)
            jijo_lbl, jijo_pct = d.get("事情補正", ("正常", 0.0))
            _set(ws, r, 2, f"{jijo_lbl}\n{_fmt_pct(jijo_pct)}", font=f, fill=fill, border=True, align=center_align)
            _set(ws, r, 3, _fmt_pct(d.get("時点修正_pct", 0)), font=f, fill=fill, border=True, align=center_align)
            std_items = _filter_nonzero(d.get("規模", []) + d.get("画地", []))
            std_text = "\n".join(f"{lbl} {_fmt_pct(pct)}" for lbl, pct in std_items) if std_items else "標準的 ±0"
            std_text += f"\n総和 {_fmt_pct(d.get('標準化補正_総和', 0))}"
            _set(ws, r, 4, std_text, font=f, fill=fill, border=True, align=center_align)
            street_text = _join_subitems(d.get("街路", []))
            street_text += f"\n総和 {_fmt_pct(d.get('街路_総和', 0))}"
            _set(ws, r, 5, street_text, font=f, fill=fill, border=True, align=center_align)
            tr_text = _join_subitems(d.get("交通接近", []))
            tr_text += f"\n総和 {_fmt_pct(d.get('交通接近_総和', 0))}"
            _set(ws, r, 6, tr_text, font=f, fill=fill, border=True, align=center_align)
            env_text = _join_subitems(d.get("環境", []))
            env_text += f"\n総和 {_fmt_pct(d.get('環境_総和', 0))}"
            _set(ws, r, 7, env_text, font=f, fill=fill, border=True, align=center_align)
            adm_text = _join_subitems(d.get("行政", []))
            adm_text += f"\n総和 {_fmt_pct(d.get('行政_総和', 0))}"
            _set(ws, r, 8, adm_text, font=f, fill=fill, border=True, align=center_align)
            _set(ws, r, 9, d.get("相乗積", 100), font=f, fill=fill, border=True, align=center_align)
            ws.row_dimensions[r].height = 70
            r += 1

        ws.merge_cells(start_row=r, start_column=1, end_row=r, end_column=9)
        koji_rate = rate_info.get("rate", 0) or 0
        _set(ws, r, 1,
             f"※ 時点修正率査定根拠:地価公示の年次変動率を参考に、地域の地価動向を分析の上、"
             f"年率 {koji_rate*100:+.1f}% で査定({rate_info.get('method','')}, n = {rate_info.get('n_points', 0)} 地点)。"
             "各補正率は %-point 表記、相乗積は 100 を基準とする指数。"
             "**中央行(2段目)=規範性の高い事例**(top1)、上下行は検証用の類似事例。"
             "標準化補正の細目(規模・形状・方位)と地域格差の細目(街路・交通接近・環境・行政)は、"
             "対応するヘドニック係数を反映。±0 の細目は非表示。",
             font=Font(name="游ゴシック", size=9, italic=True, color="595959"),
             align=Alignment(wrap_text=True, vertical="top"))
        ws.row_dimensions[r].height = 50
        r += 2

    # ■ 個別格差 + 査定価格の算定(業者用:縦並び個別格差 + 横並び算定式)
    if hijun_rows:
        # 規範性の高い事例(top1)の個別格差を採用
        primary_h_gy = next((h for h in hijun_rows if h.get("順位") == "規範性の高い事例"),
                            hijun_rows[0])
        center_align_gy = Alignment(horizontal="center", vertical="center")
        right_align_gy = Alignment(horizontal="right", vertical="center")

        # 正本補正後単価の中央値(表示丸め済み)— assess と同じ価格系列を使用
        hijun_central_val = int(round(_round_3sig(assess.get("central_unit_price"))))

        # 個別格差ブロック開始行
        kobetsu_start_row = r
        _set(ws, r, 1, "■ 個別格差",
             font=Font(name="游ゴシック", size=11, bold=True, color="FFFFFF"),
             fill=SECTION_FILL,
             align=Alignment(horizontal="left", vertical="center"))
        ws.merge_cells(start_row=r, start_column=1, end_row=r, end_column=3)
        # 査定価格の算定ヘッダ(右側)
        _set(ws, r, 4, "■ 査定価格の算定",
             font=Font(name="游ゴシック", size=11, bold=True, color="FFFFFF"),
             fill=SECTION_FILL,
             align=Alignment(horizontal="left", vertical="center"))
        ws.merge_cells(start_row=r, start_column=4, end_row=r, end_column=8)
        r += 1

        def _fmt_kobetsu_v(v):
            iv = round(v, 1)
            return int(iv) if iv == int(iv) else iv

        # 個別格差 縦表(列A=ラベル、列B=値)
        # v1.2.1: target の属性をラベル括弧に転記(Style B)、target が中間画地の場合は角地行を非表示

        # 角地(target に角地補正率 > 0 が明示入力された場合のみ表示)
        target_kado_val_gy = primary_h_gy.get("個別格差_角地", 0)
        kado_row_gy = None  # 非表示の場合は None
        if abs(round(target_kado_val_gy, 1)) >= 0.05:
            _set(ws, r, 1, "角地(角地)", font=LABEL_FONT, border=True, align=center_align_gy)
            _set(ws, r, 2, _fmt_kobetsu_v(target_kado_val_gy),
                 font=VALUE_FONT, border=True, align=center_align_gy)
            kado_row_gy = r
            r += 1

        # 算定式の視覚アンカー行(角地が非表示なら方位の行に揃える)
        first_kobetsu_row = kado_row_gy if kado_row_gy is not None else r

        # 方位(target の方位を表示ラベルに)
        target_dir_gy = str(target.get("前面道路:方位", "")).strip()
        houi_label_gy = f"方位({target_dir_gy})" if target_dir_gy else "方位"
        houi_val = _fmt_kobetsu_v(primary_h_gy.get("個別格差_方位", 0))
        _set(ws, r, 1, houi_label_gy, font=LABEL_FONT, border=True, align=center_align_gy)
        _set(ws, r, 2, houi_val, font=VALUE_FONT, border=True, align=center_align_gy)
        houi_row_gy = r
        r += 1

        # 不整形(target の土地形状を表示ラベルに、v1.2.1)
        target_shape_gy = str(target.get("土地の形状", "")).strip()
        fusei_label_gy = f"不整形({target_shape_gy})" if target_shape_gy else "不整形"
        fusei_val = _fmt_kobetsu_v(primary_h_gy.get("個別格差_不整形", 0))
        _set(ws, r, 1, fusei_label_gy, font=LABEL_FONT, border=True, align=center_align_gy)
        _set(ws, r, 2, fusei_val, font=VALUE_FONT, border=True, align=center_align_gy)
        fusei_row_gy = r
        r += 1

        # 総和。個別格差値は説明表示のみで、価格へ再適用しない。
        _set(ws, r, 1, "総和", font=LABEL_FONT, fill=PatternFill("solid", fgColor="FFF2CC"),
             border=True, align=center_align_gy)
        soan_cell_gy = ws.cell(row=r, column=2, value=100)
        soan_cell_gy.font = Font(name="游ゴシック", size=10, bold=True)
        soan_cell_gy.border = BORDER
        soan_cell_gy.alignment = center_align_gy
        soan_cell_gy.number_format = "0.00"
        soan_cell_gy.fill = PatternFill("solid", fgColor="FFF2CC")
        soan_row_gy = r
        r += 1

        # ====== 査定価格の算定 行(個別格差ブロックの隣、first_kobetsu_row 行から横並び)======
        # 方位・不整形・明示角地は正本価格に適用済み。ここは検算用に100%を掛ける。
        # v1.2.1: 視覚アンカーは first_kobetsu_row(角地非表示時は方位の行)

        # 試算値 (D列、first_kobetsu_row 行)
        _set(ws, first_kobetsu_row, 4, hijun_central_val,
             font=Font(name="游ゴシック", size=12, bold=True),
             border=True, align=center_align_gy, number_format="#,##0")
        _set(ws, first_kobetsu_row+1, 4, "正本補正後単価の中央値(円/㎡)",
             font=Font(name="游ゴシック", size=9, italic=True, color="595959"),
             align=center_align_gy)

        # × 演算子 (E列)
        _set(ws, first_kobetsu_row, 5, "×",
             font=Font(name="游ゴシック", size=14, bold=True),
             align=center_align_gy)

        # 総和/100 (F列) — 常に100。説明表示の個別格差を再乗算しない。
        soan_ref_cell = ws.cell(row=first_kobetsu_row, column=6, value=100)
        soan_ref_cell.font = Font(name="游ゴシック", size=11, bold=True)
        soan_ref_cell.border = Border(left=THIN, right=THIN, top=THIN, bottom=Side(border_style="thin", color="000000"))
        soan_ref_cell.alignment = center_align_gy
        soan_ref_cell.number_format = "0.00"
        denom_cell = ws.cell(row=first_kobetsu_row+1, column=6, value=100)
        denom_cell.font = Font(name="游ゴシック", size=11, bold=True)
        denom_cell.border = Border(left=THIN, right=THIN, top=Side(border_style="thin", color="000000"), bottom=THIN)
        denom_cell.alignment = center_align_gy

        # ≒ (G列)
        _set(ws, first_kobetsu_row, 7, "≒",
             font=Font(name="游ゴシック", size=14, bold=True),
             align=center_align_gy)

        # 案件査定価格 (H列) — 正本補正後単価の中央値を直接参照
        anken_cell_gy = ws.cell(row=first_kobetsu_row, column=8, value=f"=D{first_kobetsu_row}")
        anken_cell_gy.font = Font(name="游ゴシック", size=14, bold=True, color="C00000")
        anken_cell_gy.border = BORDER
        anken_cell_gy.alignment = center_align_gy
        anken_cell_gy.number_format = "#,##0"
        _set(ws, first_kobetsu_row+1, 8, "採用査定単価(top3中央値)",
             font=Font(name="游ゴシック", size=9, italic=True, color="595959"),
             align=center_align_gy)

        # 個別格差 + 査定価格の算定 ブロックの後は r が総和の次に進んでいる
        r += 1  # 空行

        # 注釈
        ws.merge_cells(start_row=r, start_column=1, end_row=r, end_column=8)
        _set(ws, r, 1,
             "※ 個別格差は規範性の高い事例(top1)と本物件の差から算出。"
             "**角地補正は業者の入力値(デフォルト 0%)**。"
             "MLITデータに角地情報が無いためヘドニックで推定不能 → 白箱ポリシー上、自動値は与えず業者判断に委ねる。"
             "方位・不整形補正はヘドニック係数 β(dir_score, D_fuseikei)に基づく "
             "exp(β×(本物件 − 事例)) − 1 として正本価格へ1回だけ反映済み。"
             "左欄の方位・不整形等は規範事例と本物件との差を示す説明表示です。"
             "右欄の採用査定単価はtop3の正本補正後単価の中央値であり、"
             "左欄の値を再乗算するものではありません。",
             font=Font(name="游ゴシック", size=9, italic=True, color="595959"),
             align=Alignment(wrap_text=True, vertical="top"))
        ws.row_dimensions[r].height = 45
        r += 2

        # ■ 取引事例の概要(横並び、3事例の詳細データ)
        _insert_page_break(ws, r)
        _section_header(ws, r, "■ 取引事例の概要", end_col=12)
        r += 1
        gaiyo_headers = ["事例番号", "取引㎡単価", "取引時点", "地区", "最寄り駅",
                         "駅距離(分)", "道路", "道路幅員(m)", "方位", "形状",
                         "地積(㎡)", "用途地域", "容積率(%)"]
        hdr_fill_g = PatternFill("solid", fgColor="D9E1F2")
        # 13列に拡張、セクションヘッダのマージも更新
        ws.unmerge_cells(start_row=r-1, start_column=1, end_row=r-1, end_column=12)
        ws.merge_cells(start_row=r-1, start_column=1, end_row=r-1, end_column=13)
        for j, h in enumerate(gaiyo_headers):
            _set(ws, r, j+1, h, font=LABEL_FONT, fill=hdr_fill_g, border=True,
                 align=Alignment(horizontal="center", vertical="center", wrap_text=True))
        ws.row_dimensions[r].height = 30
        r += 1

        # 表示順は比準表と整合:[top2, top1, top3](中央=規範性の高い事例)
        if len(hijun_rows) == 3:
            gaiyo_display = [hijun_rows[1], hijun_rows[0], hijun_rows[2]]
        elif len(hijun_rows) == 2:
            gaiyo_display = [hijun_rows[1], hijun_rows[0]]
        else:
            gaiyo_display = hijun_rows

        def _fmt_or_dash(v, kind="num"):
            """欠損値は ― で表示。"""
            if v is None or v == "" or (isinstance(v, float) and pd.isna(v)):
                return "―"
            if kind == "int":
                try:
                    return int(v)
                except (TypeError, ValueError):
                    return str(v)
            if kind == "num":
                try:
                    return f"{int(round(float(v))):,}"
                except (TypeError, ValueError):
                    return str(v)
            return str(v)

        for h in gaiyo_display:
            _set(ws, r, 1, str(h.get("事例番号", "?")), font=VALUE_FONT, border=True, align=center_align_gy)
            _set(ws, r, 2, _fmt_or_dash(h.get("取引価格"), "num"),
                 font=VALUE_FONT, border=True, align=center_align_gy)
            _set(ws, r, 3, str(h.get("取引四半期", "") or h.get("取引時点", "")),
                 font=VALUE_FONT, border=True, align=center_align_gy)
            _set(ws, r, 4, str(h.get("地区", "")), font=VALUE_FONT, border=True, align=center_align_gy)
            _set(ws, r, 5, str(h.get("最寄駅", "")), font=VALUE_FONT, border=True, align=center_align_gy)
            _set(ws, r, 6, _fmt_or_dash(h.get("駅距離"), "int"),
                 font=VALUE_FONT, border=True, align=center_align_gy)
            _set(ws, r, 7, str(h.get("道路種別", "")), font=VALUE_FONT, border=True, align=center_align_gy)
            _set(ws, r, 8, _fmt_or_dash(h.get("道路幅員"), "int"),
                 font=VALUE_FONT, border=True, align=center_align_gy)
            _set(ws, r, 9, str(h.get("方位", "")), font=VALUE_FONT, border=True, align=center_align_gy)
            _set(ws, r, 10, str(h.get("形状", "")) or "—", font=VALUE_FONT, border=True, align=center_align_gy)
            _set(ws, r, 11, _fmt_or_dash(h.get("面積"), "int"),
                 font=VALUE_FONT, border=True, align=center_align_gy)
            _set(ws, r, 12, str(h.get("用途地域", "")), font=VALUE_FONT, border=True, align=center_align_gy)
            _set(ws, r, 13, _fmt_or_dash(h.get("容積率_pct"), "int"),
                 font=VALUE_FONT, border=True, align=center_align_gy)
            r += 1

        # 注釈
        ws.merge_cells(start_row=r, start_column=1, end_row=r, end_column=13)
        _set(ws, r, 1,
             "※ 事例番号 = MLITデータ原本の行番号。**中央行=規範性の高い事例**(top1)。"
             "取引時点は四半期表記(例:2025年第2四半期)。",
             font=Font(name="游ゴシック", size=9, italic=True, color="595959"),
             align=Alignment(wrap_text=True, vertical="top"))
        ws.row_dimensions[r].height = 30
        r += 2

    # ■ 公示価格の概要(業者用:取引事例の概要と同様の横並びテーブル)
    # 地域標準価格チェックで選定された公示標準地の詳細属性を表示
    koji_points_for_summary = standard_check.get("selected_points", []) if standard_check else []
    if koji_points_for_summary:
        _section_header(ws, r, "■ 公示価格の概要", end_col=12)
        r += 1
        koji_headers = ["公示番号", "公示価格(円/㎡)", "所在", "地区", "最寄駅",
                        "駅距離(m)", "道路", "道路幅員(m)", "方位", "形状",
                        "地積(㎡)", "用途地域", "容積率(%)"]
        # 13列に拡張、セクションヘッダのマージも更新
        ws.unmerge_cells(start_row=r-1, start_column=1, end_row=r-1, end_column=12)
        ws.merge_cells(start_row=r-1, start_column=1, end_row=r-1, end_column=13)
        hdr_fill_kj = PatternFill("solid", fgColor="D9E1F2")
        for j, h in enumerate(koji_headers):
            _set(ws, r, j+1, h, font=LABEL_FONT, fill=hdr_fill_kj, border=True,
                 align=Alignment(horizontal="center", vertical="center", wrap_text=True))
        ws.row_dimensions[r].height = 30
        r += 1

        def _fmt_or_dash_k(v, kind="num"):
            if v is None or v == "" or v == "_":
                return "―"
            try:
                if isinstance(v, float) and pd.isna(v):
                    return "―"
            except (TypeError, ValueError):
                pass
            if kind == "int":
                try:
                    return int(float(v))
                except (TypeError, ValueError):
                    return str(v)
            if kind == "num":
                try:
                    return f"{int(round(float(v))):,}"
                except (TypeError, ValueError):
                    return str(v)
            return str(v)

        center_align_kj = Alignment(horizontal="center", vertical="center")
        # 通常は1地点(場所による価格水準差を排除するため類似度スコアで絞込み)
        for pt in koji_points_for_summary[:5]:
            _set(ws, r, 1, _short_koji_id(str(pt.get("id", ""))),
                 font=VALUE_FONT, border=True, align=center_align_kj)
            _set(ws, r, 2, _fmt_or_dash_k(pt.get("price_at_asof"), "num"),
                 font=VALUE_FONT, border=True, align=center_align_kj)
            _set(ws, r, 3, _short_koji_addr(str(pt.get("address", "")),
                                             str(pt.get("district", ""))),
                 font=VALUE_FONT, border=True,
                 align=Alignment(horizontal="center", vertical="center"))
            _set(ws, r, 4, str(pt.get("district", "")),
                 font=VALUE_FONT, border=True, align=center_align_kj)
            _set(ws, r, 5, str(pt.get("station", "")),
                 font=VALUE_FONT, border=True, align=center_align_kj)
            _set(ws, r, 6, _fmt_or_dash_k(pt.get("station_dist_m"), "int"),
                 font=VALUE_FONT, border=True, align=center_align_kj)
            _set(ws, r, 7, str(pt.get("road_type", "")),
                 font=VALUE_FONT, border=True, align=center_align_kj)
            _set(ws, r, 8, _fmt_or_dash_k(pt.get("road_width"), "int"),
                 font=VALUE_FONT, border=True, align=center_align_kj)
            _set(ws, r, 9, str(pt.get("road_dir", "")),
                 font=VALUE_FONT, border=True, align=center_align_kj)
            _set(ws, r, 10, _koji_shape_label(pt.get("frontage_ratio"),
                                              pt.get("depth_ratio")),
                 font=VALUE_FONT, border=True, align=center_align_kj)
            _set(ws, r, 11, _fmt_or_dash_k(pt.get("area_sqm"), "int"),
                 font=VALUE_FONT, border=True, align=center_align_kj)
            _set(ws, r, 12, str(pt.get("zoning", "")),
                 font=VALUE_FONT, border=True, align=center_align_kj)
            _set(ws, r, 13, _fmt_or_dash_k(pt.get("floor_area_ratio"), "int"),
                 font=VALUE_FONT, border=True, align=center_align_kj)
            r += 1

        # 注釈
        ws.merge_cells(start_row=r, start_column=1, end_row=r, end_column=13)
        _set(ws, r, 1,
             "※ 公示番号 = 「市区町村-連番」形式(例:世田谷-50 = 13112-000-050)。"
             "公示価格は査定時点へ線形補間済み。"
             "形状は間口比率(L01_036)・奥行比率(L01_037)から推定(最大比 ≤1.5: 整形、≤2.5: やや細長、それ以上: 細長)。",
             font=Font(name="游ゴシック", size=9, italic=True, color="595959"),
             align=Alignment(wrap_text=True, vertical="top"))
        ws.row_dimensions[r].height = 40
        r += 2

    # 公示地価の時系列推移(折れ線グラフ)— 時点修正に使用した標準地と整合
    koji_ts_obj = ctx.get("koji_timeseries", {})
    if isinstance(koji_ts_obj, dict):
        koji_ts = koji_ts_obj.get("data", [])
        koji_label = koji_ts_obj.get("label", "")
    else:
        koji_ts = koji_ts_obj
        koji_label = ""
    if len(koji_ts) >= 2:
        ts_header_row = r
        _set(ws, r, 1,
             f"▼ {koji_label} の直近5年間の価格推移",
             font=Font(name="游ゴシック", size=10, bold=True, color="595959"))
        r += 1
        for j, h in enumerate(["評価年", "平均単価 (円/㎡)"]):
            _set(ws, r, j+1, h, font=LABEL_FONT,
                 fill=PatternFill("solid", fgColor="D9E1F2"), border=True,
                 align=Alignment(horizontal="center", vertical="center"))
        r += 1
        ts_data_start = r
        for pt in koji_ts:
            _set(ws, r, 1, pt["year"], font=VALUE_FONT, border=True,
                 align=Alignment(horizontal="center", vertical="center"))
            _set(ws, r, 2, pt["price"], font=VALUE_FONT, border=True,
                 number_format='#,##0',
                 align=Alignment(horizontal="right", vertical="center"))
            r += 1
        ts_data_end = r - 1

        # 折れ線グラフ(内部タイトルなし。グラフシートの section header に統一)
        line = LineChart()
        line.title = None
        line.legend = None
        line.height = 7
        line.width = 14
        data_ref = Reference(ws, min_col=2, min_row=ts_data_start, max_col=2, max_row=ts_data_end)
        cats_ref = Reference(ws, min_col=1, min_row=ts_data_start, max_col=1, max_row=ts_data_end)
        line.add_data(data_ref, titles_from_data=False)
        line.set_categories(cats_ref)
        line.y_axis.title = "単価 (円/㎡)"
        line.x_axis.title = "評価年"
        # Y軸範囲と目盛を明示設定(折れ線が中央に来るよう、かつ目盛を表示)
        prices = [pt["price"] for pt in koji_ts]
        y_min = min(prices)
        y_max = max(prices)
        if y_max > y_min:
            margin = (y_max - y_min) * 0.3
        else:
            margin = y_max * 0.05
        axis_min = max(0, y_min - margin)
        axis_max = y_max + margin
        line.y_axis.scaling.min = axis_min
        line.y_axis.scaling.max = axis_max
        # 目盛間隔を 5分割に
        line.y_axis.majorUnit = (axis_max - axis_min) / 5
        line.y_axis.delete = False
        line.y_axis.majorTickMark = 'out'
        line.y_axis.number_format = '#,##0'
        # データラベル:年 + 値 を併記
        dl_line = DataLabelList()
        dl_line.showVal = True
        dl_line.showCatName = True
        dl_line.showSerName = False
        dl_line.showLegendKey = False
        dl_line.position = 't'
        dl_line.separator = '\n'
        line.dataLabels = dl_line
        line.x_axis.delete = False
        line.series[0].smooth = False
        # グラフはグラフ専用シートに配置
        graph_ws_ref = ctx.get('_graph_ws')
        if graph_ws_ref is not None:
            gr = ctx.get('_graph_row', 3)
            # セクション見出し
            _set(graph_ws_ref, gr, 1,
                 f"■ {koji_label} の直近5年間の価格推移",
                 font=Font(name="游ゴシック", size=11, bold=True, color="FFFFFF"),
                 fill=SECTION_FILL,
                 align=Alignment(horizontal="left", vertical="center"))
            graph_ws_ref.merge_cells(start_row=gr, start_column=1, end_row=gr, end_column=14)
            graph_ws_ref.add_chart(line, f"A{gr+1}")
            ctx['_graph_row'] = gr + 17  # line chart 7cm ≈ 15行 + buffer
        else:
            ws.add_chart(line, f"D{ts_header_row}")
    r += 1

    # ヘドニック回帰サマリ + β符号チェック(末尾:技術詳細・係数全開示の参考情報)
    _insert_page_break(ws, r)
    _section_header(ws, r, "■ ヘドニック回帰サマリ(係数全開示・参考情報)")
    r += 1
    if hed["ok"]:
        _set(ws, r, 1, f"サンプル数 n = {hed['n']}", font=VALUE_FONT)
        _set(ws, r, 3, f"R² = {hed['r2']:.3f}", font=VALUE_FONT)
        _set(ws, r, 5, f"自由度調整済 R² = {hed['adj_r2']:.3f}", font=VALUE_FONT)
        r += 1
        for j, h in enumerate(["特徴量", "推定値 β", "標準誤差", "p値", "有意性"]):
            _set(ws, r, j+1, h, font=LABEL_FONT, fill=PatternFill("solid", fgColor="D9E1F2"), border=True)
        r += 1
        coef_data_start = r  # 表での開始行(チャートのカテゴリ範囲開始)
        for name, c in hed["coefficients"].items():
            if name == "const":
                continue  # グラフから定数項は除外
            p = c["p"]
            if p < 0.05: fill = P_LOW_FILL; sig = "** (p<0.05)"
            elif p < 0.10: fill = P_MID_FILL; sig = "*  (p<0.10)"
            else: fill = P_HIGH_FILL; sig = "ns"
            # 表示用:業者用シート column 1〜5(簡潔ラベル + 数値)
            _set(ws, r, 1, c["label"], font=VALUE_FONT, border=True)
            _set(ws, r, 2, float(c['beta']), font=VALUE_FONT, border=True,
                 number_format='+0.0000;-0.0000;0.0000')
            _set(ws, r, 3, float(c['se']), font=VALUE_FONT, border=True,
                 number_format='0.0000')
            _set(ws, r, 4, float(p), font=VALUE_FONT, border=True, fill=fill,
                 number_format='0.0000')
            _set(ws, r, 5, sig, font=VALUE_FONT, border=True, fill=fill)
            r += 1
        coef_data_end = r - 1
        # 定数項を末尾に追加(参考表示、グラフ対象外)
        if "const" in hed["coefficients"]:
            c = hed["coefficients"]["const"]
            p = c["p"]
            if p < 0.05: fill = P_LOW_FILL; sig = "** (p<0.05)"
            elif p < 0.10: fill = P_MID_FILL; sig = "*  (p<0.10)"
            else: fill = P_HIGH_FILL; sig = "ns"
            _set(ws, r, 1, c["label"], font=VALUE_FONT, border=True)
            _set(ws, r, 2, float(c['beta']), font=VALUE_FONT, border=True,
                 number_format='+0.0000;-0.0000;0.0000')
            _set(ws, r, 3, float(c['se']), font=VALUE_FONT, border=True,
                 number_format='0.0000')
            _set(ws, r, 4, float(p), font=VALUE_FONT, border=True, fill=fill,
                 number_format='0.0000')
            _set(ws, r, 5, sig, font=VALUE_FONT, border=True, fill=fill)
            r += 1
        r += 1

        # ヘドニック係数 棒グラフ(白箱AVM の象徴:全特徴量の β を可視化)
        bar = BarChart()
        bar.type = "bar"  # 横向き棒グラフ(特徴量名が長いので)
        bar.style = 11
        bar.title = None  # グラフシートの section header に統一
        bar.legend = None
        bar.height = 10  # cm
        bar.width = 16   # cm
        # チャートは業者用シートの column 1(特徴量名)と column 2(β値)を直接参照
        data_ref = Reference(ws, min_col=2, min_row=coef_data_start,
                             max_col=2, max_row=coef_data_end)
        cats_ref = Reference(ws, min_col=1, min_row=coef_data_start,
                             max_col=1, max_row=coef_data_end)
        bar.add_data(data_ref, titles_from_data=False)
        bar.set_categories(cats_ref)
        bar.y_axis.title = None
        bar.x_axis.title = "係数 β(負=単価↓、正=単価↑)"
        bar.y_axis.delete = False
        # データラベル:カテゴリ名 + 値 を表示(Y軸ラベルが Excel で表示されない問題への対処)
        dl = DataLabelList()
        dl.showVal = True
        dl.showCatName = True   # 棒の右側に「面積 +0.0729」形式で表示
        dl.showSerName = False
        dl.showLegendKey = False
        dl.position = 'outEnd'
        dl.separator = ' '
        bar.dataLabels = dl
        # グラフはグラフ専用シートに配置
        graph_ws_ref = ctx.get('_graph_ws')
        if graph_ws_ref is not None:
            gr = ctx.get('_graph_row', 3)
            _set(graph_ws_ref, gr, 1,
                 "■ ヘドニック回帰係数 β(単価への影響度・対数空間)",
                 font=Font(name="游ゴシック", size=11, bold=True, color="FFFFFF"),
                 fill=SECTION_FILL,
                 align=Alignment(horizontal="left", vertical="center"))
            graph_ws_ref.merge_cells(start_row=gr, start_column=1, end_row=gr, end_column=14)
            graph_ws_ref.add_chart(bar, f"A{gr+1}")
            ctx['_graph_row'] = gr + 24  # bar chart 10cm ≈ 22行 + buffer
        else:
            anchor_cell = f"G{coef_data_start}"
            ws.add_chart(bar, anchor_cell)

        # β符号チェック(期待符号 vs 実際の符号)
        EXPECTED_SIGNS = {
            "ln_area": ("負", "面積大→単価下落"),
            "walk_min": ("負", "駅遠→単価下落"),
            "D_shidou": ("負", "私道→減価"),
            "D_fukuro": ("負", "袋地→減価"),
            "D_fuseikei": ("負", "不整形→減価"),
        }
        coef = hed["coefficients"]
        sign_check_label = Font(name="游ゴシック", size=10, bold=True, color="595959")
        _set(ws, r, 1, "▼ β符号チェック(期待符号 vs 実際)", font=sign_check_label)
        r += 1
        for j, h in enumerate(["特徴量", "期待符号", "実際 β", "整合", "経済的解釈"]):
            _set(ws, r, j+1, h, font=LABEL_FONT,
                 fill=PatternFill("solid", fgColor="D9E1F2"), border=True)
        r += 1
        any_significant_inconsistent = False
        for name, (expected, interpretation) in EXPECTED_SIGNS.items():
            if name not in coef:
                continue
            beta = coef[name]["beta"]
            p = coef[name]["p"]
            is_neg_expected = (expected == "負")
            is_consistent = (is_neg_expected and beta < 0) or (not is_neg_expected and beta > 0)
            is_significant = p < 0.10  # p<0.10 で統計的に有意
            if not is_consistent and is_significant:
                any_significant_inconsistent = True
                mark = "× 反転(有意・要確認)"
                ok_fill = P_HIGH_FILL
            elif not is_consistent:
                mark = "△ 反転(非有意・ノイズ範囲)"
                ok_fill = P_MID_FILL
            else:
                mark = "○ 整合"
                ok_fill = P_LOW_FILL
            _set(ws, r, 1, coef[name]["label"], font=VALUE_FONT, border=True)
            _set(ws, r, 2, expected, font=VALUE_FONT, border=True)
            _set(ws, r, 3, f"{beta:+.4f}", font=VALUE_FONT, border=True)
            _set(ws, r, 4, mark, font=VALUE_FONT, border=True, fill=ok_fill)
            _set(ws, r, 5, interpretation, font=VALUE_FONT, border=True)
            r += 1
        if any_significant_inconsistent:
            ws.merge_cells(start_row=r, start_column=1, end_row=r, end_column=8)
            _set(ws, r, 1,
                 "※ 統計的に有意な符号反転(p<0.10)は外れ値・特徴量不足・地区特性などの構造問題の可能性。事例を再確認してください。"
                 "非有意な反転(△)はノイズ範囲内のため実害なし。",
                 font=VALUE_FONT, fill=WARN_FILL)
            r += 1
    else:
        ws.merge_cells(start_row=r, start_column=1, end_row=r, end_column=8)
        _set(ws, r, 1, f"※ {hed['skip_reason']}(類似度ベース集約に降格)",
             font=VALUE_FONT, fill=WARN_FILL)
        r += 1
    r += 1

    # 散布図:全事例の駅距離 vs 時点修正後単価(査定価格と top3 を強調)
    adjusted_full = ctx.get("adjusted_full")
    if adjusted_full is not None and len(adjusted_full) >= 10:
        scatter_header_row = r
        _set(ws, r, 1, "▼ 散布図:駅距離 vs 単価(全事例・比較事例・査定価格)",
             font=Font(name="游ゴシック", size=10, bold=True, color="595959"))
        r += 1

        # データを 列 18-23 に書き込み(右側に隠れる、列幅も狭く)
        # 18=全事例 X, 19=全事例 Y, 20=top3 X, 21=top3 Y, 22=対象 X, 23=対象 Y
        scoped_data = []
        for _, rw in adjusted_full.iterrows():
            walk = rw.get("walk_min")
            price = rw.get("adjusted_unit_price") if "adjusted_unit_price" in rw else rw.get("unit_price")
            try:
                if walk is not None and not pd.isna(walk) and price is not None and not pd.isna(price):
                    scoped_data.append((float(walk), float(price)))
            except (TypeError, ValueError):
                pass

        # ヘッダ行(列幅縮小用に色だけつけて値は書かない)
        scatter_data_start = r
        for i, (walk, price) in enumerate(scoped_data):
            ws.cell(row=scatter_data_start + i, column=18, value=walk)
            ws.cell(row=scatter_data_start + i, column=19, value=price)
        scatter_scoped_end = scatter_data_start + len(scoped_data) - 1

        # top3 を column 20-21 に
        top3_data = []
        for _, rw in cases.iterrows():
            walk = rw.get("walk_min")
            price = rw.get("corrected_unit_price")
            if price is None or pd.isna(price):
                price = rw.get("adjusted_unit_price") or rw.get("unit_price")
            try:
                if walk is not None and not pd.isna(walk) and price is not None and not pd.isna(price):
                    top3_data.append((float(walk), float(price)))
            except (TypeError, ValueError):
                pass
        for i, (walk, price) in enumerate(top3_data):
            ws.cell(row=scatter_data_start + i, column=20, value=walk)
            ws.cell(row=scatter_data_start + i, column=21, value=price)
        scatter_top3_end = scatter_data_start + max(0, len(top3_data) - 1)

        # 査定価格を column 22-23 に(1点)
        target_walk = target.get("最寄駅:距離(分)")
        target_price = assess.get("central_unit_price")
        target_present = False
        if target_walk is not None and target_price is not None:
            try:
                ws.cell(row=scatter_data_start, column=22, value=float(target_walk))
                ws.cell(row=scatter_data_start, column=23, value=float(target_price))
                target_present = True
            except (TypeError, ValueError):
                pass

        # データ列の幅を狭く(右に隠す)
        for col_letter in ['R', 'S', 'T', 'U', 'V', 'W']:
            ws.column_dimensions[col_letter].width = 3

        # 散布図(マーカーのみ、線なし)
        from openpyxl.chart.marker import Marker
        from openpyxl.chart.shapes import GraphicalProperties
        from openpyxl.drawing.line import LineProperties
        from openpyxl.drawing.fill import ColorChoice

        sc = ScatterChart()
        sc.title = None  # グラフシートの section header に統一
        sc.style = 13
        sc.height = 9
        sc.width = 16
        sc.scatterStyle = "marker"  # 線なし、マーカーのみ
        sc.x_axis.title = "最寄駅徒歩(分)"
        sc.y_axis.title = "時点修正後単価 (円/㎡)"

        def _styled_series(y_ref, x_ref, title, color, size, symbol='circle'):
            ser = Series(y_ref, x_ref, title=title)
            # 線を非表示
            ser.graphicalProperties = GraphicalProperties()
            ser.graphicalProperties.line = LineProperties(noFill=True)
            # マーカー設定
            mk = Marker(symbol=symbol, size=size)
            mk.graphicalProperties = GraphicalProperties(solidFill=color)
            mk.graphicalProperties.line = LineProperties(solidFill=color)
            ser.marker = mk
            return ser

        # Series 1: 全事例(青小マーカー)
        if scoped_data:
            x_all = Reference(ws, min_col=18, min_row=scatter_data_start, max_col=18, max_row=scatter_scoped_end)
            y_all = Reference(ws, min_col=19, min_row=scatter_data_start, max_col=19, max_row=scatter_scoped_end)
            sc.series.append(_styled_series(y_all, x_all, "全事例", "4472C4", 4, 'circle'))
        # Series 2: top3(赤大マーカー)
        if top3_data:
            x_t3 = Reference(ws, min_col=20, min_row=scatter_data_start, max_col=20, max_row=scatter_top3_end)
            y_t3 = Reference(ws, min_col=21, min_row=scatter_data_start, max_col=21, max_row=scatter_top3_end)
            sc.series.append(_styled_series(y_t3, x_t3, "比較事例top3", "C00000", 9, 'diamond'))
        # Series 3: 査定価格(緑★大マーカー)
        if target_present:
            x_tg = Reference(ws, min_col=22, min_row=scatter_data_start, max_col=22, max_row=scatter_data_start)
            y_tg = Reference(ws, min_col=23, min_row=scatter_data_start, max_col=23, max_row=scatter_data_start)
            sc.series.append(_styled_series(y_tg, x_tg, "査定価格", "00B050", 14, 'star'))

        # グラフはグラフ専用シートに配置
        graph_ws_ref = ctx.get('_graph_ws')
        if graph_ws_ref is not None:
            gr = ctx.get('_graph_row', 3)
            _set(graph_ws_ref, gr, 1,
                 "■ 散布図:駅距離 vs 単価(全事例青、比較事例top3赤、査定価格緑)",
                 font=Font(name="游ゴシック", size=11, bold=True, color="FFFFFF"),
                 fill=SECTION_FILL,
                 align=Alignment(horizontal="left", vertical="center"))
            graph_ws_ref.merge_cells(start_row=gr, start_column=1, end_row=gr, end_column=14)
            graph_ws_ref.add_chart(sc, f"A{gr+1}")
            ctx['_graph_row'] = gr + 21  # scatter chart 9cm ≈ 19行 + buffer
        else:
            ws.add_chart(sc, f"G{scatter_header_row}")
        # スキップして次のセクションへ進む(データ書き込みは右側列なので r は変えない)
        r += 2

    _adjust_col_widths(ws, [14, 10, 12, 16, 12, 14, 12, 16, 10, 10, 10, 10, 12, 10])
    # 印刷範囲を明示指定(散布図用の隠しデータ R-W 列 / row 1671 までを印刷から除外)
    ws.print_area = f"A1:N{r}"


# ===== 顧客用シート =====

SHA-256: 617af64bc76d2a47c89dfbe112c55142c2beff5a67b46e38ab87d99e5e08a39c