diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index 6248f2fd6..4553de7c9 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -175,6 +175,24 @@ export function invalidateDesignDrawing(projectId: string, drawingId: string): P ); } +/** 횡단 장 배치 — 기본 [열, 행] · 장마다 {장 번호: [열, 행]}(140-3). */ +export interface CrossLayout { + default: [number, number]; + sheets: Record; +} + +export function fetchCrossLayout(projectId: string): Promise { + return requestJson(`/projects/${projectId}/cross-layout`); +} + +/** 배치 저장 — 담긴 측점이 바뀐 확정 장은 서버가 풂. */ +export function saveCrossLayout(projectId: string, layout: CrossLayout): Promise { + return requestJson(`/projects/${projectId}/cross-layout`, { + method: "PUT", + body: JSON.stringify(layout), + }); +} + /** 계획평면도 종류 · 고를 조각 · 대피소 자리 (PLAN 60장). */ export interface PlanKind { id: string; @@ -233,6 +251,14 @@ export function updatePlanKind( }); } +/** 종류 차례 — 끌어 놓은 차례 그대로(140-2 · 놓는 즉시 · 번호 바뀐 장은 서버가 확정 풂). */ +export function reorderPlanKinds(projectId: string, ids: string[]): Promise { + return requestJson(`/projects/${projectId}/plan-kinds-order`, { + method: "PUT", + body: JSON.stringify({ ids }), + }); +} + export function deletePlanKind(projectId: string, kindId: string): Promise { return requestJson(`/projects/${projectId}/plan-kinds/${encodeURIComponent(kindId)}`, { method: "DELETE", diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py index aa460b9be..6155e953e 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py @@ -1,9 +1,8 @@ -"""B07 횡단면도 장 배치 — A1 한 장에 들어가는 만큼 단면을 담는다. +"""B07 횡단면도 장 배치 — 장마다 열 × 행 칸(기본 4 × 4)에 단면을 하나씩 담는다(140-3). 채우는 순서는 **좌하단부터 아래에서 위로, 그다음 오른쪽 열**이다 -(2026-08-29 사용자 확정). 한 장에 몇 개가 들어가는지는 단면 블록의 크기가 -정하며, 블록 크기는 작성 척도(1/100)와 "설계선이 원지반과 갈라지는 구간"이 -정한다 — 즉 종이가 허용하는 만큼만 담고 남으면 다음 장으로 넘긴다. +(2026-08-29 사용자 확정). 칸 = 도각 그림칸(usable_area)을 똑같이 나눈 자리 · 단면은 +칸 가운데 · 작성 척도 1/100 그대로(큰 단면이 칸을 넘으면 겹침 — 사용자 배치로 고침). """ import re @@ -95,111 +94,52 @@ def section_block_size( return (width, top + below) -def _stack_columns(group: list[tuple[int | float, float, float]]) -> list[list[int]]: - """블록 순번을 열로 묶는다 — 아래에서 위로 쌓다 높이가 넘치면 오른쪽 새 열.""" - _usable_w, usable_h = usable_area() - columns: list[list[int]] = [] - height = 0.0 - for index, (_c, _w, block_h) in enumerate(group): - if not columns or height + block_h > usable_h + 1e-6: - columns.append([]) - height = 0.0 - columns[-1].append(index) - height += block_h - return columns - - -def _fits(group: list[tuple[int | float, float, float]]) -> bool: - """group 을 한 장에 담을 수 있나 — 열 폭 합이 가로를, 열마다 높이 합이 세로를 넘지 않는다.""" +def grid_slots(n: int, cols: int, rows: int) -> list[list[float]]: + """n 개 칸 가운데 — 그림칸(usable_area)을 열 × 행으로 똑같이 나눔 · 좌하단 → 위 → 오른쪽 열.""" usable_w, usable_h = usable_area() - columns = _stack_columns(group) - width = sum(max(group[i][1] for i in column) for column in columns) - tallest = max(sum(group[i][2] for i in column) for column in columns) - return width <= usable_w + 1e-6 and tallest <= usable_h + 1e-6 + cell_w, cell_h = usable_w / cols, usable_h / rows + return [ + [ + -usable_w / 2.0 + (index // rows + 0.5) * cell_w, + -usable_h / 2.0 + (index % rows + 0.5) * cell_h, + ] + for index in range(n) + ] -def _max_take(blocks: list[tuple[int | float, float, float]], start: int) -> int: - """blocks[start:] 를 한 장에 담을 수 있는 최대 개수.""" - limit = 0 - for take in range(1, len(blocks) - start + 1): - if not _fits(blocks[start : start + take]): - break - limit = take - return limit +def sheet_grid(layout: dict[str, Any] | None, number: int) -> tuple[int, int]: + """장 번호의 (열, 행) — 장마다 값이 없으면 기본(없으면 4 × 4).""" + layout = layout or {} + grid = (layout.get("sheets") or {}).get(str(number)) or layout.get("default") or (4, 4) + return int(grid[0]), int(grid[1]) -def _sheet_breaks(blocks: list[tuple[int, float, float]]) -> list[int]: - """장 경계를 **전체 최소 장수**가 되도록 고른다 (측점 순서는 유지). +def plan_cross_sheets( + chainages: list[int | float], layout: dict[str, Any] | None = None +) -> list[dict[str, Any]]: + """측점 목록을 A1 장으로 나눈다 — 장마다 열 × 행 칸(기본 4 × 4 · 140-3). - 앞에서부터 최대한 채우면 바로 뒤에 큰 단면이 오는 순간 칸이 그 단면 크기로 - 튀어 그 장이 통째로 비었다(2026-09-04 실측: 6칸짜리 장에 1개만 배치). 단면 - 크기는 측점마다 원지반 기울기로 달라지므로, 경계를 뒤에서부터 훑어 최소 장수 - 조합을 고른다 — 큰 단면은 자기 장에 몰리고 비슷한 크기끼리 한 장에 모인다. - 같은 장수면 **앞 장을 더 많이 채우는 쪽**을 고른다(뒷장에 여백을 몰아 준다). - """ - total = len(blocks) - best_sheets = [0] * (total + 1) - best_take = [0] * (total + 1) - for start in range(total - 1, -1, -1): - limit = max(_max_take(blocks, start), 1) - choice = (total + 1, 0) - for take in range(1, limit + 1): - candidate = (best_sheets[start + take] + 1, -take) - if candidate < choice: - choice = candidate - best_sheets[start], best_take[start] = choice[0], -choice[1] - breaks: list[int] = [] - start = 0 - while start < total: - breaks.append(best_take[start]) - start += best_take[start] - return breaks - - -def _slots(group: list[tuple[int | float, float, float]]) -> list[list[float]]: - """블록의 (가로 중심, 아래 변) — 좌하단부터 아래→위로 쌓고, 열이 차면 오른쪽 열. - - 블록은 자기 크기 그대로 맞닿게 쌓는다 — 칸을 장 최대 블록으로 통일하면 작은 - 단면의 테두리가 그림에서 수십 mm 떨어졌다(68-1). 열 폭은 그 열 최대 블록 폭이고 - 블록은 열 가운데에 선다. - """ - usable_w, usable_h = usable_area() - slots: list[list[float]] = [[0.0, 0.0] for _ in group] - left = -usable_w / 2.0 - for column in _stack_columns(group): - column_w = max(group[i][1] for i in column) - bottom = -usable_h / 2.0 - for i in column: - slots[i] = [left + column_w / 2.0, bottom] - bottom += group[i][2] - left += column_w - return slots - - -def plan_cross_sheets(blocks: list[tuple[int | float, float, float]]) -> list[dict[str, Any]]: - """(측점, 폭, 높이) 목록을 A1 장으로 나눈다. - - 블록은 자기 크기(그림 + 테두리 1 mm)로 열마다 쌓는다. 작성 척도는 1/100 고정 — 안 - 들어가면 장을 나눌 뿐 줄이지 않는다(지식DB 「설계제원_총괄」 측량·도면 기준). 장에 - 담기는 측점 수는 세트마다 다르다(2026-09-04 사용자 확정). + 단면 크기와 무관하게 칸 수만큼 담는다(큰 단면이 칸을 넘는 겹침은 사용자가 배치로 고침). + 작성 척도는 1/100 고정 — 줄이지 않는다(지식DB 「설계제원_총괄」 측량·도면 기준). """ sheets: list[dict[str, Any]] = [] start = 0 - for count in _sheet_breaks(blocks): - group = blocks[start : start + count] - chainages = [chainage for chainage, _w, _h in group] + while start < len(chainages): + cols, rows = sheet_grid(layout, len(sheets) + 1) + group = list(chainages[start : start + cols * rows]) sheets.append( { - # id는 **시작 측점**으로 짓는다. 순번(`cross_s01`)으로 지으면 B06 설계가 + # id는 **시작 측점**으로 짓는다. 순번(`cross_s01`)으로 지으면 배치가 # 바뀌어 한 장에 담기는 측점 수가 달라졌을 때 같은 이름이 다른 구간을 # 가리키고, 옛 확정 표시가 그대로 새 구간에 붙는다(2026-09-01 지적). - "id": f"cross_s{cross_key(chainages[0])}m", + "id": f"cross_s{cross_key(group[0])}m", "number": len(sheets) + 1, - "chainages": chainages, - "slots": _slots(group), + "chainages": group, + "grid": [cols, rows], + "slots": grid_slots(len(group), cols, rows), } ) - start += count + start += len(group) return sheets @@ -227,10 +167,9 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) -> if bbox is None: continue min_x, min_y, max_x, max_y = bbox - center_x, bottom_y = slots[index] - # 2) 가로는 칸 가운데, 세로는 칸 아래 변에 테두리 아래 변을 맞춰 다시 만든다. - # 테두리는 자기 그림 + 1 mm — 칸 크기로 넓히지 않는다(68-1). - origin = (center_x - (min_x + max_x) / 2.0, bottom_y - min_y) + center_x, center_y = slots[index] + # 2) 블록(테두리 = 자기 그림 + 1 mm) 가운데를 칸 가운데에 맞춰 다시 만든다. + origin = (center_x - (min_x + max_x) / 2.0, center_y - (min_y + max_y) / 2.0) placed = build_cross_drawing( section["source"], seed_id, @@ -244,12 +183,13 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) -> entities.extend(placed["entities"]) placements.extend(placed.get("cross_placements") or []) - bbox = entities_bbox(entities) - if bbox: + if entities: + # 도각 = 고정 그림칸 — 내용 크기와 무관하게 칸 격자와 같은 자리(140-3). + usable_w, usable_h = usable_area() entities.extend( frame_entities( sheet["id"], - bbox, + (-usable_w / 2.0, -usable_h / 2.0, usable_w / 2.0, usable_h / 2.0), fit=False, fields={"도면명": "횡단면도", **scale_fields(("", DRAWING_SCALE_CROSS))}, ) diff --git a/B07_DesignDetail/B07_DesignDetail_Router.py b/B07_DesignDetail/B07_DesignDetail_Router.py index 776394703..523906bab 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router.py +++ b/B07_DesignDetail/B07_DesignDetail_Router.py @@ -11,6 +11,7 @@ from uuid import UUID from aiomysql import DictCursor from fastapi import APIRouter from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B05_Profile.B05_Profile_Engine_Sections import ( @@ -45,6 +46,8 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support import ( _read_drawing, _read_json, _store_confirmed_drawing, + read_cross_layout, + write_cross_layout, ) from B07_DesignDetail.B07_DesignDetail_Router_Support_Overlay import cross_overlay_extents from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import PLAN_KIND_ID, plan_kind_source @@ -267,11 +270,7 @@ async def get_design_drawing_list( """B07 좌측 패널용 도면 메타데이터만 캐시한다.""" try: route_id, project_root, longitudinal_path, bypass = await _confirmed_source(project_id) - designs = await _designs_by_chainage(route_id) - overlays = await cross_overlay_extents(project_id, route_id) - drawings = await asyncio.to_thread( - _drawing_list, project_root, longitudinal_path, designs, overlays - ) + drawings = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path) return DesignDrawingListResponse( project_id=str(project_id), route_id=route_id, drawings=drawings, dev_bypass=bypass ) @@ -394,18 +393,13 @@ async def confirm_design_drawing( try: route_id, project_root, longitudinal_path, bypass = await _confirmed_source(project_id) designs = await _designs_by_chainage(route_id) - overlays = await cross_overlay_extents(project_id, route_id) - items = await asyncio.to_thread( - _drawing_list, project_root, longitudinal_path, designs, overlays - ) + items = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path) item = next((candidate for candidate in items if candidate.id == drawing_id), None) if not item: raise FileNotFoundError("확정할 도면을 찾을 수 없습니다.") sheet = None if CROSS_SHEET_ID.fullmatch(drawing_id): - plan = await asyncio.to_thread( - _cross_sheet_plan, project_root, longitudinal_path, designs, overlays - ) + plan = await asyncio.to_thread(_cross_sheet_plan, project_root, longitudinal_path) sheet = next((entry for entry in plan if entry["id"] == drawing_id), None) # 수량표는 CAD 테이블(Text 엔티티)에서 역추출을 우선하고, 없으면 요청 본문 폴백. # 장에는 측점이 여럿이라 측점별로 뽑는다 (엔티티 id 씨앗 = "{장id}:{측점}"). @@ -498,14 +492,8 @@ async def invalidate_design_drawing( """확정 도면 편집 시 B07 및 이후 단계를 미확정 상태로 되돌린다.""" try: route_id, project_root, longitudinal_path, bypass = await _confirmed_source(project_id) - # 목록은 **설계값을 넣어** 만든다 — 장 나눔이 측점 표시 폭에 따라 달라지므로, - # 설계 없이 만들면 방금 확정한 장 id 가 목록에 없어 [수정]이 404 로 막힌다 - # (2026-09-03 실측: `cross_s00220m` 확정 후 확정 해제 불가). - designs = await _designs_by_chainage(route_id) - overlays = await cross_overlay_extents(project_id, route_id) - items = await asyncio.to_thread( - _drawing_list, project_root, longitudinal_path, designs, overlays - ) + # 장 나눔은 배치(열 × 행)만 따름 — 설계 · 덧그림 없이 만들어도 같은 장 id(140-3). + items = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path) if drawing_id not in {item.id for item in items}: raise FileNotFoundError("변경된 도면을 찾을 수 없습니다.") await asyncio.to_thread(_invalidate_drawing, project_root, drawing_id) @@ -515,9 +503,7 @@ async def invalidate_design_drawing( if cross_match: stale = [parse_cross_key(cross_match.group(1))] elif CROSS_SHEET_ID.fullmatch(drawing_id): - plan = await asyncio.to_thread( - _cross_sheet_plan, project_root, longitudinal_path, designs, overlays - ) + plan = await asyncio.to_thread(_cross_sheet_plan, project_root, longitudinal_path) sheet = next((entry for entry in plan if entry["id"] == drawing_id), None) stale = list(sheet["chainages"]) if sheet else [] pool = get_db_pool() @@ -554,3 +540,52 @@ async def invalidate_design_drawing( status_code=500, content={"status": "error", "message": "상세 설계 도면 상태를 되돌리지 못했습니다."}, ) + + +class CrossLayoutRequest(BaseModel): + """횡단 장 배치 — 기본 [열, 행] · 장마다 {장 번호: [열, 행]}(140-3).""" + + default: list[int] = Field(default_factory=lambda: [4, 4]) + sheets: dict[str, list[int]] = Field(default_factory=dict) + + +@router.get("/{project_id}/cross-layout") +async def get_cross_layout(project_id: UUID) -> Any: + """횡단 장 배치(파일 없으면 기본 4 × 4).""" + _route_id, project_root, _longitudinal_path, _bypass = await _confirmed_source(project_id) + return await asyncio.to_thread(read_cross_layout, project_root) + + +@router.put("/{project_id}/cross-layout") +async def put_cross_layout(project_id: UUID, request: CrossLayoutRequest) -> Any: + """횡단 장 배치를 쓴다 — 담긴 측점이 바뀐 확정 장은 풀고 그 측점 설계를 잠정으로([수정] 꼴).""" + try: + route_id, project_root, longitudinal_path, _bypass = await _confirmed_source(project_id) + stale = await asyncio.to_thread( + write_cross_layout, project_root, longitudinal_path, request.model_dump() + ) + if stale: + pool = get_db_pool() + async with pool.acquire() as connection: + await connection.begin() + try: + for chainage in stale: + await merge_cross_section_design_by_round( + connection, + route_id=route_id, + chainage=chainage, + patch={"status": "provisional"}, + ) + async with connection.cursor() as cursor: + await start_stage(cursor, str(project_id), 4) + await connection.commit() + except Exception: + await connection.rollback() + raise + return await asyncio.to_thread(read_cross_layout, project_root) + except ValueError as exc: + return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) + except FileNotFoundError as exc: + return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) + except PermissionError as exc: + return JSONResponse(status_code=409, content={"status": "error", "message": str(exc)}) diff --git a/B07_DesignDetail/B07_DesignDetail_Router_PlanKinds.py b/B07_DesignDetail/B07_DesignDetail_Router_PlanKinds.py index a12340f21..6e0b7078c 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_PlanKinds.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_PlanKinds.py @@ -23,6 +23,7 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import ( delete_kind, list_kinds, read_marks, + reorder_kinds, update_kind, write_marks, ) @@ -45,6 +46,10 @@ class PlanKindRequest(BaseModel): scale: int | None = None +class PlanKindsOrderRequest(BaseModel): + ids: list[str] = Field(default_factory=list) + + class PlanMark(BaseModel): chainage_m: float = Field(ge=0) refuge: bool = False @@ -102,6 +107,17 @@ async def post_plan_kind(project_id: UUID, request: PlanKindRequest) -> Any: return _error(400, exc) +@router.put("/{project_id}/plan-kinds-order") +async def put_plan_kinds_order(project_id: UUID, request: PlanKindsOrderRequest) -> Any: + """종류 차례 — 모달에서 줄을 끌어 놓은 차례 그대로(140-2 · 놓는 즉시).""" + try: + root = await _project_root(project_id) + await asyncio.to_thread(reorder_kinds, root, request.ids) + return await asyncio.to_thread(_state, root) + except ValueError as exc: + return _error(400, exc) + + @router.put("/{project_id}/plan-kinds/{kind_id}") async def put_plan_kind(project_id: UUID, kind_id: str, request: PlanKindRequest) -> Any: """종류 업데이트 — 이름 · 조각을 고친다(기본 넷도).""" diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py index 1ddcb240a..ddf43b53b 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -34,7 +34,6 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import ( CROSS_SHEET_ID, build_cross_sheet, plan_cross_sheets, - section_block_size, ) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Standard import ( STANDARD_LABEL, @@ -82,6 +81,7 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import ( from B07_DesignDetail.B07_DesignDetail_Schema import ( DesignDrawingItem, ) +from common_util.common_util_json import atomic_write_json from common_util.common_util_route_profile import design_elevation_from_longitudinal from config.config_system import DRAWING_SCALE_PLAN @@ -97,16 +97,67 @@ MASS_HAUL_ID = "mass_haul" COVER_ID = "cover" # 표준 횡단면도 — 노선 자료가 아니라 B06 표준 횡단면 설정값으로 그리는 한 장. CROSS_STANDARD_ID = "cross_standard" -# 횡단 장 배치 판 — 단면마다 자기 그림 + 1 mm 박스(68-1). 이 판 전에 확정한 장은 옛 배치다. -CROSS_SHEET_LAYOUT = 2 +# 횡단 장 배치 판 — 장마다 열 × 행 칸 가운데(140-3). 이 판 전에 확정한 장은 옛 배치다. +CROSS_SHEET_LAYOUT = 3 +# 장마다 열 × 행 — {default: [열, 행], sheets: {장 번호: [열, 행]}}(140-3). +CROSS_LAYOUT_FILE = "cross_layout.json" +CROSS_GRID_DEFAULT = [4, 4] +_CROSS_GRID_MAX = 10 -def _drawing_list( - project_root: Path, - longitudinal_path: Path, - designs: dict[int | float, dict[str, Any]] | None = None, - overlays: dict[int | float, tuple[float, float, float, float]] | None = None, -) -> list[DesignDrawingItem]: +def _cross_grid(value: Any) -> list[int]: + if ( + not isinstance(value, (list, tuple)) + or len(value) != 2 + or not all(isinstance(n, int) and 1 <= n <= _CROSS_GRID_MAX for n in value) + ): + raise ValueError(f"배치는 열 · 행 1~{_CROSS_GRID_MAX} 사이 정수로 적어 주세요.") + return [int(value[0]), int(value[1])] + + +def read_cross_layout(project_root: Path) -> dict[str, Any]: + """횡단 장 배치 — 파일이 없거나 깨졌으면 기본 4 × 4 만.""" + path = _design_root(project_root) / CROSS_LAYOUT_FILE + try: + return checked_cross_layout(_read_json(path)) + except (OSError, ValueError, AttributeError): + return {"default": list(CROSS_GRID_DEFAULT), "sheets": {}} + + +def checked_cross_layout(layout: dict[str, Any]) -> dict[str, Any]: + sheets = layout.get("sheets") or {} + if not all(str(key).isdigit() and int(key) >= 1 for key in sheets): + raise ValueError("장 번호가 올바르지 않습니다.") + return { + "default": _cross_grid(layout.get("default") or CROSS_GRID_DEFAULT), + "sheets": {str(int(key)): _cross_grid(grid) for key, grid in sheets.items()}, + } + + +def write_cross_layout( + project_root: Path, longitudinal_path: Path, layout: dict[str, Any] +) -> list[int | float]: + """배치를 쓰고 담긴 측점이 바뀐 확정 장을 푼다 — 풀린 장의 옛 측점들(설계 상태를 되돌릴 몫).""" + checked = checked_cross_layout(layout) + before = _cross_sheet_plan(project_root, longitudinal_path) + atomic_write_json(_design_root(project_root) / CROSS_LAYOUT_FILE, checked) + after = { + sheet["id"]: sheet["chainages"] + for sheet in _cross_sheet_plan(project_root, longitudinal_path) + } + manifest = _read_manifest(project_root) + stale: list[int | float] = [] + for sheet in before: + entry = manifest["drawings"].get(sheet["id"]) + if entry and entry.get("confirmed") and after.get(sheet["id"]) != sheet["chainages"]: + entry["confirmed"] = False + stale.extend(sheet["chainages"]) + if stale: + _write_manifest(project_root, manifest) + return stale + + +def _drawing_list(project_root: Path, longitudinal_path: Path) -> list[DesignDrawingItem]: longitudinal = _read_json(longitudinal_path) station_by_chainage = _station_map(longitudinal) manifest_drawings = _read_manifest(project_root)["drawings"] @@ -123,7 +174,7 @@ def _drawing_list( # 횡단 장 계획이 실패해도 나머지 도면은 목록에 남긴다 — 예전에는 `cross_sections/` # 하나가 없으면 표지·종단면도·토적도·유역도까지 함께 사라졌다(2026-09-01 지적). try: - sheets = _cross_sheet_plan(project_root, longitudinal_path, designs, overlays) + sheets = _cross_sheet_plan(project_root, longitudinal_path) except (FileNotFoundError, ValueError, OSError) as exc: logger.warning("B07 횡단 장 계획 실패 — 나머지 도면만 싣는다: %s", exc) sheets = [] @@ -190,32 +241,21 @@ def _store_drawing_numbers(project_root: Path, drawings: list[DesignDrawingItem] _write_manifest(project_root, manifest) -def _cross_sheet_plan( - project_root: Path, - longitudinal_path: Path, - designs: dict[int | float, dict[str, Any]] | None = None, - overlays: dict[int | float, tuple[float, float, float, float]] | None = None, -) -> list[dict[str, Any]]: +def _cross_sheet_plan(project_root: Path, longitudinal_path: Path) -> list[dict[str, Any]]: """측점 도면을 A1 장으로 나눈 계획 — 목록과 도면 생성이 같은 결과를 쓴다. - 한 장에 몇 개가 들어가는지는 단면 블록 크기가 정한다(작성 척도 1/100 + - 설계선이 원지반과 갈라지는 구간). 설계 지정이 없으면 원지반 기준 폭이 된다. + 장마다 열 × 행 칸 수만큼 담는다(140-3 · 단면 크기와 무관 — 설계 · 덧그림에 장 id 가 안 흔들림). """ longitudinal = _read_json(longitudinal_path) - blocks: list[tuple[int, float, float]] = [] + chainages: list[int | float] = [] for path in sorted(_cross_files(longitudinal_path, longitudinal)): match = _CROSS_ID.fullmatch(path.stem) if not match: continue source = _read_json(path) # 측점은 파일 속 값으로 — 옛 프로젝트는 소수 측점 파일이 정수 m 이름이라 이름으로 못 앎 - chainage = cross_station(source.get("chainage_m") or parse_cross_key(match.group(1))) - design = (designs or {}).get(chainage) - design_line = _cross_design_line(longitudinal_path, source, design) - overlay = (overlays or {}).get(chainage) # 화면 덧그림 범위(68-3) - width, height = section_block_size(source, design_line, design=design, overlay=overlay) - blocks.append((chainage, width, height)) - return plan_cross_sheets(blocks) + chainages.append(cross_station(source.get("chainage_m") or parse_cross_key(match.group(1)))) + return plan_cross_sheets(chainages, read_cross_layout(project_root)) def _quantity_table( @@ -353,7 +393,7 @@ def _read_cross_sheet( """횡단 장 하나를 만든다. stored_designs는 {측점: 설계 지정}, overlays는 {측점: 덧그림 범위}.""" designs = stored_designs if isinstance(stored_designs, dict) else {} overlays = overlays or {} - plan = _cross_sheet_plan(project_root, longitudinal_path, designs, overlays) + plan = _cross_sheet_plan(project_root, longitudinal_path) sheet = next((item for item in plan if item["id"] == drawing_id), None) if sheet is None: raise FileNotFoundError("요청한 횡단 장을 찾을 수 없습니다.") diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py index 073f7bc0b..44c539b14 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py @@ -264,16 +264,39 @@ def update_kind( "scale": chosen_scale, } atomic_write_json(path, _document(kind, order)) + _release_kinds(project_root, {kind_id}) + return kind + + +def _release_kinds(project_root: Path, kind_ids: set[str]) -> None: + """그 종류들의 확정 평면도 장을 푼다 — 다시 그려야 하는 장(조각 · 축척 · 번호가 바뀜).""" manifest = _read_manifest(project_root) released = False for drawing_id, entry in manifest["drawings"].items(): match = PLAN_KIND_ID.fullmatch(drawing_id) - if match and match.group(1) == kind_id and entry.get("confirmed"): + if match and match.group(1) in kind_ids and entry.get("confirmed"): entry["confirmed"] = False released = True if released: _write_manifest(project_root, manifest) - return kind + + +def reorder_kinds(project_root: Path, ids: list[str]) -> None: + """종류 차례를 ids 대로 다시 쓴다(140-2). 앞선 종류 묶음이 바뀐 종류 = 장 번호가 바뀜 → 확정 풂.""" + before = [kind["id"] for kind in _materialize(project_root)] + if sorted(ids) != sorted(before): + raise ValueError("종류 목록이 바뀌었습니다. 모달을 다시 열어 주세요.") + folder = _kinds_dir(project_root) + for order, kind_id in enumerate(ids): + path = folder / f"{kind_id}.json" + atomic_write_json(path, {**_read_json(path), "order": order}) + moved = { + kind_id + for index, kind_id in enumerate(ids) + if set(ids[:index]) != set(before[: before.index(kind_id)]) + } + if moved: + _release_kinds(project_root, moved) def delete_kind(project_root: Path, kind_id: str) -> None: diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index fdc495cc9..a85e64072 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -22,6 +22,7 @@ import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; // 좌측 패널(도면 목록·설계 정보)은 파일이 700줄을 넘어 떼어냈다(2026-09-04). import { + buildCrossLayoutControls, buildDesignInfoPanel, buildDrawingSidePanel, isCrossSheet, @@ -36,14 +37,17 @@ import { import { confirmDesignDrawing, exportDrawing, + fetchCrossLayout, fetchDesignDrawing, fetchDesignDrawingList, fetchFrameTemplate, importFrameTemplate, invalidateDesignDrawing, resetFrameTemplate, + saveCrossLayout, saveFrameTemplate, type CadDrawing, + type CrossLayout, type DesignDrawingItem, type DesignDrawingResponse, type QuantityTable, @@ -81,6 +85,8 @@ function L(key: keyof typeof ui_locales): string { * -------------------------------------------------------------------------- */ /** [평면도 관리]에서 고쳐 화면을 다시 세울 때 — 보던 장 id. 다시 세운 뒤 그 장을 연다(없으면 첫 장). */ let reopenDrawingId: string | undefined; +/** 횡단 장 배치를 바꿔 다시 세울 때 — 보던 장 번호(배치가 바뀌면 장 id 가 바뀜 · 140-3). */ +let reopenCrossSheet: number | undefined; export async function renderB07DesignDetail(root: HTMLElement): Promise { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); @@ -138,8 +144,42 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { // 장은 id 가 `cross_s…`(여러 측점 묶음), 측점 도면은 `cross_…m` 이다. `chainage_m` 은 // 장에도 첫 측점 값이 실려 오므로 그것으로는 갈리지 않는다. const scope = isCrossSheet(drawing) ? "sheet" : "station"; - infoPanelHost.replaceChildren( - buildDesignInfoPanel(drawing.label, response.design ?? null, scope), + const panel = buildDesignInfoPanel(drawing.label, response.design ?? null, scope); + infoPanelHost.replaceChildren(panel); + if (scope === "sheet") void appendCrossLayout(drawing, panel); + }; + + /** 장 배치 칸(140-3) — 적용 = 서버 쓰기 → 목록 다시 · 같은 장 번호를 다시 엶. */ + const appendCrossLayout = async (drawing: DesignDrawingItem, panel: HTMLElement) => { + if (!projectId) return; + const number = drawings.filter(isCrossSheet).findIndex((item) => item.id === drawing.id) + 1; + let layout: CrossLayout; + try { + layout = await fetchCrossLayout(projectId); + } catch { + return; // 배치 칸만 빠짐 — 도면 보기는 그대로 + } + if (currentDrawing?.id !== drawing.id || !panel.isConnected) return; + const save = async (next: CrossLayout) => { + if (!mayDiscardEdits()) return; + showLoadingOverlay(); + try { + await saveCrossLayout(projectId, next); + reopenCrossSheet = number; + cad.destroy(); + void renderB07DesignDetail(root); + } catch (error) { + showToast(error instanceof Error ? error.message : "배치를 저장하지 못했습니다.", "error"); + } finally { + hideLoadingOverlay(); + } + }; + panel.append( + buildCrossLayoutControls( + layout.sheets[String(number)] ?? layout.default, + (grid) => void save({ ...layout, sheets: { ...layout.sheets, [String(number)]: grid } }), + (grid) => void save({ default: grid, sheets: {} }), + ), ); }; @@ -523,8 +563,13 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { // 페이지 진입 시 첫 도면(종단도)을 자동 선택 — 빈 CAD 화면 방지. // CAD가 아직 준비 전이면 cad.load 가 붙들고 있다가 ready 때 보낸다. if (drawings.length > 0) { - const reopen = drawings.findIndex((item) => item.id === reopenDrawingId); + const crossSheets = drawings.filter(isCrossSheet); + const reopenId = reopenCrossSheet + ? crossSheets[Math.min(reopenCrossSheet, crossSheets.length) - 1]?.id + : reopenDrawingId; + const reopen = drawings.findIndex((item) => item.id === reopenId); reopenDrawingId = undefined; + reopenCrossSheet = undefined; const first = Math.max(reopen, 0); void openTab(drawings[first]).then(() => { // 첫 장을 띄운 뒤에 나머지를 받는다 — 진입 속도를 뺏지 않는다. diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts index 8e2aa3eb7..9fcda741d 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts @@ -7,6 +7,7 @@ * ========================================================================== */ import { attachCollapsible } from "@ui/ui_template_collapsible"; +import { showToast } from "@ui/ui_template_elements"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import type { CrossDesignInfo, DesignDrawingItem } from "./B07_DesignDetail_Api_Fetch"; import { buildPlanSection } from "./B07_DesignDetail_UI_PlanKinds"; @@ -171,6 +172,58 @@ export function isCrossSheet(drawing: DesignDrawingItem): boolean { return drawing.kind === "cross" && drawing.id.startsWith("cross_s"); } +/** 횡단 장 배치 칸(140-3) — 「배치 열 [4] × 행 [4] [적용]」 + [모든 장 기본]. */ +export function buildCrossLayoutControls( + grid: [number, number], + onApply: (grid: [number, number]) => void, + onAllDefault: (grid: [number, number]) => void, +): HTMLElement { + const box = document.createElement("div"); + box.className = "b07-info__layout"; + const number = (value: number, label: string): HTMLInputElement => { + const input = document.createElement("input"); + input.type = "number"; + input.min = "1"; + input.max = "10"; + input.step = "1"; + input.value = String(value); + input.ariaLabel = label; + return input; + }; + const cols = number(grid[0], "열"); + const rows = number(grid[1], "행"); + const read = (): [number, number] | null => { + const value: [number, number] = [Number(cols.value), Number(rows.value)]; + return value.every((n) => Number.isInteger(n) && n >= 1 && n <= 10) ? value : null; + }; + const action = (text: string, title: string, run: (grid: [number, number]) => void) => { + const node = document.createElement("button"); + node.type = "button"; + node.className = "b07-plan-tools__button"; + node.textContent = text; + node.title = title; + node.addEventListener("click", () => { + const value = read(); + if (value) run(value); + else showToast("열 · 행은 1~10 사이 정수로 적어 주세요.", "error"); + }); + return node; + }; + const label = document.createElement("span"); + label.textContent = "배치 열"; + const times = document.createElement("span"); + times.textContent = "× 행"; + box.append( + label, + cols, + times, + rows, + action("적용", "이 장만 이 배치로", onApply), + action("모든 장 기본", "모든 장을 이 배치로(장마다 값 지움)", onAllDefault), + ); + return box; +} + /** 측구 규격 표시 문자열 (design 신구조: 형식별 ditch spec, F-2 호환). */ function ditchLabel(design: CrossDesignInfo): string { const ditch = design.ditch; diff --git a/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts b/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts index 55634f479..1b3145360 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts @@ -13,6 +13,7 @@ import { createSelectField, showToast } from "@ui/ui_template_elements"; import { stationFields } from "../B05_Profile/B05_Profile_UI_Structures_Fields"; import { addPlanKind, + deletePlanKind, savePlanMarks, updatePlanKind, type DesignDrawingItem, @@ -60,8 +61,20 @@ const LAYOUTS: { value: PlanKindLayout; text: string }[] = [ { value: "watershed", text: "유역도" }, ]; -/** 종류 — 공통 항목. 종류를 고르면 든 조각이 체크로 보이고 [업데이트] · 「새 종류」면 양식 고르고 [추가]. */ -export function kindForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElement { +/** 종류 폼 — 밖(장 단추)에서 고른 종류로 폼을 맞추는 `pick` 과 함께. */ +export interface KindForm { + root: HTMLElement; + pick: (kindId: string) => void; +} + +/** 종류 — 맨 위 [종류 ▼] 아래 상세. 종류를 고르면 든 조각이 체크로 보이고 [업데이트] · [지우기] · + * 「새 종류」면 양식 고르고 [추가]. onPick = 드롭다운으로 고른 종류(미리보기 따라감 · 140-2). */ +export function kindForm( + state: PlanKindsState, + controls: PlanKindsControls, + selected = "", + onPick?: (kindId: string) => void, +): KindForm { const form = el("div", "b07-plan-tools__block"); form.append(el("strong", "", "종류")); const pick = createSelectField({ @@ -71,7 +84,10 @@ export function kindForm(state: PlanKindsState, controls: PlanKindsControls): HT ], compact: true, ariaLabel: "종류", - onChange: () => show(), + onChange: () => { + show(); + if (pick.select.value) onPick?.(pick.select.value); + }, }); const layout = createSelectField({ options: LAYOUTS, @@ -91,6 +107,8 @@ export function kindForm(state: PlanKindsState, controls: PlanKindsControls): HT pieceList.append(...boxes.map((box) => box.root)); const action = el("button", "b07-plan-tools__button"); action.type = "button"; + const remove = el("button", "b07-plan-tools__button", "지우기"); + remove.type = "button"; // 유역도 양식은 그림이 정해져 있어 얹을 정보를 고르지 않는다. const showPieces = (): void => { const watershed = layout.select.value === "watershed"; @@ -118,9 +136,18 @@ export function kindForm(state: PlanKindsState, controls: PlanKindsControls): HT layout.select.value = kind?.layout ?? "plan"; layout.select.disabled = Boolean(kind); action.textContent = kind ? "업데이트" : "추가"; + remove.hidden = !kind; showPieces(); }; + if (state.kinds.some((kind) => kind.id === selected)) pick.select.value = selected; show(); + remove.addEventListener("click", () => { + const kind = state.kinds.find((item) => item.id === pick.select.value); + if (!kind) return; + if (!window.confirm(`「${kind.name}」 종류를 지웁니다. 다시 쓰려면 「새 종류」로 추가합니다.`)) + return; + void run(() => deletePlanKind(controls.projectId, kind.id), controls); + }); action.addEventListener("click", () => { const pieces = boxes.filter((box) => box.input.checked).map((box) => box.input.value); const kindId = pick.select.value; @@ -135,8 +162,16 @@ export function kindForm(state: PlanKindsState, controls: PlanKindsControls): HT controls, ); }); - form.append(pick.root, layout.root, scale.root, name, pieceList, action); - return form; + const actions = el("div", "b07-plan-tools__actions"); + actions.append(action, remove); + form.append(pick.root, layout.root, scale.root, name, pieceList, actions); + return { + root: form, + pick: (kindId) => { + pick.select.value = kindId; + show(); + }, + }; } export function marksForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElement { diff --git a/B07_DesignDetail/B07_DesignDetail_UI_PlanManage.ts b/B07_DesignDetail/B07_DesignDetail_UI_PlanManage.ts index 5328ed77f..af52ce824 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_PlanManage.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_PlanManage.ts @@ -2,9 +2,10 @@ * B07_DesignDetail_UI_PlanManage.ts * [평면도 관리] 모달(PLAN 130-1) — 좌측 패널이 좁아 종류 · 대피소 자리 · 장 보기를 한 창에 모음. * - * 왼쪽 = 종류 목록(× 지우기) · 종류 추가 · 업데이트 폼 · 대피소 · 사토장 자리 · 평면도 장 목록 - * 오른쪽 = 고른 장 미리보기(보기 전용 CAD · 장 바꾸기) · [확정 풀기] · [CAD 에서 열기] - * 고치기는 지금 서버 길 그대로(종류 파일 · 확정 풀기) — 고칠 때마다 모달 안을 다시 받고, + * 왼쪽 = [종류 ▼] 상세 폼(추가 · 업데이트 · 지우기) · 평면도 장(종류마다 한 줄 [≡] 이름 [1][2] · + * 줄 끌어 놓기 = 종류 순서 · 놓는 즉시 저장 140-2) · 대피소 · 사토장 자리 + * 오른쪽 = 고른 장 미리보기(보기 전용 CAD · 캔버스만 `bare`) · 확정 배지 · [CAD 에서 열기] + * 고치기는 지금 서버 길 그대로(종류 파일) — 고칠 때마다 모달 안을 다시 받고, * 닫을 때 `onClosed(changed)` 로 좌측 목록 · 도면을 다시 읽음. * ========================================================================== */ @@ -12,11 +13,10 @@ import { showToast } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; import { createCadHost } from "@ui/cad_host/cad_host"; import { - deletePlanKind, fetchDesignDrawing, fetchDesignDrawingList, fetchPlanKinds, - invalidateDesignDrawing, + reorderPlanKinds, type CadDrawing, type DesignDrawingItem, type PlanKindsState, @@ -26,6 +26,7 @@ import { kindForm, marksForm, run, + type KindForm, type PlanKindsControls, } from "./B07_DesignDetail_UI_PlanKinds"; @@ -59,6 +60,10 @@ export function openPlanManager(controls: PlanManageControls): void { let closed = false; /** 미리보기 중인 장 — 늦게 온 응답이 다른 장을 덮지 않게 이것과 맞춰 봄. */ let picked: string | undefined; + /** 드롭다운 · 줄 강조의 종류 — 장 단추를 누르면 그 장의 종류로 따라감. */ + let pickedKind = ""; + let form: KindForm | undefined; + let sheets: DesignDrawingItem[] = []; const preview = createCadHost({ title: "평면도 미리보기" }); const side = el("div", "b07-plan-manage__side"); const head = el("div", "b07-plan-manage__head"); @@ -74,48 +79,90 @@ export function openPlanManager(controls: PlanManageControls): void { }, }; - const kindList = (state: PlanKindsState, sheets: DesignDrawingItem[]): HTMLElement => { - const list = el("div", "b07-plan-tools__block b07-plan-manage__kinds"); - list.append(el("strong", "", "종류 목록")); + const kindSheets = (kindId: string) => + groupPlanSheets(sheets.filter((item) => item.plan_kind === kindId))[0]?.sheets ?? []; + + /** 장 단추 → 미리보기 + 드롭다운이 그 종류로. */ + const pickSheet = (drawing: DesignDrawingItem, label: string): void => { + pickedKind = drawing.plan_kind ?? ""; + form?.pick(pickedKind); + void show(drawing, label); + }; + + /** 「평면도 장」 한 블록 — 종류마다 한 줄 [≡] 이름 [1][2] · 줄 끌어 놓기 = 종류 순서(놓는 즉시). */ + const sheetBlock = (state: PlanKindsState): HTMLElement => { + const list = el("div", "b07-plan-tools__block b07-plan-manage__sheets"); + list.append(el("strong", "", "평면도 장")); + const ids = state.kinds.map((kind) => kind.id); + let dragId: string | null = null; for (const kind of state.kinds) { - const row = el("div", "b07-plan-kind__head"); + const row = el("div", "b07-plan-kind__head b07-plan-manage__row"); row.dataset.planKind = kind.id; - const count = sheets.filter((item) => item.plan_kind === kind.id).length; - row.append(el("span", "b07-plan-kind__name", `${kind.name} · ${count}장`)); - const remove = button("b07-plan-tools__remove", "×", () => { - if ( - !window.confirm(`「${kind.name}」 종류를 지웁니다. 다시 쓰려면 「새 종류」로 추가합니다.`) - ) - return; - void run(() => deletePlanKind(projectId, kind.id), edits); + row.draggable = true; + const grip = el("span", "b07-plan-manage__grip", "≡"); + grip.title = "끌어 놓아 종류 순서 바꾸기"; + const group = kindSheets(kind.id); + // 한 장이면 이름이 곧 장 단추 · 여러 장이면 이름 + 작은 장 단추 + const name = + group.length === 1 + ? button("b07-plan-manage__sheet b07-plan-kind__name", kind.name, () => + pickSheet(group[0].drawing, group[0].label), + ) + : el("span", "b07-plan-kind__name", kind.name); + if (group.length === 1) { + name.dataset.drawingId = group[0].drawing.id; + name.dataset.confirmed = String(group[0].drawing.confirmed); + } + row.append(grip, name); + if (group.length > 1) + group.forEach(({ drawing, label }, index) => { + const item = button("b07-plan-manage__sheet", String(index + 1), () => + pickSheet(drawing, label), + ); + item.title = label; + item.dataset.drawingId = drawing.id; + item.dataset.confirmed = String(drawing.confirmed); + row.append(item); + }); + // 끌기 꼴 = 공용 탭 줄(`cad_host_tabs.ts`) — 세로라 위 · 아래 반으로 앞 · 뒤 + row.addEventListener("dragstart", (event) => { + dragId = kind.id; + event.dataTransfer?.setData("text/plain", kind.id); + if (event.dataTransfer) event.dataTransfer.effectAllowed = "move"; + }); + row.addEventListener("dragend", () => (dragId = null)); + row.addEventListener("dragover", (event) => { + if (!dragId) return; + event.preventDefault(); + const box = row.getBoundingClientRect(); + row.dataset.drop = event.clientY > box.top + box.height / 2 ? "after" : "before"; + }); + row.addEventListener("dragleave", () => delete row.dataset.drop); + row.addEventListener("drop", (event) => { + event.preventDefault(); + const after = row.dataset.drop === "after"; + delete row.dataset.drop; + if (!dragId || dragId === kind.id) return; + const order = ids.filter((id) => id !== dragId); + order.splice(order.indexOf(kind.id) + (after ? 1 : 0), 0, dragId); + dragId = null; + if (order.join() !== ids.join()) void run(() => reorderPlanKinds(projectId, order), edits); }); - remove.title = `「${kind.name}」 종류 지우기`; - row.append(remove); list.append(row); } return list; }; - const sheetList = (sheets: DesignDrawingItem[]): HTMLElement => { - const list = el("div", "b07-plan-tools__block b07-plan-manage__sheets"); - list.append(el("strong", "", "평면도 장")); - for (const group of groupPlanSheets(sheets)) { - for (const { drawing, label } of group.sheets) { - const item = button("b07-drawing-button", label, () => void show(drawing, label)); - item.dataset.drawingId = drawing.id; - item.dataset.confirmed = String(drawing.confirmed); - list.append(item); - } - } - return list; + const markActive = (): void => { + side + .querySelectorAll(".b07-plan-manage__sheet") + .forEach((item) => (item.dataset.active = String(item.dataset.drawingId === picked))); + side + .querySelectorAll(".b07-plan-manage__row") + .forEach((row) => (row.dataset.active = String(row.dataset.planKind === pickedKind))); }; - const markActive = (): void => - side - .querySelectorAll(".b07-plan-manage__sheets .b07-drawing-button") - .forEach((item) => (item.dataset.active = String(item.dataset.drawingId === picked))); - - /** 장 하나를 미리보기에 — 보기 전용 · 확정이면 [확정 풀기](지금 [수정]과 같은 서버 길). */ + /** 장 하나를 미리보기에 — 보기 전용 · 확정 풀기는 메인 [수정](140-1). */ const show = async (drawing: DesignDrawingItem, label: string): Promise => { picked = drawing.id; markActive(); @@ -127,12 +174,6 @@ export function openPlanManager(controls: PlanManageControls): void { const badge = el("span", "b07-plan-manage__badge", response.confirmed ? "확정" : "확정 전"); badge.dataset.confirmed = String(response.confirmed); head.append(badge); - if (response.confirmed) - head.append( - button("b07-plan-tools__button", "확정 풀기", () => { - void run(() => invalidateDesignDrawing(projectId, drawing.id), edits); - }), - ); head.append(button("b07-plan-tools__button", "CAD 에서 열기", () => finish(drawing.id))); preview.load( response.drawing, @@ -146,6 +187,7 @@ export function openPlanManager(controls: PlanManageControls): void { hostTitle: "평면도 미리보기", ribbonCollapsed: true, panelsCollapsed: true, + bare: true, }, ); } catch (error) { @@ -162,15 +204,18 @@ export function openPlanManager(controls: PlanManageControls): void { fetchDesignDrawingList(projectId), ]); if (closed) return; - const sheets = list.drawings.filter((item) => item.kind === "plan"); - side.replaceChildren( - kindList(state, sheets), - kindForm(state, edits), - marksForm(state, edits), - sheetList(sheets), - ); + sheets = list.drawings.filter((item) => item.kind === "plan"); const all = groupPlanSheets(sheets).flatMap((group) => group.sheets); const keep = all.find((sheet) => sheet.drawing.id === picked) ?? all[0]; + if (keep) pickedKind = keep.drawing.plan_kind ?? ""; + // 드롭다운으로 고른 종류 → 그 종류 첫 장 미리보기 + 줄 강조 + form = kindForm(state, edits, pickedKind, (kindId) => { + pickedKind = kindId; + const first = kindSheets(kindId)[0]; + if (first) void show(first.drawing, first.label); + else markActive(); + }); + side.replaceChildren(form.root, sheetBlock(state), marksForm(state, edits)); if (keep) void show(keep.drawing, keep.label); else { picked = undefined; diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Style.css b/B07_DesignDetail/B07_DesignDetail_UI_Style.css index b0c45377c..3835e57aa 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Style.css +++ b/B07_DesignDetail/B07_DesignDetail_UI_Style.css @@ -225,6 +225,20 @@ color: var(--color-text-muted); } +/* 횡단 장 배치(140-3) — 배치 열 [4] × 행 [4] [적용] [모든 장 기본] */ +.b07-info__layout { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-4); + color: var(--color-text-muted); + font-size: var(--text-caption); +} + +.b07-info__layout input { + width: 44px; +} + /* 확정을 건너뛴 개발 상태 알림 — 눈에 띄되 도면 목록을 밀어내지 않게 한 줄만. */ .b07-drawing-list__bypass { margin: var(--spacing-4) 0 0; @@ -310,6 +324,56 @@ color: var(--color-success); } +/* 「평면도 장」 한 블록(140-2) — 종류마다 한 줄 [≡] 이름 [1][2] · 줄 끌어 놓기 = 종류 순서 */ +.b07-plan-manage__row { + padding: 2px var(--spacing-4); + border: 1px solid transparent; + border-radius: var(--radius-buttons); +} + +.b07-plan-manage__row[data-active="true"] { + border-color: var(--color-border); + background: var(--color-mist-violet); +} + +.b07-plan-manage__row[data-drop="before"] { + box-shadow: inset 0 2px 0 var(--color-primary); +} + +.b07-plan-manage__row[data-drop="after"] { + box-shadow: inset 0 -2px 0 var(--color-primary); +} + +.b07-plan-manage__grip { + color: var(--color-text-muted); + cursor: grab; +} + +.b07-plan-manage__sheet { + min-width: 26px; + padding: 2px var(--spacing-4); + border: 1px solid var(--color-border); + border-left: 3px solid transparent; + border-radius: var(--radius-buttons); + background: var(--color-surface); + color: var(--color-text); + font-size: var(--text-caption); + cursor: pointer; +} + +.b07-plan-manage__sheet.b07-plan-kind__name { + text-align: left; +} + +.b07-plan-manage__sheet[data-confirmed="true"] { + border-left-color: var(--color-success); +} + +.b07-plan-manage__sheet[data-active="true"] { + color: var(--color-primary); + border-color: var(--color-primary); +} + .b07-plan-tools__block { display: flex; flex-direction: column; diff --git a/B07_DesignDetail/openwebcad/src/App.css b/B07_DesignDetail/openwebcad/src/App.css index 847978336..f80bac8e1 100644 --- a/B07_DesignDetail/openwebcad/src/App.css +++ b/B07_DesignDetail/openwebcad/src/App.css @@ -58,6 +58,10 @@ button { .cad-host-save { --cad-title-height: 0px; } +.cad-bare { + --cad-title-height: 0px; + --cad-status-height: 0px; +} .controls { pointer-events: auto; } diff --git a/B07_DesignDetail/openwebcad/src/components/Toolbar.tsx b/B07_DesignDetail/openwebcad/src/components/Toolbar.tsx index 67e6dc26e..ba0b5e184 100644 --- a/B07_DesignDetail/openwebcad/src/components/Toolbar.tsx +++ b/B07_DesignDetail/openwebcad/src/components/Toolbar.tsx @@ -13,7 +13,7 @@ import { Ribbon } from '../ribbon/Ribbon'; import { StatusBar } from './StatusBar'; import { useCadRefresh } from './use-cad-refresh'; import { isInspectorCollapsed, isRibbonCollapsed, setInspectorCollapsed } from './ui-state'; -import { getActiveToolActor } from '../state'; +import { getActiveToolActor, isBare } from '../state'; import type { Tool } from '../tools'; import { ViewControls } from './ViewControls'; import { DropHint } from './DropHint'; @@ -26,21 +26,27 @@ export const Toolbar: FC = () => { const ribbonCollapsed = isRibbonCollapsed(); // 좌측 패널 접힘 — 호스트(M02 미리보기)가 접힌 채로 실을 수 있게 ui-state 에 둔다 const panelCollapsed = isInspectorCollapsed(); + // 캔버스만(B07 평면도 관리 모달) — 겉칸을 안 그리고 높이 · 폭 변수를 0 으로 + const bare = isBare(); useEffect(() => { // 편 상태의 폭은 App.css의 `--cad-panel-width` 기본값을 쓴다. 여기 숫자를 박아 // 두면 스타일에서 넓혀도 이 인라인 값이 덮는다(2026-09-01 실측: 300px 무시됨). - if (panelCollapsed) document.documentElement.style.setProperty('--cad-panel-width', '0px'); + if (panelCollapsed || bare) document.documentElement.style.setProperty('--cad-panel-width', '0px'); else document.documentElement.style.removeProperty('--cad-panel-width'); document.documentElement.style.setProperty( '--cad-command-height', - commandLineVisible ? '86px' : '0px' + commandLineVisible && !bare ? '86px' : '0px' ); // 리본 접힘 — 탭 줄(26px)만 남기고 패널 줄 자리를 걷는다(`Ribbon.tsx` 가 안 그림). - if (ribbonCollapsed) document.documentElement.style.setProperty('--cad-ribbon-height', '26px'); + if (bare) document.documentElement.style.setProperty('--cad-ribbon-height', '0px'); + else if (ribbonCollapsed) + document.documentElement.style.setProperty('--cad-ribbon-height', '26px'); else document.documentElement.style.removeProperty('--cad-ribbon-height'); window.dispatchEvent(new Event('resize')); - }, [panelCollapsed, commandLineVisible, ribbonCollapsed]); + }, [panelCollapsed, commandLineVisible, ribbonCollapsed, bare]); + + if (bare) return null; return ( <> diff --git a/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts b/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts index 47ecd4f5b..3960d5b73 100644 --- a/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts +++ b/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts @@ -27,6 +27,7 @@ import { setLayers, setOriginMarker, setViewOnly, + setBare, } from '../state.ts'; import { setInspectorCollapsed, setRibbonCollapsed } from '../components/ui-state.ts'; import { toast } from 'react-toastify'; @@ -68,6 +69,8 @@ interface DrawingLoadMessage { panelsCollapsed?: boolean; /** 미리보기 — 왼쪽 끌기 = 화면 옮기기 · 두 번 누름 = 전체 맞춤 · 선택 · 명령 안내 없음. */ viewOnly?: boolean; + /** 캔버스만 — 제목줄 · 리본 · 패널 · 상태막대를 안 그림(B07 평면도 관리 모달). */ + bare?: boolean; originMarker?: boolean; } @@ -227,6 +230,7 @@ export function registerAisloDrawingBridge() { setRibbonCollapsed(event.data.ribbonCollapsed === true); if (event.data.panelsCollapsed) setInspectorCollapsed(true); setViewOnly(event.data.viewOnly === true); + setBare(event.data.bare === true); setOriginMarker(event.data.originMarker === true); if (event.data.frameEdit) applyFramePreview(); // 앞 도면에서 켜 둔 그리기 도구를 내린다. 안 내리면 **확정한 도면 위에도** diff --git a/B07_DesignDetail/openwebcad/src/state.test.ts b/B07_DesignDetail/openwebcad/src/state.test.ts index a45b4fe21..5a1351e4d 100644 --- a/B07_DesignDetail/openwebcad/src/state.test.ts +++ b/B07_DesignDetail/openwebcad/src/state.test.ts @@ -7,9 +7,11 @@ import { getEntities, getHighlightedEntityIds, isDrawingDirty, + isBare, isDrawingReadOnly, resetUndoBaseline, setActiveLayerId, + setBare, setDesignMeta, setEntities, setHighlightedEntityIds, @@ -120,3 +122,23 @@ describe('isDrawingReadOnly', () => { expect(isDrawingReadOnly()).toBe(false); }); }); + +describe('setBare', () => { + it('캔버스만 — 켜면 뿌리에 cad-bare(제목줄 · 상태막대 0), 끄면 걷힌다', () => { + stubWindow(); + const classes = new Set(); + (globalThis as { document?: unknown }).document = { + documentElement: { + classList: { + toggle: (name: string, on: boolean) => (on ? classes.add(name) : classes.delete(name)), + }, + }, + }; + setBare(true); + expect(isBare()).toBe(true); + expect(classes.has('cad-bare')).toBe(true); + setBare(false); + expect(isBare()).toBe(false); + expect(classes.has('cad-bare')).toBe(false); + }); +}); diff --git a/B07_DesignDetail/openwebcad/src/state.ts b/B07_DesignDetail/openwebcad/src/state.ts index 10b70c21e..559e75731 100644 --- a/B07_DesignDetail/openwebcad/src/state.ts +++ b/B07_DesignDetail/openwebcad/src/state.ts @@ -197,6 +197,8 @@ let viewOnly = false; let originMarker = false; let hostTitle = 'B07 상세 설계'; let hostSave = false; +/** 캔버스만(B07 평면도 관리 모달) — 제목줄 · 리본 · 패널 · 상태막대를 안 그린다. */ +let bare = false; /** * 실은 뒤로 실제 편집이 있었는가. 도면을 바꾸기 전에 부모가 물어보는 근거다 — @@ -512,6 +514,13 @@ export const setHostSave = (enabled: boolean) => { // 제목 줄이 없어지면 그 높이만큼 위로 당긴다(App.css `.cad-host-save`). document.documentElement.classList.toggle('cad-host-save', enabled); }; +export const isBare = (): boolean => bare; +export const setBare = (enabled: boolean) => { + bare = enabled; + // 제목 줄 · 상태막대 높이를 걷는다(App.css `.cad-bare`) — 리본 · 패널은 Toolbar 가 0 으로. + document.documentElement.classList.toggle('cad-bare', enabled); + notifyWindow(HtmlEvent.UPDATE_STATE); +}; export const getHostTitle = (): string => hostTitle; export const setHostTitle = (title: string) => { hostTitle = title; diff --git a/B09_Estimation/B09_Estimation_Calc_Total.py b/B09_Estimation/B09_Estimation_Calc_Total.py new file mode 100644 index 000000000..ec0a8c6be --- /dev/null +++ b/B09_Estimation/B09_Estimation_Calc_Total.py @@ -0,0 +1,161 @@ +"""B09 총괄설계내역서 · 공사원가계산서 셈(PLAN 145-4) — 화면 없음 · 저장 안 함 · 파일 · M01 안 읽음. + +⚠ 짝 = `B09_Estimation_Calc_Total.ts`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 · + 거울 시험 `resources/tester/test_145_4_b09_total_calc.py`. + 조작 중은 화면 짝이 바로 셈 · [저장] 때 서버가 이 파일로 정본을 다시 셈. + +입력 + sums = 설계내역서 비목 합(145-3 `sheets.bill.sums`) + material 직접재료비 · labor 직접노무비 · expense 산출경비(관급 제외 순공사비계) · + supply_net 관급 순자재대(조달수수료 전) + estimate = 사용자 값(`user.estimate`) — rates {줄 키: %} · safety_base 기초액 · + profit_adjust 이윤 보정 · vat_adjust 부가세 조정 · indirect_material 간접재료비 · + scrap 작업설 · procurement_fee_pct 조달수수료율 +식 · 끝수 = 엑셀 총괄 49~67 그대로(줄마다 ROUNDDOWN 0 · 밑수 = 앞 줄 버린 값 · 관급 ROUNDUP −3) · +산업안전보건관리비 = A(관급 순자재대 ÷ 1.1 포함) · B(관급 빼고 × 1.2) 중 작은 쪽 · +이윤 = ROUNDDOWN((다 − 재료비) × 율, 0) − 보정 · 부가세 = ROUNDDOWN(공급가액 × 율, 0) + 조정. +빈칸 규칙 = 요율 · 비목 합이 빈칸이면 그 줄 빈칸(0 을 채우지 않음) · 그 줄을 쓰는 소계도 빈칸 · +손입력(기초액 · 보정 · 조정 · 간접재료비 · 작업설) 빈칸 = 0 · 엑셀에 없는 현행 비목 = 줄만(늘 빈칸). +""" + +from __future__ import annotations + +from typing import Any + +from B09_Estimation.B09_Estimation_Calc_Base import num, rounddown, roundup, total + +#: 제비율 줄 (키, 이름, M01 요율 후보 키, 산식 글) — 엑셀 총괄 49~58 차례 · 나.소계에 듦 +RATE_LINES: tuple[tuple[str, str, str, str], ...] = ( + ("indirect_labor", "간접노무비", "RT000008", "직접노무비 × 율"), + ("accident", "산재보험료", "RT000001", "(직접노무비 + 간접노무비) × 율"), + ("employment", "고용보험료", "RT000002", "(직접노무비 + 간접노무비) × 율"), + ("health", "건강보험료", "RT000003", "직접노무비 × 율"), + ("care", "노인장기요양보험료", "RT000005", "건강보험료 × 율"), + ("pension", "연금보험료", "RT000004", "직접노무비 × 율"), + ("safety", "산업안전보건관리비", "RT000006", "A · B 중 작은 쪽 + 기초액"), + ("other", "기타경비", "RT000009", "(직접노무비 + 간접노무비 + 재료비) × 율"), + ("environment", "환경보전비", "RT000010", "순공사비 × 율"), + ("machine", "건설기계대여금지급보증", "RT000015", "순공사비 × 율"), +) +#: 엑셀에 없는 현행 비목 — 줄만 두고 빈칸(밑수 · 고르는 값 미정 · 나.소계에 안 듦) +CURRENT_ONLY: tuple[tuple[str, str, str], ...] = ( + ("retirement", "퇴직공제부금비", "RT000011"), + ("wage_claim", "임금채권보장분담금", "RT000012"), + ("asbestos", "석면피해구제분담금", "RT000013"), + ("subcontract", "하도급대금지급보증수수료", "RT000014"), + ("performance", "공사이행보증수수료", "RT000016"), +) +#: 소계 아래 줄 (키, 이름, 후보 키, 산식 글) +TAIL_LINES: tuple[tuple[str, str, str, str], ...] = ( + ("overhead", "일반관리비", "RT000017", "나.소계 × 율"), + ("profit", "이윤", "RT000018", "(다.소계 − 재료비) × 율 − 보정"), + ("vat", "부가가치세", "RT000019", "공급가액 × 율 + 조정"), +) +_SAFETY_VAT_DIVISOR = 1.1 # 관급 순자재대가 부가세 포함 — 1.1 로 나눠 대상액에 +_SAFETY_B = 1.2 # B안 = (관급 빼고) × 1.2 + + +def _pct(base: float | None, rate: float | None) -> float | None: + """ROUNDDOWN(밑수 × 율 ÷ 100, 0) — 둘 중 빈칸이면 빈칸.""" + return None if base is None or rate is None else rounddown(base * rate / 100) + + +def procurement_fee(net: float | None, pct: float | None) -> float | None: + """조달수수료 = ROUNDDOWN(관급 순자재대 × 율 ÷ 100, 0)(엑셀 설계내역서 253 줄).""" + return _pct(net, pct) + + +def compute_total(sums: dict[str, Any] | None, estimate: dict[str, Any] | None) -> dict[str, Any]: + """총괄 제비율 줄 · 소계 · 관급 · 원가계산서 줄 · 구성비.""" + sums = sums if isinstance(sums, dict) else {} + est = estimate if isinstance(estimate, dict) else {} + rates_in = est.get("rates") if isinstance(est.get("rates"), dict) else {} + rate = {key: num(rates_in.get(key)) for key, *_ in (*RATE_LINES, *TAIL_LINES)} + + def hand(key: str) -> float: + return num(est.get(key)) or 0.0 + + material, labor = num(sums.get("material")), num(sums.get("labor")) + expense, supply_net = num(sums.get("expense")), num(sums.get("supply_net")) + direct = total([material, labor, expense]) # 가. 순공사비계 + + line: dict[str, float | None] = {} + line["indirect_labor"] = _pct(labor, rate["indirect_labor"]) + total_labor = total([labor, line["indirect_labor"]]) + line["accident"] = _pct(total_labor, rate["accident"]) + line["employment"] = _pct(total_labor, rate["employment"]) + line["health"] = _pct(labor, rate["health"]) + line["care"] = _pct(line["health"], rate["care"]) + line["pension"] = _pct(labor, rate["pension"]) + # 산업안전보건관리비 — A(관급 순자재대 ÷ 1.1 포함) · B(× 1.2) 중 작은 쪽(관급 없으면 A) + safety_a = safety_b = None + labor_material = total([labor, material]) + if labor_material is not None and rate["safety"] is not None: + base = hand("safety_base") + target = labor_material + (supply_net or 0.0) / _SAFETY_VAT_DIVISOR + safety_a = rounddown(target * rate["safety"] / 100 + base) + safety_b = rounddown((labor_material * rate["safety"] / 100 + base) * _SAFETY_B) + line["safety"] = None if safety_a is None else min(safety_a, safety_b) + line["other"] = _pct(total([labor, line["indirect_labor"], material]), rate["other"]) + line["environment"] = _pct(direct, rate["environment"]) + line["machine"] = _pct(direct, rate["machine"]) + net_cost = total([direct, *(line[key] for key, *_ in RATE_LINES)]) # 나. 순공사원가 + line["overhead"] = _pct(net_cost, rate["overhead"]) + da = total([net_cost, line["overhead"]]) # 다 + profit_raw = _pct(None if da is None or material is None else da - material, rate["profit"]) + line["profit"] = None if profit_raw is None else profit_raw - hand("profit_adjust") + supply_price = total([da, line["profit"]]) # 라. 공급가액 + vat = _pct(supply_price, rate["vat"]) + line["vat"] = None if vat is None else vat + hand("vat_adjust") + contract = total([supply_price, line["vat"]]) # 마. 도급공사비 + # 관급자재대 = ROUNDUP(순자재대 + 조달수수료, −3) · 관급 0 이면 0 + fee = ( + 0.0 if supply_net == 0 else procurement_fee(supply_net, num(est.get("procurement_fee_pct"))) + ) + supply_raw = total([supply_net, fee]) + line["supply"] = None if supply_raw is None else roundup(supply_raw, -3) + grand = total([contract, line["supply"]]) # 바. 총공사비 + + indirect_material, scrap = hand("indirect_material"), hand("scrap") + cost = { + "direct_material": None if material is None else material - indirect_material + scrap, + "indirect_material": indirect_material, + "scrap": scrap, + "material": material, + "direct_labor": labor, + "indirect_labor": line["indirect_labor"], + "labor": total_labor, + "expense": expense, + **{key: line[key] for key in ("accident", "employment", "health", "care", "pension")}, + **{key: line[key] for key in ("safety", "other", "environment", "machine")}, + "expense_sum": total( + [expense, *(line[key] for key, *_ in RATE_LINES if key != "indirect_labor")] + ), + "overhead": line["overhead"], + "profit": line["profit"], + "supply_price": supply_price, + "vat": line["vat"], + "contract": contract, + "supply": line["supply"], + "grand": grand, + } + share = { + key: (None if value is None or not grand else value / grand * 100) + for key, value in cost.items() + } + return { + "sums": {"material": material, "labor": labor, "expense": expense, "direct": direct}, + "lines": line, + "subtotals": { + "net_cost": net_cost, + "da": da, + "supply_price": supply_price, + "contract": contract, + "grand": grand, + }, + "safety": {"a": safety_a, "b": safety_b}, + "profit_raw": profit_raw, + "supply": {"net": supply_net, "fee": fee, "raw": supply_raw}, + "cost": cost, + "share": share, + } diff --git a/B09_Estimation/B09_Estimation_Calc_Total.ts b/B09_Estimation/B09_Estimation_Calc_Total.ts new file mode 100644 index 000000000..b42725d15 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Calc_Total.ts @@ -0,0 +1,148 @@ +/* ============================================================================= + * B09_Estimation_Calc_Total.ts + * B09 총괄설계내역서 · 공사원가계산서 셈(PLAN 145-4) — 화면 짝. + * + * ⚠ 짝 = `B09_Estimation_Calc_Total.py`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 · + * 거울 시험 `resources/tester/test_145_4_b09_total_calc.py`. + * 조작 중은 이 파일이 바로 셈 · [저장] 때 서버가 파이썬 짝으로 정본을 다시 셈. + * + * 입력 sums = 설계내역서 비목 합(145-3 `sheets.bill.sums`) · estimate = 사용자 값(`user.estimate`) + * 식 · 끝수 = 엑셀 총괄 49~67 그대로 · 빈칸 규칙 = 파이썬 짝 머리 글 그대로. + * ========================================================================== */ + +import { num, rounddown, roundup, total, type Row } from "./B09_Estimation_Calc_Base"; + +type Num = number | null; + +/** 제비율 줄 [키, 이름, M01 요율 후보 키, 산식 글] — 엑셀 총괄 49~58 차례 · 나.소계에 듦 */ +export const RATE_LINES: Array<[string, string, string, string]> = [ + ["indirect_labor", "간접노무비", "RT000008", "직접노무비 × 율"], + ["accident", "산재보험료", "RT000001", "(직접노무비 + 간접노무비) × 율"], + ["employment", "고용보험료", "RT000002", "(직접노무비 + 간접노무비) × 율"], + ["health", "건강보험료", "RT000003", "직접노무비 × 율"], + ["care", "노인장기요양보험료", "RT000005", "건강보험료 × 율"], + ["pension", "연금보험료", "RT000004", "직접노무비 × 율"], + ["safety", "산업안전보건관리비", "RT000006", "A · B 중 작은 쪽 + 기초액"], + ["other", "기타경비", "RT000009", "(직접노무비 + 간접노무비 + 재료비) × 율"], + ["environment", "환경보전비", "RT000010", "순공사비 × 율"], + ["machine", "건설기계대여금지급보증", "RT000015", "순공사비 × 율"], +]; +/** 엑셀에 없는 현행 비목 — 줄만 두고 빈칸 */ +export const CURRENT_ONLY: Array<[string, string, string]> = [ + ["retirement", "퇴직공제부금비", "RT000011"], + ["wage_claim", "임금채권보장분담금", "RT000012"], + ["asbestos", "석면피해구제분담금", "RT000013"], + ["subcontract", "하도급대금지급보증수수료", "RT000014"], + ["performance", "공사이행보증수수료", "RT000016"], +]; +/** 소계 아래 줄 */ +export const TAIL_LINES: Array<[string, string, string, string]> = [ + ["overhead", "일반관리비", "RT000017", "나.소계 × 율"], + ["profit", "이윤", "RT000018", "(다.소계 − 재료비) × 율 − 보정"], + ["vat", "부가가치세", "RT000019", "공급가액 × 율 + 조정"], +]; +const SAFETY_VAT_DIVISOR = 1.1; +const SAFETY_B = 1.2; + +/** ROUNDDOWN(밑수 × 율 ÷ 100, 0) — 둘 중 빈칸이면 빈칸 */ +const pct = (base: Num, rate: Num): Num => + base === null || rate === null ? null : rounddown((base * rate) / 100); + +/** 조달수수료 = ROUNDDOWN(관급 순자재대 × 율 ÷ 100, 0) */ +export const procurementFee = (net: Num, rate: Num): Num => pct(net, rate); + +export function computeTotal(sums: Row | null | undefined, estimate: Row | null | undefined): Row { + const s: Row = sums && typeof sums === "object" ? sums : {}; + const est: Row = estimate && typeof estimate === "object" ? estimate : {}; + const ratesIn: Row = est.rates && typeof est.rates === "object" ? est.rates : {}; + const rate: Record = {}; + for (const [key] of [...RATE_LINES, ...TAIL_LINES]) rate[key] = num(ratesIn[key]); + const hand = (key: string): number => num(est[key]) || 0; + + const material = num(s.material); + const labor = num(s.labor); + const expense = num(s.expense); + const supplyNet = num(s.supply_net); + const direct = total([material, labor, expense]); + + const line: Record = {}; + line.indirect_labor = pct(labor, rate.indirect_labor); + const totalLabor = total([labor, line.indirect_labor]); + line.accident = pct(totalLabor, rate.accident); + line.employment = pct(totalLabor, rate.employment); + line.health = pct(labor, rate.health); + line.care = pct(line.health, rate.care); + line.pension = pct(labor, rate.pension); + let safetyA: Num = null; + let safetyB: Num = null; + const laborMaterial = total([labor, material]); + if (laborMaterial !== null && rate.safety !== null) { + const base = hand("safety_base"); + const target = laborMaterial + (supplyNet ?? 0) / SAFETY_VAT_DIVISOR; + safetyA = rounddown((target * rate.safety) / 100 + base); + safetyB = rounddown(((laborMaterial * rate.safety) / 100 + base) * SAFETY_B); + } + line.safety = safetyA === null || safetyB === null ? null : Math.min(safetyA, safetyB); + line.other = pct(total([labor, line.indirect_labor, material]), rate.other); + line.environment = pct(direct, rate.environment); + line.machine = pct(direct, rate.machine); + const netCost = total([direct, ...RATE_LINES.map(([key]) => line[key])]); + line.overhead = pct(netCost, rate.overhead); + const da = total([netCost, line.overhead]); + const profitRaw = pct(da === null || material === null ? null : da - material, rate.profit); + line.profit = profitRaw === null ? null : profitRaw - hand("profit_adjust"); + const supplyPrice = total([da, line.profit]); + const vat = pct(supplyPrice, rate.vat); + line.vat = vat === null ? null : vat + hand("vat_adjust"); + const contract = total([supplyPrice, line.vat]); + const fee = supplyNet === 0 ? 0 : procurementFee(supplyNet, num(est.procurement_fee_pct)); + const supplyRaw = total([supplyNet, fee]); + line.supply = supplyRaw === null ? null : roundup(supplyRaw, -3); + const grand = total([contract, line.supply]); + + const indirectMaterial = hand("indirect_material"); + const scrap = hand("scrap"); + const cost: Record = { + direct_material: material === null ? null : material - indirectMaterial + scrap, + indirect_material: indirectMaterial, + scrap, + material, + direct_labor: labor, + indirect_labor: line.indirect_labor, + labor: totalLabor, + expense, + accident: line.accident, + employment: line.employment, + health: line.health, + care: line.care, + pension: line.pension, + safety: line.safety, + other: line.other, + environment: line.environment, + machine: line.machine, + expense_sum: total([ + expense, + ...RATE_LINES.filter(([key]) => key !== "indirect_labor").map(([key]) => line[key]), + ]), + overhead: line.overhead, + profit: line.profit, + supply_price: supplyPrice, + vat: line.vat, + contract, + supply: line.supply, + grand, + }; + const share: Record = {}; + for (const [key, value] of Object.entries(cost)) + share[key] = value === null || !grand ? null : (value / grand) * 100; + return { + sums: { material, labor, expense, direct }, + lines: line, + subtotals: { net_cost: netCost, da, supply_price: supplyPrice, contract, grand }, + safety: { a: safetyA, b: safetyB }, + profit_raw: profitRaw, + supply: { net: supplyNet, fee, raw: supplyRaw }, + cost, + share, + }; +} diff --git a/B09_Estimation/B09_Estimation_Linked.py b/B09_Estimation/B09_Estimation_Linked.py index e3495f1d1..caedd4c7b 100644 --- a/B09_Estimation/B09_Estimation_Linked.py +++ b/B09_Estimation/B09_Estimation_Linked.py @@ -12,6 +12,7 @@ from __future__ import annotations from pathlib import Path from typing import Any +from M01_MasterData import M01_MasterData_Store as m01 from M02_MasterTemplete import M02_Item_Prices as item_prices from M02_MasterTemplete.M02_Item_Snapshot import ( PICKS_FILE, @@ -50,7 +51,11 @@ def used_keys(folder: Path) -> list[str]: if not key: continue given = {name: (one or {}).get("값") for name, one in (values.get(key) or {}).items()} + given = {k: v for k, v in given.items() if v not in (None, "")} try: + # 빈 입력 = 자동값(142-1 · 145-1 산근 풀이와 같음) — 안 채우면 그 로직 자원을 못 모음 + auto = m01.mau.values(snapshot, snapshot.logic(key)) + given = {**{k: v for k, v in auto.items() if v is not None}, **m01._dec(given)} with mf.mp.choosing((picks or {}).get(key)): mf.run(snapshot, key, given) except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError): diff --git a/B09_Estimation/B09_Estimation_Rates.py b/B09_Estimation/B09_Estimation_Rates.py new file mode 100644 index 000000000..73113b860 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Rates.py @@ -0,0 +1,47 @@ +"""B09 원가 요율 후보(PLAN 145-4) — M01 `요율_조달청제비율.json` 을 읽어 줄마다 후보 글 한 줄. + +후보는 보이기만 — 구간(추정가격 · 공기 · 공종 · 연도)을 고르는 값은 사용자 몫이라 확정하지 않음. +요율 칸은 빈칸에서 시작(사용자 입력 · `user.estimate.rates`). +""" + +from __future__ import annotations + +import json +from functools import lru_cache +from pathlib import Path +from typing import Any + +FILE = Path(__file__).resolve().parents[1] / "resources" / "master_data" / "요율_조달청제비율.json" + + +def _percents(value: Any) -> list[float]: + """값 안 모든 rate_percent — 구간 · 연도 · 공종 갈래 통째로.""" + if isinstance(value, dict): + own = value.get("rate_percent") + found = [float(own)] if isinstance(own, (int, float)) else [] + return found + [ + p for key, v in value.items() if key != "rate_percent" for p in _percents(v) + ] + if isinstance(value, list): + return [p for item in value for p in _percents(item)] + return [] + + +@lru_cache(maxsize=1) +def rate_candidates() -> dict[str, str]: + """{RT 키: 「M01 RT… 3.56%」 · 「… 1.01~1.57%(구간 · 고르는 값 사용자)」} — 파일 없으면 {}.""" + try: + rows = json.loads(FILE.read_text(encoding="utf-8")).get("줄") or [] + except (OSError, ValueError): + return {} + out: dict[str, str] = {} + for row in rows: + percents = sorted(set(_percents(row.get("값")))) + if not percents: + text = "구간 식(고르는 값 사용자)" + elif len(percents) == 1: + text = f"{percents[0]:g}%" + else: + text = f"{percents[0]:g}~{percents[-1]:g}%(구간 · 고르는 값 사용자)" + out[str(row.get("키"))] = f"M01 {row.get('키')} {text}" + return out diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 26ed32865..37778e4e4 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -9,7 +9,8 @@ 저장 자리 = 프로젝트 안 `B09_Estimation/v1/`(`B09_Estimation_Store`) · 단가 = `B09_Estimation_Prices` · -수요 = `B09_Estimation_Linked`(연결된 사본) · 셈 = `B09_Estimation_Calc_*`(화면 짝과 같은 식). +수요 = `B09_Estimation_Linked`(연결된 사본) · 셈 = `B09_Estimation_Calc_*`(화면 짝과 같은 식) · +산근 = `B09_Estimation_Sangeun`(서버 단독 · 145-1 · 화면은 받은 그대로). """ from __future__ import annotations @@ -25,10 +26,13 @@ from B08_Quantity.B08_Quantity_Router_Earthwork import earthwork_table from B08_Quantity.B08_Quantity_Router_Hydro import _project_root from B08_Quantity.B08_Quantity_Router_Material import material_sheets from B09_Estimation import B09_Estimation_Prices as prices +from B09_Estimation import B09_Estimation_Sangeun as sangeun from B09_Estimation import B09_Estimation_Store as store from B09_Estimation.B09_Estimation_Bill_Items import bill_items, hopyo_prices from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation +from B09_Estimation.B09_Estimation_Calc_Total import compute_total from B09_Estimation.B09_Estimation_Linked import linked_resource_keys +from B09_Estimation.B09_Estimation_Rates import rate_candidates from B09_Estimation.B09_Estimation_Summary import compute_summary, hopyo_lines router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"]) @@ -64,14 +68,20 @@ async def payload(project_id: UUID, root: Path) -> dict[str, Any]: inputs["bill"] = bill_items(b08) inputs["hopyo"] = hopyo_prices(root) user = await asyncio.to_thread(store.read_user, root) + sheets = compute_estimation(inputs, user) + sheets.update(await asyncio.to_thread(sangeun.build, root, sheets)) # 145-1 — 굳힌 단가로 풀이 + sheets["gaps"] = [*sheets["gaps"], *sheets["sg_gaps"]] + # 145-4 총괄 · 원가계산서 = 설계내역서 비목 합(145-3 `bill.sums`) + 사용자 요율 · 손입력 + sheets["total"] = compute_total((sheets.get("bill") or {}).get("sums"), user.get("estimate")) return { "inputs": inputs, "user": user, "book": {"made": book.get("made")} if book else None, - "sheets": compute_estimation(inputs, user), + "sheets": sheets, + "rate_candidates": rate_candidates(), "notes": notes, # 집계표 넷 = 서버 단독(145-5) — 짝 셈 결과를 역전개 · 조작 중엔 안 바뀜 - "summary": compute_summary(compute_estimation(inputs, user), hopyo_lines(root)), + "summary": compute_summary(sheets, hopyo_lines(root)), } diff --git a/B09_Estimation/B09_Estimation_Sangeun.py b/B09_Estimation/B09_Estimation_Sangeun.py new file mode 100644 index 000000000..3fcfd78d0 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Sangeun.py @@ -0,0 +1,377 @@ +"""B09 단가산출근거 · 단가산출근거목록표(PLAN 145-1) — 서버 단독(③ · 화면 짝 없음 · 저장 안 함). + +산근 호표 = 연결된 사본(`linked_snapshots` · 143-1)의 일위대가 로직 줄 중 + `승격: true` 인 줄(134-1 · 입력값 = 사본 `입력값.json` · 빈 입력 = 자동값) + + 옛 단가승격 참조 줄(134-2 ③ 로 남은 SP · 입력값 = 굳힌 `참조사본`) + 같은 로직 + 같은 입력값(+ 켜쌓기율 · 고른 자재) = 산근 하나(`sangeun_key`) · + 번호 = 첫 등장 차례 · + 로직 줄이 부르는 다른 돈 로직(기계경비 밖) = 하위 산근(그 산근도 목록에 · 산근 → 산근). +풀이 = 로직 호표 줄을 사본 단가 묶음(`단가.json`)으로 펼쳐 수량 · 자원 키만 얻고 + (M01 엔진 식 그대로) · + 단가 = B09 프로젝트 한 벌 굳힌 단가(144 · `compute_base` 표) — 노무 = 노무비목록표 · + 중기 = 중기목록표 노 · 재 · 경 · 재료 = 자재단가대비표 적용 단가 · + % 줄(덧줄) = 엔진 식에 B09 비목 합. +끝수 = 엑셀 자리 — 수량 = 결과단위 1 단위(÷ 배) · + 작업량 Q(중간 이름 Q · Q1 …) = ROUND(식, 2) 뒤 쓰임 · + 줄 금액 = ROUNDDOWN(단가 × 수량, 1) 비목마다 · 총계 = 비목마다 ROUNDDOWN(Σ, 0) · 합계 = 셋 합. +빈칸 규칙 = 단가 · 수량을 낸 원천이 없으면 그 칸 · 그 비목 총계 None(0 을 채우지 않음) + + 「연결 없음」. +조합 밖 토공 · 운반 · 사토 산근 · 준비공 = 원천 없음(만들지 않음 · 목록 안내 한 줄). +대가(145-2) · 내역서(145-3)가 읽는 길 = `sangeun_key` + `price_of`. +""" + +from __future__ import annotations + +import hashlib +import json +import re +from decimal import Decimal +from pathlib import Path +from typing import Any + +from B09_Estimation.B09_Estimation_Calc_Base import gap, num, round_half, rounddown, total +from M01_MasterData import M01_MasterData_Store as m01 +from M01_MasterData import M01_MasterData_Store_Combo as combo_store +from M02_MasterTemplete import M02_Item_Prices as item_prices +from M02_MasterTemplete.M02_Item_Snapshot import ( + PICKS_FILE, + VALUES_FILE, + linked_snapshots, + structures_dir, +) +from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, PRICE_FILE, read_json + +mf = item_prices.mf +PARTS = ("labor", "material", "expense") +COST_PART = {"노무비": "labor", "재료비": "material", "경비": "expense"} +DEPTH = 4 # 하위 산근 깊이 상한 +_Q = re.compile(r"Q\d*") # 작업량 중간값 이름(산림품셈 09장 토공 · 07장 집재 …) +NO_SOURCE = "조합 밖 토공 · 운반 · 사토 산근 · 준비공 = 원천 없음(연결 없음 · 만들지 않음)" +EMPTY = "연결된 구조물 조합 사본에 단가승격 줄 없음 — 연결 없음" + + +# ── 산근 정체 ───────────────────────────────────────────────────────── +def _clean_given(given: Any) -> dict[str, Any]: + return { + str(k): v for k, v in sorted((given or {}).items()) if v not in (None, "") and k != "줄" + } + + +def sangeun_key(code: str, given: Any = None, rate: Any = None, picks: Any = None) -> str: + """산근 이름표 — 같은 로직 + 같은 입력값(+ 켜쌓기율 · 고른 자재)이면 같은 글. + 145-2 대가 · 145-3 내역서가 승격 줄에서 이 글을 만들어 `price_of` 로 단가를 읽음.""" + body = { + "입력값": _clean_given(given), + "율": None if rate in (None, 100) else float(rate), + "고름": {str(k): v for k, v in sorted((picks or {}).items())}, + } + text = json.dumps(body, ensure_ascii=False, sort_keys=True, default=str) + return f"SG:{code}:{hashlib.sha1(text.encode('utf-8')).hexdigest()[:10]}" + + +def price_of(sheets: dict[str, Any], key: str) -> dict[str, Any] | None: + """산근 한 줄 단가 `{no, labor, material, expense, sum}`(1 단위 · 빈칸 = None) — 없으면 None.""" + for row in sheets.get("sg_list") or []: + if row["key"] == key: + return {k: row[k] for k in ("no", *PARTS, "sum")} + return None + + +def _values(values: dict[str, Any], code: str) -> dict[str, Any]: + return {name: (one or {}).get("값") for name, one in (values.get(code) or {}).items()} + + +def rows_of(folder: Path) -> list[dict[str, Any]]: + """사본 하나의 산근 원천 줄 — 승격 로직 줄 · 옛 SP 참조 줄(차례대로).""" + from M02_MasterTemplete import M02_Labor_Ref as refs + + labor = read_json(folder / LABOR_FILE) + if not isinstance(labor, dict): + return [] + values = read_json(folder / VALUES_FILE) or {} + picks = read_json(folder / PICKS_FILE) or {} + copies = labor.get("참조사본") or {} + out = [] + for one in (labor.get("줄") or {}).get("담은로직") or []: + if not isinstance(one, dict): + continue + ref = one.get("참조") + if one.get("승격") is True and one.get("로직"): + code = str(one["로직"]) + got = { + "code": code, + "given": _values(values, code), + "rate": one.get("켜쌓기율"), + "picks": picks.get(code) or one.get("고름"), + } + elif isinstance(ref, dict) and refs.kind_of(ref.get("키")) == "promote": + line = ((copies.get(refs.ref_id(ref)) or {}).get("담은로직") or [None])[0] + if not isinstance(line, dict) or not line.get("로직"): + continue + got = { + "code": str(line["로직"]), + "given": line.get("입력값") or {}, + "rate": line.get("켜쌓기율"), + "picks": line.get("고름"), + } + else: + continue + out.append({**got, "given": _clean_given(got["given"])}) + return out + + +# ── 풀이 ────────────────────────────────────────────────────────────── +class _Prices: + """B09 표 단가 — 노무 · 중기(노 · 재 · 경) · 자재 적용 단가(키 → 값).""" + + def __init__(self, sheets: dict[str, Any]) -> None: + self.labor = {r["key"]: num(r.get("price")) for r in sheets.get("labor") or []} + self.machine = {r["key"]: r for r in sheets.get("machine") or []} + self.material = {r["key"]: num(r.get("applied")) for r in sheets.get("compare") or []} + + +def _unit(code: str) -> tuple[str, Decimal]: + """결과단위 1 단위 — (단위, 배)(「원/10㎡」 = ㎡ · 10).""" + pill = (combo_store.combo_pills({"담은로직": [{"로직": code}]})["물량"] or [{}])[0] + return str(pill.get("단위") or ""), Decimal(str(pill.get("배") or 1)) + + +def _env(whole: Any, row: dict, given: dict) -> dict: + """입력(빈 입력 = 자동값) + 중간값 · 작업량 Q = ROUND(식, 2)(엑셀 자리).""" + auto = {k: v for k, v in m01.mau.values(whole, row).items() if v is not None} + env = mf._inputs(row, {**auto, **m01._dec(given or {})}) + for step in row.get("중간", []): + try: + value = mf.evaluate(mf.parse(step["식"]), env, whole, 0) + except mf.EmptyInput as e: + env[step["이름"]] = mf.Blank(str(e)) + continue + if _Q.fullmatch(step["이름"]) and isinstance(value, (Decimal, int, float)): + value = Decimal(str(round_half(float(value), 2))) + env[step["이름"]] = value + return env + + +def _captured(whole: Any, node: Any, env: dict) -> list[str]: + """식을 돌리며 부른 기계 키(EQ) — 기계경비 로직 줄의 기계.""" + seen: list[str] = [] + base = whole.get + + def get(ref: str) -> dict[str, Any]: + row = base(ref) + key = str(row.get("키") or "") + if key.startswith("EQ") and key not in seen: + seen.append(key) + return row + + whole.get = get + try: + mf.evaluate(node, env, whole, 1) + except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError): + pass # 기계 단가를 못 셈 — 키만 있으면 됨(단가는 B09 중기목록표) + finally: + whole.get = base + return seen + + +def _money(price: float | None, qty: float | None) -> float | None: + """줄 금액 = ROUNDDOWN(단가 × 수량, 1)(엑셀 자리).""" + return None if price is None or qty is None else rounddown(price * qty, 1) + + +def block_totals(lines: list[dict[str, Any]]) -> dict[str, float | None]: + """총계 — 비목마다 ROUNDDOWN(Σ 줄 금액, 0) · 합계 = 셋 합 · 빈칸 하나면 그 비목 · 합계 빈칸.""" + out: dict[str, float | None] = {} + for part in PARTS: + s = total([ln[part] for ln in lines]) + out[part] = None if s is None else rounddown(s, 0) + return {**out, "sum": total([out[p] for p in PARTS])} + + +class Solver: + """한 번 셈 — 산근 블록을 첫 등장 차례로 모음(하위 산근 포함).""" + + def __init__(self, sheets: dict[str, Any]) -> None: + self.prices = _Prices(sheets) + self.blocks: dict[str, dict[str, Any]] = {} + + def add( + self, whole: Any, src: dict[str, Any], owner: str | None, depth: int = 0 + ) -> dict[str, Any]: + key = sangeun_key(src["code"], src["given"], src.get("rate"), src.get("picks")) + if key not in self.blocks: + self.blocks[key] = {"key": key, "no": len(self.blocks) + 1, "uses": []} # 자리 먼저 + self.blocks[key].update(self._solve(whole, src, depth)) + if owner and owner not in self.blocks[key]["uses"]: + self.blocks[key]["uses"].append(owner) + return self.blocks[key] + + def _solve(self, whole: Any, src: dict[str, Any], depth: int) -> dict[str, Any]: + code = src["code"] + try: + row = whole.logic(code) + except mf.FormulaError as e: + return self._stop(code, code, f"로직 없음 — {e}") + name = str(row.get("이름") or code) + if isinstance(row.get("흐름"), dict) or "결과" in row: + return self._stop(code, name, "돈 로직 아님(흐름 · 결과 로직) — 산근 풀이 꼴 없음") + unit, times = _unit(code) + rate = Decimal(str(src["rate"])) / 100 if src.get("rate") not in (None, 100) else None + lines: list[dict[str, Any]] = [] + try: + env = _env(whole, row, src["given"]) + with mf.mp.choosing(src.get("picks")): + for n, item in enumerate(row.get("호표", [])): + lines.append(self._line(whole, item, n, env, times, rate, depth)) + self._extras(whole, row, env, lines) + except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e: + return self._stop(code, name, str(e) or type(e).__name__) + sums = block_totals(lines) + why = next((ln["why"] for ln in lines if ln.get("why")), None) + return { + "logic": code, + "name": name, + "spec": ", ".join(f"{k}={v}" for k, v in src["given"].items()), + "unit": unit, + "source": row.get("출처") or "", + "lines": lines, + **sums, + "why": why, + } + + @staticmethod + def _stop(code: str, name: str, why: str) -> dict[str, Any]: + return {"logic": code, "name": name, "spec": "", "unit": "", "source": "", "lines": [], + **dict.fromkeys(PARTS), "sum": None, "why": why} # fmt: skip + + def _blank(self, item: dict, why: str) -> dict[str, Any]: + part = COST_PART.get(item.get("비목"), "material") + return {"kind": "blank", "name": item.get("이름", ""), "spec": "", "qty": None, + "unit": item.get("단위", ""), "ref": None, "price": {}, + **{p: (None if p == part else 0.0) for p in PARTS}, "why": why} # fmt: skip + + def _line(self, whole, item, n, env, times, rate, depth) -> dict[str, Any]: + try: + raw = mf.evaluate(mf.parse(item["수량"]), env, whole, 0) + except mf.EmptyInput as e: + return self._blank(item, str(e)) + qty = Decimal(str(raw)) / times * (rate or 1) + q = float(qty) + base = {"name": item.get("이름", ""), "spec": "", "qty": q, "unit": item.get("단위", "")} + zero = dict.fromkeys(PARTS, 0.0) + kind = item.get("종류") + if kind == "로직": + return self._logic_line(whole, item, env, q, base, zero, depth) + if kind == "인력": + key = mf.fill(item["요소"], env) if isinstance(item["요소"], str) else "" + price = self.prices.labor.get(key) + why = None if price is not None else f"노무비목록표에 없음 「{key}」" + return {**base, **zero, "kind": "labor", "ref": {"tab": "labor", "key": key}, + "price": {"labor": price}, "labor": _money(price, q), "why": why} # fmt: skip + ref = ( + {k: mf.fill(v, env) if isinstance(v, str) else v for k, v in item["요소"].items()} + if isinstance(item["요소"], dict) + else mf.fill(item["요소"], env) + ) + _price, source, _why, extra = mf.mp.priced(whole, item, ref, env, n) + goods = (extra or {}).get("품목") or {} + key = str( + goods.get("키") or (source or "").split(".")[0] or (ref if isinstance(ref, str) else "") + ) + part = COST_PART.get(item.get("비목"), "material") + price = self.prices.material.get(key) + if q == 0: + price = price if price is not None else 0.0 # 안 쓰는 재료 줄 — 금액 0 + why = None if price is not None else f"자재단가대비표에 없음 「{key}」" + spec = goods.get("규격") or "" + ref = {"tab": "compare", "key": key} + return {**base, **zero, "spec": spec, "kind": "material", "ref": ref, + "price": {part: price}, part: _money(price, q), "why": why} # fmt: skip + + def _logic_line(self, whole, item, env, q, base, zero, depth) -> dict[str, Any]: + node = mf.parse(item["요소"]) + called = node[1] if node and node[0] == "logic" else None + try: + target = whole.logic(called) if called else None + except mf.FormulaError: + target = None + if target and any(s.get("이름") == "기계" for s in target.get("입력", [])): + keys = _captured(whole, node, env) + m = self.prices.machine.get(keys[0]) if keys else None + price = {p: num((m or {}).get(p)) for p in PARTS} + why = None if m else f"중기목록표에 없음 「{keys[0] if keys else item.get('이름')}」" + amounts = {p: _money(price[p], q) if m else None for p in PARTS} + ref = {"tab": "machine", "key": keys[0] if keys else ""} + return {**base, "kind": "machine", "ref": ref, + "price": price, **amounts, "why": why} # fmt: skip + if not target or depth >= DEPTH: + return {**self._blank(item, "하위 로직을 못 펼침"), **base} + given = {name: mf.evaluate(expr, env, whole, 1) for name, expr in node[2]} + sub = self.add(whole, {"code": called, "given": _clean_given(given)}, None, depth + 1) + price = {p: sub.get(p) for p in PARTS} + why = None if sub.get("sum") is not None else f"하위 산근 {sub['no']} 빈칸" + return {**base, "kind": "sangeun", "ref": {"tab": "sg", "key": sub["key"]}, + "price": price, **{p: _money(price[p], q) for p in PARTS}, "why": why} # fmt: skip + + def _extras(self, whole, row, env, lines) -> None: + """% 줄(덧줄) — 엔진 식 그대로 · 밑수 = 그때까지 B09 비목 합 · 줄 금액 = 이름별.""" + for extra in row.get("덧줄", []): + sums = {name: total([ln[p] for ln in lines]) for name, p in COST_PART.items()} + part = COST_PART.get(extra.get("비목"), "material") + line = {"kind": "extra", "name": extra["이름"], "spec": extra["식"], "qty": 1.0, + "unit": "식", "ref": None, **dict.fromkeys(PARTS, 0.0)} # fmt: skip + if any(v is None for v in sums.values()): + lines.append({**line, part: None, "price": {}, "why": "밑수 빈칸"}) + continue + env.update({k: Decimal(str(v)) for k, v in sums.items()}) + env["줄"] = { + ln["name"]: Decimal(str(sum(ln[p] or 0 for p in PARTS))) + for ln in lines + if ln["name"] + } + try: + value = float(mf.evaluate(mf.parse(extra["식"]), env, whole, 0)) + except (mf.FormulaError, ArithmeticError, TypeError, ValueError) as e: + lines.append({**line, part: None, "price": {}, "why": str(e)}) + continue + lines.append({**line, "price": {part: value}, part: rounddown(value, 1), "why": None}) + + +# ── 모음 ────────────────────────────────────────────────────────────── +def collect(entries: list[tuple[str, Path]], sheets: dict[str, Any]) -> dict[str, Any]: + """`entries` = [(구조물 id, 사본 폴더)] → 블록 · 목록 · 「연결 없음」.""" + solver = Solver(sheets) + for owner, folder in entries: + rows = rows_of(folder) + if not rows: + continue + whole = item_prices.load_prices(read_json(folder / PRICE_FILE) or {}) + for src in rows: + solver.add(whole, src, owner) + blocks = sorted(solver.blocks.values(), key=lambda b: b["no"]) + listing = [ + {k: b[k] for k in ("no", "key", "logic", "name", "spec", "unit", "sum", *PARTS, "uses")} + for b in blocks + ] + gaps = [ + gap("sg", b["key"], f"산근 {b['no']} {b['name']}", b["why"] or "단가 빈칸") + for b in blocks + if b["sum"] is None + ] + return { + "sg": blocks, + "sg_list": listing, + "sg_gaps": gaps, + "sg_notes": [NO_SOURCE] + ([] if blocks else [EMPTY]), + } + + +def build(project_root: str | Path, sheets: dict[str, Any]) -> dict[str, Any]: + """연결된 사본 전부(구조물 id 차례)의 산근.""" + base = structures_dir(project_root) + entries = [ + (owner, base / linked["폴더"]) + for owner, linked in sorted(linked_snapshots(project_root).items()) + if "일위대가" in linked.get("부분", []) + ] + return collect(entries, sheets) diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index 871afeb9a..705b0156f 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -26,6 +26,7 @@ import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav"; import type { Row } from "./B09_Estimation_Calc_Base"; import { computeEstimation } from "./B09_Estimation_Calc_Bill"; +import { computeTotal } from "./B09_Estimation_Calc_Total"; import { buildCheck, rowIndex, @@ -51,6 +52,8 @@ import { buildSumMachine, buildSumMaterial, } from "./B09_Estimation_UI_Tabs_Summary"; +import { buildCost, buildTotal } from "./B09_Estimation_UI_Tabs_Total"; +import { buildSg, buildSgList } from "./B09_Estimation_UI_Tabs_Sangeun"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -59,8 +62,6 @@ function L(key: keyof typeof ui_locales): string { const LATER: Record = { dg_list: "일위대가목록표 — 145-2(다른 창)가 들어오면 생김", dg: "일위대가표 — 145-2(다른 창)가 들어오면 생김", - sg_list: "단가산출근거목록표 — 145-1(다른 창)가 들어오면 생김", - sg: "단가산출근거 — 145-1(다른 창)가 들어오면 생김", }; const BUILDERS: Partial> = { @@ -73,10 +74,14 @@ const BUILDERS: Partial> = { labor: buildLabor, expense: buildExpense, compare: buildCompare, + cost: buildCost, + total: buildTotal, sum_material: buildSumMaterial, sum_labor: buildSumLabor, sum_expense: buildSumExpense, sum_machine: buildSumMachine, + sg_list: buildSgList, + sg: buildSg, }; async function request(path: string, init: RequestInit = {}): Promise { @@ -100,9 +105,21 @@ function note(textValue: string): HTMLElement { return p; } -/** 브라우저 셈 — 서버 짝과 같은 식(조작 중) */ +/** 산근(145-1) = 서버 단독 — 받은 그대로 두는 칸 */ +const SERVER_ONLY = ["sg", "sg_list", "sg_gaps", "sg_notes"] as const; + +/** 브라우저 셈 — 서버 짝과 같은 식(조작 중) · 산근은 서버 답 그대로(+ 「연결 없음」) */ export function computeSheets(payload: Row, user: Row): Row { - return computeEstimation(payload.inputs, user); + const base = computeEstimation(payload.inputs, user); + const server: Row = payload.sheets || {}; + const kept = Object.fromEntries(SERVER_ONLY.map((k) => [k, server[k] ?? []])); + return { + ...base, + ...kept, + gaps: [...base.gaps, ...(server.sg_gaps || [])], + // 145-4 총괄 · 원가계산서 = 설계내역서 비목 합(145-3 `bill.sums`) + 사용자 요율 · 손입력 + total: computeTotal(base.bill?.sums, user.estimate), + }; } export function renderB09Estimation(root: HTMLElement): void { diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts new file mode 100644 index 000000000..57d6d8848 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts @@ -0,0 +1,141 @@ +/* ============================================================================= + * B09_Estimation_UI_Tabs_Sangeun.ts + * B09 단가산출근거 · 단가산출근거목록표 탭(PLAN 145-1) — 값 = 서버 `B09_Estimation_Sangeun` + * (서버 단독 · 이 탭엔 조작 없음 · [저장] · [단가 갱신] 때 서버가 다시 셈). + * + * 목록표 = 산근 호표 한 줄(합계 · 노무비 · 재료비 · 경비 · 1 단위) · 줄 누름 = 그 블록 + * 풀이표 = 블록 머리(제 n 호표) · 줄(노무 · 중기 노 · 재 · 경 · 자재 · 하위 산근 · % 줄) · 총계 + * 줄 누름 = 단가를 낸 탭 · 줄(노무비목록표 · 중기목록표 · 자재단가대비표 · 하위 산근) + * ========================================================================== */ + +import type { DataRow } from "@ui/ui_template_data_table"; +import type { Row } from "./B09_Estimation_Calc_Base"; +import { + money, + qty, + text, + type Link, + type TabBuild, + type TabId, + type TabTable, +} from "./B09_Estimation_UI_Tabs"; + +const PART_NAME: Record = { + labor: "노무비", + material: "재료비", + expense: "경 비", +}; +const PARTS = ["labor", "material", "expense"] as const; + +const notes = (sheets: Row): string => + ((sheets.sg_notes as string[]) || []).map((n) => ` · ${n}`).join(""); + +export const buildSgList: TabBuild = ({ sheets }): TabTable => { + const rows: DataRow[] = ((sheets.sg_list as Row[]) || []).map((r) => ({ + ...r, + note: `쓰는 구조물 ${(r.uses || []).length}`, + })); + return { + columns: [ + qty("no", "호표", 0), + text("name", "명칭", 220), + text("spec", "규격(입력값)", 200), + text("unit", "단위", 56), + money("sum", "합계"), + money("labor", "노무비"), + money("material", "재료비"), + money("expense", "경비"), + text("note", "비고", 110), + ], + rows, + frozen: 2, + hint: + "산근 = 연결된 구조물 조합 사본의 「단가승격」 로직 줄 · 같은 로직 + 같은 입력값 = 한 호표 · 합계 = 블록 총계 · 줄 누름 = 그 블록" + + notes(sheets), + link: (i) => (rows[i] ? { tab: "sg", key: String(rows[i].key) } : null), + }; +}; + +const LINE_TAB: Record = { + labor: "labor", + machine: "machine", + compare: "compare", + sg: "sg", +}; + +export const buildSg: TabBuild = ({ sheets }): TabTable | string => { + const rows: DataRow[] = []; + const links: Array = []; + const push = (row: DataRow, link: Link | null = null): void => { + rows.push(row); + links.push(link); + }; + for (const b of (sheets.sg as Row[]) || []) { + push({ + key: b.key, + name: `제 ${b.no} 호표 ${b.name}`, + spec: b.spec, + unit: b.unit, + sum: b.sum, + labor: b.labor, + material: b.material, + expense: b.expense, + }); + if (b.source) push({ key: `${b.key}|출처`, name: ` ${b.source}` }); + (b.lines as Row[]).forEach((ln, n) => { + const link = ln.ref?.key ? { tab: LINE_TAB[ln.ref.tab] ?? "compare", key: ln.ref.key } : null; + // 중기 = 노 · 재 · 경 세 줄(엑셀 꼴) · 그 밖 = 한 줄 + const parts = ln.kind === "machine" ? PARTS.filter((p) => ln.price?.[p] !== 0) : [null]; + for (const part of parts) { + const at = (p: string): number | null => (part === null || part === p ? ln[p] : 0); + const price = part ? ln.price?.[part] : (Object.values(ln.price || {})[0] ?? null); + push( + { + key: `${b.key}|${n}${part ? `|${part}` : ""}`, + name: ` ${ln.name}${part ? ` ${PART_NAME[part]}` : ""}`, + spec: ln.why ? `${ln.spec ? `${ln.spec} · ` : ""}${ln.why}` : ln.spec, + qty: ln.qty, + unit: ln.unit, + price: price as number | null, + sum: PARTS.some((p) => at(p) === null) + ? null + : PARTS.reduce((s, p) => s + (at(p) as number), 0), + labor: at("labor"), + material: at("material"), + expense: at("expense"), + }, + link, + ); + } + }); + push({ + key: `${b.key}|총계`, + name: " 총 계", + spec: b.why ?? "", + sum: b.sum, + labor: b.labor, + material: b.material, + expense: b.expense, + }); + } + if (!rows.length) return ((sheets.sg_notes as string[]) || []).join(" · ") || "산근 없음"; + return { + columns: [ + text("name", "공종 · 산출내역", 260), + text("spec", "규격 · 까닭", 180), + qty("qty", "수량"), + text("unit", "단위", 56), + money("price", "단가", undefined, 1), + money("sum", "합계", undefined, 1), + money("labor", "노무비", undefined, 1), + money("material", "재료비", undefined, 1), + money("expense", "경비", undefined, 1), + ], + rows, + frozen: 1, + hint: + "수량 = 로직 품(1 단위 · 작업량 Q = ROUND 2) · 단가 = 굳힌 B09 단가 · 줄 ROUNDDOWN 1 · 총계 비목마다 ROUNDDOWN 0 · 자재 적용 단가를 고치면 [저장] 뒤 반영 · 줄 누름 = 단가를 낸 탭" + + notes(sheets), + link: (i: number) => links[i] ?? null, + }; +}; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts new file mode 100644 index 000000000..d24914c29 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts @@ -0,0 +1,224 @@ +/* ============================================================================= + * B09_Estimation_UI_Tabs_Total.ts + * B09 총괄설계내역서 · 공사원가계산서 탭(PLAN 145-4) — 실무 엑셀 꼴 · 값 = `B09_Estimation_Calc_Total`. + * + * 총괄 = 가. 순공사비계(설계내역서 비목 합 · 145-3) + 제비율 줄 + 소계(나~바) + * 고칠 칸 = 요율(빈칸 시작 · 후보 = M01 조달청 제비율 글) · 손입력(기초액 · 이윤 보정 · + * 부가세 조정 · 조달수수료율) · 엑셀에 없는 현행 비목 = 줄만(빈칸) + * 원가계산서 = 총괄 값 거울 + 구성비 · 고칠 칸 = 간접재료비 · 작업설 · 줄 누름 = 총괄 그 줄로 + * ========================================================================== */ + +import type { DataRow } from "@ui/ui_template_data_table"; +import type { Row } from "./B09_Estimation_Calc_Base"; +import { CURRENT_ONLY, RATE_LINES, TAIL_LINES } from "./B09_Estimation_Calc_Total"; +import { + money, + parseNumber, + qty, + text, + type TabBuild, + type TabContext, + type TabTable, +} from "./B09_Estimation_UI_Tabs"; + +type Num = number | null; + +const won = (v: Num): string => (v === null ? "빈칸" : Math.round(v).toLocaleString("en-US")); + +/** 손입력 칸 — 줄 키 → user.estimate 칸 */ +const HAND: Record = { + safety: ["safety_base", "기초액(원)"], + profit: ["profit_adjust", "이윤 보정(원 · 뺌)"], + vat: ["vat_adjust", "부가세 조정(원)"], + supply: ["procurement_fee_pct", "조달수수료율(%)"], + indirect_material: ["indirect_material", "간접재료비(원)"], + scrap: ["scrap", "작업설 부산물(원)"], +}; +const RATE_KEYS = new Set([...RATE_LINES, ...TAIL_LINES].map(([key]) => key)); + +/** user.estimate 한 칸 고침 — 빈칸이면 지움(빈칸 = 요율 줄 빈칸 · 손입력 0) */ +function editEstimate(ctx: TabContext, rowKey: string, column: string, value: string): void { + const v = parseNumber(value); + if (v === undefined) return; + const estimate: Row = { ...(ctx.user.estimate || {}) }; + if (column === "rate" && RATE_KEYS.has(rowKey)) { + const rates: Row = { ...(estimate.rates || {}) }; + if (v === null) delete rates[rowKey]; + else rates[rowKey] = v; + estimate.rates = rates; + } else if (HAND[rowKey]) { + const field = HAND[rowKey][0]; + if (v === null) delete estimate[field]; + else estimate[field] = v; + } else return; + ctx.setUser({ ...ctx.user, estimate }); +} + +function rateText(formula: string, rate: Num): string { + return rate === null ? `${formula}(요율 빈칸)` : formula.replace("율", `${rate}%`); +} + +export const buildTotal: TabBuild = (ctx): TabTable => { + const t: Row = ctx.sheets.total || {}; + const est: Row = ctx.user.estimate || {}; + const rates: Row = est.rates || {}; + const cand: Row = ctx.payload.rate_candidates || {}; + const line: Row = t.lines || {}; + const sub: Row = t.subtotals || {}; + const rate = (key: string): Num => (typeof rates[key] === "number" ? rates[key] : null); + const hand = (key: string): Num => { + const v = est[HAND[key][0]]; + return typeof v === "number" ? v : null; + }; + const row = ( + key: string, + part: string, + name: string, + amount: Num, + note = "", + extra: Partial = {}, + ): DataRow => ({ + key, + part, + name, + amount, + note, + rate: null, + hand: null, + candidate: "", + ...extra, + }); + const rateRow = ([key, name, rt, formula]: [string, string, string, string], no: number) => + row(key, "", `${no}. ${name}`, line[key] ?? null, rateText(formula, rate(key)), { + rate: rate(key), + candidate: cand[rt] ?? rt, + ...(HAND[key] ? { hand: hand(key) } : {}), + }); + const safety: Row = t.safety || {}; + const supply: Row = t.supply || {}; + const sums: Row = t.sums || {}; + const rows: DataRow[] = [ + row("direct", "가.", "순공사비계", sums.direct ?? null, "설계내역서 비목 합(145-3)", { + labor: sums.labor ?? null, + material: sums.material ?? null, + expense: sums.expense ?? null, + }), + ...RATE_LINES.map((spec, i) => rateRow(spec, i + 1)), + ...CURRENT_ONLY.map(([key, name, rt]) => + row(key, "", name, null, "현행 비목 · 엑셀에 없음 — 밑수 · 고르는 값 미정(줄만)", { + candidate: cand[rt] ?? rt, + }), + ), + row("net_cost", "나.", "소계(순공사원가)", sub.net_cost ?? null), + rateRow(TAIL_LINES[0], RATE_LINES.length + 1), + row("da", "다.", "소계", sub.da ?? null), + rateRow(TAIL_LINES[1], RATE_LINES.length + 2), + row("supply_price", "라.", "공급가액", sub.supply_price ?? null), + rateRow(TAIL_LINES[2], RATE_LINES.length + 3), + row("contract", "마.", "도급공사비", sub.contract ?? null), + row( + "supply", + "", + `${RATE_LINES.length + 4}. 관급자재대`, + line.supply ?? null, + `순자재대 ${won(supply.net ?? null)} + 조달수수료 ${won(supply.fee ?? null)} → 천원 올림`, + { hand: hand("supply") }, + ), + row("grand", "바.", "총공사비", sub.grand ?? null), + ]; + const safetyRow = rows.find((r) => r.key === "safety"); + if (safetyRow && safety.a !== null && safety.a !== undefined) + safetyRow.note = `A(관급 ÷ 1.1 포함) ${won(safety.a)} · B(× 1.2) ${won(safety.b)} → 작은 쪽`; + const profitRow = rows.find((r) => r.key === "profit"); + if (profitRow && typeof t.profit_raw === "number") + profitRow.note = `${profitRow.note} · 보정 전 ${won(t.profit_raw)}`; + return { + columns: [ + text("part", "공종", 48), + text("name", "명칭", 210), + money("amount", "합계"), + money("labor", "노무비"), + money("material", "재료비"), + money("expense", "경비"), + qty("rate", "요율(%)", 5), + money("hand", "손입력", undefined, 2), + text("note", "산식", 320), + text("candidate", "요율 후보(확정 아님)", 260), + ], + rows, + frozen: 2, + hint: + "끝수 = 엑셀 그대로(줄마다 원 버림 · 관급 천원 올림) · 요율 = 빈칸 시작 — 빈칸이면 그 줄 · 그 줄을 쓰는 소계 빈칸 · " + + "손입력 = 안전관리비 기초액 · 이윤 보정 · 부가세 조정 · 조달수수료율(빈칸 = 0) · 공종 제목 줄은 설계내역서(145-3)가 들어오면 붙음", + editable: (i, key) => + (key === "rate" && RATE_KEYS.has(String(rows[i]?.key))) || + (key === "hand" && String(rows[i]?.key) in HAND), + onEdit: (i, key, value) => editEstimate(ctx, String(rows[i]?.key), key, value), + }; +}; + +/** 원가계산서 줄 [키(cost · 총괄 줄), 구분, 비목, 세목] */ +const COST_ROWS: Array<[string, string, string, string, string?]> = [ + ["direct_material", "순공사원가", "재료비", "직접재료비", "material"], + ["indirect_material", "순공사원가", "재료비", "간접재료비"], + ["scrap", "순공사원가", "재료비", "작업설 · 부산물 등(△)"], + ["material", "순공사원가", "재료비", "소계", "direct"], + ["direct_labor", "순공사원가", "노무비", "직접노무비", "direct"], + ["indirect_labor", "순공사원가", "노무비", "간접노무비", "indirect_labor"], + ["labor", "순공사원가", "노무비", "소계", "direct"], + ["expense", "순공사원가", "경비", "산출경비", "direct"], + ...RATE_LINES.filter(([key]) => key !== "indirect_labor").map( + ([key, name]) => + [key, "순공사원가", "경비", name, key] as [string, string, string, string, string], + ), + ["expense_sum", "순공사원가", "경비", "소계", "net_cost"], + ["overhead", "일반관리비", "", "", "overhead"], + ["profit", "이윤", "", "", "profit"], + ["supply_price", "총원가", "", "", "supply_price"], + ["vat", "부가가치세", "", "", "vat"], + ["contract", "도급공사비", "", "", "contract"], + ["supply", "관급자재대", "", "", "supply"], + ["grand", "총공사비", "", "", "grand"], +]; + +export const buildCost: TabBuild = (ctx): TabTable => { + const t: Row = ctx.sheets.total || {}; + const cost: Row = t.cost || {}; + const share: Row = t.share || {}; + const rates: Row = (ctx.user.estimate || {}).rates || {}; + const formula: Record = Object.fromEntries( + [...RATE_LINES, ...TAIL_LINES].map(([key, , , f]) => [ + key, + rateText(f, typeof rates[key] === "number" ? rates[key] : null), + ]), + ); + const rows: DataRow[] = COST_ROWS.map(([key, group, part, name, link]) => ({ + key, + group, + part, + name, + amount: cost[key] ?? null, + share: share[key] ?? null, + note: formula[key] ?? "", + link: link ?? "", + })); + return { + columns: [ + text("group", "구분", 96), + text("part", "비목", 72), + text("name", "세목", 180), + money("amount", "금액"), + qty("share", "구성비(%)", 2), + text("note", "비고", 320), + ], + rows, + frozen: 3, + hint: "총괄설계내역서 값 거울 · 구성비 = 총공사비 대비 · 고칠 칸 = 간접재료비 · 작업설(직접재료비가 그만큼 바뀌고 재료비 소계는 그대로 — 엑셀 꼴) · 줄 누름 = 총괄 그 줄", + editable: (i, key) => key === "amount" && String(rows[i]?.key) in HAND, + onEdit: (i, key, value) => editEstimate(ctx, String(rows[i]?.key), key, value), + link: (i) => { + const target = String(rows[i]?.link || ""); + return target ? { tab: "total", key: target } : null; + }, + }; +}; diff --git a/M01_MasterData/M01_MasterData_Store_Cache.py b/M01_MasterData/M01_MasterData_Store_Cache.py index ac1be8ad8..f57ec38f8 100644 --- a/M01_MasterData/M01_MasterData_Store_Cache.py +++ b/M01_MasterData/M01_MasterData_Store_Cache.py @@ -221,19 +221,19 @@ def check_saved(files: dict, changed: list[str], book: dict, narrowed=None) -> l def _labor_files() -> list[tuple[str, Path]]: - """모든 층(마스터 · 회사 · 개인)의 일위대가 `labor/<열>.json` · 단가승격 `promote/<키>.json` - · 사전조합 `precombo/<키>.json`(51-11) — (어디, 파일).""" + """모든 층(마스터 · 회사 · 개인)의 일위대가 `labor/<열>.json` · 사전조합 `precombo/<키>.json`(51-11) — + (어디, 파일).""" from M02_MasterTemplete import M02_Template_Layers as layers # M02 가 M01 을 부름 — 늦게 return [ (f"{what} {where}", path) - for kind, what in (("labor", "일위대가"), ("promote", "단가승격"), ("precombo", "사전조합")) + for kind, what in (("labor", "일위대가"), ("precombo", "사전조합")) for where, path in layers.layer_files(kind) ] def labor_errors(whole) -> list[str]: - """일위대가 · 단가승격 · 사전조합 문서가 담은 로직이 마스터에 없음(39-1d · 50-3) — + """일위대가 · 사전조합 문서가 담은 로직 마스터에 없음(39-1d · 50-3) — 로직 지우기를 막음 · 어디서 쓰는지 알림 · 참조 줄(로직 없음)은 건너뜀. 전부터 깨진 것은 `save` 가 고치기 전 검사와 견주어 뺌.""" out = [] diff --git a/M02_MasterTemplete/M02_Labor_Doc.py b/M02_MasterTemplete/M02_Labor_Doc.py index 1eef8d95f..5c99a203a 100644 --- a/M02_MasterTemplete/M02_Labor_Doc.py +++ b/M02_MasterTemplete/M02_Labor_Doc.py @@ -1,7 +1,7 @@ """M02 일위대가 문서 `labor/<열id>.json` — 정리 · 검사 · 계산 · 알약(PLAN 39-1a · 설계_정함 1장). 문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬, -담은로직: [{로직, 별칭, 메모, 입력값, 수량, 켜쌓기율?, 고름?} | {참조: {층, 주인, 키}, 별칭, 메모, 수량}], +담은로직: [{로직, 별칭, 메모, 입력값, 수량, 켜쌓기율?, 고름?, 승격?} | {참조: {층, 주인, 키}, 별칭, 메모, 수량}], 자재: [{코드, 이름, 규격, 단위, 수량}], 계산값}` — 이름은 구조물 열 이름(따로 안 둠). 수량 = `null`(면적산출 알약 칸 값 · 로직 알약 「이름|단위」 · 자재 알약 「자재|MT키」) | `{손: 수}` (손이 칸보다 앞섬) · 같은 로직 두 번 금지 · 입력값 = 그 로직 입력 이름만(빈 값 = 자동값). @@ -17,6 +17,7 @@ 로직 자재(103-1) = 담은 로직 맨 위 층 호표 재료 줄(`logic_mats` · 줄마다 `재료`) · 고름 = `{호표 차례: MT키}`(엔진 `choosing` 그대로) · 금액은 로직 재료비 안이라 합 · 알약에 안 더함(보기만) · 사용자 자재와 같은 품목 두 번은 [저장] 때 400. +승격(134-1) = 로직 줄 「단가승격」 표시(`true` 만 · 없음 = 단가산출) · 금액 무관 · B09 호표 몫. """ from __future__ import annotations @@ -124,6 +125,7 @@ def clean(doc: Any) -> dict[str, Any]: "수량": _qty(one.get("수량"), where), **({"켜쌓기율": rate} if rate is not None else {}), **({"고름": picks} if picks else {}), + **({"승격": True} if one.get("승격") is True else {}), } ) mats, codes = [], set() diff --git a/M02_MasterTemplete/M02_Labor_Ref.py b/M02_MasterTemplete/M02_Labor_Ref.py index 9c5228fe3..765262be8 100644 --- a/M02_MasterTemplete/M02_Labor_Ref.py +++ b/M02_MasterTemplete/M02_Labor_Ref.py @@ -1,9 +1,9 @@ -"""M02 일위대가 조합 서고 — 단가승격 · 사전조합 · 참조(PLAN 50-3 · 조사 갈래 A). +"""M02 일위대가 사전조합 서고 · 참조(PLAN 50-3 · 조사 갈래 A · 134-2 단가승격 서고 지움). -자리 = 층 양식 길 새 종류 둘(`M02_Template_Layers.LIBRARY_KINDS`) — 파일 이름 = 키: - promote `{층}/promote/SP-######.json` 단가승격 - `{종류:"단가승격", 키, 이름, 단위, 배, 본뜬, 담은로직:[{로직, 입력값}]}` — - 줄 하나 · 입력값은 승격 때 고정 · 값 = 로직 기준 ÷ 배(1 단위 값 · `배` 는 저장 때) +자리 = 층 양식 길 종류(`M02_Template_Layers.LIBRARY_KINDS`) — 파일 이름 = 키: + promote 옛 단가승격 `{층}/promote/SP-######.json`(134-2 · 새로 안 만듦 · 읽기 · 셈만) — + `{종류:"단가승격", 키, 이름, 단위, 배, 담은로직:[{로직, 입력값}]}` · 값 = 로직 기준 ÷ 배 · + 층 문서를 읽을 때 옛 참조 줄 = 로직 줄 + `승격`(`unpromote`) · 프로젝트 사본은 그대로 셈 precombo `{층}/precombo/SC-######.json` 사전조합 `{종류:"사전조합", 키, 이름, 단위, 본뜬, 담은로직:[{참조, 수량:{손}, 메모} | {로직, 입력값, 메모, 수량:{손}}]}` — 승격 · 사전조합 · 단가산출 로직(51-11) · @@ -27,8 +27,8 @@ from typing import Any from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete import M02_Template_Layers as layers -PREFIX = {"promote": "SP", "precombo": "SC"} -TITLE = {"promote": "단가승격", "precombo": "사전조합"} +PREFIX = {"precombo": "SC"} +TITLE = {"precombo": "사전조합"} DEPTH = 8 # 참조 깊이 상한(저장 · 계산 둘 다) REF_HEAD = "참조|" _KEY = re.compile(r"^(SP|SC)-(\d{6})$") @@ -133,61 +133,67 @@ def check_key(kind: str, name: str) -> None: def clean_doc(kind: str, doc: Any) -> dict[str, Any]: """서고 문서를 서버가 받는 꼴로 — `doc["키"]` = 파일 이름(부르는 쪽이 넣음).""" - from M01_MasterData import M01_MasterData_Store_Combo as combo_store - from M02_MasterTemplete import M02_Labor_Doc as labor - if not isinstance(doc, dict): raise _bad("서고 문서가 묶음 아님") key = str(doc.get("키") or "") check_key(kind, key) name, unit = str(doc.get("이름") or "").strip(), str(doc.get("단위") or "").strip() out: dict[str, Any] = {"종류": TITLE[kind], "키": key} - if kind == "promote": - lines = doc.get("담은로직") or [] - if len(lines) != 1 or not isinstance(lines[0], dict) or not lines[0].get("로직"): - raise _bad("단가승격은 단가산출 로직 한 줄") - line = labor.clean({"담은로직": lines})["담은로직"][0] - pill = (combo_store.combo_pills({"담은로직": [line]})["물량"] or [{}])[0] - out.update( - 이름=name or pill.get("이름") or line["로직"], - 단위=unit or pill.get("단위") or None, - 배=pill.get("배") or 1, # ponytail: 저장 때 적음 · 마스터 결과단위가 바뀌면 다시 저장 - 담은로직=[{k: line[k] for k in ("로직", "입력값", "켜쌓기율") if k in line}], - **origin(line["로직"]), - ) - else: - if not name: - raise _bad("사전조합 이름이 빔") - out.update(이름=name, 단위=unit or None, 담은로직=_combo_lines(doc, key)) - out.update({k: str(doc.get(k) or "").strip() for k in ("구분", "상세구분")}) + if not name: + raise _bad("사전조합 이름이 빔") + out.update(이름=name, 단위=unit or None, 담은로직=_combo_lines(doc, key)) + out.update({k: str(doc.get(k) or "").strip() for k in ("구분", "상세구분")}) out["본뜬"] = doc["본뜬"] if isinstance(doc.get("본뜬"), dict) else None return out -def origin(code: str) -> dict[str, Any]: - """단가승격 칸 — 원 로직의 구분 · 상세구분 + 연결 {로직 키, 출처 글}(51-12 · 없으면 빈 글).""" - from M01_MasterData import M01_MasterData_Store as m01 - - try: - got = m01.loaded()[1].logic(code) - except m01.cm.mf.FormulaError: - got = {} - return { - "구분": got.get("구분") or "", - "상세구분": got.get("상세구분") or "", - "연결": {"로직": code, "출처": got.get("출처") or ""}, - } +_OLD_CLASS = re.compile(r"품셈") # 134-5 — 옛 품셈 분류(건설품셈 공통 · 산림품셈 · 07장 …) def filled(kind: str, doc: Any) -> Any: - """옛 서고 문서(51-12 앞) 읽을 때 칸 채움 — 승격 = 원 로직 것 · 사전조합 = 빈 글(화면 몫).""" - if not isinstance(doc, dict) or kind not in PREFIX or "구분" in doc: + """옛 서고 문서 읽을 때 칸 채움 — 칸 없음(51-12 앞) · 옛 품셈 분류(134-5) = 빈 글(미분류) · + 파일은 안 고침([저장] 때 굳음) · 그 밖 지금 값 그대로(구조물정의가 바뀌어도 화면이 선택지로 둠).""" + if not isinstance(doc, dict) or kind not in PREFIX: return doc - if kind == "precombo": - return {**doc, "구분": "", "상세구분": ""} - line = (doc.get("담은로직") or [{}])[0] - code = str(line.get("로직") or "") if isinstance(line, dict) else "" - return {**doc, **origin(code)} + if "구분" in doc and not _OLD_CLASS.search(str(doc.get("구분") or "")): + return doc + return {**doc, "구분": "", "상세구분": ""} + + +def unpromote(kind: str, doc: Any) -> Any: + """층 문서를 읽을 때(134-2) — 옛 단가승격 참조 줄 → 로직 줄 `{로직, 입력값, 켜쌓기율, 수량}` + + 일위대가면 `승격: true`(사전조합 안 로직 줄엔 없음) · 값 같음(승격 값 = 기준 ÷ 배 = 로직 줄 반영) · + 같은 로직 줄이 이미 있으면 그 줄은 옛 참조 그대로(읽기 · 셈 · 저장 됨) · 파일은 안 고침([저장] 때 굳음).""" + lines = doc.get("담은로직") if isinstance(doc, dict) and kind in ("labor", "precombo") else None + if not isinstance(lines, list): + return doc + have = {str(one.get("로직")) for one in lines if isinstance(one, dict) and one.get("로직")} + out, changed = [], False + for one in lines: + ref = one.get("참조") if isinstance(one, dict) else None + src = ( + read_ref(ref) if isinstance(ref, dict) and kind_of(ref.get("키")) == "promote" else None + ) + line = ((src or {}).get("담은로직") or [None])[0] + code = str(line.get("로직") or "") if isinstance(line, dict) else "" + if not code or code in have: + out.append(one) + continue + have.add(code) + changed = True + # ponytail: 수량 null(면적산출 칸 연결)은 알약 키가 로직 것으로 바뀌어 칸을 다시 이어야 함 + out.append( + { + "로직": code, + "별칭": one.get("별칭"), + "메모": one.get("메모"), + "입력값": dict(line.get("입력값") or {}), + "수량": one.get("수량"), + **{k: line[k] for k in ("켜쌓기율", "고름") if line.get(k) is not None}, + **({"승격": True} if kind == "labor" else {}), + } + ) + return {**doc, "담은로직": out} if changed else doc def _combo_lines(doc: dict, key: str) -> list[dict[str, Any]]: @@ -314,7 +320,7 @@ def carry(doc: dict[str, Any], source: Here) -> dict[str, Any]: return ref if ref_id(ref) in done: return done[ref_id(ref)] - if depth > DEPTH or not readable(source, ref): + if depth > DEPTH or not readable(source, ref) or kind_of(ref.get("키")) == "promote": raise _bad(f"복사할 수 없는 참조 「{ref.get('키')}」") got = read_ref(ref) if got is None: diff --git a/M02_MasterTemplete/M02_Labor_Router.py b/M02_MasterTemplete/M02_Labor_Router.py index 481ee952f..a11395af4 100644 --- a/M02_MasterTemplete/M02_Labor_Router.py +++ b/M02_MasterTemplete/M02_Labor_Router.py @@ -81,7 +81,7 @@ def _copy(kind: str, doc: dict[str, Any], source: refs.Here, ref: dict) -> dict[ @router.post("/layers/{layer}/library/{kind}") def new_library( layer: Layer, - kind: Literal["promote", "precombo"], + kind: Literal["precombo"], body: LibraryBody, owner: str | None = None, session: Session = Depends(verify_session), diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index 4dd0f674d..455daa988 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -10,8 +10,8 @@ export type Layer = "system" | "company" | "personal" | "project"; /** 서버 종류 — 화면의 「표 양식」 = table · 「도면 양식」 = drawing · 「구조물 도면」 = structure · * 「면적산출」 = area(구조물집계표 열마다 한 통합문서) · * 「일위대가」 = labor(열마다 문서 · 39장) · - * 「단가승격」 = promote · 「사전조합」 = precombo(일위대가 조합 서고 · 50-3 · 키가 이름) */ -export type Kind = "table" | "drawing" | "structure" | "area" | "labor" | "promote" | "precombo"; + * 「사전조합」 = precombo(일위대가 사전조합 서고 · 50-3 · 키가 이름 · 옛 단가승격 promote 는 134-2 에서 뺌) */ +export type Kind = "table" | "drawing" | "structure" | "area" | "labor" | "precombo"; export interface TemplateInfo { 종류: Kind; @@ -70,26 +70,39 @@ export const getActiveLayer = (): Layer => activeLayer; export const getActiveOwner = (): string | null => activeOwner; const enc = encodeURIComponent; -/** 지금 층 길이면 `owner` 인자를 붙임 */ -const owned = (layer: Layer, path: string): string => - layer === activeLayer && activeOwner && layer !== "system" && layer !== "project" - ? `${path}${path.includes("?") ? "&" : "?"}owner=${enc(activeOwner)}` +/** 지금 층 길이면 `owner` 인자를 붙임 · `owner` 를 넘기면(134-3 · 사전조합 층) 그 주인만(null = 자기 층) */ +const owned = (layer: Layer, path: string, owner?: string | null): string => { + const who = owner === undefined ? (layer === activeLayer ? activeOwner : null) : owner; + return who && layer !== "system" && layer !== "project" + ? `${path}${path.includes("?") ? "&" : "?"}owner=${enc(who)}` : path; +}; const scoped = (path: string, init: RequestInit = {}): Promise => call(owned(activeLayer, `/layers/${activeLayer}${path}`), init); const q = (projectId: string | null): string => (projectId ? `?project_id=${enc(projectId)}` : ""); const head = (layer: Layer): string => `/layers/${layer}/templates`; -const item = (layer: Layer, kind: Kind, name: string, projectId: string | null): string => - owned(layer, `${head(layer)}/${kind}/${enc(name)}${layer === "system" ? "" : q(projectId)}`); +const item = ( + layer: Layer, + kind: Kind, + name: string, + projectId: string | null, + owner?: string | null, +): string => + owned( + layer, + `${head(layer)}/${kind}/${enc(name)}${layer === "system" ? "" : q(projectId)}`, + owner, + ); /** 층 길은 `{층, 양식: [...]}` · 옛 시스템 길은 배열 — 둘 다 목록으로 */ export const listTemplates = async ( layer: Layer, projectId: string | null, + owner?: string | null, ): Promise => { const got = await call( - owned(layer, `${head(layer)}${layer === "system" ? "" : q(projectId)}`), + owned(layer, `${head(layer)}${layer === "system" ? "" : q(projectId)}`, owner), ); return Array.isArray(got) ? got : got.양식; }; @@ -99,7 +112,8 @@ export const readTemplate = ( kind: Kind, name: string, projectId: string | null, -): Promise => call(item(layer, kind, name, projectId)); + owner?: string | null, +): Promise => call(item(layer, kind, name, projectId, owner)); export const saveTemplate = ( layer: Layer, @@ -108,8 +122,12 @@ export const saveTemplate = ( projectId: string | null, 판: string, 문서: unknown, + owner?: string | null, ): Promise => - call(item(layer, kind, name, projectId), { method: "PUT", body: JSON.stringify({ 판, 문서 }) }); + call(item(layer, kind, name, projectId, owner), { + method: "PUT", + body: JSON.stringify({ 판, 문서 }), + }); /** 시스템 층만 지움 */ export const deleteTemplate = (kind: Kind, name: string, 판: string): Promise<{ ok: boolean }> => @@ -121,8 +139,9 @@ export const deleteLayerTemplate = ( kind: Kind, name: string, 판: string, + owner?: string | null, ): Promise<{ ok: boolean }> => - call(owned(layer, `/layers/${layer}/templates/${kind}/${enc(name)}?판=${enc(판)}`), { + call(owned(layer, `/layers/${layer}/templates/${kind}/${enc(name)}?판=${enc(판)}`, owner), { method: "DELETE", }); @@ -131,10 +150,11 @@ export type LibraryBody = { 문서: Record } | { 본뜬: { 층: Layer; 주인: string | null; 키: string } }; export const newLibrary = ( layer: Layer, - kind: "promote" | "precombo", + kind: "precombo", body: LibraryBody, + owner?: string | null, ): Promise => - call(owned(layer, `/layers/${layer}/library/${kind}`), { + call(owned(layer, `/layers/${layer}/library/${kind}`, owner), { method: "POST", body: JSON.stringify(body), }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.css b/M02_MasterTemplete/M02_MasterTemplete_Labor.css index 25c01e014..550e2ec7b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor.css @@ -204,6 +204,16 @@ color: var(--color-text-secondary); } +/* 구분 드롭다운(134-1) — 칸 너비만 · 품셈 책 글은 아래 */ +.m02-labrow__kind .ui-field { + display: block; +} + +.m02-labrow__kind .ui-select { + width: auto; + min-width: 0; +} + /* 줄 접고 폄 표(51-2) · 고른 줄 하위 행 — 이름 들여쓰기 · 흐린 바탕 · 위 줄보다 촘촘(51-7) */ .m02-labrow .m02-labrow__sub td { padding: var(--spacing-4) var(--spacing-8); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts index 47275de84..6f1fa2a76 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts @@ -133,6 +133,8 @@ export interface LaborRow { 켜쌓기율?: number | null; /** 고른 로직 자재(103-1) — {호표 차례: MT키} · 없으면 엔진 기본 */ 고름?: Record; + /** 단가승격(134-1 · 로직 줄 구분 드롭다운) — 없음 = 단가산출 · 금액 무관 · B09 호표 몫 */ + 승격?: boolean; } export interface LaborMat { 코드: string; @@ -170,6 +172,7 @@ export function normalizeLabor(doc: unknown, column: string): LaborDoc { ...(r.참조 ? { 참조: r.참조 } : {}), ...(r.켜쌓기율 != null ? { 켜쌓기율: r.켜쌓기율 } : {}), ...(r.고름 && Object.keys(r.고름).length ? { 고름: { ...r.고름 } } : {}), + ...(r.승격 === true && !r.참조 ? { 승격: true } : {}), })), 자재: (d.자재 ?? []).map((m) => ({ 코드: m.코드, @@ -215,9 +218,9 @@ export type { Money as LaborCost, } from "./M02_MasterTemplete_Labor_Calc"; -/** 지금 층이면 `owner` 를 붙인 층 길(`/layers/{층}/labor/{열}…`) */ -const laborAt = (layer: Layer, column: string, tail: string): string => { - const owner = layer === getActiveLayer() ? getActiveOwner() : null; +/** 지금 층이면 `owner` 를 붙인 층 길(`/layers/{층}/labor/{열}…`) · `who` 를 넘기면 그 주인(134-3) */ +const laborAt = (layer: Layer, column: string, tail: string, who?: string | null): string => { + const owner = who !== undefined ? who : layer === getActiveLayer() ? getActiveOwner() : null; const q = owner && layer !== "system" && layer !== "project" ? `?owner=${enc(owner)}` : ""; return `/layers/${layer}/labor/${enc(column)}${tail}${q}`; }; @@ -228,8 +231,9 @@ export const calcLabor = ( column: string, doc: LaborDoc | null, 수량?: Record, + owner?: string | null, ): Promise => - request(laborAt(layer, column, "/calc"), "POST", { + request(laborAt(layer, column, "/calc", owner), "POST", { ...(doc ? { doc } : {}), ...(수량 ? { 수량 } : {}), }); @@ -239,14 +243,14 @@ export interface LibraryRow { 이름: string; 제목?: string | null; 단위?: string | null; - /** 단가승격 = 원 로직 것 · 사전조합 = 사용자가 고름(51-12) */ + /** 사전조합 = 사용자가 고름(51-12) */ 구분?: string | null; 상세구분?: string | null; } -/** 서고 목록 — 그 층(주인) 단가승격 `promote` · 사전조합 `precombo` 문서 */ +/** 서고 목록 — 그 층(주인) 사전조합 `precombo` 문서 */ export const fetchLibrary = async ( ref: Omit, - kind: "promote" | "precombo", + kind: "precombo", ): Promise => { const q = ref.주인 && ref.층 !== "system" ? `?owner=${enc(ref.주인)}` : ""; const got = await call<{ 양식: (LibraryRow & { 종류: string })[] }>( diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index 098950a16..b1a4047a7 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -10,9 +10,9 @@ * · 자재목록 = 로직 자재 줄(103-1 · `Labor_LogicMats` · [바꾸기] → `담은로직[].고름` · 입력이 바뀌면 걸린 고름 풂) + * 사용자 자재 줄([삭제]) · 로직 자재와 같은 품목은 사용자 자재로 못 담음 * · 오른쪽 「원문」 접는 패널(47-4 · 상세만 · 공용 `ui_template_side_panels`) = 고른 줄 로직의 출처 원문 - * · 서고 모드(50-3 · `mode`) = 단가승격 · 사전조합 — 이름 칸 · 단가산출 로직 표 · 원문만(자재 · 면적 · 다이어그램 없음) · - * 정보 칸 구분 · 상세구분(51-12 · 승격 = 읽기만 + 연결 · 사전조합 = 고름) · - * 승격은 로직 한 줄(입력값 고정 · 더하기 · 순서 · 수량 없음) · 사전조합 = 일위대가 상세와 같은 [로직 추가] · 표 · 동작(51-11) · 수량 손 입력(기본 1) + * · 서고 모드(50-3 · `mode`) = 사전조합 — 이름 칸 · 단가산출 로직 표 · 원문만(자재 · 면적 · 다이어그램 없음) · + * 정보 칸 구분 · 상세구분(51-12 · 고름) · 일위대가 상세와 같은 [로직 추가] · 표 · 동작(51-11) · 수량 손 입력(기본 1) + * · 로직 줄 구분 드롭다운(134-1 · 단가산출 | 단가승격 · 서고 모드엔 없음) * ========================================================================== */ import { @@ -27,7 +27,6 @@ import { ROUTES } from "@config/config_frontend"; import { navigateTo } from "../A00_Common/router"; import { setPendingLogicKey } from "../M01_MasterData/M01_MasterData_UI_Side"; import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source"; -import type { SubBrief } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet"; type Recalc = (typeof import("../A00_Common/spreadsheet/spreadsheet_recalc"))["recalcWorkbook"]; import { @@ -43,7 +42,6 @@ import { lineId, settle, type LaborRef } from "./M02_MasterTemplete_Labor_Calc"; import { areaLinks, calcLabor, - fetchLibLogicSubs, fetchLogicInputs, normalizeLabor, type LaborCalc, @@ -68,6 +66,8 @@ import { insideKeys, releaseTied } from "./M02_Labor_LogicMats_Model"; import { keepKnown } from "./M02_Labor_Values_Model"; import { pickMaterial } from "./M02_Material_Pick"; import { createUnitPick, NO_UNIT } from "./DataFlow/M02_DF_UnitPick"; +import { fetchStructureDef } from "./StructOptions/M02_SO_Api"; +import { precomboClasses } from "./M02_MasterTemplete_UI_Side_Precombo"; import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css"; import "../M01_MasterData/M01_MasterData_UI_LogicLab_Style.css"; import "@ui/ui_template_general_layout.css"; @@ -84,8 +84,10 @@ export interface LaborDetailOptions { /** 구조물 조합 카드 갈래(43-4) — 단가산출 로직 표 + 자재목록만 · 위로 · 아래로 · 로직 추가 · * 자재 추가 · 정보(단위 · 본뜬) · 다이어그램 없음 · 옵션 · 삭제 · 줄 · 수량 모달은 상세와 같음 */ card?: boolean; - /** 일위대가 조합 서고(50-3) — 단가승격 · 사전조합 문서(`column` = 키) */ - mode?: "promote" | "precombo"; + /** 일위대가 사전조합 서고(50-3) — 사전조합 문서(`column` = 키) */ + mode?: "precombo"; + /** 그 층 주인(134-3 · 사전조합 층 · null = 자기 층) — 없으면 페이지 층 것 */ + owner?: string | null; } export interface LaborDetailHandle { @@ -105,7 +107,6 @@ export function mountLaborDetail( const saved = JSON.stringify(cur); const edit = !opt.readOnly; const lib = opt.mode ?? null; - const promote = lib === "promote"; const logics = new Map(); let calc: LaborCalc | null = null; /** 마지막으로 받은 calc — 다시 세는 동안 로직 자재 줄을 그대로 둠(줄이 사라졌다 나타나지 않게) */ @@ -195,7 +196,7 @@ export function mountLaborDetail( logicTable( { rows: cur.담은로직, logics, calc, fail, cells: cells(), selected: picked }, // 읽기만이면 순서 · 삭제 없음([옵션] = 보기 모달은 둠) - edit && !promote + edit ? { open: pick, option: openRow, @@ -204,6 +205,8 @@ export function mountLaborDetail( move: reorder, remove: removeRow, alias: setAlias, + // 134-1 — 구조물 일위대가 로직 줄만(사전조합 안 로직 줄엔 없음) + ...(lib ? {} : { promote: setPromote }), } : { open: pick, option: openRow, ref: openRef }, ), @@ -242,7 +245,13 @@ export function mountLaborDetail( draw(); try { // 저장 안 한 칸 연결이 있으면 그 칸 값으로(없으면 서버가 저장된 면적산출을 읽음) - const got = await calcLabor(opt.layer, opt.column, cur, areaDirty ? given() : undefined); + const got = await calcLabor( + opt.layer, + opt.column, + cur, + areaDirty ? given() : undefined, + opt.owner, + ); if (!alive || mine !== seq) return; calc = last = got; pills = new Map( @@ -361,6 +370,12 @@ export function mountLaborDetail( cur.담은로직[i].별칭 = value; opt.onChanged(dirty()); } + /** 구분 드롭다운(134-1) — 화면 캐시만 · 금액 무관이라 다시 셈 없음 */ + function setPromote(i: number, on: boolean): void { + if (on) cur.담은로직[i].승격 = true; + else delete cur.담은로직[i].승격; + opt.onChanged(dirty()); + } function removeMat(i: number): void { cur.자재.splice(i, 1); changed(); @@ -445,12 +460,17 @@ export function mountLaborDetail( /** 서고 탭(50-3 ④) — 참조 줄 더함(같은 참조 두 번 막음 · 위 층만은 서버도 막음) */ const library = { layer: opt.layer, - owner: opt.layer === getActiveLayer() ? getActiveOwner() : null, + owner: + opt.owner !== undefined + ? opt.owner + : opt.layer === getActiveLayer() + ? getActiveOwner() + : null, pick: (ref: LaborRef, name: string) => { const id = lineId({ 참조: ref }); if (cur.담은로직.some((r) => lineId(r) === id)) return showToast(tc("Labor_AlreadyRef").replace("{value}", `${ref.키} ${name}`), "error"); - const 수량 = lib === "precombo" ? { 손: 1 } : null; // 사전조합 줄 기본 1(51-6) + const 수량 = lib ? { 손: 1 } : null; // 사전조합 줄 기본 1(51-6) cur.담은로직.push({ 로직: "", 참조: ref, 별칭: null, 메모: null, 입력값: {}, 수량 }); changed(); }, @@ -464,7 +484,7 @@ export function mountLaborDetail( if (cur.담은로직.some((r) => r.로직 === x.키)) return showToast(tc("Labor_Already").replace("{value}", `${x.키} ${x.이름}`), "error"); // 사전조합 = 면적산출 없음 → 손 1(51-11) - const 수량 = lib === "precombo" ? { 손: 1 } : null; + const 수량 = lib ? { 손: 1 } : null; cur.담은로직.push({ 로직: x.키, 별칭: null, 메모: null, 입력값: {}, 수량 }); void loadLogics().then(() => changed()); }, @@ -539,30 +559,15 @@ export function mountLaborDetail( opt.onChanged(dirty()); }); - /** 서고 구분 · 상세구분(51-12) — 승격 = 원 로직 것 읽기만 + 연결(원 로직 키 · 출처) · - * 사전조합 = 로직 구분 목록(logic-lib/subs)에서 고름(구분 바꾸면 상세구분 = 그 첫 줄) */ - const meta = cur as { 구분?: string | null; 상세구분?: string | null; 연결?: unknown }; - const readField = (label: string, field: string, value: string): HTMLElement => { - const f = createInputField({ type: "text", label }); - f.root.dataset.laborField = field; - f.input.value = value; - f.input.readOnly = true; - return f.root; - }; + /** 서고 구분 · 상세구분(51-12 · 134-5) — 구조물군 › 종류 + 공통(`precomboClasses`)에서 고름 · + * 구분 바꾸면 상세구분 = 그 첫 줄(공통) · 빈 값 = 미분류 */ + const meta = cur as { 구분?: string | null; 상세구분?: string | null }; const libMeta = (): HTMLElement[] => { - if (promote) { - const link = (meta.연결 ?? {}) as { 로직?: string; 출처?: string }; - return [ - readField(tc("Sub"), "구분", meta.구분 ?? ""), - readField(tc("Detail"), "상세구분", meta.상세구분 ?? ""), - readField(tc("Lib_Link"), "연결", [link.로직, link.출처].filter(Boolean).join(" · ")), - ]; - } const sub = createSelectField({ options: [], label: tc("Sub"), disabled: !edit }); const detail = createSelectField({ options: [], label: tc("Detail"), disabled: !edit }); sub.root.dataset.laborField = "구분"; detail.root.dataset.laborField = "상세구분"; - let subs: SubBrief[] = []; + let subs: { name: string; details: string[] }[] = []; // 목록에 없는 지금 값(빈 것 = 「—」 · 옛 문서)도 선택지로 둠 const opts = (names: string[], now: string | null | undefined) => [...new Set([now ?? "", ...names])] @@ -590,9 +595,9 @@ export function mountLaborDetail( opt.onChanged(dirty()); }); fill(); - void fetchLibLogicSubs().then( - (got) => { - subs = got; + void fetchStructureDef().then( + (def) => { + subs = precomboClasses(def); fill(); }, () => undefined, @@ -625,8 +630,8 @@ export function mountLaborDetail( ], }), ]), - // 승격 = 로직 한 줄(새로 만들 때 고름) · 사전조합 = 같은 [로직 추가](51-11) - section(tc("Logics_Title"), opt.card || promote ? null : addLogic, logicHost), + // 사전조합 = 같은 [로직 추가](51-11) + section(tc("Logics_Title"), opt.card ? null : addLogic, logicHost), ...(lib ? [] : [section(tc("Labor_Mats"), opt.card ? null : addMat, matHost)]), ...(opt.card || lib ? [] : [dgBox]), ], @@ -657,7 +662,7 @@ export function mountLaborDetail( // 서고 모드 = 문서 종류 그대로 · 열 · 자재 없음 getDoc: () => { if (!lib) return cur; - const out: Record = { ...cur, 종류: promote ? "단가승격" : "사전조합" }; + const out: Record = { ...cur, 종류: "사전조합" }; delete out.열; delete out.자재; return out as unknown as LaborDoc; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts index ebf61477e..d684978b9 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts @@ -60,11 +60,11 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra detail.select.disabled = true; const close = (): void => backdrop.remove(); - /* 한 목록 — 구분(단가산출 · 단가승격 · 일위조합) 칸 · 층 단추는 서고 줄(승격 · 조합)의 층 */ + /* 한 목록 — 구분(단가산출 · 일위조합) 칸 · 층 단추는 서고 줄(사전조합)의 층 */ const layerBar = el("div", { className: "ui-btn-group", attrs: { "data-pick-layers": "" } }); let scopes: Scope[] = []; let scope: Scope | null = null; - let libRows: { kind: "Kind_Promote" | "Kind_Combo"; row: LibraryRow }[] = []; + let libRows: { kind: "Kind_Combo"; row: LibraryRow }[] = []; let loading = false; let seq = 0; /* 공용 찾기 목록 — 구분 · 상세구분 2열 · 이름 왼쪽 맞춤 · ↑ ↓ Enter */ @@ -132,15 +132,9 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra loading = true; find(); try { - const [promote, combo] = await Promise.all([ - fetchLibrary(at, "promote"), - fetchLibrary(at, "precombo"), - ]); + const combo = await fetchLibrary(at, "precombo"); if (mine !== seq) return; - libRows = [ - ...promote.map((row) => ({ kind: "Kind_Promote" as const, row })), - ...combo.map((row) => ({ kind: "Kind_Combo" as const, row })), - ]; + libRows = combo.map((row) => ({ kind: "Kind_Combo" as const, row })); } catch (error) { if (mine !== seq) return; libRows = []; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_RefEdit.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_RefEdit.ts index f94d73223..d8edd56ab 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_RefEdit.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_RefEdit.ts @@ -2,7 +2,6 @@ * M02_MasterTemplete_Labor_RefEdit.ts * 일위대가 참조 줄 모달(51-14) — 서고 원본(50-3)을 그 자리에서 고침 · [저장] = 서고 저장 길(`saveTemplate`) · * 그 문서를 쓰는 모든 구조물에 따라감(머리 한 줄 알림) · 그 층을 못 고치면 읽기만. - * · 단가승격 [옵션] = 승격 문서 로직 한 줄의 넣을 값 모달(`Labor_Input`) * · 일위조합 [수정] = 사전조합 상세(서고 모드 표 · 안 줄 [옵션] · 수량 · 위로 · 아래로 · 삭제 · 추가) * 닫으면(저장 · 취소 모두) `closed` — 부르는 쪽이 구조물 일위대가 표를 다시 셈. * ========================================================================== */ @@ -10,15 +9,13 @@ import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; import { readTemplate, saveTemplate, type Layer } from "./M02_MasterTemplete_Api_Fetch"; -import { fetchLogicInputs, type LaborRow } from "./M02_MasterTemplete_Labor_Api"; +import type { LaborRow } from "./M02_MasterTemplete_Labor_Api"; import type { LaborRef } from "./M02_MasterTemplete_Labor_Calc"; -import { openLaborInput } from "./M02_MasterTemplete_Labor_Input"; import { tc } from "./M02_MasterTemplete_Labor_Text"; -import { keepKnown } from "./M02_Labor_Values_Model"; export interface RefEditOptions { ref: LaborRef; - /** 「SP-000001 메쌓기」 */ + /** 「SC-000001 돌붙임」 */ title: string; readOnly: boolean; closed: () => void; @@ -28,50 +25,23 @@ const fail = (error: unknown, key: "Load_Failed" | "Lib_SaveFailed"): void => showToast(error instanceof Error ? error.message : tc(key), "error"); export async function openRefEdit(opt: RefEditOptions): Promise { - const kind = opt.ref.키.startsWith("SP-") ? "promote" : "precombo"; + const kind = "precombo"; const layer = opt.ref.층 as Layer; let doc: { 담은로직?: LaborRow[] }; let 판: string; try { - const got = await readTemplate(layer, kind, opt.ref.키, null); + const got = await readTemplate(layer, kind, opt.ref.키, null, opt.ref.주인); // 134-3 — 참조 주인 그대로 doc = got.문서 as typeof doc; 판 = got.판 ?? ""; } catch (error) { return fail(error, "Load_Failed"); } const save = (body: unknown): Promise => - saveTemplate(layer, kind, opt.ref.키, null, 판, body).then(() => + saveTemplate(layer, kind, opt.ref.키, null, 판, body, opt.ref.주인).then(() => showToast(tc("Saved"), "success"), ); const notice = opt.readOnly ? `${tc("Ref_Shared")} · ${tc("Ref_ReadOnly")}` : tc("Ref_Shared"); - if (kind === "promote") { - const row = doc.담은로직?.[0]; - if (!row) return fail(null, "Load_Failed"); - const logic = row.로직 - ? ((await fetchLogicInputs([row.로직]).catch(() => null))?.로직[0] ?? null) - : null; - return openLaborInput({ - title: opt.title, - logic, - values: row.입력값, - memo: row.메모, - rate: row.켜쌓기율 ?? null, - readOnly: opt.readOnly, - notice, - okLabel: tc("Save"), - done: (next) => { - row.입력값 = logic ? keepKnown(logic.입력, next.입력값).kept : next.입력값; - row.메모 = next.메모; - if (next.켜쌓기율 === null) delete row.켜쌓기율; - else row.켜쌓기율 = next.켜쌓기율; - void save(doc) - .catch((error) => fail(error, "Lib_SaveFailed")) - .finally(opt.closed); - }, - }); - } - // 사전조합 — 상세와 같은 부품(서로 부르므로 늦게 받음) const { mountLaborDetail } = await import("./M02_MasterTemplete_Labor_Detail"); let dirty = false; @@ -98,6 +68,7 @@ export async function openRefEdit(opt: RefEditOptions): Promise { onChanged: (d) => (dirty = d && !opt.readOnly), goArea: null, mode: "precombo", + owner: opt.ref.주인, }); const cancel = createButton({ label: tc(opt.readOnly ? "In_Close" : "In_Cancel"), diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts index 6184cf511..adb93289c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts @@ -8,11 +8,11 @@ * 자재목록 = 코드 · 이름 · 규격 · 수량산출 · 단위 · 가격 다섯(기준 칸 강조) · 기준단가 / 반영 금액(43-3). * 금액은 calc 답(없으면 「…」). * 줄 누름 · 줄 단추는 `on` 이 있을 때만(보기 갈래 = 없음) — 순서 칸 2행(51-14 · 위 [위로] [아래로] / 아래 [옵션] 또는 [수정] · [삭제] · - * 묶음 `ui-btn-group` · 순서 없는 카드 갈래 = 아래 줄만) · 단가승격 줄 = [옵션] · 일위조합 줄 = [수정](`ref` · 서고 원본) · + * 묶음 `ui-btn-group` · 순서 없는 카드 갈래 = 아래 줄만) · 일위조합 줄 = [수정](`ref` · 서고 원본 · 옛 단가승격 참조 줄 = 단추 없음) · * 상세는 줄 누름 = 고름(`selected` 줄 하이라이트 · 그 줄 바로 아래 하위 행 · 51-2 · 화살표 없음 51-7) · 넣을 값 모달은 [옵션](47-5). * ========================================================================== */ -import { createButton, el } from "@ui/ui_template_elements"; +import { createButton, createSelectField, el } from "@ui/ui_template_elements"; import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money"; import type { ItemLogic } from "./M02_MasterTemplete_Api_Fetch"; import type { @@ -41,14 +41,36 @@ const HEAD: Record<(typeof COSTS)[number], Parameters[0]> = { /** 옵션 값 줄이 걸치는 칸 수 — 구분 · 이름 · 별칭(113-1 · 나머지 칸은 위 줄에서 rowspan 2) */ const SPAN = 3; -/** 구분(50-4 · 저장 안 함) — 줄 모양(참조 키 머리)에서 읽음 · SP- = 단가승격 · SC- = 일위조합(50-3) */ -export const kindOf = (row: Pick): string => { +/** 구분(50-4) — 로직 줄 = `승격`(134-1 · 단가승격 · 없음 = 단가산출) · 참조 줄 = 키 머리(SC- = 일위조합 · + * 옛 SP- = 단가승격 글 고정 · 134-2) */ +export const kindOf = (row: Pick): string => { const key = row.참조?.키 ?? row.로직; return tc( - key.startsWith("SP-") ? "Kind_Promote" : key.startsWith("SC-") ? "Kind_Combo" : "Kind_Logic", + row.승격 || key.startsWith("SP-") + ? "Kind_Promote" + : key.startsWith("SC-") + ? "Kind_Combo" + : "Kind_Logic", ); }; +/** 구분 드롭다운(134-1) — 로직 줄만 [단가산출 | 단가승격] · 줄 누름과 따로 */ +function kindPick(on: boolean, set: (on: boolean) => void): HTMLElement { + const pick = createSelectField({ + options: [ + { value: "", text: tc("Kind_Logic") }, + { value: "1", text: tc("Kind_Promote") }, + ], + value: on ? "1" : "", + compact: true, + ariaLabel: tc("Sub"), + onChange: (v) => set(v === "1"), + }); + pick.select.dataset.laborKind = ""; + pick.root.addEventListener("click", (e) => e.stopPropagation()); // 줄 누름(고름)과 따로 + return pick.root; +} + /** 품셈 책(102-3) — 출처 첫 낱말 */ const BOOK = /^(건설|산림)품셈/; @@ -180,7 +202,7 @@ export interface RowHandlers { open: (i: number) => void; /** [옵션] 단추(47-5 · 넣을 값 모달) — 없으면 단추 없음 */ option?: (i: number) => void; - /** 참조 줄 단추(51-14) — 단가승격 [옵션] · 일위조합 [수정] = 서고 원본 모달 · 없으면 단추 없음 */ + /** 참조 줄 단추(51-14) — 일위조합 [수정] = 서고 원본 모달 · 없으면 단추 없음 */ ref?: (i: number) => void; /** 수량산출 칸 누름(손 입력 · 엑셀에서 고르기) — 없으면 줄 누름과 같음 */ qty?: (i: number) => void; @@ -189,6 +211,8 @@ export interface RowHandlers { remove?: (i: number) => void; /** 별칭 칸 고침(44-6 · 글자마다 · 빈 글 = null) — 없으면 글만 보임 */ alias?: (i: number, value: string | null) => void; + /** 구분 드롭다운(134-1 · 로직 줄 단가산출 ↔ 단가승격) — 없으면 글만 */ + promote?: (i: number, on: boolean) => void; } /** 별칭 칸(44-6) — 고칠 수 있으면 칸 안 글상자(누르면 바로 고침 · 줄 누름과 따로) · 아니면 글 · 비면 빈 칸 */ @@ -361,7 +385,9 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement el("td", { className: "m02-labrow__kind", children: [ - kindOf(row), + on?.promote && !row.참조 + ? kindPick(!!row.승격, (v) => on.promote?.(i, v)) + : kindOf(row), // 102-3 — 단가산출 줄만 품셈 책(출처 첫 낱말) ...(book ? [el("small", { className: "m02-labrow__book", text: book })] : []), ], @@ -419,13 +445,8 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement : []), group([ ...(row.참조 - ? ref - ? [ - small( - tc(row.참조.키.startsWith("SP-") ? "Labor_Option" : "Labor_Edit"), - () => ref(i), - ), - ] + ? ref && !row.참조.키.startsWith("SP-") + ? [small(tc("Labor_Edit"), () => ref(i))] : [] : option ? [small(tc("Labor_Option"), () => option(i))] diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index 1af48b9f1..8088e75fb 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -140,8 +140,9 @@ const TEXT = { In_Close: ["닫기", "Close"], Lib_Name: ["이름", "Name"], // 서고 목록표(51-4) - Lib_Promote: ["단가산출 승격", "Promoted logic"], Lib_PrecomboAll: ["일위대가 사전조합", "Pre-combination"], + Lib_None: ["사전조합 없음", "No pre-combination"], + Lib_Unsorted: ["미분류", "Unsorted"], Lib_Create: ["생성", "Create"], Lib_Copy: ["복사", "Copy"], Lib_Delete: ["삭제", "Delete"], @@ -150,7 +151,6 @@ const TEXT = { Lib_Base: ["기준 금액", "Base amount"], Lib_Date: ["고친 날", "Modified"], // 서고 구분 · 연결(51-12) - Lib_Link: ["연결", "Linked logic"], } as const satisfies Record; export type ComboTextKey = keyof typeof TEXT; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Layer.ts b/M02_MasterTemplete/M02_MasterTemplete_Layer.ts index 43a7b6b86..77736b7d4 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Layer.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Layer.ts @@ -1,8 +1,8 @@ /* ============================================================================= * M02_MasterTemplete_Layer.ts * M02 한 화면의 뿌리 셋(마스터 · 회사 · 개인 · PLAN 27-1) — 진입 해시 - * `#/m02-master-template?open=구조물조합|옵션|도면&root=master|company|personal&owner=…&company=…` - * (옛 `?layer=` 도 root 로 읽음) · 고칠 권한(`accessFor` — 서버 `M02_Layer_Access` 와 짝 · 27-1c) · + * `#/m02-master-template?open=구조물조합|옵션|도면&root=master|company|personal&owner=…&company=…&pre=…` + * (옛 `?layer=` 도 root 로 읽음 · `pre` = 일위대가 사전조합 층 · 134-3) · 고칠 권한(`accessFor` — 서버 `M02_Layer_Access` 와 짝 · 27-1c) · * 회사 없음 안내 · 회사용 읽기만일 때 [개인용으로 복사] 막대. * ========================================================================== */ @@ -25,20 +25,30 @@ export interface Entry { open: OpenTarget; /** 시스템 관리자가 보는 회사(PLAN 29 · 없으면 sessionStorage `aislo.adminCompany` · 자기 회사) */ company: string | null; + /** 일위대가 사전조합 층(134-3 · 페이지 층과 따로) — 없으면 기본(`defaultRoot`) */ + pre: Root | null; } const OPENS: OpenTarget[] = ["구조물조합", "옵션", "도면", "일위대가조합"]; +const ROOTS: Root[] = ["master", "company", "personal"]; export const layerOfRoot = (root: Root): ManageLayer => (root === "master" ? "system" : root); -/** 주소 인자에서 진입 — 모르는 값 · 없음 = 마스터 · 구조물 조합 */ -export function entryFromHash(): Entry { +/** 주소 인자에서 진입 — 층 모르는 값 · 없음 = 기본 층(134-4 · 개인 · 회사 없으면 마스터 · 사용자를 안 주면 + * 마스터) · 열 곳 없음 = 구조물 조합 */ +export function entryFromHash(user?: SessionUser | null): Entry { const query = currentQuery(); const raw = query.get("root") ?? query.get("layer"); - const root: Root = raw === "company" || raw === "personal" ? raw : "master"; + const root: Root = + raw === "master" || raw === "company" || raw === "personal" + ? raw + : user === undefined + ? "master" + : defaultRoot(user); const open = OPENS.find((one) => one === query.get("open")) ?? "구조물조합"; const owner = root === "master" ? null : query.get("owner") || null; - return { root, owner, open, company: query.get("company") || null }; + const pre = ROOTS.find((one) => one === query.get("pre")) ?? null; + return { root, owner, open, company: query.get("company") || null, pre }; } /** 진입 해시 인자 — `navigateTo(ROUTES.M02_MASTER_TEMPLATE, entryQuery(…))` */ @@ -47,8 +57,13 @@ export const entryQuery = (entry: Entry): Record => ({ root: entry.root, ...(entry.owner ? { owner: entry.owner } : {}), ...(entry.company ? { company: entry.company } : {}), + ...(entry.pre ? { pre: entry.pre } : {}), }); +/** 기본 층(134-4) — 내 개인 층이 있으면 개인 · 회사 없으면 마스터 */ +export const defaultRoot = (user: SessionUser | null): Root => + ownOwner("personal", user) ? "personal" : "master"; + /** 볼 회사 — 시스템 관리자 = 해시 `company` · sessionStorage `aislo.adminCompany`(대시보드가 넣음) · * 자기 회사 차례 · 그 밖 = 자기 회사 · 없으면 null */ export function viewCompany(entry: Entry, user: SessionUser | null): string | null { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Library_List.ts b/M02_MasterTemplete/M02_MasterTemplete_Library_List.ts index 4e729405d..9774566c7 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Library_List.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Library_List.ts @@ -1,6 +1,6 @@ /* ============================================================================= * M02_MasterTemplete_Library_List.ts - * 일위대가 조합 서고 목록표(51-4) — 좌측 항목(단가산출 승격 · 일위대가 사전조합)을 누르면 상세 칸에 뜸. + * 일위대가 사전조합 서고 목록표(51-4) — 좌측 사전조합 층 단추를 누르면 상세 칸에 뜸. * 표 = 고름 · 키 · 구분 · 상세구분(51-12) · 이름 · 단위 · 기준 금액(서버 1 단위 계) · 고친 날 · 줄 누름 = 그 문서 상세. * 머리 [생성] · [복사](고른 줄) · [삭제](고른 줄 · 쓰는 곳 있으면 서버 409 까닭 그대로). * 꼴 = M01 단가산출 로직 목록 — 찾기 줄(`buildHeadFilters`) · 쪽(`buildPager`) · 표(`m01-master__grid`) 를 M01 에서 import. @@ -21,7 +21,6 @@ import "../M01_MasterData/M01_MasterData_UI_Style.css"; import { deleteLayerTemplate, deleteTemplate, - getActiveOwner, listTemplates, newLibrary, readTemplate, @@ -30,8 +29,6 @@ import { type LibraryBody, type TemplateInfo, } from "./M02_MasterTemplete_Api_Fetch"; -import { fetchLibLogicSubs } from "./M02_MasterTemplete_Labor_Api"; -import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import type { PrecomboKind } from "./M02_MasterTemplete_UI_Side_Precombo"; @@ -46,10 +43,14 @@ type Row = TemplateInfo & { export interface LibraryListOptions { layer: Layer; + /** 그 층 주인(134-3 · 사전조합 층 · null = 자기 층) */ + owner: string | null; kind: PrecomboKind; canEdit: boolean; /** 줄 누름 · 생성 · 복사 뒤 — 그 문서 상세 */ open: (key: string, label: string) => void; + /** 생성 · 복사 · 삭제 뒤 — 좌측 나무를 다시 그림 */ + changed: () => void; } const SIZE = 30; @@ -131,7 +132,9 @@ export function mountLibraryList( const load = async (): Promise => { try { - rows = (await listTemplates(opt.layer, null)).filter((r) => r.종류 === opt.kind) as Row[]; + rows = (await listTemplates(opt.layer, null, opt.owner)).filter( + (r) => r.종류 === opt.kind, + ) as Row[]; } catch (error) { rows = []; showToast(L("M02_LoadFailed").replace("{value}", why(error)), "error"); @@ -172,34 +175,16 @@ export function mountLibraryList( /** 새로 — 서버가 그 층 다음 키로 씀 → 그 문서 상세 */ const made = (key: string, label: string): void => { showToast(L("M02_Created"), "success"); + opt.changed(); opt.open(key, label); }; const create = async (body: LibraryBody, label: string): Promise => - made((await newLibrary(opt.layer, opt.kind, body)).이름, label); + made((await newLibrary(opt.layer, opt.kind, body, opt.owner)).이름, label); - /** 단가승격 = 단가산출 로직 고르기 → 이름 · 단위 · 구분 · 연결은 로직 것(서버가 채움) · - * 사전조합 = 이름 + 구분 · 상세구분 처음 값 = 로직 구분 목록 첫 줄(51-12 · 상세에서 바꿈) · 빈 묶음 */ + /** 사전조합 = 이름 · 구분 · 상세구분 빈 값(미분류 · 134-5 · 상세에서 고름) · 빈 묶음 */ btnNew.addEventListener("click", () => { - if (opt.kind === "promote") - return openLogicPicker((x) => { - const doc = { - 이름: x.이름, - 단위: x.결과단위 || null, - 담은로직: [{ 로직: x.키, 입력값: {} }], - }; - void create({ 문서: doc }, x.이름).catch((error) => - showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error"), - ); - }); nameDialog(L("M02_NewTitle"), "", async (name) => { - const first = (await fetchLibLogicSubs())[0]; - const 문서 = { - 이름: name, - 구분: first?.name ?? null, - 상세구분: first?.details[0] ?? null, - 단위: null, - 담은로직: [], - }; + const 문서 = { 이름: name, 구분: "", 상세구분: "", 단위: null, 담은로직: [] }; await create({ 문서 }, name); }); }); @@ -209,14 +194,22 @@ export function mountLibraryList( const from = rows.find((r) => r.이름 === picked); if (!from) return; nameDialog(L("M02_CopyTitle"), `${from.제목 ?? from.이름} (2)`, async (name) => { - const got = await newLibrary(opt.layer, opt.kind, { - 본뜬: { 층: opt.layer, 주인: getActiveOwner(), 키: from.이름 }, - }); - const doc = await readTemplate(opt.layer, opt.kind, got.이름, null); - await saveTemplate(opt.layer, opt.kind, got.이름, null, got.판, { - ...(doc.문서 as object), - 이름: name, - }); + const got = await newLibrary( + opt.layer, + opt.kind, + { 본뜬: { 층: opt.layer, 주인: opt.owner, 키: from.이름 } }, + opt.owner, + ); + const doc = await readTemplate(opt.layer, opt.kind, got.이름, null, opt.owner); + await saveTemplate( + opt.layer, + opt.kind, + got.이름, + null, + got.판, + { ...(doc.문서 as object), 이름: name }, + opt.owner, + ); made(got.이름, name); }); }); @@ -229,8 +222,9 @@ export function mountLibraryList( if (!(await showConfirmDialog(L("M02_DeleteConfirm").replace("{value}", label)))) return; try { if (opt.layer === "system") await deleteTemplate(opt.kind, info.이름, info.판); - else await deleteLayerTemplate(opt.layer, opt.kind, info.이름, info.판); + else await deleteLayerTemplate(opt.layer, opt.kind, info.이름, info.판, opt.owner); showToast(L("M02_Deleted"), "success"); + opt.changed(); picked = null; await load(); } catch (error) { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py index f675cfb41..03633c2ac 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py @@ -311,6 +311,8 @@ async def read_layer_template( found["층"] = layer found["출처"] = layers.read_manifest(folder).get(f"{kind}/{name}") found["문서"] = refs.filled(kind, found["문서"]) # 옛 서고 문서 구분 · 상세구분(51-12) + if layer != "project": # 옛 단가승격 참조 줄 = 새 꼴(134-2 · 프로젝트 사본은 그대로) + found["문서"] = await asyncio.to_thread(refs.unpromote, kind, found["문서"]) return found diff --git a/M02_MasterTemplete/M02_MasterTemplete_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Store.py index 67baa039e..5aa3aaf2e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Store.py @@ -570,7 +570,7 @@ def _server(kind: str, doc: Any, source: Path | None = None) -> Any: from M02_MasterTemplete import M02_Labor_Doc as labor from M02_MasterTemplete import M02_Labor_Ref as refs - if source is not None and isinstance(doc, dict) and kind != "promote": + if source is not None and isinstance(doc, dict): doc = refs.carry(doc, refs.where(source)) if kind == "labor": return labor.server(doc) diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index d98c88b00..31b05e1f4 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -46,6 +46,10 @@ export interface Selection { name: string; /** 화면 제목에 쓸 글 — 없으면 name 그대로(구조물 도면은 열 머리글) */ label?: string; + /** 사전조합 층(134-3 · 페이지 층과 따로) — 서버 길에 붙일 주인(null = 자기 층) · 고칠 권한 · + * 없으면 페이지 층 것 */ + owner?: string | null; + canWrite?: boolean; } interface EditorHandle { @@ -243,9 +247,11 @@ export function createMain( const empty = (text: string): void => { host.replaceChildren(el("p", { className: "m02-main__empty", text })); }; + /** 고를 것마다 고칠 권한 — 사전조합 층은 고름에 실림(134-3) · 그 밖 페이지 층 */ + const writable = (at: Selection): boolean => at.canWrite ?? canEdit(at.layer, isAdmin); const bar = (): void => { reload.hidden = !sel; - save.hidden = !sel || !canEdit(sel.layer, isAdmin); + save.hidden = !sel || !writable(sel); back.hidden = !sel || !(sel.kind in NUMBER_FIELD); nav.update(back.hidden ? null : sel); // 보임 조건 = 뒤로 단추와 같음 const kind = sel && sel.kind in HELP_TITLE ? (sel.kind as keyof typeof HELP_TITLE) : null; @@ -345,7 +351,7 @@ export function createMain( } async function mount(doc: unknown, at: Selection): Promise { - const readOnly = !canEdit(at.layer, isAdmin); + const readOnly = !writable(at); host.replaceChildren(); if (at.kind === "table") { const create = await findExport<(h: HTMLElement, d: unknown, o: object) => EditorHandle>( @@ -424,7 +430,7 @@ export function createMain( void open({ ...at, kind: "area" }); return true; }, - ...(mode ? { mode } : {}), + ...(mode ? { mode, owner: at.owner ?? null } : {}), }); // [저장] = 엑셀에서 고른 칸 연결(면적 문서) 먼저 · 그다음 일위대가(서버가 새 칸 값으로 계산값) editor = { @@ -508,14 +514,16 @@ export function createMain( editor = mountLibraryList(host, slot, { layer: next.layer, kind: next.kind, - canEdit: canEdit(next.layer, isAdmin), + owner: next.owner ?? null, + canEdit: writable(next), open: (name, label) => void open({ ...next, name, label }), + changed: () => onSaved?.(), }); return; } const stop = showLoading(host); // 0.3초 넘으면 상세 칸 안에 「불러오는 중」(PLAN 30-6) try { - const got = await readTemplate(next.layer, next.kind, next.name, next.projectId); + const got = await readTemplate(next.layer, next.kind, next.name, next.projectId, next.owner); if (mine !== seq) return; version = got.판 ?? ""; loaded = got.문서; @@ -563,7 +571,15 @@ export function createMain( ...(edited as Record), }) // + 저장 전 [보내기 취소] 연결(112-2) : edited; - const info = await saveTemplate(at.layer, at.kind, at.name, at.projectId, version, doc); + const info = await saveTemplate( + at.layer, + at.kind, + at.name, + at.projectId, + version, + doc, + at.owner, + ); version = info.판; loaded = doc; dirty = false; diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index 7eebb2002..95f647195 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -19,6 +19,7 @@ import { fetchOwners, setActiveLayer } from "./M02_MasterTemplete_Api_Fetch"; import { accessFor, buildReadOnlyBar, + defaultRoot, entryFromHash, entryQuery, layerOfRoot, @@ -41,6 +42,7 @@ import { } from "./M02_MasterTemplete_TreePanel_Roots"; import { canEdit, createMain, newItemButton, type Selection } from "./M02_MasterTemplete_UI_Main"; import { buildSide } from "./M02_MasterTemplete_UI_Side"; +import { precomboList, type PrecomboScope } from "./M02_MasterTemplete_UI_Side_Precombo"; import { buildTwoWay, pickedName } from "./M02_MasterTemplete_ItemPick"; import "./M02_MasterTemplete_UI_Style.css"; @@ -50,7 +52,7 @@ const NEW_ASK = export async function renderM02MasterTemplate(root: HTMLElement): Promise { const user = await fetchSessionUser().catch(() => null); - const entry = entryFromHash(); + const entry = entryFromHash(user); // 134-4 — 해시에 층 없음 = 개인(회사 없으면 마스터) const company = viewCompany(entry, user); // 볼 회사(시스템 관리자는 고른 회사) let layer = layerOfRoot(entry.root); // 회사 층 주인 = 볼 회사 · 자기 층 주인을 적어도 자기 층 · 회사 없는 사람의 회사 · 개인 = 마스터(단추 자리에 안내) @@ -173,6 +175,35 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise { + const at = + (want === "company" && !company) || (want === "personal" && !ownOwner("personal", user)) + ? "master" + : want; + const to = layerOfRoot(at); + const who = at === "company" ? companyOwner(company, user) : null; + return { root: at, layer: to, owner: who, canWrite: accessFor(to, who, user).canWrite }; + }; + let pre = preScope(entry.pre ?? defaultRoot(user)); + const setPre = (want: Root): void => { + pre = preScope(want); + entry.pre = pre.root; + const query = new URLSearchParams(entryQuery(entry)); + history.replaceState(null, "", `#/${ROUTES.M02_MASTER_TEMPLATE}?${query}`); + void side.refreshPrecombo(); + }; + const preBar = (): HTMLElement => + createLayerBar({ + user, + active: { root: pre.root, owner: pre.owner }, + company, + go: (next) => setPre(next), + // 켜진 단추 다시 누름 = 그 층 목록표(생성 · 복사 · 삭제) + reselect: () => + void main.confirmLeave().then((ok) => ok && void main.open(precomboList(pre))), + }); const side = buildSide({ layer, isAdmin, @@ -184,8 +215,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise void main.openTable(layer)), peopleTree("구조물조합", tree.root), ], - // 일위대가 조합(50-3) — 같은 층 단추 · 누르면 그 층 화면에서 이 컨테이너를 펼침 - precomboBar: [layerBar("일위대가조합", null, () => undefined)], + precombo: { scope: () => pre, bar: preBar }, }); const layout = el("div", { className: "ui-workflow-layout m02-master" }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts index 7ceefa0f5..f0e73ad40 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts @@ -1,7 +1,7 @@ /* ============================================================================= * M02_MasterTemplete_UI_Side.ts - * 마스터 템플릿 좌측 패널 — 「구조물 조합」(층 단추 · 나무) · 「일위대가 조합」(층 단추 · 단가승격 · 사전조합 · - * `UI_Side_Precombo` · 50-3) · 「도면 양식」(새로 · 본떠 · 지우기). + * 마스터 템플릿 좌측 패널 — 「구조물 조합」(층 단추 · 나무) · 「일위대가 사전조합」(층 단추 따로 · 2단 폴더 나무 · + * `UI_Side_Precombo` · 50-3 · 134-3) · 「도면 양식」(새로 · 본떠 · 지우기). * 서버 길은 계약 `6_계약.md`. * 구조물 도면 · 일위대가 · 면적산출 상세는 좌측 줄이 아니라 항목 카드 [수정]으로(PLAN 45-2). * 화면 이름 「구조물 조합」(PLAN 26-7a) — 파일 이름 · 양식 키 · API 길은 「구조물집계표」 그대로. @@ -32,7 +32,11 @@ import { STRUCTURE_TABLE } from "./M02_MasterTemplete_Structure"; import { buildOptionsSection } from "./StructOptions/M02_SO_Options"; import { buildPracticeSection } from "./M02_MasterTemplete_Practice"; import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main"; -import { buildPrecomboSection, PRECOMBO_TITLE } from "./M02_MasterTemplete_UI_Side_Precombo"; +import { + buildPrecomboSection, + PRECOMBO_TITLE, + type PrecomboOptions, +} from "./M02_MasterTemplete_UI_Side_Precombo"; const KINDS: { kind: Kind; label: () => string }[] = [ { kind: "table", label: () => L("M02_KindTable") }, @@ -60,8 +64,8 @@ export interface SideOptions { showView: (label: string, node: HTMLElement) => (dirty: boolean) => void; /** 「구조물 조합」 줄 아래 — 층 단추 · 고른 층의 구조물 라이브러리 나무(PLAN 29-2 · 페이지가 만듦) */ tree?: HTMLElement[]; - /** 「일위대가 조합」 줄 아래 층 단추(50-3 · 페이지가 만듦) */ - precomboBar?: HTMLElement[]; + /** 「일위대가 사전조합」 층(134-3 · 페이지 층과 따로 · 층 단추는 페이지가 만듦) */ + precombo: Pick; } export interface SideHandle { @@ -71,6 +75,8 @@ export interface SideHandle { paint: () => void; /** 진입 해시 `open`(PLAN 27-1d) — 그 컨테이너를 펼침(옵션은 열기까지) · 옵션 · 도면은 시스템 층에만 */ reveal: (target: "옵션" | "도면" | "일위대가조합") => void; + /** 사전조합 층이 바뀜(134-3) — 그 컨테이너만 다시 그림 */ + refreshPrecombo: () => Promise; } /** 서버 이름 규칙과 같게 — 화면에서 먼저 막음 */ @@ -134,13 +140,12 @@ export function buildSide(opt: SideOptions): SideHandle { openOnExpand(summary, async () => { if (!isPicked("table", STRUCTURE_TABLE) && (await opt.confirmLeave())) opt.onOpen(null); }); - // 일위대가 조합 서고(50-3) — 구조물 조합 바로 아래 · 층마다(마스터 · 회사 · 개인) + // 일위대가 사전조합 서고(50-3 · 134-3) — 구조물 조합 바로 아래 · 층은 페이지 층과 따로 const precombo = buildPrecomboSection({ - layer, - bar: opt.precomboBar ?? [], + ...opt.precombo, confirmLeave: opt.confirmLeave, onOpen: opt.onOpen, - openKind: () => (opt.getOpen()?.layer === layer ? (opt.getOpen()?.kind ?? null) : null), + getOpen: opt.getOpen, }); const precomboSec = section(PRECOMBO_TITLE, precombo.body); // 구조물 옵션 정의 · 도면 양식은 시스템 층(마스터 템플릿)에만 · 정의 고쳐 씀은 시스템 관리자만 · 다른 역할은 읽기만(PLAN 48-2) @@ -243,7 +248,7 @@ export function buildSide(opt: SideOptions): SideHandle { if (mine !== seq) return; paintList(); paintButtons(); - precombo.paint(); + await precombo.refresh(); } /* --- 시스템 층 새로 · 본떠 · 지우기(도면 양식만 — 구조물 도면은 집계표 열이 정함) --- */ @@ -331,5 +336,5 @@ export function buildSide(opt: SideOptions): SideHandle { }; void refresh(); - return { root, refresh, paint, reveal }; + return { root, refresh, paint, reveal, refreshPrecombo: precombo.refresh }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts index 7c198a6df..b81254066 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts @@ -1,68 +1,198 @@ /* ============================================================================= * M02_MasterTemplete_UI_Side_Precombo.ts - * 좌측 「일위대가 조합」 컨테이너(50-3 ③ · 51-4) — 구조물 조합 아래 · 층 단추(페이지가 만듦) · - * 항목 둘 「단가산출 승격」(promote) · 「일위대가 사전조합」(precombo)만. - * 항목 누름 = 상세 칸에 그 종류 목록표(`Library_List` · 생성 · 복사 · 삭제는 거기) · - * 표 줄 누름 = 편집 칸(UI_Main 종류 promote · precombo = 일위대가 상세 모드). + * 좌측 「일위대가 사전조합」 컨테이너(50-3 ③ · 51-4 · 134-3) — 구조물 조합 아래 · 층 단추(페이지가 만듦 · + * 페이지 층과 따로 · 마스터 · 회사 · 내 개인) + 그 층 사전조합 나무(구분 › 상세구분 2단 폴더 · 빈 구분 = 미분류). + * 문서 누름 = 편집 칸(UI_Main 종류 precombo = 일위대가 상세 모드) · 켜진 층 단추 다시 누름 = 목록표 + * (`Library_List` · 생성 · 복사 · 삭제는 거기) · 그 층 주인(`owner`) · 고칠 권한(`canWrite`)은 고름에 실어 보냄. * ========================================================================== */ import { el } from "@ui/ui_template_elements"; -import type { Kind, Layer } from "./M02_MasterTemplete_Api_Fetch"; +import { createTreeRow, renderTree, type TreeNode } from "@ui/ui_template_tree"; +import { listTemplates, type Kind, type Layer } from "./M02_MasterTemplete_Api_Fetch"; +import type { Root } from "./M02_MasterTemplete_Layer"; +import type { StructDef } from "./StructOptions/M02_SO_Def_Model"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import type { Selection } from "./M02_MasterTemplete_UI_Main"; -export type PrecomboKind = Extract; +export type PrecomboKind = Extract; -const PARTS = [ - { kind: "promote", label: "Lib_Promote" }, - { kind: "precombo", label: "Lib_PrecomboAll" }, -] as const; -export const PRECOMBO_TITLE = "일위대가 조합"; -export const isPrecombo = (kind: Kind): kind is PrecomboKind => - kind === "promote" || kind === "precombo"; +export const PRECOMBO_TITLE = "일위대가 사전조합"; +export const isPrecombo = (kind: Kind): kind is PrecomboKind => kind === "precombo"; + +/** 여러 구조물에 쓰는 묶음(콘크리트 · 거푸집 등) — 군마다 첫 상세구분(134-5) */ +export const COMMON = "공통"; + +/** 사전조합 구분 · 상세구분(134-5) — 구조물군 A~G › 그 군 구조물 종류 + 「공통」(원천 `구조물정의.json` · + * B05 구조물 배치와 같은 갈래 · 꺼진 종류도 넣음) */ +export const precomboClasses = ( + def: Pick, +): { name: string; details: string[] }[] => + Object.entries(def.구조물군) + .filter(([key]) => /^[A-G]$/.test(key)) + .map(([key, name]) => ({ + name, + details: [COMMON, ...new Set(def.types.filter((t) => t.group === key).map((t) => t.name))], + })); + +/** 사전조합 층(134-3) — 페이지 층과 따로 · `owner` null = 자기 층 */ +export interface PrecomboScope { + root: Root; + layer: Layer; + owner: string | null; + canWrite: boolean; +} + +/** 그 층 목록표 고름(켜진 층 단추 다시 누름) */ +export const precomboList = (scope: PrecomboScope): Selection => ({ + layer: scope.layer, + owner: scope.owner, + canWrite: scope.canWrite, + projectId: null, + kind: "precombo", + name: "", + label: tc("Lib_PrecomboAll"), +}); export interface PrecomboOptions { - layer: Layer; - /** 층 단추 줄(페이지가 만듦 · 누르면 그 층 화면 + 이 컨테이너 펼침) */ - bar: HTMLElement[]; + scope: () => PrecomboScope; + /** 층 단추 줄(페이지가 만듦 · 그릴 때마다 새로 — 켜진 단추가 바뀜) */ + bar: () => HTMLElement; confirmLeave: () => Promise; onOpen: (sel: Selection | null) => void; - /** 이 층에서 지금 열린 종류(목록이든 문서든) — 항목 고름 표시 */ - openKind: () => Kind | null; + getOpen: () => Selection | null; } export interface PrecomboHandle { body: HTMLElement; + /** 고름 표시만 */ paint: () => void; + /** 층 단추 · 나무를 그 층 목록으로 다시 그림 */ + refresh: () => Promise; +} + +interface Row { + 이름: string; + 종류: string; + 제목?: string | null; + 구분?: string | null; + 상세구분?: string | null; +} + +/** 줄 → 구분 › 상세구분 2단 나무(빈 구분 = 미분류 · 맨 뒤 · 빈 상세구분 = 구분 바로 아래) */ +export function groupRows( + rows: Row[], + unsorted: string, +): { 구분: string; 상세: Map }[] { + const out = new Map>(); + for (const row of rows) { + const top = row.구분 || ""; + const sub = row.구분 ? row.상세구분 || "" : ""; + if (!out.has(top)) out.set(top, new Map()); + const subs = out.get(top)!; + subs.set(sub, [...(subs.get(sub) ?? []), row]); + } + const order = (a: string, b: string): number => + !a ? 1 : !b ? -1 : a.localeCompare(b, "ko-KR", { numeric: true }); + return [...out.keys()].sort(order).map((top) => ({ + 구분: top || unsorted, + 상세: new Map([...out.get(top)!].sort(([a], [b]) => order(a, b))), + })); } export function buildPrecomboSection(opt: PrecomboOptions): PrecomboHandle { - const items = PARTS.map((part) => { - const b = el("button", { - className: "m02-side__item ui-sidebar-row", - text: tc(part.label), - attrs: { type: "button", "data-precombo": part.kind }, + const barHost = el("div"); + const treeHost = el("div", { className: "ui-tree", attrs: { "data-precombo-tree": "" } }); + const leaves = new Map(); + const closed = new Set(); // 접은 폴더 — 다시 그려도 그대로 + let rows: Row[] = []; + let seq = 0; + + const open = async (row: Row): Promise => { + const at = opt.scope(); + if (!(await opt.confirmLeave())) return; + opt.onOpen({ + ...precomboList(at), + name: row.이름, + label: row.제목 ? `${row.이름} ${row.제목}` : row.이름, }); - // 이름 "" = 목록표(UI_Main) - b.addEventListener("click", async () => { - if (!(await opt.confirmLeave())) return; - opt.onOpen({ - layer: opt.layer, - projectId: null, - kind: part.kind, - name: "", - label: tc(part.label), - }); - }); - return b; + }; + const leaf = (row: Row): TreeNode => ({ + id: `doc:${row.이름}`, + label: row.제목 || row.이름, + tip: row.이름, + count: null, + run: () => void open(row), }); + const folder = (id: string, label: string, children: TreeNode[]): TreeNode => ({ + id, + label, + count: children.length, + folder: true, + open: !closed.has(id), + children, + }); + + const draw = (): void => { + leaves.clear(); + if (!rows.length) + return void treeHost.replaceChildren( + el("p", { className: "m02-side__empty", text: tc("Lib_None") }), + ); + const nodes = groupRows(rows, tc("Lib_Unsorted")).map((g) => + folder( + `sub:${g.구분}`, + g.구분, + [...g.상세].flatMap(([sub, list]) => + sub ? [folder(`sub:${g.구분}/${sub}`, sub, list.map(leaf))] : list.map(leaf), + ), + ), + ); + treeHost.replaceChildren( + ...renderTree(nodes, (node, caret, depth, onClick) => { + const row = createTreeRow(node, caret, depth, () => { + onClick(); + if (!node.children?.length) return; + if (row.querySelector(".ui-tree__caret")?.textContent === "▸") closed.add(node.id); + else closed.delete(node.id); + }); + if (node.run) { + row.dataset.precomboKey = node.id.slice(4); + leaves.set(node.id.slice(4), row); + } + return row; + }), + ); + paint(); + }; + + const paint = (): void => { + const cur = opt.getOpen(); + const at = opt.scope(); + const mine = + cur && isPrecombo(cur.kind) && cur.layer === at.layer && (cur.owner ?? null) === at.owner; + for (const [key, row] of leaves) row.classList.toggle("is-active", !!mine && cur.name === key); + }; + + const refresh = async (): Promise => { + const mine = ++seq; + const at = opt.scope(); + barHost.replaceChildren(opt.bar()); + treeHost.replaceChildren(el("p", { className: "m02-side__empty", text: tc("Loading") })); + let got: Row[] = []; + try { + got = (await listTemplates(at.layer, null, at.owner)).filter((r) => r.종류 === "precombo"); + } catch { + got = []; // 못 받으면 빈 나무(목록표에서 까닭) + } + if (mine !== seq) return; + rows = got; + draw(); + }; + const body = el("div", { className: "m02-side__items ui-sidebar-list", - children: [...opt.bar, ...items], + attrs: { "data-precombo": "" }, + children: [barHost, treeHost], }); - const paint = (): void => { - const on = opt.openKind(); - items.forEach((b, i) => b.classList.toggle("is-active", PARTS[i].kind === on)); - }; - return { body, paint }; + return { body, paint, refresh }; } diff --git a/M02_MasterTemplete/M02_Template_Layers.py b/M02_MasterTemplete/M02_Template_Layers.py index b1ab41fbf..b4297033c 100644 --- a/M02_MasterTemplete/M02_Template_Layers.py +++ b/M02_MasterTemplete/M02_Template_Layers.py @@ -28,8 +28,8 @@ from config import config_system LAYERS = ("system", "company", "personal", "project") # area = 면적산출 — 통합문서(구조물집계표 열마다 · `A00_Common/spreadsheet/`) # labor = 일위대가(구조물 열마다 · `M02_Labor_Doc`) -# promote = 단가승격(SP-) · precombo = 사전조합(SC-) — 일위대가 조합 서고(`M02_Labor_Ref`) -LIBRARY_KINDS = ("promote", "precombo") +# precombo = 사전조합(SC-) — 일위대가 사전조합 서고(`M02_Labor_Ref` · 옛 단가승격 promote 는 134-2 에서 뺌) +LIBRARY_KINDS = ("precombo",) KINDS = ("table", "drawing", "structure", "labor", "area", *LIBRARY_KINDS) TEMPLATES_DIRNAME = "templates" INITIAL_DIRNAME = "_initial" @@ -173,7 +173,6 @@ _SKELETON = { "structure": ("도면.entities",), "labor": ("담은로직",), "area": ("시트",), - "promote": ("담은로직",), "precombo": ("담은로직",), } diff --git a/resources/tester/helper_130_b07_plan_manage.cjs b/resources/tester/helper_130_b07_plan_manage.cjs index 5b6aea7ba..612ce5081 100644 --- a/resources/tester/helper_130_b07_plan_manage.cjs +++ b/resources/tester/helper_130_b07_plan_manage.cjs @@ -1,4 +1,5 @@ -/* 130-1 [평면도 관리] 모달 헬퍼 — 좌측 「평면도」 묶음 · 모달(종류 추가 · 업데이트 · 지우기 · 장 미리보기)을 +/* 130-1 · 140-2 [평면도 관리] 모달 헬퍼 — 좌측 「평면도」 묶음 · 모달(종류 추가 · 업데이트 · 지우기 · 장 미리보기 · + * 드롭다운 따라가기 · 줄 끌어 순서)을 * Node 로 돌려 표준출력 JSON 한 벌로. TS 는 그 자리에서 트랜스파일 · 화면 그릇(DOM)은 쓰는 만큼만 흉내 · * 서버 길(`_Api_Fetch`)은 메모리 가짜 · 공용 모달 · CAD · 드롭다운 · 체크박스는 기록용 가짜. */ @@ -52,6 +53,12 @@ class FakeNode { click() { (this.listeners.click || []).forEach((handler) => handler({})); } + fire(type, event = {}) { + (this.listeners[type] || []).forEach((handler) => handler({ preventDefault() {}, ...event })); + } + getBoundingClientRect() { + return { top: 0, height: 20, left: 0, width: 100 }; + } /** `.a` · `.a .b`(자손) 만. */ querySelectorAll(selector) { const parts = selector @@ -204,6 +211,12 @@ const API = { return copy(); }, savePlanMarks: async () => copy(), + reorderPlanKinds: async (_, ids) => { + calls.push(["reorder", ids]); + state.kinds = ids.map((id) => state.kinds.find((k) => k.id === id)); + confirmed.delete("plan_k_route"); // 번호 바뀐 장 확정 풂(서버 몫 흉내) + return copy(); + }, invalidateDesignDrawing: async (_, id) => { calls.push(["invalidate", id]); confirmed.delete(id); @@ -258,14 +271,10 @@ const { buildDrawingSidePanel } = loadTs(path.join(B07, "B07_DesignDetail_UI_Pan await settle(); const modal = modals[0]; const cad = cads[0]; - const kindRows = () => - modal.body - .querySelectorAll(".b07-plan-manage__kinds .b07-plan-kind__head") - .map((row) => row.dataset.planKind); + const rows = () => modal.body.querySelectorAll(".b07-plan-manage__sheets .b07-plan-manage__row"); + const kindRows = () => rows().map((row) => row.dataset.planKind); const sheetIds = () => - modal.body - .querySelectorAll(".b07-plan-manage__sheets .b07-drawing-button") - .map((b) => b.dataset.drawingId); + modal.body.querySelectorAll(".b07-plan-manage__sheet").map((b) => b.dataset.drawingId); const kindPick = () => selects.filter((s) => s.label === "종류").at(-1); const action = () => all(modal.body).find( @@ -302,16 +311,17 @@ const { buildDrawingSidePanel } = loadTs(path.join(B07, "B07_DesignDetail_UI_Pan await settle(); out.update = { call: calls.find((c) => c[0] === "update"), - rowText: modal.body - .querySelectorAll(".b07-plan-manage__kinds .b07-plan-kind__head") - .find((row) => row.dataset.planKind === "k_new").textContent, + rowText: rows().find((row) => row.dataset.planKind === "k_new").textContent, }; - // 지우기 — 그 줄의 × - const row = modal.body - .querySelectorAll(".b07-plan-manage__kinds .b07-plan-kind__head") - .find((r) => r.dataset.planKind === "k_new"); - byText(row, "BUTTON", "×")[0].click(); + // 지우기 — 드롭다운에서 그 종류를 고르고 폼 안 [지우기] + kindPick().change("k_new"); + out.removeHiddenForNew = (() => { + kindPick().change(""); + return byText(modal.body, "BUTTON", "지우기")[0].hidden; + })(); + kindPick().change("k_new"); + byText(modal.body, "BUTTON", "지우기")[0].click(); await settle(); await settle(); out.remove = { @@ -320,10 +330,10 @@ const { buildDrawingSidePanel } = loadTs(path.join(B07, "B07_DesignDetail_UI_Pan confirms: confirms.length, }; - // 미리보기 장 바꾸기 — 「지형 2장」 → 「노선배치도」(확정 → [확정 풀기]) + // 미리보기 장 바꾸기 — 「지형 2장」 → 「노선배치도」(확정 배지만 · [확정 풀기] 없음 140-1) const pickSheet = async (id) => { modal.body - .querySelectorAll(".b07-plan-manage__sheets .b07-drawing-button") + .querySelectorAll(".b07-plan-manage__sheet") .find((b) => b.dataset.drawingId === id) .click(); await settle(); @@ -334,19 +344,49 @@ const { buildDrawingSidePanel } = loadTs(path.join(B07, "B07_DesignDetail_UI_Pan preview: cad.loads.at(-1).drawing.entities[0].id, head: head().textContent, active: modal.body - .querySelectorAll(".b07-plan-manage__sheets .b07-drawing-button") + .querySelectorAll(".b07-plan-manage__sheet") .filter((b) => b.dataset.active === "true") .map((b) => b.dataset.drawingId), + sheetTexts: rows()[0] + .querySelectorAll(".b07-plan-manage__sheet") + .map((b) => b.textContent), + dropdown: kindPick().select.value, + activeRow: rows() + .filter((r) => r.dataset.active === "true") + .map((r) => r.dataset.planKind), }; await pickSheet("plan_k_route"); out.routeHead = head().textContent; - byText(head(), "BUTTON", "확정 풀기")[0].click(); + out.routeDropdown = kindPick().select.value; + + // 드롭다운으로 종류 고름 → 그 종류 첫 장 미리보기 + 줄 강조 + kindPick().change("k_terrain"); await settle(); - await settle(); - out.release = { - call: calls.find((c) => c[0] === "invalidate"), - head: head().textContent, + out.dropdownPick = { preview: cad.loads.at(-1).drawing.entities[0].id, + activeRow: rows() + .filter((r) => r.dataset.active === "true") + .map((r) => r.dataset.planKind), + }; + + // 줄 끌어 놓기 — 제자리면 부르지 않음 · 「노선배치도」를 「지형」 위 반에 놓으면 앞으로(놓는 즉시) + const dragTo = async (fromId, toId, clientY) => { + const from = rows().find((r) => r.dataset.planKind === fromId); + const to = rows().find((r) => r.dataset.planKind === toId); + from.fire("dragstart", {}); + to.fire("dragover", { clientY }); + to.fire("drop", {}); + from.fire("dragend"); + await settle(); + await settle(); + }; + await dragTo("k_route", "k_terrain", 15); // 아래 반 = 뒤 = 제자리 + out.dragSame = calls.filter((c) => c[0] === "reorder").length; + await dragTo("k_route", "k_terrain", 5); + out.drag = { + call: calls.find((c) => c[0] === "reorder"), + kinds: kindRows(), + sheets: sheetIds(), }; // 닫기 — 고친 게 있으니 changed = true · 미리보기 듣기 멈춤 @@ -359,7 +399,7 @@ const { buildDrawingSidePanel } = loadTs(path.join(B07, "B07_DesignDetail_UI_Pan await settle(); const modal2 = modals[1]; modal2.body - .querySelectorAll(".b07-plan-manage__sheets .b07-drawing-button") + .querySelectorAll(".b07-plan-manage__sheet") .find((b) => b.dataset.drawingId === "plan_k_route") .click(); await settle(); diff --git a/resources/tester/helper_m02_labor_normalize.cjs b/resources/tester/helper_m02_labor_normalize.cjs new file mode 100644 index 000000000..753edd03b --- /dev/null +++ b/resources/tester/helper_m02_labor_normalize.cjs @@ -0,0 +1,22 @@ +/* M02 일위대가 문서 화면 정리(`M02_MasterTemplete_Labor_Api.ts` `normalizeLabor`) 헬퍼(134-1) — + * 그 자리에서 트랜스파일해 Node 로 돌림 · 가져오는 길은 빈 대역 · stdin = {doc, column} · stdout = 정리한 문서 JSON. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const file = path.join( + __dirname, + "..", + "..", + "M02_MasterTemplete", + "M02_MasterTemplete_Labor_Api.ts", +); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const stub = new Proxy({}, { get: () => function () {} }); +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, () => stub); + +const input = JSON.parse(fs.readFileSync(0, "utf8")); +process.stdout.write(JSON.stringify(box.exports.normalizeLabor(input.doc, input.column))); diff --git a/resources/tester/helper_m02_precombo_scope.cjs b/resources/tester/helper_m02_precombo_scope.cjs new file mode 100644 index 000000000..58423a827 --- /dev/null +++ b/resources/tester/helper_m02_precombo_scope.cjs @@ -0,0 +1,54 @@ +/* M02 일위대가 사전조합 층(134-3) 헬퍼 — `M02_MasterTemplete_Api_Fetch.ts` 명시 owner 길 · + * `M02_MasterTemplete_UI_Side_Precombo.ts` `groupRows`(구분 › 상세구분 2단) 를 트랜스파일해 Node 로 돌림 · + * `precomboClasses`(134-5 · 구조물군 › 종류 + 공통) · fetch 대역이 부른 주소를 모음 · + * stdin = {rows, def?} · stdout = {urls, groups, classes} JSON. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const STUBS = { "@config/config_frontend": { API_BASE_URL: "/api" } }; +const stub = new Proxy({}, { get: (_t, k) => (k === "currentLanguageIndex" ? () => 0 : () => {}) }); +const cache = new Map(); +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + if (request.startsWith(".") && !request.endsWith(".css")) + return loadTs(`${path.resolve(path.dirname(full), request)}.ts`); + return stub; + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +const urls = []; +global.fetch = async (url) => { + urls.push(url); + return { status: 200, ok: true, json: async () => ({ 양식: [] }) }; +}; + +(async () => { + const input = JSON.parse(fs.readFileSync(0, "utf8")); + const api = loadTs(path.join(ROOT, "M02_MasterTemplete", "M02_MasterTemplete_Api_Fetch.ts")); + const side = loadTs( + path.join(ROOT, "M02_MasterTemplete", "M02_MasterTemplete_UI_Side_Precombo.ts"), + ); + api.setActiveLayer("personal", "7/99"); // 페이지 = 남의 개인을 보는 중 + await api.readTemplate("personal", "labor", "x1", null); // 페이지 층 = 그 사람 + await api.readTemplate("personal", "precombo", "SC-000001", null, null); // 사전조합 = 내 개인 + await api.listTemplates("company", null, "8"); // 사전조합 = 볼 회사 + await api.saveTemplate("system", "precombo", "SC-000002", null, "v", {}, null); + const groups = side.groupRows(input.rows, "미분류").map((g) => ({ + 구분: g.구분, + 상세: [...g.상세].map(([k, v]) => [k, v.map((r) => r.이름)]), + })); + const classes = input.def ? side.precomboClasses(input.def) : null; // 134-5 + process.stdout.write(JSON.stringify({ urls, groups, classes })); +})(); diff --git a/resources/tester/test_130_b07_plan_manage.py b/resources/tester/test_130_b07_plan_manage.py index 457190f4f..ca7023a5d 100644 --- a/resources/tester/test_130_b07_plan_manage.py +++ b/resources/tester/test_130_b07_plan_manage.py @@ -3,9 +3,11 @@ 못박는 것 - 좌측 「평면도」 = 장 단추 + [평면도 관리] 하나 · 종류 폼 · 대피소 칸 · × 없음. - - 단추 → 공용 모달(큰 창) · 안에 종류 목록 · 종류 폼 · 대피소 · 사토장 자리 · 장 목록 · 첫 장 미리보기(보기 전용). - - 종류 추가 · 업데이트(축척 포함) · 지우기 = 지금 서버 길 · 고칠 때마다 모달 안을 다시 받음. - - 미리보기 장 바꾸기 · 확정이면 [확정 풀기](invalidate) · 닫으면 changed=True · [CAD 에서 열기] = 그 장 id. + - 단추 → 공용 모달(큰 창) · 안에 [종류 ▼] 폼 · 평면도 장 블록(종류마다 한 줄 · 작은 장 단추) · 대피소 · 사토장 자리 · + 첫 장 미리보기(보기 전용). + - 종류 추가 · 업데이트(축척 포함) · 지우기(폼 안) = 지금 서버 길 · 고칠 때마다 모달 안을 다시 받음. + - 장 단추 → 드롭다운 따라감 · 드롭다운 → 그 종류 첫 장 · 줄 끌어 놓기 = 종류 순서(놓는 즉시 · 140-2). + - 미리보기 장 바꾸기 · 캔버스만(bare) · 확정 배지만(확정 풀기 없음 140-1) · 닫으면 changed=True · [CAD 에서 열기] = 그 장 id. Node 헬퍼 `helper_130_b07_plan_manage.cjs` 가 화면 그릇 흉내로 돌림. 화면은 사용자 확인. """ @@ -54,6 +56,7 @@ def test_첫_장_미리보기는_보기_전용(out): assert out["open"]["preview"] == "plan_k_terrain" extra = out["open"]["extra"] assert extra["readOnly"] is True and extra["viewOnly"] is True + assert extra["bare"] is True # 140-1 캔버스만(제목줄 · 리본 · 상태막대 없음) assert extra["recoveryScope"] == "b07-plan-preview" # 본 CAD 자동백업 칸과 안 겹침 @@ -66,10 +69,11 @@ def test_종류_추가(out): def test_종류_업데이트(out): assert out["updateLabel"] == "업데이트" assert out["update"]["call"] == ["update", "k_new", "sub6임시2", [], 1000] - assert out["update"]["rowText"].startswith("sub6임시2 · 1장") + assert out["update"]["rowText"] == "≡sub6임시2" # 한 장이면 이름이 곧 장 단추 def test_종류_지우기(out): + assert out["removeHiddenForNew"] is True # 「새 종류」엔 [지우기] 없음 assert out["remove"]["call"] == ["delete", "k_new"] assert out["remove"]["confirms"] == 1 assert out["remove"]["kinds"] == ["k_terrain", "k_route"] @@ -80,14 +84,27 @@ def test_미리보기_장_바꾸기(out): assert s2["preview"] == "plan_k_terrain_2" assert s2["active"] == ["plan_k_terrain_2"] assert "확정 전" in s2["head"] and "확정 풀기" not in s2["head"] + assert s2["sheetTexts"] == ["1", "2"] # 여러 장 = 작은 장 단추 + assert s2["dropdown"] == "k_terrain" and s2["activeRow"] == ["k_terrain"] + assert out["routeDropdown"] == "k_route" -def test_확정이면_확정_풀기(out): - assert "확정 풀기" in out["routeHead"] - rel = out["release"] - assert rel["call"] == ["invalidate", "plan_k_route"] - assert "확정 풀기" not in rel["head"] - assert rel["preview"] == "plan_k_route" # 보던 장 그대로 +def test_드롭다운이면_그_종류_첫_장(out): + assert out["dropdownPick"] == {"preview": "plan_k_terrain", "activeRow": ["k_terrain"]} + + +def test_줄_끌어_놓기는_종류_순서(out): + assert out["dragSame"] == 0 # 제자리면 서버 안 부름 + drag = out["drag"] + assert drag["call"] == ["reorder", ["k_route", "k_terrain"]] + assert drag["kinds"] == ["k_route", "k_terrain"] + assert drag["sheets"] == ["plan_k_route", "plan_k_terrain", "plan_k_terrain_2"] + + +def test_확정이면_배지만(out): + # 140-1 확정 풀기는 메인 [수정] — 모달엔 배지 · [CAD 에서 열기]만 + assert "확정" in out["routeHead"] and "CAD 에서 열기" in out["routeHead"] + assert "확정 풀기" not in out["routeHead"] def test_닫으면_좌측_다시_읽기_신호(out): diff --git a/resources/tester/test_140_b07_cross_grid.py b/resources/tester/test_140_b07_cross_grid.py new file mode 100644 index 000000000..35eb4c799 --- /dev/null +++ b/resources/tester/test_140_b07_cross_grid.py @@ -0,0 +1,148 @@ +# -*- coding: utf-8 -*- +"""140-3 B07 횡단면도 장 배치 — 장마다 열 × 행 칸(기본 4 × 4). + +못박는 것 + - 칸 = 그림칸(usable_area)을 똑같이 나눈 자리 · 차례 = 좌하단 → 위 → 오른쪽 열. + - 측점 73개 · 4 × 4 → 16 · 16 · 16 · 16 · 9 · 17번째 측점 = 2장 첫 칸. + - 장마다 다른 배치(sheets {장 번호: [열, 행]}) · 기본 바꾸기. + - 단면 블록 가운데 = 칸 가운데 · 블록 크기 = 1/100 그대로(section_block_size). + - 도각 = 고정 그림칸 — 내용이 작아도 커도 같은 자리. + - 배치 저장 · 읽기(깨진 값 거절) · 담긴 측점이 바뀐 확정 장만 풂. +""" + +import json +from pathlib import Path + +import pytest + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import FRAME_LAYER_ID +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import ( + build_cross_sheet, + grid_slots, + plan_cross_sheets, + section_block_size, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Template import entities_bbox, usable_area +from B07_DesignDetail.B07_DesignDetail_Router_Support import ( + _cross_sheet_plan, + read_cross_layout, + write_cross_layout, +) +from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import _read_manifest, _write_manifest + + +def _source(chainage: float, slope: float, half: int = 10) -> dict: + return { + "chainage_m": chainage, + "samples": [ + {"valid": True, "offset_m": float(x), "elevation_m": 100.0 + slope * x} + for x in range(-half, half + 1) + ], + } + + +def test_grid_slots_bottom_left_up_then_right() -> None: + w, h = usable_area() + slots = grid_slots(6, 4, 4) + cw, ch = w / 4, h / 4 + assert slots[0] == pytest.approx([-w / 2 + cw / 2, -h / 2 + ch / 2]) # 좌하단 + assert slots[1] == pytest.approx([-w / 2 + cw / 2, -h / 2 + 1.5 * ch]) # 그 위 + assert slots[4] == pytest.approx([-w / 2 + 1.5 * cw, -h / 2 + ch / 2]) # 다음 열 맨 아래 + + +def test_73_stations_default_grid_17th_is_sheet_two() -> None: + chainages = [i * 20.0 for i in range(73)] + sheets = plan_cross_sheets(chainages) + assert [len(s["chainages"]) for s in sheets] == [16, 16, 16, 16, 9] + assert sheets[1]["chainages"][0] == chainages[16] # 17번째 측점 = 2장 첫 칸 + assert sheets[1]["number"] == 2 and sheets[1]["grid"] == [4, 4] + assert sheets[1]["id"] == "cross_s00320m" # 장 id = 첫 측점 + + +def test_per_sheet_and_default_layout() -> None: + chainages = [float(i) for i in range(40)] + layout = {"default": [3, 3], "sheets": {"1": [2, 2], "3": [5, 4]}} + sheets = plan_cross_sheets(chainages, layout) + assert [s["grid"] for s in sheets] == [[2, 2], [3, 3], [5, 4], [3, 3]] + assert [len(s["chainages"]) for s in sheets] == [4, 9, 20, 7] + + +def _frame_bbox(drawing: dict) -> tuple: + return entities_bbox([e for e in drawing["entities"] if e.get("layerId") == FRAME_LAYER_ID]) + + +def test_block_centered_in_cell_at_scale_and_frame_fixed() -> None: + table = {"ground": 100.0, "planned": 100.0} + small = [_source(0.0, 0.05, 6)] + big = [_source(0.0, 0.6, 15), _source(20.0, 0.2, 8)] + drawings = [] + for sources in (small, big): + sheet = plan_cross_sheets([s["chainage_m"] for s in sources])[0] + sections = [ + {"chainage": s["chainage_m"], "source": s, "quantity_table": table, "title": ""} + for s in sources + ] + drawing = build_cross_sheet(sheet, sections) + for source, slot, placement in zip( + sources, sheet["slots"], drawing["cross_placements"], strict=True + ): + left, bottom, right, top = placement["frame"] + # 블록 가운데 = 칸 가운데 + assert (left + right) / 2 == pytest.approx(slot[0]) + assert (bottom + top) / 2 == pytest.approx(slot[1]) + # 1/100 그대로 — 블록 크기가 자기 치수 그대로(칸 맞춤으로 줄이지 않음) + width, height = section_block_size(source, None, design=None) + assert right - left == pytest.approx(width, abs=0.5) + drawings.append(drawing) + # 도각 = 고정 그림칸 — 내용 크기와 무관하게 같은 자리 + assert _frame_bbox(drawings[0]) == pytest.approx(_frame_bbox(drawings[1])) + + +def _project(tmp_path: Path, count: int) -> tuple[Path, Path]: + root = tmp_path / "project" + cross_dir = root / "B06_Section" / "cross_sections" + cross_dir.mkdir(parents=True) + longitudinal_path = root / "B06_Section" / "longitudinal" / "longitudinal.json" + longitudinal_path.parent.mkdir(parents=True) + stations = [] + for i in range(count): + chainage = i * 20 + stations.append({"chainage_m": float(chainage), "center_x": 0.0, "center_y": 0.0}) + (cross_dir / f"cross_{chainage:05d}m.json").write_text( + json.dumps(_source(float(chainage), 0.1)), "utf-8" + ) + longitudinal_path.write_text(json.dumps({"stations": stations}), "utf-8") + return root, longitudinal_path + + +def test_layout_store_and_release_changed_confirmed(tmp_path: Path) -> None: + root, longitudinal_path = _project(tmp_path, 20) + assert read_cross_layout(root) == {"default": [4, 4], "sheets": {}} # 파일 없으면 기본 + ids = [s["id"] for s in _cross_sheet_plan(root, longitudinal_path)] + assert ids == ["cross_s00000m", "cross_s00320m"] # 16 + 4 + manifest = _read_manifest(root) + manifest["drawings"] = {i: {"confirmed": True} for i in ids} + _write_manifest(root, manifest) + + # 2장만 3 × 3 — 1장 측점 그대로(확정 유지) · 2장 측점 그대로(4개 < 9칸) → 풀 장 없음 + assert ( + write_cross_layout(root, longitudinal_path, {"default": [4, 4], "sheets": {"2": [3, 3]}}) + == [] + ) + assert read_cross_layout(root)["sheets"] == {"2": [3, 3]} + + # 1장 3 × 3 — 1장 9개로 줄고 2장은 다른 측점에서 시작 → 1장 풂(옛 측점 16개 돌려줌) + stale = write_cross_layout( + root, longitudinal_path, {"default": [4, 4], "sheets": {"1": [3, 3]}} + ) + assert stale[:16] == [i * 20 for i in range(16)] + drawings = _read_manifest(root)["drawings"] + assert drawings["cross_s00000m"]["confirmed"] is False + assert drawings["cross_s00320m"]["confirmed"] is False # 사라진 장 id 도 풂 + new_ids = [s["id"] for s in _cross_sheet_plan(root, longitudinal_path)] + assert new_ids == ["cross_s00000m", "cross_s00180m"] + + with pytest.raises(ValueError): + write_cross_layout(root, longitudinal_path, {"default": [0, 4], "sheets": {}}) + with pytest.raises(ValueError): + write_cross_layout(root, longitudinal_path, {"default": [4, 4], "sheets": {"x": [2, 2]}}) diff --git a/resources/tester/test_144_b09_skeleton.py b/resources/tester/test_144_b09_skeleton.py index c50dd1a30..ee4697857 100644 --- a/resources/tester/test_144_b09_skeleton.py +++ b/resources/tester/test_144_b09_skeleton.py @@ -130,5 +130,6 @@ def test_연결된_사본이_부른_자원만(tmp_path): (tmp_path / name).write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") assert used_keys(tmp_path) == ["EQ000004", "MO000002", "LB000048"] (tmp_path / "입력값.json").write_text("{}", encoding="utf-8") - assert used_keys(tmp_path) == [] # 입력 빈 로직은 못 돎 — 부른 줄 없음 + auto = used_keys(tmp_path) # 빈 입력 = 자동값(145-1 · 142-1) — 자동 기계 한 대 자원만 + assert auto and len(auto) <= 3 and auto[0].startswith("EQ") assert used_keys(tmp_path / "없음") == [] diff --git a/resources/tester/test_145_1_b09_sangeun.py b/resources/tester/test_145_1_b09_sangeun.py new file mode 100644 index 000000000..f951db5d1 --- /dev/null +++ b/resources/tester/test_145_1_b09_sangeun.py @@ -0,0 +1,179 @@ +"""145-1 B09 단가산출근거 · 목록표 — 승격 줄 → 산근 블록 · 엑셀 검산 · 같은 산근 하나 · 빈 표. + +- 산근 = 연결된 사본 일위대가 로직 줄 중 `승격: true` + 옛 SP 참조 줄(134-2 ③) · 승격 아닌 줄은 안 담음 +- 줄 금액 = ROUNDDOWN(B09 굳힌 단가 × 1 단위 수량, 1) · 총계 = 비목마다 ROUNDDOWN 0 — 엑셀 + 단가산출근거 제 6호표(뒷채움) 줄 값 · 총계 8,328(6,878 · 538 · 912)과 같음 +- 같은 로직 + 같은 입력값 = 산근 하나(쓰는 구조물이 늘어남) · 입력이 다르면 둘 · 번호 = 첫 등장 +- 사본 없으면 빈 표 + 「연결 없음」 · 조합 밖 토공 · 운반 산근 안내 한 줄 · 작업량 Q = ROUND 2 +""" + +import asyncio +import json +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B09_Estimation import B09_Estimation_Prices as prices # noqa: E402 +from B09_Estimation import B09_Estimation_Router as router_mod # noqa: E402 +from B09_Estimation import B09_Estimation_Sangeun as sg # noqa: E402 +from B09_Estimation.B09_Estimation_Calc_Base import compute_base # noqa: E402 +from B09_Estimation.B09_Estimation_Linked import used_keys # noqa: E402 +from M02_MasterTemplete import M02_Item_Prices as item_prices # noqa: E402 + +STONE = "GF000219" # 돌쌓기 메쌓기(인력) — 석공 0.195 · 보통인부 0.156 / ㎡ +NET = "GC000337" # 낙석방지망 — 노무 · 크레인(중기) · 자재 셋 +NET_IN = {"구분": "기계식", "방지망": "포켓식", "지역": "전국평균", "크레인": "2104-0010"} +SP_REF = {"층": "system", "주인": None, "키": "SP-000001"} + + +def _snapshot(folder: Path, rows, values=None, copies=None) -> Path: + folder.mkdir(parents=True, exist_ok=True) + labor = { + "줄": {"종류": "일위대가", "담은로직": rows}, + **({"참조사본": copies} if copies else {}), + } + files = { + "단가.json": item_prices.to_json(item_prices.collect([STONE, NET])), + "일위대가.json": labor, + "입력값.json": {k: {n: {"값": v} for n, v in d.items()} for k, d in (values or {}).items()}, + } + for name, data in files.items(): + (folder / name).write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") + return folder + + +def _sheets(*folders: Path) -> dict: + keys = [k for f in folders for k in used_keys(f)] + return compute_base(prices.build_inputs(None, list(dict.fromkeys(keys))), {}) + + +def test_승격_줄만_산근_블록_단가는_B09_표(tmp_path): + a = _snapshot(tmp_path / "a", [{"로직": STONE, "승격": True}, {"로직": NET}], {NET: NET_IN}) + sheets = _sheets(a) + got = sg.collect([("s1", a)], sheets) + (block,) = got["sg"] + assert (block["no"], block["logic"], block["unit"]) == (1, STONE, "㎡") + labor = {r["key"]: r["price"] for r in sheets["labor"]} + lines = block["lines"] + assert [(ln["kind"], ln["ref"]["key"], ln["qty"]) for ln in lines] == [ + ("labor", "LB000033", 0.195), + ("labor", "LB000002", 0.156), + ] + want = [sg.rounddown(labor[ln["ref"]["key"]] * ln["qty"], 1) for ln in lines] + assert [ln["labor"] for ln in lines] == want + assert block["labor"] == sg.rounddown(sum(want), 0) and block["sum"] == block["labor"] + assert block["material"] == 0 and block["expense"] == 0 and block["why"] is None + assert got["sg_list"][0]["key"] == block["key"] and got["sg_gaps"] == [] + assert sg.price_of(got, block["key"]) == { + "no": 1, "labor": block["labor"], "material": 0.0, "expense": 0.0, "sum": block["sum"], + } # fmt: skip + + +def test_중기는_중기목록표_노_재_경_자재는_적용_단가(tmp_path): + a = _snapshot(tmp_path / "a", [{"로직": NET, "승격": True}], {NET: NET_IN}) + sheets = _sheets(a) + (block,) = sg.collect([("s1", a)], sheets)["sg"] + kinds = [ln["kind"] for ln in block["lines"]] + assert kinds == ["labor", "labor", "machine", "material", "material", "material"] + crane = block["lines"][2] + row = next(r for r in sheets["machine"] if r["key"] == crane["ref"]["key"]) + for part in sg.PARTS: + assert crane[part] == sg.rounddown(row[part] * crane["qty"], 1) + net = block["lines"][3] + applied = next(r["applied"] for r in sheets["compare"] if r["key"] == net["ref"]["key"]) + assert net["material"] == sg.rounddown(applied * net["qty"], 1) + assert block["sum"] == block["labor"] + block["material"] + block["expense"] + + +def test_엑셀_6호표_뒷채움_검산(): + """단가산출근거 C285 = 8,328(6,878 · 538 · 912) — 보통인부 0.018 × 165,545 · 굴삭기(0.2m3) 0.07 × + 중기목록표 55,700 · 7,695 · 13,041(줄 2,979.8 · 3,899.0 · 538.6 · 912.8 · 계 8,330.2 → 비목마다 버림).""" + zero = dict.fromkeys(sg.PARTS, 0.0) + labor = {**zero, "labor": sg._money(165545, 0.018)} + machine = {p: sg._money(v, 0.07) for p, v in zip(sg.PARTS, (55700, 7695, 13041))} + assert labor["labor"] == 2979.8 + assert (machine["labor"], machine["material"], machine["expense"]) == (3899.0, 538.6, 912.8) + assert sg.block_totals([labor, machine]) == { + "labor": 6878, "material": 538, "expense": 912, "sum": 8328, + } # fmt: skip + assert sg.block_totals([labor, {**machine, "material": None}])["sum"] is None # 빈칸 = 빈칸 + + +def test_같은_로직_같은_입력은_하나_다르면_둘_옛_SP_도_같은_산근(tmp_path): + a = _snapshot(tmp_path / "a", [{"로직": NET, "승격": True}], {NET: NET_IN}) + b = _snapshot(tmp_path / "b", [{"로직": NET, "승격": True}], {NET: {**NET_IN, "": ""}}) + other = {**NET_IN, "방지망": "비포켓식"} + c = _snapshot(tmp_path / "c", [{"로직": NET, "승격": True}], {NET: other}) + sp = {"담은로직": [{"로직": NET, "입력값": NET_IN}], "배": 1} + d = _snapshot( + tmp_path / "d", + [{"로직": "", "참조": SP_REF}, {"로직": STONE, "승격": True}], + copies={"참조|system||SP-000001": sp}, + ) + got = sg.collect([("s1", a), ("s2", b), ("s3", c), ("s4", d)], _sheets(a, c, d)) + blocks = got["sg"] + assert [(x["no"], x["logic"], x["uses"]) for x in blocks] == [ + (1, NET, ["s1", "s2", "s4"]), # 빈 입력 칸은 무시 · 옛 SP(같은 로직 · 입력) = 같은 산근 + (2, NET, ["s3"]), + (3, STONE, ["s4"]), + ] + assert blocks[0]["key"] == sg.sangeun_key(NET, {**NET_IN, "x": None}, 100, {}) + assert blocks[0]["key"] != sg.sangeun_key(NET, NET_IN, 90) # 켜쌓기율이 다르면 다른 산근 + + +def test_사본_없으면_빈_표_연결_없음(tmp_path, monkeypatch): + got = sg.collect([("s1", tmp_path / "없음")], {}) + assert got["sg"] == [] and got["sg_list"] == [] and got["sg_gaps"] == [] + assert got["sg_notes"] == [sg.NO_SOURCE, sg.EMPTY] + plain = _snapshot(tmp_path / "p", [{"로직": STONE}]) # 승격 없는 사본도 빈 표 + assert sg.collect([("s1", plain)], {})["sg"] == [] + + async def fake_materials(_project_id): + return {} + + monkeypatch.setattr(router_mod, "material_sheets", fake_materials) + sheets = asyncio.run(router_mod.payload("p", tmp_path))["sheets"] + assert sheets["sg"] == [] and sheets["sg_list"] == [] + assert sg.EMPTY in sheets["sg_notes"] and sg.NO_SOURCE in sheets["sg_notes"] + + +def test_단가_없으면_빈칸과_연결_없음(tmp_path): + a = _snapshot(tmp_path / "a", [{"로직": STONE, "승격": True}]) + got = sg.collect([("s1", a)], {"labor": [], "machine": [], "compare": []}) + (block,) = got["sg"] + assert block["labor"] is None and block["sum"] is None + assert "노무비목록표에 없음" in block["why"] + assert [(g["tab"], g["key"]) for g in got["sg_gaps"]] == [("sg", block["key"])] + + +def test_작업량_Q는_소수_둘(): + import M01_MasterData.M01_MasterData_Store as m01 + + whole = m01.loaded()[1] + for code, row in sorted((whole.index.get("GF") or {}).items()): + if not any(sg._Q.fullmatch(s["이름"]) for s in row.get("중간", [])): + continue + try: + env = sg._env(whole, row, {}) + except Exception: # noqa: BLE001 — 입력이 필요한 로직은 건너뜀 + continue + q = next(env[s["이름"]] for s in row["중간"] if sg._Q.fullmatch(s["이름"])) + if hasattr(q, "as_tuple"): + assert q == round(q, 2), code + return + pytest.skip("자동값으로 도는 작업량 Q 로직 없음") + + +def test_화면_탭_둘_서버_산근_그대로(): + shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8") + assert "sg_list: buildSgList," in shell and "sg: buildSg," in shell + assert 'const SERVER_ONLY = ["sg", "sg_list", "sg_gaps", "sg_notes"] as const;' in shell + assert "gaps: [...base.gaps, ...(server.sg_gaps || [])]" in shell + tabs = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Sangeun.ts").read_text("utf-8") + assert 'link: (i) => (rows[i] ? { tab: "sg", key: String(rows[i].key) } : null),' in tabs + assert "PARTS.filter((p) => ln.price?.[p] !== 0)" in tabs # 중기 = 노 · 재 · 경 세 줄 diff --git a/resources/tester/test_145_4_b09_total_calc.py b/resources/tester/test_145_4_b09_total_calc.py new file mode 100644 index 000000000..a4a87e750 --- /dev/null +++ b/resources/tester/test_145_4_b09_total_calc.py @@ -0,0 +1,215 @@ +# -*- coding: utf-8 -*- +"""145-4 B09 총괄설계내역서 · 공사원가계산서 셈 — 엑셀 검산 대조 · 거울(`B09_Estimation_Calc_Total.py` ↔ `.ts`). + +견본 = 기번3 공통 엑셀(`2024년 간선임도사업(기번3).xlsx`) 총괄 48 비목 합 · M49~M64 요율 · N55 기초액 · +I62 이윤 보정 · 설계내역서 J253 관급 순자재대 · D253 조달수수료율 → 총괄 D49~D67 · E55 A/B · 원가계산서 구성비. +""" + +import json +import re +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B09_Estimation.B09_Estimation_Calc_Total import compute_total # noqa: E402 +from B09_Estimation.B09_Estimation_Rates import rate_candidates # noqa: E402 + +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) + +SUMS = {"material": 194234417, "labor": 346800554, "expense": 169324165, "supply_net": 69474220} +RATES = { + "indirect_labor": 14.5, + "accident": 3.56, + "employment": 1.01, + "health": 3.545, + "care": 12.95, + "pension": 4.5, + "safety": 1.86, + "other": 6, + "environment": 0.8, + "machine": 0.4, + "overhead": 6, + "profit": 15, + "vat": 10, +} +ESTIMATE = { + "rates": RATES, + "safety_base": 5349000, + "profit_adjust": 905980, + "procurement_fee_pct": 0.54, +} + +EXCEL_LINES = { # 총괄 D49~D58 · D60 · D62 · D64 · D66 + "indirect_labor": 50286080, + "accident": 14136284, + "employment": 4010575, + "health": 12294079, + "care": 1592083, + "pension": 15606024, + "safety": 16586996, + "other": 35479263, + "environment": 5682873, + "machine": 2841436, + "overhead": 52132489, + "profit": 108109955, + "vat": 102911727, + "supply": 69850000, +} +EXCEL_SUBTOTALS = { # 총괄 D59 · D61 · D63 · D65 · D67 + "net_cost": 868874829, + "da": 921007318, + "supply_price": 1029117273, + "contract": 1132029000, + "grand": 1201879000, +} + + +def test_엑셀_검산_총괄(): + out = compute_total(SUMS, ESTIMATE) + assert out["sums"]["direct"] == 710359136 # 가. 순공사비계 D48 + assert out["lines"] == EXCEL_LINES + assert out["subtotals"] == EXCEL_SUBTOTALS + assert out["safety"] == {"a": 16586996, "b": 18494700} # E55 글 A · B + assert out["profit_raw"] == 109015935 # J62 보정 전 + assert out["supply"] == {"net": 69474220, "fee": 375160, "raw": 69849380} # I66 · O66 + + +def test_엑셀_검산_원가계산서_거울_구성비(): + cost = compute_total(SUMS, ESTIMATE)["cost"] + share = compute_total(SUMS, ESTIMATE)["share"] + assert cost["direct_material"] == 194234417 and cost["material"] == 194234417 # D5 · D8 + assert cost["labor"] == 397086634 # D11 + assert cost["expense_sum"] == 277553778 # D22 + assert cost["supply_price"] == 1029117273 and cost["grand"] == 1201879000 # D25 · D29 + assert share["direct_material"] == pytest.approx(16.160896146783497) # E5 + assert share["labor"] == pytest.approx(33.03881954839048) # E11 + assert share["supply"] == pytest.approx(5.8117331278772655) # E28 + assert share["grand"] == 100 + + +def test_간접재료비_작업설은_직접재료비만_바꿈(): + est = {**ESTIMATE, "indirect_material": 1000, "scrap": 300} + out = compute_total(SUMS, est) + assert out["cost"]["direct_material"] == 194234417 - 1000 + 300 # 엑셀 D5 = F48 − D6 + D7 + assert out["cost"]["material"] == 194234417 + assert out["subtotals"] == EXCEL_SUBTOTALS + + +def test_빈칸_규칙(): + blank = compute_total(SUMS, {}) + assert all(v is None for k, v in blank["lines"].items() if k != "supply") + assert blank["subtotals"]["net_cost"] is None and blank["subtotals"]["grand"] is None + assert blank["lines"]["supply"] is None # 조달수수료율 빈칸 + # 요율 하나만 빈칸 — 그 줄 · 소계 빈칸 · 다른 줄은 셈 + one = compute_total(SUMS, {**ESTIMATE, "rates": {**RATES, "care": None}}) + assert one["lines"]["care"] is None and one["lines"]["health"] == 12294079 + assert one["subtotals"]["net_cost"] is None and one["lines"]["supply"] == 69850000 + # 비목 합이 아직 없음(145-3 전) — 모두 빈칸 + none = compute_total(None, ESTIMATE) + assert none["subtotals"]["grand"] is None and none["lines"]["indirect_labor"] is None + # 관급 없음 — 수수료율 빈칸이어도 관급 0 + zero = compute_total({**SUMS, "supply_net": 0}, {**ESTIMATE, "procurement_fee_pct": None}) + assert zero["lines"]["supply"] == 0 and zero["subtotals"]["grand"] is not None + + +def test_안전관리비_B_가_작으면_B(): + big = compute_total({**SUMS, "supply_net": 2_000_000_000}, ESTIMATE) + assert big["safety"]["a"] > big["safety"]["b"] + assert big["lines"]["safety"] == big["safety"]["b"] + + +def test_요율_후보_글(): + cand = rate_candidates() + assert cand["RT000001"] == "M01 RT000001 3.56%" + assert "구간" in cand["RT000002"] and cand["RT000019"].endswith("10%") + + +def _run_ts(cases: list) -> list: + base = ROOT / "B09_Estimation" / "B09_Estimation_Calc_Base.ts" + calc = ROOT / "B09_Estimation" / "B09_Estimation_Calc_Total.ts" + body = ( + f"\nconst cases = {json.dumps(cases, ensure_ascii=False)};" + "\nconsole.log(JSON.stringify(cases.map(([s, e]) => computeTotal(s, e))));" + ) + script = "".join(IMPORTS.sub("", p.read_text(encoding="utf-8")) for p in (base, calc)) + body + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", script], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def test_거울_TS_가_같은_값(): + cases = [ + [SUMS, ESTIMATE], + [SUMS, {**ESTIMATE, "indirect_material": 1000, "scrap": 300, "vat_adjust": -27}], + [SUMS, {}], + [None, ESTIMATE], + [{**SUMS, "supply_net": 0}, {**ESTIMATE, "procurement_fee_pct": None}], + [{**SUMS, "supply_net": 2_000_000_000}, ESTIMATE], + [{**SUMS, "labor": None}, ESTIMATE], + [SUMS, {**ESTIMATE, "rates": {**RATES, "care": None, "profit": 12.5}}], + ] + assert _run_ts(cases) == [compute_total(s, e) for s, e in cases] + + +def test_화면_탭_요율_손입력_칸(): + """총괄 탭 — 요율 · 손입력 칸만 고침 · 빈칸 = 지움 · 원가계산서 줄 누름 = 총괄 그 줄.""" + files = [ + ROOT / "B09_Estimation" / name + for name in ( + "B09_Estimation_Calc_Base.ts", + "B09_Estimation_Calc_Total.ts", + "B09_Estimation_UI_Tabs.ts", + "B09_Estimation_UI_Tabs_Total.ts", + ) + ] + body = f""" +let user = {{ estimate: {json.dumps(ESTIMATE)} }}; +const setUser = (u) => (user = u); +const sums = {json.dumps(SUMS)}; +const ctx = () => ({{ sheets: {{ total: computeTotal(sums, user.estimate) }}, payload: {{}}, user, setUser }}); +let t = buildTotal(ctx()); +const at = (rows, key) => rows.findIndex((r) => r.key === key); +const out = {{}}; +out.editable = [ + t.editable(at(t.rows, "care"), "rate"), t.editable(at(t.rows, "care"), "hand"), + t.editable(at(t.rows, "safety"), "hand"), t.editable(at(t.rows, "retirement"), "rate"), + t.editable(at(t.rows, "net_cost"), "rate"), +]; +t.onEdit(at(t.rows, "care"), "rate", "13.14"); +out.care = user.estimate.rates.care; +t = buildTotal(ctx()); +t.onEdit(at(t.rows, "care"), "rate", ""); +out.careGone = "care" in user.estimate.rates; +t = buildTotal(ctx()); +out.grandBlank = t.rows[at(t.rows, "grand")].amount; +t.onEdit(at(t.rows, "safety"), "hand", "1,000"); +out.base = user.estimate.safety_base; +const c = buildCost(ctx()); +out.link = c.link(at(c.rows, "safety")); +c.onEdit(at(c.rows, "scrap"), "amount", "300"); +out.scrap = user.estimate.scrap; +console.log(JSON.stringify(out)); +""" + script = "".join(IMPORTS.sub("", p.read_text(encoding="utf-8")) for p in files) + body + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", script], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + out = json.loads(result.stdout) + assert out["editable"] == [True, False, True, False, False] + assert out["care"] == 13.14 and out["careGone"] is False + assert out["grandBlank"] is None # 요율 지우면 그 줄 · 소계 빈칸 + assert out["base"] == 1000 and out["scrap"] == 300 + assert out["link"] == {"tab": "total", "key": "safety"} diff --git a/resources/tester/test_b01_settings_cards.py b/resources/tester/test_b01_settings_cards.py index 82c61a547..004a0013d 100644 --- a/resources/tester/test_b01_settings_cards.py +++ b/resources/tester/test_b01_settings_cards.py @@ -217,7 +217,8 @@ def test_회사_찾기_거르기(): @pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") def test_구조물_조합_기본_층_개인(): - """123-1 — 회사 있는 사람은 역할 무관 구조물 조합 = 개인 · 회사 없으면 지금 규칙 · 다른 곳 그대로.""" + """123-1 — 회사 있는 사람은 역할 무관 구조물 조합 = 개인 · 회사 없으면 지금 규칙 · 다른 곳 그대로 · + 134-4 — 층 없는 주소도 개인(회사 없으면 마스터) · 사전조합 기본 층도 같은 규칙.""" m = "B01_Dashboard_PermMasterWrite" combo = f"{M02}?open=구조물조합&root=personal" assert _cards({"role": "SYSTEM_ADMIN", "is_master": False, "company_id": 1}, 3) == [ @@ -238,7 +239,12 @@ def test_구조물_조합_기본_층_개인(): assert _cards({"role": "ADMIN", "is_master": False, "company_id": None})[0][2] == ( f"{M02}?open=구조물조합&root=company" ) - # 다른 곳 기본 그대로 — 고르기 탭(일위대가 · 복사 생성 · 복사해 가져오기) · 층 없는 주소 + # 다른 곳 기본 그대로 — 고르기 탭(일위대가 · 복사 생성 · 복사해 가져오기) layer = _text(ROOT / "M02_MasterTemplete" / "M02_MasterTemplete_Layer.ts") assert 'return { tabs, first: isSystem || !own ? "master" : "personal" };' in layer - assert 'raw === "company" || raw === "personal" ? raw : "master";' in layer + # 134-4 — 층 없는 주소 = 기본 층(개인 · 회사 없으면 마스터) · 사전조합 층도 같은 기본 + assert 'ownOwner("personal", user) ? "personal" : "master";' in layer + assert ': user === undefined\n ? "master"\n : defaultRoot(user);' in layer + page = _text(ROOT / "M02_MasterTemplete" / "M02_MasterTemplete_UI_Page.ts") + assert "const entry = entryFromHash(user);" in page + assert "let pre = preScope(entry.pre ?? defaultRoot(user));" in page diff --git a/resources/tester/test_b07_cross_sheet_pad.py b/resources/tester/test_b07_cross_sheet_pad.py index dcf0cb1f4..2a7fd9754 100644 --- a/resources/tester/test_b07_cross_sheet_pad.py +++ b/resources/tester/test_b07_cross_sheet_pad.py @@ -72,8 +72,7 @@ _TABLE = {"ground": 100.0, "planned": 100.0} def test_each_block_hugs_its_own_drawing_by_one_mm() -> None: """크기 다른 단면 셋을 한 장에 — 박스마다 자기 그림(표 포함)에서 사방 1.0 mm.""" sources = [_source(0.0, 0.05), _source(20.0, 0.6, 15), _source(40.0, 0.2, 6)] - blocks = [(s["chainage_m"], *section_block_size(s, None, design=None)) for s in sources] - sheets = plan_cross_sheets(blocks) + sheets = plan_cross_sheets([s["chainage_m"] for s in sources]) assert len(sheets) == 1 sections = [ {"chainage": s["chainage_m"], "source": s, "quantity_table": _TABLE, "title": ""} @@ -100,13 +99,10 @@ def test_each_block_hugs_its_own_drawing_by_one_mm() -> None: assert top - max(b[3] for b in mine) == pytest.approx(1.0) -def test_small_blocks_are_not_widened_by_a_big_one() -> None: - """큰 단면 하나 + 작은 단면 여럿 — 칸을 큰 블록으로 통일하던 때보다 한 장에 더 담긴다.""" - blocks = [(0.0, 300.0, 300.0)] + [(float(i), 110.0, 60.0) for i in range(1, 30)] - sheets = plan_cross_sheets(blocks) - # 옛 배치(칸 300×300 → A1 739×499 에 2×1)는 첫 장에 2개였다. - assert len(sheets[0]["chainages"]) > 2 - assert sum(len(s["chainages"]) for s in sheets) == 30 +def test_sheets_take_grid_count_not_block_size() -> None: + """140-3 장 나눔 = 칸 수(기본 4 × 4) — 단면 크기와 무관 · 측점 30개면 16 + 14.""" + sheets = plan_cross_sheets([float(i) for i in range(30)]) + assert [len(s["chainages"]) for s in sheets] == [16, 14] def test_window_stops_where_lines_end() -> None: diff --git a/resources/tester/test_b07_plan_kinds.py b/resources/tester/test_b07_plan_kinds.py index ca84bd61c..7f2827860 100644 --- a/resources/tester/test_b07_plan_kinds.py +++ b/resources/tester/test_b07_plan_kinds.py @@ -10,6 +10,7 @@ ⑦ 용지도 종류(93장) — 기본 종류 · 옛 종류 폴더에 한 번 더함(지우면 안 살아남) · 용지 조각 층 ⑧ 배수유역도 종류(109장) — 양식 watershed · 한 장 목록 · 지우기 · 다시 만들기 · IP 표 두 줄 차례 · 용지도 상자 안 선 0 +⑨ 종류 차례(140-2) — 끌어 놓은 차례 그대로 · 다시 읽어 같음 · 앞선 종류가 바뀐 종류만 확정 풂 · 목록 어긋나면 거절 """ import json @@ -43,6 +44,7 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import ( list_kinds, plan_kind_items, read_marks, + reorder_kinds, slope_records, update_kind, write_marks, @@ -442,3 +444,27 @@ def test_ip_table_two_lines_left_to_right_top_to_bottom() -> None: assert origins[3][0] == origins[0][0] # 다음 줄은 다시 왼쪽 assert tops[3] == 5.0 + 8 * 5.0 # 마지막 줄이 도곽 왼쪽 아래 기준(옛 한 줄 자리와 같음) assert tops[0] - tops[3] == 8 * 5.0 + 3.0 # 줄 간격 그대로 + + +def test_reorder_kinds_order_and_release_renumbered(tmp_path: Path) -> None: + root = tmp_path / "project" + manifest = _read_manifest(root) + manifest["drawings"] = { + "plan_terrain": {"confirmed": True}, + "plan_route": {"confirmed": True}, + "plan_layout": {"confirmed": True}, + } + _write_manifest(root, manifest) + before = [k["id"] for k in list_kinds(root)] + assert before[:3] == ["terrain", "route", "layout"] + after = ["route", "terrain", *before[2:]] # 앞 둘만 바꿈 + reorder_kinds(root, after) + assert [k["id"] for k in list_kinds(root)] == after # 다시 읽어 같음 + drawings = _read_manifest(root)["drawings"] + assert drawings["plan_terrain"]["confirmed"] is False # 번호 바뀜 + assert drawings["plan_route"]["confirmed"] is False + assert drawings["plan_layout"]["confirmed"] is True # 앞선 종류 묶음 그대로 = 번호 그대로 + with pytest.raises(ValueError): + reorder_kinds(root, after[:-1]) # 빠진 종류 + with pytest.raises(ValueError): + reorder_kinds(root, [*after[:-1], "terrain"]) # 겹친 종류 diff --git a/resources/tester/test_m02_card_shell.py b/resources/tester/test_m02_card_shell.py index 3cfce2bf0..fbd950f94 100644 --- a/resources/tester/test_m02_card_shell.py +++ b/resources/tester/test_m02_card_shell.py @@ -81,7 +81,7 @@ def test_일위대가_카드는_상세_카드_갈래(): assert "move: reorder," in detail # 85-3 — 카드도 순서(▲▼ · 끌기) · 빼기는 그대로 # 서고 모드(50-3 · lib)도 더하기 · 다이어그램 없음 # 51-11 사전조합 = 일위대가 상세와 같은 [로직 추가] · 같은 목록(걸러냄 없음) - assert "opt.card || promote ? null : addLogic" in detail + assert "opt.card ? null : addLogic" in detail assert "only" not in _text("M02_MasterTemplete_Labor_Pick.ts") assert "...(opt.card || lib ? [] : [dgBox])," in detail assert "opt.card ? null : addMat" in detail # 49-6 — 카드엔 [자재 추가] 없음 · 상세는 그대로 @@ -151,16 +151,17 @@ def test_일위대가_줄_고름과_옵션_단추(): def test_순서칸_2행_참조줄_옵션_수정(): - """51-14 — 순서 칸 2행(위 [위로] [아래로] / 아래 [옵션]·[수정] [삭제]) · 단가승격 [옵션] · 일위조합 [수정] = - 서고 원본 모달(저장 = 서고 저장 길 · 자기 층만 고침) · 닫으면 다시 셈.""" + """51-14 — 순서 칸 2행(위 [위로] [아래로] / 아래 [옵션]·[수정] [삭제]) · 일위조합 [수정] = + 서고 원본 모달(저장 = 서고 저장 길 · 자기 층만 고침) · 닫으면 다시 셈 · 옛 단가승격 참조 줄 = 단추 없음(134-2).""" rows = _text("M02_MasterTemplete_Labor_Rows.ts") assert 'className: "m02-labrow__tools"' in rows - assert '"SP-") ? "Labor_Option" : "Labor_Edit"' in rows + assert 'ref && !row.참조.키.startsWith("SP-")' in rows + assert '[small(tc("Labor_Edit"), () => ref(i))]' in rows detail = _text("M02_MasterTemplete_Labor_Detail.ts") assert "readOnly: !edit || ref.층 !== opt.layer," in detail assert "closed: () => alive && recalc()," in detail ref = _text("M02_MasterTemplete_Labor_RefEdit.ts") - assert "saveTemplate(layer, kind, opt.ref.키, null, 판, body)" in ref + assert "saveTemplate(layer, kind, opt.ref.키, null, 판, body, opt.ref.주인)" in ref assert 'mode: "precombo",' in ref and "showConfirmDialog" in ref assert 'Labor_Edit: ["수정"' in _text("M02_MasterTemplete_Labor_Text.ts") diff --git a/resources/tester/test_m02_labor_layer.py b/resources/tester/test_m02_labor_layer.py index dbe9a852e..36c32ad76 100644 --- a/resources/tester/test_m02_labor_layer.py +++ b/resources/tester/test_m02_labor_layer.py @@ -5,7 +5,7 @@ - GC000260 90% = 100% × 0.9(비목 · 계 · 줄 수량 · 금액) · 단가는 그대로. - clean = 0 초과 200 이하 · 소수 한 자리 · 100 · 대상 밖은 안 둠. - 프로젝트 사본 계산(`M02_Item_Prices.calc`)도 같은 율 · 같은 값. - - 서고(단가승격)도 율을 담고 같은 값. + - 옛 단가승격 서고 문서도 율을 따라 같은 값(134-2 · 프로젝트 사본 셈). """ from __future__ import annotations @@ -83,11 +83,8 @@ def test_문서_계산과_프로젝트_사본_같음() -> None: assert _d(line["계"]) == pytest.approx(_d(full["줄"][0]["기준"]["계"]) * Decimal("0.9")) -def test_서고_단가승격도_율() -> None: - doc = refs.clean_doc( - "promote", {"키": "SP-900001", "담은로직": [{"로직": CODE, "켜쌓기율": 90}]} - ) - assert doc["담은로직"][0]["켜쌓기율"] == 90 +def test_옛_단가승격도_율() -> None: + doc = {"키": "SP-900001", "배": 1, "담은로직": [{"로직": CODE, "켜쌓기율": 90}]} whole = m01.loaded()[1] expand = refs.Expand(whole, lambda ref: doc) got = expand({"층": "system", "주인": None, "키": "SP-900001"}) diff --git a/resources/tester/test_m02_labor_promote_flag.py b/resources/tester/test_m02_labor_promote_flag.py new file mode 100644 index 000000000..cbb3ac888 --- /dev/null +++ b/resources/tester/test_m02_labor_promote_flag.py @@ -0,0 +1,90 @@ +"""일위대가 로직 줄 단가승격 표시(PLAN 134-1) — 줄에 `승격: true` · 금액 무관. + +- 서버 `clean` = `true` 만 남김(거짓 · 글 · 참조 줄은 버림) · 저장 왕복에 남음 · 금액 그대로 +- 사전조합 안 로직 줄엔 승격 없음(정리 때 버림) +- 화면 `normalizeLabor` 도 `true` 만 통과(Node 헬퍼) +""" + +import json +import os +import subprocess + +import pytest + +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from resources.tester.test_m02_structure import write_sample_table + +HERE = os.path.dirname(os.path.abspath(__file__)) +NET = "GC000337" +BARE = "GC000994" + + +def _doc(lines): + return {"열": "rv", "담은로직": lines, "자재": []} + + +def test_clean_은_참만_남김(): + got = labor.clean(_doc([{"로직": NET, "승격": True}, {"로직": BARE, "승격": "yes"}]))[ + "담은로직" + ] + assert got[0]["승격"] is True and "승격" not in got[1] + + +def test_승격은_금액을_안_바꿈(): + plain = labor.calc(labor.clean(_doc([{"로직": NET, "수량": {"손": 2}}])), {}) + flag = labor.calc(labor.clean(_doc([{"로직": NET, "수량": {"손": 2}, "승격": True}])), {}) + assert flag["반영합"] == plain["반영합"] and flag["기준합"] == plain["기준합"] + + +def test_사전조합_안_로직_줄엔_승격_없음(monkeypatch): + monkeypatch.setattr(refs, "here", lambda: ("system", None)) + lines = refs._combo_lines({"담은로직": [{"로직": NET, "승격": True}]}, "SC-000009") + assert "승격" not in lines[0] + + +@pytest.fixture() +def system(tmp_path, monkeypatch): + write_sample_table(tmp_path) + monkeypatch.setattr(store, "FOLDER", tmp_path) + store.create_structure("rv") + return tmp_path + + +def test_저장_왕복에_승격_남음(system): + have = store.read("labor", "rv") + sent = { + **have["문서"], + "담은로직": [ + {"로직": NET, "수량": None, "승격": True}, + {"로직": BARE, "수량": None}, + ], + } + store.write("labor", "rv", have["판"], sent) + rows = store.read("labor", "rv")["문서"]["담은로직"] + assert rows[0]["승격"] is True and "승격" not in rows[1] + # 내리면(드롭다운 단가산출) 칸째 사라짐 + have = store.read("labor", "rv") + store.write("labor", "rv", have["판"], {**have["문서"], "담은로직": [{"로직": NET}]}) + assert "승격" not in store.read("labor", "rv")["문서"]["담은로직"][0] + + +def test_화면_정리도_참만_통과(): + doc = { + "담은로직": [ + {"로직": NET, "승격": True}, + {"로직": BARE, "승격": False}, + {"로직": "", "참조": {"층": "system", "주인": None, "키": "SC-000001"}, "승격": True}, + ] + } + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_m02_labor_normalize.cjs")], + input=json.dumps({"doc": doc, "column": "rv"}, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + rows = json.loads(proc.stdout)["담은로직"] + assert [r.get("승격") for r in rows] == [True, None, None] diff --git a/resources/tester/test_m02_labor_ref.py b/resources/tester/test_m02_labor_ref.py index 5dc00d437..78516ffb4 100644 --- a/resources/tester/test_m02_labor_ref.py +++ b/resources/tester/test_m02_labor_ref.py @@ -1,7 +1,9 @@ -"""일위대가 조합 서고(PLAN 50-3 · `M02_Labor_Ref`) — 단가승격 · 사전조합 · 참조 줄. +"""일위대가 사전조합 서고(PLAN 50-3 · `M02_Labor_Ref`) — 사전조합 · 참조 줄 · 옛 단가승격(134-2). 펼침(재귀 · 순환 · 깊이) · 층 규칙(위 층만) · 쓰는 곳 지우기 409 · 층 사이 따라 복사 · 프로젝트 굳힘 · 로직 지우기 검사 · TS 짝 settle 거울. 층은 임시 폴더(마스터 · storage). +옛 단가승격(SP) = 새로 못 만듦 · 남은 파일은 읽기 · 셈 · 굳힘만(`_promote` = 옛 파일을 바로 씀) · +층 문서를 읽을 때 옛 참조 줄 = 로직 줄 + 승격(`unpromote` · 같은 로직이 있으면 그대로). """ import json @@ -44,9 +46,26 @@ def world(tmp_path, monkeypatch): } -def _promote(folder, code=TEN, **extra): - with store.at(folder): - return refs.create("promote", {"담은로직": [{"로직": code, "입력값": {}}], **extra})["이름"] +def _promote(folder, code=TEN, values=None, **extra): + """옛 단가승격 파일(134-2 앞 꼴) — 층마다 다음 번호 · 배 = 로직 물량 알약 배.""" + from M01_MasterData import M01_MasterData_Store_Combo as combo_store + + line = {"로직": code, "입력값": values or {}, **extra} + pill = (combo_store.combo_pills({"담은로직": [line]})["물량"] or [{}])[0] + folder = folder / "promote" + folder.mkdir(parents=True, exist_ok=True) + key = f"SP-{len(list(folder.glob('SP-*.json'))) + 1:06d}" + doc = { + "종류": "단가승격", + "키": key, + "이름": pill.get("이름") or code, + "단위": pill.get("단위"), + "배": pill.get("배") or 1, + "담은로직": [line], + "본뜬": None, + } + (folder / f"{key}.json").write_text(json.dumps(doc, ensure_ascii=False), encoding="utf-8") + return key def _combo(folder, lines, name="조합"): @@ -65,30 +84,78 @@ def _base(code): # ── 자료 틀 ──────────────────────────────────────────────────────────── -def test_승격은_로직_한_줄_단위_배_이름은_로직에서_키는_층마다_다음_번호(world): - assert _promote(world["system"]) == "SP-000001" - assert _promote(world["system"], STONE, 이름="돌") == "SP-000002" - assert _promote(world["personal"]) == "SP-000001" # 층마다 셈 - doc = _read(world["system"], "promote", "SP-000001") - assert (doc["종류"], doc["단위"], doc["배"]) == ("단가승격", "㎡", 10) - assert doc["이름"] and doc["담은로직"] == [{"로직": TEN, "입력값": {}}] - assert _read(world["system"], "promote", "SP-000002")["이름"] == "돌" +def test_단가승격_서고는_없음_층_종류에서_뺌(world): + assert "promote" not in layers.KINDS and layers.LIBRARY_KINDS == ("precombo",) + with store.at(world["system"]), pytest.raises(KeyError): + refs.create("promote", {"담은로직": [{"로직": TEN}]}) -@pytest.mark.parametrize( - "body", - [ - {"담은로직": []}, - {"담은로직": [{"로직": TEN}, {"로직": STONE}]}, - {"담은로직": [{"로직": "GF999999"}]}, - {"담은로직": [{"참조": {**SYS, "키": "SP-000001"}}]}, - ], -) -def test_틀린_승격은_400(world, body): - _promote(world["system"]) - with store.at(world["system"]), pytest.raises(store.StoreError) as e: - refs.create("promote", body) - assert e.value.status == 400 +# ── 옛 단가승격 참조 줄 = 읽을 때 새 꼴(134-2) ─────────────────────────── +def test_옛_참조_줄은_로직_줄_승격으로_보이고_값_같음(world): + sp = _promote(world["system"], TEN) + old = { + "열": "rv", + "담은로직": [ + {"참조": {**SYS, "키": sp}, "별칭": "승", "메모": "m", "수량": {"손": 4}}, + {"로직": STONE, "수량": {"손": 1}}, + ], + "자재": [], + } + got = refs.unpromote("labor", old) + assert got["담은로직"][0] == { + "로직": TEN, + "별칭": "승", + "메모": "m", + "입력값": {}, + "수량": {"손": 4}, + "승격": True, + } + assert old["담은로직"][0]["참조"]["키"] == sp # 받은 문서는 그대로 + held = _promote(world["system"], PLAIN, {"규격": "NX"}, 켜쌓기율=90) # 입력값 · 율도 옮김 + one = refs.unpromote("labor", {"담은로직": [{"참조": {**SYS, "키": held}}]})["담은로직"][0] + assert (one["입력값"], one["켜쌓기율"]) == ({"규격": "NX"}, 90) + with store.at(world["system"]): + before = labor.calc(labor.clean(old), {})["반영합"]["계"] + after = labor.clean(got) + assert after["담은로직"][0]["승격"] is True # [저장] 때 새 꼴로 굳음 + assert labor.calc(after, {})["반영합"]["계"] == pytest.approx(before, rel=1e-9) + + +def test_옛_참조_줄은_같은_로직이_있으면_그대로_사전조합은_승격_없이(world): + sp = _promote(world["system"], STONE) + lines = [{"로직": STONE, "수량": None}, {"참조": {**SYS, "키": sp}, "수량": None}] + doc = {"열": "rv", "담은로직": lines, "자재": []} + assert refs.unpromote("labor", doc) is doc # 겹침 = 옛 참조 줄 그대로(저장 됨) + with store.at(world["system"]): + assert labor.clean(doc)["담은로직"][1]["참조"]["키"] == sp + combo = {"담은로직": [{"참조": {**SYS, "키": sp}, "수량": {"손": 2}, "메모": None}]} + one = refs.unpromote("precombo", combo)["담은로직"][0] + assert one["로직"] == STONE and one["수량"] == {"손": 2} and "승격" not in one + assert refs.unpromote("area", combo) is combo # 다른 종류는 안 봄 + + +def test_층_길_읽기는_옛_참조_줄을_새_꼴로_파일은_그대로(world): + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from common_util.common_util_auth import verify_session + from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layer_router + + sp = _promote(world["system"]) + with store.at(world["personal"]): + line = {"참조": {**SYS, "키": sp}, "수량": {"손": 2}} + store.write("labor", "x1", "", {"열": "x1", "담은로직": [line], "자재": []}) + app = FastAPI() + app.include_router(layer_router.router) + app.dependency_overrides[verify_session] = lambda: { + "user_id": 42, + "company_id": 7, + "role": "USER", + "is_master": False, + } + got = TestClient(app).get("/api/m02/layers/personal/templates/labor/x1").json() + assert got["문서"]["담은로직"][0]["로직"] == TEN and got["문서"]["담은로직"][0]["승격"] is True + assert _read(world["personal"], "labor", "x1")["담은로직"][0]["참조"]["키"] == sp def test_사전조합_틀린_줄_400_순환_422(world): @@ -219,25 +286,25 @@ def test_참조_줄_settle_파이썬과_TS_가_같은_답(world): # ── 지우기 · 복사 · 굳힘 · 로직 지우기 ───────────────────────────────── def test_쓰는_곳이_있으면_지우기_409_없으면_지움(world): - sp = _promote(world["system"]) + sc = _combo(world["system"], [{"로직": TEN}]) with store.at(world["personal"]): - line = {"참조": {**SYS, "키": sp}} + line = {"참조": {**SYS, "키": sc}} store.write("labor", "x1", "", {"열": "x1", "담은로직": [line], "자재": []}) with store.at(world["system"]), pytest.raises(store.StoreError) as e: - store.delete("promote", sp) + store.delete("precombo", sc) assert e.value.status == 409 and "개인 7/42" in e.value.detail (world["personal"] / "labor" / "x1.json").unlink() with store.at(world["system"]): - store.delete("promote", sp) - assert not (world["system"] / "promote" / f"{sp}.json").exists() + store.delete("precombo", sc) + assert not (world["system"] / "precombo" / f"{sc}.json").exists() def test_복사는_못_읽는_참조를_같이_복사_본뜬(world): - sp = _promote(world["personal"]) + inner = _combo(world["personal"], [{"로직": TEN}], "안") sys_sp = _promote(world["system"]) sc = _combo( world["personal"], - [{"참조": {**MINE, "키": sp}}, {"참조": {**SYS, "키": sys_sp}, "수량": {"손": 2}}], + [{"참조": {**MINE, "키": inner}}, {"참조": {**SYS, "키": sys_sp}, "수량": {"손": 2}}], ) with store.at(world["personal"]): doc = store.read("precombo", sc)["문서"] @@ -248,10 +315,15 @@ def test_복사는_못_읽는_참조를_같이_복사_본뜬(world): got = _read(world["company"], "precombo", made["이름"]) assert got["본뜬"] == {**MINE, "키": sc} assert [one["참조"] for one in got["담은로직"]] == [ - {**COMPANY, "키": "SP-000001"}, # 회사가 못 읽는 개인 승격 → 회사 층 새 키 - {**SYS, "키": sys_sp}, # 마스터는 그대로 + {**COMPANY, "키": "SC-000001"}, # 회사가 못 읽는 개인 사전조합 → 회사 층 새 키 + {**SYS, "키": sys_sp}, # 마스터는 그대로(옛 승격도) ] - assert _read(world["company"], "promote", "SP-000001")["본뜬"] == {**MINE, "키": sp} + assert _read(world["company"], "precombo", "SC-000001")["본뜬"] == {**MINE, "키": inner} + # 못 읽는 옛 개인 승격은 따라 복사 안 함(134-2 · 승격 서고 없음) + mine_sp = _promote(world["personal"]) + with store.at(world["company"]), pytest.raises(store.StoreError) as e: + refs.carry({"담은로직": [{"참조": {**MINE, "키": mine_sp}}]}, ("personal", "7/42")) + assert e.value.status == 400 def test_길_새로_복사_권한(world): @@ -271,14 +343,15 @@ def test_길_새로_복사_권한(world): } client = TestClient(app) url = "/api/m02/layers/{}/library/{}" - body = {"문서": {"담은로직": [{"로직": TEN}]}} - got = client.post(url.format("personal", "promote"), json=body).json() - assert got["이름"] == "SP-000001" - assert client.post(url.format("system", "promote"), json=body).status_code == 403 - copy = {"본뜬": {**MINE, "키": "SP-000001"}} - assert client.post(url.format("personal", "promote"), json=copy).json()["이름"] == "SP-000002" - other = {"본뜬": {"층": "personal", "주인": "7/99", "키": "SP-000001"}} - assert client.post(url.format("personal", "promote"), json=other).status_code == 403 + body = {"문서": {"이름": "가", "담은로직": [{"로직": TEN}]}} + got = client.post(url.format("personal", "precombo"), json=body).json() + assert got["이름"] == "SC-000001" + assert client.post(url.format("system", "precombo"), json=body).status_code == 403 + copy = {"본뜬": {**MINE, "키": "SC-000001"}} + assert client.post(url.format("personal", "precombo"), json=copy).json()["이름"] == "SC-000002" + other = {"본뜬": {"층": "personal", "주인": "7/99", "키": "SC-000001"}} + assert client.post(url.format("personal", "precombo"), json=other).status_code == 403 + assert client.post(url.format("personal", "promote"), json=body).status_code == 422 # 134-2 def test_프로젝트는_참조사본으로_굳힘_원본을_지워도_같은_값(world): @@ -305,7 +378,7 @@ def test_프로젝트는_참조사본으로_굳힘_원본을_지워도_같은_ assert line["수량"] == 1 and line["반영"]["계"] == pytest.approx(live, rel=1e-9) -def test_로직_지우기_검사는_단가승격도_보고_참조_줄은_건너뜀(world): +def test_로직_지우기_검사는_승격_줄도_보고_참조_줄은_건너뜀(world): from M01_MasterData import M01_MasterData_Store_Cache as cache sp = _promote(world["system"], STONE) @@ -319,8 +392,14 @@ def test_로직_지우기_검사는_단가승격도_보고_참조_줄은_건너 if code == STONE: raise cache.cm.mf.FormulaError("없음") + assert cache.labor_errors(Gone()) == [] # 옛 승격 서고는 안 봄(134-2) · 참조 줄 건너뜀 + with store.at(world["system"]): + have = store.read("labor", "rv") + line = {"로직": STONE, "승격": True} + store.write("labor", "rv", have["판"], {**have["문서"], "담은로직": [line]}) + number = store.read("labor", "rv")["문서"]["일위대가번"] got = cache.labor_errors(Gone()) - assert got == [f"단가승격 마스터 「{sp}」 이 로직 「{STONE}」 을 씀 — 거기서 먼저 뺌"] + assert got == [f"일위대가 마스터 「{number}」 이 로직 「{STONE}」 을 씀 — 거기서 먼저 뺌"] def test_새_프로젝트_씨앗은_서고를_안_가져감(world, tmp_path): @@ -332,16 +411,11 @@ def test_새_프로젝트_씨앗은_서고를_안_가져감(world, tmp_path): def test_층_목록_서고_줄은_이름_단위_기준금액을_실음(world): sp = _promote(world["system"]) sc = _combo(world["system"], [{"참조": {**SYS, "키": sp}, "수량": {"손": 3}}], "셋") - rows = [ - {"종류": "promote", "이름": sp}, - {"종류": "precombo", "이름": sc}, - {"종류": "labor", "이름": "rv"}, - ] + rows = [{"종류": "precombo", "이름": sc}, {"종류": "labor", "이름": "rv"}] refs.summaries(world["system"], rows) one = float(Decimal(str(_base(TEN)["계"])) / 10) - assert rows[0]["기준금액"] == pytest.approx(one) - assert (rows[1]["제목"], rows[1]["기준금액"]) == ("셋", pytest.approx(one * 3)) - assert "기준금액" not in rows[2] + assert (rows[0]["제목"], rows[0]["기준금액"]) == ("셋", pytest.approx(one * 3)) + assert "기준금액" not in rows[1] def test_사전조합_로직_줄_섞임_합은_손셈_지우기_409_굳힘(world): @@ -370,9 +444,6 @@ def test_사전조합_로직_줄_섞임_합은_손셈_지우기_409_굳힘(world assert one["기준"]["계"] == pytest.approx(float(want)) assert one["반영"]["계"] == pytest.approx(float(want * 5)) assert [x.get("로직") or x["참조"]["키"] for x in one["안"]] == [sp, STONE, TEN] - with store.at(world["system"]), pytest.raises(store.StoreError) as e: - store.delete("promote", sp) - assert e.value.status == 409 and "사전조합 마스터 「섞음」" in e.value.detail class Gone: # STONE 을 지운 마스터 def logic(self, code): @@ -409,15 +480,8 @@ def test_사전조합이_자기_자신을_담으면_422_수량_기본_1(world): assert e.value.status == 422 and "자기 자신" in e.value.detail -def test_서고_구분_상세구분_연결_승격은_원_로직_사전조합은_고른_값_옛_문서는_읽을_때(world): - import M01_MasterData.M01_MasterData_Store as m01 - - row = m01.loaded()[1].logic(TEN) - sp = _promote(world["system"], 구분="틀림", 상세구분="틀림", 연결={"로직": "x"}) - doc = _read(world["system"], "promote", sp) - assert row["구분"] and row["상세구분"] - assert (doc["구분"], doc["상세구분"]) == (row["구분"], row["상세구분"]) # 화면 값 안 받음 - assert doc["연결"] == {"로직": TEN, "출처": row.get("출처") or ""} +def test_서고_구분_상세구분_사전조합은_고른_값_옛_문서는_읽을_때(world): + sp = _promote(world["system"]) sc = _combo(world["system"], [{"참조": {**SYS, "키": sp}}]) assert (_read(world["system"], "precombo", sc)["구분"], "") == ("", "") with store.at(world["system"]): @@ -425,12 +489,10 @@ def test_서고_구분_상세구분_연결_승격은_원_로직_사전조합은_ store.write("precombo", sc, have["판"], {**have["문서"], "구분": " 가 ", "상세구분": "나"}) got = _read(world["system"], "precombo", sc) assert (got["구분"], got["상세구분"]) == ("가", "나") - # 옛 문서(칸 없음) — 승격은 원 로직 것 · 사전조합은 빈 글 - old = {k: v for k, v in doc.items() if k not in ("구분", "상세구분", "연결")} - (world["system"] / "promote" / f"{sp}.json").write_text(json.dumps(old), encoding="utf-8") - assert refs.filled("promote", old)["연결"] == doc["연결"] + # 옛 문서(칸 없음) — 사전조합은 빈 글 · 옛 승격은 손대지 않음 + old = _read(world["system"], "promote", sp) + assert refs.filled("promote", old) is old assert refs.filled("precombo", {"담은로직": []})["구분"] == "" - rows = [{"종류": "promote", "이름": sp}, {"종류": "precombo", "이름": sc}] + rows = [{"종류": "precombo", "이름": sc}] refs.summaries(world["system"], rows) - assert (rows[0]["구분"], rows[0]["상세구분"]) == (row["구분"], row["상세구분"]) - assert (rows[1]["구분"], rows[1]["상세구분"], rows[1]["연결"]) == ("가", "나", None) + assert (rows[0]["구분"], rows[0]["상세구분"], rows[0]["연결"]) == ("가", "나", None) diff --git a/resources/tester/test_m02_labor_tree.py b/resources/tester/test_m02_labor_tree.py index ff2f7bb97..c8b7aa5ce 100644 --- a/resources/tester/test_m02_labor_tree.py +++ b/resources/tester/test_m02_labor_tree.py @@ -57,9 +57,9 @@ def test_좌측_하위_컨테이너와_전체_항목_길_없음(): def test_층_단추는_구조물_조합_한_벌(): page = _text("M02_MasterTemplete_UI_Page.ts") - # 구조물 조합 줄 아래 하나 + 일위대가 조합 서고(50-3) 하나 - assert page.count('layerBar("') == 2 - assert 'layerBar("일위대가조합", null, () => undefined)' in page + # 구조물 조합 줄 아래 하나 · 일위대가 사전조합은 층 따로(134-3 · `preBar`) + assert page.count('layerBar("') == 1 + assert "const preBar = (): HTMLElement =>" in page assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page diff --git a/resources/tester/test_m02_layer_screen.py b/resources/tester/test_m02_layer_screen.py index a4e027391..0b4034b86 100644 --- a/resources/tester/test_m02_layer_screen.py +++ b/resources/tester/test_m02_layer_screen.py @@ -86,7 +86,9 @@ def test_주소_인자를_라우트에서_뗌(): layer = _text(LAYER) # 진입 해시 `?open=&root=&owner=`(PLAN 27-1d) · 옛 `?layer=` 도 root 로 assert 'query.get("root") ?? query.get("layer")' in layer - assert 'raw === "company" || raw === "personal" ? raw : "master"' in layer + assert ( + 'raw === "master" || raw === "company" || raw === "personal"' in layer + ) # 없음 = 기본(134-4) assert '["구조물조합", "옵션", "도면", "일위대가조합"]' in layer # 일위대가 조합 서고 = 50-3 picker = _text(ROOT / "ui_template" / "ui_template_item_picker.ts") assert 'open: "구조물조합", root: "personal"' in picker and "layer=personal" not in picker @@ -201,7 +203,7 @@ def test_층_단추_셋_남의_회사_사람_나무(): assert "...(opt.canEdit || canCopy ? { menu: () => menuFor(target, id) } : {})" in panel assert "const got = opt.canEdit && api.paste ? clipOf(clipKind) : null;" in panel api = _text(M02 / "M02_MasterTemplete_Api_Fetch.ts") - assert "owner=${enc(activeOwner)}" in api and "/owners" in api + assert "owner=${enc(who)}" in api and "/owners" in api # 134-3 — 명시 owner 도 같은 자리 def test_층_길_접두어와_고정_풀림(): @@ -215,7 +217,10 @@ def test_층_길_접두어와_고정_풀림(): assert '"system"' not in _text(M02 / "M02_MasterTemplete_Structure.ts") # 열 조합 읽기(옛 Combo_Api · 39-4 D3 지움) 자리 = 열마다 일위대가 문서 — 층을 받아 부름 labor = _text(M02 / "M02_MasterTemplete_Labor_Api.ts") - assert "const laborAt = (layer: Layer, column: string, tail: string): string =>" in labor + assert ( + "const laborAt = (layer: Layer, column: string, tail: string, who?: string | null): string =>" + in labor + ) side = _text(M02 / "M02_MasterTemplete_UI_Side.ts") assert "const layer = opt.layer;" in side assert 'layer === "system"' in side # 도면 양식은 시스템 층에만 diff --git a/resources/tester/test_m02_precombo_class.py b/resources/tester/test_m02_precombo_class.py new file mode 100644 index 000000000..73eb21215 --- /dev/null +++ b/resources/tester/test_m02_precombo_class.py @@ -0,0 +1,69 @@ +"""일위대가 사전조합 구분 · 상세구분 새 분류(PLAN 134-5) — 구조물군 A~G › 그 군 종류 + 「공통」. + +- 원천 = 마스터 `구조물정의.json`(구조물군 · types) — 군마다 첫 상세구분 「공통」 · 꺼진 종류도 · 「호환(기타)」 뺌 +- 옛 품셈 분류(건설품셈 공통 · 산림품셈) = 읽을 때 빈 값(미분류) · 파일 안 고침 · 새 분류 값 · 지금 값은 그대로 +- 새 문서 = 빈 값(미분류) · 상세 드롭다운 원천 = 구조물 정의(로직 구분 목록 아님) +""" + +import json +import os +import subprocess +from pathlib import Path + +from M02_MasterTemplete import M02_Labor_Ref as refs + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = Path(HERE).parents[1] +M02 = ROOT / "M02_MasterTemplete" + + +def test_분류는_구조물군_종류_공통(): + defn = json.loads((ROOT / "resources/master_template/구조물정의.json").read_text("utf-8")) + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_m02_precombo_scope.cjs")], + input=json.dumps({"rows": [], "def": defn}, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + classes = json.loads(proc.stdout)["classes"] + groups = {k: v for k, v in defn["구조물군"].items() if k in "ABCDEFG"} + assert [c["name"] for c in classes] == list( + groups.values() + ) # A 횡단배수 … G 노면공 · 기타 없음 + c = next(c for c in classes if c["name"] == groups["C"]) + assert c["details"] == [ + "공통", + "옹벽", + "돌쌓기(찰)", + "돌쌓기(메)", + "흙막이", + "큰돌쌓기", + "소단", + ] + b = next(c for c in classes if c["name"] == groups["B"]) + assert "측구(옆도랑)" in b["details"] # 꺼진 종류도 넣음 + assert all( + c["details"][0] == "공통" and len(set(c["details"])) == len(c["details"]) for c in classes + ) + + +def test_옛_품셈_분류는_미분류_새_값은_그대로(): + old = {"이름": "가", "구분": "건설품셈 공통", "상세구분": "07장 돌공사", "담은로직": []} + got = refs.filled("precombo", old) + assert (got["구분"], got["상세구분"]) == ("", "") and old["구분"] == "건설품셈 공통" + assert refs.filled("precombo", {**old, "구분": "산림품셈", "상세구분": "03장"})["구분"] == "" + now = {**old, "구분": "C 사면안정", "상세구분": "옹벽"} + assert refs.filled("precombo", now) is now + gone = {**old, "구분": "Z 없어진 군", "상세구분": "x"} # 지금 값은 선택지로(화면 몫) + assert refs.filled("precombo", gone) is gone + assert refs.filled("precombo", {"담은로직": []})["구분"] == "" # 칸 없는 옛 문서 + + +def test_화면_원천은_구조물_정의_새_문서는_미분류(): + detail = (M02 / "M02_MasterTemplete_Labor_Detail.ts").read_text("utf-8") + assert "subs = precomboClasses(def);" in detail and "fetchLibLogicSubs" not in detail + assert '[...new Set([now ?? "", ...names])]' in detail # 지금 값도 선택지 그대로 + lib = (M02 / "M02_MasterTemplete_Library_List.ts").read_text("utf-8") + assert '구분: "", 상세구분: ""' in lib and "fetchLibLogicSubs" not in lib diff --git a/resources/tester/test_m02_precombo_scope.py b/resources/tester/test_m02_precombo_scope.py new file mode 100644 index 000000000..13ef7a69b --- /dev/null +++ b/resources/tester/test_m02_precombo_scope.py @@ -0,0 +1,88 @@ +"""일위대가 사전조합 층 따로(PLAN 134-3) — 페이지 층과 따로 · 명시 owner · 2단 폴더 나무. + +- 서버 길: 명시 owner 를 넘기면 그 주인만(null = 자기 층) — 남의 개인을 보는 중에도 사전조합 길에 안 섞임 · + 안 넘기면 지금처럼 페이지 층 주인(Node · fetch 대역) +- 나무: 구분 › 상세구분 2단 · 빈 구분 = 미분류 맨 뒤 · 빈 상세구분 = 구분 바로 아래 +- 사전조합 층 단추 = 해시를 바꿔 페이지를 다시 세우지 않음(replaceState · 그 컨테이너만 다시 그림) · + 구조물 조합 층 단추는 그대로 해시 이동 · 주소 `pre` 에 남음 · 고름에 owner · canWrite +""" + +import json +import os +import subprocess +from pathlib import Path + +HERE = os.path.dirname(os.path.abspath(__file__)) +M02 = Path(HERE).parents[1] / "M02_MasterTemplete" + + +def _text(name: str) -> str: + return (M02 / name).read_text(encoding="utf-8") + + +def _run(rows): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_m02_precombo_scope.cjs")], + input=json.dumps({"rows": rows}, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + return json.loads(proc.stdout) + + +def test_명시_owner_길은_페이지_층_주인과_안_섞임(): + urls = _run([])["urls"] + assert urls[0] == "/api/m02/layers/personal/templates/labor/x1?owner=7%2F99" + assert urls[1] == "/api/m02/layers/personal/templates/precombo/SC-000001" + assert urls[2] == "/api/m02/layers/company/templates?owner=8" + assert urls[3] == "/api/m02/layers/system/templates/precombo/SC-000002" + + +def test_나무는_구분_상세구분_2단_미분류는_맨_뒤(): + rows = [ + {"이름": "SC-000003", "종류": "precombo", "구분": "C 사면안정", "상세구분": "옹벽"}, + {"이름": "SC-000001", "종류": "precombo", "구분": "", "상세구분": "07장"}, + {"이름": "SC-000002", "종류": "precombo", "구분": "A 횡단배수", "상세구분": ""}, + {"이름": "SC-000004", "종류": "precombo", "구분": "C 사면안정", "상세구분": "돌쌓기"}, + {"이름": "SC-000005", "종류": "precombo", "구분": "A 횡단배수", "상세구분": "공통"}, + ] + groups = _run(rows)["groups"] + assert [g["구분"] for g in groups] == ["A 횡단배수", "C 사면안정", "미분류"] + assert groups[0]["상세"] == [["공통", ["SC-000005"]], ["", ["SC-000002"]]] + assert groups[1]["상세"] == [["돌쌓기", ["SC-000004"]], ["옹벽", ["SC-000003"]]] + assert groups[2]["상세"] == [["", ["SC-000001"]]] # 구분 없으면 상세구분도 안 봄 + + +def test_사전조합_층_단추는_페이지를_다시_안_세움(): + page = _text("M02_MasterTemplete_UI_Page.ts") + pre = page.split("const setPre = (want: Root): void => {")[1].split("};")[0] + assert "history.replaceState(" in pre and "side.refreshPrecombo()" in pre + assert "navigateTo" not in pre and "setActiveLayer" not in pre # 페이지 층 · 해시 이동 없음 + assert "entry.pre = pre.root;" in pre # 구조물 조합 층 이동(go) 때도 주소에 남음 + bar = page.split("const preBar = (): HTMLElement =>")[1].split("const side = buildSide(")[0] + assert "go: (next) => setPre(next)," in bar and "main.open(precomboList(pre))" in bar + # 구조물 조합 층 단추는 그대로 해시 이동 한 벌 + assert page.count('layerBar("') == 1 + assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page + assert "precombo: { scope: () => pre, bar: preBar }," in page + + +def test_고름에_owner_권한_서고_길에_명시_owner(): + side = _text("M02_MasterTemplete_UI_Side_Precombo.ts") + assert 'export const PRECOMBO_TITLE = "일위대가 사전조합";' in side + assert "owner: scope.owner," in side and "canWrite: scope.canWrite," in side + assert "listTemplates(at.layer, null, at.owner)" in side + main = _text("M02_MasterTemplete_UI_Main.ts") + assert ( + "const writable = (at: Selection): boolean => at.canWrite ?? canEdit(at.layer, isAdmin);" + in main + ) + assert "readTemplate(next.layer, next.kind, next.name, next.projectId, next.owner)" in main + assert "at.owner,\n );" in main # saveTemplate + assert "owner: next.owner ?? null," in main and "mode, owner: at.owner ?? null" in main + lib = _text("M02_MasterTemplete_Library_List.ts") + assert "getActiveOwner" not in lib and lib.count("opt.owner") >= 6 + detail = _text("M02_MasterTemplete_Labor_Detail.ts") + assert "opt.owner !== undefined" in detail diff --git a/ui_template/cad_host/cad_host.ts b/ui_template/cad_host/cad_host.ts index 2931d1e82..cd0f3d601 100644 --- a/ui_template/cad_host/cad_host.ts +++ b/ui_template/cad_host/cad_host.ts @@ -77,6 +77,8 @@ export interface CadLoadExtra { panelsCollapsed?: boolean; /** 미리보기 — 왼쪽 끌기 = 화면 옮기기 · 두 번 누름 = 전체 맞춤 · 선택 · 명령 안내 없음. */ viewOnly?: boolean; + /** 캔버스만 — 제목줄 · 리본 · 패널 · 상태막대를 안 그린다(B07 평면도 관리 모달). */ + bare?: boolean; /** 좌표 0,0 에 기준점 십자를 그린다(그리기 겹판 · 저장 안 됨) — 안 보내면 없음. */ originMarker?: boolean; }