# -*- coding: utf-8 -*-
"""
実DDL照合結果と確認事項を PDF にする。
前回 TeamWorks へ送付した「テーブル定義書レビュー結果」
(make_schema_review_pdf.py) の体裁をそのまま踏襲した続編。
A4 縦 / reportlab / 日本語フォント埋め込み / ヘッダーとページ番号。
フォントは BIZ UDGothic(日本語フォント)を埋め込む。
中国語向けフォントは漢字の字形が異なるため使わない。
フォールバックにも混ぜないこと。
BIZ UDGothic は等幅(半角は 0.5em 固定)なので、SQL の枠にもこのフォントを使う。
コードブロック内の空白は に置換して桁を保持する。
原稿 (docs/handoff/TeamWorks実DDL照合結果_第1回_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, "D-Watch_実DDL照合結果_20260903.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") # A 区分の強調色(前回 PDF と同一)
WN = colors.HexColor("#b45309") # B 区分の強調色(前回 PDF と同一)
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):
"""全ページ共通のヘッダー罫線・フッター・ページ番号。前回の 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 = [
["区分", "件数", "性質"],
["A. 合意が必要", "3件", "返答が無いと実装を決められない"],
["B. 情報共有", "3件", "返答は必須でない。DB側でしか対処できないものを含む"],
["C. 仕様確認", "1件", "以前の合意事項の解釈"],
]
# A-3: ご回答と実DDL の食い違い
A3_ROWS = [
["カラム", "ご回答", "実DDL"],
["customer_contacts.contact_type", "unsigned tinyint", "<b>bigint unsigned</b>"],
["customer_contacts.email", "varchar(100)", "varchar(100)(一致)"],
["facility_contacts.contact_type", "unsigned tinyint", "tinyint unsigned(一致)"],
["facility_contacts.email", "varchar(100)", "<b>varchar(255)</b>"],
]
# B-1: 欠けている外部キー
B1_ROWS = [
["テーブル", "カラム", "定義書上の参照先"],
["clean_statuses", "clean_timetable_id", "clean_timetables(id)"],
["clean_status_histories", "clean_timetable_id", "clean_timetables(id)"],
["robot_task_versions", "clean_schedule_id", "clean_schedules(id)"],
]
# B-3: DEFAULT 句が無い timestamp
B3_ROWS = [
["テーブル", "カラム"],
["clean_task_reports", "created_at, updated_at"],
["security_status_histories", "reported_at"],
]
# 参考: 照合により解決した事項
R_ROWS = [
["保留していた事項", "DDL での確認結果"],
["対応日時のカラム名",
"<b>responded_at</b>(正しい綴り)。定義書の responsed_at は誤記と確定"],
["control_reports のシリアル番号",
"<b>serial_number</b>。定義書の sireal_number は誤記と確定"],
["clean_status の -1 問題",
"実DDL は符号あり tinyint。<b>定義書の型表記が誤りで、備考が正しかった</b>"],
["makers テーブル",
"DDL に存在せず、devices からの外部キーも無し。<b>ご回答どおり</b>"],
["controls.facility_id の外部キー", "<b>存在を確認</b>。ご回答どおり"],
["clean_timetables.precheck の既定値",
"<b>0</b>(本清掃)。定義書の 9 は誤りと確定"],
["clean_link_schedules",
"<b>不要テーブルとご確認済み</b>。link_schedules を参照します"],
["section_robot_statuses.indicatived_at",
"<b>綴り違いではなく、列自体が廃止</b>されていました"],
]
def build():
doc = BaseDocTemplate(
OUT, pagesize=A4,
leftMargin=18 * mm, rightMargin=18 * mm,
topMargin=18 * mm, bottomMargin=20 * mm,
title="D-Watch 実DDL照合結果と確認事項",
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("実DDL照合結果と確認事項", S_TITLE))
# 1 行に収まる長さで確定させている(9.5pt で本文幅の 97%)
story.append(Paragraph(
"宛先: TeamWorks(DB開発側) 対象: clean_watch_20260902.sql "
"作成日: 2026-09-03 作成者: DFA Robotics", S_SUB))
story.append(Spacer(1, 9))
story.append(Paragraph(
"2026-09-02 付でご提供いただいた clean_watch_20260902.sql(CREATE TABLE 39個)を、"
"こちらで手書きしていた Prisma スキーマと機械的に照合しました。", S_BODY))
story.append(Paragraph(
"照合は独立した2つの方法で行い、結果が一致することを確認しています。", S_LEAD))
story.extend(num([
"DDL からローカルに実データベースを構築し、prisma db pull で生成したスキーマと比較",
"DDL を直接パースしてカラム定義を抽出し、比較",
]))
story.append(Spacer(1, 3))
story.append(Paragraph(
"差分は49件でした。そのうち <b>42件はこちらで実DDLに合わせるだけで完結</b>します。"
"本書には、<b>こちらだけでは判断できない3件</b>と、<b>共有しておきたい3件</b>、"
"および<b>仕様の確認1件</b>を記載しています。", S_BODY))
story.append(Spacer(1, 4))
# ---------------------------------------------------------- サマリ表
story.append(table(SUMMARY_ROWS, [30 * mm, 14 * mm, W - 44 * mm],
[S_CELL, S_CODE, S_CELL]))
story.append(Spacer(1, 6))
story.append(Paragraph(
"こちらで完結する42件については本書に記載していません。", S_NOTE))
# ---------------------------------------------------------- A
story.append(Paragraph("A. 合意が必要(3件)", S_H2))
story.append(Paragraph(
"A-1 robot_error_reports の主キーと id の意味", S_H3))
story.append(Paragraph("<b>最も影響が大きい項目です。</b>", S_BODY))
story.append(Paragraph("実DDL の状態:", S_LEAD))
story.append(code([
"`id` varchar(50) NOT NULL, -- 主キーでも UNIQUE でもない。制約なし",
"`error_id` varchar(50) NOT NULL,",
"UNIQUE KEY `robot_error_reports_report_id_unique` (`error_id`)",
], CW))
story.append(Spacer(1, 5))
story.append(Paragraph(
"PRIMARY KEY が定義されておらず、一意制約は error_id のみです。", S_BODY))
story.append(Paragraph(
"一方こちらの API 契約と型定義は次のようになっています。", S_LEAD))
story.append(code([
"PrecheckItem.id : number",
"PATCH /prechecks/{id}/status",
], CW))
story.append(Spacer(1, 5))
story.append(Paragraph(
"具体的な帰結です。アラート一覧で担当者が「対応中」を押すと、画面は id を送ります。"
"しかし、", S_LEAD))
story.extend(bull([
"id は一意でないため、その値で1行を特定できません",
"id は文字列なので、number として扱うと不一致が起きます",
"更新できるのは error_id だけです",
]))
story.append(Spacer(1, 3))
story.append(Paragraph(
"<b>このため対応状況の更新(PATCH)が設計として成立しません。</b>", S_BODY))
story.append(Paragraph("伺いたいこと:", S_LEAD))
story.extend(num([
"id に一意制約を付ける予定はありますか。それとも error_id が正式なキーですか",
"こちらの API が画面に返すべき ID は id と error_id のどちらですか",
"id が文字列である前提でよろしいですか(こちらの型を number から string に変更します)",
]))
story.append(CondPageBreak(45 * mm))
story.append(Paragraph("A-2 履歴テーブルの id は誰が採番するのか", S_H3))
story.append(Paragraph("clean_status_histories の状態:", S_LEAD))
story.append(code([
"`id` bigint unsigned NOT NULL, -- AUTO_INCREMENT ではない",
"UNIQUE KEY `clean_status_histories_id_unique` (`id`)",
], CW))
story.append(Spacer(1, 5))
story.append(Paragraph(
"PRIMARY KEY が無く、AUTO_INCREMENT も付いていません。"
"一方で UNIQUE 制約はあります。", S_BODY))
story.append(Paragraph(
"これが元テーブル clean_statuses.id を引き継ぐ設計だとすると、"
"<b>1つの清掃状態につき履歴を1件しか作れません。</b>"
"履歴テーブルとしては不自然に見えるため、意図を確認したいです。", S_BODY))
story.append(Paragraph(
"clean_timetable_histories.id も同様に AUTO_INCREMENT がありません。", S_BODY))
story.append(Paragraph("伺いたいこと:", S_LEAD))
story.extend(num([
"これらの id は誰が採番しますか(アプリ側で連番を振る想定でしょうか)",
"元テーブルの id を引き継ぐ設計ですか。その場合、同じ清掃状態の履歴が"
"複数回作られることはない前提でよろしいですか",
]))
story.append(Spacer(1, 3))
story.append(Paragraph(
"こちらが検証用のダミーデータを作る際に、採番方法が決まらないと投入できません。",
S_BODY))
story.append(CondPageBreak(50 * mm))
story.append(Paragraph("A-3 先の回答(contact 系の型)はいつ反映されますか", S_H3))
story.append(Paragraph(
"先の確認事項 B-6 について、次のようにご回答いただきました。", S_LEAD))
story.append(quote(
"typo です。contact_type は unsigned tinyint とします。"
"email は varchar(100) とします。", CW))
story.append(Spacer(1, 5))
story.append(Paragraph(
"いただいた DDL(2026-09-02 付)には、まだ反映されていないようです。", S_LEAD))
# 長いカラム名が折り返さない幅を確保する(52mm)
story.append(table(A3_ROWS, [52 * mm, 32 * mm, W - 84 * mm],
[S_CODE, S_CODE, S_CODE]))
story.append(Spacer(1, 6))
story.append(Paragraph(
"顧客側と施設側で型が食い違った状態が残っています。", S_BODY))
story.append(Paragraph("伺いたいこと:", S_LEAD))
story.extend(num([
"当面は現行の DDL に合わせて実装してよろしいですか"
"(後で変更が入れば prisma db pull の差分に出るため追随できます)",
"変更を入れる時期の目安があればお知らせください",
]))
story.append(Spacer(1, 3))
story.append(Paragraph(
"なお customer_contacts.contact_type が bigint の場合、"
"こちらでは JavaScript の bigint 型として受け取ることになり、"
"JSON へ変換する際に明示的な数値変換が必要になります(実装側で対応します)。",
S_BODY))
# ---------------------------------------------------------- B
# B 区分の見出しだけがページ末に取り残されないよう、B-1 の表まで含めた高さで判定する
story.append(CondPageBreak(75 * mm))
story.append(Paragraph("B. 情報共有(3件)", S_H2))
story.append(Paragraph(
"返答は必須ではありません。こちらは現状のまま実装を進められます。", S_NOTE))
story.append(Paragraph("B-1 外部キーが3本欠けています", S_H3))
story.append(Paragraph(
"定義書では fk と記載されているものの、実DDL に制約が見当たりませんでした。",
S_LEAD))
story.append(table(B1_ROWS, [48 * mm, 42 * mm, W - 90 * mm],
[S_CODE, S_CODE, S_CODE], WN))
story.append(Spacer(1, 6))
story.append(Paragraph(
"意図的なものか、付け忘れかをご確認いただければ幸いです。"
"付け忘れであれば DB 側で追加いただくのが適切と考えます。"
"意図的であれば、こちらは参照先が存在しない行(孤児行)を想定した実装にします。",
S_BODY))
story.append(Paragraph(
"<b>返答をお待ちせず、孤児行を想定した実装で進めます。</b>", S_BODY))
story.append(CondPageBreak(35 * mm))
story.append(Paragraph(
"B-2 インデックスが1つ欠けています(DB側でしか対処できません)", S_H3))
story.append(Paragraph(
"robot_task_versions.clean_schedule_id にインデックスがありません。"
"外部キーも無いため、MySQL による自動作成も行われていません。", S_BODY))
story.append(Paragraph(
"この列で絞り込む検索が全件走査になります。"
"300施設分のタスク履歴が積み上がるテーブルのため、"
"運用が進むにつれて表示が遅くなることが見込まれます。", S_BODY))
story.append(Paragraph(
"<b>こちら側に回避手段がないため、DB 側でのご対応をお願いしたい項目です。</b>",
S_BODY))
story.append(CondPageBreak(35 * mm))
story.append(Paragraph("B-3 timestamp NOT NULL に DEFAULT 句がありません", S_H3))
story.append(table(B3_ROWS, [55 * mm, W - 55 * mm], [S_CODE, S_CODE]))
story.append(Spacer(1, 6))
story.append(Paragraph(
"いずれも timestamp NOT NULL ですが DEFAULT 句がなく、"
"INSERT 時に明示的な値の指定が必要です。", S_BODY))
story.append(Paragraph(
"アプリ側で時刻を管理する設計であれば意図的と理解しますが、"
"created_at に既定値が無いのは珍しい形のため念のため共有します。", S_BODY))
story.append(Paragraph(
"<b>こちらは参照のみのため実装への影響はありません。</b>", S_BODY))
# ---------------------------------------------------------- C
story.append(Paragraph("C. 仕様確認(1件)", S_H2))
story.append(CondPageBreak(45 * mm))
story.append(Paragraph(
"C-1 アドバイス規則 R3 の条件に「出発失敗」を含みますか", S_H3))
story.append(Paragraph(
"こちらの実装規約では、行展開アドバイスの規則を次のように定めています。", S_LEAD))
story.append(code([
"R3 = 完了率が△以下の状態が2日以上連続",
"R4 = 出発失敗 または △以下 の状態が3日以上連続",
], CW))
story.append(Spacer(1, 5))
story.append(Paragraph(
"R3 と R4 で条件を書き分けていますが、実装は両方とも"
"「連続問題日数」という1つの値を見ており、区別できません。", S_BODY))
story.append(Paragraph(
"さらに連続問題日数の定義が「△以下 <b>または</b> 出発失敗」であるため、"
"<b>現状では2日連続の出発失敗で R3 が発火します。</b>", S_BODY))
story.append(Paragraph("伺いたいこと:", S_LEAD))
story.extend(num([
"R3 の条件に出発失敗を含めるのが正しいですか",
"それとも R3 は完了率の低下のみを対象とし、出発失敗は R4 だけの条件ですか",
]))
story.append(Spacer(1, 3))
story.append(Paragraph(
"後者の場合、連続日数を「△以下のみ」と「出発失敗を含む」の2種類に分けて"
"数える必要があるため、実装方針が変わります。", S_BODY))
# ---------------------------------------------------------- 参考
story.append(CondPageBreak(60 * mm)) # 見出しだけがページ末に残らないようにする
story.append(Paragraph("参考: 照合により解決した事項", S_H2))
story.append(Paragraph(
"いただいた DDL により、こちらで保留していた判断が確定しました。"
"あわせてご報告します。", S_NOTE))
story.append(Spacer(1, 3))
# 左列は section_robot_statuses.indicatived_at が折り返さない幅にする
story.append(table(R_ROWS, [66 * mm, W - 66 * mm], [S_CODE, S_CELL]))
story.append(Spacer(1, 6))
story.append(Paragraph(
"また、こちらが定義書から手書きしたスキーマは、"
"28テーブル中12テーブルが実DDLと完全一致していました。"
"残りの差分も、大半は定義書側の記述と実DDL の食い違いに起因するものでした。",
S_BODY))
# ---------------------------------------------------------- 末尾
story.append(Spacer(1, 10))
story.append(Paragraph(
"本書の A(3件)についてご回答をいただければ、"
"対応状況の更新機能とダミーデータによる検証に着手できます。"
"B・C については実装を止めるものではありませんので、"
"お手隙の際にご確認ください。", S_NOTE))
doc.build(story)
print("生成:", OUT)
if __name__ == "__main__":
build()