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

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

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

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

原稿は docs/handoff/TeamWorks確認事項_第5回_20260907.md。
文言・件数・表の内容は原稿から変更していない。

★第5回は「新規に第6回を作る」のではなく未送付の第4回の続きとして統合した版★
(送付が1回で済み、優先順も1つの表で伝えられるため。docs/decisions.md D-071 参照)。
"""
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_確認事項_第5回_20260907.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 = [
    ["区分", "件数", "性質"],
    ["1. 解決した件のご共有", "3件",
     "<b>回答不要</b>。いただいた回答をどう反映したかのご報告"],
    ["2. 最新のDDLのお願い", "1件",
     "<b>対応済みとのご回答が、受領済みDDLに入っていません</b>"],
    ["3. 週の帰属についての確認", "3件",
     "<b>X-1 は定義書とご回答が食い違っています。最優先</b>"],
    ["4. エラー情報についての確認", "3件",
     "<b>定義書の備考欄が空で、値が分かりません</b>"],
    ["5. 継続と保留", "2件",
     "DB更新用アカウント / B-1・B-2 は保留で了解しております"],
]

# 2. 最新のDDL
DDL_ROWS = [
    ["#", "内容", "受領済みDDLの状態"],
    ["A-1", "robot_error_reports の主キーを error_id にする", "<b>未反映</b>"],
    ["A-3", "contact_type / email の型", "<b>未反映</b>"],
    ["B-3b", "timestamp 系の DEFAULT", "<b>未反映</b>"],
]

# X-1 第6週が発生する月
SIX_ROWS = [
    ["年月", "1日", "日数", "第6週の範囲", "状態"],
    ["2025-03", "土", "31日", "3/30〜3/31(2日)", "過去"],
    ["2025-08", "金", "31日", "8/31 のみ(<b>1日</b>)", "過去"],
    ["2025-11", "土", "30日", "11/30 のみ(<b>1日</b>)", "過去"],
    ["<b>2026-05</b>", "金", "31日", "<b>5/31 のみ(1日)</b>", "<b>過去</b>"],
    ["<b>2026-08</b>", "土", "31日", "<b>8/30〜8/31(2日)</b>",
     "<b>先月。確定済み</b>"],
    ["2027-01", "金", "31日", "1/31 のみ(1日)", "今後"],
    ["2027-05", "土", "31日", "5/30〜5/31(2日)", "今後"],
    ["2027-10", "金", "31日", "10/31 のみ(1日)", "今後"],
]

# X-1 参考: 2026年8月の週の切れ目
AUG_ROWS = [
    ["週", "期間", "日数"],
    ["8月第1週", "08-01(土)のみ", "<b>1日</b>"],
    ["8月第2週", "08-02(日)〜08-08(土)", "7日"],
    ["8月第3週", "08-09(日)〜08-15(土)", "7日"],
    ["8月第4週", "08-16(日)〜08-22(土)", "7日"],
    ["8月第5週", "08-23(日)〜08-29(土)", "7日"],
    ["<b>8月第6週</b>", "<b>08-30(日)〜08-31(月)</b>", "<b>2日</b>"],
]

# X-1 A案 / B案
AB_ROWS = [
    ["案", "内容", "当方の対応"],
    ["A", "<b>定義書が古く、week に 6 が入る</b>",
     "当方はこの前提で実装します(下記)"],
    ["B", "<b>week は 1〜5 のままで、第6週は別の扱いになる</b>",
     "<b>扱いを教えてください</b>(第5週に含める / 行を作らない / 別の値 など)"],
]

# X-1 当方の実装
IMPL_ROWS = [
    ["項目", "当方の実装"],
    ["API の week パラメータ", "<b>1〜6</b>"],
    ["画面の期間ナビ",
     "「2026年8月 <b>第6週</b>」を表示し、8月第6週と9月第1週を行き来する"],
    ["サマリの読み込み", "<b>week = 6 の行が来る前提</b>で読みます"],
    ["week = 0", "<b>月の集計値</b>として扱い、週次の指定値としては受け付けません"],
]

# X-2 粒度
GRAIN_ROWS = [
    ["粒度", "year", "month", "week"],
    ["週次", "年", "月", "<b>1〜6</b>"],
    ["月次", "年", "月", "<b>0</b>"],
    ["年次", "年", "<b>0</b>", "<b>0</b>"],
]

# X-3 2つの単位
UNIT_ROWS = [
    ["", "単位"],
    ["<b>バッチの対象定義</b>",
     "「毎週日曜 6:00 に<b>前週(日曜〜土曜)</b>を集計」= <b>7日で1本</b>"],
    ["<b>サマリの格納単位</b>",
     "月境界で分割した週 = <b>月をまたぐと2本</b>"],
]

# X-3 具体例
BATCH_ROWS = [
    ["集計対象", "期間", "格納される行"],
    ["前週の8月分", "<b>08-30(日)〜08-31(月)</b>",
     "year=2026,<br/>month=8, week=6"],
    ["前週の9月分", "<b>09-01(火)〜09-05(土)</b>",
     "year=2026,<br/>month=9, week=1"],
]

# Y-1 想定される形
FORM_ROWS = [
    ["想定される形", "例"],
    ["PUDU のエラーコードの<b>数値を文字列で</b>", "\"10001\""],
    ["PUDU の<b>コード名(英字)</b>", "\"HandleUp\" / \"LOST_LOCATION\""],
    ["<b>日本語の名称</b>", "\"ハンドルアップ\""],
    ["<b>コードと名称の組み合わせ</b>", "\"10001:HandleUp\""],
]

# おわりに 優先順
PRIO_ROWS = [
    ["順", "項目", "理由"],
    ["1", "<b>X-1</b>(week の上限)",
     "<b>B の場合、当方の実装をやり直すことになります</b>。"
     "A であればそのまま進められます"],
    ["2", "<b>最新のDDL</b>", "受領後、当方でその日のうちに照合します"],
    ["3", "<b>Y-1</b>(error_type の値の形)",
     "清掃詳細ログの状態表示(ハンドルアップ / 緊急停止 / オフライン)が作れません"],
    ["4", "Y-2 / Y-3", "重要度表示と、レポートのエラー内訳に使います"],
    ["5", "X-2 / X-3", "確認のみ。<b>当方の作業は止まっていません</b>"],
    ["6", "DB更新用アカウント", "実DB接続の段階で必要になります"],
]


def build():
    doc = BaseDocTemplate(
        OUT, pagesize=A4,
        leftMargin=18 * mm, rightMargin=18 * mm,
        topMargin=18 * mm, bottomMargin=20 * mm,
        title="D-Watch 確認事項(第5回)",
        author="DFA Robotics",
        subject="週の帰属・エラー情報の確認と、最新DDLのお願い",
    )
    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("確認事項(第5回)", S_TITLE))
    story.append(Paragraph(
        "宛先: TeamWorks(DB開発側) 作成日: 2026-09-07 "
        "作成者: DFA Robotics", S_SUB))
    story.append(Spacer(1, 9))

    story.append(Paragraph(
        "第3回・第4回のご回答をありがとうございました。"
        "<b>すべて反映しています。</b>", S_BODY))
    story.append(Paragraph("本書は次の5部構成です。", S_LEAD))
    story.append(Spacer(1, 3))
    story.append(table(SUMMARY_ROWS, [50 * mm, 15 * mm, W - 65 * mm],
                       [S_CELL, S_CODE, S_CELL]))
    story.append(Spacer(1, 6))

    # ---------------------------------------------------------- 1. 共有
    story.append(Paragraph("1. 解決した件のご共有(回答不要です)", S_H2))

    story.append(Paragraph("1-1. W-1(月跨ぎ週)— いただいたルールをそのまま採用しました",
                           S_H3))
    story.append(Paragraph(
        "「週の回数と月は明確に区分けする」というご方針に合わせ、"
        "<b>当方の暦計算を「週を月境界で分割する」方式に統一します。</b>", S_BODY))
    story.extend(bull([
        "8/30〜8/31=<b>8月第6週</b> / 9/1〜9/5=<b>9月第1週</b> / "
        "9/6〜9/12=<b>9月第2週</b>",
        "2026/8/1 は1日だけでも <b>8月第1週</b>",
        "<b>当方が持っていた別ルール(1日でも翌月が混ざれば翌月の第1週)は"
        "破棄しました</b>",
    ]))
    story.append(Paragraph(
        "暦を2020〜2040年の全252か月で総当たりして検証し、"
        "いただいた例と一致することを確認しています。", S_NOTE))

    story.append(Paragraph(
        "1-2. D-1(link_schedules.intervals の書式)— 解決しました", S_H3))
    story.append(Paragraph(
        "<b>区切りはカンマ</b>で、24個・先頭が0時・最後尾が23時・"
        "値は秒(1〜3599)、という理解です。", S_BODY))
    story.append(quote(
        "テーブル定義書の備考欄に最初から明記されていました。"
        "こちらの確認漏れでした。2往復もお手数をおかけして申し訳ありません。"
        "以後、書式や値域は定義書を先に確認します。", CW))
    story.append(Spacer(1, 4))

    story.append(Paragraph("1-3. Q1-1(備考)— ビューアー側で持つことにしました", S_H3))
    story.append(Paragraph(
        "D-Secログの「備考」は、<b>DB の項目ではなく運用担当が書き留めるメモ</b>"
        "でしたので、ビューアーに入力欄を作り、DFA 側の独自ストアに保存する形で"
        "実装します。<b>メインDB には書き込みません。DB 側の対応は不要です。</b>",
        S_BODY))

    # ---------------------------------------------------------- 2. DDL
    story.append(CondPageBreak(60 * mm))
    story.append(Paragraph("2. 最新のDDLをいただけますでしょうか", S_H2))
    story.append(Paragraph(
        "第4回で「<b>A-1 / A-3 / B-3b は対応済み</b>」とのご回答をいただきましたが、"
        "<b>受領済みの clean_watch_20260907.sql(2026-09-07 10:29 受領)には"
        "入っていませんでした。</b>", S_BODY))
    story.append(Paragraph(
        "3つの方法(全文差分 / CREATE TABLE 単位の抽出 / 制約名の全文検索)で"
        "照合しています。", S_LEAD))
    story.append(Spacer(1, 3))
    story.append(table(DDL_ROWS, [14 * mm, 96 * mm, W - 110 * mm],
                       [S_CODE, S_CELL, S_CELL], head_color=NG))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "その後に対応いただいたものと思いますので、"
        "<b>最新のものをいただけますでしょうか。</b>"
        "受領しましたら、当方でその日のうちに照合してご報告します。", S_BODY))

    # ---------------------------------------------------------- 3. 週の帰属
    story.append(CondPageBreak(70 * mm))
    story.append(Paragraph("3. 週の帰属についての確認(3件)", S_H2))

    story.append(Paragraph(
        "X-1 サマリの week は 1〜5 と定義されていますが、"
        "第6週はどう格納されますか", S_H3))
    story.append(Paragraph(
        "テーブル定義書(customer_summaries / facility_summaries / "
        "customer_error_summaries / facility_error_summaries の4テーブル)の "
        "week カラムの備考には、こう書かれています。", S_LEAD))
    story.append(quote(
        "第1週 1, 第2週 2, 第3週 3, 第4週 4, 第5週 5 0は月の集計値を表す", CW))
    story.append(Spacer(1, 4))
    story.append(Paragraph(
        "一方、第4回でいただいた W-1 のご回答では、8/30〜8/31 が「8月第6週」です。"
        "つまり、<b>定義書の 1〜5 では第6週を表現できません。</b>", S_BODY))

    story.append(Paragraph("第6週はカレンダー上、実際に発生します", S_H3))
    story.append(Paragraph(
        "<b>発生条件</b>: <b>31日月で1日が金曜または土曜、または30日月で1日が土曜</b>"
        "(28日・29日月では発生しません)。"
        "2020〜2040 の252か月のうち<b>55か月=年平均2.6回</b>発生します。", S_BODY))
    story.append(Paragraph(
        "<b>そして 2026年5月と8月は、すでに過ぎた月です。</b>", S_LEAD))
    story.append(Spacer(1, 3))
    story.append(table(SIX_ROWS,
                       [22 * mm, 12 * mm, 14 * mm, 58 * mm, W - 106 * mm],
                       [S_CODE, S_CELL, S_CELL, S_CELL, S_CELL]))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "つまりこれは「将来こうなったら困る」という話ではなく、"
        "<b>すでに格納先が必要になっている</b>という話です。"
        "2026年9月6日(日)6:00 の週次バッチが集計した「前週」には、"
        "<b>2026年8月 第6週(08-30〜08-31)が含まれる</b>はずです。", S_BODY))

    story.append(Paragraph(
        "この範囲は1〜2日しかないため、欠けても気づきにくいです", S_H3))
    story.append(Paragraph(
        "上の表のとおり、第6週の実日数は<b>1日または2日</b>です。そのため、", S_LEAD))
    story.extend(bull([
        "もし「week は1〜5」という前提で実装されていると、"
        "<b>08-30〜08-31 の2日分がどこにも格納されないまま、"
        "総量としては目立たず数字が落ちます</b>",
        "逆に「第5週に含める」実装だと、"
        "<b>2026年8月第5週が 08-23〜08-31 の9日間</b>になり、"
        "当方の暦計算(日曜〜土曜で切り、月境界で分割)と<b>範囲がズレます</b>",
    ]))
    story.append(Paragraph(
        "<b>どちらも、当方の画面と貴社のサマリで数字が食い違う形になります。</b>",
        S_BODY))

    story.append(CondPageBreak(60 * mm))
    story.append(Paragraph("参考: 2026年8月の週の切れ目(全6週)", S_H3))
    story.append(Paragraph(
        "1日が土曜の31日月なので、<b>第1週と第6週の両方が短くなります</b>。", S_LEAD))
    story.append(Spacer(1, 3))
    story.append(table(AUG_ROWS, [26 * mm, 66 * mm, W - 92 * mm]))
    story.append(Spacer(1, 6))

    story.append(Paragraph("お伺いしたいこと", S_H3))
    story.append(Paragraph("<b>次のどちらでしょうか。</b>", S_LEAD))
    story.append(Spacer(1, 3))
    story.append(table(AB_ROWS, [12 * mm, 74 * mm, W - 86 * mm],
                       [S_CODE, S_CELL, S_CELL], head_color=WN))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "<b>当方の方針: week の上限を 6 として実装します。</b>(DFA として決定済み)",
        S_BODY))
    story.append(Paragraph(
        "第6週がカレンダー上で実際に発生する以上、"
        "<b>上限5では表現できない期間が生じる</b>ためです。", S_LEAD))
    story.append(Spacer(1, 3))
    story.append(table(IMPL_ROWS, [48 * mm, W - 48 * mm]))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "week カラムの型は unsigned tinyint なので <b>6 は物理的に格納できます</b>。"
        "ですので、<b>A であれば当方はこのまま進められます。</b>"
        "ただし定義書の備考が「1〜5」のままだと、貴社・当方とも"
        "後から読んだときに判断できません。"
        "<b>定義書の備考の更新をお願いできますでしょうか。</b>", S_BODY))
    story.append(Paragraph(
        "<b>B の場合は、当方の実装をやり直すことになります。</b>"
        "その場合はお早めにお知らせください。あわせて、"
        "<b>2026年5月・8月の第6週にあたる期間の数字がどこに入っているか</b>も"
        "お教えください。", S_BODY))

    story.append(CondPageBreak(55 * mm))
    story.append(Paragraph(
        "X-2 week = 0 は「月の集計値」、month = 0 は「年の集計値」という理解で"
        "よいですか", S_H3))
    story.append(Paragraph(
        "定義書の備考から、次のように読み取りました。", S_LEAD))
    story.append(Spacer(1, 3))
    story.append(table(GRAIN_ROWS,
                       [30 * mm, 24 * mm, 24 * mm, W - 78 * mm]))
    story.append(Spacer(1, 6))
    story.extend(bull([
        "週次・月次・年次を区別する専用のカラムは無く、"
        "<b>month / week が 0 かどうかで粒度を判別する</b>",
        "したがって、当方がサマリを読むときは"
        "<b>月次を取るなら week = 0 で絞り、"
        "年次を取るなら month = 0 AND week = 0 で絞る</b>",
    ]))
    story.append(Paragraph(
        "<b>当方はこの前提で実装します</b>(定義書の記載を正とします)。"
        "<b>違っている場合のみ</b>お知らせいただければ結構です。"
        "なお<b>年次の行(month = 0)は P1 では読みません。</b>"
        "レポート画面は週次・月次のみのためです。", S_BODY))

    story.append(CondPageBreak(60 * mm))
    story.append(Paragraph(
        "X-3 週次バッチの「前週」は、月をまたぐとき2行になりますか", S_H3))
    story.append(Paragraph(
        "<b>これは「矛盾していませんか」というお伺いではありません。</b>"
        "<b>2行になるのが、いただいたルールの当然の帰結</b>という理解です。"
        "お伺いしたいのは、<b>バッチの実装がそうなっているか</b>の確認です。", S_BODY))
    story.append(Paragraph(
        "気になっているのは、次の2つの単位が一致しないことです。", S_LEAD))
    story.append(Spacer(1, 3))
    story.append(table(UNIT_ROWS, [44 * mm, W - 44 * mm],
                       [S_CELL, S_CELL], head_color=SUB))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "つまり「前週」は7日ひと続きでも、<b>格納先は2行に分かれる</b>はずです。"
        "具体例(2026年9月6日〈日〉6:00 のバッチ):", S_LEAD))
    story.append(Spacer(1, 3))
    story.append(table(BATCH_ROWS, [26 * mm, 62 * mm, W - 88 * mm],
                       [S_CELL, S_CELL, S_CODE]))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "<b>この場合、1回のバッチで2行が作られるという理解でよいでしょうか。</b>",
        S_BODY))
    story.extend(bull([
        "<b>もし1行だけ(どちらか一方の月に寄せる)なら、"
        "寄せなかった側の期間の数字が欠けます。</b>"
        "例では 08-30〜08-31 の2日分、または 09-01〜09-05 の5日分が抜けます",
        "当方の画面は「その月の第N週」を指定してサマリを読むため、"
        "<b>行が無い週は「データなし」として表示されます</b>",
    ]))
    story.append(Paragraph(
        "なお、この分割によって週ごとの日数が1〜7日で変わります(上の例では2日と5日)。"
        "稼働率・完了率などの比率は分母も一緒に短くなるため計算は成立しますが、"
        "「前週比」を並べたときに2日の週と7日の週を比べることになります。"
        "第4回で「その分集計結果に差が出ますが、そこは運用後の課題になります」と"
        "いただいたとおりの認識でおりますので、<b>この点は回答不要です。</b>", S_NOTE))

    # ---------------------------------------------------------- 4. エラー情報
    story.append(CondPageBreak(70 * mm))
    story.append(Paragraph("4. エラー情報についての確認(3件)", S_H2))
    story.append(Paragraph(
        "第3回でいただいた D-2 のご回答(<b>待機中・充電中は robots.status、"
        "ハンドルアップ以降は robot_error_reports から取得。"
        "値は PUDU の「Common Open Interface Error Codes」</b>)を踏まえ、"
        "<b>当方で対応表を作る前提で準備しています。</b>", S_BODY))
    story.append(quote(
        "ところが、テーブル定義書の error_level / error_type / error_detail の"
        "備考欄がいずれも空欄で、格納される値が分かりませんでした。"
        "値の形が分からないと対応表のキーが決まらないため、以下をお伺いします。", CW))
    story.append(Spacer(1, 4))

    story.append(Paragraph(
        "Y-1 error_type(varchar(100))に入る値の「形」を教えてください", S_H3))
    story.append(Paragraph(
        "PUDU のドキュメントを参照するにあたり、"
        "どの形で格納されるかで対応表のキーが変わります。", S_LEAD))
    story.append(Spacer(1, 3))
    story.append(table(FORM_ROWS, [78 * mm, W - 78 * mm],
                       [S_CELL, S_CODE]))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "<b>実際に入っている値を1〜2件、そのままお見せいただけると確実です。</b>"
        "(varchar(100) という長さから、コード番号だけではない可能性が高いと"
        "見ていますが、推測は避けたいです)", S_BODY))
    story.append(Paragraph(
        "あわせて、<b>error_type が空文字や NULL になるケースがあるか</b>も"
        "お教えください。当方は「<b>未知の値は「その他」に寄せる</b>」方針で"
        "実装するため、<b>空の場合も「その他」に寄せてよいか</b>を"
        "確認させてください。", S_BODY))

    story.append(Paragraph(
        "Y-2 error_level(varchar(20))に入る値の一覧を教えてください", S_H3))
    story.append(Paragraph(
        "<b>定義書の備考欄が空</b>で、値が分かりません。"
        "varchar(20) なので文字列と思われますが、", S_LEAD))
    story.extend(bull([
        "\"warning\" / \"error\" のような英字の区分でしょうか",
        "\"軽度\" / \"重度\" のような日本語でしょうか",
        "PUDU 側の区分をそのまま入れているのでしょうか",
    ]))
    story.append(Paragraph(
        "<b>当方はこの値を画面の重要度表示や並び順に使いたいと考えています。</b>"
        "値の一覧と、<b>それぞれの意味(どちらが重いか)</b>を"
        "お教えいただけますでしょうか。", S_BODY))

    story.append(CondPageBreak(50 * mm))
    story.append(Paragraph(
        "Y-3 サマリ側の error_type は明細と同じ値域ですか", S_H3))
    story.append(Paragraph(
        "customer_error_summaries / facility_error_summaries にも "
        "error_level / error_type / error_detail / error_count の4カラムがあり、"
        "<b>1行 = 1エラー種別 × 期間の集計行</b>という設計と読み取りました。", S_BODY))
    story.append(Paragraph(
        "<b>この error_type は、robot_error_reports の error_type と"
        "同じ値が入るという理解でよいでしょうか。</b>", S_LEAD))
    story.extend(bull([
        "<b>同じ</b>であれば、当方が作る対応表を"
        "<b>明細とサマリの両方で使えます</b>",
        "<b>違う</b>(サマリ側では別の分類にまとめている等)の場合は、"
        "<b>サマリ側の値の一覧もお教えください</b>",
    ]))
    story.append(Paragraph(
        "<b>当方のレポート画面は「エラー種別の内訳」をサマリから読む設計です。</b>"
        "明細とサマリで分類が違うと、"
        "<b>同じ画面の中で明細とサマリの数字が突き合わせられなくなります。</b>",
        S_BODY))
    story.append(Paragraph(
        "また <b>error_detail(text)に何が入るか</b>もお教えください。"
        "画面に出す想定であれば、<b>長さの目安</b>"
        "(1行程度か、長文が入りうるか)を知りたいです。", S_BODY))

    # ---------------------------------------------------------- 5. 継続と保留
    story.append(CondPageBreak(55 * mm))
    story.append(Paragraph("5. 継続と保留", S_H2))

    story.append(Paragraph("継続: DB更新用アカウントの発行(第1回から)", S_H3))
    story.append(Paragraph(
        "robot_error_reports / control_error_histories の "
        "status / comment / responded_at / resolved_at <b>のみ UPDATE 可</b>、"
        "という権限をお願いしております。", S_BODY))
    story.append(Paragraph(
        "<b>恐れ入りますが、発行の見込みをお教えいただけますでしょうか。</b>"
        "当方はいま開発用のサンプルデータで実装しており"
        "<b>作業は止まっていません</b>が、"
        "<b>実DBへの接続に進む段階で必要になります。</b>", S_BODY))

    story.append(Paragraph("保留で了解しているもの: B-1 / B-2", S_H3))
    story.append(Paragraph(
        "外部キー3本とインデックスについて「後で登録する」とのご回答でしたので、"
        "<b>保留で了解しております。当方の開発は止まっていません。</b>"
        "登録された際に、DDL の再受領時にあわせて確認します。", S_BODY))

    # ---------------------------------------------------------- 末尾
    story.append(CondPageBreak(60 * mm))
    story.append(Paragraph("おわりに", S_H2))
    story.append(Paragraph("優先度をつけるとすれば次の順です。", S_LEAD))
    story.append(Spacer(1, 3))
    story.append(table(PRIO_ROWS, [10 * mm, 52 * mm, W - 62 * mm],
                       [S_CODE, S_CELL, S_CELL]))
    story.append(Spacer(1, 8))
    story.append(Paragraph("引き続きよろしくお願いします。", S_NOTE))

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


if __name__ == "__main__":
    build()