# -*- 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 などを等幅フォントの枠に入れる。空白は にして桁を保つ。"""
esc = []
for ln in lines:
ln = ln.replace("&", "&").replace("<", "<").replace(">", ">")
esc.append(ln.replace(" ", " "))
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()