Newer
Older
client_dwatch / docs / handoff / pdf / make_teamworks_qa9_pdf.py
# -*- coding: utf-8 -*-
"""
TeamWorks 宛 確認事項(第9回)を PDF にする。

第1回〜第8回(make_ddl_review_pdf.py / make_teamworks_qa2_pdf.py 〜
make_teamworks_qa8_pdf.py)の体裁をそのまま踏襲した続編。
A4 縦 / reportlab / 日本語フォント埋め込み / フッター罫線とページ番号。

フォントは BIZ UDGothic(日本語フォント)を埋め込む。
中国語向けフォント(SimSun / Microsoft YaHei / Noto Sans SC / STSong 等)は
漢字の字形が異なるため使わない。フォールバックにも混ぜないこと(CLAUDE.md §0-3)。

BIZ UDGothic は等幅(半角は 0.5em 固定)なので、コードの枠にもこのフォントを使う。
コードブロック内の空白は   に置換して桁を保持する。

原稿は docs/handoff/TeamWorks確認事項_第9回_20260915.md。
文言・件数・表の内容は原稿から変更していない。
原稿の ★(星)は PDF には出さず、太字で表現する(第2回からの作法)。

★2026-09-16 の改訂★
  原稿に12章「DB接続経路について — お願い3件・確認6問」が加わった
  (設問 20問 → 26問 / お願い 10件 → 13件)。
  初版(2026-09-15)の末尾にあった「参考: テスト環境について(申し送り)」は、
  ★12章に吸収したので削除した★(重複させない)。
  このスクリプトは .md を読まず本文を転記しているため、原稿を直したら
  ★必ずこちらも直すこと★。直さないと PDF だけ古いまま残る。

★2026-09-17(2回目)の改訂★
  ★★テスト環境のDBに入っているデータは、1行残らず当方(DFA)が投入した
  検証用のダミーだと判明した★★(調査は docs/handoff/テストDBのデータ出所_20260917.md)。
  ★先方由来なのは「器」=スキーマ・索引・型・サーバー変数・権限だけ★なので、
  ★「行の中身」を根拠にしていた記述を原稿が全部取り下げた★。追随させたのは次のとおり。
  1. ★文書ヘッダの改訂点ブロックを2つにした★
     (新設の「2026-09-17(2回目)」+既存を「(1回目)」に降格)。
  2. ★はじめに に「おことわり — テスト環境のデータは当方が入れたものでした」
     (2行の表つき)と「本書の構成」を新設★。中心は「11章・12章」→
     ★「12章。あわせて3章・4章・6章」★。
  3. 構成表の 2 / 5 / 6 / 10 / 11 章を差し替え(5章・10章は種別「ご報告」・
     優先度「—」)。★件数は 質問25問 / お願い5件 / 参考情報1章★。
  4. 1章 … 引用ブロック3つ(行数はダミー由来で、見るべきは type/possible_keys/key
     という構造)+1-4・1-5 の注記。計算過程の「1.45回」は★実測→当方の置き値★へ。
  5. ★2章 … 実測表(6列4行)を削除★。2-1 を全面差し替え、★Q2-2 を削除★、
     Q2-1 の選択肢を3行→2行。
  6. ★5章 … 章ごと差し替え(設問・表なし)★。
  7. 6章 … 「なぜ「清掃実施回数」が要るのか(Q6-2 の理由)」を新設。
  8. 7章 … 7-1 のコード枠を削除、Q7-4 のコード枠を2クエリに減らし引用ブロックを追加。
  9. 8章 … 8-1 の表を差し替え、★Q8-3 を「固定値」→「下限・上限・変更の頻度」★へ。
  10. 9章 … Q9-1 の末尾2行を差し替え。
  11. ★10章 … 章ごと差し替え(すでに発行済み。SHOW GRANTS のコード枠つき)★。
      ★IDENTIFIED BY PASSWORD のハッシュは絶対に載せない★(原稿も伏せてある)。
  12. ★11章 … 章タイトルを「清掃の開始時刻と、リアルタイム性について」に。
      Q11-1 は「取り下げ」ではなく★実装の確認★として作り直し、delay 集計表と
      SQL枠を削除、★Q11-4 を新設★★。
      ★★Q11-1 は生きた設問である(質問25問に数える)。「取り下げました」
        「解決しましたので取り下げ」「残り3問に縮小」と書かないこと★★ —
      ★初版の改訂ではそう書いてしまい、同じ節に生きた Q11-1 がある矛盾になった。
        2026-09-17 に本文側が直し、こちらも追随している。
        点検スクリプトの 12. の FORBIDDEN が旧文言の残存を見る★
  13. 末尾に「取り下げた項目」と★「取り下げではなく、お伺いする内容を改めた項目」★の
      2段落を追加(★Q11-1 は後者★)。
  ★12章は1文字も変えていない。★
  ★点検スクリプトの 12. の FORBIDDEN が、取り下げた語(2章の実測表・Q2-2・Q5-1・
    旧Q11-1・10章の依頼文)の残存を見る。文言を戻す場合はそちらも直すこと。★

★2026-09-17(1回目)の改訂★
  原稿が2点の新情報で書き換わった(★索引は TeamWorks が対応中★ /
  ★現状のロボット台数は30台未満★)。追随させたのは次のとおり。
  1. ★1章を「ご依頼(7件)・最優先」から「索引について(参考情報)」へ格下げ★。
     1-0(本章の位置づけ)を新設し、1-2〜1-5 の見出しから「ご依頼」を外し、
     1-6 を「①〜⑦の一覧(突き合わせ用)」の表に差し替えた。
  2. ★規模の前提を「施設200件」から「ロボット30台」に置き直した★
     (1-2 の走査行数表 2年=約193万行 / 1-4 / 2-4 の桁あふれ 約9,100㎡)。
     計算過程のコード枠を 1-2・1-4・2-4 に足している。
  3. ★8章を「本番規模の見積もり」から「制御盤の報告間隔」に差し替え★。
     Q8-1・Q8-2 を削除(設問 28問 → ★26問★ / お願い 13件 → ★6件★)。
     ★番号は詰めず Q8-3 のまま★(原稿の断り書きどおり)。
  4. 文書ヘッダに「2026-09-17 の改訂点」の引用ブロックを足し、はじめに・
     構成表・おわりにを差し替えた。
  ★12章は1文字も変えていない。★
  ★点検スクリプト(check_teamworks_qa9_pdf.py)の 12. が、取り下げた語
    (「索引のご依頼」「ご依頼①②」「本番規模の見積もり」等)が PDF に
    残っていないかを見る。文言を戻す場合はそちらも直すこと。★

★第8回から足したもの★
  なし。部品(st / table / form_table / code / quote / callout / qhead /
  blank_box / bull / on_page)は make_teamworks_qa8_pdf.py をそのまま流用し、
  表データと build() の本文だけ差し替えている。

★原稿との差分(意図的なもの。文言は足していない)★
  1. 巻末に「ご回答欄」を付けた。第2回〜第8回と同じ作法。
     第9回の原稿には節としては無いが、設問の採番と要約だけを転記したもので、
     新しい主張は書いていない。
  2. 原稿の「## 2-2.」直下は Q2-1 / Q2-3 の2問で、見出しの「(2問)」と一致する
     (★2026-09-17 の2回目の改訂で Q2-2 を取り下げた。番号は詰めていない★)。
  3. 原稿の7章は Q7-5 が Q7-4 より前に置かれている。★原稿の順序のまま出す★。
     ★2026-09-17 の改訂で、Q7-5 の直前にその断り書き(引用ブロック)が
       原稿に加わった。順序を入れ替えるのではなく、断り書きごと出すこと★
  4. 原稿のはじめにの構成表は 1〜12 の12行。
     「次の12部構成です。」という本文も含め、原稿のまま出す。
     ★「N部構成」の N・構成表の行数・本文の章数の3つは必ず一致させること★
     (点検スクリプトの 7. がこの3つを別々に数えて突き合わせる)。

★列幅の決め方(第7回・第8回と同じ)★
wordWrap="CJK" は ASCII の語中でも改行する。そのため長い識別子は
  (1) 行頭に来るよう <br/> で明示的に改行してから置く
  (2) 列の内寸(列幅 - 左右パディング 12pt)が識別子の幅
      (BIZ UDGothic は半角 0.5em なので フォントサイズ × 文字数 ÷ 2)
      を上回るように列幅を決める
の2点で語中改行を避ける。

第9回でいちばん長い識別子は
  clean_status_histories_clean_timetable_id_index (47字)
  robot_error_reports_clean_timetable_id_index    (43字)
の2本で、いずれも ALTER TABLE 文(コード枠)の中にしか出てこない。
コード枠の内寸は 168mm - 14pt = 462pt、8.2pt での 47字は 192.7pt なので余裕がある。
本文・表に出る長い識別子は
  customers_group_id_foreign      (26字 → 8.8pt で 114.4pt = 40.4mm)
  control_reports_control_id_foreign(34字 → 9.5pt で 161.5pt = 57.0mm)
  facilities.customer_id          (22字 → 8.8pt で  96.8pt = 34.2mm)
  clean_status_histories          (22字)
で、それぞれ 7-4 の選択肢表の「内容」列 68mm、1-5 の本文(<br/> で行頭に落とす)、
7-5 の解釈表の「DBでの表現」列 90mm に収めている。
"""
import os

from reportlab.lib import colors
from reportlab.lib.enums import TA_LEFT
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import mm
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.lib.fonts import addMapping
from reportlab.platypus import (
    BaseDocTemplate, CondPageBreak, Frame, PageBreak, PageTemplate, Paragraph,
    Spacer, Table, TableStyle,
)

FONT_DIR = "C:/Windows/Fonts/"
pdfmetrics.registerFont(TTFont("JP", FONT_DIR + "BIZ-UDGothicR.ttc", subfontIndex=0))
pdfmetrics.registerFont(TTFont("JP-B", FONT_DIR + "BIZ-UDGothicB.ttc", subfontIndex=0))
# <b> タグを太字フォントへ対応づける
addMapping("JP", 0, 0, "JP")
addMapping("JP", 1, 0, "JP-B")

# 出力先。既定はこのスクリプトと同じディレクトリ。
# 環境変数 DWATCH_PDF_OUT で変えられる(例: 送付前に Downloads へ出す)。
OUT_DIR = os.environ.get("DWATCH_PDF_OUT",
                         os.path.dirname(os.path.abspath(__file__)))
OUT = os.path.join(OUT_DIR, "DWatch_確認事項_第9回_20260915.pdf")

# 第1回 PDF と同一のパレット
INK = colors.HexColor("#1e293b")
SUB = colors.HexColor("#475569")
MUT = colors.HexColor("#94a3b8")
LINE = colors.HexColor("#cbd5e1")
HEAD_BG = colors.HexColor("#f1f5f9")
CODE_BG = colors.HexColor("#f8fafc")
NG = colors.HexColor("#b91c1c")   # 実装が止まる区分の強調色
WN = colors.HexColor("#b45309")   # 定義の確認区分の強調色

ss = getSampleStyleSheet()


def st(name, size, leading, font="JP", color=INK, space_after=0, space_before=0,
       left=0, first=0):
    """段落スタイルを作る。left/first でぶら下げインデントを表現する。"""
    s = ParagraphStyle(
        name, parent=ss["Normal"], fontName=font, fontSize=size, leading=leading,
        textColor=color, alignment=TA_LEFT, spaceAfter=space_after,
        spaceBefore=space_before, wordWrap="CJK",
        leftIndent=left, firstLineIndent=first,
    )
    s.bulletFontName = font
    return s


S_TITLE = st("t", 17, 24, "JP-B", INK, 2)
S_SUB = st("s", 9.5, 15, "JP", SUB, 0)
S_H2 = st("h2", 12.5, 18, "JP-B", INK, 4, 10)
S_H3 = st("h3", 10.5, 16, "JP-B", INK, 3, 8)
S_H4 = st("h4", 9.8, 15, "JP-B", SUB, 2, 6)
S_BODY = st("b", 9.5, 15.5, "JP", INK, 4)
S_LEAD = st("ld", 9.5, 15.5, "JP", INK, 2)          # 直後にコード枠や表が続く導入文
S_NOTE = st("n", 9, 14.5, "JP", SUB, 4)
S_LIST = st("li", 9.5, 15.5, "JP", INK, 2, 0, 15, -15)   # 「1. 」のぶら下げ
S_BULL = st("bu", 9.5, 15.5, "JP", INK, 2, 0, 14, -14)   # 「・」のぶら下げ
S_CELL = st("c", 8.8, 13.8, "JP", INK)
S_CELL_S = st("cs", 8.8, 13.8, "JP", SUB)
S_CODE = st("cd", 8.2, 12.6, "JP", INK)
S_TH = st("th", 8.4, 12, "JP-B", SUB)
S_QUOTE = st("q", 9.2, 14.8, "JP", SUB)
S_CALL = st("ca", 10.5, 17, "JP-B", INK)   # 目立たせたい一文を囲む枠の中身
S_QH = st("qh", 10, 15.5, "JP-B", INK)     # 設問(Q2-1 …)の見出し帯


def P(t, s=S_CELL):
    return Paragraph(t, s)


def on_page(canvas, doc):
    """全ページ共通のフッター罫線・ページ番号。第1回の PDF と同一。"""
    canvas.saveState()
    canvas.setFont("JP", 8)
    canvas.setFillColor(MUT)
    canvas.drawString(18 * mm, 12 * mm, "D-Watch / DFA Robotics")
    canvas.drawRightString(A4[0] - 18 * mm, 12 * mm, "%d" % doc.page)
    canvas.setStrokeColor(LINE)
    canvas.setLineWidth(0.4)
    canvas.line(18 * mm, 15.5 * mm, A4[0] - 18 * mm, 15.5 * mm)
    canvas.restoreState()


TBL_STYLE = [
    ("FONTNAME", (0, 0), (-1, -1), "JP"),
    ("BACKGROUND", (0, 0), (-1, 0), HEAD_BG),
    ("LINEBELOW", (0, 0), (-1, 0), 0.6, LINE),
    ("LINEBELOW", (0, 1), (-1, -2), 0.3, LINE),
    ("VALIGN", (0, 0), (-1, -1), "TOP"),
    ("TOPPADDING", (0, 0), (-1, -1), 5),
    ("BOTTOMPADDING", (0, 0), (-1, -1), 5),
    ("LEFTPADDING", (0, 0), (-1, -1), 6),
    ("RIGHTPADDING", (0, 0), (-1, -1), 6),
]

# 空欄にご記入いただく表。空のセルにも枠が見えるよう GRID を引く。
FORM_STYLE = [
    ("FONTNAME", (0, 0), (-1, -1), "JP"),
    ("BACKGROUND", (0, 0), (-1, 0), HEAD_BG),
    ("GRID", (0, 0), (-1, -1), 0.3, LINE),
    ("LINEBELOW", (0, 0), (-1, 0), 0.6, LINE),
    ("VALIGN", (0, 0), (-1, -1), "TOP"),
    ("TOPPADDING", (0, 0), (-1, -1), 7),
    ("BOTTOMPADDING", (0, 0), (-1, -1), 7),
    ("LEFTPADDING", (0, 0), (-1, -1), 6),
    ("RIGHTPADDING", (0, 0), (-1, -1), 6),
]


def table(rows, widths, cell_styles=None, head_color=None):
    """rows[0] を見出し行とする表。cell_styles で列ごとの段落スタイルを指定する。"""
    if cell_styles is None:
        cell_styles = [S_CELL] * len(rows[0])
    data = [[P(c, S_TH) for c in rows[0]]]
    for r in rows[1:]:
        data.append([P(c, cell_styles[i]) for i, c in enumerate(r)])
    t = Table(data, colWidths=widths, repeatRows=1)
    style = list(TBL_STYLE)
    if head_color is not None:
        style.append(("TEXTCOLOR", (0, 1), (0, -1), head_color))
    t.setStyle(TableStyle(style))
    return t


def form_table(rows, widths, cell_styles=None):
    """ご記入いただく表。table() に GRID を足しただけ。空セルは &nbsp; で高さを持たせる。"""
    if cell_styles is None:
        cell_styles = [S_CELL] * len(rows[0])
    data = [[P(c, S_TH) for c in rows[0]]]
    for r in rows[1:]:
        data.append([P(c if c else "&nbsp;", cell_styles[i])
                     for i, c in enumerate(r)])
    t = Table(data, colWidths=widths, repeatRows=1)
    t.setStyle(TableStyle(list(FORM_STYLE)))
    return t


