feat(B01,B07): 설계일자 사용자 지정 입력 + 도면번호 자동 부여

사용자 확정(2026-09-02): 설계일자는 확정일 자동이 아니라 **사용자가 넣는다**. 도면번호는
Claude 판단으로 목록 순번을 쓴다.

설계일자
- `013_company_assets.sql` 로 `projects.design_date DATE` 추가(같은 마이그레이션에
  로고·서명 공유 테이블 자리도 함께 만들었음 — 배선은 다음 커밋).
- B01 프로젝트 수정 모달에 날짜 칸. 공용 `createInputField` 가 `type="date"` 를 받게 넓힘.
- 표제란에는 도면 표기 관행대로 `2026. 09. 02.` 꼴로 적는다.

도면번호
- 단건 조회는 목록 순서를 모른다 — 알려면 횡단 장 계획을 다시 계산해야 하고 도면을 열
  때마다 그러면 비싸다. **목록이 매긴 번호를 manifest 에 적어 두고** 단건 조회가 읽는다.
  목록은 화면 진입 때 늘 먼저 뜨므로 성립하고, 목록이 바뀌면 다음 조회에서 다시 적힌다.
- `add_title_fields()` 신설 — 도면을 읽는 도중에 아는 값을 문맥에 얹는 창구.

검증: `pytest tmp/tests/ -q` 139 passed / 0 failed, 루트 `tsc --noEmit` 통과.
실사용자 경로(5174, wdw) — 대시보드 [수정] 모달 라벨 10개(설계일자 칸 추가 확인) →
`2026-09-02` 입력 → [확인] → 종단면도 표제란에 `2026. 09. 02.`.
도면번호는 목록 13건 기준 종단 1 · 유역도 6 · 용지도 13 이 도각에 그대로 실림
(표지는 도각을 두르지 않아 번호 칸이 없다).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-02 11:38:50 +09:00
co-authored by Claude Opus 5
parent 56aed2f2aa
commit 313a7dde43
9 changed files with 97 additions and 11 deletions
+2
View File
@@ -47,6 +47,7 @@ export interface ProjectItem {
client_org?: string | null;
project_number?: string | null;
work_amount?: string | null;
design_date?: string | null;
owner_name?: string | null;
workflow_stage: number;
progress_percent: number;
@@ -139,6 +140,7 @@ export interface UpdateProjectRequest {
client_org?: string | null;
project_number?: string | null;
work_amount?: string | null;
design_date?: string | null;
}
export interface AdminUpdateUserRequest extends UpdateUserRequest {
+7 -5
View File
@@ -149,7 +149,7 @@ async def list_user_projects(user_id: int) -> list[dict[str, Any]]:
await cursor.execute(
"""SELECT id, company_id, name, region, road_type, project_year,
estimated_length_m, memo, status, updated_at, created_at,
client_org, project_number, work_amount
client_org, project_number, work_amount, design_date
FROM projects WHERE user_id = %s AND deleted_at IS NULL
ORDER BY updated_at DESC, created_at DESC""",
(user_id,),
@@ -171,7 +171,7 @@ async def list_company_projects(company_id: int) -> list[dict[str, Any]]:
await cursor.execute(
"""SELECT p.id, p.company_id, p.name, p.region, p.road_type, p.project_year,
p.estimated_length_m, p.memo, p.status, p.updated_at, p.created_at,
p.client_org, p.project_number, p.work_amount,
p.client_org, p.project_number, p.work_amount, p.design_date,
u.name AS owner_name, u.email AS owner_email
FROM projects p LEFT JOIN users u ON u.id = p.user_id
WHERE p.company_id = %s AND p.deleted_at IS NULL
@@ -195,7 +195,7 @@ async def list_all_projects() -> list[dict[str, Any]]:
await cursor.execute(
"""SELECT p.id, p.company_id, p.name, p.region, p.road_type, p.project_year,
p.estimated_length_m, p.memo, p.status, p.updated_at, p.created_at,
p.client_org, p.project_number, p.work_amount,
p.client_org, p.project_number, p.work_amount, p.design_date,
u.name AS owner_name, u.email AS owner_email
FROM projects p LEFT JOIN users u ON u.id = p.user_id
WHERE p.deleted_at IS NULL
@@ -218,7 +218,7 @@ async def get_project(project_id: str) -> dict[str, Any] | None:
await cursor.execute(
"""SELECT id, user_id, company_id, name, region, road_type, project_year,
estimated_length_m, memo, status,
client_org, project_number, work_amount
client_org, project_number, work_amount, design_date
FROM projects WHERE id = %s AND deleted_at IS NULL""",
(project_id,),
)
@@ -233,7 +233,8 @@ async def update_project(project_id: str, data: dict[str, Any], actor_id: int) -
"""UPDATE projects
SET name = %s, region = %s, road_type = %s, project_year = %s,
estimated_length_m = %s, memo = %s, status = COALESCE(%s, status),
client_org = %s, project_number = %s, work_amount = %s
client_org = %s, project_number = %s, work_amount = %s,
design_date = %s
WHERE id = %s AND deleted_at IS NULL""",
(
data["name"],
@@ -246,6 +247,7 @@ async def update_project(project_id: str, data: dict[str, Any], actor_id: int) -
data.get("client_org"),
data.get("project_number"),
data.get("work_amount"),
data.get("design_date"),
project_id,
),
)
+4
View File
@@ -1,5 +1,7 @@
"""B01_Dashboard 요청 스키마."""
from datetime import date
from pydantic import BaseModel, Field
@@ -50,6 +52,8 @@ class UpdateProjectRequest(BaseModel):
client_org: str | None = Field(default=None, max_length=255)
project_number: str | None = Field(default=None, max_length=100)
work_amount: str | None = Field(default=None, max_length=100)
# 설계일자는 확정일 자동이 아니라 사용자 지정이다 (2026-09-02 사용자 확정).
design_date: date | None = None
class AdminUpdateUserRequest(UpdateUserRequest):
+9
View File
@@ -105,6 +105,12 @@ export function openEditProjectModal(user: DashboardUser, project: ProjectItem):
value: project.work_amount ?? "",
placeholder: "예: L=2.14km",
});
// 설계일자는 확정일 자동이 아니라 사용자가 지정한다 (2026-09-02 사용자 확정).
const designDate = createInputField({
label: "설계일자 (도면 표제란)",
type: "date",
value: (project.design_date ?? "").slice(0, 10),
});
if (isUserOnly) {
name.input.disabled = true;
@@ -116,6 +122,7 @@ export function openEditProjectModal(user: DashboardUser, project: ProjectItem):
clientOrg.input.disabled = true;
projectNumber.input.disabled = true;
workAmount.input.disabled = true;
designDate.input.disabled = true;
}
openModal(
@@ -130,6 +137,7 @@ export function openEditProjectModal(user: DashboardUser, project: ProjectItem):
clientOrg.root,
projectNumber.root,
workAmount.root,
designDate.root,
],
async () => {
await updateProject(project.id, {
@@ -143,6 +151,7 @@ export function openEditProjectModal(user: DashboardUser, project: ProjectItem):
client_org: clientOrg.input.value.trim() || null,
project_number: projectNumber.input.value.trim() || null,
work_amount: workAmount.input.value.trim() || null,
design_date: designDate.input.value || null,
});
showToast(L("B01_Dashboard_Saved"), "success");
},
@@ -62,6 +62,15 @@ def use_title_fields(fields: dict[str, str] | None) -> None:
_title_fields.set(fields or {})
def add_title_fields(extra: dict[str, str]) -> None:
"""이미 세운 표제란 값에 몇 개를 덧붙인다.
도면번호처럼 **도면을 읽는 도중에야 아는 값**을 위해서다. 라우터가 DB 값을 먼저 세우고,
작도 스레드가 여기서 나머지를 얹는다(`asyncio.to_thread` 가 문맥을 복사한다).
"""
_title_fields.set({**_title_fields.get(), **(extra or {})})
def company_template_path(company_dir: Path, name: str = A1_TEMPLATE) -> Path:
"""회사 도각 파일 경로(없을 수도 있다)."""
return Path(company_dir) / COMPANY_TEMPLATE_SUBDIR / f"{name}.json"
+7 -5
View File
@@ -123,17 +123,16 @@ async def _title_block_fields(project_id: UUID) -> dict[str, str]:
아직 채우는 자리와 이유:
- 사업량·연도기번은 사람이 넣는 값이다(B01 프로젝트 수정 화면). 비어 있으면 빈칸.
- 설계일자 "확정일"인데 도각은 **확정 ** 그려져 저장본에 굳는다.
채울 시점 정의가 미결이라 비워 둔다.
- 도면번호 단건 조회가 목록 순서를 모른다(목록을 다시 만들면 도면을 때마다
횡단 계획을 재계산하게 된다).
- 설계일자·도면번호는 여기서 담지 않는다 설계일자는 사용자가 넣은 `design_date`
그대로 쓰고(확정일 자동이 아니다), 도면번호는 도면을 읽는 쪽이 manifest
적힌 목록 순번을 얹는다.
"""
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
await cursor.execute(
"""
SELECT p.name, p.region, p.client_org, p.project_number, p.work_amount,
c.name, c.logo_path,
p.design_date, c.name, c.logo_path,
COALESCE(designer.name, owner.name),
COALESCE(designer.signature_path, owner.signature_path),
pm.name, lead.name
@@ -156,6 +155,7 @@ async def _title_block_fields(project_id: UUID) -> dict[str, str]:
client_org,
project_number,
work_amount,
design_date,
company,
logo_path,
designer,
@@ -169,6 +169,8 @@ async def _title_block_fields(project_id: UUID) -> dict[str, str]:
"시행청": client_org,
"연도기번": project_number,
"사업량": work_amount,
# 도면 표기 관행대로 "2026. 09. 02." 꼴로 적는다.
"설계일자": f"{design_date:%Y. %m. %d.}" if design_date else None,
"용역회사": company,
"설계자": designer,
"과업책임자": pm,
@@ -40,6 +40,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import (
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import (
QUANTITY_VALUE_KEYS,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Template import add_title_fields
from B07_DesignDetail.B07_DesignDetail_Schema import (
DesignDrawingItem,
)
@@ -417,9 +418,29 @@ def _drawing_list(
# 목록의 절반이 눌리지 않는 회색 버튼이면 고장난 것처럼 보인다.
for drawing_id, label in BLANK_DRAWINGS:
drawings.append(DesignDrawingItem(id=drawing_id, kind="blank", label=label))
_store_drawing_numbers(project_root, drawings)
return drawings
def _store_drawing_numbers(project_root: Path, drawings: list[DesignDrawingItem]) -> None:
"""도면번호(목록 순번)를 manifest 에 적어 둔다.
단건 조회는 목록 순서를 모른다 알려면 횡단 계획을 다시 계산해야 하고, 그것을 도면
때마다 하면 비싸다. 목록은 화면에 들어올 먼저 뜨므로, 그때 매긴 번호를 적어
두고 단건 조회는 그것을 읽는다. 목록이 바뀌면 다음 조회에서 다시 적힌다.
"""
manifest = _read_manifest(project_root)
entries = manifest["drawings"]
changed = False
for number, item in enumerate(drawings, start=1):
entry = entries.setdefault(item.id, {})
if entry.get("number") != number:
entry["number"] = number
changed = True
if changed:
_write_manifest(project_root, manifest)
def _cross_sheet_plan(
project_root: Path,
longitudinal_path: Path,
@@ -590,6 +611,9 @@ def _read_drawing(
stored_design(없으면 기본값)에서 만든다.
"""
manifest_entry = _read_manifest(project_root)["drawings"].get(drawing_id, {})
number = manifest_entry.get("number")
if isinstance(number, int):
add_title_fields({"도면번호": str(number)})
saved_path = _design_root(project_root) / "drawings" / f"{drawing_id}.json"
if manifest_entry.get("confirmed") and saved_path.is_file():
saved = _read_json(saved_path)
+34
View File
@@ -0,0 +1,34 @@
-- 013_company_assets.sql
-- 설계일자·도면 자산(로고·서명) 회사 공유 (2026-09-02 사용자 확정)
--
-- ① 설계일자는 확정일 자동이 아니라 **사용자가 지정 입력**한다.
-- ② 로고·서명은 **회사 안에서 공유하는 목록**으로 관리한다. 사용자 계정에 물릴 수도 있고
-- 아닐 수도 있다(회사 공용 직인 등). 회사 구성원과 회사 관리자가 작성·수정하고,
-- 시스템 관리자는 전체 권한을 가진다.
-- ③ 도면이 어느 로고·서명을 쓸지는 **프로젝트가 고른다** — 목록에서 골라 물린다.
--
-- 011 의 `companies.logo_path`·`users.signature_path` 는 이 표가 정본이 되면서 읽지 않는다.
-- 컬럼은 남겨 둔다(4환경 공유 DB 라 지우는 쪽이 위험하다).
USE aislo_db;
ALTER TABLE projects
ADD COLUMN IF NOT EXISTS design_date DATE NULL COMMENT '설계일자 (사용자 지정)' AFTER work_amount,
ADD COLUMN IF NOT EXISTS logo_asset_id INT NULL COMMENT '도면에 쓸 회사 로고 (company_assets.id)',
ADD COLUMN IF NOT EXISTS signature_asset_id INT NULL COMMENT '도면에 쓸 서명 (company_assets.id)';
-- 회사 안에서 공유하는 도면 자산. 그림은 파일로 두고 경로만 담는다.
CREATE TABLE IF NOT EXISTS company_assets (
id INT AUTO_INCREMENT PRIMARY KEY,
company_id INT NOT NULL,
kind ENUM('LOGO', 'SIGNATURE') NOT NULL,
label VARCHAR(100) NOT NULL COMMENT '목록에 보이는 이름',
file_path VARCHAR(500) NOT NULL COMMENT 'storage/ 기준 상대 경로',
user_id INT NULL COMMENT '이 자산의 주인 (없으면 회사 공용)',
created_by INT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL DEFAULT NULL,
INDEX idx_company_assets_company (company_id, kind, deleted_at),
INDEX idx_company_assets_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+1 -1
View File
@@ -82,7 +82,7 @@ export interface InputFieldOptions {
label?: string;
/** placeholder (i18n 결과) */
placeholder?: string;
type?: "text" | "password" | "email" | "number" | "search" | "tel";
type?: "text" | "password" | "email" | "number" | "search" | "tel" | "date";
value?: string;
required?: boolean;
/** number 타입 범위 (1차 유효성 검사용, frontend.md §4) */