You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

1334 lines
59 KiB

from __future__ import annotations
from pathlib import Path
from typing import Iterable, Sequence
from docx import Document
from docx.enum.section import WD_SECTION
from docx.enum.table import (
WD_CELL_VERTICAL_ALIGNMENT,
WD_ROW_HEIGHT_RULE,
WD_TABLE_ALIGNMENT,
)
from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_BREAK, WD_LINE_SPACING
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.shared import Cm, Inches, Pt, RGBColor
ROOT = Path(__file__).resolve().parents[1]
OUT_DIR = ROOT / "Housing_UserManual"
OUT_FILE = OUT_DIR / "Housing_User_Manual_CodeAligned_v1.0.docx"
LOGO = ROOT / "Resources" / "mobidigm-logo.png"
SCREEN_DIR = ROOT / "tools" / "manual-screens"
LOGIN_IMAGE = SCREEN_DIR / "01-login.png"
WAITING_IMAGE = SCREEN_DIR / "02-waiting-barcode.png"
START_IMAGE = SCREEN_DIR / "03-wait-start-signal.png"
BOARD_IMAGE = SCREEN_DIR / "04-board-test.png"
OK_IMAGE = SCREEN_DIR / "05-result-ok.png"
NG_IMAGE = SCREEN_DIR / "06-result-ng.png"
DB_ERROR_IMAGE = SCREEN_DIR / "07-db-save-error.png"
NAVY = "102738"
DARK_NAVY = "07111C"
CYAN = "16C7E8"
BLUE = "2F86FF"
GREEN = "27D17F"
RED = "E65252"
ORANGE = "F39C3D"
LIGHT_BLUE = "EAF7FA"
LIGHT_GRAY = "F2F5F7"
MID_GRAY = "D7E0E5"
TEXT = "17212B"
MUTED = "586A75"
WHITE = "FFFFFF"
def set_cell_shading(cell, fill: str) -> None:
tc_pr = cell._tc.get_or_add_tcPr()
shd = tc_pr.find(qn("w:shd"))
if shd is None:
shd = OxmlElement("w:shd")
tc_pr.append(shd)
shd.set(qn("w:fill"), fill)
def set_cell_margins(cell, top=90, start=100, bottom=90, end=100) -> None:
tc = cell._tc
tc_pr = tc.get_or_add_tcPr()
tc_mar = tc_pr.first_child_found_in("w:tcMar")
if tc_mar is None:
tc_mar = OxmlElement("w:tcMar")
tc_pr.append(tc_mar)
for margin, value in (("top", top), ("start", start), ("bottom", bottom), ("end", end)):
node = tc_mar.find(qn(f"w:{margin}"))
if node is None:
node = OxmlElement(f"w:{margin}")
tc_mar.append(node)
node.set(qn("w:w"), str(value))
node.set(qn("w:type"), "dxa")
def set_repeat_table_header(row) -> None:
tr_pr = row._tr.get_or_add_trPr()
tbl_header = OxmlElement("w:tblHeader")
tbl_header.set(qn("w:val"), "true")
tr_pr.append(tbl_header)
def set_cell_text_color(cell, color: str) -> None:
for paragraph in cell.paragraphs:
for run in paragraph.runs:
run.font.color.rgb = RGBColor.from_string(color)
def set_cell_width(cell, width_cm: float) -> None:
tc_pr = cell._tc.get_or_add_tcPr()
tc_w = tc_pr.find(qn("w:tcW"))
if tc_w is None:
tc_w = OxmlElement("w:tcW")
tc_pr.append(tc_w)
tc_w.set(qn("w:w"), str(Cm(width_cm).twips))
tc_w.set(qn("w:type"), "dxa")
def set_fixed_table_widths(table, widths: Sequence[float]) -> None:
tbl_pr = table._tbl.tblPr
layout = tbl_pr.first_child_found_in("w:tblLayout")
if layout is None:
layout = OxmlElement("w:tblLayout")
tbl_pr.append(layout)
layout.set(qn("w:type"), "fixed")
for index, width_cm in enumerate(widths):
table.columns[index].width = Cm(width_cm)
for cell in table.columns[index].cells:
cell.width = Cm(width_cm)
set_cell_width(cell, width_cm)
def set_run_font(run, name: str = "Malgun Gothic", size: float | None = None, bold: bool | None = None) -> None:
run.font.name = name
run._element.rPr.rFonts.set(qn("w:eastAsia"), name)
if size is not None:
run.font.size = Pt(size)
if bold is not None:
run.bold = bold
def add_page_number(paragraph) -> None:
paragraph.alignment = WD_ALIGN_PARAGRAPH.RIGHT
run = paragraph.add_run("Page ")
set_run_font(run, size=8)
fld_char1 = OxmlElement("w:fldChar")
fld_char1.set(qn("w:fldCharType"), "begin")
instr_text = OxmlElement("w:instrText")
instr_text.set(qn("xml:space"), "preserve")
instr_text.text = " PAGE "
fld_char2 = OxmlElement("w:fldChar")
fld_char2.set(qn("w:fldCharType"), "end")
run._r.append(fld_char1)
run._r.append(instr_text)
run._r.append(fld_char2)
def set_keep_with_next(paragraph) -> None:
p_pr = paragraph._p.get_or_add_pPr()
keep_next = OxmlElement("w:keepNext")
p_pr.append(keep_next)
def set_table_borders(table, color=MID_GRAY, size="6") -> None:
tbl = table._tbl
tbl_pr = tbl.tblPr
borders = tbl_pr.first_child_found_in("w:tblBorders")
if borders is None:
borders = OxmlElement("w:tblBorders")
tbl_pr.append(borders)
for edge in ("top", "left", "bottom", "right", "insideH", "insideV"):
tag = f"w:{edge}"
element = borders.find(qn(tag))
if element is None:
element = OxmlElement(tag)
borders.append(element)
element.set(qn("w:val"), "single")
element.set(qn("w:sz"), size)
element.set(qn("w:space"), "0")
element.set(qn("w:color"), color)
def set_paragraph_spacing(paragraph, before=0, after=5, line=1.25) -> None:
fmt = paragraph.paragraph_format
fmt.space_before = Pt(before)
fmt.space_after = Pt(after)
fmt.line_spacing_rule = WD_LINE_SPACING.MULTIPLE
fmt.line_spacing = line
def add_table(
doc: Document,
headers: Sequence[str],
rows: Iterable[Sequence[str]],
widths: Sequence[float] | None = None,
header_fill: str = NAVY,
) -> object:
rows = list(rows)
table = doc.add_table(rows=1, cols=len(headers))
table.alignment = WD_TABLE_ALIGNMENT.CENTER
table.autofit = False
set_table_borders(table)
if widths:
set_fixed_table_widths(table, widths)
header = table.rows[0]
set_repeat_table_header(header)
for index, text in enumerate(headers):
cell = header.cells[index]
cell.text = text
set_cell_shading(cell, header_fill)
cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER
set_cell_margins(cell)
if widths:
cell.width = Cm(widths[index])
paragraph = cell.paragraphs[0]
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
for run in paragraph.runs:
set_run_font(run, size=8.5, bold=True)
run.font.color.rgb = RGBColor(255, 255, 255)
for row_index, values in enumerate(rows):
cells = table.add_row().cells
for index, value in enumerate(values):
cell = cells[index]
cell.text = str(value)
cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER
set_cell_margins(cell)
if widths:
cell.width = Cm(widths[index])
if row_index % 2 == 1:
set_cell_shading(cell, "F7F9FA")
for paragraph in cell.paragraphs:
set_paragraph_spacing(paragraph, after=1, line=1.12)
for run in paragraph.runs:
set_run_font(run, size=8.4)
run.font.color.rgb = RGBColor.from_string(TEXT)
doc.add_paragraph()
return table
def add_bullet(doc: Document, text: str, level: int = 0) -> None:
paragraph = doc.add_paragraph(style="List Bullet" if level == 0 else "List Bullet 2")
paragraph.paragraph_format.left_indent = Cm(0.55 + 0.45 * level)
paragraph.paragraph_format.first_line_indent = Cm(-0.25)
set_paragraph_spacing(paragraph, after=3, line=1.2)
run = paragraph.add_run(text)
set_run_font(run, size=9.5)
def add_number(doc: Document, text: str) -> None:
paragraph = doc.add_paragraph(style="List Number")
paragraph.paragraph_format.left_indent = Cm(0.7)
paragraph.paragraph_format.first_line_indent = Cm(-0.35)
set_paragraph_spacing(paragraph, after=3, line=1.2)
run = paragraph.add_run(text)
set_run_font(run, size=9.5)
def add_body(doc: Document, text: str, bold_prefix: str | None = None) -> None:
paragraph = doc.add_paragraph()
set_paragraph_spacing(paragraph, after=6, line=1.3)
if bold_prefix and text.startswith(bold_prefix):
first = paragraph.add_run(bold_prefix)
set_run_font(first, size=9.5, bold=True)
rest = paragraph.add_run(text[len(bold_prefix):])
set_run_font(rest, size=9.5)
else:
run = paragraph.add_run(text)
set_run_font(run, size=9.5)
def add_note(doc: Document, title: str, text: str, kind: str = "info") -> None:
palette = {
"info": (LIGHT_BLUE, CYAN),
"warning": ("FFF3E5", ORANGE),
"danger": ("FDEBEC", RED),
"success": ("E9F8F1", GREEN),
}
fill, accent = palette[kind]
table = doc.add_table(rows=1, cols=2)
table.alignment = WD_TABLE_ALIGNMENT.CENTER
table.autofit = False
set_table_borders(table, color=fill, size="0")
set_fixed_table_widths(table, [1.4, 14.6])
table.rows[0].height = Cm(1.4)
table.rows[0].height_rule = WD_ROW_HEIGHT_RULE.AT_LEAST
accent_cell, text_cell = table.rows[0].cells
set_cell_shading(accent_cell, accent)
set_cell_shading(text_cell, fill)
set_cell_margins(accent_cell, top=100, bottom=100, start=0, end=0)
set_cell_margins(text_cell, top=110, bottom=110, start=150, end=150)
p = text_cell.paragraphs[0]
set_paragraph_spacing(p, after=1, line=1.18)
r1 = p.add_run(f"{title} ")
set_run_font(r1, size=8.8, bold=True)
r1.font.color.rgb = RGBColor.from_string(accent)
r2 = p.add_run(text)
set_run_font(r2, size=8.8)
r2.font.color.rgb = RGBColor.from_string(TEXT)
doc.add_paragraph().paragraph_format.space_after = Pt(1)
def add_code_block(doc: Document, text: str) -> None:
table = doc.add_table(rows=1, cols=1)
table.alignment = WD_TABLE_ALIGNMENT.CENTER
set_table_borders(table, color="CAD4DA")
cell = table.cell(0, 0)
set_cell_shading(cell, "F5F7F8")
set_cell_margins(cell, top=120, bottom=120, start=160, end=160)
p = cell.paragraphs[0]
p.paragraph_format.space_after = Pt(0)
for line_index, line in enumerate(text.splitlines()):
if line_index:
p.add_run().add_break()
run = p.add_run(line)
set_run_font(run, name="Consolas", size=8)
run.font.color.rgb = RGBColor.from_string("25323B")
doc.add_paragraph()
def add_image(doc: Document, path: Path, caption: str) -> None:
if not path.exists():
add_note(doc, "이미지 누락", f"{path.name} 파일을 찾을 수 없어 본문에 삽입하지 못했습니다.", "warning")
return
paragraph = doc.add_paragraph()
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
paragraph.paragraph_format.space_after = Pt(3)
run = paragraph.add_run()
run.add_picture(str(path), width=Cm(16.6))
caption_p = doc.add_paragraph()
caption_p.alignment = WD_ALIGN_PARAGRAPH.CENTER
caption_p.paragraph_format.space_after = Pt(8)
caption_run = caption_p.add_run(caption)
set_run_font(caption_run, size=8)
caption_run.italic = True
caption_run.font.color.rgb = RGBColor.from_string(MUTED)
def add_flow(doc: Document, steps: Sequence[str]) -> None:
table = doc.add_table(rows=1, cols=len(steps))
table.alignment = WD_TABLE_ALIGNMENT.CENTER
table.autofit = False
set_table_borders(table, color="7BC9D7", size="8")
for index, step in enumerate(steps):
cell = table.cell(0, index)
set_cell_shading(cell, LIGHT_BLUE if index % 2 == 0 else "F5FBFC")
set_cell_margins(cell, top=160, bottom=160, start=60, end=60)
p = cell.paragraphs[0]
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
r = p.add_run(step)
set_run_font(r, size=7.8, bold=True)
r.font.color.rgb = RGBColor.from_string(NAVY)
doc.add_paragraph()
def add_chapter(doc: Document, number: int, title: str) -> None:
if number > 1:
doc.add_page_break()
p = doc.add_paragraph(style="Heading 1")
p.paragraph_format.space_before = Pt(0)
p.paragraph_format.space_after = Pt(12)
set_keep_with_next(p)
r = p.add_run(f"{number}. {title}")
set_run_font(r, size=18, bold=True)
r.font.color.rgb = RGBColor.from_string(NAVY)
border = OxmlElement("w:pBdr")
bottom = OxmlElement("w:bottom")
bottom.set(qn("w:val"), "single")
bottom.set(qn("w:sz"), "18")
bottom.set(qn("w:space"), "5")
bottom.set(qn("w:color"), CYAN)
border.append(bottom)
p._p.get_or_add_pPr().append(border)
def add_section(doc: Document, number: str, title: str) -> None:
p = doc.add_paragraph(style="Heading 2")
p.paragraph_format.space_before = Pt(10)
p.paragraph_format.space_after = Pt(6)
set_keep_with_next(p)
r = p.add_run(f"{number} {title}")
set_run_font(r, size=12, bold=True)
r.font.color.rgb = RGBColor.from_string(BLUE)
def configure_document(doc: Document) -> None:
section = doc.sections[0]
section.page_width = Inches(8.5)
section.page_height = Inches(11)
section.top_margin = Cm(1.78)
section.bottom_margin = Cm(1.78)
section.left_margin = Cm(1.91)
section.right_margin = Cm(1.91)
section.header_distance = Cm(1.0)
section.footer_distance = Cm(1.0)
styles = doc.styles
normal = styles["Normal"]
normal.font.name = "Malgun Gothic"
normal._element.rPr.rFonts.set(qn("w:eastAsia"), "Malgun Gothic")
normal.font.size = Pt(9.5)
normal.font.color.rgb = RGBColor.from_string(TEXT)
for style_name in ("Heading 1", "Heading 2", "Heading 3"):
style = styles[style_name]
style.font.name = "Malgun Gothic"
style._element.rPr.rFonts.set(qn("w:eastAsia"), "Malgun Gothic")
for style_name in ("List Bullet", "List Bullet 2", "List Number"):
style = styles[style_name]
style.font.name = "Malgun Gothic"
style._element.rPr.rFonts.set(qn("w:eastAsia"), "Malgun Gothic")
header = section.header
header_p = header.paragraphs[0]
header_p.alignment = WD_ALIGN_PARAGRAPH.RIGHT
r = header_p.add_run("HOUSING ASSEMBLY HMI | USER MANUAL")
set_run_font(r, size=7.5, bold=True)
r.font.color.rgb = RGBColor.from_string(MUTED)
footer = section.footer
footer_table = footer.add_table(1, 2, width=Inches(6.6))
footer_table.alignment = WD_TABLE_ALIGNMENT.CENTER
footer_table.autofit = False
left, right = footer_table.rows[0].cells
left.width = Cm(8)
right.width = Cm(8)
p_left = left.paragraphs[0]
r_left = p_left.add_run("MOBIDIGM · Internal Production Document")
set_run_font(r_left, size=7.5)
r_left.font.color.rgb = RGBColor.from_string(MUTED)
add_page_number(right.paragraphs[0])
settings = doc.settings._element
update_fields = OxmlElement("w:updateFields")
update_fields.set(qn("w:val"), "true")
settings.append(update_fields)
def add_cover(doc: Document) -> None:
accent = doc.add_table(rows=1, cols=1)
accent.alignment = WD_TABLE_ALIGNMENT.CENTER
accent.autofit = False
cell = accent.cell(0, 0)
set_cell_shading(cell, CYAN)
cell.height = Cm(0.25)
set_cell_margins(cell, top=0, bottom=0, start=0, end=0)
for _ in range(3):
doc.add_paragraph()
if LOGO.exists():
p_logo = doc.add_paragraph()
p_logo.alignment = WD_ALIGN_PARAGRAPH.RIGHT
p_logo.add_run().add_picture(str(LOGO), width=Cm(4.8))
doc.add_paragraph()
title = doc.add_paragraph()
title.alignment = WD_ALIGN_PARAGRAPH.LEFT
r = title.add_run("HOUSING\nASSEMBLY HMI")
set_run_font(r, size=30, bold=True)
r.font.color.rgb = RGBColor.from_string(NAVY)
title.paragraph_format.space_after = Pt(4)
subtitle = doc.add_paragraph()
r = subtitle.add_run("사용자 매뉴얼")
set_run_font(r, size=21, bold=True)
r.font.color.rgb = RGBColor.from_string(CYAN)
subtitle.paragraph_format.space_after = Pt(3)
desc = doc.add_paragraph()
r = desc.add_run("Inspection Control and Sensor Monitoring Console")
set_run_font(r, size=10.5)
r.font.color.rgb = RGBColor.from_string(MUTED)
for _ in range(4):
doc.add_paragraph()
meta = doc.add_table(rows=4, cols=2)
meta.alignment = WD_TABLE_ALIGNMENT.LEFT
meta.autofit = False
set_table_borders(meta, color=MID_GRAY)
values = [
("문서 버전", "1.0"),
("작성 기준일", "2026-07-09"),
("대상 프로그램", "Housing (.NET 9 WPF)"),
("작성 기준", "소스 코드 및 배포 구성 파일 정합"),
]
for i, (label, value) in enumerate(values):
left, right = meta.rows[i].cells
left.width = Cm(4.0)
right.width = Cm(9.0)
left.text = label
right.text = value
set_cell_shading(left, LIGHT_GRAY)
set_cell_margins(left, top=90, bottom=90, start=120, end=120)
set_cell_margins(right, top=90, bottom=90, start=120, end=120)
for run in left.paragraphs[0].runs:
set_run_font(run, size=9, bold=True)
run.font.color.rgb = RGBColor.from_string(NAVY)
for run in right.paragraphs[0].runs:
set_run_font(run, size=9)
doc.add_paragraph()
add_note(
doc,
"문서 주의",
"본 매뉴얼은 2026-07-09 현재 코드 동작을 기준으로 작성했습니다. 현장 배선, 장비 주소, 판정 기준은 승인된 생산 조건을 우선하며, 문서의 예시 값은 그대로 적용하지 마십시오.",
"warning",
)
doc.add_page_break()
def add_front_matter(doc: Document) -> None:
p = doc.add_paragraph()
r = p.add_run("문서 관리")
set_run_font(r, size=19, bold=True)
r.font.color.rgb = RGBColor.from_string(NAVY)
p.paragraph_format.space_after = Pt(10)
add_table(
doc,
["버전", "일자", "변경 내용", "비고"],
[("1.0", "2026-07-09", "Housing 코드 정합형 사용자 매뉴얼 최초 작성", "Initial")],
[2.0, 3.0, 8.5, 2.5],
)
add_note(
doc,
"확인 범위",
"프로그램 빌드는 성공했으나 실제 보드·계측기·NI-6501·SQL Server를 연결한 생산 사이클은 수행하지 않았습니다. 설비별 명령 및 네트워크 값은 현장 승인본과 대조해야 합니다.",
"info",
)
doc.add_page_break()
p = doc.add_paragraph()
r = p.add_run("목차")
set_run_font(r, size=19, bold=True)
r.font.color.rgb = RGBColor.from_string(NAVY)
p.paragraph_format.space_after = Pt(10)
contents = [
("1", "개요"),
("2", "시스템 구성 및 사전 준비"),
("3", "설치 및 프로그램 실행"),
("4", "로그인"),
("5", "메인 화면 구성"),
("6", "환경 및 판정 기준 설정"),
("7", "표준 작업 절차"),
("8", "검사 공정 상세"),
("9", "판정 및 결과 확인"),
("10", "데이터베이스 저장"),
("11", "로그아웃 및 종료"),
("12", "오류 처리 및 문제 해결"),
("13", "운영 주의사항 및 유지관리"),
("14", "부록"),
]
toc = doc.add_table(rows=0, cols=2)
toc.alignment = WD_TABLE_ALIGNMENT.CENTER
toc.autofit = False
set_fixed_table_widths(toc, [1.0, 15.0])
for number, title in contents:
row = toc.add_row()
row.height = Cm(1.0)
row.height_rule = WD_ROW_HEIGHT_RULE.EXACTLY
set_cell_width(row.cells[0], 1.0)
set_cell_width(row.cells[1], 15.0)
row.cells[0].text = number
row.cells[1].text = title
set_cell_shading(row.cells[0], NAVY)
set_cell_shading(row.cells[1], LIGHT_GRAY if int(number) % 2 else "F9FAFB")
for run in row.cells[0].paragraphs[0].runs:
set_run_font(run, size=9.5, bold=True)
run.font.color.rgb = RGBColor(255, 255, 255)
for run in row.cells[1].paragraphs[0].runs:
set_run_font(run, size=9.5, bold=True)
run.font.color.rgb = RGBColor.from_string(TEXT)
set_cell_margins(row.cells[0], top=100, bottom=100)
set_cell_margins(row.cells[1], top=100, bottom=100, start=150)
def chapter_1(doc: Document) -> None:
add_chapter(doc, 1, "개요")
add_section(doc, "1.1", "프로그램 목적")
add_body(
doc,
"Housing Assembly HMI는 PCB 바코드와 보드 IC 일련번호(IC_SN)를 연결하고, 전압 및 전류를 측정하여 설정 범위에 따라 OK/NG를 판정한 뒤 SQL Server에 결과를 저장하는 WPF 기반 생산 검사 프로그램이다.",
)
add_body(
doc,
"프로그램은 작업자 로그인 정보(Maker, Model, Variant, Operator, Line, Lot, Jig)와 측정 결과를 하나의 Housing 조립 이력으로 관리한다.",
)
add_section(doc, "1.2", "주요 공정 흐름")
add_flow(doc, ["LOGIN", "BARCODE", "START", "BOARD", "IC_SN", "MEASURE", "DB SAVE", "RESULT"])
for text in [
"작업 정보와 작업자 ID/PW를 입력하여 로그인한다.",
"PCB 바코드를 스캐너로 읽는다.",
"설비 시작 신호가 활성화된 경우 NI-6501 또는 LAN 신호를 기다린다.",
"보드 연결, IC_SN 읽기, CAL DEFAULT 명령을 수행한다.",
"계측기에서 전압과 전류를 읽는다.",
"V/mA 범위로 OK 또는 NG를 판정한다.",
"dbo.Housing_Assembly 테이블에 IC_SN 기준으로 Insert 또는 Update한다.",
"결과를 표시하고 다음 스캔을 위해 바코드와 IC_SN을 비운다.",
]:
add_number(doc, text)
add_section(doc, "1.3", "대상 사용자")
add_table(
doc,
["사용자", "주요 업무"],
[
("생산 작업자", "로그인, 바코드 스캔, 결과 확인, NG 및 오류 보고"),
("라인 관리자", "작업 옵션·판정 범위 확인, 설비 상태 점검, 재작업 판단"),
("설비/IT 관리자", "Hardware.ini, Database.ini, LoginOptions.json, DB 및 드라이버 유지관리"),
],
[3.2, 12.8],
)
add_section(doc, "1.4", "문서 적용 한계")
add_bullet(doc, "정확한 Windows 지원 버전은 프로젝트 코드에 명시되어 있지 않다. 배포본은 Windows x64 대상으로 구성되어 있다.")
add_bullet(doc, "계측기 모델, IP, COM 포트, DI 채널은 현장 구성에 따라 달라질 수 있다.")
add_bullet(doc, "예시 화면의 수치와 색상은 화면 구조 설명용이며 생산 판정 기준이 아니다.")
def chapter_2(doc: Document) -> None:
add_chapter(doc, 2, "시스템 구성 및 사전 준비")
add_section(doc, "2.1", "소프트웨어 구성")
add_table(
doc,
["항목", "코드/배포 기준", "설명"],
[
("Application", ".NET 9 / WPF / Windows", "Housing.exe, 최대화된 HMI 화면"),
("Architecture", "win-x64 배포 폴더", "현재 publish 폴더 기준 x64"),
("Database", "Microsoft SQL Server", "Microsoft.Data.SqlClient 사용"),
("Board", "Serial COM", "PCB 보드 명령 및 IC_SN 읽기"),
("Equipment", "VISA / Serial / TCP", "SCPI 전압·전류 측정"),
("Start Signal", "NI-6501 또는 TCP", "설비 시작 입력 감시"),
("Barcode", "Serial COM", "고정 포트 또는 Auto 탐색"),
],
[2.6, 4.8, 8.6],
)
add_section(doc, "2.2", "필요 장비 및 연결")
add_table(
doc,
["장비", "용도", "대표 설정"],
[
("PCB Board Interface", "연결 확인, IC_SN 읽기, CAL DEFAULT", "COM, Baud, 명령, <end> 토큰"),
("Barcode Scanner", "PCB 바코드 입력", "COM 또는 Auto, Baud 9600"),
("Keysight 34465A 계열 DMM", "DC 전압 측정", "LAN/VISA/Serial, MEAS:VOLT:DC?"),
("Keysight E36233A 계열", "전류 측정 및 출력 제어", "LAN/VISA/Serial, MEAS:CURR?"),
("NI USB-6501", "설비 시작 DI 신호", "Dev*/port*/line*, Active High/Low"),
("SQL Server", "로그인 및 검사 이력 저장", "서버, DB, 계정, 프로시저/테이블"),
],
[3.7, 5.0, 7.3],
)
add_section(doc, "2.3", "실행 전 점검")
for text in [
"보드, 바코드 스캐너, DMM, 전원 공급 장치 및 NI-6501 전원이 정상인지 확인한다.",
"보드 COM 포트와 바코드 스캐너 COM 포트가 중복되지 않는지 확인한다.",
"LAN 계측기와 DB 서버에 네트워크로 접근 가능한지 확인한다.",
"NI-DAQmx Runtime과 NI-6501 인식 상태를 확인한다(StartSignal=Ni6501 사용 시).",
"Hardware.ini, Database.ini, LoginOptions.json이 사용자가 Housing을 설치한 폴더에 있는지 확인한다.",
"승인된 V/mA 판정 범위를 확인한다.",
"DB의 dbo.Housing_Assembly 테이블과 로그인 구성이 준비되었는지 확인한다.",
]:
add_bullet(doc, text)
add_note(
doc,
"보안",
"Database.ini와 LoginOptions.json에는 평문 비밀번호가 포함될 수 있다. 생산 PC의 파일 접근 권한을 제한하고 매뉴얼·메일·메신저에 실제 값을 복사하지 않는다.",
"danger",
)
def chapter_3(doc: Document) -> None:
add_chapter(doc, 3, "설치 및 프로그램 실행")
add_section(doc, "3.1", "설치")
add_body(
doc,
"승인된 배포 패키지의 setup.exe 또는 Setup.msi를 사용한다. 저장소에는 여러 Setup 산출물이 있으므로 파일명만으로 최신본을 판단하지 말고 배포 담당자가 지정한 버전을 사용한다.",
)
add_number(doc, "이전 버전이 실행 중이면 정상 로그아웃 후 종료한다.")
add_number(doc, "승인된 설치 패키지를 실행한다.")
add_number(doc, "설치 후 사용자가 Housing을 설치한 폴더에 필수 설정 파일이 배치되었는지 확인한다.")
add_number(doc, "현장 승인본과 설정을 대조한 뒤 프로그램을 실행한다.")
add_section(doc, "3.2", "실행 방법")
add_body(
doc,
"바탕화면 바로가기 또는 사용자가 Housing을 설치한 폴더의 Housing.exe를 실행한다. 메인 창이 생성된 뒤 로그인 창이 모달로 표시된다. 로그인 창을 취소하거나 닫으면 메인 프로그램도 종료된다.",
)
add_section(doc, "3.3", "실행 및 구성 파일")
add_table(
doc,
["파일", "기본 위치", "용도"],
[
("Housing.exe", "사용자가 Housing을 설치한 폴더의 Housing.exe", "프로그램 실행"),
("Hardware.ini", "사용자가 Housing을 설치한 폴더의 Hardware.ini", "Board, Scanner, StartSignal, Equipment"),
("Database.ini", "사용자가 Housing을 설치한 폴더의 Database.ini", "DB 및 로그인 프로시저 설정"),
("LoginOptions.json", "사용자가 Housing을 설치한 폴더의 LoginOptions.json", "로그인 선택지 및 선택적 로컬 계정"),
("init.sql", "사용자가 Housing을 설치한 폴더의 init.sql(선택)", "DB 최초 구축 또는 승인된 스키마 변경 시에만 DBA가 실행"),
],
[3.0, 8.0, 5.0],
)
add_note(
doc,
"init.sql은 런타임 필수 파일이 아님",
"프로그램 코드는 init.sql을 읽거나 실행하지 않는다. 운영 DB에 필요한 로그인 프로시저와 테이블/인덱스가 이미 준비되어 있다면 사용자가 Housing을 설치한 폴더에 init.sql이 없어도 검사 동작에는 영향이 없다.",
"info",
)
add_section(doc, "3.4", "초기 실행 확인")
add_bullet(doc, "로그인 화면 제목이 Housing HMI Login으로 표시되는지 확인한다.")
add_bullet(doc, "Maker/Model/Line/Jig 선택 목록이 표시되는지 확인한다.")
add_bullet(doc, "로그인 후 메인 화면이 최대화되어 표시되는지 확인한다.")
add_bullet(doc, "바코드 입력 영역에 포커스가 자동으로 유지되는지 확인한다.")
def chapter_4(doc: Document) -> None:
add_chapter(doc, 4, "로그인")
add_section(doc, "4.1", "입력 항목")
add_table(
doc,
["화면 항목", "필수", "설명"],
[
("Maker", "필수", "LoginOptions.json의 makers 목록"),
("Model", "필수", "LoginOptions.json의 models 목록"),
("Variant 1", "선택", "빈 값 (None) 허용"),
("Variant 2", "선택", "빈 값 (None) 허용"),
("ID", "필수", "작업자 ID, DB 필드 최대 10자"),
("PW", "필수", "작업자 비밀번호"),
("Line", "필수", "생산 라인"),
("Lot No", "필수", "LOT 번호, DB 필드 최대 8자"),
("Jig No", "필수", "치구 번호"),
],
[3.2, 2.0, 10.8],
)
add_image(doc, LOGIN_IMAGE, "그림 4-1. 로그인 화면 예시 (작업 정보와 계정 값은 설명용)")
add_section(doc, "4.2", "로그인 절차")
for text in [
"Maker와 Model을 선택한다.",
"필요한 경우 Variant 1/2를 선택한다. 미사용 시 (None)을 선택한다.",
"ID와 PW를 입력한다.",
"Line, Lot No, Jig No를 입력 또는 선택한다.",
"ACCESS / LOG IN 버튼을 누른다.",
"메인 화면 우측 상단의 LOGIN ID가 입력한 작업자 ID로 표시되는지 확인한다.",
]:
add_number(doc, text)
add_section(doc, "4.3", "인증 방식 우선순위")
add_table(
doc,
["우선", "조건", "인증 동작"],
[
("1", "LoginOptions.json의 loginAccounts가 1개 이상", "JSON의 ID/PW와 일치하는지 확인; DB 인증은 수행하지 않음"),
("2", "로컬 계정 없음 + OfflinePreview=True", "입력값이 비어 있지 않으면 인증 허용"),
("3", "로컬 계정 없음 + OfflinePreview=False", "Database.ini의 저장 프로시저로 ID/PW/ProcessName 확인"),
],
[1.3, 6.0, 8.7],
)
add_note(
doc,
"운영 권고",
"양산 환경에서는 테스트용 로컬 계정과 OfflinePreview 설정을 제거/비활성화하고 DB 인증 정책을 사용한다. 실제 비밀번호는 본 문서에 기록하지 않는다.",
"danger",
)
add_section(doc, "4.4", "입력 이력")
add_body(
doc,
"Maker, Model, Variant, Operator, Line, Lot, Jig 선택값은 프로그램이 자동으로 저장하여 다음 로그인 때 제안한다. Operator 값 중 특정 관리자용 ID는 코드에서 이력 저장 대상에서 제외된다.",
)
def chapter_5(doc: Document) -> None:
add_chapter(doc, 5, "메인 화면 구성")
add_section(doc, "5.1", "전체 화면")
add_body(
doc,
"메인 창은 기본 1200×800, 최소 840×560으로 정의되며 실행 시 최대화된다. 상단 헤더, 공정 추적 바, 스캔/추적성 영역, 측정 모니터링 영역, 하단 상태 표시로 구성된다.",
)
add_image(doc, WAITING_IMAGE, "그림 5-1. 로그인 직후 바코드 대기 화면")
add_note(
doc,
"화면 예시",
"프로젝트에 포함된 화면 이미지는 UI 구조와 OK/NG 색상 설명용이다. 이미지의 수치·LOGIN ID·공정 단계가 실제 검사 한 사이클의 정합된 기록을 의미하지는 않는다.",
"warning",
)
add_note(
doc,
"RESULT 영역의 의미",
"RESULT는 최종 판정만 표시하는 칸이 아니라 공정 안내를 함께 표시하는 공용 영역이다. 바코드 인식 순간에는 “스캔 완료. 시료 투입 후 Housing 장비 시작 버튼을 눌러주세요.”가 잠깐 표시될 수 있으나, StartSignal이 활성화된 현재 구성에서는 곧 “장비 시작 신호 대기 / NI-6501 Dev1/port0/line0” 안내로 전환된다. 측정 판정이 끝난 뒤에만 OK 또는 NG로 바뀐다.",
"info",
)
add_section(doc, "5.2", "화면 영역")
add_table(
doc,
["영역", "표시/입력", "사용 목적"],
[
("Header", "프로그램명, LOGIN ID, LOGOUT", "작업자 확인 및 로그아웃"),
("PROCESS", "BARCODE > START > BOARD > IC_SN > MEASURE > DB SAVE > RESULT", "현재 공정 단계 강조"),
("PCB_BARCODE INPUT", "스캔된 PCB 바코드", "추적성 입력"),
("IC_SN OUTPUT", "보드에서 읽은 Hex ID", "제품 식별"),
("V OUTPUT", "최소/최대 기준, 측정 전압", "전압 판정"),
("mA OUTPUT", "최소/최대 기준, 측정 전류", "전류 판정"),
("RESULT", "상태 메시지 또는 OK/NG", "최종 결과 확인"),
],
[3.4, 6.2, 6.6],
)
add_section(doc, "5.3", "공정 상태 표시")
add_table(
doc,
["상태", "의미", "기본 색상"],
[
("WAITING BARCODE", "다음 PCB 바코드 대기", "Cyan"),
("BARCODE SCANNED", "유효 바코드 수신", "Green"),
("WAIT START SIGNAL / START DELAY", "설비 시작 입력 또는 지연 대기", "Orange"),
("BOARD TEST", "보드 통신 및 명령 수행", "Orange"),
("IC_SN READ", "IC_SN 읽기 완료", "Cyan"),
("MEASUREMENT DONE", "전압/전류 읽기 완료", "Cyan"),
("DB SAVING", "검사 결과 저장 중", "Orange"),
("COMPLETE OK / COMPLETE NG", "저장 완료 및 최종 상태", "Green / Red"),
("ERROR / DB SAVE ERROR / SCANNER ERROR", "오류 발생", "Red"),
],
[5.3, 8.0, 2.9],
)
def chapter_6(doc: Document) -> None:
add_chapter(doc, 6, "환경 및 판정 기준 설정")
add_section(doc, "6.1", "V/mA 판정 범위")
add_body(
doc,
"메인 화면의 V OUTPUT 및 mA OUTPUT 상단 범위 입력란에서 최소값과 최대값을 설정한다. Enter를 누르면 포커스가 바코드 입력란으로 돌아간다. 실제 저장은 다음 검사가 시작될 때 입력값 검증 후 수행된다.",
)
add_bullet(doc, "VMin/VMax 단위: V")
add_bullet(doc, "AMin/AMax 화면 단위: mA")
add_bullet(doc, "내부 저장 단위: A (화면 mA 값을 1000으로 나누어 저장)")
add_bullet(doc, "최소값은 최대값보다 클 수 없다.")
add_bullet(doc, "범위 경계값은 PASS에 포함된다.")
add_note(
doc,
"중요",
"판정 범위는 품질 승인값이다. 권한 없는 작업자가 임의 변경하지 않도록 운영 절차와 계정 권한으로 통제한다.",
"danger",
)
add_section(doc, "6.2", "Hardware.ini")
add_body(doc, "Hardware.ini는 사용자가 Housing을 설치한 폴더에 있다. 이 파일은 설비/IT 관리자가 장비 연결 구성을 변경할 때 사용한다.")
add_table(
doc,
["Section", "주요 Key", "주의사항"],
[
("[Board]", "PortName, BaudRate, ReadTimeout, Commands, EndToken", "보드 펌웨어 명령과 일치"),
("[BarcodeScanner]", "Enabled, PortName, BaudRate, IdleCommitMilliseconds", "Auto 사용 시 다른 Serial 포트 제외"),
("[StartSignal]", "Enabled, Connection, Channel/Host, ActiveState, Timeout", "Ni6501 또는 Tcp"),
("[Equipment]", "Timeout, Settle, PreBoard/Logout, Voltage*, Current*", "VISA/Serial/TCP와 SCPI 명령"),
],
[3.5, 7.2, 6.5],
)
add_note(
doc,
"보호 동작",
"Equipment 명령 목록은 세미콜론(;)으로 분리된다. 코드상 전원 공급 장치 CH2의 OUTP OFF 명령은 보호 목적으로 건너뛴다.",
"info",
)
add_section(doc, "6.3", "Database.ini")
add_body(doc, "Database.ini는 사용자가 Housing을 설치한 폴더에 있다. 이 파일은 설비/IT 관리자가 DB 연결 및 로그인 설정을 변경할 때 사용한다.")
add_table(
doc,
["Section", "Key", "설명"],
[
("[Database]", "IP, Database, DbId, DbPw", "SQL Server 접속 정보"),
("[Database]", "Encrypt, TrustServerCertificate, Timeout", "연결 보안 및 시간 제한"),
("[Login]", "Procedure", "기본 dbo.CheckOperator"),
("[Login]", "ProcessName", "기본 Housing"),
("[Login]", "OfflinePreview", "True이면 DB 없이 로그인 허용 가능"),
],
[3.0, 5.2, 9.0],
)
add_section(doc, "6.4", "LoginOptions.json")
add_body(doc, "LoginOptions.json은 사용자가 Housing을 설치한 폴더에 있다. 이 파일은 설비/IT 관리자가 로그인 선택 항목을 변경할 때 사용한다.")
add_code_block(
doc,
"""{
"loginAccounts": [],
"makers": ["<MAKER>"],
"models": ["<MODEL>"],
"variant1Values": [],
"variant2Values": [],
"lineValues": ["<LINE>"],
"jigNoValues": ["<JIG>"]
}""",
)
add_note(
doc,
"예시 값",
"위 예시는 구조만 보여준다. loginAccounts를 비워야 DB/OfflinePreview 분기로 이동한다. 생산 값은 현장 관리자가 승인한 목록을 사용한다.",
"warning",
)
def chapter_7(doc: Document) -> None:
add_chapter(doc, 7, "표준 작업 절차")
add_section(doc, "7.1", "작업 시작 전")
for text in [
"설비 전원, 케이블, 치구 및 안전 상태를 확인한다.",
"보드/스캐너 COM 포트와 계측기 LAN 또는 VISA 연결을 확인한다.",
"작업 지시서의 Maker, Model, Variant, Line, Lot, Jig를 확인한다.",
"승인된 전압/전류 판정 범위를 확인한다.",
"DB와 시작 신호 장치가 준비되었는지 확인한다.",
]:
add_bullet(doc, text)
add_section(doc, "7.2", "검사 시작")
for text in [
"Housing을 실행하고 작업 정보를 입력하여 로그인한다.",
"메인 화면 LOGIN ID와 V/mA 판정 범위를 확인한다.",
"제품을 치구에 올바르게 장착한다.",
"PCB 바코드를 스캔한다.",
"화면에 BARCODE SCANNED가 표시되는지 확인한다.",
"시작 신호가 활성화된 구성에서는 설비 Start 버튼을 눌러 입력 신호를 발생시킨다.",
"BOARD → IC_SN → MEASURE → DB SAVE → RESULT 진행을 관찰한다.",
"OK이면 제품을 정상 흐름으로 이동하고, NG 또는 오류이면 분리 후 표준 조치를 수행한다.",
]:
add_number(doc, text)
add_image(
doc,
START_IMAGE,
"그림 7-1. 바코드 입력 후 NI-6501 Dev1/port0/line0 시작 신호 대기 예시",
)
add_note(
doc,
"바코드 입력 후 화면 전환",
"BarcodeTextBox의 확인 문구는 일시적이다. 프로그램이 검사 시퀀스에 진입하고 StartSignal.Enabled=True이면 RESULT에 “장비 시작 신호 대기”, “NI-6501 Dev1/port0/line0”, “시료를 넣고 Housing 장비 시작 버튼을 눌러주세요.”가 표시된다. PhysicalChannel은 Hardware.ini에서 읽으며, 최종 OK/NG는 보드 통신과 전압·전류 측정이 완료된 뒤 표시된다.",
"info",
)
add_section(doc, "7.3", "허용 바코드 형식")
add_table(
doc,
["형식", "예시", "판정 규칙"],
[
("표기형", "PCBA S/N: A251001AM46110901T00001-01", "PCBA S/N 뒤 4~50자의 영문/숫자/-"),
("단일 Serial", "A251001AM46110901T00001-01", "영문/숫자/- 4~50자, 첫 글자 영숫자, 숫자 1개 이상"),
("구조형", "MODEL:M1;SERIAL:A251001AM46110901T00001-01;OPTION:O1", "MODEL, SERIAL, OPTION 세 필드 필수"),
],
[2.5, 6.0, 8.7],
)
add_note(
doc,
"스캐너 입력",
"붙여넣기·Backspace·Delete·Esc·Ctrl/Alt 조합은 바코드 입력에서 차단된다. 스캐너 또는 승인된 키보드 웨지 입력을 사용한다.",
"info",
)
add_section(doc, "7.4", "검사 후")
add_bullet(doc, "OK/NG 표시와 색상을 확인한다.")
add_bullet(doc, "DB SAVE ERROR가 표시되면 결과가 화면에 OK여도 저장 완료로 간주하지 않는다.")
add_bullet(doc, "검사 종료 후 바코드와 IC_SN이 자동으로 비워지고 다음 스캔을 기다리는지 확인한다.")
add_bullet(doc, "작업 종료 또는 작업자 교대 시 LOGOUT을 사용한다.")
def chapter_8(doc: Document) -> None:
add_chapter(doc, 8, "검사 공정 상세")
add_section(doc, "8.1", "전체 시퀀스")
add_table(
doc,
["단계", "프로그램 동작", "실패 시 대표 상태"],
[
("1. Barcode", "형식 검증 후 PCB_BARCODE INPUT 반영", "SCAN ERROR"),
("2. Range", "V/mA 숫자 및 Min ≤ Max 검증, 설정 저장", "RANGE SETTING ERROR"),
("3. Start", "NI-6501 또는 TCP 신호 대기, 지연 시각 계산", "ERROR / Timeout"),
("4. Pre-Board", "전류 채널 연결, 사전 SCPI 명령, 지연", "ERROR"),
("5. Board Connect", "PreConnect → Connect, Success 응답 확인", "ERROR"),
("6. IC_SN", "ReadId 응답에서 8~32자리 Hex 추출", "ERROR"),
("7. Board Setup", "PostReadId 및 CalDefault 명령", "ERROR"),
("8. Measure", "전압 후 전류 측정, 숫자 파싱", "ERROR"),
("9. Judge", "전압 AND 전류가 범위 내이면 OK", "NG"),
("10. DB", "IC_SN 기준 Upsert", "DB SAVE ERROR"),
("11. Reset", "PCB Barcode/IC_SN 비우기, 다음 스캔", "WAITING BARCODE"),
],
[2.7, 9.0, 5.5],
)
add_image(
doc,
BOARD_IMAGE,
"그림 8-1. 보드 검사 진행 및 IC_SN 025949A8492CFEAC 표시 예시",
)
add_section(doc, "8.2", "시작 신호")
add_body(
doc,
"StartSignal.Enabled=False이면 바코드 수신 후 시작 신호를 기다리지 않는다. Enabled=True이면 설정된 입력이 활성 상태가 될 때까지 기다린다.",
)
add_table(
doc,
["Connection", "동작"],
[
("Ni6501", "지정 DI PhysicalChannel을 PollInterval마다 읽고 ActiveState와 비교"),
("Tcp + ReadCommand 없음", "LAN 장치의 이벤트 메시지를 기다리고 ActiveResponse 일치 확인"),
("Tcp + ReadCommand 있음", "명령을 주기적으로 전송하고 응답을 ActiveResponse와 비교"),
],
[4.2, 11.8],
)
add_note(
doc,
"RequireInactiveBeforeStart",
"True이면 프로그램은 기존 Active 신호가 먼저 해제된 뒤 새로운 Active 전이를 기다린다. 버튼이 눌린 채로 시작하면 해제 대기에서 Timeout이 발생할 수 있다.",
"warning",
)
add_section(doc, "8.3", "보드 통신")
add_bullet(doc, "보드 통신은 Hardware.ini [Board]의 Serial COM 설정을 사용한다.")
add_bullet(doc, "CONNECT 응답에 독립된 Success 행이 있어야 정상 연결로 판단한다.")
add_bullet(doc, "IC_SN은 응답 중 8~32자리 16진수 행을 찾아 대문자로 표시한다.")
add_bullet(doc, "IC_SN 읽기 전에 실패하면 finally에서 보드 OFF 명령을 시도한다.")
add_section(doc, "8.4", "계측기 통신")
add_body(
doc,
"전압과 전류 채널은 각각 VISA, Serial(COM), TCP(LAN/Ethernet) 중 하나를 사용할 수 있다. TCP 사용 시 IdnMatch가 설정되어 있으면 *IDN? 응답을 먼저 검증한다.",
)
add_bullet(doc, "전류 채널 SetupCommand를 먼저 실행하고 SettleMilliseconds 동안 대기한다.")
add_bullet(doc, "전압을 먼저 읽고 전류를 읽는다.")
add_bullet(doc, "응답에서 첫 번째 숫자(지수 표기 포함)를 측정값으로 파싱한다.")
add_bullet(doc, "전류 채널 CleanupCommand는 성공/실패와 관계없이 가능한 범위에서 실행한다.")
def chapter_9(doc: Document) -> None:
add_chapter(doc, 9, "판정 및 결과 확인")
add_section(doc, "9.1", "판정식")
add_code_block(
doc,
"""VoltagePass = VMin ≤ MeasuredVoltage ≤ VMax
CurrentPass = AMin ≤ MeasuredCurrent(A) ≤ AMax
InspectionPass = VoltagePass AND CurrentPass""",
)
add_body(
doc,
"화면의 전류 기준은 mA이지만 내부 판정은 A 단위로 변환하여 비교한다. 측정 결과는 화면에서 소수점 둘째 자리까지 표시되며, 비교 자체는 decimal 원값으로 수행된다.",
)
add_section(doc, "9.2", "OK 결과")
add_bullet(doc, "전압과 전류가 모두 범위 내이면 RESULT에 큰 초록색 OK가 표시된다.")
add_bullet(doc, "V OUTPUT과 mA OUTPUT 테두리 및 글자도 각각 초록색으로 표시된다.")
add_bullet(doc, "DB 저장이 성공하면 상태가 COMPLETE OK로 표시된다.")
add_image(doc, OK_IMAGE, "그림 9-1. OK 표시 예시 (표시 수치는 설명용)")
add_section(doc, "9.3", "NG 결과")
add_bullet(doc, "전압 또는 전류 중 하나라도 범위를 벗어나면 RESULT에 큰 빨간색 NG가 표시된다.")
add_bullet(doc, "각 항목은 개별 PASS/FAIL 색상으로 표시된다.")
add_bullet(doc, "NG 팝업에 실패 항목의 측정값과 기준 범위가 표시된다.")
add_bullet(doc, "DB에는 Result=NG로 저장을 시도한다.")
add_image(doc, NG_IMAGE, "그림 9-2. NG 표시 예시 (표시 수치는 설명용)")
add_section(doc, "9.4", "DB 저장 오류와 판정의 구분")
add_note(
doc,
"DB SAVE ERROR",
"측정 판정과 데이터 저장은 별개다. 화면에 OK/NG가 먼저 표시된 뒤 DB 저장이 실패할 수 있다. DB SAVE ERROR가 발생한 제품은 저장 여부를 DB에서 확인하기 전 정상 완료로 처리하지 않는다.",
"danger",
)
def chapter_10(doc: Document) -> None:
add_chapter(doc, 10, "데이터베이스 저장")
add_section(doc, "10.1", "저장 방식")
add_body(
doc,
"검사 완료 후 dbo.Housing_Assembly를 IC_SN으로 조회하여 기존 행이 있으면 Update, 없으면 Insert한다. 트랜잭션과 UPDLOCK/HOLDLOCK을 사용하여 동시 Upsert 충돌을 줄인다.",
)
add_section(doc, "10.2", "저장 필드")
add_table(
doc,
["구분", "DB Field", "출처"],
[
("식별", "IC_SN", "보드 READ_ID 응답"),
("식별", "PCB_Barcode", "바코드 스캔"),
("작업", "Maker, Model, Variant_1, Variant_2", "로그인 화면"),
("작업", "Operator, Line, Lot_No, Jig_No", "로그인 화면"),
("시간", "Production_Date", "저장 시점의 PC 로컬 시간(초 단위)"),
("측정", "PT_Vol_1", "전압 측정값"),
("측정", "PT_Current_1", "전류 측정값을 mA로 변환"),
("판정", "Result", "OK 또는 NG"),
],
[2.6, 6.4, 9.6],
)
add_section(doc, "10.3", "DB 사전 조건")
add_bullet(doc, "dbo.Housing_Assembly 테이블은 운영 DB에 미리 존재해야 한다.")
add_bullet(doc, "init.sql은 선택적 DB 관리 스크립트다. 로그인 테이블/프로시저를 준비하고, Housing_Assembly가 이미 있는 경우 IC_SN Unique Index를 추가한다.")
add_bullet(doc, "DB 서버에 필요한 프로시저와 테이블/인덱스가 이미 구성되어 있다면 init.sql을 다시 실행할 필요가 없다.")
add_bullet(doc, "init.sql은 기본 계정 데이터를 생성 또는 갱신할 수 있으므로 운영 DB에서는 DBA 검토와 승인 없이 실행하지 않는다.")
add_bullet(doc, "중복 IC_SN이 존재하면 Unique Index 생성이 중단된다.")
add_bullet(doc, "Database.ini의 IP, Database, DbId가 비어 있으면 저장할 수 없다.")
add_bullet(doc, "현재 코드에는 DB 실패 시 CSV 등 로컬 대체 저장 기능이 없다.")
add_section(doc, "10.4", "DB 오류 확인")
add_body(
doc,
"DB 저장 실패 팝업에는 예외 메시지와 대상 서버/DB/설정 파일 경로가 표시된다. 비밀번호는 표시되지 않지만 화면 캡처를 외부로 공유하기 전에 서버 정보가 포함되어 있는지 확인한다.",
)
def chapter_11(doc: Document) -> None:
add_chapter(doc, 11, "로그아웃 및 종료")
add_section(doc, "11.1", "정상 로그아웃")
for text in [
"진행 중인 검사가 없는지 확인한다.",
"상단 LOGOUT 버튼을 누른다.",
"프로그램이 Current 채널의 LogoutCommand를 실행하여 전원 출력 OFF를 시도한다.",
"화면이 Reset되고 로그인 창이 다시 표시되는지 확인한다.",
"작업자 교대 시 새 작업 정보로 로그인한다.",
]:
add_number(doc, text)
add_section(doc, "11.2", "로그아웃 명령 실패")
add_body(
doc,
"전원 OFF 명령이 실패하면 경고 팝업이 표시되지만 프로그램은 화면을 Reset하고 로그인 창을 연다. 이 경우 실제 전원 공급 장치 출력 상태를 직접 확인하고 설비 담당자에게 보고한다.",
)
add_section(doc, "11.3", "프로그램 종료")
add_note(
doc,
"종료 순서",
"창 닫기는 바코드 스캐너와 타이머를 정리하지만 LogoutCommand를 호출하지 않는다. 작업 종료 시 먼저 LOGOUT으로 전원 OFF 동작을 수행하고, 실제 장비 출력이 안전한지 확인한 다음 창을 닫는다.",
"danger",
)
add_bullet(doc, "검사 중 강제 종료하지 않는다.")
add_bullet(doc, "작업 관리자에서 Housing 프로세스를 강제 종료하는 것은 비상 상황에서만 수행한다.")
add_bullet(doc, "비정상 종료 후에는 보드·전원 공급 장치·치구 상태를 확인한 뒤 재실행한다.")
def chapter_12(doc: Document) -> None:
add_chapter(doc, 12, "오류 처리 및 문제 해결")
add_section(doc, "12.1", "표준 조치")
for text in [
"제품을 정상 제품 흐름에서 분리한다.",
"현재 PROCESS 상태와 팝업 메시지를 기록한다.",
"장비 전원, 케이블, COM 포트, LAN 상태를 확인한다.",
"관련 설정 파일을 승인본과 비교한다.",
"DB SAVE ERROR이면 IC_SN/PCB Barcode로 DB 저장 여부를 확인한다.",
"원인을 제거한 뒤 제품 재검사/재작업 기준에 따라 진행한다.",
"동일 오류가 반복되면 설비/IT 관리자에게 전달한다.",
]:
add_number(doc, text)
add_section(doc, "12.2", "주요 오류와 조치")
add_table(
doc,
["상태/오류", "대표 원인", "조치"],
[
("Login 실패", "ID/PW 불일치, 로컬/DB 인증 설정", "LoginOptions, OfflinePreview, DB 프로시저 확인"),
("SCANNER ERROR", "COM 포트 없음/점유/설정 오류", "스캐너 전원, 포트, Baud, Board 포트 중복 확인"),
("SCAN ERROR", "바코드 형식 불일치", "라벨 품질과 허용 형식 확인 후 재스캔"),
("RANGE SETTING ERROR", "숫자 아님 또는 Min > Max", "승인된 V/mA 기준 재입력"),
("Start Timeout", "DI/LAN 신호 미수신 또는 Active 고정", "NI 채널/배선/ActiveState/RequireInactive 확인"),
("NI-DAQmx 오류", "Runtime 누락, 장치 미인식, 비트 불일치", "NI-DAQmx x64 Runtime 및 NI MAX 확인"),
("Board 연결 실패", "COM/케이블/명령/응답 오류", "Board Port, Baud, Success 응답 확인"),
("IC_SN 읽기 실패", "응답에 유효 Hex ID 없음", "제품 접촉, 보드 응답, ReadIdCommand 확인"),
("LAN IDN mismatch", "잘못된 계측기 IP 또는 IdnMatch", "*IDN? 응답과 설정 대조"),
("측정값 파싱 실패", "SCPI 응답에 숫자 없음", "ReadCommand, Terminator, 계측기 모드 확인"),
("DB SAVE ERROR", "DB 접속/권한/스키마/데이터 길이", "서버, 계정, 테이블, IC_SN 중복 및 필드 길이 확인"),
("Logout OFF 실패", "전원 공급 장치 연결/명령 오류", "출력 상태 수동 확인 후 설정/통신 점검"),
],
[4.0, 6.0, 8.2],
)
add_image(
doc,
DB_ERROR_IMAGE,
"그림 12-1. 측정 판정은 OK이나 DB 저장에 실패한 상태 예시",
)
add_section(doc, "12.3", "관리자에게 전달할 정보")
add_bullet(doc, "발생 시각, 작업자 ID, Line/Lot/Jig")
add_bullet(doc, "PCB Barcode와 IC_SN(표시된 경우)")
add_bullet(doc, "PROCESS 상태와 팝업 전문")
add_bullet(doc, "사용한 설정 파일 버전(비밀번호 제외)")
add_bullet(doc, "장비 전원/케이블/포트/네트워크 확인 결과")
add_bullet(doc, "재현 빈도와 재시도 결과")
def chapter_13(doc: Document) -> None:
add_chapter(doc, 13, "운영 주의사항 및 유지관리")
add_section(doc, "13.1", "생산 운영 주의")
add_bullet(doc, "검사 중 판정 범위를 변경하지 않는다.")
add_bullet(doc, "검사 중 보드, 스캐너, 계측기, NI-6501 케이블을 분리하지 않는다.")
add_bullet(doc, "NG 제품은 팝업의 실패 항목을 확인하고 정상 제품과 즉시 분리한다.")
add_bullet(doc, "DB 저장 실패 제품은 저장 여부 확인 전 출하 가능 상태로 처리하지 않는다.")
add_bullet(doc, "같은 PCB Barcode/IC_SN 재검사는 기존 DB 행을 덮어쓸 수 있으므로 재작업 절차를 따른다.")
add_section(doc, "13.2", "설정 변경 관리")
add_number(doc, "현재 설정 파일을 날짜/버전이 포함된 폴더에 백업한다.")
add_number(doc, "변경 사유, 변경자, 승인자, 적용 일시를 기록한다.")
add_number(doc, "비밀번호를 제외한 변경 항목을 동료 검토한다.")
add_number(doc, "비생산 제품으로 로그인·스캔·시작 신호·측정·DB 저장을 검증한다.")
add_number(doc, "검증 결과와 배포 파일 해시/버전을 보관한다.")
add_section(doc, "13.3", "정기 점검")
add_table(
doc,
["주기", "점검 항목"],
[
("매 작업 시작", "장비 연결, 판정 범위, 로그인 정보, DB/시작 신호"),
("일일", "NG/오류 현황, DB 누락, 스캐너/치구 상태"),
("변경 시", "설정 백업, 비생산 검증, 승인 기록"),
("정기", "계측기 교정 유효기간, NI/통신 드라이버, DB 백업 및 계정 권한"),
],
[3.0, 13.0],
)
add_section(doc, "13.4", "정보 보안")
add_bullet(doc, "Database.ini와 LoginOptions.json의 평문 자격 증명을 최소화한다.")
add_bullet(doc, "생산 PC 폴더 ACL을 필요한 관리자와 실행 계정으로 제한한다.")
add_bullet(doc, "기본/샘플 계정은 양산 전 변경 또는 제거한다.")
add_bullet(doc, "오류 화면 공유 시 서버 주소, 작업자 ID, 제품 식별자를 마스킹한다.")
def chapter_14(doc: Document) -> None:
add_chapter(doc, 14, "부록")
add_section(doc, "14.1", "설정 파일 경로 요약")
add_table(
doc,
["파일", "실제 사용 위치", "비고"],
[
("Hardware.ini", "사용자가 Housing을 설치한 폴더의 Hardware.ini", "필수"),
("Database.ini", "사용자가 Housing을 설치한 폴더의 Database.ini", "필수"),
("LoginOptions.json", "사용자가 Housing을 설치한 폴더의 LoginOptions.json", "필수. 없으면 관리자에게 문의"),
("init.sql", "사용자가 Housing을 설치한 폴더의 init.sql", "선택 파일. 최초 DB 구성/변경 시 DBA 승인 후 실행"),
],
[4.0, 8.0, 4.0],
)
add_section(doc, "14.2", "Hardware.ini 키 참조")
add_table(
doc,
["영역", "대표 키", "허용/의미"],
[
("Board", "PortName / BaudRate / ReadTimeout", "COM 포트 / bps / ms"),
("BarcodeScanner", "Enabled / PortName", "True|False / COMx|Auto"),
("StartSignal", "Connection", "Ni6501 또는 Tcp/Lan/Ethernet"),
("StartSignal", "ActiveState", "High 또는 Low"),
("StartSignal", "TimeoutMilliseconds", "0 이하면 무제한, 그 외 ms"),
("Equipment", "*Connection", "Visa|Usb|Serial|Com|Tcp|Lan|Ethernet|None"),
("Equipment", "*IdnMatch", "쉼표로 구분된 허용 IDN 문자열"),
("Equipment", "*Setup/Cleanup/ReadCommand", "SCPI, 복수 명령은 ; 구분"),
],
[3.5, 6.0, 6.5],
)
add_section(doc, "14.3", "용어")
add_table(
doc,
["용어", "의미"],
[
("PCB Barcode", "PCB/PCBA 라벨에서 읽은 생산 추적 식별자"),
("IC_SN", "보드에서 읽은 8~32자리 16진수 IC 일련번호"),
("SCPI", "계측기 제어에 사용되는 표준 명령 체계"),
("VISA", "USB/LAN/계측기 통신 리소스 접근 계층"),
("NI-6501", "시작 신호에 사용하는 USB 디지털 I/O 장치"),
("DI", "Digital Input"),
("IDN", "SCPI *IDN? 장비 식별 응답"),
("Upsert", "같은 IC_SN이 있으면 Update, 없으면 Insert"),
("OK/NG", "판정 합격/불합격"),
],
[3.3, 12.7],
)
add_section(doc, "14.4", "코드 기준 기능 매핑")
add_table(
doc,
["기능", "코드 기준"],
[
("프로그램 시작", "App.xaml.cs → MainWindow"),
("로그인", "Login/StartupLoginWindow.xaml(.cs)"),
("바코드 입력", "MainWindow.ProcessScannerTextAsync / SerialBarcodeScanner"),
("검사 전체 흐름", "MainWindow.RunBoardSequenceAsync"),
("시작 신호", "StartSignalWatcher / Ni6501StartSignalWatcher / TcpStartSignalWatcher"),
("보드 통신", "BoardTestService / SerialBoardClient"),
("전압·전류 측정", "EquipmentMeasurementService / IScpiClient 구현"),
("판정", "MainWindow.IsVoltagePass / IsCurrentPass"),
("설정 저장", "InspectionSettingsStore"),
("DB 저장", "HousingAssemblyRepository.UpsertInspectionAsync"),
("로그아웃 전원 OFF", "EquipmentMeasurementService.RunLogoutCommandAsync"),
],
[5.2, 10.8],
)
add_section(doc, "14.5", "빠른 작업 체크리스트")
checklist = [
"□ 설비·보드·계측기·스캐너·NI-6501 전원/연결 확인",
"□ 작업 정보(Maker/Model/Line/Lot/Jig) 확인",
"□ LOGIN ID 확인",
"□ V/mA 판정 범위 승인값 확인",
"□ 제품 장착 및 PCB 바코드 스캔",
"□ 시작 신호 입력",
"□ IC_SN 및 측정 진행 확인",
"□ OK/NG 및 DB SAVE 상태 확인",
"□ NG/오류 제품 분리 및 보고",
"□ 작업 종료 시 LOGOUT 후 장비 출력 확인",
]
for item in checklist:
add_body(doc, item)
add_note(
doc,
"문의 시",
"프로그램 버전, 발생 시각, 공정 상태, 제품 식별자, 오류 메시지, 설정 버전과 장비 연결 상태를 준비하십시오. 비밀번호 및 민감한 서버 정보는 전달하지 마십시오.",
"info",
)
def build_document() -> Path:
OUT_DIR.mkdir(parents=True, exist_ok=True)
doc = Document()
configure_document(doc)
add_cover(doc)
add_front_matter(doc)
chapter_1(doc)
chapter_2(doc)
chapter_3(doc)
chapter_4(doc)
chapter_5(doc)
chapter_6(doc)
chapter_7(doc)
chapter_8(doc)
chapter_9(doc)
chapter_10(doc)
chapter_11(doc)
chapter_12(doc)
chapter_13(doc)
chapter_14(doc)
props = doc.core_properties
props.title = "Housing Assembly HMI 사용자 매뉴얼"
props.subject = "Housing 코드 정합형 사용자 매뉴얼"
props.author = "MOBIDIGM"
props.keywords = "Housing, HMI, User Manual, Barcode, Voltage, Current, SQL Server"
props.comments = "Generated from the Housing source tree and reference manual structure."
doc.save(OUT_FILE)
return OUT_FILE
if __name__ == "__main__":
result = build_document()
print(result)