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

第1回「実DDL照合結果と確認事項」(make_ddl_review_pdf.py) の体裁を
そのまま踏襲した続編。A4 縦 / reportlab / 日本語フォント埋め込み /
ヘッダー罫線とページ番号。

フォントは BIZ UDGothic(日本語フォント)を埋め込む。
中国語向けフォントは漢字の字形が異なるため使わない。
フォールバックにも混ぜないこと。

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

原稿は docs/handoff/TeamWorks確認事項_第2回_20260903.md。
文言・件数・表の内容は原稿から変更していない。
"""
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, 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_確認事項_第2回_20260903.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_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)


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),
]


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 code(lines, width):
    """SQL などを等幅フォントの枠に入れる。空白は &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 num(items, start=1):
    """「1. 」形式の番号付きリスト。"""
    return [Paragraph("%d. %s" % (i, t), S_LIST)
            for i, t in enumerate(items, start)]


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


# ---------------------------------------------------------------- 表データ
SUMMARY_ROWS = [
    ["区分", "件数", "性質"],
    ["A. 実装が止まる", "2件", "取得元が特定できず、該当画面を実装できない"],
    ["B. 定義の確認", "2件", "取り違えると誤った値を表示する"],
    ["C. ご回答を受けた追加確認", "3件", "第1回のご回答の反映にあたり確認したい点"],
    ["D. 継続", "1件", "第1回でお願いしたもののうち、まだ動きがないもの"],
]

# Q1: D-Security の候補テーブル
Q1_CAND_ROWS = [
    ["テーブル", "主なカラム"],
    ["controls",
     "serial_number / facility_id / section_id / status(tinyint unsigned, 既定 0) / "
     "inactive / verified_at / deleted_at"],
    ["control_reports",
     "control_id / facility_id / section_id / serial_number / reported_at"],
    ["control_error_histories",
     "control_id / facility_id / section_id / status(tinyint unsigned) / comment / "
     "occurred_at / responded_at / resolved_at"],
    ["security_statuses",
     "facility_id / section_id / status(tinyint unsigned, 既定 1) / reported_at"],
    ["security_status_histories",
     "facility_id / section_id / status(tinyint unsigned) / reported_at"],
]

# Q1-1: D-Secログ画面
Q1_LOG_ROWS = [
    ["画面の項目", "想定している取得元", "確認したいこと"],
    ["デバイスのシリアル", "controls.serial_number", "これで合っていますか"],
    ["施設名", "controls.facility_id → facilities.facility_name", ""],
    ["<b>通信状態</b>(正常 / 通信断 / 異常)", "<b>不明</b>", "Q1-2 でお伺いします"],
    ["<b>最終通信日時</b>", "control_reports.reported_at",
     "この最新値でよいですか。control_reports は定期的な生存報告という理解で"
     "合っていますか。報告の間隔はどれくらいですか"],
    ["備考", "<b>不明</b>",
     "デバイスごとの備考を持つカラムはありますか(無ければ画面から落とします)"],
]

# Q1-3: D-Secアラート画面
Q1_ALERT_ROWS = [
    ["画面の項目", "想定している取得元", "確認したいこと"],
    ["検知日時", "control_error_histories.occurred_at", ""],
    ["施設名", "facility_id → facilities.facility_name", ""],
    ["清掃エリア名", "section_id → sections.section_name", ""],
    ["デバイスのシリアル", "control_id → controls.serial_number", ""],
    ["対応状況(未対応 / 対応中 / 完了)", "control_error_histories.status",
     "<b>コード値定義をご教示ください</b>"],
    ["メモ", "control_error_histories.comment", ""],
    ["対応日時 / 解決日時", "responded_at / resolved_at", ""],
    ["<b>アラート種別</b>(セキュリティ解除 / 通信断)", "<b>不明</b>", "下記でお伺いします"],
    ["<b>詳細</b>(アラートの本文)", "<b>不明</b>", "下記でお伺いします"],
]

