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

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

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

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

原稿は docs/handoff/TeamWorks確認事項_第3回_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_確認事項_第3回_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 = [
    ["区分", "件数", "性質"],
    ["1. ご質問への回答", "2件", "第2回でいただいた逆質問へのお答え"],
    ["2. 当方で変更するもの", "4件", "<b>回答不要。</b>認識に相違があればご指摘ください"],
    ["3. 追加でお伺いしたいこと", "4件",
     "<b>D-1・D-2 が解決しないと画面の中身が埋まりません</b>"],
]

# 2. 当方で変更するもの
CHANGE_ROWS = [
    ["#", "変更", "根拠となったご回答"],
    ["2-1",
     "<b>D-Secログの「通信状態」を3状態(正常 / 通信断 / 異常)から"
     "2状態(正常 / 異常)に変更</b>",
     "controls.status が 0/1 の2値で、かつ「異常=生死報告が来ていない」とのことなので、"
     "<b>通信断と異常は同じもの</b>だと理解しました"],
    ["2-2", "<b>D-Secアラートの「種別」列を落とす</b>",
     "制御盤で発生するアラートは<b>「生死報告がない」の1種類だけ</b>"],
    ["2-3", "<b>アラートの「詳細」列も落とす</b>",
     "種別が1つしかないため詳細文が存在しない"],
    ["2-4",
     "<b>通信断の判定は control_reports.reported_at からの経過時間で行い、"
     "しきい値は link_schedules.intervals から取る</b>",
     "Q1-2 のご回答どおり。書式のみ D-1 で確認させてください"],
]

# D-2: 画面に出す6状態と robots.status の対応
STATE_ROWS = [
    ["画面に出す値", "robots.status の対応"],
    ["待機中", "0 Standby"],
    ["充電中", "201 Charging Line / 202 Charging Pile"],
    ["セキュリティ有効", "security_statuses.status = 1 から判定"],
    ["<b>ハンドルアップ</b>", "<b>該当なし</b>"],
    ["<b>緊急停止</b>", "<b>該当なし</b>"],
    ["<b>オフライン</b>", "<b>該当なし</b>"],
]


