feat(B07): 표제란에 로고·서명 자리 신설 + 시행청·담당자 DB 확장

사용자 지시(2026-09-02) — 로고·서명을 임의로 만들어 표제란에 넣고, 위치·크기는
도각을 고치면 따라오게 하며, 시행청·과업책임자 등은 DB를 넓혀 채울 것.

도각(표제란)
- `00_template_A1.json` 에 그림 자리 2개 추가 — 회사 로고는 용역회사 칸 왼쪽
  (316~348 × 20~36mm), 설계자 서명은 설계 칸 아래(638~680 × 17.5~25.5mm).
  자리·크기가 **템플릿 좌표로만** 정해지므로 도각을 고치면 그대로 따라옴.
- `_fill_placeholders` 가 그림 자리(`imageData`)의 `{{키}}` 도 채움. 값을 못 구하면
  빈 문자열을 남기지 않고 **엔티티째 제거** — 빈 값은 CAD 가 깨진 그림으로 그림.
- `_transform_entity` 가 `points` 배열도 옮김. 그림(로고·서명)과 띠(Hatch)가 이 키를
  쓰는데 여태 변환 대상이 아니라 도각을 옮기면 제자리에 남았음.

DB (`011_title_block.sql`, 전부 ADD COLUMN·NULL 허용)
- `projects` — `client_org`(시행청), `pm_user_id`·`field_lead_user_id`·`designer_user_id`
- `companies.logo_path` · `users.signature_path` — 그림은 파일로 두고 경로만 담음
  (기존 `storage_path`·`input_files` 와 같은 방식)
- FK 는 걸지 않음 — 사유를 파일 머리말에 적음(소프트 삭제·LEFT JOIN·4환경 공유).

배선
- `_title_block_fields` 가 시행청·과업책임자·분야별책임자·설계자(배정 없으면 소유자)와
  로고·서명 data URL 까지 실어 보냄.
- `read_stored_asset()` 신설 — `storage/` 기준 상대 경로의 **파일**을 읽음.
  `resolve_stored_project_path()` 는 폴더를 만드는 프로젝트 루트용이라 파일에 못 씀.

CAD 결함 1건 (이번 작업에서 드러남)
- `screenCanvas.drawController.drawImage` 가 `(width, height)` 를 좌표처럼 변환해
  화면 오프셋과 y 뒤집기가 섞여 들어갔음 — 그림이 제 자리를 벗어나고 비율이 무너짐.
  크기는 배율만 곱하고 자리는 세계 중심으로 잡도록 고침(SVG 컨트롤러와 같은 방식).
- 검증 창구 `__aisloCad.screen(x, y)` 추가 — 세계→화면 좌표. 그림·글자가 제 자리에
  그려졌는지 픽셀로 판정할 때 씀.

