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; client_org?: string | null;
project_number?: string | null; project_number?: string | null;
work_amount?: string | null; work_amount?: string | null;
design_date?: string | null;
owner_name?: string | null; owner_name?: string | null;
workflow_stage: number; workflow_stage: number;
progress_percent: number; progress_percent: number;
@@ -139,6 +140,7 @@ export interface UpdateProjectRequest {
client_org?: string | null; client_org?: string | null;
project_number?: string | null; project_number?: string | null;
work_amount?: string | null; work_amount?: string | null;
design_date?: string | null;
} }
export interface AdminUpdateUserRequest extends UpdateUserRequest { 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( await cursor.execute(
"""SELECT id, company_id, name, region, road_type, project_year, """SELECT id, company_id, name, region, road_type, project_year,
estimated_length_m, memo, status, updated_at, created_at, 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 FROM projects WHERE user_id = %s AND deleted_at IS NULL
ORDER BY updated_at DESC, created_at DESC""", ORDER BY updated_at DESC, created_at DESC""",
(user_id,), (user_id,),
@@ -171,7 +171,7 @@ async def list_company_projects(company_id: int) -> list[dict[str, Any]]:
await cursor.execute( await cursor.execute(
"""SELECT p.id, p.company_id, p.name, p.region, p.road_type, p.project_year, """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.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 u.name AS owner_name, u.email AS owner_email
FROM projects p LEFT JOIN users u ON u.id = p.user_id FROM projects p LEFT JOIN users u ON u.id = p.user_id
WHERE p.company_id = %s AND p.deleted_at IS NULL 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( await cursor.execute(
"""SELECT p.id, p.company_id, p.name, p.region, p.road_type, p.project_year, """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.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 u.name AS owner_name, u.email AS owner_email
FROM projects p LEFT JOIN users u ON u.id = p.user_id FROM projects p LEFT JOIN users u ON u.id = p.user_id
WHERE p.deleted_at IS NULL WHERE p.deleted_at IS NULL
@@ -218,7 +218,7 @@ async def get_project(project_id: str) -> dict[str, Any] | None:
await cursor.execute( await cursor.execute(
"""SELECT id, user_id, company_id, name, region, road_type, project_year, """SELECT id, user_id, company_id, name, region, road_type, project_year,
estimated_length_m, memo, status, 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""", FROM projects WHERE id = %s AND deleted_at IS NULL""",
(project_id,), (project_id,),
) )
@@ -233,7 +233,8 @@ async def update_project(project_id: str, data: dict[str, Any], actor_id: int) -
"""UPDATE projects """UPDATE projects
SET name = %s, region = %s, road_type = %s, project_year = %s, SET name = %s, region = %s, road_type = %s, project_year = %s,
estimated_length_m = %s, memo = %s, status = COALESCE(%s, status), 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""", WHERE id = %s AND deleted_at IS NULL""",
( (
data["name"], 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("client_org"),
data.get("project_number"), data.get("project_number"),
data.get("work_amount"), data.get("work_amount"),
data.get("design_date"),
project_id, project_id,
), ),
) )
+4
View File
@@ -1,5 +1,7 @@
"""B01_Dashboard 요청 스키마.""" """B01_Dashboard 요청 스키마."""
from datetime import date
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -50,6 +52,8 @@ class UpdateProjectRequest(BaseModel):
client_org: str | None = Field(default=None, max_length=255) client_org: str | None = Field(default=None, max_length=255)
project_number: str | None = Field(default=None, max_length=100) project_number: str | None = Field(default=None, max_length=100)
work_amount: 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): class AdminUpdateUserRequest(UpdateUserRequest):
+9
View File
@@ -105,6 +105,12 @@ export function openEditProjectModal(user: DashboardUser, project: ProjectItem):
value: project.work_amount ?? "", value: project.work_amount ?? "",
placeholder: "예: L=2.14km", placeholder: "예: L=2.14km",
}); });
// 설계일자는 확정일 자동이 아니라 사용자가 지정한다 (2026-09-02 사용자 확정).
const designDate = createInputField({
label: "설계일자 (도면 표제란)",
type: "date",
value: (project.design_date ?? "").slice(0, 10),
});
if (isUserOnly) { if (isUserOnly) {
name.input.disabled = true; name.input.disabled = true;
@@ -116,6 +122,7 @@ export function openEditProjectModal(user: DashboardUser, project: ProjectItem):
clientOrg.input.disabled = true; clientOrg.input.disabled = true;
projectNumber.input.disabled = true; projectNumber.input.disabled = true;
workAmount.input.disabled = true; workAmount.input.disabled = true;
designDate.input.disabled = true;
} }
openModal( openModal(
@@ -130,6 +137,7 @@ export function openEditProjectModal(user: DashboardUser, project: ProjectItem):
clientOrg.root, clientOrg.root,
projectNumber.root, projectNumber.root,
workAmount.root, workAmount.root,
designDate.root,
], ],
async () => { async () => {
await updateProject(project.id, { await updateProject(project.id, {
@@ -143,6 +151,7 @@ export function openEditProjectModal(user: DashboardUser, project: ProjectItem):
client_org: clientOrg.input.value.trim() || null, client_org: clientOrg.input.value.trim() || null,
project_number: projectNumber.input.value.trim() || null, project_number: projectNumber.input.value.trim() || null,
work_amount: workAmount.input.value.trim() || null, work_amount: workAmount.input.value.trim() || null,
design_date: designDate.input.value || null,
}); });
showToast(L("B01_Dashboard_Saved"), "success"); 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 {}) _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: def company_template_path(company_dir: Path, name: str = A1_TEMPLATE) -> Path:
"""회사 도각 파일 경로(없을 수도 있다).""" """회사 도각 파일 경로(없을 수도 있다)."""
return Path(company_dir) / COMPANY_TEMPLATE_SUBDIR / f"{name}.json" 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 프로젝트 수정 화면). 비어 있으면 빈칸. - 사업량·연도기번은 사람이 넣는 값이다(B01 프로젝트 수정 화면). 비어 있으면 빈칸.
- 설계일자"확정일"인데 도각은 **확정 전**에 그려져 저장본에 굳는다. - 설계일자·도면번호는 여기서 담지 않는다 — 설계일자는 사용자가 넣은 `design_date`
채울 시점 정의가 미결이라 비워 둔다. 를 그대로 쓰고(확정일 자동이 아니다), 도면번호는 도면을 읽는 쪽이 manifest 에
- 도면번호 — 단건 조회가 목록 순서를 모른다(목록을 다시 만들면 도면을 열 때마다 적힌 목록 순번을 얹는다.
횡단 장 계획을 재계산하게 된다).
""" """
pool = get_db_pool() pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor: async with pool.acquire() as connection, connection.cursor() as cursor:
await cursor.execute( await cursor.execute(
""" """
SELECT p.name, p.region, p.client_org, p.project_number, p.work_amount, 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.name, owner.name),
COALESCE(designer.signature_path, owner.signature_path), COALESCE(designer.signature_path, owner.signature_path),
pm.name, lead.name pm.name, lead.name
@@ -156,6 +155,7 @@ async def _title_block_fields(project_id: UUID) -> dict[str, str]:
client_org, client_org,
project_number, project_number,
work_amount, work_amount,
design_date,
company, company,
logo_path, logo_path,
designer, designer,
@@ -169,6 +169,8 @@ async def _title_block_fields(project_id: UUID) -> dict[str, str]:
"시행청": client_org, "시행청": client_org,
"연도기번": project_number, "연도기번": project_number,
"사업량": work_amount, "사업량": work_amount,
# 도면 표기 관행대로 "2026. 09. 02." 꼴로 적는다.
"설계일자": f"{design_date:%Y. %m. %d.}" if design_date else None,
"용역회사": company, "용역회사": company,
"설계자": designer, "설계자": designer,
"과업책임자": pm, "과업책임자": pm,
@@ -40,6 +40,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import (
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import ( from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import (
QUANTITY_VALUE_KEYS, QUANTITY_VALUE_KEYS,
) )
from B07_DesignDetail.B07_DesignDetail_Engine_Template import add_title_fields
from B07_DesignDetail.B07_DesignDetail_Schema import ( from B07_DesignDetail.B07_DesignDetail_Schema import (
DesignDrawingItem, DesignDrawingItem,
) )
@@ -417,9 +418,29 @@ def _drawing_list(
# 목록의 절반이 눌리지 않는 회색 버튼이면 고장난 것처럼 보인다. # 목록의 절반이 눌리지 않는 회색 버튼이면 고장난 것처럼 보인다.
for drawing_id, label in BLANK_DRAWINGS: for drawing_id, label in BLANK_DRAWINGS:
drawings.append(DesignDrawingItem(id=drawing_id, kind="blank", label=label)) drawings.append(DesignDrawingItem(id=drawing_id, kind="blank", label=label))
_store_drawing_numbers(project_root, drawings)
return 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( def _cross_sheet_plan(
project_root: Path, project_root: Path,
longitudinal_path: Path, longitudinal_path: Path,
@@ -590,6 +611,9 @@ def _read_drawing(
stored_design(없으면 기본값)에서 만든다. stored_design(없으면 기본값)에서 만든다.
""" """
manifest_entry = _read_manifest(project_root)["drawings"].get(drawing_id, {}) 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" saved_path = _design_root(project_root) / "drawings" / f"{drawing_id}.json"
if manifest_entry.get("confirmed") and saved_path.is_file(): if manifest_entry.get("confirmed") and saved_path.is_file():
saved = _read_json(saved_path) 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; label?: string;
/** placeholder (i18n 결과) */ /** placeholder (i18n 결과) */
placeholder?: string; placeholder?: string;
type?: "text" | "password" | "email" | "number" | "search" | "tel"; type?: "text" | "password" | "email" | "number" | "search" | "tel" | "date";
value?: string; value?: string;
required?: boolean; required?: boolean;
/** number 타입 범위 (1차 유효성 검사용, frontend.md §4) */ /** number 타입 범위 (1차 유효성 검사용, frontend.md §4) */