def code(lines, width):
    """コードを等幅フォントの枠に入れる。空白は &nbsp; にして桁を保つ。"""
    esc = []
    for ln in lines:
        ln = ln.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
        esc.append(ln.replace(" ", "&nbsp;"))
    t = Table([[P("<br/>".join(esc), S_CODE)]], colWidths=[width])
    t.setStyle(TableStyle([
        ("FONTNAME", (0, 0), (-1, -1), "JP"),
        ("BACKGROUND", (0, 0), (-1, -1), CODE_BG),
        ("LINEBEFORE", (0, 0), (0, -1), 1.8, LINE),
        ("VALIGN", (0, 0), (-1, -1), "TOP"),
        ("TOPPADDING", (0, 0), (-1, -1), 6),
        ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
        ("LEFTPADDING", (0, 0), (-1, -1), 8),
        ("RIGHTPADDING", (0, 0), (-1, -1), 6),
    ]))
    return t


def quote(text, width):
    """補足・お断りの引用。左に縦罫線を引く。"""
    t = Table([[P(text, S_QUOTE)]], colWidths=[width])
    t.setStyle(TableStyle([
        ("FONTNAME", (0, 0), (-1, -1), "JP"),
        ("LINEBEFORE", (0, 0), (0, -1), 1.8, MUT),
        ("VALIGN", (0, 0), (-1, -1), "TOP"),
        ("TOPPADDING", (0, 0), (-1, -1), 3),
        ("BOTTOMPADDING", (0, 0), (-1, -1), 3),
        ("LEFTPADDING", (0, 0), (-1, -1), 8),
        ("RIGHTPADDING", (0, 0), (-1, -1), 0),
    ]))
    return t


def callout(text, width):
    """一番伝えたい一文を薄い背景の枠に入れる(引用ではないので本文色)。"""
    t = Table([[P(text, S_CALL)]], colWidths=[width])
    t.setStyle(TableStyle([
        ("FONTNAME", (0, 0), (-1, -1), "JP"),
        ("BACKGROUND", (0, 0), (-1, -1), CODE_BG),
        ("LINEBEFORE", (0, 0), (0, -1), 1.8, MUT),
        ("VALIGN", (0, 0), (-1, -1), "TOP"),
        ("TOPPADDING", (0, 0), (-1, -1), 7),
        ("BOTTOMPADDING", (0, 0), (-1, -1), 7),
        ("LEFTPADDING", (0, 0), (-1, -1), 10),
        ("RIGHTPADDING", (0, 0), (-1, -1), 8),
    ]))
    return t


def qhead(text, width):
    """設問(Q2-1 …)の見出し帯。25問あるので節見出しと見分けがつくようにする。"""
    t = Table([[P(text, S_QH)]], colWidths=[width])
    t.setStyle(TableStyle([
        ("FONTNAME", (0, 0), (-1, -1), "JP"),
        ("BACKGROUND", (0, 0), (-1, -1), HEAD_BG),
        ("LINEBEFORE", (0, 0), (0, -1), 2.2, MUT),
        ("VALIGN", (0, 0), (-1, -1), "TOP"),
        ("TOPPADDING", (0, 0), (-1, -1), 5),
        ("BOTTOMPADDING", (0, 0), (-1, -1), 5),
        ("LEFTPADDING", (0, 0), (-1, -1), 8),
        ("RIGHTPADDING", (0, 0), (-1, -1), 8),
    ]))
    return t


def blank_box(width, height):
    """自由記入欄の空枠。"""
    t = Table([[P("&nbsp;", S_CELL)]], colWidths=[width], rowHeights=[height])
    t.setStyle(TableStyle([
        ("FONTNAME", (0, 0), (-1, -1), "JP"),
        ("BOX", (0, 0), (-1, -1), 0.5, LINE),
        ("VALIGN", (0, 0), (-1, -1), "TOP"),
    ]))
    return t


def bull(items):
    """「・」形式の箇条書き。"""
    return [Paragraph("・%s" % t, S_BULL) for t in items]


def numlist(items):
    """「1. 」形式の番号付き箇条書き。"""
    return [Paragraph("%d. %s" % (i + 1, t), S_LIST)
            for i, t in enumerate(items)]


# ---------------------------------------------------------------- 表データ
# はじめに: おことわり(2026-09-17 の2回目の改訂で新設)
DISCLAIM_ROWS = [
    ["種別", "例", "本書での扱い"],
    ["<b>構造から分かること</b>",
     "DDL / SHOW CREATE TABLE / 索引の有無 / possible_keys = null / "
     "カラムの有無・型 / サーバー変数 / 権限",
     "<b>そのまま根拠として記載しております</b>"],
    ["<b>行の中身から<br/>読み取っていたこと</b>",
     "行数・値の分布・「全件が同じ値」「0行である」等",
     "<b>取り下げるか、「当方の検証用データでの観測です」と"
     "明記しております</b>"],
]

# はじめに: 12部構成(原稿の表のまま)
SUMMARY_ROWS = [
    ["#", "内容", "種別", "優先度"],
    ["<b>1. 索引について</b>",
     "<b>ご対応中と伺いました。当方の EXPLAIN 実測と、"
     "当方が見ていた列の一覧です</b>",
     "<b>参考情報</b>", "<b>—</b>"],
    ["<b>2. サマリの面積は期間内の合計でしょうか</b>",
     "<b>仕様の確認のみに縮めました</b>", "質問", "<b>高</b>"],
    ["<b>3. 第8回 Q4 の残り<br/>(サマリの率・分子・分母)</b>",
     "<b>再掲</b>", "質問", "高"],
    ["<b>4. サマリの error_count の集計範囲</b>",
     "<b>当期と確定期間で定義が食い違う恐れ</b>", "質問", "高"],
    ["<b>5. 面積の単位</b>",
     "<b>弊社で㎡と確定しました。設問は取り下げです</b>",
     "<b>ご報告</b>", "<b>—</b>"],
    ["<b>6. グループ単位のサマリ</b>",
     "<b>group_summaries が存在しません</b>", "質問", "<b>高</b>"],
    ["<b>7. グループ・顧客マスタのデータ投入</b>",
     "<b>画面に分類を出すために必要です</b>", "<b>お願い</b>", "中"],
    ["<b>8. 制御盤の報告間隔</b>",
     "<b>control_reports の行数を決めます。台数は当方で把握しました</b>",
     "質問", "中"],
    ["<b>9. 軽い確認2件 + DDL未反映2件</b>",
     "<b>DDLは4回目のお願いです</b>", "質問", "低"],
    ["<b>10. DB更新用アカウント</b>",
     "<b>発行済みでした。ご依頼を取り下げます</b>",
     "<b>ご報告</b>", "<b>—</b>"],
    ["<b>11. 清掃の開始時刻と<br/>リアルタイム性</b>",
     "<b>Q11-1 は「実装の確認」に改めました。Q11-4 を新設(4問)</b>",
     "質問", "中"],
    ["<b>12. DB接続経路についての<br/>お願い3件・確認6問</b>",
     "<b>実測つき。期限は「ステージングに実データが入る前」です</b>",
     "<b>お願い+質問</b>", "<b>高</b>"],
]

# 1-2. 影響の大きさ(★2026-09-17 にロボット30台の前提で置き直した★)
SCAN_ROWS = [
    ["表示期間", "塊の数",
     "走査行数<br/>(clean_status_histories・<b>ロボット30台で外挿</b>)"],
    ["今日1日", "1", "約 4.4万行"],
    ["<b>2年(731日)</b>", "<b>44</b>", "<b>約 193万行</b>"],
]

# 1-6. ①〜⑦の一覧(突き合わせ用。2026-09-17 に新設)
# ★列幅★ control_error_histories(24字 = 8.8pt で 105.6pt = 37.3mm)が
# 語中で割れないよう「テーブル」列の内寸(列幅 - 4.2mm)でこれを上回るようにする。
INDEX_LIST_ROWS = [
    ["#", "テーブル", "当方が見ていた列<br/>(順序も含みます)",
     "2026-09-13 時点の実測"],
    ["<b>①</b>", "clean_status_histories", "clean_timetable_id",
     "<b>索引なし(possible_keys = null)</b>"],
    ["<b>②</b>", "robot_error_reports", "clean_timetable_id",
     "<b>索引なし(possible_keys = null)</b>"],
    ["<b>③</b>", "clean_timetables", "(precheck, start_at)",
     "単一列索引はあるが実行計画で未使用"],
    ["<b>④</b>", "clean_timetables", "(facility_id, start_at)", "同上"],
    ["<b>⑤</b>", "clean_status_histories", "started_at", "<b>索引なし</b>"],
    ["<b>⑥</b>", "control_reports", "(control_id, reported_at)",
     "(control_id) のみ。<br/>MAX(reported_at) がフルスキャン"],
    ["<b>⑦</b>", "control_error_histories", "occurred_at", "<b>索引なし</b>"],
]

# 1-5. ⑥の根拠
MAXCOL_ROWS = [
    ["MAX() の対象", "索引に含まれるか", "type", "rows", "Extra"],
    ["reported_at(現行)", "<b>含まれない</b>",
     "index(<b>フルスキャン</b>)", "14", "Using where"],
    ["id(参考の実験)", "<b>含まれる</b>", "<b>range</b>", "<b>3</b>",
     "<b>Using index for group-by</b>"],
]

# 2-2. Q2-1
# ★2026-09-17(2回目)の改訂で、2-1 の実測表(6列4行)は削除した。
#   テストDBの行は当方が入れたダミーだったため(原稿 2-1)。
#   選択肢の C(「まだバッチが動いていない」)も同時に消えて A / B の2行になった★
Q21_ROWS = [
    ["選択肢", "内容"],
    ["<b>A</b>",
     "<b>期間内の合計を書く</b>"
     "(当方はサマリをそのまま足し合わせて総面積を出します)"],
    ["B", "<b>合計ではない</b>(1回ぶんの代表値・平均など。その場合、定義をお教えください)"],
]

Q23_ROWS = [
    ["選択肢", "内容"],
    ["<b>A</b>", "<b>必ず1行</b>(清掃1回につき1行)"],
    ["B", "<b>複数行できる</b>(清掃前 → 清掃中 → 清掃終了 のように状態ごとに積む)"],
]

# 2-4. 年次サマリの桁あふれ
OVERFLOW_ROWS = [
    ["前提", "上限に達する条件"],
    ["<b>ロボット30台・年365日</b>",
     "<b>1台1日あたり 約9,100㎡ を超えると、年次行があふれます</b>"],
]

# 4. error_count
Q41_ROWS = [
    ["選択肢", "内容"],
    ["A", "<b>除外している</b>(本清掃 precheck = 0 のみを数えている)"],
    ["B", "<b>除外していない</b>(事前チェックのエラーも数えている)"],
]

# 6-1
Q61_ROWS = [
    ["選択肢", "内容"],
    ["<b>A</b>",
     "<b>当方が customer_summaries の複数行を合算する</b>(テーブル追加は不要)"],
    ["B", "<b>group_summaries を追加してバッチで集計する</b>"],
    ["C", "<b>グループ単位の集計は想定していない</b>"],
]

# 6-2
RATIO_ROWS = [
    ["率", "計算式(当方の定義)", "サマリの分子", "サマリの分母", "合算できるか"],
    ["清掃完了率<br/>clean_ratio", "Σ清掃面積 ÷ Σ対象面積", "clean_area",
     "task_area", "<b>できる</b>"],
    ["出発成功率<br/>launch_ratio", "定刻出発回数 ÷ 予定回数", "launch_count",
     "task_count ?", "<b>式が未確認</b>"],
    ["<b>稼働率<br/>operation_ratio</b>", "清掃<b>実施</b>回数 ÷ 予定回数",
     "<b>該当カラムが無い</b>", "task_count ?", "<b>できない</b>"],
    ["<b>エラー発生率<br/>error_ratio</b>",
     "エラー発生回数 ÷ 清掃<b>実施</b>回数", "error_count",
     "<b>該当カラムが無い</b>", "<b>できない</b>"],
]

# 6-2. なぜ「清掃実施回数」が要るのか(2026-09-17 の2回目の改訂で新設)
TASKCOUNT_ROWS = [
    ["task_count が何であれば", "困ること"],
    ["<b>予定回数</b>(当方の理解)",
     "<b>実施回数が無い</b> → "
     "<b>稼働率の分子・エラー発生率の分母が出せません</b>"],
    ["<b>実施回数</b>",
     "<b>予定回数が無い</b> → <b>稼働率の分母が出せません</b>"],
]

Q62_ROWS = [
    ["選択肢", "内容"],
    ["<b>A</b>",
     "<b>追加できる</b>(customer_summaries / facility_summaries に1カラムずつ)"],
    ["B", "<b>すでに既存のカラムで表現されている</b>(どれか教えてください)"],
    ["C", "<b>追加は難しい</b>"],
]

# 7-2. Q7-1
Q71_ROWS = [
    ["選択肢", "例"],
    ["<b>A</b>",
     "<b>屋号</b>(ドン・キホーテ / MEGAドン・キホーテ / アピタ / ピアゴ …)"],
    ["B", "<b>法人</b>(株式会社ドン・キホーテ / 株式会社ユニー)"],
]

# 7-2. Q7-5
LAYER_ROWS = [
    ["解釈", "空くのは", "例", "DBでの表現"],
    ["<b>①</b>", "<b>真ん中(屋号)</b>",
     "店舗がホールディングスの直下にぶら下がる",
     "<b>表現できません</b> — facilities.customer_id は NOT NULL で "
     "customers を指すため、中間を飛ばせません"],
    ["<b>②</b>", "<b>上(ホールディングス)</b>",
     "屋号はあるが、属する持株会社が無い",
     "customers.group_id = 0(= Q7-4 の件)"],
]

# 7-2. Q7-4
Q74_ROWS = [
    ["選択肢", "内容", "備考"],
    ["<b>A</b>", "<b>groups に id = 0 の番人行を1件入れる</b>",
     "<b>INSERT には NO_AUTO_VALUE_ON_ZERO が必要になります</b>"],
    ["B", "<b>外部キー制約 customers_group_id_foreign を外す</b>",
     "センチネル方式と両立します"],
    ["C", "<b>group_id を nullable にする</b>",
     "<b>索引効率を理由にセンチネル方式を採られた経緯があるため、"
     "蒸し返しになります</b>"],
    ["D", "<b>グループなしの顧客は存在しない</b>(必ずどこかのグループに属する)",
     "<b>この場合、当方は「グループなし」の表示を作りません</b>"],
]

# 8. 報告間隔
INTERVAL_ROWS = [
    ["報告間隔", "2年間の行数(<b>制御盤11台と置いた試算</b>)"],
    ["60秒(定義書の例)", "約 1,100万行"],
    ["<b>10秒</b>(第2回のご回答の例)", "<b>約 7,000万行</b>"],
]

# 8. Q8-3(2026-09-17 の2回目の改訂で「固定値」→「下限・上限・変更の頻度」)
Q83_ROWS = [
    ["#", "伺いたいこと", "例"],
    ["<b>a</b>", "<b>運用上の下限(最短の報告間隔)</b>",
     "「10秒より短くはしない」など"],
    ["<b>b</b>", "<b>運用上の上限(最長の報告間隔)</b>",
     "「長くても60秒」など"],
    ["<b>c</b>", "<b>変更の頻度</b>",
     "「初期設定から基本的に変えない」/"
     "「制御盤ごとに個別に決める」/「運用中に随時変える」など"],
]

# 8-1. 当方の把握(2026-09-17 に新設。旧 Q8-1 / Q8-2 の差し替え)
# ★2回目の改訂で「テスト環境の controls は11行」等の行数根拠を外した
#   (テストDBの行は当方が入れたダミーだったため)★
SCALE_ROWS = [
    ["項目", "当方の把握", "出典"],
    ["<b>ロボット台数</b>", "<b>現状 30台未満</b>",
     "<b>弊社(DFA)の把握。2026-09-17</b>"],
    ["D-S制御盤の台数",
     "<b>未確定</b>(試算では <b>11台</b> と置いております)",
     "<b>弊社で確定させます</b>"],
    ["施設数", "<b>未確定</b>", "<b>弊社で確定させます</b>"],
]

# 9. DDL 未反映
DDL_ROWS = [
    ["#", "内容"],
    ["1", "clean_status_histories.id の AUTO_INCREMENT"],
    ["2", "security_status_histories.reported_at の DEFAULT"],
]