검증: `pytest tmp/tests/ -q` 135 passed / 0 failed(그림 자리 3건 신규),
`npx vitest run` 87 passed / 0 failed, `check-types`·`build` 통과.
화면 실측(5174, wdw): 표준도 API Text 24개 `{{` 잔존 0, Image 2개가 도각 좌표
(316,20)-(348,36)·(638,18)-(680,26)에 실림. 캔버스 픽셀 판정 — 두 자리 모두 배경색
외 픽셀이 그려짐(로고 88px·서명 64px), 로고 파랑(20,70,140)이 슬롯 안에서만 검출.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-02 08:35:25 +09:00
co-authored by Claude Opus 5
parent 23fea9846b
commit b1593bce83
8 changed files with 200 additions and 31 deletions
+42 -8
View File
@@ -3,7 +3,8 @@
import asyncio
import logging
import re
from pathlib import Path
from base64 import b64encode
from pathlib import Path, PurePosixPath
from typing import Any
from uuid import UUID
@@ -54,7 +55,7 @@ from B07_DesignDetail.B07_DesignDetail_Schema import (
FrameTemplateSaveResponse,
)
from common_util.common_util_drainage_context import load_drainage_context
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_storage import read_stored_asset, resolve_stored_project_path
from common_util.common_util_workflow_state import complete_stage, start_stage
from config.config_db import get_db_pool
@@ -97,27 +98,50 @@ async def _company_dir(project_id: UUID) -> Path:
return root.parent.parent
def _asset_data_url(relative_path: str | None) -> str:
"""회사 로고·개인 서명 파일을 CAD `ImageEntity`가 읽는 data URL로 만든다.
파일이 없거나 경로가 수상하면 빈 문자열 — 그 자리는 그림째 빠진다
(`_fill_placeholders`). 그림은 DB에 경로만 담는 기존 방식 그대로다.
"""
blob = read_stored_asset(relative_path)
if not blob:
return ""
suffix = PurePosixPath(str(relative_path)).suffix.lower()
mime = "image/svg+xml" if suffix == ".svg" else f"image/{suffix.lstrip('.') or 'png'}"
return f"data:{mime};base64,{b64encode(blob).decode('ascii')}"
async def _title_block_fields(project_id: UUID) -> dict[str, str]:
"""도각 표제란에 채울 값. **DB가 아는 것만** 담고 나머지는 담지 않는다.
담지 않은 자리는 `_fill_placeholders`가 빈칸으로 지운다 — 도각 원본에 남의 값이
박혀 있어도 도면에는 나가지 않는다(2026-08-31 사용자 확정, 이것이 1순위 목적).
사람 배정(과업책임자·분야별책임자·설계자)은 `projects`의 FK를 따라간다. 설계자는
배정이 없으면 프로젝트 소유자로 떨어진다 — 혼자 쓰는 계정에서도 칸이 차게.
아직 못 채우는 자리와 이유:
- 시행청·과업책임자·분야별책임자 — `projects`에 칸이 없다. B02 등록 화면과
마이그레이션이 서야 채워진다(공유 DB 변경이라 사용자 확정 대기).
- 축척(A1/A3)·사업량·연도기번 — 값을 지어내지 않는다(임의 수치 금지).
- 설계일자 — "확정일"인데 도각은 **확정 전**에 그려져 저장본에 굳는다.
채울 시점 정의가 미결이라 비워 둔다.
- 도면번호 — 단건 조회가 목록 순서를 모른다(목록을 다시 만들면 도면을 열 때마다
횡단 장 계획을 재계산하게 된다).
"""
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
await cursor.execute(
"""
SELECT p.name, p.region, c.name, u.name
SELECT p.name, p.region, p.client_org, c.name, c.logo_path,
COALESCE(designer.name, owner.name),
COALESCE(designer.signature_path, owner.signature_path),
pm.name, lead.name
FROM projects p
LEFT JOIN companies c ON c.id = p.company_id
LEFT JOIN users u ON u.id = p.user_id
LEFT JOIN users owner ON owner.id = p.user_id
LEFT JOIN users designer ON designer.id = p.designer_user_id
LEFT JOIN users pm ON pm.id = p.pm_user_id
LEFT JOIN users lead ON lead.id = p.field_lead_user_id
WHERE p.id = %s AND p.deleted_at IS NULL
""",
(str(project_id),),
@@ -125,8 +149,18 @@ async def _title_block_fields(project_id: UUID) -> dict[str, str]:
row = await cursor.fetchone()
if not row:
return {}
name, region, company_name, designer = row
fields = {"공사명": name, "위치": region, "용역회사": company_name, "설계자": designer}
name, region, client_org, company, logo_path, designer, signature_path, pm, lead = row
fields = {
"공사명": name,
"위치": region,
"시행청": client_org,
"용역회사": company,
"설계자": designer,
"과업책임자": pm,
"분야별책임자": lead,
"회사로고": _asset_data_url(logo_path),
"설계자서명": _asset_data_url(signature_path),
}
return {key: str(value) for key, value in fields.items() if value}