# -*- coding: utf-8 -*-
"""
TeamWorks 宛 確認事項(第4回)を PDF にする。
第1回〜第3回(make_ddl_review_pdf.py / make_teamworks_qa2_pdf.py /
make_teamworks_qa3_pdf.py)の体裁を
そのまま踏襲した続編。A4 縦 / reportlab / 日本語フォント埋め込み /
ヘッダー罫線とページ番号。
フォントは BIZ UDGothic(日本語フォント)を埋め込む。
中国語向けフォントは漢字の字形が異なるため使わない。
フォールバックにも混ぜないこと。
BIZ UDGothic は等幅(半角は 0.5em 固定)なので、SQL の枠にもこのフォントを使う。
コードブロック内の空白は に置換して桁を保持する。
原稿は docs/handoff/TeamWorks確認事項_第4回_20260907.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_確認事項_第4回_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 などを等幅フォントの枠に入れる。空白は にして桁を保つ。"""
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. 修正版DDLの照合結果", "7件中2件", "<b>5件がまだ入っていません。</b>状況をお知らせください"],
["2. 新しくお願いしたいこと", "2件", "<b>W-1 は集計の食い違いに直結します</b>"],
["3. 第3回からの継続", "2件", "まだ回答をいただいていないもの"],
]
# 1. 反映されていたもの
DONE_ROWS = [
["内容", "変化"],
["customer_contacts.contact_type", "bigint unsigned → <b>tinyint unsigned</b>"],
["clean_task_reports の created_at / updated_at",
"<b>DEFAULT CURRENT_TIMESTAMP</b> を付与(updated_at は ON UPDATE 付き)"],
]
# 1. まだ入っていないもの
PENDING_ROWS = [
["#", "お願いしていた内容", "現在のDDL"],
["A-1", "error_id を主キーと位置付けて対応",
"<b>PRIMARY KEY 無しのまま。</b>UNIQUE KEY は error_id のみ"],
["A-2", "履歴テーブルの id を<br/>auto_increment で採番",
"clean_status_histories.id / clean_timetable_histories.id とも <b>AUTO_INCREMENT 無し</b>。"
"ファイル全体の AUTO_INCREMENT 数が <b>50 で 09-02 版と同数</b>"],
["A-3b", "email を両テーブルとも<br/>varchar(100)",
"<b>facility_contacts.email が varchar(255) のまま</b>"
"(contact_type だけ直っています)"],
["B-1", "外部キー3本を追加",
"<b>3本とも無し。</b>CONSTRAINT 総数が <b>48 で 09-02 版と同数</b>。"
"制約名(clean_statuses_clean_timetable_id_foreign 等)を全文検索しても見つかりません"],
["B-2", "robot_task_versions.clean_schedule_id<br/>にインデックス",
"KEY は robot_id のみ"],
["B-3b", "security_status_histories.reported_at<br/>に DEFAULT",
"<b>timestamp NOT NULL のまま</b>"],
]
# W-1: 週の帰属の具体例
WEEK_ROWS = [
["週", "当方の扱い"],
["2026-08-23(日) 〜 08-29(土)", "8月の週"],
["<b>2026-08-30(日) 〜 09-05(土)</b>",
"★<b>9月 第1週</b>★(8/30・8/31 を含むが 9月に数える)"],
["2026-09-06(日) 〜 09-12(土)", "9月 第2週"],
]
# W-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>"],
]
# 3. 継続
CONT_ROWS = [
["#", "内容", "状態"],
["D-1", "link_schedules.intervals(VarChar(120))の<b>書式</b>。"
"実際に入っている値の例を1〜2件",
"★<b>これが分からないと D-Secログの通信断判定が実装できません</b>★ 最優先でお願いします"],
["D-4", "PrecheckItem.id の切替対象は「id と error_id のどちらを使うか」だけで、"
"<b>型は varchar のまま</b>という理解でよいか",
"継続。数値型に変わる可能性があるなら、API の破壊的変更になるため事前に知りたいです"],
]
def build():
doc = BaseDocTemplate(
OUT, pagesize=A4,
leftMargin=18 * mm, rightMargin=18 * mm,
topMargin=18 * mm, bottomMargin=20 * mm,
title="D-Watch 確認事項(第4回)",
author="DFA Robotics",
subject="修正版DDL(clean_watch_20260907.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("確認事項(第4回)", S_TITLE))
story.append(Paragraph(
"宛先: TeamWorks(DB開発側) 対象: clean_watch_20260907.sql "
"作成日: 2026-09-07 作成者: DFA Robotics", S_SUB))
story.append(Spacer(1, 9))
story.append(Paragraph(
"修正版のDDLをありがとうございました。<b>受領したその日に照合しています。</b>", S_BODY))
story.append(Paragraph("本書は次の3部構成です。", S_LEAD))
story.append(Spacer(1, 3))
story.append(table(SUMMARY_ROWS, [46 * mm, 18 * mm, W - 64 * mm],
[S_CELL, S_CODE, S_CELL]))
story.append(Spacer(1, 6))
story.append(quote(
"第3回でお伺いした D-3(セキュリティ解除の判定)は当方で解決しました。"
"「制御盤の生死報告」と「セキュリティ解除の状態」は別の概念で、生死報告が来ない場合は"
"サーバーから制御盤異常としてエラーが上がるが、セキュリティ解除はエラーにならず"
"別の値で管理される、という整理です。security_statuses.status = 0 の継続から"
"当方で導出します。回答は不要です。", CW))
# ---------------------------------------------------------- 1
story.append(Paragraph("1. 修正版DDLの照合結果", S_H2))
story.append(Paragraph(
"clean_watch_20260902.sql との差分は<b>14行</b>でした。"
"テーブル数は 39 で変化ありません。", S_BODY))
story.append(Paragraph("反映されていたもの(2件)— ありがとうございました", S_H3))
story.append(table(DONE_ROWS, [62 * mm, W - 62 * mm], [S_CODE, S_CELL]))
story.append(Spacer(1, 6))
story.append(CondPageBreak(70 * mm))
story.append(Paragraph("★まだ入っていないもの(5件)★", S_H3))
story.append(table(PENDING_ROWS, [14 * mm, 64 * mm, W - 78 * mm],
[S_CODE, S_CELL, S_CELL], head_color=SUB))
story.append(Spacer(1, 6))
story.append(Paragraph(
"照合は<b>3つの方法</b>(09-02版との全体差分 / 各 CREATE TABLE ブロックからの項目抽出 / "
"追加されるはずの制約名の全文検索)で行い、いずれも同じ結果でした。", S_BODY))
story.append(Paragraph("伺いたいこと:", S_LEAD))
story.extend(num([
"<b>これらは「これから対応」という理解でよいでしょうか。</b>それとも"
"<b>意図的に見送られた</b>ものがありますか"
"(あれば、その旨だけ教えていただければこちらは現行DDLに合わせて実装します)",
"対応いただける場合、<b>まとまった段階で改めてDDLをいただけますか。</b>目安の時期があれば",
]))
story.append(Spacer(1, 3))
story.append(quote(
"急かす意図はありません。上記5件はいずれも当方の開発を止めていません。"
"こちらは受領したDDLをそのまま prisma db pull で取り込む運用にしているため、"
"状況が分かれば十分です。", CW))
# ---------------------------------------------------------- 2
story.append(CondPageBreak(80 * mm))
story.append(Paragraph("2. 新しくお願いしたいこと(2件)", S_H2))
story.append(Paragraph("W-1 ★月跨ぎの週を、どちらの月に数えていますか★", S_H3))
story.append(Paragraph(
"<b>週次サマリを作成いただくのは TeamWorks 様側</b>という理解です"
"(毎週日曜 6:00 / 前週 日〜土)。一方、進行中の週(当期)は当方が詳細テーブルから"
"集計します。<b>両者で「週の帰属」が食い違うと、同じ週が別の月に集計されます。</b>", S_BODY))
story.append(Paragraph("<b>当方は次のルールで実装します。</b>", S_LEAD))
story.append(Spacer(1, 2))
story.append(quote(
"その月の1日を含む週が、その月の第1週。"
"言い換えると「1日でも翌月が混ざれば、その週は翌月の第1週」", CW))
story.append(Spacer(1, 5))
story.append(Paragraph("具体例:", S_LEAD))
story.append(Spacer(1, 3))
story.append(table(WEEK_ROWS, [62 * mm, W - 62 * mm], [S_CODE, S_CELL]))
story.append(Spacer(1, 6))
story.append(Paragraph("伺いたいこと:", S_LEAD))
story.extend(num([
"<b>サマリテーブル側も同じルールでしょうか。</b>"
"もし「週の開始日が属する月」で数えておられる場合、上の例は<b>8月の週</b>になり、"
"<b>当方の画面(9月第1週)と食い違います</b>",
"違っていた場合、<b>どちらに合わせるべきかご意見をいただけますか。</b>"
"当方はどちらでも実装できます",
]))
story.append(Spacer(1, 3))
story.append(Paragraph(
"なお月次・年次は暦月・暦年の日単位で締める(週とは独立)認識です。", S_NOTE))
story.append(CondPageBreak(80 * mm))
story.append(Paragraph(
"W-2 robot_error_reports.error_type に入る値の一覧をご作成いただけますか", S_H3))
story.append(Paragraph(
"第3回でもお伺いした件です。<b>実装は止めずに進めます</b>"
"(未知の値はすべて「その他」に寄せます)が、"
"<b>一覧をいただければ分類の精度が上がります。</b>", S_BODY))
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(
"<b>網羅でなくて構いません。</b>実際に流れている値を、お手元で確認できる範囲で"
"書き出していただけると助かります"
"(例: 直近1か月の SELECT DISTINCT error_type の結果など)。"
"<b>急ぎではありません。</b>届いた時点で分類ルールに追加します。", S_BODY))
# ---------------------------------------------------------- 3
story.append(CondPageBreak(55 * mm))
story.append(Paragraph("3. 第3回からの継続(2件)", S_H2))
story.append(Spacer(1, 3))
story.append(table(CONT_ROWS, [14 * mm, 68 * mm, W - 82 * mm],
[S_CODE, S_CELL, S_CELL], head_color=SUB))
story.append(Spacer(1, 6))
story.append(Paragraph(
"<b>継続(第1回から)</b>: DB更新用アカウントの発行"
"(robot_error_reports / control_error_histories の "
"status / comment / responded_at / resolved_at のみ UPDATE 可)。", S_BODY))
# ---------------------------------------------------------- 末尾
story.append(Paragraph("おわりに", S_H2))
story.append(Paragraph(
"<b>優先度をつけるとすれば D-1 → W-1 → 1の5件 → W-2 の順</b>です。", S_BODY))
story.extend(bull([
"<b>D-1</b> が無いと D-Secログの通信状態が出せません",
"<b>W-1</b> は集計の数字が食い違う話なので、実装が進む前に揃えておきたいです",
"<b>1の5件</b>と <b>W-2</b> は、当方の作業を止めていません",
]))
story.append(Spacer(1, 6))
story.append(Paragraph("引き続きよろしくお願いします。", S_NOTE))
doc.build(story)
print("生成:", OUT)
if __name__ == "__main__":
build()