# 11. Q11-1(2026-09-17 の2回目の改訂で作り直した)
# ★旧 Q11-1「実開始時刻はどこにありますか」(A / B / C の選択肢表)と、
#   11-2 の delay 集計表・SQL 枠は削除した。started_at を使う形で解決したため★
Q111_ROWS = [
    ["ご回答", "当方の動き"],
    ["<b>A. その理解で問題ない</b>",
     "<b>このまま実装します</b>(<b>ご回答はこの一言で結構です</b>)"],
    ["<b>B. started_at には予定時刻が入る</b>",
     "<b>実装を止め、実開始時刻の所在を改めてご相談します</b>"],
]

# 11. Q11-4(2026-09-17 の2回目の改訂で新設)
Q114_ROWS = [
    ["#", "伺いたいこと"],
    ["<b>a</b>", "<b>そのテーブル・カラム名</b>"],
    ["<b>b</b>",
     "<b>おおよその更新間隔</b>"
     "(「数秒ごと」「1分ごと」など、おおよそで構いません)"],
    ["<b>c</b>", "<b>履歴として積まれるのか、上書きされるのか</b>"],
]

# 12-1. 期限の対比
DEADLINE_ROWS = [
    ["時点", "状態", "作り直しのコスト"],
    ["<b>いま<br/>(検証用データのみ)</b>",
     "<b>実データは入っておりません</b>",
     "<b>ほぼゼロ</b>。アカウントを作り直しても、失うものがありません"],
    ["<b>ステージングに<br/>実データが入った後</b>",
     "<b>平文の経路を通った資格情報を前提に、"
     "運用が始まってしまいます</b>",
     "<b>「作り直す」しか手がなくなります</b>"],
]

# 12-2. 実測のメタ情報(いつ・誰が・どう測ったか)
MEASURE_META_ROWS = [
    ["項目", "内容"],
    ["<b>対象</b>",
     "<b>テスト環境 133.125.238.144:13306 / DB clean_watch</b>"
     "(本書1-1に記載のものと同じサーバーです)"],
    ["<b>サーバーの<br/>バージョン</b>",
     "<b>VERSION() = 10.11.19-MariaDB-ubu2204</b>"],
    ["<b>測った人</b>", "<b>DFA(本プロジェクトの作業者)</b>"],
    ["<b>測った日</b>",
     "<b>2026-09-13</b>(<b>2026-09-15 に本書の初版へ転記しました</b>)"],
    ["<b>測り方</b>",
     "<b>貴社より受領した参照用アカウント</b>(SELECT のみ)で"
     "テスト環境に接続し、<b>サーバー変数を照会した</b>"],
]

# 12-2. 実測値。★列幅は require_secure_transport(24字 = 8.8pt で 105.6pt
# = 37.3mm)が語中で割れないよう、内寸(列幅 - 左右パディング 12pt = 4.2mm)で
# これを上回るように 46mm にしている★
TESTENV_ROWS = [
    ["変数/設定", "実測値", "意味"],
    ["have_ssl", "<b>DISABLED</b>",
     "<b>サーバーが TLS を提供していません</b>"],
    ["require_secure_transport", "<b>OFF</b>",
     "<b>暗号化されていない接続を、サーバーが受け付けます</b>"],
    ["参照用アカウントの<br/>接続元ホスト", "<b>参照用アカウント@%</b>",
     "<b>% は「どこからでも」。接続元の制限がありません</b>"],
]

# 12-6. 当方の現在の理解と、その根拠
UNDERSTAND_ROWS = [
    ["#", "当方の理解", "根拠", "確認の有無"],
    ["1", "本番のDBは <b>MariaDB 10.11</b>",
     "<b>貴社のご回答(2026-09-10)"
     "「本番サーバはMariaDBです」</b>", "<b>確認済み</b>"],
    ["2", "DBは <b>さくらインターネットの<br/>データベースアプライアンス</b>",
     "同ご回答「さくらインターネットの"
     "データベースアプライアンスはPostgreSQLかMariaDBなので」",
     "<b>確認済み</b>"],
    ["3", "アプリは <b>さくらのクラウド VM</b>"
     "(Node.js 常駐が必要なため)",
     "<b>当方側の想定</b>", "<b>未確認</b>"],
    ["4", "<b>「アプリ=VM」「DB=アプライアンス」という"
     "別サービスの組み合わせである</b>",
     "<b>上記2・3からの読み取り</b>", "<b>未確認</b>"],
    ["5", "<b>VM と DB が同一ネットワークで閉域接続される</b>",
     "<b>当方側の想定</b>", "<b>未確認</b>"],
]

# 12-6. Q12-1 の選択肢
Q121_ROWS = [
    ["#", "対象", "選択肢"],
    ["<b>a</b>", "<b>アプリサーバー(Node.js を載せる VM)</b>",
     "<b>貴社が用意 / 弊社が用意 / 未定</b>"],
    ["<b>b</b>", "<b>DBサーバー(データベースアプライアンス)</b>",
     "<b>貴社が用意 / 弊社が用意 / 未定</b>"],
]

# ご回答欄(原稿の設問の採番と要約を転記したもの)
ANSWER_ROWS = [
    ["質問番号", "質問の要約", "ご回答"],
    ["<b>1章</b>",
     "<b>索引(参考情報)— ご対応が完了しましたら、その旨を<br/>"
     "お知らせください</b>", ""],
    ["<b>Q2-1</b>",
     "<b>集計バッチは task_area / clean_area に期間内の合計を<br/>"
     "書き込む仕様か(A / B)</b>", ""],
    # ★Q2-2 は 2026-09-17(2回目)に取り下げた。番号は詰めず Q2-3 のまま★
    ["<b>Q2-3</b>",
     "1つの clean_timetable_id に clean_status_histories は<br/>"
     "何行できるか(A / B)", ""],
    ["<b>3章</b>",
     "<b>第8回 Q4 の残り(率の計算式・分子・分母・件数列)</b>", ""],
    ["Q4-1", "error_count は事前チェックを除外しているか(A / B)", ""],
    ["Q4-2", "clean_timetable_id が null のエラー行は含まれるか", ""],
    ["Q4-3", "clean_error が 2 / 3 の清掃は含まれるか", ""],
    # ★Q5-1 は 2026-09-17(2回目)に取り下げた(弊社で ㎡ と確定)★
    ["Q6-1", "グループ単位の集計の想定(A / B / C)", ""],
    ["Q6-2", "「清掃実施回数」のカラムを追加できるか(A / B / C)", ""],
    ["<b>7章</b>",
     "<b>本番の groups / customers のデータ投入の時期の目安</b>", ""],
    ["Q7-1", "customers は法人と屋号のどちらの粒度か(A / B)", ""],
    ["Q7-2", "屋号を持たない顧客の customer_name に入る値", ""],
    ["Q7-3", "facilities.shop_id は何を表す列か", ""],
    ["Q7-5", "「屋号を持たない顧客」はどの段が空くか(① / ②)", ""],
    ["<b>Q7-4</b>",
     "<b>customers.group_id = 0 が投入できない件<br/>(A / B / C / D)</b>", ""],
    # ★Q8-1 / Q8-2 は 2026-09-17 に取り下げた(当方側で決めることのため)。
    #   番号は詰めず Q8-3 のまま残す(原稿8章の断り書きどおり)★
    ["Q8-3",
     "link_schedules.intervals の運用上の下限・上限・<br/>変更の頻度"
     "(a / b / c)", ""],
    ["Q9-1", "robot_task_reports は clean_task_reports のことか", ""],
    ["Q9-2", "1回の清掃の中の、区域別の内訳は取得できるか", ""],
    ["9章", "DDL 未反映の2件(AUTO_INCREMENT / DEFAULT)", ""],
    # ★10章は 2026-09-17(2回目)にご依頼を取り下げた(すでに発行済みでした)★
    ["<b>Q11-1</b>",
     "<b>clean_status_histories.started_at を実開始時刻として<br/>"
     "実装してよいか(A / B)</b>", ""],
    ["Q11-2", "clean_status_trans = 1 で止まったケースは履歴に残るか", ""],
    ["<b>Q11-3</b>",
     "<b>launch_count / launch_ratio の計算式</b>", ""],
    ["Q11-4",
     "started_at より短い間隔で更新されるテーブル・カラムは<br/>"
     "あるか(a / b / c)", ""],
    ["<b>12章</b>",
     "<b>お願い3件: 接続元IPの制限 / TLS の有効化 /<br/>"
     "本番用DBアカウントの新規発行</b>", ""],
    ["<b>Q12-1</b>",
     "<b>アプリVM(a)とDBサーバー(b)は、それぞれ<br/>"
     "どちらが用意するか</b>", ""],
    ["Q12-2", "本番のDBはデータベースアプライアンスか<br/>(YES / NO)", ""],
    ["Q12-3", "本番のアプリはさくらのクラウドのVMか<br/>(YES / NO)", ""],
    ["<b>Q12-4</b>",
     "<b>VM と DBアプライアンスは同一の閉域ネットワークで<br/>"
     "結べるか(YES / NO)</b>", ""],
    ["Q12-5", "YES の場合、公開IPからの接続を完全に閉じられるか"
     "<br/>(YES / NO / 条件つき)", ""],
    ["Q12-6", "NO の場合、TLS を有効にできるか"
     "<br/>(YES / NO / 該当なし)", ""],
]