# Q2: 事前チェック時点の状態を探した範囲
Q2_ROWS = [
    ["テーブル・カラム", "確認した状況"],
    ["robots.status<br/>(smallint, 既定 0)",
     "ロボットの<b>現在</b>の状態と思われます。<b>過去の値は残りません</b>"],
    ["section_robot_statuses.robot_status<br/>(smallint, 既定 0)",
     "同上。定義書の備考も「現時点では未確定」で、robots.status からの算出ルールが不明です"],
    ["clean_timetables.precheck",
     "0: 本清掃 / 1: 1回目 / 2: 2回目(第1回 B-1 でご回答いただいた内容)。"
     "事前チェックの<b>タイミングは分かりますが、そのときの状態がどこにもありません</b>"],
    ["robot_instruct_histories",
     "指示の履歴であり、状態のスナップショットではないと理解しています"],
]

# Q4: コード値定義が不明なカラム
Q4_ROWS = [
    ["テーブル.カラム", "型", "既定値"],
    ["robots.status", "smallint", "0"],
    ["section_robot_statuses.robot_status", "smallint", "0"],
    ["controls.status", "tinyint unsigned", "0"],
    ["control_error_histories.status", "tinyint unsigned", "0"],
    ["security_statuses.status", "tinyint unsigned", "1"],
    ["security_status_histories.status", "tinyint unsigned", "なし"],
    ["robot_error_reports.status", "tinyint unsigned", "0"],
]

# C-1: A-3(contact 系)のご回答と現在の DDL
C1_ROWS = [
    ["カラム", "ご回答", "現在の DDL"],
    ["customer_contacts.contact_type", "tinyint", "<b>bigint unsigned</b>"],
    ["facility_contacts.contact_type", "tinyint", "tinyint unsigned(一致)"],
    ["customer_contacts.email", "varchar(100)", "varchar(100)(一致)"],
    ["facility_contacts.email", "varchar(100)", "<b>varchar(255)</b>"],
]

# D: 継続
CONT_ROWS = [
    ["内容", "状態"],
    ["DB更新用アカウントの発行。<br/>robot_error_reports / control_error_histories の<br/>"
     "status / comment / responded_at / resolved_at のみ UPDATE 可",
     "<b>継続</b>"],
]

# 参考: 第1回のご回答で解決した事項
ANS_ROWS = [
    ["#", "ご回答", "こちらの対応"],
    ["A-1",
     "error_id を主キーと位置付けて対応する予定。id も error_id も英字を含むため"
     "数値型にはできない",
     "PrecheckItem.id を <b>number → string</b> に変更し、値は error_id にします。"
     "PATCH /prechecks/{id}/status の設計に着手できます(C-3 で最終確認)"],
    ["A-2",
     "採番はデータベース。auto_increment で採番する。元テーブルと履歴テーブルは<b>1対1</b>",
     "ビューアーはこの2テーブルを参照しないため、実装への影響はありません。"
     "DDL への AUTO_INCREMENT 追加を C-2 で確認しています"],
    ["A-3",
     "contact_type は両テーブルとも tinyint、email は両テーブルとも varchar(100)",
     "DDL への反映を C-1 で確認しています。反映後の DDL を db pull で取り込みます"],
    ["B-1", "制約(外部キー3本)は後から追加する",
     "<b>孤児行を想定した実装はやめます。</b>追加後の DDL に合わせます"],
    ["B-2", "インデックスも後から追加する。現状は20施設弱",
     "承知しました。<b>当面の性能懸念は小さい</b>と理解しました"],
    ["B-3",
     "プログラム側で明示的に対応しているが、指摘どおり DEFAULT を追加する"
     "(created_at: 新規作成時、updated_at: 新規作成時/更新時)",
     "追加後の DDL に合わせます"],
]

# 参考: 受領DDL の確認により解決していた事項
DONE_ROWS = [
    ["内容", "確認結果"],
    ["responded_at / resolved_at の<br/>カラム追加",
     "<b>両テーブルに既に存在します</b>(robot_error_reports / control_error_histories、"
     "いずれも datetime NULL可)。綴りも responded_at で正しくなっています"],
    ["D-Secアラートへの書き込み可否",
     "実体が control_error_histories であれば、<b>既に更新可4カラムに含まれています</b>。"
     "追加の権限は不要という理解です(Q1-3 で実体の確認をお願いしています)"],
]


