feat(560-1): D01 생성 칸 여섯 걷음 · 이름 자동 「<시작 연도> <사업 지역> 노선 계획」 · 임도 종류 필수 풂 · D04 새 임도 이름 연도 한 번

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 20:00:41 +09:00
co-authored by Claude Opus 5.5
parent 6d4964ae55
commit 9ec63ac4f5
6 changed files with 36 additions and 99 deletions
+6 -2
View File
@@ -1,4 +1,8 @@
"""D01 예정노선 등록 API · 대시보드 「받은 예정노선」 (요구 345 · 요구서 3-1 · 2-2)."""
"""D01 예정노선 등록 API · 대시보드 「받은 예정노선」 (요구 345 · 요구서 3-1 · 2-2).
화면은 이름 · 시작 연도 · 사업 지역만 보냄(요구서 389 3장 1 · 3) — 끝 연도 = 시작 · 임도 종류 없음
= 설계속도 작업임도 20 · 측점 없음(DB 칸 그대로).
"""
from typing import Any
@@ -21,7 +25,7 @@ class CreatePlanRequest(BaseModel):
project_year: int = Field(..., ge=2000, le=2100)
project_year_end: int | None = Field(default=None, ge=2000, le=2100)
region: str = Field(..., min_length=1, max_length=100)
road_type: str = Field(..., pattern="^(main|fire|work)$")
road_type: str | None = Field(default=None, pattern="^(main|fire|work)$")
design_speed_kph: int | None = None
start_chainage_m: float | None = Field(default=None, ge=0)
end_chainage_m: float | None = Field(default=None, ge=0)
+10 -89
View File
@@ -1,51 +1,25 @@
/* =============================================================================
* D01_PlanRegister_UI_Page.ts
* D01 예정노선 등록(요구서 345 3-1 · PLAN 529-1) — B02 등록 화면 꼴 · 도면 칸 없음.
* 칸 = 사업연도 시작 · 끝 · 사업지역 · 종류 「노선 계획」(고정) · 임도 종류(B02 값) · 설계속도
* (종류별 고를 수 있는 값 · 기본 = 가장 낮은 값 = B05 resolve_design_speed) · 프로젝트명(B02 이름
* 조합기 · 연도 자리 = 「시작~끝」) · 노선 시작 · 종료 누가거리.
* D01 예정노선 등록(요구서 345 3-1 · 389 3장 1~3 · PLAN 560-1) — B02 등록 화면 꼴 · 도면 칸 없음.
* 칸 = 사업연도(시작) · 사업지역 · 프로젝트명(B02 이름 조합기 · 「<시작 연도> <사업 지역> 노선 계획」)
* · 사업연도 끝 · 임도 종류 · 설계속도 · 측점은 안 받음(끝 연도 = D03 [저장] · 나머지 = 서버 기본).
* [등록] = POST /api/d01/project → 현재 프로젝트 · D02 로.
* ========================================================================== */
import { ROUTES } from "@config/config_frontend";
import {
createButton,
createCard,
createInputField,
createSelectField,
} from "@ui/ui_template_elements";
import { createButton, createCard, createInputField } from "@ui/ui_template_elements";
import { createFormGrid, createGeneralLayout } from "@ui/ui_template_general_layout";
import { createHelpButton } from "@ui/ui_template_help";
import { createText } from "@ui/ui_template_text";
import { t } from "@ui/ui_template_locale";
import { stationLabel } from "../common_util/common_util_station";
import { createGroupProject } from "../A00_Common/group_workflow";
import { navigateTo } from "../A00_Common/router";
import { createNameComposer } from "../B02_ProjRegister/B02_ProjRegister_UI_Name";
import { SPEED_CHOICES } from "../B05_Profile/logic/B05_Profile_Grade_Criteria";
/** B02 임도 종류 → 설계속도 기준 등급(B05 grade_class_of 와 같음) */
const GRADE_CLASS: Record<string, string> = { main: "trunk", fire: "fire", work: "work" };
/** 측점 간격 — D03 `PLAN_STATION_INTERVAL_M` 와 같음 */
const STATION_INTERVAL_M = 20;
const numberOrNull = (text: string): number | null =>
text.trim() === "" ? null : Number.parseFloat(text);
export function renderD01PlanRegister(root: HTMLElement): void {
const year = new Date().getFullYear();
const yearStart = createInputField({
label: t("DE_Reg_YearStart"),
type: "number",
value: String(year),
min: 2000,
max: 2100,
});
const yearEnd = createInputField({
label: t("DE_Reg_YearEnd"),
type: "number",
value: String(year),
value: String(new Date().getFullYear()),
min: 2000,
max: 2100,
});
@@ -54,75 +28,29 @@ export function renderD01PlanRegister(root: HTMLElement): void {
placeholder: t("B02_Proj_Field_Region_Placeholder"),
required: true,
});
const kind = createInputField({ label: t("DE_Reg_Kind"), value: t("DE_Reg_KindPlan") });
kind.input.readOnly = true;
const roadType = createSelectField({
label: t("B02_Proj_Field_RoadType"),
options: [
{ value: "main", text: t("B02_Proj_RoadType_Main") },
{ value: "fire", text: t("B02_Proj_RoadType_Fire") },
{ value: "work", text: t("B02_Proj_RoadType_Work") },
],
});
const speed = createSelectField({ label: t("DE_Reg_DesignSpeed"), options: [] });
const name = createInputField({ label: t("DE_Reg_Name"), required: true });
const startM = createInputField({ label: t("DE_Reg_StartM"), type: "number", min: 0 });
const endM = createInputField({ label: t("DE_Reg_EndM"), type: "number", min: 0 });
// 누가거리 칸 옆 즉시 측점 표기(저장값 · 서버 검사는 m 그대로)
for (const field of [startM, endM]) {
const shown = createText("", "muted");
shown.dataset.station = "";
field.root.insertBefore(shown, field.root.lastElementChild);
field.input.addEventListener("input", () => {
const value = numberOrNull(field.input.value);
shown.textContent =
value !== null && Number.isFinite(value) && value >= 0
? `= ${stationLabel(value, STATION_INTERVAL_M, 1)}`
: "";
});
}
// 설계속도 = 종류별 고를 수 있는 값 · 기본 = 가장 낮은 값(20)
const syncSpeeds = (): void => {
const choices = SPEED_CHOICES[GRADE_CLASS[roadType.select.value]] ?? [20];
speed.setOptions(
choices.map((kph) => ({ value: String(kph), text: `${kph} km/h` })),
String(Math.min(...choices)),
);
};
// 이름 = B02 조합기 그대로(손대기 전 = 자동 · 고친 뒤 = 조각만 갈아 끼움)
const composer = createNameComposer();
const years = (): string => {
const [a, b] = [yearStart.input.value.trim(), yearEnd.input.value.trim()];
return b && b !== a ? `${a}~${b}` : a;
};
const syncName = (): void => {
name.input.value = composer.update({
year: years(),
year: yearStart.input.value.trim(),
region: region.input.value,
type: roadType.select.selectedOptions[0]?.textContent ?? "",
type: t("DE_Reg_KindPlan"),
});
};
name.input.addEventListener("input", () => composer.edit(name.input.value));
for (const field of [yearStart, yearEnd, region]) field.input.addEventListener("input", syncName);
roadType.select.addEventListener("change", () => (syncSpeeds(), syncName()));
syncSpeeds();
for (const field of [yearStart, region]) field.input.addEventListener("input", syncName);
syncName();
const submit = (): void => {
const fields = [yearStart, yearEnd, region, name, startM, endM];
const fields = [yearStart, region, name];
fields.forEach((field) => field.setError());
const y0 = Number.parseInt(yearStart.input.value, 10);
const y1 = Number.parseInt(yearEnd.input.value, 10);
const [s, e] = [numberOrNull(startM.input.value), numberOrNull(endM.input.value)];
const errors: Array<[(typeof fields)[number], string | false]> = [
[yearStart, !(y0 >= 2000 && y0 <= 2100) && t("Common_Validation_NumberRange")],
[yearEnd, !(y1 >= y0 && y1 <= 2100) && t("DE_Reg_YearOrder")],
[region, !region.input.value.trim() && t("B02_Proj_Error_Required")],
[name, !name.input.value.trim() && t("B02_Proj_Error_Required")],
[startM, s !== null && !(s >= 0) && t("Common_Validation_NumberRange")],
[endM, e !== null && s !== null && !(e > s) && t("DE_Reg_ChainOrder")],
];
const bad = errors.filter(([, message]) => message);
bad.forEach(([field, message]) => field.setError(message as string));
@@ -132,20 +60,13 @@ export function renderD01PlanRegister(root: HTMLElement): void {
{
project_name: name.input.value.trim(),
project_year: y0,
project_year_end: y1,
region: region.input.value.trim(),
road_type: roadType.select.value,
design_speed_kph: Number(speed.select.value),
start_chainage_m: s,
end_chainage_m: e,
},
ROUTES.D02_PLAN_BASE,
);
};
const grid = createFormGrid(
[yearStart, yearEnd, region, kind, roadType, speed, name, startM, endM].map((f) => f.root),
);
const grid = createFormGrid([yearStart, region, name].map((f) => f.root));
const actions = document.createElement("div");
actions.className = "ui-general-block__actions ui-general-block__actions--end";
actions.append(
+4 -1
View File
@@ -15,6 +15,7 @@ from __future__ import annotations
import asyncio
import hashlib
import json
import re
import shutil
import tempfile
from pathlib import Path
@@ -250,10 +251,12 @@ async def import_route_files(
async def _new_road_project(assignee: dict[str, Any], session: dict[str, Any]) -> str:
"""예정노선 이름 · 지역 · 임도 종류 · 그 연도로 임도 프로젝트(B02 등록과 같은 함수)."""
body = _parsed(assignee["route_json"]) or {}
# 「2026 홍천 노선 계획」 꼴 = 앞 연도를 그 연도로 갈아 끼움(연도 두 번 안 붙음) · 그 밖 = 뒤에 연도
name, dated = re.subn(r"^\d{4}(?:~\d{4})?(?=\s)", str(assignee["year"]), assignee["name"])
created = await create_project(
user_id=int(session["user_id"]),
company_id=int(session["company_id"]),
name=f"{assignee['name']} {assignee['year']}",
name=name if dated else f"{assignee['name']} {assignee['year']}",
region=assignee["region"] or "",
road_type=assignee["road_type"] or "work",
project_year=int(assignee["year"]),
@@ -124,6 +124,8 @@ def test_d01_plan_created(monkeypatch):
assert seen["design_speed_kph"] == 40
asyncio.run(d01.post_plan_project(None, _plan(road_type="work", design_speed_kph=40), ADMIN))
assert seen["design_speed_kph"] == 20 # 작업임도 = 20 만
asyncio.run(d01.post_plan_project(None, _plan(road_type=None), ADMIN))
assert seen["road_type"] is None and seen["design_speed_kph"] == 20 # 560-1 임도 종류 안 받음
with pytest.raises(HTTPException) as e:
asyncio.run(d01.post_plan_project(None, _plan(project_year_end=2025), FOREST))
assert e.value.status_code == 400
+14
View File
@@ -264,6 +264,20 @@ def test_new_mode_creates_road_project(env, monkeypatch):
assert made["company_id"] == 5 and len(env["rows"]) == 5
def test_new_mode_name_year_once(env, monkeypatch):
"""560-1 — 「<시작 연도> <지역> 노선 계획」 이름 = 앞 연도를 그 연도로(두 번 안 붙음)."""
made = {}
async def create_project(**kw):
made.update(kw)
return {"project_id": ROAD_ID}
monkeypatch.setattr(link, "create_project", create_project)
_use(monkeypatch, _assignee(name="2025 홍천 노선 계획"))
_link(mode="new", project_id=None)
assert made["name"] == "2026 홍천 노선 계획"
def _stage2_writes(log):
return [
args
-7
View File
@@ -16,13 +16,6 @@ def _no_hex(path: str) -> None:
assert not re.search(r"#[0-9a-fA-F]{3,8}\b", css), f"{path}: 날 색"
def test_d01_station_label_is_text_part():
ts = _src("D01_PlanRegister/D01_PlanRegister_UI_Page.ts")
assert "style.cssText" not in ts
assert 'createText("", "muted")' in ts
assert 'dataset.station = ""' in ts # 시험 · 촬영이 잡음
def test_d02_parts():
ts = _src("D02_PlanBase/D02_PlanBase_UI_Page.ts")
css = _src("D02_PlanBase/D02_PlanBase_UI_Style.css")