def build():
    doc = BaseDocTemplate(
        OUT, pagesize=A4,
        leftMargin=18 * mm, rightMargin=18 * mm,
        topMargin=18 * mm, bottomMargin=20 * mm,
        title="D-Watch 確認事項(第9回)",
        author="DFA Robotics",
        subject="サマリの面積・実際の清掃開始時刻・DB接続経路ほかのご確認",
    )
    frame = Frame(doc.leftMargin, doc.bottomMargin, doc.width, doc.height, id="f")
    doc.addPageTemplates([PageTemplate(id="p", frames=[frame], onPage=on_page)])

    W = doc.width          # 174mm
    CW = W - 6 * mm        # 引用・コード枠の幅
    story = []

    # ---------------------------------------------------------- 表題と前文
    story.append(Paragraph("確認事項(第9回)", S_TITLE))
    story.append(Paragraph(
        # ★2026-09-17 の日付が増えて1行に収まらなくなったので、
        #   「作成者:」の前で明示的に改行する(自動折り返しだと
        #   DFA / Robotics が割れる)★
        "宛先: TeamWorks(DB開発側) 作成: 2026-09-17 改訂"
        "(初版 2026-09-15 / 統合 2026-09-16)<br/>"
        "作成者: DFA Robotics", S_SUB))
    story.append(Spacer(1, 5))
    story.append(quote(
        "<b>2026-09-17(2回目)の改訂点</b>: "
        "<b>テスト環境のDBに入っているデータは、当方(DFA)が入れた"
        "検証用のダミーデータでありました</b>。"
        "<b>つきましては「行の中身」を根拠にしていた記述を、"
        "取り下げるか、当方の検証用データでの観測である旨を明記する"
        "形に改めております</b> / "
        "<b>2章は実データの話を削り、仕様の確認のみに縮めました"
        "(Q2-2 は取り下げ)</b> / "
        "<b>5章(面積の単位)は弊社で「㎡」と確定しましたので、"
        "設問(Q5-1)を取り下げました</b> / "
        "<b>8章 Q8-3 は「固定値」ではなく「運用上の下限・上限・変更の頻度」"
        "を伺う形に改めました</b> / "
        "<b>10章(DB更新用アカウント)はすでに権限が発行されておりました。"
        "当方の確認漏れです。ご依頼を取り下げました</b> / "
        "<b>11章 Q11-1(実際の清掃開始時刻)は お伺いする内容を改めました</b>。"
        "「どこにありますか」ではなく "
        "<b>「started_at を使って実装してよろしいでしょうか」</b> という"
        "確認に変えております。あわせて <b>Q11-4</b> を新たに立てました",
        CW))
    story.append(Spacer(1, 5))
    story.append(quote(
        "<b>2026-09-17(1回目)の改訂点</b>: "
        "<b>索引(1章)はご対応中と伺いましたので、ご依頼を取り下げ"
        "参考情報に改めました</b> / "
        "<b>規模の前提を「施設200件(仮置き)」から"
        "「ロボット30台未満(弊社把握)」に置き直しました(1章・2-4)</b> / "
        "<b>台数・施設数の設問(旧 Q8-1 / Q8-2)を取り下げました"
        "(弊社側で決めるべきことのため)</b>", CW))
    story.append(Spacer(1, 5))
    story.append(quote(
        "<b>本書は、第9回確認事項に、DB接続経路についての申し送り"
        "(お願い3件・確認6問)を統合したものです。</b><br/>"
        "<b>別便は出しません。第9回として1通にまとめてお送りいたします。"
        "統合した内容は12章です。</b>", CW))
    story.append(Spacer(1, 9))

    story.append(Paragraph("はじめに", S_H2))
    story.append(Paragraph(
        "<b>第8回(2026-09-11)にはご回答をいただき、ありがとうございました。</b>"
        "<b>いただいた回答をもとに、タイムゾーン・エラー率・"
        "エリア別完了率の実装を直しております。</b>", S_BODY))
    story.append(Paragraph(
        "<b>第8回の Q4(サマリの率・分子・分母の関係)は"
        "「多いので後で回答」との留保をいただいたままです。"
        "取り下げておりませんので、引き続きお願いいたします"
        "(本書の3章に再掲します)。</b>", S_BODY))
    story.append(Paragraph(
        "そのうえで、<b>テスト環境のDBに接続して実測を行ったところ、"
        "確認事項が新たに発生しました。</b>", S_BODY))

    # -------------------------------- おことわり(2026-09-17 の2回目の改訂で新設)
    story.append(CondPageBreak(90 * mm))
    story.append(Paragraph(
        "おことわり — テスト環境のデータは当方が入れたものでした", S_H2))
    story.append(Paragraph(
        "<b>本書の初版(2026-09-15)では、テスト環境のDBの「行の中身」を"
        "根拠にした記述がございました。</b>"
        "<b>その後、当方で確認いたしましたところ、テスト環境に入っている"
        "データは当方(DFA)が開発用に投入した検証用のダミーデータでした。"
        "</b>", S_BODY))
    story.append(Paragraph(
        "<b>そのため、行数・値の分布・特定のテーブルが0行であること等を"
        "根拠にした指摘は、貴社のバッチや収集処理の挙動を"
        "示すものではありませんでした。</b>", S_BODY))
    story.append(Paragraph(
        "<b>本改訂では、これらを次のように仕分けております。</b>", S_LEAD))
    story.append(Spacer(1, 3))
    story.append(table(DISCLAIM_ROWS, [40 * mm, 66 * mm, W - 106 * mm],
                       [S_CELL, S_CELL, S_CELL]))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "<b>紛らわしいことを申し上げ、失礼いたしました。</b>", S_BODY))

    # -------------------------------- 本書の構成
    story.append(CondPageBreak(60 * mm))
    story.append(Paragraph("本書の構成", S_H2))
    story.append(Spacer(1, 2))
    story.append(callout(
        "<b>索引の件については、ご対応中と伺いました。</b><br/>"
        "<b>つきましては1章はご依頼を取り下げ、参考情報に改めております"
        "(詳細は1章の冒頭に記しました)。</b><br/>"
        "<b>そのうえで本書の中心は、12章(DB接続経路)です。</b><br/>"
        "<b>12章は期限が「ステージングに実データが入る前」という"
        "性質のもので、過ぎますと作り直しになります。</b>", CW))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "<b>あわせて、3章・4章・6章(サマリの計算式と分子・分母)が"
        "当方の実装に直結いたします。</b>"
        "<b>11章は、Q11-1(実際の清掃開始時刻)について、お伺いする内容を"
        "「どこにありますか」から "
        "<b>「この理解で実装してよろしいでしょうか」</b> に"
        "改めております。</b>", S_BODY))
    story.append(Spacer(1, 2))
    # ★12行になって表の高さが 147mm。表紙の前文と合わせると1ページに収まらず、
    #   最終行(12章)だけが次ページに落ちる。表は目次なので割らずに送り、
    #   導入の一文(「次の12部構成です。」)も表と一緒に送る★
    story.append(CondPageBreak(157 * mm))
    story.append(Paragraph(
        "本書は次の12部構成です"
        "(<b>質問25問 / お願い5件 / 参考情報1章</b>)。", S_LEAD))
    story.append(Spacer(1, 3))
    # 種別の列は「お願い+質問」(6字 = 8.8pt で 52.8pt = 18.6mm)が
    # 1行に収まるよう 26mm にしている(内寸 = 26mm - 4.2mm = 21.8mm)
    story.append(table(SUMMARY_ROWS, [52 * mm, W - 102 * mm, 26 * mm, 24 * mm],
                       [S_CELL, S_CELL, S_CELL, S_CELL]))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "<b>7章は貴社の担当領域(DBとバッチ)への変更を伴いますので、"
        "「お願い」として書いております。</b>"
        "<b>ご負担や副作用のご懸念があれば、その旨をお知らせください。"
        "当方で別の手を検討します。</b>", S_BODY))

    # ---------------------------------------------------------- 1. 索引
    story.append(CondPageBreak(75 * mm))
    story.append(Paragraph(
        "1. 索引について(ご対応中と伺いました。参考情報です)", S_H2))

    story.append(Paragraph(
        "1-0. 本章の位置づけ — ご依頼は取り下げております", S_H3))
    story.append(Paragraph(
        "<b>索引についてはご対応中と伺いました。"
        "つきましては本章は依頼ではなく参考情報です。</b>"
        "<b>ご対応が完了した時点で、残っているものがあれば"
        "改めてご相談します。</b>", S_BODY))
    story.append(Paragraph(
        "<b>本章に残しておりますのは、当方が EXPLAIN で実測した結果と、"
        "当方が見ていた列の一覧です。</b>"
        "<b>貴社が入れられた索引で足りているかどうかを、そちらで"
        "突き合わせていただけるように残しております。</b>"
        "<b>この一覧のとおりにしていただきたい、という意味では"
        "ありません。</b>", S_BODY))
    story.append(Spacer(1, 2))
    story.append(quote(
        "<b>初版(2026-09-15)では本章を「最優先のご依頼(7件)」として"
        "書いておりました。本改訂(2026-09-17)で取り下げております。"
        "行き違いがございましたら申し訳ございません。</b>", CW))
    story.append(Spacer(1, 6))

    story.append(CondPageBreak(70 * mm))
    story.append(Paragraph("1-1. なぜこの列を見ていたか", S_H3))
    story.append(Paragraph(
        "当方は清掃詳細ログの表示期間を <b>それまでの上限31日から、"
        "上限2年(731日)に広げました</b>。", S_BODY))
    story.append(Paragraph(
        "そのために EXPLAIN で実行計画を確認したところ、"
        "<b>索引が無いために全表走査になっている経路が"
        "複数見つかりました。</b> "
        "以下の①〜⑦は、そのとき当方が見ていた列です。", S_BODY))
    story.append(Paragraph(
        "<b>現状(データが少ないうち)は問題が表に出ません。</b>"
        "<b>問題になるのは、本番のデータが積み上がってからです。</b>",
        S_BODY))
    # ★「実測」と書くときは数字の出所を併記する(当方の作業規約)。
    #   旧「以下、すべて実測です。推測ではありません。」は出所を書かずに
    #   言い切っていたので差し替えた。★点検スクリプトの 12. が旧文言を見る★
    story.append(Paragraph(
        "以下の SHOW CREATE TABLE と EXPLAIN は、"
        "<b>テスト環境で実際に実行したものです。推測ではありません。</b>",
        S_BODY))
    story.append(Paragraph(
        "<b>ただし、そこに出てくる行数(rows / r_rows)は"
        "当方が入れた検証用データの件数です。</b>"
        "<b>申し上げたいのは type / possible_keys / key という"
        "実行計画の形のほうで、行数ではございません。</b>"
        "<b>詳しくは冒頭の「おことわり」をご覧ください。</b>", S_BODY))
    story.append(Paragraph("実測環境:", S_LEAD))
    story.append(Spacer(1, 3))
    story.append(code([
        "mysql://…@133.125.238.144:13306/clean_watch",
        "VERSION() = 10.11.19-MariaDB-ubu2204",
    ], CW))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "<b>なお ALTER TABLE の形で書いておりますのは、"
        "列と順序を誤解なくお伝えするためです。</b>"
        "<b>当方でこれを実行することはございません"
        "(当方はメインDBのスキーマを変更しない方針です)。</b>", S_BODY))

    story.append(CondPageBreak(80 * mm))
    story.append(Paragraph(
        "1-2. ①② — 索引が1本も無い列", S_H3))
    story.append(Spacer(1, 2))
    story.append(code([
        "ALTER TABLE `clean_status_histories`",
        "  ADD INDEX `clean_status_histories_clean_timetable_id_index` "
        "(`clean_timetable_id`);",
        "",
        "ALTER TABLE `robot_error_reports`",
        "  ADD INDEX `robot_error_reports_clean_timetable_id_index` "
        "(`clean_timetable_id`);",
    ], CW))
    story.append(Spacer(1, 6))

    story.append(CondPageBreak(60 * mm))
    story.append(Paragraph("根拠(EXPLAIN の出力そのまま)", S_H4))
    story.append(quote(
        "<b>行数の読み方について</b><br/>"
        "以下の rows / r_rows は、<b>当方が入れた検証用データでの行数</b>です"
        "(「おことわり」参照)。<b>本番の行数を示すものではありません。</b><br/>"
        "一方、type / possible_keys / key は "
        "<b>表の構造(索引の有無)から決まる</b>もので、"
        "<b>行数によって変わりません。</b> "
        "<b>本章で申し上げたいのは後者です。</b>", CW))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "clean_status_histories を clean_timetable_id IN (5件) で"
        "引いたとき:", S_LEAD))
    story.append(Spacer(1, 3))
    story.append(code([
        "type=ALL | possible_keys=null | key=null | key_len=null",
        "rows=538 | r_rows=538.00 | filtered=100 | r_filtered=0.93 | "
        "Extra=Using where",
    ], CW))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "robot_error_reports を同様に引いたとき:", S_LEAD))
    story.append(Spacer(1, 3))
    story.append(code([
        "type=ALL | possible_keys=null | key=null",
        "rows=49 | r_rows=49.00 | filtered=100 | r_filtered=4.08 | "
        "Extra=Using where",
    ], CW))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "<b>possible_keys = null = 使える索引が1本もありません。</b>"
        "<b>表の全行を読んで、そのうち一致した行だけを残しています</b>"
        "(上の 538行・0.93% は <b>当方の検証用データでの値</b>です)。",
        S_BODY))
    story.append(Paragraph(
        "これは統計に基づくコスト判断ではなく、<b>構造的な全表走査</b>です。"
        "行数が増えても索引を選ぶようにはなりません。", S_BODY))

    story.append(CondPageBreak(75 * mm))
    story.append(Paragraph("SHOW CREATE TABLE での確認", S_H4))
    story.append(Spacer(1, 2))
    story.append(code([
        "-- clean_status_histories",
        "UNIQUE KEY `clean_status_histories_id_unique`           (`id`)",
        "KEY        `clean_status_histories_facility_id_foreign` (`facility_id`)",
        "KEY        `clean_status_histories_section_id_foreign`  (`section_id`)",
        "-- clean_timetable_id には索引も FK もありません",
        "",
        "-- robot_error_reports",
        "UNIQUE KEY `robot_error_reports_report_id_unique`    (`error_id`)",
        "KEY        `robot_error_reports_facility_id_foreign` (`facility_id`)",
        "KEY        `robot_error_reports_section_id_foreign`  (`section_id`)",
        "KEY        `robot_error_reports_robot_id_foreign`    (`robot_id`)",
        "-- clean_timetable_id には索引がありません",
    ], CW))
    story.append(Spacer(1, 6))

    story.append(CondPageBreak(70 * mm))
    story.append(Paragraph(
        "影響の大きさ(2026-09-17 に前提を置き直しました)", S_H4))
    story.append(Paragraph(
        "当方のアプリは、この2つのテーブルを "
        "<b>clean_timetable_id IN (…) の形でしか引きません。</b> "
        "IN の要素数が1000を超えると1000件ずつの塊に分けて投げますが、"
        "<b>塊ごとに全表走査が起きます。</b>", S_BODY))
    story.append(Paragraph(
        "<b>初版ではこの走査行数を「施設200件」という"
        "当方の仮置きで外挿しておりました。</b>"
        "<b>その後、弊社で現状のロボット台数を確認したところ "
        "30台未満 でしたので、置き直しております。</b>", S_BODY))
    story.append(Paragraph(
        "<b>台数の根拠は、弊社(DFA)自身の把握「現状30台未満」"
        "(2026-09-17)です。</b>"
        "<b>貴社への質問ではございません。</b>", S_BODY))

    story.append(CondPageBreak(95 * mm))
    story.append(Paragraph("計算過程", S_H4))
    story.append(quote(
        "<b>下の「1台1日2回」は、貴社のデータから得た値ではありません。</b><br/>"
        "当方が入れた検証用データの密度"
        "(31日 / 12エリア / 538行 = 1.45 回/日/エリア)を切り上げた、"
        "<b>当方の置き値</b>です。<br/>"
        "<b>実運用の清掃回数が決まりましたら、"
        "当方から置き直してお知らせいたします。</b>", CW))
    story.append(Spacer(1, 6))
    story.append(code([
        "1) 1ロボット(=1清掃エリア)あたりの1日の清掃予定回数",
        "   当方の置き値(当方の検証用データの密度 "
        "1.45 回/日/エリア を切り上げたもの)",
        "   → 2 回/日/台 として計算する",
        "",
        "2) 2年ぶんの行数(当方の画面の期間上限は 731日です)",
        "   30台 × 2回/日 × 731日 = 43,860行",
        "   = clean_timetables(precheck = 0)の行数。",
        "     clean_status_histories も同数とみなす",
        "     (当方の検証用データでは 1対1 でしたが、"
        "本来の仕様は 2章 Q2-3 でお伺いしております)",
        "",
        "3) IN の塊の数",
        "   43,860 ÷ 1,000 → 44塊",
        "",
        "4) 走査行数(1塊につき全表走査1回)",
        "   44塊 × 43,860行 = 約 193万行",
    ], CW))
    story.append(Spacer(1, 6))

    story.append(CondPageBreak(45 * mm))
    story.append(table(SCAN_ROWS, [26 * mm, 20 * mm, W - 46 * mm],
                       [S_CELL, S_CELL, S_CELL]))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "<b>初版(施設200件での外挿)では「2年で約6,430万行」と"
        "しておりました。約 1/33 になります。</b>", S_BODY))

    story.append(CondPageBreak(70 * mm))
    story.append(Paragraph(
        "数字が小さくなったことについて、事実だけ申し添えます", S_H4))
    story.append(Paragraph(
        "<b>「だから問題ない」と断定するつもりはございません。</b> "
        "分かっていることは次の3点です。", S_LEAD))
    story.append(Spacer(1, 2))
    story.extend(bull([
        "possible_keys = null である以上、"
        "<b>全表走査は行数によらず構造的に起きます</b>。"
        "<b>行数が増えても索引を選ぶようにはなりません</b>"
        "(統計に基づくコスト判断ではないためです)",
        "清掃詳細ログは、この全表走査を "
        "<b>2つの経路(本体と連続日数の算出)で別々に撃ちます</b>。"
        "2年の表示1回あたり、合計で 約386万行の走査になります",
        "上の 43,860行 は「1台1日2回」への切り上げです。"
        "<b>塊の数と1塊あたりの行数が両方とも台数・回数に比例するため、"
        "走査行数はそれらに対して二乗で効きます</b>。"
        "台数が倍になれば走査行数は4倍になります",
    ]))

    story.append(CondPageBreak(80 * mm))
    story.append(Paragraph(
        "1-3. ③④ — 複合索引(clean_timetables)", S_H3))
    story.append(Spacer(1, 2))
    story.append(code([
        "ALTER TABLE `clean_timetables`",
        "  ADD INDEX `clean_timetables_precheck_start_at_index` "
        "(`precheck`, `start_at`);",
        "",
        "ALTER TABLE `clean_timetables`",
        "  ADD INDEX `clean_timetables_facility_id_start_at_index` "
        "(`facility_id`, `start_at`);",
    ], CW))
    story.append(Spacer(1, 6))

    story.append(CondPageBreak(75 * mm))
    story.append(Paragraph("根拠", S_H4))
    story.append(Paragraph(
        "当方の主クエリは <b>常に precheck = 0 AND start_at &gt;= ? "
        "AND start_at &lt; ? の AND</b> です。"
        "start_at 単独の索引はありますが、実行計画では使われていません。",
        S_LEAD))
    story.append(Spacer(1, 3))
    story.append(code([
        "-- 2年範囲の主クエリ",
        "type=ALL | possible_keys=clean_timetables_start_at_index | key=null | "
        "key_len=null",
        "rows=1044 | r_rows=538.00 | Extra=Using where; Using filesort",
    ], CW))
    story.append(Spacer(1, 6))
    story.append(Paragraph("施設で絞った場合も同様です。", S_LEAD))
    story.append(Spacer(1, 3))
    story.append(code([
        "-- 2年範囲 + facility_id IN (1,2,3)",
        "type=ALL | possible_keys=clean_timetables_facility_id_foreign,"
        "clean_timetables_start_at_index",
        "key=null | rows=1044 | r_rows=175.00 | filtered=32.47 | "
        "Extra=Using where; Using filesort",
    ], CW))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "<b>単一列の索引が2本とも候補に挙がりながら、"
        "どちらも使われていません。</b>"
        "これが複合索引を必要とする形そのものです。", S_BODY))
    story.append(Spacer(1, 2))
    story.append(quote(
        "<b>ただし、ここは①②とは性質が異なります。</b><br/>"
        "③④は possible_keys に索引が挙がっておりますので、"
        "<b>使うかどうかはコスト判断</b>です。<br/>"
        "<b>上の実行計画は当方の検証用データ(少量)で測ったものですので、"
        "行数が増えれば選択が変わる可能性があります。</b><br/>"
        "そのため③④は、①②ほど強くは申し上げられません。", CW))
    story.append(Spacer(1, 6))

    story.append(CondPageBreak(45 * mm))
    story.append(Paragraph("2本を1本にまとめない理由", S_H4))
    story.append(Paragraph(
        "(facility_id, precheck, start_at) の1本に寄せる案もありえますが、"
        "<b>施設で絞らない呼び出し(画面の既定)が主経路</b>であり、"
        "その1本では precheck = 0 AND start_at が引けません。"
        "<b>そのため当方は2本別々のものとして見ておりました。</b>", S_BODY))
    story.append(Paragraph(
        "なお clean_timetables の既存索引は6本すべて単一列で、"
        "<b>2026-09-13 時点では複合索引は1本もありませんでした</b>。",
        S_BODY))

    story.append(CondPageBreak(80 * mm))
    story.append(Paragraph(
        "1-4. ⑤ — 期間での集計(面積の累計表示に関係します)", S_H3))
    story.append(Spacer(1, 2))
    story.append(code([
        "ALTER TABLE `clean_status_histories`",
        "  ADD INDEX `clean_status_histories_started_at_index` (`started_at`);",
    ], CW))
    story.append(Spacer(1, 6))
    story.append(Paragraph("根拠", S_H4))
    story.append(Paragraph(
        "当方は <b>清掃総面積を期間で絞って集計する画面</b> を"
        "追加しております(本書2章と関連)。"
        "その集計は clean_status_histories を期間で絞って SUM() を取る"
        "形になりますが、<b>started_at に索引が無いため、"
        "期間で絞っても全行を走査します。</b>", S_LEAD))
    story.append(Spacer(1, 3))
    story.append(code([
        "EXPLAIN SELECT facility_id, SUM(clean_area) FROM "
        "clean_status_histories",
        " WHERE started_at >= '2026-09-01' AND started_at < '2026-09-14' "
        "GROUP BY facility_id;",
        "→ type=index | key=clean_status_histories_facility_id_foreign | "
        "rows=538 | Extra=Using where",
    ], CW))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "(rows は当方の検証用データでの行数です。"
        "<b>申し上げたいのは「started_at に索引が無い」という構造のほう</b>で、"
        "こちらは SHOW CREATE TABLE で確認しております)", S_NOTE))
    story.append(Paragraph(
        "<b>ここでも規模の前提を置き直しております。</b>"
        "当方の社内資料は 「1日約360行・年間約13万行・保持2年」 を"
        "想定しておりましたが、"
        "これは施設200件規模に相当する数字でした。", S_LEAD))
    story.append(Spacer(1, 3))
    story.append(code([
        "ロボット30台 × 2回/日 × 365日 = 約 2.2万行/年"
        "(2年で 約4.4万行)",
    ], CW))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "<b>規模は当初想定の 1/6 ほどになりますが、"
        "「期間で絞っても全行を読む」という形そのものは"
        "行数によらず変わりません。</b>", S_BODY))

    story.append(CondPageBreak(80 * mm))
    story.append(Paragraph(
        "1-5. ⑥⑦ — D-Security 制御盤(第8回以降に判明)", S_H3))
    story.append(Spacer(1, 2))
    story.append(code([
        "ALTER TABLE `control_reports`",
        "  ADD INDEX `control_reports_control_id_reported_at_index` "
        "(`control_id`, `reported_at`);",
        "",
        "ALTER TABLE `control_error_histories`",
        "  ADD INDEX `control_error_histories_occurred_at_index` "
        "(`occurred_at`);",
    ], CW))
    story.append(Spacer(1, 6))

    story.append(CondPageBreak(70 * mm))
    story.append(Paragraph(
        "⑥の根拠 — <b>同じテーブル・同じ索引で、列を変えただけで"
        "実行計画が切り替わりました</b>", S_H4))
    story.append(Paragraph(
        "制御盤ごとの「最終通信時刻」を取るため MAX(reported_at) を"
        "使っています。", S_LEAD))
    story.append(Spacer(1, 3))
    story.append(table(MAXCOL_ROWS,
                       [32 * mm, 24 * mm, 30 * mm, 14 * mm, W - 100 * mm],
                       [S_CELL, S_CELL, S_CELL, S_CELL, S_CELL]))
    story.append(Spacer(1, 4))
    story.append(Paragraph(
        "(rows は当方の検証用データでの行数です。"
        "<b>ご覧いただきたいのは type と Extra が切り替わった点</b>です)",
        S_NOTE))
    story.append(Spacer(1, 2))
    story.append(Paragraph(
        "<b>InnoDB の二次索引には主キーが暗黙に付く</b>ため、既存の<br/>"
        "control_reports_control_id_foreign は物理的に "
        "(control_id, id) の複合索引として働いています。"
        "<b>2列目が reported_at になれば、MAX(reported_at) にも"
        "同じ効果が及びます。</b>", S_BODY))
    story.append(Paragraph(
        "<b>control_reports は心拍の報告が積み上がるテーブルで、"
        "本番では最も行数が多くなる見込みです</b>"
        "(<b>8章の報告間隔次第で1,100万行〜7,000万行。"
        "制御盤11台という当方の置き値での試算です</b>)。"
        "<b>ここが全表走査のままですと、D-Sログ画面が開けなくなります。</b>",
        S_BODY))
    story.append(Paragraph(
        "<b>清掃側(①〜⑤)とは事情が異なります。</b> "
        "清掃側の行数は台数と清掃回数で決まりますが、"
        "control_reports は <b>時間あたりの報告間隔で決まる</b>ため、"
        "台数が少なくても行数は積み上がります。"
        "<b>そのため上の「ロボット30台」の置き直しは、⑥⑦には効きません。"
        "</b>", S_BODY))

    story.append(CondPageBreak(35 * mm))
    story.append(Paragraph("⑦の根拠", S_H4))
    story.append(Paragraph(
        "D-Secアラートの絞り込みで occurred_at の範囲指定を使いますが、"
        "索引がありません。", S_BODY))

    story.append(CondPageBreak(95 * mm))
    story.append(Paragraph("1-6. ①〜⑦の一覧(突き合わせ用)", S_H3))
    story.append(Paragraph(
        "<b>貴社のご対応内容と突き合わせていただくための一覧です。</b>",
        S_LEAD))
    story.append(Spacer(1, 3))
    # 「テーブル」列は control_error_histories(24字 = 8.8pt で 37.3mm)が
    # 語中で割れないよう 44mm(内寸 39.8mm)にしている
    story.append(table(INDEX_LIST_ROWS,
                       [10 * mm, 44 * mm, 44 * mm, W - 98 * mm],
                       [S_CELL, S_CELL, S_CELL, S_CELL]))
    story.append(Spacer(1, 6))
    story.append(Paragraph("申し添え", S_H4))
    story.extend(bull([
        "①〜⑦のテーブルは、いずれも "
        "<b>貴社のデータ収集処理が書き込むテーブル</b>です。"
        "<b>索引を足すぶん、書き込みがわずかに遅くなります。</b>"
        "<b>その兼ね合いのご判断は貴社にお任せいたします。</b>",
        "<b>当方の見立てでは ①② が突出しています</b>"
        "(索引が1本も無い列のため)。"
        "<b>③〜⑦は、①②の後で差し支えありません。</b>",
        "<b>当方はメインDBのスキーマを変更しない方針です。</b> "
        "<b>上記を当方で実行することはございません。</b>",
        "<b>ご対応が完了しましたら、その旨をお知らせいただけますと、"
        "当方で EXPLAIN を測り直し、残りがあるかどうかを"
        "確認いたします。</b>",
    ]))

    # ---------------------------------------------------------- 2. サマリの面積
    story.append(CondPageBreak(85 * mm))
    story.append(Paragraph(
        "2. サマリの面積は、期間内の合計でしょうか", S_H2))

    story.append(Paragraph(
        "2-1. 本章は仕様の確認のみに縮めております", S_H3))
    story.append(Paragraph(
        "<b>初版では、テスト環境の facility_summaries / "
        "customer_summaries の値を並べて「合計に見えません」と"
        "申し上げておりました。</b>"
        "<b>この値は当方が入れた検証用のダミーデータでしたので、"
        "当該の指摘は取り下げます。</b>"
        "<b>お騒がせいたしました。</b>", S_BODY))
    story.append(Paragraph(
        "<b>残しますのは、実装のために必要な仕様の確認だけです。</b>",
        S_BODY))
    story.append(Paragraph(
        "当方は <b>「清掃総面積の累計」を画面に表示する機能</b> を"
        "作っており、その数字をサマリから取るのか、"
        "clean_status_histories から毎回その場集計するのかを"
        "決める必要があります"
        "(後者を選ぶ場合に効くのが、1章の⑤の列です)。", S_BODY))

    story.append(CondPageBreak(85 * mm))
    story.append(Paragraph("2-2. お伺いしたいこと(2問)", S_H3))
    story.append(Spacer(1, 2))
    story.append(qhead(
        "Q2-1. 集計バッチは、task_area / clean_area に "
        "期間内の合計 を書き込む仕様でしょうか", CW))
    story.append(Spacer(1, 4))
    story.append(table(Q21_ROWS, [16 * mm, W - 16 * mm], [S_CELL, S_CELL]))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "<b>B の場合、当方は clean_status_histories から"
        "毎回その場集計する形にいたします。</b>"
        "<b>「サマリに合計を入れるほうが筋がよい」というご判断であれば、"
        "そちらに合わせます。</b>", S_BODY))

    story.append(CondPageBreak(90 * mm))
    story.append(qhead(
        "Q2-3. 1つの clean_timetable_id に対し、clean_status_histories は"
        "何行できますでしょうか", CW))
    story.append(Spacer(1, 4))
    story.append(Paragraph(
        "<b>面積の合計をSQLの SUM() で取るか、行を1本に畳んでから足すかが、"
        "これで変わります。</b>", S_LEAD))
    story.append(Spacer(1, 3))
    story.append(table(Q23_ROWS, [16 * mm, W - 16 * mm], [S_CELL, S_CELL]))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "<b>「清掃状態履歴」というテーブル名からは、"
        "B の設計ではないかとも思われます。</b>"
        "<b>一方で clean_status_histories にはカラムとしての状態"
        "(clean_status_trans 相当)が無く、この点は A のようにも"
        "読めます。</b>"
        "<b>どちらとも決めかねましたので、お教えください。</b>", S_BODY))
    story.append(Paragraph(
        "<b>B の場合、SUM() で足すと同じ清掃の面積を"
        "何度も数えてしまいます。</b>"
        "<b>そして、それは例外もエラーも出さずに、"
        "ただ数字が大きくなるだけです。</b>", S_BODY))
    story.append(Paragraph(
        "<b>当方は安全側(B の前提で、最新の1行に畳んでから足す)で"
        "実装いたしますが、A と確定できるのであれば、"
        "そのほうが速くなります。</b>", S_BODY))
    story.append(Spacer(1, 2))
    story.append(quote(
        "<b>設問番号について</b>: "
        "初版の Q2-2(「合計でない場合の正しい経路をお教えください」)は"
        "取り下げました"
        "(Q2-1 の B に当方の対応を書きましたので、"
        "独立の設問にする必要がなくなったためです)。"
        "<b>番号は詰めずに Q2-3 のままにしております。</b>", CW))

    story.append(CondPageBreak(85 * mm))
    story.append(Paragraph(
        "2-4. 参考: 年次サマリの桁あふれについて(優先度は低いです)", S_H3))
    story.append(Paragraph(
        "<b>ご依頼でも質問でもなく、気づいた点のご報告です。</b>", S_LEAD))
    story.append(Spacer(1, 3))
    story.append(code([
        "facility_summaries.task_area / clean_area   double(14,6)  "
        "→ 上限 約 1億㎡",
        "customer_summaries.task_area / clean_area   double(14,6)  → 同上",
    ], CW))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "<b>もしサマリに期間内の合計を入れる場合(Q2-1 が A の場合)、"
        "年次の行が上限に当たる可能性があります。</b>", S_LEAD))
    story.append(Spacer(1, 3))
    story.append(table(OVERFLOW_ROWS, [44 * mm, W - 44 * mm],
                       [S_CELL, S_CELL]))
    story.append(Spacer(1, 6))
    story.append(code([
        "1億㎡ ÷ 30台 ÷ 365日 ≒ 9,132 ㎡/台/日",
    ], CW))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "<b>これは当方の計算であり、実測ではありません</b>"
        "(当方の手元には実際の店舗面積の値がございませんので、"
        "9,100㎡ が現実的な値かどうかは判断できておりません)。", S_BODY))
    story.append(Paragraph(
        "<b>初版では「施設200件」を前提に 約1,370㎡ としておりましたが、"
        "弊社で現状のロボット台数を確認したところ30台未満でしたので、"
        "置き直しております(1章と同じ前提です)。</b>"
        "<b>桁あふれの可能性はそのぶん遠のきました。</b>", S_BODY))
    story.append(Paragraph(
        "<b>週次・月次では問題になりません。"
        "P1 で当方が読むのも週次・月次のみです。</b>"
        "<b>年次サマリを作られる際にご一考いただければ、"
        "という程度のものです。</b>", S_BODY))

    # ---------------------------------------------------------- 3. 第8回 Q4
    story.append(CondPageBreak(70 * mm))
    story.append(Paragraph(
        "3. 第8回 Q4 の残り(サマリの率・分子・分母の関係)— 再掲", S_H2))
    story.append(Paragraph(
        "第8回で「多いので後で回答」との留保をいただいた件です。"
        "<b>取り下げておりません。</b>", S_BODY))
    story.append(Paragraph(
        "お伺いしたい内容は第8回の4章と同じです。要点だけ再掲します。",
        S_LEAD))
    story.append(Spacer(1, 2))
    story.extend(bull([
        "サマリに入る各率(clean_ratio 等)の <b>計算式</b>",
        "<b>分子・分母に対応するカラム名</b>",
        "件数列(task_count 等)が <b>何を数えているか</b>"
        "(予定の数か、実施の数か)",
    ]))
    story.append(Spacer(1, 2))
    story.append(Paragraph(
        "<b>2章の面積の件と同じ枠の話ですので、"
        "あわせてご回答いただけますと助かります。</b>", S_BODY))

    # ---------------------------------------------------------- 4. error_count
    story.append(CondPageBreak(80 * mm))
    story.append(Paragraph("4. サマリの error_count の集計範囲", S_H2))
    story.append(Paragraph(
        "第8回 A7 でいただいたご回答が、"
        "<b>「バッチが実際にそうしている」のか「当方への助言」なのか</b>"
        "を読み取れませんでした。"
        "<b>当期(進行中の週・月)は当方が自前で集計するため、"
        "定義が食い違うと、確定期間と当期でグラフが段差になります。</b>",
        S_BODY))

    story.append(Paragraph("お伺いしたいこと(3問)", S_H3))
    story.append(Spacer(1, 2))
    story.append(qhead(
        "Q4-1. サマリの error_count は、事前チェック"
        "(precheck = 30 / 60)を除外して集計されていますでしょうか", CW))
    story.append(Spacer(1, 4))
    story.append(table(Q41_ROWS, [16 * mm, W - 16 * mm], [S_CELL, S_CELL]))
    story.append(Spacer(1, 8))

    # Q4-2 は原稿に本文が無く帯だけなので、単独でページ末に取り残されないよう
    # Q4-3 の帯まで入る高さを確保してから置く
    story.append(CondPageBreak(62 * mm))
    story.append(qhead(
        "Q4-2. clean_timetable_id が null のエラー行は、サマリの "
        "error_count に含まれますでしょうか", CW))
    story.append(Spacer(1, 8))

    story.append(CondPageBreak(55 * mm))
    story.append(qhead(
        "Q4-3. clean_error が 2(セキュリティ有効)/ "
        "3(D-S制御盤異常)の清掃は、サマリの error_count に"
        "含まれますでしょうか", CW))
    story.append(Spacer(1, 4))
    story.append(Paragraph(
        "<b>第8回 A6-1 で「2 / 3 では robot_error_reports が作られない」"
        "とのご回答をいただいております。</b>"
        "<b>その場合、サマリ側でも数えていない、"
        "という理解でよろしいでしょうか。</b>", S_BODY))

    # ---------------------------------------------------------- 5. 面積の単位
    # ★2026-09-17(2回目)の改訂で章ごと差し替えた。設問(Q5-1)も表も無い★
    story.append(CondPageBreak(55 * mm))
    story.append(Paragraph("5. 面積の単位(設問を取り下げます)", S_H2))
    story.append(Paragraph(
        "<b>初版では「task_area / clean_area の単位は平方メートル(㎡)"
        "でしょうか」とお伺いしておりました(Q5-1)。</b>", S_BODY))
    story.append(Spacer(1, 2))
    story.append(callout(
        "<b>本件は弊社(DFA)で「㎡」と確定いたしましたので、"
        "設問を取り下げます。</b><br/>"
        "<b>画面には ㎡ として表示いたします。</b>", CW))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "<b>貴社にご対応いただくことはございません。</b>"
        "<b>万一「㎡ではない」という事情がございましたら、"
        "その場合のみお知らせください。</b>", S_BODY))

    # ---------------------------------------------------------- 6. グループ
    story.append(CondPageBreak(85 * mm))
    story.append(Paragraph("6. グループ単位のサマリ", S_H2))

    story.append(Paragraph(
        "6-1. group_summaries に相当するテーブルが見当たりません", S_H3))
    story.append(Paragraph(
        "サマリ系のテーブルは次の4本と認識しております。", S_LEAD))
    story.append(Spacer(1, 3))
    story.append(code([
        "customer_summaries        / customer_error_summaries   "
        "(customer_id を参照)",
        "facility_summaries        / facility_error_summaries   "
        "(facility_id を参照)",
    ], CW))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "<b>グループ(groups)単位のサマリがありません。</b>", S_BODY))
    story.append(Paragraph(
        "当方は <b>ホールディングス単位のKPIを画面に出す</b> 予定です"
        "(本書7章と関連)。", S_BODY))

    story.append(CondPageBreak(75 * mm))
    story.append(Paragraph("お伺いしたいこと(1問)", S_H3))
    story.append(Spacer(1, 2))
    story.append(qhead(
        "Q6-1. グループ単位の集計は、どちらの想定でしょうか", CW))
    story.append(Spacer(1, 4))
    story.append(table(Q61_ROWS, [16 * mm, W - 16 * mm], [S_CELL, S_CELL]))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "<b>A の場合、率は「率の平均」ではなく"
        "「Σ分子 ÷ Σ分母」で再計算いたします。</b>"
        "<b>分子・分母がサマリに入っている前提ですので、"
        "3章のご回答と関係します。</b>", S_BODY))

    story.append(CondPageBreak(95 * mm))
    story.append(Paragraph(
        "6-2. ところが、4つの率のうち2つは分子・分母が揃っておりません",
        S_H3))
    story.append(Paragraph(
        "<b>本節は、テーブルのカラム構成(DDL)と当方の計算式を"
        "突き合わせたもので、行の中身には依存しておりません。</b>",
        S_BODY))
    story.append(Paragraph(
        "サマリのカラムを当方の計算式(要件定義)と突き合わせたところ、"
        "次のようになりました。", S_LEAD))
    story.append(Spacer(1, 3))
    story.append(table(RATIO_ROWS,
                       [30 * mm, 48 * mm, 30 * mm, 28 * mm, W - 136 * mm],
                       [S_CELL, S_CELL, S_CELL, S_CELL, S_CELL]))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "<b>「清掃実施回数」に当たるカラムが見当たりませんでした。</b>",
        S_BODY))

    # なぜ「清掃実施回数」が要るのか(2026-09-17 の2回目の改訂で新設)
    story.append(CondPageBreak(95 * mm))
    story.append(Paragraph(
        "なぜ「清掃実施回数」が要るのか(Q6-2 の理由)", S_H4))
    story.append(Paragraph(
        "<b>当方の4つの率のうち、2つは「清掃実施回数」を必要とします。</b>",
        S_LEAD))
    story.append(Spacer(1, 3))
    story.append(code([
        "稼働率       = 清掃実施回数   ÷ 清掃予定回数 × 100",
        "エラー発生率 = エラー発生回数 ÷ 清掃実施回数 × 100",
    ], CW))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "<b>つまり「実施回数」は、一方の率では分母、"
        "もう一方の率でも分母に当たります。</b>"
        "<b>そして task_count は、予定回数か実施回数かの"
        "どちらか一方にしかなりません。</b>", S_LEAD))
    story.append(Spacer(1, 3))
    story.append(table(TASKCOUNT_ROWS, [50 * mm, W - 50 * mm],
                       [S_CELL, S_CELL]))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "<b>いずれにせよ、現在のカラム構成では2つの率のうち"
        "少なくとも片方が計算できません。</b>"
        "(task_count が実際にどちらなのかは、"
        "3章でお伺いしております)", S_BODY))
    story.append(Paragraph(
        "<b>これは合算のときだけの話ではありません。</b>", S_BODY))
    story.append(Spacer(1, 2))
    story.append(callout(
        "<b>確定した期間(前週・前月)の稼働率・エラー発生率そのものが、"
        "サマリからは出せません。</b>", CW))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "<b>進行中の期間は当方が詳細テーブルからその場集計しますので"
        "出せますが、確定期間はサマリを読む設計ですので、"
        "そこで数字が欠けます。</b>", S_BODY))
    story.append(Paragraph(
        "<b>複数顧客を合算する場合は、さらに事情が重くなります。</b>"
        "<b>率の平均を取るのは誤りで、分子と分母をそれぞれ足してから"
        "割る必要がありますが、この2つはその材料がありません。</b>",
        S_BODY))

    story.append(CondPageBreak(75 * mm))
    story.append(qhead(
        "Q6-2. サマリに「清掃実施回数」のカラムを追加していただくことは"
        "可能でしょうか", CW))
    story.append(Spacer(1, 4))
    story.append(table(Q62_ROWS, [16 * mm, W - 16 * mm], [S_CELL, S_CELL]))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "<b>C の場合、当方は確定期間(前週・前月)の稼働率・"
        "エラー発生率を画面に出さず、"
        "清掃完了率と総清掃面積だけを出す形にいたします。</b>"
        "<b>進行中の期間だけ2つの率が出て、確定期間では消える、"
        "という見え方になります。</b>", S_BODY))
    story.append(Paragraph(
        "<b>初版では「効いてくるのは2社目から」と書いておりましたが、"
        "これは合算の話に限った見立てでした。</b>"
        "<b>上のとおり、1社であっても確定期間の2つの率が出せませんので、"
        "訂正いたします。</b>", S_BODY))
    story.append(Paragraph(
        "<b>お急ぎいただく必要はございませんが、設計を決めたいので"
        "方針だけ伺えますと助かります。</b>", S_BODY))

    # ---------------------------------------------------------- 7. マスタ投入
    story.append(CondPageBreak(85 * mm))
    story.append(Paragraph(
        "7. グループ・顧客マスタのデータ投入のお願い", S_H2))

    story.append(Paragraph("7-1. 背景", S_H3))
    story.append(Paragraph(
        "当方は画面に <b>「ホールディングス → グループ(ブランド)→ 店舗」"
        "の3階層の絞り込み</b> を出す予定です。", S_BODY))
    story.append(Paragraph(
        "テーブルの構造"
        "(groups → customers.group_id → facilities.customer_id)は "
        "<b>すでに3階層になっており、"
        "当方から追加をお願いする必要はございません。</b>", S_BODY))
    # ★2026-09-17(2回目)の改訂でコード枠を削除した
    #   (テスト環境の中身は当方が入れたダミーだったため)★
    story.append(Paragraph(
        "<b>ただし、当方の手元には本番の中身がございません。</b>", S_BODY))
    story.append(Paragraph(
        "テスト環境に入っておりますのは <b>当方が入れた検証用のダミー</b>"
        "(groups 1行 / customers 1行 / "
        "facilities 12行・全行が同じ customer_id)だけです。"
        "<b>そのため当方では、3階層の絞り込みが実データで"
        "どう見えるかを確かめられておりません。</b>", S_BODY))

    story.append(CondPageBreak(60 * mm))
    story.append(Paragraph("7-2. お願いと、確認したいこと", S_H3))
    story.append(Spacer(1, 2))
    story.append(qhead(
        "お願い: 本番の groups / customers のデータ投入を"
        "お願いいたします", CW))
    story.append(Spacer(1, 4))
    story.append(Paragraph(
        "<b>時期の目安だけでも結構です。</b>"
        "<b>当方はそれまで、検証用の複数グループ・複数顧客のデータを"
        "自前で用意して進めます。</b>", S_BODY))

    story.append(CondPageBreak(75 * mm))
    story.append(qhead(
        "Q7-1. customers は、法人と屋号のどちらの粒度で"
        "入る想定でしょうか", CW))
    story.append(Spacer(1, 4))
    story.append(Paragraph(
        "<b>当方は「屋号(ブランド)」の粒度で画面を作る方針です。</b>",
        S_LEAD))
    story.append(Spacer(1, 3))
    story.append(table(Q71_ROWS, [16 * mm, W - 16 * mm], [S_CELL, S_CELL]))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "<b>A と B では、同じ法人の中で屋号を分けられるかどうかが"
        "変わります。</b>"
        "<b>貴社の収集処理がどちらの粒度で customers を作る想定かを"
        "お教えください。</b>", S_BODY))

    story.append(CondPageBreak(55 * mm))
    story.append(qhead(
        "Q7-2. 屋号を持たない顧客は、どう表現されますでしょうか", CW))
    story.append(Spacer(1, 4))
    story.append(Paragraph(
        "<b>当方は「屋号が無い顧客もありうる」という前提で"
        "画面を作ります。</b> "
        "customers.group_id は <b>0 がセンチネル(グループなし)</b> と"
        "認識しておりますが、"
        "<b>屋号そのものが無い場合に customer_name に何が入るか</b>を"
        "お教えください。", S_BODY))

    story.append(CondPageBreak(50 * mm))
    story.append(qhead(
        "Q7-3. facilities.shop_id(varchar(20)、既定 '0')は"
        "何を表す列でしょうか", CW))
    story.append(Spacer(1, 4))
    story.append(Paragraph(
        "テーブル定義書からは読み取れず、"
        "<b>テスト環境の値も当方が入れたダミー(全行 \"0\")でしたので、"
        "値から推し量ることもできませんでした。</b>", S_BODY))
    story.append(Paragraph(
        "<b>顧客側の店舗コードでしょうか。"
        "分類の手がかりに使えるかを知りたく存じます。</b>", S_BODY))

    # ★原稿の7章は Q7-5 が Q7-4 より前に置かれている。その断り書き
    #   (2026-09-17 の改訂で新設)。引用ブロックと Q7-5 の帯は必ず同じページに★
    story.append(CondPageBreak(112 * mm))
    story.append(Spacer(1, 2))
    story.append(quote(
        "<b>設問の並びについて</b><br/>"
        "<b>本節は Q7-5 → Q7-4 の順に並んでおります</b>。"
        "Q7-4(group_id = 0 が投入できない件)が Q7-5 のご回答を"
        "前提とする内容のため、この順にしております。<br/>"
        "<b>当方の管理表がこの番号で追っておりますので、"
        "番号は振り直しておりません。</b>", CW))
    story.append(Spacer(1, 8))
    story.append(qhead(
        "Q7-5. 「屋号を持たない顧客」は、3階層のどの段が空くという"
        "意味でしょうか", CW))
    story.append(Spacer(1, 4))
    story.append(Paragraph(
        "<b>当方の中でも解釈が割れましたので、"
        "貴社のDBの想定を伺わせてください。</b>", S_BODY))
    story.append(Paragraph(
        "当方が作ろうとしている階層はこの3段です。", S_LEAD))
    story.append(Spacer(1, 3))
    story.append(code([
        "ホールディングス  →  屋号(ブランド)  →  店舗",
        "   groups              customers          facilities",
    ], CW))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "「屋号を持たない顧客がありうる」と伺っておりますが、"
        "<b>どの段が空くのかで設計が変わります。</b>", S_LEAD))
    story.append(Spacer(1, 3))
    story.append(table(LAYER_ROWS, [12 * mm, 28 * mm, 44 * mm, W - 84 * mm],
                       [S_CELL, S_CELL, S_CELL, S_CELL]))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "<b>② であれば、Q7-4 が解決すればそのまま作れます。</b>", S_BODY))
    story.append(Paragraph(
        "<b>① の場合、customers にダミーの行を1件作って、"
        "そこに店舗をぶら下げることになります。</b>"
        "<b>その場合、customer_name に何を入れるか"
        "(空文字は避けたく存じます)をお決めいただく必要があります。</b>",
        S_BODY))
    story.append(Paragraph(
        "<b>貴社の収集処理が customers をどう作る想定かを"
        "お教えいただければ、当方はそれに合わせます。</b>", S_BODY))

    story.append(CondPageBreak(95 * mm))
    story.append(qhead(
        "Q7-4. customers.group_id = 0(グループなし)は、"
        "現状のDDLでは投入できないように見えます", CW))
    story.append(Spacer(1, 4))
    story.append(Paragraph(
        "<b>これは質問というより、実DDLの矛盾のご報告です。</b>", S_BODY))
    story.append(Paragraph(
        "当方は customers.group_id について、"
        "<b>「NOT NULL / 既定値 0 / 0 はグループなしを表すセンチネル」</b>"
        "と理解しております"
        "(貴社より、索引の効率を理由にこの方式を採る旨の"
        "ご回答をいただいております)。", S_BODY))
    story.append(Paragraph(
        "<b>ところが、実際のDDLでは 0 を投入できません。</b> "
        "当方が接続して確認した内容です。", S_LEAD))
    story.append(Spacer(1, 3))
    story.append(code([
        "-- SHOW CREATE TABLE customers (抜粋)",
        "`group_id` bigint(20) unsigned NOT NULL DEFAULT 0,",
        "CONSTRAINT `customers_group_id_foreign` FOREIGN KEY (`group_id`) "
        "REFERENCES `groups` (`id`)",
    ], CW))
    story.append(Spacer(1, 6))
    # ★2026-09-17(2回目)の改訂で「SELECT id FROM groups … 1 のみ」の行を
    #   削除した(テスト環境の groups の中身は当方が入れたダミーのため)★
    story.append(code([
        "SELECT @@foreign_key_checks;           →  1"
        "(外部キー制約は有効です)",
        "SELECT @@sql_mode;                     →  NO_AUTO_VALUE_ON_ZERO は"
        "含まれておりません",
    ], CW))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "<b>外部キー制約が有効である以上、group_id = 0 の顧客を "
        "INSERT するには、groups に id = 0 の行が"
        "存在していなければなりません。</b>"
        "<b>group_id を省略して INSERT した場合も、"
        "既定値の 0 が入るため同じです。</b>", S_BODY))
    story.append(Paragraph(
        "そして groups.id は AUTO_INCREMENT ですので、"
        "<b>id = 0 の行を入れること自体に NO_AUTO_VALUE_ON_ZERO が必要</b>"
        "ですが、上のとおり sql_mode には含まれておりません。", S_BODY))
    story.append(Spacer(1, 2))
    story.append(quote(
        "<b>申し添え</b>: テスト環境の groups には id = 0 の行が"
        "ございませんでしたが、"
        "<b>この表の中身は当方が入れた検証用のダミー</b>ですので、"
        "貴社の想定を示すものではありません。<br/>"
        "<b>そのため本件は「実データがこうなっている」ではなく、"
        "「DDLと sql_mode の組み合わせ上こうなる」というご報告です。</b>",
        CW))

    story.append(CondPageBreak(85 * mm))
    story.append(Paragraph("Q7-4 でお伺いしたいこと", S_H3))
    story.append(Paragraph(
        "<b>当方はメインDBのスキーマを変更しない方針のため、"
        "当方では直せません。</b>"
        "<b>どの形がよいか、貴社のご判断をお聞かせください。</b>", S_LEAD))
    story.append(Spacer(1, 3))
    story.append(table(Q74_ROWS, [16 * mm, 68 * mm, W - 84 * mm],
                       [S_CELL, S_CELL, S_CELL]))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "<b>D であれば、対応は不要です。その旨だけお知らせください。</b>",
        S_BODY))

    # ---------------------------------------------------------- 8. 報告間隔
    story.append(CondPageBreak(85 * mm))
    story.append(Paragraph("8. 制御盤の報告間隔", S_H2))

    story.append(Paragraph(
        "8-1. 台数については、当方で把握いたしました(設問を取り下げます)",
        S_H3))
    story.append(Paragraph(
        "初版では「P1 稼働時点の施設数」「ロボット・D-S制御盤の台数」の"
        "2問を貴社にお伺いしておりました。"
        "<b>この2問は取り下げます。</b>", S_BODY))
    story.append(Paragraph(
        "<b>理由: 本番の規模は弊社(DFA)側で決めるべきことであり、"
        "貴社にお伺いする筋のものではありませんでした。"
        "当方の記録にもその旨が残っておりました。</b>", S_BODY))
    story.append(Paragraph(
        "<b>現時点の当方の把握は次のとおりです。"
        "参考までにお知らせいたします。</b>", S_LEAD))
    story.append(Spacer(1, 3))
    story.append(table(SCALE_ROWS, [30 * mm, 74 * mm, W - 104 * mm],
                       [S_CELL, S_CELL, S_CELL]))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "<b>上の「11台」は、当方がテスト環境に入れた検証用データの台数を"
        "そのまま置き値にしたものです。</b>"
        "実運用の台数ではありません。", S_BODY))
    story.append(Paragraph(
        "<b>1章・2-4 の外挿は、この「ロボット30台」を前提に"
        "置き直しております。</b>"
        "<b>台数が大きく動いた場合は、当方から改めてお知らせいたします。</b>",
        S_BODY))

    story.append(CondPageBreak(85 * mm))
    story.append(Paragraph("お伺いしたいこと(1問)", S_H3))
    story.append(Spacer(1, 2))
    story.append(qhead(
        "Q8-3. link_schedules.intervals(制御盤の報告間隔・秒)は、"
        "運用上どの範囲で動きますでしょうか", CW))
    story.append(Spacer(1, 4))
    story.append(Paragraph(
        "<b>初版では「実運用値をお教えください」と、"
        "固定の1つの値を伺う形で書いておりました。</b>"
        "<b>その後、この値は運用上変わりうるものだと分かりましたので、"
        "聞き方を改めます。</b>", S_BODY))
    story.append(Paragraph(
        "<b>お教えいただきたいのは、次の3点です。</b>", S_LEAD))
    story.append(Spacer(1, 3))
    story.append(table(Q83_ROWS, [10 * mm, 62 * mm, W - 72 * mm],
                       [S_CELL, S_CELL, S_CELL]))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "<b>おおよそで構いません。</b>"
        "<b>「だいたいこの範囲」が分かれば十分です。</b>", S_BODY))

    story.append(CondPageBreak(75 * mm))
    story.append(Paragraph("なぜ範囲と頻度を伺うのか", S_H4))
    story.append(Paragraph(
        "<b>ここが control_reports の行数を決めるためです。</b>"
        "<b>そして、行数の見積もりが6倍変わります。</b>", S_LEAD))
    story.append(Spacer(1, 3))
    story.append(table(INTERVAL_ROWS, [60 * mm, W - 60 * mm],
                       [S_CELL, S_CELL]))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "<b>1,100万行と7,000万行とでは、索引の要否(1-5 の⑥)も、"
        "画面の作り方も変わります。</b>"
        "<b>下限が分かれば「最大でどこまで積み上がるか」を"
        "押さえられますし、変更の頻度が分かれば、"
        "その前提がいつまで持つかを判断できます。</b>", S_BODY))
    story.append(Paragraph(
        "<b>なお、上の台数(ロボット30台未満)はこの試算には効きません。</b>"
        "control_reports の行数は台数よりも報告間隔で決まるためです"
        "(1-5 ⑥に同じことを書いております)。", S_BODY))
    story.append(Spacer(1, 2))
    story.append(quote(
        "<b>設問番号について</b>: 本章の設問は Q8-3 のみとなりますが、"
        "<b>番号は詰めずに Q8-3 のままにしております</b>"
        "(当方側の管理表がこの番号で対応を追っているためです。"
        "ご了承ください)。", CW))

    # ---------------------------------------------------------- 9. 軽い確認
    story.append(CondPageBreak(80 * mm))
    story.append(Paragraph("9. 軽い確認2件 + DDL未反映2件", S_H2))
    story.append(Spacer(1, 2))
    story.append(qhead(
        "Q9-1. 第8回 A9-2 の robot_task_reports は、DDL 上の "
        "clean_task_reports のことでしょうか", CW))
    story.append(Spacer(1, 4))
    story.append(Paragraph(
        "<b>テーブル一覧に robot_task_reports が"
        "見当たりませんでした。</b>"
        "<b>なお、仮にそうであっても当方の実装は "
        "clean_status_histories を読んでおりますので、"
        "影響はございません。表記の確認のみです。</b>", S_BODY))
    story.append(Paragraph(
        "<b>あわせて、clean_task_reports に "
        "今後データが入る予定があるか もお教えいただけますと"
        "助かります。</b>", S_BODY))
    story.append(Paragraph(
        "テスト環境のこの表には行がございませんが、"
        "<b>そこに入っているデータは当方が用意した検証用のものですので、"
        "貴社の収集処理が書くかどうかは当方では分かりません</b>。",
        S_BODY))

    story.append(CondPageBreak(60 * mm))
    story.append(qhead(
        "Q9-2. 1回の清掃の中の、区域別の内訳は取得できますでしょうか"
        "(聞き直し)", CW))
    story.append(Spacer(1, 4))
    story.append(Paragraph(
        "<b>第8回でもお伺いした件です。</b> "
        "当方が実装できたのは "
        "<b>「1清掃エリア(=1ロボット)につき1つの完了率」</b> までで、"
        "<b>売り場・通路・レジ前といった内訳</b>ではありません。", S_BODY))
    story.append(Paragraph(
        "<b>取得できない場合は「できない」とお答えいただければ、"
        "当方はこの機能を諦めます。</b>", S_BODY))

    story.append(CondPageBreak(60 * mm))
    story.append(qhead("お願い: DDL 未反映の2件(4回目)", CW))
    story.append(Spacer(1, 4))
    story.append(Paragraph(
        "<b>急ぎません。お手すきの折で結構です。</b>", S_LEAD))
    story.append(Spacer(1, 3))
    story.append(table(DDL_ROWS, [10 * mm, W - 10 * mm], [S_CODE, S_CELL]))
    story.append(Spacer(1, 6))

    # ---------------------------------------------------------- 10. 更新用アカウント
    # ★2026-09-17(2回目)の改訂で章ごと差し替えた。すでに発行済みだった★
    # ★SHOW GRANTS の出力を載せるが、IDENTIFIED BY PASSWORD のハッシュは
    #   絶対に載せない(原稿も伏せてある。点検スクリプトの 10. が見ている)★
    story.append(CondPageBreak(80 * mm))
    story.append(Paragraph(
        "10. DB更新用アカウント"
        "(すでに発行済みでした。ご依頼を取り下げます)", S_H2))
    story.append(Paragraph(
        "<b>第5回(2026-09-07)・第6回(2026-09-08)・"
        "第8回(2026-09-11)から継続してお願いしておりました件です。</b>",
        S_BODY))
    story.append(Spacer(1, 2))
    story.append(callout(
        "<b>当方が接続して確認いたしましたところ、必要な UPDATE 権限は"
        "すでに付与されておりました。</b>", CW))
    story.append(Spacer(1, 6))
    story.append(code([
        "SHOW GRANTS FOR CURRENT_USER();   (2026-09-17 に当方が実行)",
        "→ GRANT UPDATE ON `clean_watch`.`robot_error_reports` …",
        "→ GRANT UPDATE ON `clean_watch`.`control_error_histories` …",
    ], CW))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "<b>当方が必要としていたのは、この2つのテーブルの"
        "status / comment / responded_at / resolved_at の"
        "更新のみです。</b>"
        "<b>すでに満たされております。</b>", S_BODY))
    story.append(Paragraph(
        "<b>ご対応済みであることに気づかず、3回にわたって重ねて"
        "お願いしてしまいました。当方の確認漏れです。"
        "失礼いたしました。</b>", S_BODY))
    story.append(Paragraph(
        "<b>本件は取り下げます。"
        "貴社にご対応いただくことはございません。</b>", S_BODY))

    # ---------------------------------------------------------- 11. 開始時刻
    # ★2026-09-17(2回目)の改訂で作り直した。
    #   旧 Q11-1(実開始時刻はどこか)・11-2 の delay 集計表・SQL 枠は削除。
    #   Q11-1 は「この理解で実装してよいか」という確認に置き換え、
    #   Q11-4(よりリアルタイムに近い更新先)を新設した★
    story.append(CondPageBreak(85 * mm))
    story.append(Paragraph(
        "11. 清掃の開始時刻と、リアルタイム性について", S_H2))

    story.append(Paragraph(
        "11-1. Q11-1 は、お伺いする内容を改めました", S_H3))
    story.append(Paragraph(
        "<b>初版では「ロボットが実際に清掃を開始した時刻は、"
        "どのテーブル・どのカラムに入りますでしょうか」と"
        "お伺いしておりました(Q11-1)。</b>", S_BODY))
    story.append(Paragraph(
        "<b>そのように申し上げた根拠は2つございましたが、"
        "いずれも当方側の事情でした。</b>", S_LEAD))
    story.append(Spacer(1, 2))
    story.extend(numlist([
        "テーブル定義書の started_at の備考が"
        "「清掃スケジュールの清掃開始日時を格納する」と読め、"
        "<b>予定時刻の写しのように見えた</b>こと",
        "テスト環境で予定時刻との差を取ったところ全件0分だったこと — "
        "<b>ただしこのデータは当方が入れた検証用のダミーで、"
        "予定時刻をそのまま started_at にも入れていたものでした</b>",
    ]))
    story.append(Spacer(1, 4))
    story.append(callout(
        "<b>弊社内で確認いたしましたところ、"
        "clean_status_histories.started_at を実際の清掃開始時刻として"
        "扱ってよい、との結論になりました。</b>", CW))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "<b>誤った前提でお手を煩わせるところでした。"
        "失礼いたしました。</b>"
        "<b>つきましては、お伺いする内容を「どこにありますか」から"
        "「この理解で実装してしまってよろしいでしょうか」に"
        "改めさせていただきます。</b>", S_BODY))

    story.append(CondPageBreak(100 * mm))
    story.append(qhead(
        "Q11-1. clean_status_histories.started_at を実際の清掃開始時刻"
        "として実装を進めますが、問題ございませんでしょうか", CW))
    story.append(Spacer(1, 4))
    story.append(Paragraph(
        "<b>当方が書こうとしている実装は次のとおりです。</b>", S_LEAD))
    story.append(Spacer(1, 3))
    story.append(code([
        "実開始時刻  = clean_status_histories.started_at",
        "遅延        = started_at − clean_timetables.start_at",
        "出発失敗    = 予定開始時刻 + 猶予30分 を過ぎても "
        "started_at が入らない",
    ], CW))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "<b>確認させていただきたい理由</b> — テーブル定義書の "
        "clean_status_histories.started_at の備考が "
        "<b>「清掃スケジュールの清掃開始日時を格納する」</b> と"
        "書かれており、<b>予定時刻の写しとも読めてしまう</b>ためです。",
        S_BODY))
    story.append(Spacer(1, 2))
    story.append(callout(
        "<b>もし実際には予定時刻が入る列でしたら、上の「出発失敗」は"
        "永久に0件になります。</b><br/>"
        "<b>エラーにもならず、画面上は「全件が時間どおりに出発した」と"
        "表示され続けます。</b>", CW))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "<b>そのため、実装前に一言だけ確認させていただきたい次第です。</b>",
        S_LEAD))
    story.append(Spacer(1, 3))
    story.append(table(Q111_ROWS, [56 * mm, W - 56 * mm], [S_CELL, S_CELL]))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "<b>貴社の収集処理の都合で「いまは予定時刻を書いているが、"
        "今後は実開始時刻を書く」といった事情がございましたら、"
        "その旨もお教えいただけますと助かります。</b>", S_BODY))

    story.append(CondPageBreak(90 * mm))
    story.append(Paragraph("11-2. 背景(残る3問の前提)", S_H3))
    story.append(Paragraph(
        "当方は、清掃が <b>「予定時刻から遅れて始まった/"
        "始まらなかった」</b> という状態を画面に出そうと"
        "しております。", S_BODY))
    story.append(Paragraph(
        "当方の要件定義では、清掃失敗を次のように定めております。", S_LEAD))
    story.append(Spacer(1, 3))
    story.append(quote(
        "予定開始時刻 + <b>猶予30分</b> を経過しても"
        "タスク開始が確認できない", CW))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "started_at で遅延そのものは出せるようになりましたが、"
        "<b>次の2点は started_at だけでは埋まりません</b>。", S_LEAD))
    story.append(Spacer(1, 2))
    story.extend(bull([
        "started_at が入らなかったとき、"
        "<b>「指示すら出ていない」のか「指示は出たが動き出さなかった」のか</b>"
        " が区別できません(→ Q11-2)",
        "貴社のサマリに入っている「出発成功」と、当方の日次判定とで "
        "<b>定義がそろっているか</b> が分かりません(→ Q11-3)",
    ]))
    story.append(Spacer(1, 4))
    # ★見出しだけがページ末に取り残されないよう、Q11-2 の帯まで入る高さを
    #   確保してから見出しを置く★
    story.append(CondPageBreak(95 * mm))
    story.append(Paragraph(
        "お伺いしたいこと(Q11-1 のほかに3問)", S_H3))
    story.append(Spacer(1, 2))
    story.append(qhead(
        "Q11-2. clean_statuses.clean_status_trans が 1(清掃指示)で"
        "止まったケースは、履歴として残りますでしょうか", CW))
    story.append(Spacer(1, 4))
    story.append(Paragraph(
        "定義書によれば clean_status_trans は<br/>"
        "0: 清掃開始前, 1: 清掃指示, 2: 清掃中, … と推移します。", S_BODY))
    story.append(Paragraph(
        "<b>「1(清掃指示)で止まった = 指示は出したが動き出さなかった」"
        "と読めますので、これが取れれば出発失敗を判定できます。</b>",
        S_BODY))
    story.append(Paragraph(
        "<b>ただし clean_status_histories にこのカラムが無く、"
        "clean_statuses(現在の状態)は清掃ごとに上書きされるため、"
        "過去を遡ることができません。</b>", S_BODY))
    story.append(Paragraph(
        "<b>履歴に残る仕組みがあるかをお教えください。</b>", S_BODY))

    story.append(CondPageBreak(80 * mm))
    story.append(qhead(
        "Q11-3. facility_summaries.launch_count / launch_ratio"
        "(出発成功数・出発成功率)の計算式をお教えください", CW))
    story.append(Spacer(1, 4))
    story.append(Paragraph(
        "<b>サマリには、貴社が計算済みの「出発成功数・出発成功率」の"
        "カラムが実在しております。</b>"
        "<b>つまり貴社側では「出発に成功したか」を判定できている、"
        "ということかと存じます。</b>", S_BODY))
    story.append(Paragraph(
        "定義書の備考は <b>「補足を参照.」</b> とあるのみで、"
        "<b>補足の本文を受領しておりません。</b>", S_BODY))
    story.append(Paragraph(
        "<b>この判定式が分かれば、当方も同じ定義で"
        "日次の判定ができるようになります。</b>", S_BODY))

    # Q11-4(2026-09-17 の2回目の改訂で新設)
    story.append(CondPageBreak(95 * mm))
    story.append(qhead(
        "Q11-4. clean_status_histories.started_at のほかに、"
        "より短い間隔で更新されるテーブル・カラムは"
        "ございますでしょうか", CW))
    story.append(Spacer(1, 4))
    story.append(Paragraph(
        "<b>新しくお伺いする設問です。</b>", S_BODY))
    story.append(Paragraph(
        "当方は現在、清掃の進み具合を clean_status_histories から"
        "読んでおります。"
        "<b>これよりも短い間隔で更新される(=よりリアルタイムに近い)"
        "テーブル・カラムがあるのであれば、お教えください。</b>", S_BODY))
    story.append(Paragraph(
        "<b>具体的な候補を挙げてお伺いすることは、あえていたしません。</b>"
        "<b>当方で「このテーブルではないか」と当たりをつけますと、"
        "推測が混じります。</b>"
        "<b>貴社の収集処理が書き込んでいるもののうち、"
        "更新間隔が短いものをお示しいただければ、当方でそれに合わせます。</b>",
        S_BODY))
    story.append(Paragraph(
        "<b>あわせて、差し支えなければ次も添えていただけますと"
        "助かります。</b>", S_LEAD))
    story.append(Spacer(1, 3))
    story.append(table(Q114_ROWS, [10 * mm, W - 10 * mm], [S_CELL, S_CELL]))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "<b>無い場合は「無い」とお答えいただければ、当方は "
        "clean_status_histories のままで進めます。</b>", S_BODY))

    story.append(CondPageBreak(55 * mm))
    story.append(Paragraph(
        "11-3. ご回答がいただけるまでの当方の対応", S_H3))
    story.append(Paragraph(
        "<b>Q11-2 / Q11-3 のいずれも取れない場合、当方は"
        "「出発失敗」と「未実施」を同じものとして扱います</b>"
        "(どちらも clean_error = 1 で判定します)。", S_BODY))
    story.append(Paragraph(
        "<b>Q11-4 にご回答がない場合は、clean_status_histories を読む"
        "現在の形のまま進めます。</b>", S_BODY))
    story.append(Paragraph(
        "<b>いずれも機能を止めるものではありませんので、"
        "ご回答は急ぎません。</b>"
        "<b>ただ、取れるのであれば取りたい、"
        "という性質のものです。</b>", S_BODY))

    # ------------------------------------------------ 12. DB接続経路
    # 初版(2026-09-15)の「参考: テスト環境について(申し送り)」は
    # ★この章に吸収した★。同じ内容を2箇所に書かないこと。
    story.append(CondPageBreak(85 * mm))
    story.append(Paragraph(
        "12. DB接続経路について — お願い3件・確認6問", S_H2))
    story.append(Paragraph(
        "<b>本章は、第9回の初版(2026-09-15)の末尾に"
        "「参考: テスト環境について(申し送り)」として"
        "情報共有のみで記載していた内容を、"
        "正式なお願いとして書き直したものです。</b>"
        "<b>別便にはせず、本書に統合しております。重複はありません。</b>",
        S_BODY))
    story.append(Paragraph(
        "<b>お願い(12-3〜12-5)と質問(12-6)を分けて"
        "書いております。</b>", S_BODY))

    story.append(CondPageBreak(75 * mm))
    story.append(Paragraph(
        "12-1. 期限 — ステージングに実データが入る前", S_H3))
    story.append(Paragraph(
        "<b>日付ではなく「イベント」で切っております。</b>", S_LEAD))
    story.append(Spacer(1, 3))
    story.append(table(DEADLINE_ROWS,
                       [42 * mm, 56 * mm, W - 98 * mm],
                       [S_CELL, S_CELL, S_CELL]))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "<b>過ぎるとどうなるか</b> — <b>実データ投入後に同じことを"
        "行う場合、次が全部必要になります。</b>", S_LEAD))
    story.append(Spacer(1, 2))
    story.extend(numlist([
        "<b>DBアカウントの再発行</b> → <b>接続している側"
        "(当方のアプリ、貴社のデータ収集処理・集計バッチ)を"
        "いったん全部止めて差し替える必要があります</b>",
        "<b>TLS の有効化</b> → <b>サーバ再起動を伴う可能性があり、"
        "夜間の清掃データ収集を止められません</b>"
        "(D-Watch は<b>夜間に自動実行された清掃を翌朝確認する運用</b>です)",
        "<b>接続元IPの制限</b> → <b>実データが流れている状態で"
        "絞りますと、漏れた接続元からデータ収集が止まります</b>",
    ]))
    story.append(Spacer(1, 4))
    story.append(callout(
        "<b>つまり「止める調整」が発生します。"
        "いまであれば、その調整が要りません。</b><br/>"
        "<b>だからこそ、実データが入る前にお願いしております。</b>", CW))

    story.append(CondPageBreak(95 * mm))
    story.append(Paragraph(
        "12-2. 実測値(いつ・誰が・どう測ったか)", S_H3))
    story.append(Paragraph(
        "<b>推測ではありません。実測です。</b>", S_LEAD))
    story.append(Spacer(1, 3))
    story.append(table(MEASURE_META_ROWS, [30 * mm, W - 30 * mm],
                       [S_CELL, S_CELL]))
    story.append(Spacer(1, 6))
    story.append(table(TESTENV_ROWS, [46 * mm, 26 * mm, W - 72 * mm],
                       [S_CELL, S_CELL, S_CELL]))
    story.append(Spacer(1, 6))
    story.append(quote(
        "<b>本章の作成にあたり、テストDBへの再接続は行っておりません"
        "(意図的です)</b>。"
        "<b>参照専用のアカウントではありますが、接続条件が現在も"
        "揃っているかが不明なため</b>、"
        "<b>上記は「2026-09-13 に実測された値」です</b>。<br/>"
        "<b>すでにご対応済みでしたら、12-3〜12-5 は"
        "読み飛ばしてください。</b>", CW))
    story.append(Spacer(1, 6))

    story.append(CondPageBreak(60 * mm))
    story.append(Paragraph("<b>なぜ問題か</b>:", S_LEAD))
    story.append(Spacer(1, 2))
    story.extend(bull([
        "<b>have_ssl = DISABLED と require_secure_transport = OFF が"
        "重なりますと、接続は必ず平文になります</b>。"
        "<b>クライアント側で「TLSを使う」と指定しても、"
        "サーバーが提供していないため暗号化されません</b>",
        "<b>接続元が %(どこからでも)のため、"
        "経路は公開IP上にあります</b>",
        "この2つが重なりますと、"
        "<b>DB接続の資格情報が経路上で保護されていない状態</b>"
        "になります",
        "<b>現在は検証用データのみのため実害はありません。"
        "問題になるのは実データが入った後です</b>",
    ]))
    story.append(Spacer(1, 4))
    story.append(Paragraph(
        "<b>なお、このサーバーは弊社(DFA)が契約し、"
        "貴社に構築・保守をお願いしているものと認識しております。</b>"
        "<b>そのため、実施のご依頼は貴社宛といたしました。"
        "この認識に誤りがございましたら、その旨をお知らせください。</b>",
        S_BODY))

    story.append(CondPageBreak(70 * mm))
    story.append(Paragraph(
        "12-3. お願い1: 接続元の制限(本章の中で最優先です)", S_H3))
    story.append(Paragraph(
        "参照用アカウント@% の % を、<b>固定IP</b>"
        "(または閉域ネットワーク内のみ)に"
        "絞っていただけますでしょうか。", S_BODY))
    story.append(Paragraph("なぜ TLS より先にお願いするのか", S_H4))
    story.append(Paragraph(
        "当方が利用しているDB接続用のライブラリに、"
        "<b>「TLS を有効にしても、条件によっては接続時の資格情報が"
        "経路上に平文で出てしまう」という既知の問題</b>が"
        "報告されております"
        "(<b>当方が 2026-09-16 に自動検査ツールで確認した実測です</b>)。",
        S_BODY))
    story.append(Paragraph(
        "<b>これは弊社内製アプリケーション側の事情ですので、"
        "どう解くかは弊社で判断いたします。"
        "貴社にご対応をお願いするものではありません。</b>", S_BODY))
    story.append(Paragraph(
        "<b>ただし1点だけ、貴社へのお願いの優先順に関係いたします。</b>"
        "この問題がある間は、"
        "<b>TLS を有効にしていただいても資格情報が保護されない"
        "可能性が残ります</b>。", S_BODY))
    story.append(Spacer(1, 2))
    story.append(callout(
        "<b>そのため当方としては、TLS の有効化よりも先に"
        "「経路そのものを閉じる」=接続元IPの制限のほうが効くと"
        "考えております。</b><br/>"
        "<b>恐れ入りますが、12-4(TLS)より 12-3(接続元の制限)を"
        "先にご検討いただけますと幸いです。</b>", CW))

    story.append(CondPageBreak(45 * mm))
    story.append(Paragraph("12-4. お願い2: TLS の有効化", S_H3))
    story.append(Paragraph(
        "have_ssl を有効にし、可能であれば "
        "require_secure_transport = ON に"
        "していただけますでしょうか。", S_BODY))
    story.append(Paragraph("<b>12-3 の後で構いません。</b>", S_BODY))
    story.append(Paragraph(
        "<b>なお 12-6 の Q12-4 が YES(VMとDBを閉域で結べる)と"
        "なる場合、本項は不要になる可能性があります。</b>", S_BODY))

    story.append(CondPageBreak(90 * mm))
    story.append(Paragraph(
        "12-5. お願い3: 本番用DBアカウントの新規発行", S_H3))
    story.append(Spacer(1, 2))
    story.append(callout(
        "<b>本番接続に使うアカウントは、現在のテスト用とは別のものを"
        "新規に発行していただけますでしょうか。</b><br/>"
        "<b>ユーザー名・パスワードとも</b>お願いいたします。", CW))
    story.append(Spacer(1, 6))
    story.append(Paragraph("理由", S_H4))
    story.extend(bull([
        "<b>過去に、実在のDBユーザー名が当方リポジトリのコミットに"
        "含まれたことがあります</b>"
        "(<b>2026-09-10 のコミットで除去済みですが、"
        "Git の履歴には残っております</b>)。"
        "リポジトリは非公開ですが、"
        "<b>アクセス権を持つ全員が履歴を読めます</b>",
        "<b>パスワードは当該コミットには含まれておりません</b>"
        "(架空の値のみでした)。"
        "<b>問題はユーザー名が既知になっている点です</b>",
        "<b>ユーザー名が既知である以上、12-2 の 参照用アカウント@%"
        "(どこからでも接続可)と組み合わさりますと、"
        "推測の手間が減ります</b>",
        "<b>あわせて、このアカウントの認証情報は、受け渡しの過程で"
        "平文のまま当方側の記録に残っております</b>。"
        "この点からも、"
        "<b>本番用は別の値を新規に発行していただくのが確実</b>と"
        "考えております",
    ]))
    story.append(Spacer(1, 4))
    story.append(Paragraph("お願いしていないこと", S_H4))
    story.extend(bull([
        "<b>Git 履歴の書き換えはお願いしておりません</b>"
        "(当方でも実施いたしません)。"
        "<b>「発行し直す」ほうで解いていただきたい、"
        "というお願いです</b>",
        "アカウントの発行元は貴社ですので、"
        "<b>再発行は貴社にしかお願いできません</b>",
    ]))
    story.append(Spacer(1, 4))
    story.append(Paragraph(
        "<b>タイミングは 12-1 のとおり、"
        "ステージングに実データが入る前です。</b>", S_BODY))

    story.append(CondPageBreak(90 * mm))
    story.append(Paragraph(
        "12-6. 確認: 本番構成について(6問)", S_H3))
    story.append(Paragraph(
        "<b>ここからは質問です。お願いではありません。</b>"
        "<b>Q12-1 以外は YES / NO でお答えいただけます。</b>", S_BODY))
    story.append(Paragraph("なぜお伺いするのか", S_H4))
    story.append(Paragraph(
        "<b>12-3〜12-5 の解き方(=「経路を閉じる」)が"
        "成立するかどうかが、本番の構成にかかっております</b>。",
        S_LEAD))
    story.append(Spacer(1, 2))
    story.extend(bull([
        "VM と DB が同一の閉域ネットワークに入れるのであれば → "
        "<b>12-3・12-4 は「初めから起きない」</b>",
        "公開IP越しに接続する構成であれば → "
        "<b>現在の状態がそのまま本番に持ち越されます</b>",
    ]))
    story.append(Spacer(1, 4))
    story.append(Paragraph(
        "<b>当方は「閉じた構成になる見込み」と考えておりますが、"
        "これは当方の読み取りであり、"
        "明示の確認をいただいた記録がございません。</b>"
        "<b>推測のまま本番に進みますと、"
        "12-1 の「作り直し」に直結いたします。</b>", S_BODY))

    story.append(CondPageBreak(85 * mm))
    story.append(Paragraph("当方の現在の理解と、その根拠", S_H4))
    story.append(Spacer(1, 2))
    story.append(table(UNDERSTAND_ROWS,
                       [8 * mm, 56 * mm, 62 * mm, W - 126 * mm],
                       [S_CELL, S_CELL, S_CELL_S, S_CELL]))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "<b>すなわち、貴社のご回答は「なぜ MariaDB なのか」の"
        "ご説明であり、「本番の構成はこうなります」という"
        "ご説明ではありませんでした。当方が読み取っただけです。</b>",
        S_BODY))
    story.append(Paragraph(
        "<b>そこで、読み取りが合っているかどうかを"
        "確認させてください。</b>", S_BODY))

    story.append(CondPageBreak(75 * mm))
    story.append(qhead(
        "Q12-1. アプリサーバー(VM)とDBサーバーは、"
        "それぞれどちらが用意する想定でしょうか", CW))
    story.append(Spacer(1, 4))
    story.append(Paragraph(
        "<b>当方の中で理解が揃っておりません。"
        "まずここを確定させていただきたく存じます。</b>", S_LEAD))
    story.append(Spacer(1, 3))
    story.append(table(Q121_ROWS, [12 * mm, 70 * mm, W - 82 * mm],
                       [S_CELL, S_CELL, S_CELL]))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "<b>弊社の記録には「アプリ側は弊社」と読める記述と"
        "「貴社に用意していただく」と読める記述が"
        "混在しております。</b>"
        "<b>どちらが正かは貴社のご認識に合わせますので、"
        "お教えください。</b>", S_BODY))

    story.append(CondPageBreak(60 * mm))
    story.append(qhead(
        "Q12-2. 本番のDBは、さくらインターネットの"
        "データベースアプライアンス"
        "(=VM上に自前で構築した MariaDB ではない)で"
        "間違いないでしょうか", CW))
    story.append(Spacer(1, 4))
    story.append(Paragraph("YES / NO", S_BODY))

    story.append(CondPageBreak(50 * mm))
    story.append(qhead(
        "Q12-3. 本番のアプリ(Node.js)は、"
        "さくらのクラウドの VM に載せる想定で"
        "間違いないでしょうか", CW))
    story.append(Spacer(1, 4))
    story.append(Paragraph("YES / NO", S_BODY))

    story.append(CondPageBreak(50 * mm))
    story.append(qhead(
        "Q12-4. その VM と DBアプライアンスは、"
        "同一の閉域ネットワーク(プライベート接続)で"
        "結べますでしょうか", CW))
    story.append(Spacer(1, 4))
    story.append(Paragraph("YES / NO", S_BODY))

    story.append(CondPageBreak(50 * mm))
    story.append(qhead(
        "Q12-5. Q12-4 が YES の場合、"
        "DB側で公開IPからの接続を完全に閉じることは"
        "可能でしょうか", CW))
    story.append(Spacer(1, 4))
    story.append(Paragraph("YES / NO / 条件つき", S_BODY))

    story.append(CondPageBreak(60 * mm))
    story.append(qhead(
        "Q12-6. Q12-4 が NO の場合(公開IP越しの接続になる場合)、"
        "TLS を有効にできますでしょうか", CW))
    story.append(Spacer(1, 4))
    story.append(Paragraph("YES / NO / 該当なし", S_BODY))
    story.append(Spacer(1, 2))
    story.append(Paragraph(
        "<b>NO や「条件つき」の場合は、差し支えなければ理由を"
        "1行だけ添えていただけますと助かります。</b>"
        "<b>当方はそれを前提に、12-3〜12-5 のお願いの内容を"
        "組み直します。</b>", S_BODY))

    story.append(CondPageBreak(70 * mm))
    story.append(Paragraph(
        "12-7. 当方(DFA)側で済ませたこと", S_H3))
    story.append(Paragraph(
        "<b>丸投げではない、というご説明のために書いております。</b>",
        S_BODY))
    story.append(Paragraph(
        "2026-09-13 に当方のアプリコードを棚卸しいたしました。"
        "<b>危険な箇所は1件も見つかっておりません</b>"
        "(生SQL 0件 / 並べ替えのカラム名は固定リテラル1箇所 / "
        "where にユーザー入力なし / "
        "エラー本文に内部情報を載せている箇所 0件 / "
        "DBへの書き込み 0件 / リポジトリ内に実在の認証情報なし)。",
        S_BODY))
    story.append(Paragraph(
        "あわせて、生SQL禁止の静的解析ルール化・"
        "パスパラメータ検証の全体一致化・"
        "CSVの数式インジェクション対策・"
        "本番前セキュリティ確認チェックリストの整備・"
        "DBユーザー名の既定値をコードから除去する対応を"
        "済ませております。", S_BODY))
    story.append(Spacer(1, 2))
    story.append(callout(
        "<b>問題があるのはコードではなく、DBへ至る「経路」のほうです。"
        "そして経路は、当方のコードでは閉じられません。</b><br/>"
        "<b>そのため本章の3件を、正式にお願いとしてお渡しいたします。</b>",
        CW))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "<b>ご負担や副作用のご懸念があれば、その旨をお知らせください。"
        "当方で別の手を検討します。</b>", S_BODY))

    # ---------------------------------------------------------- おわりに
    story.append(CondPageBreak(60 * mm))
    story.append(Paragraph(
        "<b>以上です。ご確認のほど、よろしくお願いいたします。</b>",
        S_BODY))
    # ★Q11-1 は「取り下げ」ではない。取り下げた項目の列挙に混ぜないこと
    #   (点検スクリプトの 12. の FORBIDDEN が、混ざった形の残存を見る)★
    story.append(Paragraph(
        "<b>本改訂で取り下げた項目</b>: <b>5章 Q5-1</b>"
        "(面積の単位。弊社で㎡と確定)/ <b>10章</b>"
        "(DB更新用アカウント。すでに発行済みでした)/ "
        "<b>2章 Q2-2</b>。", S_BODY))
    story.append(Paragraph(
        "<b>取り下げではなく、お伺いする内容を改めた項目</b>: "
        "<b>11章 Q11-1</b> — 「実際の清掃開始時刻はどこにありますか」→ "
        "<b>「started_at を使って実装してよろしいでしょうか」</b>。"
        "<b>こちらはご回答をお願いいたします"
        "(A / B の一言で結構です)。</b>", S_BODY))
    # ★この文は Q11-1(本書で唯一「ご回答をお願いします」と書いた設問)の
    #   段落の直後に来る。「いずれもご対応は不要です」と書くと、せっかくの
    #   回答依頼を打ち消して読めてしまう。★対象を「取り下げた項目」に限定すること★
    #   (点検スクリプトの 12. の FORBIDDEN が旧文言の復活を見る)★
    story.append(Paragraph(
        "<b>上の「本改訂で取り下げた項目」については、"
        "ご対応は不要です。</b>"
        "<b>あわせて、テスト環境の「行の中身」を根拠にしていた指摘を"
        "取り下げております(冒頭の「おことわり」をご覧ください)。</b>",
        S_BODY))
    story.append(Spacer(1, 2))
    story.append(callout(
        "<b>1章(索引)はご対応中と伺いましたので、依頼を取り下げ、"
        "参考情報に改めております。</b><br/>"
        "<b>ご対応が完了しましたら、その旨をお知らせください。"
        "当方で EXPLAIN を測り直します。</b>", CW))
    story.append(Spacer(1, 6))
    story.append(callout(
        "<b>12章は、期限が「ステージングに実データが入る前」という"
        "性質のものです。</b><br/>"
        "<b>まずは 12-6 の Q12-1〜Q12-6 だけでも先にお答えいただけますと、"
        "12-3〜12-5 のお願いの内容を確定できます。</b>", CW))

    # ---------------------------------------------------------- ご回答欄
    # ご回答欄は印刷してご記入いただく想定なので、必ず新しいページから始める
    story.append(PageBreak())
    story.append(Paragraph("ご回答欄", S_TITLE))
    story.append(Paragraph(
        "<b>この表に一言だけご記入いただく形でも、"
        "下の自由記入欄にまとめてご記入いただく形でも結構です。</b>"
        "<b>選択肢のある設問は、記号(A / B / C …)に○を付けて"
        "いただくだけで結構です。</b>", S_LEAD))
    story.append(Spacer(1, 4))
    story.append(form_table(ANSWER_ROWS, [18 * mm, 100 * mm, W - 118 * mm],
                            [S_CELL, S_CELL, S_CELL]))
    story.append(Spacer(1, 8))

    story.append(CondPageBreak(60 * mm))
    story.append(Paragraph("自由記入欄", S_H3))
    story.append(Spacer(1, 3))
    story.append(blank_box(W, 42 * mm))
    story.append(Spacer(1, 6))
    story.append(Paragraph("引き続きよろしくお願いします。", S_NOTE))

    doc.build(story)
    print("生成:", OUT)


if __name__ == "__main__":
    build()