def build():
    doc = BaseDocTemplate(
        OUT, pagesize=A4,
        leftMargin=18 * mm, rightMargin=18 * mm,
        topMargin=18 * mm, bottomMargin=20 * mm,
        title="D-Watch 確認事項(第2回)",
        author="DFA Robotics",
        subject="実DDL(clean_watch_20260902.sql)を正とした実装で取得元が特定できなかった項目",
    )
    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
    CW = W - 6 * mm          # コード枠・引用枠の幅(本文より少し内側)
    story = []

    # ---------------------------------------------------------- 表題と背景
    story.append(Paragraph("確認事項(第2回)", S_TITLE))
    story.append(Paragraph(
        "宛先: TeamWorks(DB開発側) 対象: clean_watch_20260902.sql "
        "作成日: 2026-09-03 作成者: DFA Robotics", S_SUB))
    story.append(Spacer(1, 9))

    story.append(Paragraph(
        "第1回「実DDL照合結果と確認事項」に続く確認事項です。"
        "<b>第1回の A-1〜A-3 / B-1〜B-3 についてはご回答をいただきました。"
        "ありがとうございました。</b>"
        "受け止めと対応は末尾の「参考: 第1回のご回答で解決した事項」に記載しています。", S_BODY))
    story.append(Paragraph(
        "今回の本題は、<b>DDLを正としてビューアーのデータ取得層を実装しようとした結果、"
        "取得元が特定できなかった項目</b>(Q1・Q2)です。", S_BODY))
    story.append(Paragraph(
        "<b>Q1 と Q2 は、解決しないと該当画面を実装できません。</b>"
        "いずれも「DDL に該当カラムが見当たらない」という種類の問い合わせです。"
        "<b>すでに存在するのに当方が見落としている可能性</b>もありますので、"
        "その場合はテーブル名・カラム名をご指摘いただければ十分です。", S_BODY))
    story.append(Spacer(1, 4))

    # ---------------------------------------------------------- サマリ表
    story.append(table(SUMMARY_ROWS, [44 * mm, 14 * mm, W - 58 * mm],
                       [S_CELL, S_CODE, S_CELL]))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "末尾の「参考」2節(第1回のご回答で解決した事項 / 受領DDLの確認により解決していた事項)は"
        "<b>回答不要</b>です。こちらの受け止めを記載しているだけですので、"
        "認識に相違があればご指摘ください。", S_NOTE))

    # ---------------------------------------------------------- Q1
    story.append(Paragraph("A. 実装が止まる(2件)", S_H2))

    story.append(Paragraph(
        "Q1 D-Security 画面のデータ取得元", S_H3))
    story.append(Paragraph(
        "ビューアーには D-Security 用の画面が2つあります。"
        "F-06 D-Secログ(D-S制御盤の一覧と通信状態)と、"
        "F-07 D-Secアラート(セキュリティ解除・通信断のアラート一覧)です。", S_BODY))
    story.append(Paragraph(
        "受領したDDL(39テーブル)を確認したところ、"
        "<b>これらに直接対応する名前のテーブルが見当たりませんでした</b>"
        "(ds_devices / ds_alerts のような名前のテーブルはありません)。"
        "候補として次の5テーブルがあると考えています。", S_LEAD))
    story.append(Spacer(1, 3))
    story.append(table(Q1_CAND_ROWS, [46 * mm, W - 46 * mm], [S_CODE, S_CODE]))
    story.append(Spacer(1, 6))

    story.append(Paragraph("Q1-1 D-Secログ画面(デバイス一覧)", S_H3))
    story.append(Paragraph(
        "画面に出す項目と当方の想定を並べます。<b>想定が合っているかご確認ください。</b>", S_LEAD))
    story.append(Spacer(1, 3))
    story.append(table(Q1_LOG_ROWS, [38 * mm, 56 * mm, W - 94 * mm],
                       [S_CELL, S_CODE, S_CELL]))
    story.append(Spacer(1, 6))

    story.append(CondPageBreak(40 * mm))
    story.append(Paragraph("Q1-2 controls.status のコード値定義をご教示ください", S_H3))
    story.append(Paragraph(
        "controls.status は tinyint unsigned / 既定値 0 ですが、"
        "<b>取りうる値と意味が分かりません。</b>"
        "0 / 1 / 2 … がそれぞれ何を表すかをご教示ください。", S_BODY))
    story.append(Paragraph(
        "あわせて、画面に出したい<b>3状態(正常 / 通信断 / 異常)</b>の"
        "判定方法を確認させてください。", S_LEAD))
    story.extend(num([
        "「異常」は controls.status から判定できますか",
        "「通信断」は control_reports.reported_at からの経過時間で判定する想定でよいですか。"
        "その場合、<b>何分(何時間)で通信断とみなすか</b>の運用上の基準はありますか",
    ]))
    story.append(Spacer(1, 3))
    story.append(Paragraph(
        "現在のサンプルデータでは「6時間以上通信なし」としていますが、"
        "根拠のない仮の値です。", S_NOTE))

    story.append(CondPageBreak(50 * mm))
    story.append(Paragraph("Q1-3 D-Secアラート画面", S_H3))
    story.append(Paragraph(
        "control_error_histories が実体だと考えています。"
        "<b>カラムの対応が下記で合っているかご確認ください。</b>", S_LEAD))
    story.append(Spacer(1, 3))
    story.append(table(Q1_ALERT_ROWS, [36 * mm, 62 * mm, W - 98 * mm],
                       [S_CELL, S_CODE, S_CELL]))
    story.append(Spacer(1, 6))
    story.append(Paragraph("伺いたいこと:", S_LEAD))
    story.extend(num([
        "<b>アラート種別</b>(「セキュリティ解除されたまま」なのか「通信断」なのか)を"
        "区別するカラムがありますか。control_error_histories には種別を表すカラムが"
        "見当たりません。status が種別を兼ねているのでしょうか、それとも別テーブルでしょうか",
        "<b>アラートの詳細文</b>(例:「セキュリティ復帰失敗」)はどこに入りますか。"
        "comment は運用担当が書き込む欄という理解なので、"
        "<b>検知側が書く詳細</b>とは別だと考えています",
    ]))

    story.append(CondPageBreak(40 * mm))
    story.append(Paragraph(
        "Q1-4 security_statuses / security_status_histories の位置づけ", S_H3))
    story.append(Paragraph(
        "security_statuses.status は tinyint unsigned / <b>既定値 1</b> です。", S_LEAD))
    story.extend(num([
        "<b>コード値定義をご教示ください</b>(0 / 1 / 2 … が何を表すか。"
        "既定値が 1 である理由も)",
        "このテーブルは<b>セキュリティの現在状態</b>を持つもので、"
        "「解除されたまま戻っていない」という状態は"
        "<b>ここから判定する</b>という理解で合っていますか",
        "security_status_histories は security_statuses の変更履歴という理解で合っていますか",
    ]))

    # ---------------------------------------------------------- Q2
    story.append(CondPageBreak(60 * mm))
    story.append(Paragraph("Q2 事前チェック(60分前 / 30分前)時点のロボット状態", S_H3))
    story.append(Paragraph(
        "ダッシュボードと清掃詳細ログに、<b>清掃開始の60分前と30分前のロボットの状態</b>を"
        "並べて表示する欄があります。表示する値は次の6種類です。", S_LEAD))
    story.append(Spacer(1, 3))
    story.append(code([
        "待機中 / 充電中 / ハンドルアップ / 緊急停止 / オフライン / セキュリティ有効",
    ], CW))
    story.append(Spacer(1, 5))
    story.append(Paragraph(
        "<b>この「60分前時点の状態」「30分前時点の状態」を、"
        "後から参照できるテーブルが見当たりません。</b>"
        "当方が確認した範囲では次のとおりです。", S_LEAD))
    story.append(Spacer(1, 3))
    story.append(table(Q2_ROWS, [56 * mm, W - 56 * mm], [S_CODE, S_CELL]))
    story.append(Spacer(1, 6))
    story.append(Paragraph("伺いたいこと:", S_LEAD))
    story.extend(num([
        "<b>事前チェック時点のロボット状態は、どのテーブルのどのカラムに記録されますか。</b>"
        "記録されない場合は、<b>現状では画面に出せない</b>ことになるため、その旨をご教示ください"
        "(その場合は当該欄を P2 以降に送るか、別の表現に変える判断をします)",
        "<b>robots.status(smallint)のコード値定義をご教示ください。</b>"
        "上記6種類との対応が分かる形でお願いします。なお robots.clean_status については"
        "第1回 A-2 で「0: 清掃前 / 1: 清掃中 / 2: 清掃終了(セキュリティ有効) / "
        "-1: 清掃完了(セキュリティ無効)」とご回答いただいていますが、"
        "<b>status のほうは未回答</b>です",
        "section_robot_statuses.robot_status を配下の robots.status から算出するルールは"
        "<b>決まりましたか</b>(定義書の備考は「現時点では未確定」のままです)",
    ]))

    # ---------------------------------------------------------- B
    story.append(CondPageBreak(50 * mm))
    story.append(Paragraph("B. 定義の確認(2件)", S_H2))

    story.append(Paragraph("Q3 clean_schedules.weeks の曜日の並び順", S_H3))
    story.append(Paragraph(
        "clean_schedules.weeks は varchar(7) で、"
        "曜日ごとの実施有無を7桁で持つ理解です。", S_BODY))
    story.append(Paragraph(
        "<b>左から順に「日・月・火・水・木・金・土」で合っていますか。</b>"
        "それとも「月・火・水・木・金・土・日」でしょうか。", S_BODY))
    story.append(Paragraph(
        "画面には「毎日」「月水金」のようなラベルで表示するため、"
        "<b>並び順を取り違えると別の曜日として表示されます</b>"
        "(例: 1000000 を日曜と読むか月曜と読むか)。"
        "あわせて、1 が実施・0 が非実施で合っていますか。", S_BODY))

    story.append(Paragraph("Q4 ステータス系カラムのコード値定義(まとめ)", S_H3))
    story.append(Paragraph(
        "Q1 / Q2 と重複しますが、<b>コード値の定義が不明なカラムを一覧にします。</b>"
        "第1回で clean_statuses.clean_status と clean_timetables.precheck は"
        "ご回答いただきました。残りは次の7つです。", S_LEAD))
    story.append(Spacer(1, 3))
    story.append(table(Q4_ROWS, [72 * mm, 34 * mm, W - 106 * mm],
                       [S_CODE, S_CODE, S_CODE]))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "robot_error_reports.status については、"
        "画面では「未対応 / 対応中 / 完了」の3状態で扱う想定です。"
        "control_error_histories.status と同じ体系でしょうか。", S_BODY))

    # ---------------------------------------------------------- C
    story.append(CondPageBreak(60 * mm))
    story.append(Paragraph("C. 第1回のご回答を受けた追加確認(3件)", S_H2))
    story.append(Paragraph(
        "第1回について<b>ご回答をいただきありがとうございました。すべて反映しています。</b>"
        "そのうえで、<b>確認させていただきたい点が3件</b>あります。", S_BODY))

    story.append(Paragraph("C-1 A-3(contact 系の型)は DDL 側を修正いただけますか", S_H3))
    story.append(Paragraph("いただいたご回答:", S_LEAD))
    story.append(Spacer(1, 2))
    story.append(quote(
        "contact_type は両テーブルとも tiny int. "
        "email は両テーブルとも varchar(100) で定義してください.", CW))
    story.append(Spacer(1, 5))
    story.append(Paragraph(
        "現在いただいている DDL(clean_watch_20260902.sql)は"
        "<b>まだ食い違ったまま</b>です。", S_LEAD))
    story.append(Spacer(1, 3))
    story.append(table(C1_ROWS, [62 * mm, 34 * mm, W - 96 * mm],
                       [S_CODE, S_CODE, S_CODE]))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "こちらは<b>実DDL を正としてスキーマを機械的に取り込む運用</b>にしているため、"
        "<b>DDL 側を直していただく前提</b>で認識が合っているかを確認させてください。", S_LEAD))
    story.extend(num([
        "<b>DDL 側を修正いただけるという理解で合っていますか</b>"
        "(こちらは修正後の DDL を prisma db pull で取り込みます)",
        "反映の目安時期をお知らせください",
    ]))

    story.append(CondPageBreak(45 * mm))
    story.append(Paragraph("C-2 B-1 / B-2 / B-3 の修正を含む DDL をいつ頂けますか", S_H3))
    story.append(Paragraph(
        "ご回答で、<b>外部キー3本(B-1)・インデックス1本(B-2)・"
        "timestamp の DEFAULT(B-3)</b>を後から追加いただけると伺いました。"
        "こちらは受領した DDL を prisma db pull で<b>機械的に取り込む運用</b>にしているため、"
        "修正が入るたびに DDL を頂き直す必要があります。", S_LEAD))
    story.extend(num([
        "上記の修正(<b>C-1 の contact 系も含めて</b>)がまとまった段階で、"
        "<b>DDL を再度いただけますか</b>",
        "目安の時期をお知らせください",
        "<b>A-2 の auto_increment も同じ修正に含まれる</b>という理解で合っていますか。"
        "現在の DDL では clean_status_histories.id / clean_timetable_histories.id に "
        "AUTO_INCREMENT が付いていません",
    ]))

    story.append(CondPageBreak(45 * mm))
    story.append(Paragraph("C-3 API が画面に返す ID は error_id で確定してよいですか", S_H3))
    story.append(Paragraph("いただいたご回答:", S_LEAD))
    story.append(Spacer(1, 2))
    story.append(quote(
        "とりあえず error_id を主キーと位置付け対応する予定です. "
        "ちなみに id も error_id もアルファベットを含んでいるので, "
        "数値型にすることはできません.", CW))
    story.append(Spacer(1, 5))
    story.append(Paragraph(
        "このご回答を受けて、こちらは <b>API の PrecheckItem.id を number から string へ変更し、"
        "値には error_id を入れる</b>方針で進めます。", S_LEAD))
    story.extend(num([
        "<b>この方針で問題ないですか</b>",
        "「運用を以て最終的な回答を求める」とのことでしたが、"
        "<b>後で id 側に一意制約が付く可能性はありますか。</b>"
        "付く可能性があるのであれば、切り替えられる形で実装しておきます",
    ]))

    # ---------------------------------------------------------- D
    story.append(CondPageBreak(40 * mm))
    story.append(Paragraph("D. 継続(1件)", S_H2))
    story.append(Paragraph(
        "第1回でお願いしたもののうち、<b>まだ動きがないもの</b>です。", S_NOTE))
    story.append(Spacer(1, 3))
    story.append(table(CONT_ROWS, [W - 30 * mm, 30 * mm], [S_CELL, S_CELL]))

    # ---------------------------------------------------------- 参考
    story.append(CondPageBreak(60 * mm))
    story.append(Paragraph("参考: 第1回のご回答で解決した事項", S_H2))
    story.append(Paragraph(
        "<b>回答不要です。</b>こちらの受け止めと対応を記載しますので、"
        "認識に相違があればご指摘ください。", S_NOTE))
    story.append(Spacer(1, 3))
    story.append(table(ANS_ROWS, [12 * mm, 68 * mm, W - 80 * mm],
                       [S_CODE, S_CELL, S_CELL], head_color=SUB))

    story.append(CondPageBreak(45 * mm))
    story.append(Paragraph("参考: 受領DDL の確認により解決していた事項", S_H2))
    story.append(Paragraph(
        "第1回でお願いしていた次の2件は、受領したDDLを確認したところ"
        "既に満たされていました。<b>対応不要です。</b>", S_NOTE))
    story.append(Spacer(1, 3))
    story.append(table(DONE_ROWS, [52 * mm, W - 52 * mm], [S_CELL, S_CELL]))

    # ---------------------------------------------------------- 当方判断
    story.append(Paragraph("参考: 当方で判断して進める項目(回答不要)", S_H2))
    story.append(Paragraph(
        "ご回答を待たずに当方で決めて進めるものです。"
        "認識に相違があればご指摘ください。", S_NOTE))
    story.append(Spacer(1, 2))
    story.extend(bull([
        "<b>clean_schedules に serial_number が無い</b>件 → sections 経由で robots を引きます。"
        "P1 は「1清掃エリア = 1ロボット」の前提のため一意に定まります",
        "<b>clean_schedules.end_time が 24〜29時を取る</b>件(第1回 B-5 で回答済み)"
        "→ 画面表示は「翌04:30」の形にします",
    ]))

    # ---------------------------------------------------------- 末尾
    story.append(Spacer(1, 10))
    story.append(Paragraph(
        "本書の Q1・Q2 についてご回答をいただければ、"
        "D-Security 画面と清掃詳細ログのデータ取得層に着手できます。"
        "Q3・Q4 は実装を止めるものではありませんが、"
        "表示が誤る箇所のため早めにご確認いただけると助かります。"
        "C は第1回のご回答の反映にあたっての確認で、"
        "とくに C-2(修正版 DDL の受領時期)の目安をいただけると助かります。", S_NOTE))

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


if __name__ == "__main__":
    build()