def build():
    doc = BaseDocTemplate(
        OUT, pagesize=A4,
        leftMargin=18 * mm, rightMargin=18 * mm,
        topMargin=18 * mm, bottomMargin=20 * mm,
        title="D-Watch 確認事項(第3回)",
        author="DFA Robotics",
        subject="第2回のご回答への返信と追加確認",
    )
    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("確認事項(第3回)", 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(
        "第2回「確認事項」への<b>ご回答をありがとうございました。</b>"
        "<b>Q1・Q2 で止まっていた D-Security 画面と清掃詳細ログの設計が進められます。</b>", S_BODY))
    story.append(Paragraph("本書は次の3部構成です。", S_LEAD))
    story.append(Spacer(1, 3))
    story.append(table(SUMMARY_ROWS, [44 * mm, 14 * mm, W - 58 * mm],
                       [S_CELL, S_CODE, S_CELL]))

    # ---------------------------------------------------------- 1
    story.append(Paragraph("1. ご質問への回答", S_H2))

    story.append(Paragraph(
        "1-1.「備考ってありましたでしょうか?(見落とし?)」について", S_H3))
    story.append(Paragraph(
        "<b>DB側の項目ではありません。こちら(ビューアー)側で持つべきものでした。</b>", S_BODY))
    story.append(Paragraph(
        "D-Secログ画面のモックに「備考」列があり、サンプル値として"
        "「セキュリティ復帰失敗」「6時間以上通信なし」が入っていました。"
        "Q1-3 でご回答いただいた<b>「アラートは生死報告がないの1種類だけ、詳細文は無い」</b>を"
        "踏まえると、これらは<b>検知側が出す情報ではなく、運用担当が手で書き留めるメモ</b>として"
        "想定されていたものだと分かりました。", S_BODY))
    story.append(Paragraph(
        "<b>→ ビューアー側に入力欄を作り、当方の独自ストアに保存します。"
        "DB側の対応は不要です。</b>"
        "点検記録や運用設定と同じ扱いで、<b>メインDBには書き込みません。</b>", S_BODY))
    story.append(Paragraph(
        "なお control_error_histories.comment については、"
        "<b>アラート1件ごとに運用担当が対応時に書き込む欄</b>(更新可4カラムの1つ)という理解で、"
        "<b>デバイスごとの備考とは別物</b>と考えています。この認識で合っていますか。", S_BODY))

    story.append(CondPageBreak(70 * mm))
    story.append(Paragraph(
        "1-2.「切り替えられる形にした場合のデメリットは?」について", S_H3))
    story.append(Paragraph(
        "<b>恒久的なコストはほぼありません。</b>"
        "ただし切り替える瞬間に2つ、避けられない影響があります。", S_BODY))

    story.append(Paragraph(
        "<b>(1) 切り替えた瞬間、それ以前に画面が持っていた ID が全部無効になります</b>", S_LEAD))
    story.append(Paragraph(
        "対応状況の更新はアラート一覧の行から行うため、"
        "<b>画面を開いたままの担当者が「対応中」を押すと、古い ID で更新をかけて 404 になります。</b>"
        "リロードすれば直るので実害は小さいですが、"
        "<b>運用が動いていない時間帯に切り替えていただく</b>必要があります。", S_BODY))

    story.append(Paragraph(
        "<b>(2) 切替をまたぐと、同じレコードが別の ID で記録され、ログの追跡が切れます</b>", S_LEAD))
    story.append(Paragraph(
        "「いつ・誰が・どのアラートを対応済みにしたか」を後から追うとき、"
        "切替前後で ID が変わるため同一性が分からなくなります。"
        "<b>切り替えた日時を当方の記録に残しておけば読み替えられる</b>ので、"
        "これも致命的ではありません。", S_BODY))

    story.append(Paragraph(
        "<b>(3) コードに分岐が残る問題は、作り方で消せます</b>", S_LEAD))
    story.append(Paragraph(
        "<b>ID をどちらにするかの決定を1つの関数に閉じ込め、"
        "それ以外のコードは ID の中身を一切知らない形にします。</b>"
        "こうすれば分岐は1箇所だけで済み、テストもその関数に2パターン書けば足ります。", S_BODY))
    story.append(Paragraph(
        "逆にこれをやらずに ID を扱う全箇所へ分岐を散らすと、"
        "<b>切替が現実的に不可能になります。</b>"
        "この形で実装しますので、恒久的な負担は生じません。", S_BODY))
    # この段落はページ境界で割れると「(bigint auto_increment)」が分断されて読みにくい
    story.append(CondPageBreak(32 * mm))
    story.append(Paragraph(
        "<b>1点だけ確認させてください(D-4)。</b>"
        "「切り替える可能性がある」のは <b>id と error_id のどちらを使うか</b>であって、"
        "<b>型は両方とも varchar のまま</b>という理解でよいでしょうか。"
        "もし将来 id を数値型(bigint auto_increment)に変える可能性があるなら、"
        "API の型が文字列から数値に変わる<b>破壊的変更</b>になり、"
        "切替の難度が大きく上がります。", S_BODY))

    # ---------------------------------------------------------- 2
    story.append(CondPageBreak(60 * mm))
    story.append(Paragraph("2. 当方で変更するもの(回答不要)", S_H2))
    story.append(Paragraph(
        "ご回答を受けて、こちらの画面仕様を次のように変更します。"
        "<b>認識に相違があればご指摘ください。</b>", S_NOTE))
    story.append(Spacer(1, 3))
    story.append(table(CHANGE_ROWS, [12 * mm, 72 * mm, W - 84 * mm],
                       [S_CODE, S_CELL, S_CELL], head_color=SUB))

    # ---------------------------------------------------------- 3
    story.append(CondPageBreak(60 * mm))
    story.append(Paragraph("3. 追加でお伺いしたいこと(4件)", S_H2))

    story.append(Paragraph("D-1 ★link_schedules.intervals の書式★", S_H3))
    story.append(Paragraph(
        "VarChar(120) で「時間帯別の設定(単位: 秒)」とのことですが、"
        "<b>書式が分かりません。</b>", S_BODY))
    story.append(Paragraph(
        "<b>実際に入っている値の例を1〜2件いただけますか。</b>", S_LEAD))
    story.append(Spacer(1, 2))
    story.append(code([
        "0-6:600,6-22:120,22-24:600   ← このような形でしょうか",
        "(JSON なのか、区切り文字は何か、時間帯の表し方をご教示ください)",
    ], CW))
    story.append(Spacer(1, 5))
    story.append(Paragraph(
        "<b>これが分からないと通信断の判定が実装できません。</b>", S_BODY))

    story.append(CondPageBreak(70 * mm))
    story.append(Paragraph(
        "D-2 ★robot_error_reports.error_type に実際に入る値★", S_H3))
    story.append(Paragraph(
        "清掃詳細ログには、清掃開始の60分前・30分前のロボット状態を出す欄があります。"
        "表示する値は6種類ですが、いただいた robots.status のコード表と突き合わせると"
        "<b>3つが対応先不明</b>です。", S_LEAD))
    story.append(Spacer(1, 3))
    story.append(table(STATE_ROWS, [46 * mm, W - 46 * mm], [S_CELL, S_CODE]))
    story.append(Spacer(1, 6))
    story.append(Paragraph(
        "この3つは robot_error_reports.error_type から判定することになると考えています。", S_BODY))
    story.append(Paragraph(
        "先の回答で「error_type は PUDU Cloud 由来で列挙が未定義。"
        "プログラム側で分類する必要がある」と伺っていますが、"
        "<b>実際に入っている値をご存じの範囲で列挙いただけますか</b>(網羅でなくて構いません)。"
        "<b>分類ルールはこちらで作り、未知の値は「その他」に寄せます。</b>", S_BODY))

    story.append(CondPageBreak(45 * mm))
    story.append(Paragraph("D-3 「セキュリティ解除されたまま」の判定", S_H3))
    story.append(Paragraph(
        "Q1-3 で「制御盤のアラートは生死報告がないだけ」と伺いました。"
        "一方で画面には<b>「セキュリティが解除されたまま戻っていない」</b>という"
        "アラートを出したいと考えています。", S_BODY))
    story.append(Paragraph(
        "<b>これは security_statuses.status = 0(セキュリティ無効)が続いている状態から、"
        "当方で導出してよいでしょうか。</b>"
        "制御盤側でこれを検知してどこかに記録している、ということはありませんか。", S_BODY))

    story.append(Paragraph("D-4 PrecheckItem.id の型(1-2 の再掲)", S_H3))
    story.append(Paragraph(
        "切替の対象は「id と error_id のどちらを使うか」だけで、"
        "<b>型は varchar のまま</b>という理解でよいですか。", S_BODY))

    # ---------------------------------------------------------- 末尾
    story.append(Spacer(1, 10))
    story.append(Paragraph(
        "<b>D-1・D-2 についてご回答をいただければ、D-Secログの通信断判定と"
        "清掃詳細ログの状態表示が完成します。</b>"
        "それまでの間も画面とAPIの骨格は作れますので、作業は止まりません。", S_NOTE))
    story.append(Paragraph(
        "<b>C(修正版DDL)は本日中とのこと、ありがとうございます。</b>"
        "受領しましたら prisma db pull で機械的に取り込み、差分をご報告します。", S_NOTE))

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


if __name__ == "__main__":
    build()