Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_5

- test_m02_labor_ref 충돌: 134 사전조합 줄 하나 + 142-2 알약 굳힘 시험 · 사본 셈 새 꼴(줄.기준.계)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sDZSjFvoiDjdGpgj3muc3
This commit is contained in:
2026-10-09 15:18:26 +09:00
co-authored by Claude Opus 5.5
74 changed files with 2223 additions and 873 deletions
+4
View File
@@ -102,6 +102,8 @@ export const STATE_REGISTRY = {
scope: "global",
legacy: () => "b06:profile-panel-collapsed",
},
/** B06 횡단 카드 한 줄 카드 수(138-3 · 1~6 · 기본 3). */
"cross-columns": { bucket: "pref", scope: "global" },
"section-panel-height": {
bucket: "pref",
scope: "global",
@@ -109,6 +111,8 @@ export const STATE_REGISTRY = {
},
/* ── ② 설계 초안 ─────────────────────────────────────────────────────── */
/** B06 횡단 [상세] 모달 측점 구조물 글(138-4) — 측점키(0.01 m) → 글. [저장]이 내보냄. */
"structure-notes": { bucket: "draft", scope: "route" },
/** 아직 정본에 안 넣은 구조물 목록. B05 에서 만들고 B06 [저장]이 내보낸다. */
structures: { bucket: "draft", scope: "project", legacy: (p) => `b05:structures:${p}` },
/** 3D 램프로 바꾼 측점 상단측(측구 방향). */
@@ -63,6 +63,35 @@ export function wideningRuns(
return runs;
}
/**
* 확폭 면적(㎡ · 138-2 · 표시만) — 확폭(좌 + 우) 측점 사이 사다리꼴 합 Σ(wᵢ + wᵢ₊₁)/2 × Δ.
* 구간 앞뒤 이웃 측점(확폭 0)까지 이어 붙이고 · 노면공 범위 `[start, end]` 밖은 잘라 냄(자른 끝은 직선 보간).
*/
export function wideningArea(
stations: readonly WideningStation[],
run: WideningRun,
range: { start: number; end: number },
): number {
const sorted = [...stations].sort((a, b) => a.chainage_m - b.chainage_m);
const first = sorted.findIndex((s) => Math.abs(s.chainage_m - run.start_m) <= WIDENING_TOL_M);
const last = sorted.findIndex((s) => Math.abs(s.chainage_m - run.end_m) <= WIDENING_TOL_M);
if (first < 0 || last < 0) return 0;
const points = sorted
.slice(Math.max(first - 1, 0), last + 2)
.map((s) => [s.chainage_m, wideningOf(s)] as const);
let area = 0;
for (let i = 0; i + 1 < points.length; i += 1) {
const [x0, w0] = points[i];
const [x1, w1] = points[i + 1];
const a = Math.max(x0, range.start);
const b = Math.min(x1, range.end);
if (b <= a || x1 <= x0) continue;
const at = (x: number): number => w0 + ((w1 - w0) * (x - x0)) / (x1 - x0);
area += ((at(a) + at(b)) / 2) * (b - a);
}
return area;
}
const round = (value: number): number => Number(value.toFixed(2));
/** 저장 글 → 끈 구간 쌍. 틀린 조각은 버림. */
@@ -10,6 +10,7 @@ import {
isRunOff,
offForRuns,
toggleRun,
wideningArea,
wideningRuns,
type WideningRun,
} from "../A00_Common/design/design_structure_widening";
@@ -37,18 +38,24 @@ export function createWideningBox(hooks: {
let off = "";
/** 지금 범위의 구간들 — 횡단 자료 · 범위가 없으면 null(+ 안내 글). */
const runs = (): { list: WideningRun[] | null; note: string } => {
const runs = (): {
list: WideningRun[] | null;
note: string;
area?: (run: WideningRun) => number;
} => {
const sections = pipeSections();
if (!sections?.length) return { list: null, note: "횡단 자료 없음" };
const { start, end } = hooks.range();
if (start === null || end === null || end <= start)
return { list: null, note: "시작 · 종료 측점을 넣으면 보임" };
const list = wideningRuns(sections, start, end);
return { list, note: list.length ? "" : "범위 안 확폭 없음" };
// 확폭 면적(138-2) — 표시만 · 수량엔 안 넣음
const area = (run: WideningRun) => wideningArea(sections, run, { start, end });
return { list, note: list.length ? "" : "범위 안 확폭 없음", area };
};
const paintBody = (): void => {
const { list, note } = runs();
const { list, note, area } = runs();
if (!list?.length) {
const text = document.createElement("span");
text.className = "b05-structure__widening-note";
@@ -68,6 +75,12 @@ export function createWideningBox(hooks: {
const from = stationLabel(run.start_m, step);
const to = stationLabel(run.end_m, step);
button.textContent = `${from === to ? from : `${from} ~ ${to}`} · 확폭 ${Number(run.widening_m.toFixed(2))} m`;
const size = area?.(run);
const areaText = document.createElement("span");
areaText.className = "b05-structure__widening-area";
areaText.textContent =
size !== undefined && Number.isFinite(size) ? `${size.toFixed(2)} ㎡` : "";
button.append(areaText);
button.title = on ? "확폭 포함 — 누르면 규격 폭만" : "규격 폭만 — 누르면 확폭 포함";
button.addEventListener("click", () => {
off = toggleRun(list, off, run);
@@ -608,3 +608,20 @@
.b05-structure__widening [data-widening-run][aria-pressed="true"]::before {
content: "✓ ";
}
/* 확폭 면적(138-2) — 단추 안 오른쪽 끝 · 끈 구간 회색 */
.b05-structure__widening [data-widening-run] {
display: flex;
align-items: center;
column-gap: 4px;
}
.b05-structure__widening-area {
margin-left: auto;
padding-left: var(--spacing-8, 8px);
white-space: nowrap;
}
.b05-structure__widening [aria-pressed="false"] .b05-structure__widening-area {
color: var(--color-text-secondary);
}
+26
View File
@@ -285,3 +285,29 @@ export async function previewCrossDesigns(
},
);
}
/** B07 횡단 수량표 한 장(138-4 · 서버 `cross_quantity_sheet`) — 값 · 글 · 칸 배치. */
export interface CrossQuantitySheet {
quantity_table: Record<string, number | null>;
/** B07 표에 찍히는 글 그대로(값 없음 = 「-」). */
text: Record<string, string>;
layout: {
header: Array<[string, string]>;
left: Array<[string | null, string | null, string | null]>;
middle: Array<[string | null, string, string]>;
right: Array<[string | null, string | null]>;
};
}
/** 캐시 설계로 B07 횡단 수량표를 받는다 — 표시 전용(정본을 안 씀). */
export async function fetchCrossQuantityTable(
projectId: string,
routeId: number,
chainageM: number,
design: CrossDesign | null | undefined,
): Promise<CrossQuantitySheet> {
return requestJson<CrossQuantitySheet>(
`/projects/${projectId}/sections/${routeId}/cross-quantity-table`,
{ method: "POST", body: JSON.stringify({ chainage_m: chainageM, design: design ?? null }) },
);
}
+4
View File
@@ -464,6 +464,8 @@ export interface CrossDesign {
pavement_thickness_m?: number;
/** B05 법정 경사 분석의 포장 제안 여부(사용자 토글과 무관하게 유지). */
pavement_suggested?: boolean;
/** 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸). */
structure_note?: string;
/** 노면(노견 포함) 양 끝점 — 노면 렌더링 기준. */
road_edges: {
left: { offset_m: number; elevation_m: number };
@@ -605,4 +607,6 @@ export interface CrossSectionPatch {
fill_area_m2?: number;
cut_soil_area_m2?: number;
cut_rock_area_m2?: number;
/** 측점 구조물 글(138-4) — 빈 글 = 지움. */
structure_note?: string;
}
+11 -1
View File
@@ -87,6 +87,8 @@ export const USER_TOUCHED_KEYS = [
"berm",
// 측점별 암 절토 경사(2026-09-07) — 표준을 바꿔도 개별로 고친 측점은 그대로 둔다.
"cut_slope_ratio_user",
// 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸) — 계산 입력이 아님.
"structure_note",
] as const;
/** 다시 계산해도 살려 두는 값 — 위 사용자 값에 **상태를 나르는 둘**을 더한 것. */
@@ -108,6 +110,14 @@ const PRESERVED_KEYS = [
...SPOIL_KEYS,
] as const;
/**
* 포장 = 노면공 · 물넘이 범위 안만(138-1) — 측점에 얹힌 제원(`pavement` · `ford_pavement`)이 곧 범위다.
* 서버 짝 = `B06_Section_Router_Design.paved_at` · 저장값 · 카드 선택은 쓰지 않는다.
*/
export function pavedBySpecs(section: Pick<CrossSection, "pavement" | "ford_pavement">): boolean {
return Boolean(section.pavement?.length) || Boolean(section.ford_pavement);
}
function preserveUserFields(
next: NonNullable<CrossSection["design"]>,
previous: CrossSection["design"],
@@ -293,7 +303,7 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
ditchType:
choice?.ditch_type ??
(typeof design.ditch_type === "string" ? design.ditch_type : "standard"),
paved: choice?.paved ?? Boolean(design.paved),
paved: pavedBySpecs(section),
standard,
berm: bermAt(section.chainage_m),
rockBoundaryOffsetM,
+3 -1
View File
@@ -44,6 +44,8 @@ from B06_Section.B06_Section_Router_Design import (
USER_TOUCHED_KEYS,
ford_drop_at,
ford_surface_drops,
paved_at,
pavement_ranges,
stored_berm,
stored_cut_slope,
)
@@ -640,7 +642,7 @@ async def compute_cross_section_design(
section_mode=request.section_mode,
ditch_side=request.ditch_side,
ditch_type=request.ditch_type,
paved=request.paved,
paved=paved_at(request.chainage_m, pavement_ranges(project_root)),
standard=request.standard_cross_section,
rock_boundary_offset_m=request.rock_boundary_offset_m,
two_stage_slope=request.two_stage_slope,
@@ -0,0 +1,94 @@
"""B06 횡단 [상세] 모달의 **B07 횡단 수량표 값** 창구(138-4).
값은 B07 횡단도가 쓰는 `_quantity_table` 한 벌을 **브라우저 캐시 설계**로 돌린 것이다 —
화면이 TS 로 다시 짜지 않는다(CLAUDE.md 5장 ③ 서버 단독). 칸 배치도 B07 표 정의를 그대로
실어 보내 화면은 그리기만 한다. **표시 전용** — 정본을 쓰지 않는다. 모달을 열 때 부른다.
"""
from __future__ import annotations
import asyncio
import logging
from pathlib import Path
from typing import Any
from uuid import UUID
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 B06_Section.B06_Section_Repository import get_longitudinal_section
from B06_Section.B06_Section_Router_Design import read_cross_design_inputs
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import _format
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import (
_CROSS_HEADER_KEYS,
_CROSS_LEFT_ROWS,
_CROSS_MIDDLE_ROWS,
_CROSS_RIGHT_ROWS,
)
from B07_DesignDetail.B07_DesignDetail_Router_Support import _quantity_table
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B06 Profile Cross"])
class CrossQuantityTableRequest(BaseModel):
chainage_m: float = Field(..., ge=0)
# 브라우저 캐시의 그 측점 설계(조작 반영본) — 없으면 원본 칸만.
design: dict[str, Any] | None = None
def cross_quantity_sheet(source: dict[str, Any], design: dict[str, Any] | None) -> dict[str, Any]:
"""B07 횡단 수량표 한 장 — 값(숫자) · 글(B07 표와 같은 꼴) · 칸 배치."""
table = _quantity_table(source, design)
text = {
key: _format(value) if isinstance(value, (int, float)) else "-"
for key, value in table.items()
}
return {
"quantity_table": table,
"text": text,
"layout": {
"header": [list(row) for row in _CROSS_HEADER_KEYS],
"left": [list(row) for row in _CROSS_LEFT_ROWS],
"middle": [list(row) for row in _CROSS_MIDDLE_ROWS],
"right": [list(row) for row in _CROSS_RIGHT_ROWS],
},
}
@router.post("/{project_id}/sections/{route_id}/cross-quantity-table", response_model=None)
async def cross_quantity_table(
project_id: UUID, route_id: int, request: CrossQuantityTableRequest
) -> JSONResponse:
pool = get_db_pool()
async with pool.acquire() as connection:
longitudinal = await get_longitudinal_section(connection, project_id, route_id)
stored_path = await get_project_storage_relative_path(connection, project_id)
if not longitudinal or not stored_path:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "종횡단 상세 결과가 없습니다."},
)
try:
*_, source = await asyncio.to_thread(
read_cross_design_inputs,
Path(resolve_stored_project_path(stored_path)),
str(longitudinal["longitudinal_file_path"]),
request.chainage_m,
)
sheet = cross_quantity_sheet(source, request.design)
except (FileNotFoundError, ValueError) as error:
return JSONResponse(status_code=404, content={"status": "error", "message": str(error)})
except Exception:
logger.exception(
"횡단 수량표 계산 실패: project_id=%s chainage=%s", project_id, request.chainage_m
)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "횡단 수량표를 만들지 못했습니다."},
)
return JSONResponse(content={"status": "success", **sheet})
+21 -15
View File
@@ -110,12 +110,17 @@ def ford_drop_at(chainage_m: float, drops: list[tuple[float, float]]) -> float:
return 0.0
def paved_at(chainage_m: float, ranges: list[tuple[float, float]], stored: Any = None) -> bool:
"""이 측점을 포장으로 볼 것인가 — 구간 안이면 강제, 밖이면 사용자 저장값(기본 비포장)."""
for start, end in ranges:
if start - 1e-9 <= chainage_m <= end + 1e-9:
return True
return bool(stored) if isinstance(stored, bool) else False
# 범위 끝 허용오차(m) — 브라우저 짝(`common_util_structure_pavement` · `_culvert_sets`)과 같은 값.
_PAVED_EDGE_TOLERANCE_M = 0.02
def paved_at(chainage_m: float, ranges: list[tuple[float, float]]) -> bool:
"""이 측점을 포장으로 볼 것인가 — 노면공 · 물넘이 범위 안만 포장, 밖은 비포장(저장값 무시).
브라우저 짝 = `B06_Section_Cross_Refresh.ts` `pavedBySpecs`(같은 규칙 · 138-1).
"""
tol = _PAVED_EDGE_TOLERANCE_M
return any(start - tol <= chainage_m <= end + tol for start, end in ranges)
# 사용자 조작값 — 다시 계산해도 그대로 승계한다. **이 목록이 서버의 유일한 한 벌이다.**
@@ -143,6 +148,8 @@ USER_TOUCHED_KEYS = (
# 측점별 암 절토 경사 — 표준 횡단면 설정을 바꿔도 개별로 고친 측점은 그대로 둔다
# (2026-09-07 사용자 확정: 「사용자가 기본값을 사용하지 않는 값들은 변경되면 안됨」).
"cut_slope_ratio_user",
# 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸) — 계산 입력이 아님.
"structure_note",
)
# 노선 [확인] 체인이 옛 노선에서 누가거리로 넘기는 **사용자 입력**(PLAN 74-3) — 계획고 · 면적 ·
@@ -152,7 +159,6 @@ CARRY_INPUT_KEYS = (
"ground_type",
"ditch_type",
"ditch_choice",
"paved",
"two_stage_slope",
"rock_boundary_offset_m",
"cut_slope_ratio_user",
@@ -178,23 +184,23 @@ def enforce_pavement_ranges(
project_root: Path,
standard: dict[str, Any] | None = None,
) -> int:
"""포장 구간·물넘이 범위 안 측점을 포장으로 맞춘다 — 저장분이 비포장이어도 그렇다.
"""포장 = 노면공 · 물넘이 범위 안만 — 저장분과 다르면 켬 · 끔 양쪽으로 맞춘다(138-1).
사용자가 구간을 지정하면 그 안 횡단도에 **자동 반영**된다(2026-08-28 사용자 확정).
범위 밖 옛 `paved=true`(지운 노면공 · 옛 카드 단추)는 비포장으로 되돌린다.
포장 여부는 횡단경사·포장층을 바꾸므로 플래그만 갈아 끼우지 않고 다시 계산한다.
사용자 조작값(반폭·구조물 조정)은 그대로 승계한다.
"""
ranges = pavement_ranges(project_root)
if not ranges:
return 0
ford_drops = ford_surface_drops(project_root)
changed = 0
for section in cross_sections:
design = section.get("design")
if not isinstance(design, dict) or design.get("paved"):
if not isinstance(design, dict):
continue
chainage = float(section.get("chainage_m", 0.0))
if not paved_at(chainage, ranges):
wanted = paved_at(chainage, ranges)
if bool(design.get("paved")) == wanted:
continue
try:
recomputed = compute_cross_design(
@@ -204,7 +210,7 @@ def enforce_pavement_ranges(
section_mode=str(design.get("section_mode") or "left_cut"),
ditch_side=design.get("ditch_side"),
ditch_type=str(design.get("ditch_type") or "standard"),
paved=True,
paved=wanted,
standard=standard,
rock_boundary_offset_m=design.get(
"rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M
@@ -449,7 +455,7 @@ def recompute_designs_for_alignment(
section_mode=str(stored.get("section_mode") or modes.get(key, "left_cut")),
ditch_side=stored.get("ditch_side"),
ditch_type=str(stored.get("ditch_type") or "standard"),
paved=paved_at(chainage, paved_ranges, stored.get("paved")),
paved=paved_at(chainage, paved_ranges),
standard=standard,
rock_boundary_offset_m=session_offsets.get(
key,
@@ -527,7 +533,7 @@ def compute_default_designs(
section_mode=default_modes.get(round(chainage_m, 3), "left_cut"),
ditch_type=str(user.get("ditch_type") or "standard"),
# 포장은 사용자 구간 지정만 켠다 — 경사 제안은 경고로만 남는다(2026-08-28).
paved=paved_at(chainage_m, paved_ranges, user.get("paved")),
paved=paved_at(chainage_m, paved_ranges),
standard=standard,
rock_boundary_offset_m=(
STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M if rock_offset is None else rock_offset
+2
View File
@@ -192,6 +192,8 @@ class CrossSectionPatch(BaseModel):
box_length_m: float | None = Field(default=None, ge=0)
# B08 자재 집계표 세월교 관 한 련 길이(131) — 그린 관 길이 1 m 올림(m) · 같은 길.
ford_pipe_length_m: float | None = Field(default=None, ge=0)
# 측점 구조물 글(138-4 · 횡단 [상세] 모달) — 빈 글 = 지움.
structure_note: str | None = Field(default=None, max_length=2000)
class SectionConfirmRequest(BaseModel):
+14 -18
View File
@@ -1,6 +1,6 @@
/* =============================================================================
* B06_Section_UI_Cross_Design.ts
* 측점 표준횡단 설계 지정 컨트롤(지반유형·단면유형·측구위치·측구형식·포장)과
* 측점 표준횡단 설계 지정 컨트롤(지반유형·단면유형·측구위치·측구형식)과
* 설계선·암 경계선·포장층 오버레이.
*
* 카드 헤더 아래에 세그먼트 버튼을 배치하고, 선택이 바뀌면 onChange로 계산을
@@ -27,6 +27,7 @@ export {
appendRockBoundaryOverlay,
} from "./B06_Section_UI_Cross_Design_Surface";
export { appendCrossDesignOverlay } from "./B06_Section_UI_Cross_Design_Line";
import { openCrossDetail, refreshCrossDetail } from "./B06_Section_UI_Cross_Detail";
/** 넘침 정리 한 장 몫 — 쓰기·읽기·쓰기 세 토막으로 갈라 두어 프레임 단위로 묶는다. */
interface ReflowCard {
@@ -439,25 +440,13 @@ export function buildDesignControls(
!rockCut,
L("B06_Design_Disabled_RockCut"),
);
// 포장 토글: 라벨 삭제, 버튼명 활성="포장"/비활성="비포장"(컬러 유지) — E-7.
const paved = toggle("", state.paved, L("B06_Design_Paved_On"), L("B06_Design_Paved_Off"), () => {
state.paved = !state.paved;
emit();
});
// B05 법정 경사 분석이 포장을 제안한 측점은 근거 문구를 배지·툴팁으로 표기한다.
if (design?.pavement_suggested) {
paved.button.title = L("B06_Design_Paved_Suggested");
const badge = document.createElement("span");
badge.className = "b06-design__paved-badge";
badge.textContent = "⚠";
badge.title = L("B06_Design_Paved_Suggested");
paved.wrap.append(badge);
}
// 포장 단추는 없다(138-1) — 포장 = 노면공 · 물넘이 범위만(`pavedBySpecs`) · 「포장 권장」은 알림 줄.
// `state.paved` 는 저장값 그대로 실어 보낸다(재계산이 범위로 다시 정함).
// 오버플로 드롭다운(N-2-2): 컨트롤이 카드 폭에서 한 행을 넘치면 뒤쪽부터 우측 "⋯"
// 드롭다운(세로 배치)으로 옮겨 다음 행으로 밀리는 것을 막는다. 경사 방향은 항상 인라인,
// 이동 우선순위(뒤에서부터): 포장 → 복합경사 → 측구형식 → 측구 토글.
const moveable = [ditchToggle.wrap, ditchTypeSeg, twoStage.wrap, paved.wrap];
// 이동 우선순위(뒤에서부터): 복합경사 → 측구형식 → 측구 토글.
const moveable = [ditchToggle.wrap, ditchTypeSeg, twoStage.wrap];
const more = document.createElement("details");
more.className = "b06-design__more";
const moreSummary = document.createElement("summary");
@@ -467,7 +456,14 @@ export function buildDesignControls(
const morePanel = document.createElement("div");
morePanel.className = "b06-design__more-panel";
more.append(moreSummary, morePanel);
bar.append(slopeDirSeg, ...moveable, more);
// [상세](138-4) — 맨 오른쪽 · 넘침 정리(moveable) 밖이라 늘 보임
const detail = document.createElement("button");
detail.type = "button";
detail.className = "b06-design__btn b06-design__detail";
detail.textContent = L("B06_Design_Detail");
detail.addEventListener("click", () => openCrossDetail(section));
bar.append(slopeDirSeg, ...moveable, more, detail);
refreshCrossDetail(section);
// 넘침 정리는 **세 토막**으로 나눈다 — 쓰기(reset) → 읽기(measure) → 쓰기(apply).
// 카드 안에서 한 장씩 하면 쓰기 뒤 읽기가 카드 수만큼 반복돼 브라우저가 레이아웃을
+260
View File
@@ -0,0 +1,260 @@
/* =============================================================================
* B06_Section_UI_Cross_Detail.ts
* 횡단 카드 측구 줄 맨 오른쪽 [상세] → 모달(138-4).
*
* 맨 위 = 측점 구조물 글(`design.structure_note` · 세션 초안 → [저장]이 patch 로 내보냄) ·
* 가운데 = 카드 그림 복제(어두운 바탕 · CAD 꼴) · 아래 = B07 횡단 수량표(값 · 칸 배치 모두
* 서버 `cross-quantity-table` 한 길 · 캐시 설계로 · 열 때 부름).
* 막은 투명(누름 통과) · 자리는 그 카드를 안 가리는 넓은 쪽(좌/우 → 위/아래).
* 모달이 열린 채 카드를 다시 그리면 그림 · 표를 새로 맞춤.
* ========================================================================== */
import "./B06_Section_UI_Style_Cross_Detail.css";
import { openModal } from "@ui/ui_template_modal";
import { readState, writeState } from "../A00_Common/b_page_state";
import { fetchCrossQuantityTable } from "./B06_Section_Api_Fetch";
import type {
CrossQuantitySheet,
CrossSection,
SectionDetailResponse,
} from "./B06_Section_Api_Fetch";
import { inferStationInterval, stationLabel } from "./B06_Section_UI_Section_Common";
/** 페이지가 한 번 걸어 줌(`_UI_Page_Design_Sync`) — 카드 줄은 프로젝트 · 노선을 모름. */
export interface CrossDetailHost {
projectId: string;
routeId: () => number | null;
detail: () => SectionDetailResponse | null;
}
let host: CrossDetailHost | null = null;
export function setCrossDetailHost(next: CrossDetailHost): void {
host = next;
}
/* ── 구조물 글 세션 초안(측점키 0.01 m → 글) ─────────────────────────────── */
const noteKey = (chainageM: number): string => chainageM.toFixed(2);
/** [저장]이 patch 로 싣는 글 — 측점(m) → 글(빈 글 = 지움). */
export function readStructureNotes(projectId: string, routeId: number): Map<number, string> {
const raw = readState<Record<string, string>>("structure-notes", projectId, routeId) ?? {};
return new Map(Object.entries(raw).map(([key, text]) => [Number(key), String(text)]));
}
function noteOf(section: CrossSection): string {
const routeId = host?.routeId();
if (host && routeId !== null && routeId !== undefined) {
const draft = readState<Record<string, string>>("structure-notes", host.projectId, routeId);
const text = draft?.[noteKey(section.chainage_m)];
if (text !== undefined) return text;
}
return section.design?.structure_note ?? "";
}
function writeNote(section: CrossSection, text: string): void {
// 캐시 설계에도 얹음 — B05 [저장](`crossPatchesFromCache`)이 같은 값을 실음
if (section.design) section.design.structure_note = text;
const routeId = host?.routeId();
if (!host || routeId === null || routeId === undefined) return;
const all = readState<Record<string, string>>("structure-notes", host.projectId, routeId) ?? {};
all[noteKey(section.chainage_m)] = text;
writeState("structure-notes", all, host.projectId, routeId);
}
/* ── 자리 ───────────────────────────────────────────────────────────── */
const GAP_PX = 12;
const MIN_WIDTH_PX = 360;
const MAX_WIDTH_PX = 720;
/** 카드를 안 가리는 넓은 쪽에 붙임 — 좌우가 좁으면 위아래. */
function place(dialog: HTMLElement, card: HTMLElement | null): void {
const vw = window.innerWidth;
const vh = window.innerHeight;
const rect = card?.getBoundingClientRect();
const style = dialog.style;
style.position = "fixed";
style.margin = "0";
if (!rect) {
style.width = `${Math.min(MAX_WIDTH_PX, vw - GAP_PX * 2)}px`;
style.left = `${(vw - Math.min(MAX_WIDTH_PX, vw - GAP_PX * 2)) / 2}px`;
style.top = `${GAP_PX}px`;
style.maxHeight = `${vh - GAP_PX * 2}px`;
return;
}
const left = rect.left;
const right = vw - rect.right;
if (Math.max(left, right) >= MIN_WIDTH_PX + GAP_PX * 2) {
const width = Math.min(MAX_WIDTH_PX, Math.max(left, right) - GAP_PX * 2);
const top = Math.min(Math.max(rect.top, GAP_PX), vh - GAP_PX - 240);
style.width = `${width}px`;
style.left = `${right >= left ? rect.right + GAP_PX : rect.left - GAP_PX - width}px`;
style.top = `${Math.max(top, GAP_PX)}px`;
style.maxHeight = `${vh - Math.max(top, GAP_PX) - GAP_PX}px`;
return;
}
const width = Math.min(MAX_WIDTH_PX, vw - GAP_PX * 2);
style.width = `${width}px`;
style.left = `${Math.min(Math.max(rect.left, GAP_PX), vw - GAP_PX - width)}px`;
const below = vh - rect.bottom;
if (below >= rect.top) {
style.top = `${rect.bottom + GAP_PX}px`;
style.maxHeight = `${Math.max(below - GAP_PX * 2, 160)}px`;
} else {
style.top = `${GAP_PX}px`;
style.maxHeight = `${Math.max(rect.top - GAP_PX * 2, 160)}px`;
}
}
/* ── 표(B07 칸 배치 그대로) ─────────────────────────────────────────────── */
function cell(tag: "th" | "td", text: string, span?: { col?: number; row?: number }): HTMLElement {
const element = document.createElement(tag);
element.textContent = text;
if (span?.col) element.setAttribute("colspan", String(span.col));
if (span?.row) element.setAttribute("rowspan", String(span.row));
return element;
}
/** B07 `_cross_table_entities` 와 같은 꼴 — 제목 · 머리 넷 쌍 · 본문 6행 × (좌 · 중 · 우). */
export function buildQuantityTable(sheet: CrossQuantitySheet, title: string): HTMLTableElement {
const { layout, text } = sheet;
const value = (key: string | null): string => (key ? (text[key] ?? "-") : "");
const table = document.createElement("table");
table.className = "b06-detail__qtable";
const titleRow = table.insertRow();
titleRow.append(cell("th", title, { col: 8 }));
const headRow = table.insertRow();
for (const [label, key] of layout.header)
headRow.append(cell("th", label), cell("td", value(key)));
layout.left.forEach(([groupL, subL, keyL], index) => {
const row = table.insertRow();
if (groupL !== null && subL !== null) row.append(cell("th", groupL, { row: 2 }));
else if (groupL !== null) row.append(cell("th", groupL, { col: 2 }));
if (subL !== null) row.append(cell("th", subL));
row.append(cell("td", value(keyL)));
const [groupM, subM, keyM] = layout.middle[index];
if (groupM !== null) row.append(cell("th", groupM, { row: 2 }));
row.append(cell("th", subM), cell("td", value(keyM)));
const [labelR, keyR] = layout.right[index];
row.append(cell("th", labelR ?? ""), cell("td", value(keyR)));
});
return table;
}
/* ── 모달 ───────────────────────────────────────────────────────────── */
interface OpenDetail {
stationId: string;
section: CrossSection;
paint: () => void;
loadTable: () => void;
close: () => void;
}
let current: OpenDetail | null = null;
let tableTimer = 0;
const cardOf = (section: CrossSection): HTMLElement | null =>
document.getElementById(`cross-${section.station_id}`);
function titleOf(section: CrossSection): string {
const stations = host?.detail()?.longitudinal.stations ?? [];
return stationLabel(section.chainage_m, inferStationInterval(stations));
}
/** [상세] — 이미 열린 것은 닫고 이 측점으로 엶. */
export function openCrossDetail(section: CrossSection): void {
current?.close();
const title = titleOf(section);
let tableSeq = 0;
openModal({
title: `${title} 상세`,
closeLabel: "닫기",
dialogClass: "b06-detail",
backdropClass: "b06-detail__backdrop",
mount(body, close) {
const dialog = body.parentElement as HTMLElement;
const note = document.createElement("textarea");
note.className = "ui-input b06-detail__note";
note.rows = 3;
note.placeholder = "구조물 메모 — 이 측점에 남길 글";
note.value = noteOf(section);
note.addEventListener("input", () => writeNote(state.section, note.value));
const cad = document.createElement("div");
cad.className = "b06-detail__cad";
const tableBox = document.createElement("div");
tableBox.className = "b06-detail__table";
body.append(note, cad, tableBox);
const state: OpenDetail = {
stationId: section.station_id,
section,
paint() {
const card = cardOf(state.section);
const svg = card?.querySelector("svg.b06-section__chart");
cad.replaceChildren(svg ? svg.cloneNode(true) : document.createTextNode("그림 없음"));
place(dialog, card);
},
loadTable() {
const projectId = host?.projectId;
const routeId = host?.routeId();
if (!projectId || routeId === null || routeId === undefined) {
tableBox.textContent = "노선 정보 없음";
return;
}
const seq = ++tableSeq;
tableBox.classList.add("is-loading");
fetchCrossQuantityTable(
projectId,
routeId,
state.section.chainage_m,
state.section.design,
)
.then((sheet) => {
if (seq !== tableSeq || current !== state) return;
tableBox.replaceChildren(buildQuantityTable(sheet, title));
})
.catch((error: unknown) => {
if (seq !== tableSeq || current !== state) return;
tableBox.textContent = `수량표를 못 받음 — ${error instanceof Error ? error.message : error}`;
})
.finally(() => seq === tableSeq && tableBox.classList.remove("is-loading"));
},
close() {
forget();
close();
},
};
current = state;
tableBox.textContent = "수량표 불러오는 중…";
state.paint();
state.loadTable();
},
// 닫기 단추 · Esc 도 같은 정리를 거침
beforeClose: () => {
forget();
return true;
},
});
}
/** 열린 모달 기억을 지움(닫힐 때). */
function forget(): void {
window.clearTimeout(tableTimer);
current = null;
}
/**
* 카드를 다시 그릴 때마다 부름(`buildDesignControls`) — 그 측점 모달이 열려 있으면 새 카드로
* 그림을 다시 뜨고 표는 조작이 멈춘 뒤 한 번 다시 받음(최신 응답만).
*/
export function refreshCrossDetail(section: CrossSection): void {
const open = current;
if (!open || open.stationId !== section.station_id) return;
open.section = section;
requestAnimationFrame(() => current === open && open.paint());
window.clearTimeout(tableTimer);
tableTimer = window.setTimeout(() => current === open && open.loadTable(), 400);
}
@@ -4,6 +4,8 @@
* (2026-09-14 브레인 승인 (나)). 판정은 `_Cross_FillSlope_Warn`, 여기는 그리기만.
* 측점을 누르면 그 카드로 간다. 경고만 — 구조물을 세우거나 값을 바꾸지 않는다.
* ⭐ 2026-09-15 — 그 밑에 사면길이 3m 초과 **소단 검토 정보 한 줄**(경고 모양 아님 · 브레인 ③).
* ⭐ 138-1 — 「⚠ 포장 권장 · n측점」 줄(B05 법정 경사 제안 · 포장 범위 밖 측점 · 옛 카드 배지 자리).
* ⭐ 138-3 — 줄 전체가 카드 칸 위에 고정(sticky) · 알림 없어도 줄 유지(오른쪽 끝 열 수 칸은 부르는 쪽이 붙임).
* ========================================================================== */
import type { CrossSection } from "./B06_Section_Api_Fetch";
@@ -15,6 +17,7 @@ import {
type FillSlopeWarning,
} from "./B06_Section_UI_Cross_FillSlope_Warn";
import { L, stationLabel } from "./B06_Section_UI_Section_Common";
import { pavedBySpecs } from "./B06_Section_Cross_Refresh";
export interface FillSlopeNotice {
root: HTMLElement;
@@ -46,7 +49,8 @@ function noticeBlock(className: string): {
const label = L(side === "left" ? "B06_Design_Ditch_Left" : "B06_Design_Ditch_Right");
return `${label} ${open ? "≥" : ""}${lengthM.toFixed(2)}m`;
});
button.textContent = `${stationLabel(section.chainage_m, stationInterval)} ${parts.join(" · ")}`;
button.textContent =
`${stationLabel(section.chainage_m, stationInterval)} ${parts.join(" · ")}`.trim();
button.dataset.stationId = section.station_id;
return button;
}),
@@ -57,9 +61,15 @@ function noticeBlock(className: string): {
export function createFillSlopeNotice(onPick: (stationId: string) => void): FillSlopeNotice {
const root = document.createElement("div");
root.className = "b06-section__notice-bar";
const blocks = document.createElement("div");
blocks.className = "b06-section__notice-blocks";
root.append(blocks);
const warn = noticeBlock("b06-section__notice");
const info = noticeBlock("b06-section__notice b06-section__notice--info");
root.append(warn.root, info.root);
const pave = noticeBlock("b06-section__notice");
pave.root.title = L("B06_Design_Paved_Suggested");
blocks.append(warn.root, pave.root, info.root);
root.addEventListener("click", (event) => {
const id = (event.target as HTMLElement).closest("button")?.dataset.stationId;
if (id) onPick(id);
@@ -73,6 +83,13 @@ export function createFillSlopeNotice(onPick: (stationId: string) => void): Fill
fillSlopeWarnings(sections, fillSlopeLengths),
stationInterval,
);
pave.fill(
`⚠ ${L("B06_Notice_PaveSuggested")}`,
sections
.filter((section) => section.design?.pavement_suggested && !pavedBySpecs(section))
.map((section) => ({ section, sides: [] })),
stationInterval,
);
info.fill(
`ⓘ ${BERM_INFO_TEXT}`,
bermReviewStations(sections, slopeRunLengths),
@@ -63,6 +63,20 @@ export function crossPlotBase(
return { sourceSamples, minOffset, maxOffset, elevationMid, pixelsPerMeter, naturalHeight };
}
/**
* 같은 기하를 다른 카드 폭으로 그렸을 때 자연 높이(바닥 적용 전 · 다시 계산 안 함 · 138-3).
* 그림 몫은 폭(좌우 여백 뺀)에 비례 · 위아래 여백은 그대로.
*/
export function naturalHeightAtWidth(
base: CrossPlotBase,
fromWidthPx: number,
toWidthPx: number,
): number {
const fixed = CROSS_PAD.top + CROSS_PAD.bottom + AREA_OVERLAY_HEADROOM_PX;
const inner = (width: number): number => Math.max(width - CROSS_PAD.left - CROSS_PAD.right, 1e-6);
return (base.naturalHeight - fixed) * (inner(toWidthPx) / inner(fromWidthPx)) + fixed;
}
/** 재 둔 기하에 행 높이만 얹는다 — 다시 계산하지 않는다. */
export function crossPlotFromBase(base: CrossPlotBase, forcedHeightPx?: number): CrossPlotMetrics {
const heightPx = forcedHeightPx ?? Math.max(base.naturalHeight, CROSS_HEIGHT);
@@ -23,6 +23,7 @@ import { L } from "./B06_Section_UI_Page_Common";
import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
import type { CrossDesignChange, SectionViewController } from "./B06_Section_UI_Section_View";
import type { StructureInstance } from "../B05_Profile/B05_Profile_Api_Structures";
import { setCrossDetailHost } from "./B06_Section_UI_Cross_Detail";
export interface DesignSyncContext {
projectId: string | null;
@@ -42,6 +43,8 @@ export interface DesignSyncController {
export function createDesignSync(ctx: DesignSyncContext): DesignSyncController {
const { projectId } = ctx;
// 횡단 [상세] 모달(138-4)이 쓰는 프로젝트 · 노선 · 상세
if (projectId) setCrossDetailHost({ projectId, routeId: ctx.routeId, detail: ctx.detail });
/**
* 측점 설계 버튼 변경 처리: (1) 선택을 즉시 로컬 반영해 해당 카드만 리프레시(버튼 즉시 반응),
@@ -29,6 +29,7 @@ import {
structureAreaRows,
} from "./B06_Section_Structure_Layouts";
import { crossDesignChoices } from "./B06_Section_Cross_Design_Session";
import { readStructureNotes } from "./B06_Section_UI_Cross_Detail";
import type { StandardCrossSection } from "./B06_Section_Api_Fetch";
import type { RockBoundaryControl } from "./B06_Section_UI_Section_View";
import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope";
@@ -386,6 +387,12 @@ export function collectSectionEdits(ctx: SectionPersistContext): {
// 자동으로 되돌아가지 않으므로(켬/끔 둘 뿐) 지금은 손해가 없다(2026-09-09).
if (typeof choice.ditch_choice === "boolean") patch.ditch_choice = choice.ditch_choice;
});
// 측점 구조물 글(138-4 · [상세] 모달) — 세션 초안 그대로(빈 글 = 지움)
const routeId = ctx.routeId();
if (ctx.projectId && routeId !== null)
readStructureNotes(ctx.projectId, routeId).forEach((note, chainage) => {
patchFor(chainage).structure_note = note;
});
if (detail) {
// 구조물 폐회로 면적 — **카드를 그리지 않은 측점까지** 여기서 다 계산한다
// (2026-09-06 사용자 확정: 「일단은 브라우저 계산으로」).
+32 -36
View File
@@ -1,47 +1,43 @@
/* =============================================================================
* B06_Section_UI_Section_Columns.ts
* 횡단 카드 한 줄 카드 수 고르기 — 보기 칸 높이에 첫 줄 + 둘째 줄 절반이 들어가는
* 가장 적은 카드 수. 카드 폭은 최소~최대 폭 안(그림은 축척 그대로 · 폭만 바뀜).
* 횡단 카드 열 수 · 폭 · 높이(138-3) — 열 수 = 사용자 값(알림 줄 오른쪽 끝 · 1~6 · 기본 3).
* 카드 폭 = (칸 폭 − 간격) ÷ 열 수 · 그래프 높이 = 폭 × (3열 기준 높이 ÷ 3열 폭) — 열 수가
* 바뀌어도 그림 비율 그대로(글자 크기는 그대로).
* import 없음 — 시험이 이 파일만 옮겨 돌림(`test_138_cross_columns.py`).
* ========================================================================== */
import {
CROSS_CARD_CHROME,
CROSS_GRID_GAP,
CROSS_GRID_MAX_WIDTH,
CROSS_GRID_MIN_WIDTH,
} from "./B06_Section_UI_Section_Common";
export const CROSS_COLUMNS_MIN = 1;
export const CROSS_COLUMNS_MAX = 6;
export const CROSS_COLUMNS_DEFAULT = 3;
/** 높이 비율의 기준 열 수. */
export const CROSS_COLUMNS_BASE = 3;
export interface CrossColumns {
columns: number;
cardWidth: number;
/** 저장값 → 열 수(정수 · 1~6 · 못 읽으면 기본 3). */
export function clampCrossColumns(value: unknown): number {
const number = typeof value === "string" && value.trim() === "" ? NaN : Number(value);
if (!Number.isFinite(number)) return CROSS_COLUMNS_DEFAULT;
return Math.min(CROSS_COLUMNS_MAX, Math.max(CROSS_COLUMNS_MIN, Math.round(number)));
}
const widthFor = (columns: number, viewWidth: number): number =>
Math.min(CROSS_GRID_MAX_WIDTH, (viewWidth - (columns - 1) * CROSS_GRID_GAP) / columns);
/** 카드 폭(px) = (칸 폭 − 간격 × (열 수 − 1)) ÷ 열 수. */
export function crossCardWidth(viewWidth: number, columns: number, gap: number): number {
return Math.max(0, (viewWidth - gap * (columns - 1)) / columns);
}
/**
* `heightsFor(cardWidth, count)` — 그 폭일 때 앞 `count` 장의 카드 자연 높이.
* `viewHeight` 가 0 이하(숨김 · 아직 안 잰 칸)면 가장 큰 카드 쪽을 고른다.
* 카드마다 강제 높이(px) — `baseHeights[i]` = 그 카드를 3열 폭(`baseWidth`)으로 그렸을 때 높이.
* 지금 폭으로 비례해 늘리고 · 같은 줄(열 수 단위) 최댓값으로 맞춤.
*/
export function pickCrossColumns(
viewWidth: number,
viewHeight: number,
heightsFor: (cardWidth: number, count: number) => number[],
): CrossColumns {
const unit = CROSS_GRID_MIN_WIDTH + CROSS_GRID_GAP;
const most = Math.max(1, Math.floor((viewWidth + CROSS_GRID_GAP) / unit));
const fewest = Math.min(
most,
Math.max(1, Math.ceil((viewWidth + CROSS_GRID_GAP) / (CROSS_GRID_MAX_WIDTH + CROSS_GRID_GAP))),
);
for (let columns = fewest; columns < most; columns += 1) {
const cardWidth = widthFor(columns, viewWidth);
if (viewHeight <= 0) return { columns, cardWidth };
const heights = heightsFor(cardWidth, columns * 2);
const first = Math.max(0, ...heights.slice(0, columns)) + CROSS_CARD_CHROME;
const second = Math.max(0, ...heights.slice(columns)) + CROSS_CARD_CHROME;
const needed = heights.length > columns ? first + CROSS_GRID_GAP + second / 2 : first;
if (needed <= viewHeight) return { columns, cardWidth };
}
return { columns: most, cardWidth: widthFor(most, viewWidth) };
export function crossRowHeights(
baseHeights: readonly number[],
baseWidth: number,
cardWidth: number,
columns: number,
): number[] {
const scale = baseWidth > 0 ? cardWidth / baseWidth : 1;
const heights = baseHeights.map((height) => height * scale);
return heights.map((_, index) => {
const start = index - (index % columns);
return Math.max(...heights.slice(start, start + columns));
});
}
@@ -35,11 +35,6 @@ export const LONG_WIDTH = 1200;
export const LONG_HEIGHT = 220;
export const CROSS_WIDTH = 560;
export const CROSS_HEIGHT = 250;
// 카드 폭 범위 — 한 줄 카드 수는 보기 칸 높이로 정한다(`_Section_Columns`).
export const CROSS_GRID_MIN_WIDTH = 400;
export const CROSS_GRID_MAX_WIDTH = 800;
// 카드에서 그림 높이 밖 몫 — 머리 · 설계 줄 · 꼬리(실측 135).
export const CROSS_CARD_CHROME = 135;
export const CROSS_GRID_GAP = 16;
// bottom은 측점 라벨 한 줄 몫이다. X축 제목을 그래프 상단으로 올린 뒤 남던 여백을 걷어내,
// 종단면도 아래 라벨이 그 밑 유토곡선과의 틈 **가운데**에 오도록 좁혔다(2026-08-02 사용자 지시).
+66 -44
View File
@@ -38,12 +38,22 @@ import {
type CrossPlotBase,
crossPlotBase,
effectiveCardHalfWidth,
naturalHeightAtWidth,
} from "./B06_Section_UI_Cross_View_Metrics";
import { CROSS_HEIGHT } from "./B06_Section_UI_Section_Common";
import { CROSS_GRID_GAP, CROSS_HEIGHT } from "./B06_Section_UI_Section_Common";
import type { SectionStructureEdit } from "./B06_Section_UI_Section_View_Menu";
import { culvertLinkFor as culvertLink } from "./B06_Section_UI_Cross_Culvert_Wire";
import type { CulvertLink } from "./B06_Section_UI_Cross_Culvert_Wire";
import { pickCrossColumns } from "./B06_Section_UI_Section_Columns";
import {
CROSS_COLUMNS_BASE,
CROSS_COLUMNS_MAX,
CROSS_COLUMNS_MIN,
clampCrossColumns,
crossCardWidth,
crossRowHeights,
} from "./B06_Section_UI_Section_Columns";
import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state";
import { createInputField } from "@ui/ui_template_elements";
import {
CROSS_WIDTH,
type CrossDesignChange,
@@ -93,9 +103,9 @@ export function createSectionView(
let currentStationInterval: number | undefined;
let structureEdit: SectionStructureEdit | null = null;
let renderWidth = 0;
let renderHeight = 0;
let drawnColumns = 0;
let resizeTimer = 0;
/** 한 줄 카드 수 — 사용자 값(보기 설정 pref · 138-3). */
let columnCount = clampCrossColumns(readStateRaw("cross-columns"));
// 카드 단위 재빌드에 재사용하는 렌더 컨텍스트 (draw에서 갱신)
let cachedStationInterval = 1;
let cachedCardWidth = CROSS_WIDTH;
@@ -113,6 +123,29 @@ export function createSectionView(
const fillSlopeNotice = createFillSlopeNotice((id) =>
selectedStationId === id ? revealCard(id, "smooth") : selectStation(id, true),
);
// 열 수 칸(138-3) — 고정 알림 줄 오른쪽 끝 · 1~6 · 바꾸면 카드 전부 다시 그림
const columnsField = createInputField({
type: "number",
compact: true,
min: CROSS_COLUMNS_MIN,
max: CROSS_COLUMNS_MAX,
value: String(columnCount),
});
columnsField.root.classList.add("b06-section__columns");
columnsField.input.title = L("B06_Cross_Columns");
columnsField.input.addEventListener("change", () => {
const next = clampCrossColumns(columnsField.input.value);
columnsField.input.value = String(next);
if (next === columnCount) return;
columnCount = next;
writeStateRaw("cross-columns", String(next));
draw();
});
fillSlopeNotice.root.append(columnsField.root);
// 카드 scroll-margin-top = 고정 줄 높이 — 고른 카드가 줄 밑에 숨지 않게
new ResizeObserver(() =>
cards.style.setProperty("--b06-notice-h", `${fillSlopeNotice.root.offsetHeight}px`),
).observe(fillSlopeNotice.root);
/** 종단도 한 벌 — 첫 그리기에서 횡단 보기 알림을 건다(`_Section_View_Profile`). */
const profile = createSectionProfile(
@@ -138,14 +171,7 @@ export function createSectionView(
return Number.isFinite(width) ? Math.max(0, width) : 0;
};
const contentHeight = (): number => {
const style = getComputedStyle(cards);
const pad = (value: string): number => Number.parseFloat(value) || 0;
const height = cards.clientHeight - pad(style.paddingTop) - pad(style.paddingBottom);
return Number.isFinite(height) ? Math.max(0, height) : 0;
};
/** 카드 폭에서 앞 `count` 장의 기하 — 한 줄 카드 수 고르기 · 그리기가 함께 쓴다. */
/** 카드 폭에서 앞 `count` 장의 기하. */
const plotBasesFor = (detail: SectionDetailResponse, cardWidth: number, count: number) =>
detail.cross_sections
.slice(0, count)
@@ -158,11 +184,17 @@ export function createSectionView(
designElevationAt(detail.longitudinal.design_profiles, section.chainage_m),
),
);
const naturalHeightsOf = (bases: ReadonlyArray<CrossPlotBase | null | undefined>): number[] =>
bases.map((base) => Math.max(base?.naturalHeight ?? CROSS_HEIGHT, CROSS_HEIGHT));
const pickColumns = (detail: SectionDetailResponse) =>
pickCrossColumns(renderWidth, renderHeight, (cardWidth, count) =>
naturalHeightsOf(plotBasesFor(detail, cardWidth, count)),
/** 3열 폭으로 그렸을 때 높이(바닥 250) — 다른 열 수도 이 비율을 지킴(138-3). */
const baseHeightsOf = (
bases: ReadonlyArray<CrossPlotBase | null | undefined>,
cardWidth: number,
baseWidth: number,
): number[] =>
bases.map((base) =>
Math.max(
base ? naturalHeightAtWidth(base, cardWidth, baseWidth) : CROSS_HEIGHT,
CROSS_HEIGHT,
),
);
/**
@@ -368,14 +400,11 @@ export function createSectionView(
grid.addEventListener("click", (event) => {
if (!(event.target as HTMLElement).closest(".b06-cross-card")) clearSelection();
});
// 열 수 = 보기 칸 높이에 1.5줄이 드는 가장 적은 수 · 카드 폭 400~800(`_Section_Columns`).
const picked = pickColumns(detail);
const columnCount = picked.columns;
drawnColumns = columnCount;
// 열 수 = 사용자 값 · 폭 = (칸 폭 − 간격) ÷ 열 수(`_Section_Columns` · 138-3).
cachedCardWidth = crossCardWidth(renderWidth, columnCount, CROSS_GRID_GAP);
// CSS auto-fill의 자체 열 수와 JS columnCount가 어긋나면 같은 행 높이 그룹핑이 실제
// 렌더 행과 달라져 카드 내부가 뒤죽박죽된다(N-2-6). 열 수 · 폭을 JS가 명시해 일치시킨다.
grid.style.gridTemplateColumns = `repeat(${columnCount}, ${picked.cardWidth}px)`;
cachedCardWidth = picked.cardWidth;
grid.style.gridTemplateColumns = `repeat(${columnCount}, ${cachedCardWidth}px)`;
cachedRowHeight.clear();
if (detail.cross_sections.length) {
// 1차: 각 카드의 자연 높이(250px 바닥 적용) 측정 → 같은 행(columnCount 단위) 최댓값을 행 높이로.
@@ -385,19 +414,18 @@ export function createSectionView(
// 반폭도 카드가 실제로 쓰는 값(`effectiveCardHalfWidth`, 자동 줌아웃 포함)으로 맞춰
// 재 둔다 — 카드·행 높이가 어긋나지 않는다.
const plotBases = plotBasesFor(detail, cachedCardWidth, sections.length);
const naturalHeights = naturalHeightsOf(plotBases);
// 2차: 행별 최댓값으로 강제 높이를 정해 같은 행 카드를 동일 높이로 렌더한다.
for (let start = 0; start < sections.length; start += columnCount) {
const rowHeight = Math.max(...naturalHeights.slice(start, start + columnCount));
for (
let index = start;
index < Math.min(start + columnCount, sections.length);
index += 1
) {
cachedRowHeight.set(sections[index].station_id, rowHeight);
grid.append(buildCrossCard(sections[index], rowHeight, plotBases[index]));
}
}
// 2차: 높이 = 폭 × (3열 높이 ÷ 3열 폭) · 같은 행 최댓값으로 맞춰 동일 높이로 렌더한다.
const baseWidth = crossCardWidth(renderWidth, CROSS_COLUMNS_BASE, CROSS_GRID_GAP);
const rowHeights = crossRowHeights(
baseHeightsOf(plotBases, cachedCardWidth, baseWidth),
baseWidth,
cachedCardWidth,
columnCount,
);
sections.forEach((section, index) => {
cachedRowHeight.set(section.station_id, rowHeights[index]);
grid.append(buildCrossCard(section, rowHeights[index], plotBases[index]));
});
} else {
grid.append(emptyView(L("B06_Profile_View_NoCross")));
}
@@ -441,19 +469,14 @@ export function createSectionView(
else fillSlopeNotice.update(currentDetail.cross_sections, cachedStationInterval);
};
// 높이만 바뀌면(아래 패널 끌기 · 접기) 다시 그리지 않는다 — 열 수는 사용자 값(138-3).
const resizeObserver = new ResizeObserver(() => {
const nextWidth = contentWidth();
const nextHeight = contentHeight();
if (!Number.isFinite(nextWidth) || nextWidth <= 0) return;
const widthMoved = Math.abs(nextWidth - renderWidth) >= 1;
if (!widthMoved && Math.abs(nextHeight - renderHeight) < 1) return;
if (Math.abs(nextWidth - renderWidth) < 1) return;
window.clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(() => {
renderWidth = nextWidth;
renderHeight = nextHeight;
// 높이만 바뀌면(아래 패널 끌기 · 접기) 한 줄 카드 수가 바뀔 때만 다시 그린다.
if (!widthMoved && currentDetail && pickColumns(currentDetail).columns === drawnColumns)
return;
draw();
}, 150);
});
@@ -490,7 +513,6 @@ export function createSectionView(
// 진입 시 측점을 자동으로 고르지 않는다(2026-08-02 사용자 지시) — 0측점이 선택된 채로
// 시작하면 사용자가 고르지도 않은 카드가 강조돼 있고 유토곡선 말풍선도 떠 있다.
renderWidth = contentWidth();
renderHeight = contentHeight();
draw();
if (renderWidth <= 0) requestAnimationFrame(() => resizeObserver.observe(cards));
},
+37 -1
View File
@@ -44,7 +44,8 @@
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background: var(--color-surface-raised);
scroll-margin-top: var(--spacing-8);
/* 고정 알림 줄 높이만큼 띄움(138-3) — 고른 카드가 줄 밑에 숨지 않게 */
scroll-margin-top: calc(var(--b06-notice-h, 0px) + var(--spacing-8));
/* 카드를 **자체 쌓임 맥락**으로 만든다. 이게 없으면 카드 안 오버레이(줌 버튼·면적 표)의
z-index가 최상위 축에서 겨루게 되어 이웃 판 위로 삐져나온다(2026-08-02 사용자 지적). */
isolation: isolate;
@@ -122,6 +123,41 @@
gap: var(--spacing-16);
}
/* 알림 줄(138-3) — 카드 칸 맨 위 고정 · 알림 없어도 줄 유지 · 오른쪽 끝 열 수 칸.
위 여백(카드 칸 padding)만큼 끌어올려 그 틈으로 카드가 비치지 않게 */
.b06-section__notice-bar {
position: sticky;
top: calc(-1 * var(--spacing-16));
z-index: 3;
display: flex;
align-items: flex-start;
gap: var(--spacing-8);
margin-top: calc(-1 * var(--spacing-16));
padding-block: var(--spacing-16) var(--spacing-4);
background: var(--color-bg);
}
.b06-section__notice-blocks {
flex: 1 1 auto;
min-width: 0;
}
.b06-section__notice-blocks > .b06-section__notice:last-of-type {
margin-bottom: 0;
}
.b06-section__columns {
flex: none;
width: 4.5em;
margin-left: auto;
}
/* 펼친 목록이 길면 줄 안에서 스크롤 — 고정 줄이 카드 칸을 다 덮지 않게 */
.b06-section__notice-list {
max-height: 30vh;
overflow: auto;
}
/* 성토사면 5m 초과 경고 줄(2026-09-14) — 요약 한 줄, 펼치면 측점 단추 목록. */
.b06-section__notice {
margin-bottom: var(--spacing-8);
@@ -404,13 +404,6 @@
align-self: center;
}
/* 포장 제안 배지: B05 법정 경사 분석이 포장을 권장한 측점 표시 */
.b06-design__paved-badge {
font-size: 0.72rem;
color: var(--color-warning);
cursor: help;
}
/* 세월교 구체(2026-08-25) — 바닥판은 콘크리트. 측벽은 ㄴ형 집수정 형상을 그대로
쓰므로 `.b06-chart__culvert-basin` 색을 공유한다. */
.b06-chart__ford-slab {
@@ -0,0 +1,89 @@
/* =============================================================================
* B06_Section_UI_Style_Cross_Detail.css
* 횡단 카드 [상세] 단추 · 모달(138-4) — 투명 막(누름 통과) · 글 칸 · CAD 꼴 그림 · B07 수량표.
* ========================================================================== */
/* 측구 줄 맨 오른쪽 — ⋯ 가 보이면 그 옆, 숨으면 혼자 오른쪽 끝 */
.b06-design__detail {
flex: 0 0 auto;
margin-left: auto;
}
.b06-design__more:not([hidden]) ~ .b06-design__detail {
margin-left: 0;
}
/* 막은 투명 · 누름은 아래 화면으로 — 모달 몸만 받음 */
.ui-modal__backdrop.b06-detail__backdrop {
background-color: transparent;
pointer-events: none;
}
.b06-detail__backdrop > .ui-modal.b06-detail {
pointer-events: auto;
overflow: auto;
box-shadow: var(--shadow-lg);
}
.b06-detail__note {
width: 100%;
box-sizing: border-box;
resize: vertical;
font: inherit;
}
/* 가벼운 CAD 꼴 — 어두운 바탕 · 카드 그림 복제에 다크 색 토큰을 덮어씀 */
.b06-detail__cad {
--color-bg: #111111;
--color-surface: #111111;
--color-surface-raised: #1a1a1a;
--color-border: #3a3a3a;
--color-text: #f2f2f2;
--color-text-body: #e6e6e6;
--color-text-secondary: #b8b8b8;
--color-text-muted: #8b8b8b;
--color-primary: #c9a8ff;
--color-warning: #ffd34d;
--color-danger: #ff5f57;
--color-success: #4cd37b;
border-radius: var(--radius-cards);
background: #111111;
color: #f2f2f2;
overflow: hidden;
}
.b06-detail__cad > svg {
display: block;
width: 100%;
height: auto;
}
/* B07 횡단 수량 산출표 — 칸 배치 그대로 */
.b06-detail__table.is-loading {
opacity: 0.6;
}
.b06-detail__qtable {
width: 100%;
border-collapse: collapse;
font-size: var(--text-caption);
font-variant-numeric: tabular-nums;
}
.b06-detail__qtable th,
.b06-detail__qtable td {
padding: 2px 4px;
border: 1px solid var(--color-border);
text-align: center;
white-space: nowrap;
}
.b06-detail__qtable th {
font-weight: 500;
color: var(--color-text-secondary);
}
.b06-detail__qtable tr:first-child > th {
text-align: left;
color: var(--color-text);
}
+4 -4
View File
@@ -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 = []
+39 -100
View File
@@ -13,7 +13,7 @@
`단가.json` = `{파일 이름: {그룹 · 머리 칸 …, 줄|표: [줄]}}` — `master_formula.Master` 가
그대로 읽는 꼴.
계산(`calc`)은 이 묶음과 `입력값.json` · `고른자재.json` · `면적.json`(알약 칸 값) 만 씀 — 마스터
현재값을 읽지 않음. 수량 · 반영 · 합은 일위대가 문서와 같은 함수(`M02_Labor_Doc.settle`).
현재값을 읽지 않음. 셈은 층 일위대가 셈 한 벌(`M02_Labor_Doc.calc` · 142-1 · 빈 입력 = 자동값).
"""
from __future__ import annotations
@@ -265,6 +265,27 @@ def missing_inputs(snapshot: Any, labor: dict[str, Any], values: dict[str, Any])
return out
def with_values(doc: dict[str, Any], values: dict[str, Any], picks: dict[str, Any]) -> dict:
"""사본 문서에 사본 `입력값.json` · `고른자재.json` 을 줄 입력값 · 고름으로 얹음(142-1) —
빈 값은 안 얹음(= 자동값 · M02 층 셈과 같음)."""
lines = []
for one in doc.get("담은로직") or []:
code = one.get("로직") if isinstance(one, dict) else None
if not code:
lines.append(one)
continue
given = {
name: got.get("값")
for name, got in (values.get(code) or {}).items()
if isinstance(got, dict) and got.get("값") not in (None, "")
}
line = {**one, "입력값": given}
if isinstance((picks or {}).get(code), dict):
line["고름"] = picks[code]
lines.append(line)
return {**doc, "담은로직": lines}
def calc(
labor: dict[str, Any],
prices: dict[str, Any],
@@ -272,107 +293,25 @@ def calc(
picks: dict[str, Any],
area: Any = None,
) -> dict[str, Any]:
"""복사본만으로 계산 — 입력이 빠진 로직은 안 돌리고 `멈춤` · 세 비목 · 계는 돈 로직 합
(수량 1 기준).
일위대가 문서(39-1c)면 줄마다 수량 · 반영 · `자재` · `기준합` · `반영합` 을 덧붙임 —
수량 = 손 · 면적 알약 칸(`area`) · 반영 = `M02_Labor_Doc.settle`(문서 계산과 한 벌)."""
snapshot = load_prices(prices)
gaps = missing_inputs(snapshot, labor, values)
blocked = {gap["로직"] for gap in gaps}
"""복사본만으로 계산 — 층 셈(`M02_Labor_Doc.calc`) 한 벌(142-1) · 단가 = 사본 `단가.json` ·
참조 = 굳힌 `참조사본` · 알약 = 가져온 때 것 · 수량 칸 = 사본 `면적.json` · 빈 입력 = 자동값.
답 = 층 셈 꼴(`줄` · `자재` · `기준합` · `반영합`) + `알약` + `빠진입력`(「빈 값 n」 표시)."""
from M02_MasterTemplete import M02_Labor_Doc as labor_doc
sums = dict.fromkeys(mf.COST_ITEMS, Decimal(0))
lines, stuck = [], []
rates = { # 켜쌓기율(85-2) — 문서 계산과 같은 `layered`
one.get("로직"): one.get("켜쌓기율")
for one in (labor.get("줄") or {}).get("담은로직") or []
if isinstance(one, dict)
}
for at, key in enumerate(_logic_keys(labor), start=1):
head: dict[str, Any] = {"차례": at, "로직": key}
if key in blocked:
names = [gap["입력"] for gap in gaps if gap["로직"] == key]
lines.append({**head, "ok": False, "까닭": f"입력 없음 — {', '.join(names)}"})
stuck.append(key)
continue
given = {name: one.get("값") for name, one in (values.get(key) or {}).items()}
try:
with mf.mp.choosing((picks or {}).get(key)):
got = labor_doc.layered(mf.run(snapshot, key, given), rates.get(key))
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as error:
lines.append({**head, "ok": False, "까닭": str(error) or type(error).__name__})
stuck.append(key)
continue
if "결과" in got:
lines.append({**head, "ok": True, "갈래": "결과", "결과": got["결과"]})
continue
for name in mf.COST_ITEMS:
sums[name] += got[name]
lines.append(
{
**head,
"ok": True,
"갈래": "돈",
**{k: got[k] for k in mf.COST_ITEMS},
"계": got["계"],
}
)
for line in _ref_lines(snapshot, labor, len(lines)): # 참조 줄(50-3) — 굳힌 사본으로
if line["ok"]:
for name in mf.COST_ITEMS:
sums[name] += line[name]
else:
stuck.append(line["참조"]["키"])
lines.append(line)
out = {"줄": lines, **sums, "계": sum(sums.values()), "빠진입력": gaps, "멈춤": stuck}
return to_json({**out, **_reflected(snapshot, labor, lines, area)})
def _ref_lines(snapshot: Any, labor: dict[str, Any], start: int) -> list[dict]:
"""참조 줄 — 기준 = 굳힌 사본(`참조사본`)을 복사본 단가로 펼친 1 단위 값
(입력값은 승격 때 고정)."""
from M02_MasterTemplete import M02_Labor_Ref as refs
snapshot = load_prices(prices)
doc = with_values(labor.get("줄") or {}, values or {}, picks or {})
copies = labor.get("참조사본") or {}
expand = refs.Expand(snapshot, lambda ref: copies.get(refs.ref_id(ref)))
out = []
for at, one in enumerate((labor.get("줄") or {}).get("담은로직") or [], start=start + 1):
if not (isinstance(one, dict) and isinstance(one.get("참조"), dict)):
continue
got = expand(one["참조"])
head = {"차례": at, "참조": one["참조"], "이름": got.get("이름"), "분모": got.get("단위")}
if got.get("기준") is None:
out.append({**head, "ok": False, "까닭": got.get("까닭")})
continue
out.append({**head, "ok": True, "갈래": "돈", **got["기준"]})
return out
def _reflected(snapshot: Any, labor: dict[str, Any], lines: list[dict], area: Any) -> dict:
"""줄마다 수량 · 반영 + 자재 + 기준합 · 반영합 — 기준은 위 줄 그대로 · 알약은 가져온 때 것."""
from M02_MasterTemplete import M02_Labor_Doc as labor_doc
doc = labor.get("줄") or {}
pill_of = {code: pill for pill in labor.get("알약") or [] for code in pill.get("로직") or []}
base = []
for line in lines:
money = {k: line[k] for k in (*mf.COST_ITEMS, "계")} if line.get("갈래") == "돈" else None
if "참조" in line: # 알약 키 = ref_id · 배 1(50-3)
head = {"참조": line["참조"], "알약": labor_doc.line_id(line), "배": 1}
base.append({**head, "분모": line.get("분모"), "기준": money})
continue
pill = pill_of.get(line["로직"]) or {}
head = {"로직": line["로직"], "알약": pill.get("키"), "배": pill.get("배") or 1}
base.append({**head, "분모": pill.get("단위"), "기준": money})
mats = []
for one in doc.get("자재") or []:
code = str(one.get("코드") or "")
got = labor_doc.material_price(snapshot, code, one.get("단위")) # 가격 다섯 · 기준칸(43-3)
mats.append({"코드": code, "알약": labor_doc.material_key(code), **got})
settled = labor_doc.settle(
to_json({"줄": base, "자재": mats}), doc, labor_doc.area_values(area)
saved = labor.get("알약")
got = labor_doc.calc(
doc,
labor_doc.area_values(area),
whole=snapshot,
read=lambda ref: copies.get(refs.ref_id(ref)),
pills=[p for p in saved if p.get("로직")] if isinstance(saved, list) else None,
)
for line, done in zip(lines, settled["줄"]):
line.update({k: done[k] for k in ("알약", "분모", "배", "수량", "수량출처", "반영")})
return {"자재": settled["자재"], "기준합": settled["기준합"], "반영합": settled["반영합"]}
return {
**got,
"알약": saved if isinstance(saved, list) else labor_doc.pills(doc),
"빠진입력": missing_inputs(snapshot, labor, values or {}),
}
+20 -10
View File
@@ -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()
@@ -378,16 +380,24 @@ def material_price(master: Any, code: str, unit: str | None) -> dict[str, Any]:
}
def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]:
def calc(
doc: dict,
given: dict[str, Any] | None = None,
*,
whole: Any = None,
read: Any = None,
pills: list[dict] | None = None,
) -> dict[str, Any]:
"""`doc` = 정리한 문서 · `given` = {알약 키: 값}(없으면 지금 층 면적산출 파일에서) —
줄마다 기준(서버 단독 · M01 엔진) → `settle`. 못 도는 줄은 까닭만(통째로 멈추지 않음)."""
whole = m01.loaded()[1]
expand = refs.Expand(whole)
by_logic = {
code: pill
for pill in combo_store.combo_pills({"담은로직": doc.get("담은로직") or []})["물량"]
for code in pill["로직"]
}
줄마다 기준(서버 단독 · M01 엔진) → `settle`. 못 도는 줄은 까닭만(통째로 멈추지 않음).
프로젝트 사본 셈(142-1)도 이 한 벌 — `whole` = 사본 `단가.json`(`M02_Item_Prices.load_prices`) ·
`read` = 참조 → 굳힌 `참조사본` · `pills` = 사본 `알약`(가져온 때 것). 없으면 지금 M01 · 층."""
whole = m01.loaded()[1] if whole is None else whole
expand = refs.Expand(whole) if read is None else refs.Expand(whole, read)
if pills is None:
pills = combo_store.combo_pills({"담은로직": doc.get("담은로직") or []})["물량"]
by_logic = {code: pill for pill in pills for code in pill.get("로직") or []}
lines = []
for one in doc.get("담은로직") or []:
if _is_ref(one):
+50 -50
View File
@@ -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,61 @@ 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 ""},
}
def filled(kind: str, doc: Any) -> Any:
"""옛 서고 문서(51-12 앞) 읽을 때 칸 채움 — 승격 = 원 로직 것 · 사전조합 = 빈 글(화면 몫)."""
"""옛 서고 문서(51-12 앞) 읽을 때 칸 채움 — 사전조합 = 빈 글(화면 몫)."""
if not isinstance(doc, dict) or kind not in PREFIX or "구분" in doc:
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)}
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 +314,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:
+1 -1
View File
@@ -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),
@@ -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 = <T>(path: string, init: RequestInit = {}): Promise<T> =>
call<T>(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<TemplateInfo[]> => {
const got = await call<TemplateInfo[] | { 양식: TemplateInfo[] }>(
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<TemplateDoc> => call(item(layer, kind, name, projectId));
owner?: string | null,
): Promise<TemplateDoc> => 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<TemplateInfo> =>
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<string, unknown> } | { 본뜬: { 층: Layer; 주인: string | null; 키: string } };
export const newLibrary = (
layer: Layer,
kind: "promote" | "precombo",
kind: "precombo",
body: LibraryBody,
owner?: string | null,
): Promise<TemplateInfo> =>
call(owned(layer, `/layers/${layer}/library/${kind}`), {
call(owned(layer, `/layers/${layer}/library/${kind}`, owner), {
method: "POST",
body: JSON.stringify(body),
});
@@ -20,11 +20,7 @@ import type {
SpreadsheetHandle,
Workbook,
} from "../A00_Common/spreadsheet/spreadsheet";
import {
readTemplate,
saveTemplate,
type StructureDoc,
} from "./M02_MasterTemplete_Api_Fetch";
import { readTemplate, saveTemplate, type StructureDoc } from "./M02_MasterTemplete_Api_Fetch";
import {
cardShell,
helpButton,
@@ -293,13 +289,14 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
if (!laborCard) return;
laborEd?.destroy();
laborEd = null;
const layer = opts.source().layer;
const src = opts.source();
const layer = src.layer;
let mod: typeof import("./M02_MasterTemplete_Labor_Detail");
let got: Awaited<ReturnType<typeof readTemplate>>;
let got: { 문서: unknown; 판?: string | null };
try {
[mod, got] = await Promise.all([
import("./M02_MasterTemplete_Labor_Detail"),
readTemplate(layer, "labor", at, null),
src.labor ? src.labor.read(at) : readTemplate(layer, "labor", at, null),
]);
} catch (error) {
if (mine === seq) laborCard.shell.fail(fill(TEXT.noSheet, why(error)), reload);
@@ -310,15 +307,24 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
const d = (got.문서 ?? {}) as { 일위대가번?: string };
laborCard.no.textContent =
d.일위대가번 || numberOf("labor", at) || fill(TEXT.noFieldNumber, PART_FIELD.labor);
const via = src.labor; // 자료 자리 길(142-1 · 고르기 모달) — 셈 · 면적도 그 자리에서
laborEd = mod.mountLaborDetail(laborHost, got.문서, {
layer,
column: at,
readOnly: !opts.editable(),
onChanged: (dirty) => laborCard.saver.mark(dirty && opts.editable()),
readOnly: !laborWritable(),
...(via
? {
calc: (doc, 수량) => via.calc(at, doc, 수량),
readArea: () => src.read("area", at),
}
: {}),
onChanged: (dirty) => laborCard.saver.mark(dirty && laborWritable()),
goArea: null, // 면적 칸 잇기는 수량 모달 안(43-1) — 카드에서 화면을 옮기지 않음
card: true,
});
};
/** 일위대가 카드를 고칠 수 있나 — 쓰는 길(`write`)이 없는 자료 자리(고르기 모달)는 보기만(142-1) */
const laborWritable = (): boolean => opts.editable() && !!opts.source().write;
/** 카드 [저장] — 상세와 같은 차례: 고른 칸 연결(면적 문서) 먼저 → 일위대가(판을 다시 읽어 409 막음) */
async function saveLabor(): Promise<void> {
const at = column;
@@ -349,7 +355,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
const sheetEdit = !!opts.sheetEdit?.can();
for (const c of cards) {
const can =
c.kind === "structure" ? drawEdit : c.kind === "labor" ? opts.editable() : sheetEdit;
c.kind === "structure" ? drawEdit : c.kind === "labor" ? laborWritable() : sheetEdit;
c.saver.button.hidden = !can;
c.edit.hidden = !opts.editable();
c.take.hidden = !opts.editable() || !opts.onImport;
@@ -11,6 +11,8 @@ import {
saveTemplate,
type Layer,
} from "./M02_MasterTemplete_Api_Fetch";
import type { LaborDoc } from "./M02_MasterTemplete_Labor_Api";
import type { LaborCalc } from "./M02_MasterTemplete_Labor_Calc";
/** 집계표 열 하나에 딸린 문서 — 구조물 도면 · 면적산출 */
export type ItemPart = "structure" | "area";
@@ -23,6 +25,16 @@ export interface ItemSource {
read: (part: ItemPart, column: string) => Promise<{ 문서: unknown; 판?: string | null }>;
/** 카드 안 [저장](PLAN 31-4) — 전체 편집기와 같은 저장 길 · 없으면 카드는 보기만 */
write?: (part: ItemPart, column: string, 판: string, 문서: unknown) => Promise<{ 판: string }>;
/** 일위대가 카드 자리(142-1) — 읽기 · 셈(고르기 모달 = 프로젝트 복사본 · 원본 층) ·
* 없으면 카드가 지금 층 길(`readTemplate` · `calcLabor`) · `write` 가 없으면 카드는 보기만 */
labor?: {
read: (column: string) => Promise<{ 문서: unknown; 판?: string | null }>;
calc: (
column: string,
doc: LaborDoc,
수량?: Record<string, number>,
) => Promise<LaborCalc & { 알약: unknown[] }>;
};
}
/** M02 화면의 자료 자리 — 부를 때의 지금 층(`getActiveLayer`) 그대로 */
@@ -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);
@@ -133,6 +133,8 @@ export interface LaborRow {
켜쌓기율?: number | null;
/** 고른 로직 자재(103-1) — {호표 차례: MT키} · 없으면 엔진 기본 */
고름?: Record<string, string>;
/** 단가승격(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<string, number>,
owner?: string | null,
): Promise<LaborCalc & { 알약: unknown[] }> =>
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<LaborRef, "키">,
kind: "promote" | "precombo",
kind: "precombo",
): Promise<LibraryRow[]> => {
const q = ref.주인 && ref.층 !== "system" ? `?owner=${enc(ref.주인)}` : "";
const got = await call<{ 양식: (LibraryRow & { 종류: string })[] }>(
@@ -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 {
@@ -84,8 +84,13 @@ export interface LaborDetailOptions {
/** 구조물 조합 카드 갈래(43-4) — 단가산출 로직 표 + 자재목록만 · 위로 · 아래로 · 로직 추가 ·
* 자재 추가 · 정보(단위 · 본뜬) · 다이어그램 없음 · 옵션 · 삭제 · 줄 · 수량 모달은 상세와 같음 */
card?: boolean;
/** 일위대가 조합 서고(50-3) — 단가승격 · 사전조합 문서(`column` = 키) */
mode?: "promote" | "precombo";
/** 일위대가 사전조합 서고(50-3) — 사전조합 문서(`column` = 키) */
mode?: "precombo";
/** 그 층 주인(134-3 · 사전조합 층 · null = 자기 층) — 없으면 페이지 층 것 */
owner?: string | null;
/** 셈 · 면적 자리 주입(142-1 · 고르기 모달 = 프로젝트 복사본) — 없으면 층 길 */
calc?: (doc: LaborDoc, 수량?: Record<string, number>) => Promise<LaborCalc & { 알약: unknown[] }>;
readArea?: () => Promise<{ 문서: unknown; 판?: string | null }>;
}
export interface LaborDetailHandle {
@@ -105,7 +110,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<string, ItemLogic>();
let calc: LaborCalc | null = null;
/** 마지막으로 받은 calc — 다시 세는 동안 로직 자재 줄을 그대로 둠(줄이 사라졌다 나타나지 않게) */
@@ -195,7 +199,7 @@ export function mountLaborDetail(
logicTable(
{ rows: cur.담은로직, logics, calc, fail, cells: cells(), selected: picked },
// 읽기만이면 순서 · 삭제 없음([옵션] = 보기 모달은 둠)
edit && !promote
edit
? {
open: pick,
option: openRow,
@@ -204,6 +208,8 @@ export function mountLaborDetail(
move: reorder,
remove: removeRow,
alias: setAlias,
// 134-1 — 구조물 일위대가 로직 줄만(사전조합 안 로직 줄엔 없음)
...(lib ? {} : { promote: setPromote }),
}
: { open: pick, option: openRow, ref: openRef },
),
@@ -242,7 +248,10 @@ export function mountLaborDetail(
draw();
try {
// 저장 안 한 칸 연결이 있으면 그 칸 값으로(없으면 서버가 저장된 면적산출을 읽음)
const got = await calcLabor(opt.layer, opt.column, cur, areaDirty ? given() : undefined);
const qty = areaDirty ? given() : undefined;
const got = opt.calc
? await opt.calc(cur, qty)
: await calcLabor(opt.layer, opt.column, cur, qty, 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,25 +559,9 @@ 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) — 로직 구분 목록(logic-lib/subs)에서 고름(구분 바꾸면 상세구분 = 그 첫 줄) */
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 = "구분";
@@ -625,8 +629,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]),
],
@@ -640,7 +644,7 @@ export function mountLaborDetail(
lib // 서고 모드엔 면적산출 없음
? undefined
: Promise.all([
readTemplate(opt.layer, "area", opt.column, null),
opt.readArea ? opt.readArea() : readTemplate(opt.layer, "area", opt.column, null),
import("../A00_Common/spreadsheet/spreadsheet_recalc"),
]).then(
([got, { recalcWorkbook }]) => {
@@ -657,7 +661,7 @@ export function mountLaborDetail(
// 서고 모드 = 문서 종류 그대로 · 열 · 자재 없음
getDoc: () => {
if (!lib) return cur;
const out: Record<string, unknown> = { ...cur, 종류: promote ? "단가승격" : "사전조합" };
const out: Record<string, unknown> = { ...cur, 종류: "사전조합" };
delete out.열;
delete out.자재;
return out as unknown as LaborDoc;
@@ -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 = [];
@@ -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<void> {
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<unknown> =>
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<void> {
onChanged: (d) => (dirty = d && !opt.readOnly),
goArea: null,
mode: "precombo",
owner: opt.ref.주인,
});
const cancel = createButton({
label: tc(opt.readOnly ? "In_Close" : "In_Cancel"),
@@ -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<typeof tc>[0]> = {
/** 옵션 값 줄이 걸치는 칸 수 — 구분 · 이름 · 별칭(113-1 · 나머지 칸은 위 줄에서 rowspan 2) */
const SPAN = 3;
/** 구분(50-4 · 저장 안 함) — 줄 모양(참조 키 머리)에서 읽음 · SP- = 단가승격 · SC- = 일위조합(50-3) */
export const kindOf = (row: Pick<LaborRow, "로직" | "참조">): string => {
/** 구분(50-4) — 로직 줄 = `승격`(134-1 · 단가승격 · 없음 = 단가산출) · 참조 줄 = 키 머리(SC- = 일위조합 ·
* 옛 SP- = 단가승격 글 고정 · 134-2) */
export const kindOf = (row: Pick<LaborRow, "로직" | "참조" | "승격">): 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))]
@@ -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<string, readonly [string, string]>;
export type ComboTextKey = keyof typeof TEXT;
+12 -3
View File
@@ -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,9 +25,12 @@ 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);
@@ -38,7 +41,8 @@ export function entryFromHash(): Entry {
const root: Root = raw === "company" || raw === "personal" ? raw : "master";
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 +51,13 @@ export const entryQuery = (entry: Entry): Record<string, string> => ({
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 {
@@ -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,
@@ -31,7 +30,6 @@ import {
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 +44,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 +133,9 @@ export function mountLibraryList(
const load = async (): Promise<void> => {
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,25 +176,14 @@ 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<void> =>
made((await newLibrary(opt.layer, opt.kind, body)).이름, label);
made((await newLibrary(opt.layer, opt.kind, body, opt.owner)).이름, label);
/** 단가승격 = 단가산출 로직 고르기 → 이름 · 단위 · 구분 · 연결은 로직 것(서버가 채움) ·
* 사전조합 = 이름 + 구분 · 상세구분 처음 값 = 로직 구분 목록 첫 줄(51-12 · 상세에서 바꿈) · 빈 묶음 */
/** 사전조합 = 이름 + 구분 · 상세구분 처음 값 = 로직 구분 목록 첫 줄(51-12 · 상세에서 바꿈) · 빈 묶음 */
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 문서 = {
@@ -209,14 +202,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 +230,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) {
@@ -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
@@ -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)
@@ -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<void> {
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<string, unknown>),
}) // + 저장 전 [보내기 취소] 연결(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;
@@ -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";
@@ -173,6 +175,35 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
kind: "table",
name: STRUCTURE_TABLE,
};
// 일위대가 사전조합 층(134-3) — 페이지 층(해시 root)과 따로 · 마스터 · 볼 회사 · 내 개인(사람 나무 없음) ·
// 못 가는 층(회사 없음) = 마스터 · 고른 층 = 주소 `pre`(페이지를 다시 세우지 않음 · replaceState)
const preScope = (want: Root): PrecomboScope => {
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<Mounte
layerBar("구조물조합", tree.add, () => 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" });
@@ -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<PrecomboOptions, "scope" | "bar">;
}
export interface SideHandle {
@@ -71,6 +75,8 @@ export interface SideHandle {
paint: () => void;
/** 진입 해시 `open`(PLAN 27-1d) — 그 컨테이너를 펼침(옵션은 열기까지) · 옵션 · 도면은 시스템 층에만 */
reveal: (target: "옵션" | "도면" | "일위대가조합") => void;
/** 사전조합 층이 바뀜(134-3) — 그 컨테이너만 다시 그림 */
refreshPrecombo: () => Promise<void>;
}
/** 서버 이름 규칙과 같게 — 화면에서 먼저 막음 */
@@ -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 };
}
@@ -1,68 +1,182 @@
/* =============================================================================
* 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 { tc } from "./M02_MasterTemplete_Labor_Text";
import type { Selection } from "./M02_MasterTemplete_UI_Main";
export type PrecomboKind = Extract<Kind, "promote" | "precombo">;
export type PrecomboKind = Extract<Kind, "precombo">;
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-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<boolean>;
onOpen: (sel: Selection | null) => void;
/** 이 층에서 지금 열린 종류(목록이든 문서든) — 항목 고름 표시 */
openKind: () => Kind | null;
getOpen: () => Selection | null;
}
export interface PrecomboHandle {
body: HTMLElement;
/** 고름 표시만 */
paint: () => void;
/** 층 단추 · 나무를 그 층 목록으로 다시 그림 */
refresh: () => Promise<void>;
}
interface Row {
이름: string;
종류: string;
제목?: string | null;
구분?: string | null;
상세구분?: string | null;
}
/** 줄 → 구분 › 상세구분 2단 나무(빈 구분 = 미분류 · 맨 뒤 · 빈 상세구분 = 구분 바로 아래) */
export function groupRows(
rows: Row[],
unsorted: string,
): { 구분: string; 상세: Map<string, Row[]> }[] {
const out = new Map<string, Map<string, Row[]>>();
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<string, HTMLElement>();
const closed = new Set<string>(); // 접은 폴더 — 다시 그려도 그대로
let rows: Row[] = [];
let seq = 0;
const open = async (row: Row): Promise<void> => {
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<void> => {
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 };
}
@@ -8,8 +8,8 @@
PUT …/items/{폴더}/{종류} {판, 내용} — 판이 다르면 409 · 면적은 서버 계산값
POST …/items/{폴더}/reapply 재적용(입력값 · 고른자재는 같은 키면 살림 · `덮임` 알림)
POST …/items/{폴더}/save-as {to, 경로, 이름} 개인 층에 새 항목으로(단가 묶음은 안 가져감)
POST …/items/{폴더}/calc 복사본만으로 계산(세 비목 · 계 · 빠진 입력)
일위대가 문서면 줄마다 수량 · 반영 + 자재 · 기준합 · 반영합
POST …/items/{폴더}/calc 복사본만으로 계산 — 층 셈 한 벌(142-1 · 줄 · 자재 ·
기준합 · 반영합 + 알약 · 빠진입력 · 빈 입력 = 자동값)
종류 = 도면 · 면적 · 입력값 · 고른자재 · 일위대가(읽기만 · 쓰면 400) ·
`refresh` = 옛 이름(재적용).
⚠ 등록은 `main.py`(로그인만) — 프로젝트 권한(같은 회사)은 여기서 `_project` 로 봄(403 · 404).
+2 -3
View File
@@ -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": ("담은로직",),
}
+2
View File
@@ -60,6 +60,7 @@ from B06_Section.B06_Section_Router_Confirm import (
from B06_Section.B06_Section_Router_HaulPlan import (
router as b06_section_haul_plan_router,
)
from B06_Section.B06_Section_Router_CrossTable import router as b06_section_cross_table_router
from B07_DesignDetail.B07_DesignDetail_Router import router as b07_design_router
from B07_DesignDetail.B07_DesignDetail_Router_Frame import router as b07_frame_router
from B07_DesignDetail.B07_DesignDetail_Router_PlanKinds import router as b07_plan_kinds_router
@@ -645,6 +646,7 @@ app.include_router(b06_section_router, dependencies=protected_with_company)
app.include_router(b06_section_stations_router, dependencies=protected_with_company)
app.include_router(b06_section_confirm_router, dependencies=protected_with_company)
app.include_router(b06_section_haul_plan_router, dependencies=protected_with_company)
app.include_router(b06_section_cross_table_router, dependencies=protected_with_company)
app.include_router(b07_design_router, dependencies=protected_with_company)
app.include_router(b07_frame_router, dependencies=protected_with_company)
app.include_router(b07_plan_kinds_router, dependencies=protected_with_company)
@@ -0,0 +1,25 @@
/* 138-3 횡단 카드 열 수 · 폭 · 높이 — `B06_Section_UI_Section_Columns.ts` 를 그 자리에서 트랜스파일해 Node 로 돌림. */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const file = path.join(__dirname, "..", "..", "B06_Section", "B06_Section_UI_Section_Columns.ts");
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
new Function("exports", "module", js)(box.exports, box);
const c = box.exports;
// 칸 폭 1232 · 간격 16 → 3열 폭 400
const baseWidth = c.crossCardWidth(1232, 3, 16);
const base = [250, 300, 280, 260, 400];
const out = {
clamp: [undefined, "", "abc", 0, 1, 2.6, 6, 9, "4"].map(c.clampCrossColumns),
widths: [1, 2, 3, 6].map((n) => c.crossCardWidth(1232, n, 16)),
baseWidth,
rows3: c.crossRowHeights(base, baseWidth, baseWidth, 3),
rows2: c.crossRowHeights(base, baseWidth, c.crossCardWidth(1232, 2, 16), 2),
rows1: c.crossRowHeights(base, baseWidth, c.crossCardWidth(1232, 1, 16), 1),
};
process.stdout.write(JSON.stringify(out));
+13
View File
@@ -50,5 +50,18 @@ const out = {
toggledOn: w.toggleRun(runs, off, runs[0]),
stale: w.offForRuns(runs, "20-60,0-5,100-130"),
empty: w.offForRuns([], "20-60"),
// 확폭 면적(138-2) — 앞뒤 확폭 0 측점까지 사다리꼴 · 범위로 자름
area: runs.map((r) => w.wideningArea(stations, r, { start: 0, end: 140 })),
areaClipped: w.wideningArea(stations, runs[0], { start: 30, end: 140 }),
areaSingle: w.wideningArea(
[at(0), at(20, 1, 0), at(40)],
{ start_m: 20, end_m: 20, widening_m: 1 },
{ start: 0, end: 40 },
),
areaMissing: w.wideningArea(
stations,
{ start_m: 999, end_m: 999, widening_m: 1 },
{ start: 0, end: 140 },
),
};
process.stdout.write(JSON.stringify(out));
@@ -143,15 +143,18 @@ globalThis.fetch = async (url, init = {}) => {
}
if (part === "calc")
return reply(200, {
줄: [],
계: 12345.6,
줄: [
{ 로직: "GA1", 까닭: "없는 요소" },
{ 로직: "GA2", 까닭: null },
],
자재: [],
빠진입력: [
{ 로직: "GA1", 입력: "지역" },
{ 로직: "GA2", 입력: "지역" },
],
멈춤: ["GA1"],
기준합: { 계: 100 },
반영합: { 계: 300 },
알약: [],
});
if (method === "GET") {
const content = item.parts[part] ?? null;
@@ -246,10 +249,25 @@ async function main() {
out.calc = await items.calc(info.폴더);
out.calcMissing = await items.calc("없는폴더");
// 일위대가 — 복사본 요약(복사본 계산) · 원본 층 요약(층 calc) · 넣을 값 = 담은 로직 입력 전부
out.laborCopy = await project.laborOf(row(), items, info.폴더);
out.laborOrigin = await project.laborOf(row(), null, null);
out.laborNone = await project.laborOf(row({ 열: "없는열" }), null, null);
// 일위대가(142-1) — 모달 카드 셈 = 복사본 셈 답 통째 · 문서 = 복사본 입력값 · 고른자재를 얹음
const raw = await items.calcLabor(info.폴더);
out.laborCalc = { 줄: raw.줄.length, 반영합: raw.반영합, 빠진입력: raw.빠진입력.length };
out.laborCalcMissing = await items.calcLabor("없는폴더").then(
() => "성공",
(error) => error.message,
);
out.withValues = project.withCopyValues(
{
일위대가번: "SU-000007",
담은로직: [
{ 로직: "GA1", 입력값: { 지역: 1 } },
{ 로직: "GA2", 입력값: { 지역: 2 }, 고름: { 0: "MT1" } },
{ 참조: { 층: "system", 주인: null, 키: "SC-000001" } },
],
},
{ GA1: { 지역: { 값: 15, 확인: true }, 소량감률: { 값: "", 확인: false } } },
{ GA1: { 0: "MT9" } },
);
const valueData = await project.laborValueData(["GA1", "GA2"], { GA1: {} });
out.laborValues = {
keys: state.logicKeys,
@@ -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)));
@@ -0,0 +1,52 @@
/* M02 일위대가 사전조합 층(134-3) 헬퍼 — `M02_MasterTemplete_Api_Fetch.ts` 명시 owner 길 ·
* `M02_MasterTemplete_UI_Side_Precombo.ts` `groupRows`(구분 › 상세구분 2단) 를 트랜스파일해 Node 로 돌림 ·
* fetch 대역이 부른 주소를 모음 · stdin = {rows} · stdout = {urls, groups} 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.이름)]),
}));
process.stdout.write(JSON.stringify({ urls, groups }));
})();
@@ -0,0 +1,46 @@
# -*- coding: utf-8 -*-
"""B06 횡단 카드 열 수 · 폭 · 높이(PLAN 138-3) — 열 수 1~6 · 기본 3 · 폭 = (칸 − 간격) ÷ n · 높이 = 폭 × 3열 비율.
규칙은 Node 헬퍼(`helper_138_cross_columns.cjs`)가 `B06_Section_UI_Section_Columns.ts` 를 그대로 돌림.
"""
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
@pytest.fixture(scope="module")
def out():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_138_cross_columns.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_열_수_1_6_기본_3(out):
assert out["clamp"] == [3, 3, 3, 1, 1, 3, 6, 6, 4]
def test_카드_폭은_칸_폭_나누기_열_수(out):
assert out["widths"] == [1232, 608, 400, (1232 - 80) / 6]
assert out["baseWidth"] == 400
def test_3열은_자연_높이_줄_최댓값(out):
assert out["rows3"] == [300, 300, 300, 400, 400]
def test_다른_열_수는_3열_비율_그대로(out):
# 2열 폭 608 = 3열 400 × 1.52 → 높이도 1.52 배 · 줄(2장) 최댓값
assert [round(h, 6) for h in out["rows2"]] == [456, 456, 425.6, 425.6, 608]
# 1열 폭 1232 = 3.08 배
assert [round(h, 6) for h in out["rows1"]] == [770, 924, 862.4, 800.8, 1232]
+145
View File
@@ -0,0 +1,145 @@
# -*- coding: utf-8 -*-
"""B06 횡단 [상세] 모달(PLAN 138-4) — 구조물 글 새 키 저장 왕복 · B07 수량표 값 POST 한 길.
- 글(`structure_note`): 브라우저 세션 초안 → [저장] patch → 서버 스키마 → 정본 병합 →
재계산(포장 · 세월교 · 사토장)에서도 남음.
- 표: `cross-quantity-table` 이 B07 `_quantity_table` 을 캐시 설계로 돌린 값과 칸 배치를 줌.
./venv/Scripts/python.exe -m pytest resources/tester/test_138_cross_detail.py -q
"""
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from fastapi import FastAPI # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from B06_Section import B06_Section_Router_CrossTable as table_mod # noqa: E402
from B06_Section import B06_Section_Router_Design as design_mod # noqa: E402
from B06_Section.B06_Section_Schema import CrossSectionPatch # noqa: E402
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import QUANTITY_VALUE_KEYS # noqa: E402
# ── 글 저장 왕복 ─────────────────────────────────────────────────────────
def test_글은_patch_스키마를_지나_정본_병합_값으로_남는다():
patch = CrossSectionPatch(chainage_m=40.0, structure_note="L형 측구 · 집수정 보강")
dumped = patch.model_dump()
# 확정 · 저장 경로와 같은 걸름(`_apply_section_edits`) — 최상위 None 만 걷음
merged = {k: v for k, v in dumped.items() if k != "chainage_m" and v is not None}
assert merged == {"structure_note": "L형 측구 · 집수정 보강"}
# 빈 글 = 지움 — 걸러지지 않고 실림
empty = CrossSectionPatch(chainage_m=40.0, structure_note="").model_dump()
assert empty["structure_note"] == ""
def test_글은_재계산에서도_남는다(monkeypatch):
monkeypatch.setattr(design_mod, "pavement_ranges", lambda root: [(0.0, 100.0)])
monkeypatch.setattr(design_mod, "ford_surface_drops", lambda root: [])
monkeypatch.setattr(design_mod, "design_elevation_from_longitudinal", lambda lon, ch: 100.0)
monkeypatch.setattr(design_mod, "curve_widening_args", lambda section: {})
monkeypatch.setattr(
design_mod, "compute_cross_design", lambda samples, elev, **kw: {"paved": kw["paved"]}
)
sections = [{"chainage_m": 40.0, "design": {"paved": False, "structure_note": "메모"}}]
assert design_mod.enforce_pavement_ranges({}, sections, Path("."), None) == 1
assert sections[0]["design"]["structure_note"] == "메모"
def test_브라우저가_글을_저장_patch_에_싣는다():
persist = (PROJECT_ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(
encoding="utf-8"
)
assert "readStructureNotes(" in persist and ".structure_note = note" in persist
state = (PROJECT_ROOT / "A00_Common" / "b_page_state.ts").read_text(encoding="utf-8")
assert '"structure-notes": { bucket: "draft", scope: "route" }' in state
# ── 수량표 값 ────────────────────────────────────────────────────────────
SOURCE = {"chainage_m": 40.0, "center_z": 101.5, "samples": []}
DESIGN = {
"design_elevation_m": 100.25,
"cut_soil_area_m2": 1.234,
"cut_rock_area_m2": 0.5,
"fill_area_m2": 2.0,
}
def test_표_값은_B07_수량표_한_벌():
sheet = table_mod.cross_quantity_sheet(SOURCE, DESIGN)
table = sheet["quantity_table"]
assert set(QUANTITY_VALUE_KEYS) <= set(table)
assert table["ground"] == 101.5 and table["planned"] == 100.25
assert round(table["cut"], 6) == 1.25 and table["fill"] == 0.0
assert table["cut_soil"] == 1.234 and table["embankment"] == 2.0
# 글은 B07 표에 찍히는 꼴 그대로 · 값 없음 = 「-」
assert sheet["text"]["cut_soil"] == "1.23"
assert sheet["text"]["fence"] == "-"
layout = sheet["layout"]
assert [row[1] for row in layout["header"]] == ["ground", "planned", "fill", "cut"]
assert len(layout["left"]) == len(layout["middle"]) == len(layout["right"]) == 6
def test_설계_없으면_원본_칸만():
sheet = table_mod.cross_quantity_sheet(SOURCE, None)
assert sheet["quantity_table"]["ground"] == 101.5
assert sheet["quantity_table"]["planned"] is None
assert sheet["text"]["cut_soil"] == "-"
class _Pool:
def acquire(self):
pool = self
class _Ctx:
async def __aenter__(self):
return pool
async def __aexit__(self, *exc):
return False
return _Ctx()
def test_POST_한_길이_캐시_설계로_표를_낸다(monkeypatch):
seen = {}
async def longitudinal(connection, project_id, route_id):
return {"longitudinal_file_path": "B05_Profile/route/longitudinal.json"}
async def storage(connection, project_id):
return "proj"
def read_inputs(root, long_path, chainage):
seen["chainage"] = chainage
return [], 100.0, False, SOURCE
monkeypatch.setattr(table_mod, "get_db_pool", lambda: _Pool())
monkeypatch.setattr(table_mod, "get_longitudinal_section", longitudinal)
monkeypatch.setattr(table_mod, "get_project_storage_relative_path", storage)
monkeypatch.setattr(table_mod, "resolve_stored_project_path", lambda rel: "C:/x")
monkeypatch.setattr(table_mod, "read_cross_design_inputs", read_inputs)
app = FastAPI()
app.include_router(table_mod.router)
client = TestClient(app)
url = "/api/projects/00000000-0000-0000-0000-000000000001/sections/7/cross-quantity-table"
response = client.post(url, json={"chainage_m": 40.0, "design": DESIGN})
assert response.status_code == 200, response.text
body = response.json()
assert seen["chainage"] == 40.0
assert body["quantity_table"]["cut_soil"] == 1.234
assert body["text"]["planned"] == "100.25"
# 측점 파일이 없으면 404 · 사유 그대로
def missing(*args):
raise FileNotFoundError("해당 측점의 횡단 상세 파일을 찾을 수 없습니다.")
monkeypatch.setattr(table_mod, "read_cross_design_inputs", missing)
response = client.post(url, json={"chainage_m": 41.0, "design": None})
assert response.status_code == 404
assert "횡단 상세 파일" in response.json()["message"]
@@ -25,7 +25,8 @@ def test_이월은_사용자_입력만() -> None:
"ditch_side",
):
assert result_key not in CARRY_INPUT_KEYS
assert {"ground_type", "paved", "ditch_choice", "cut_slope_ratio_user"} <= set(CARRY_INPUT_KEYS)
assert {"ground_type", "ditch_choice", "cut_slope_ratio_user"} <= set(CARRY_INPUT_KEYS)
assert "paved" not in CARRY_INPUT_KEYS # 포장 = 노면공 범위로 새로 정함(138-1)
def test_체인이_옛_설계를_통째로_옮기지_않는다() -> None:
+16
View File
@@ -58,3 +58,19 @@ def test_끈_구간_글_왕복(out):
def test_안_맞는_끈_값은_무시_저장_때_빠짐(out):
assert out["stale"] == "20-60"
assert out["empty"] == ""
def test_확폭_면적_사다리꼴_앞뒤_0_측점까지(out):
# 0~20: (0+0.3)/2×20=3 · 20~40: 10.5 · 40~60: 11.5 · 60~80: 4 → 29
# 80~100: 5 · 100~120: 11 · 120~140: 6 → 22
assert [round(a, 6) for a in out["area"]] == [29.0, 22.0]
def test_확폭_면적_노면공_범위로_자름(out):
# 30 에서 자름: w(30)=0.525 → (0.525+0.75)/2×10=6.375 + 11.5 + 4 = 21.875
assert round(out["areaClipped"], 6) == 21.875
def test_한_측점_구간도_면적_자료_없으면_0(out):
assert round(out["areaSingle"], 6) == 20.0 # (0+1)/2×20 × 2
assert out["areaMissing"] == 0
+32 -7
View File
@@ -126,17 +126,42 @@ def test_missing_truth_files_are_not_an_error(tmp_path):
# ── 포장 판정 규칙 ─────────────────────────────────────────────────────────
def test_outside_range_keeps_user_value_and_defaults_unpaved():
"""경사 제안은 더 이상 포장을 켜지 않는다 — 구간 밖은 사용자 값(없으면 비포장)."""
def test_outside_range_is_unpaved():
"""포장 = 노면공 · 물넘이 범위만(138-1) — 밖은 비포장(옛 저장값 인자 없음)."""
ranges = [(10.0, 20.0)]
assert paved_at(30.0, ranges) is False
assert paved_at(30.0, ranges, True) is True
assert paved_at(30.0, ranges, False) is False
assert paved_at(15.0, ranges) is True
assert paved_at(20.02, ranges) is True # 끝 허용오차 = 브라우저 짝 0.02 m
assert paved_at(20.03, ranges) is False
assert paved_at(15.0, []) is False
def test_inside_range_overrides_user_off_switch():
"""구간 안은 강제 포장 — 물넘이는 콘크리트 노면이고 지정 구간은 자동 반영이다."""
assert paved_at(15.0, [(10.0, 20.0)], False) is True
def test_enforce_turns_paved_on_and_off(monkeypatch):
"""재계산은 켬↔끔 양쪽 — 범위 밖 옛 `paved=true` 는 비포장으로 되돌림(138-1)."""
from pathlib import Path
from B06_Section import B06_Section_Router_Design as design_mod
monkeypatch.setattr(design_mod, "pavement_ranges", lambda root: [(0.0, 50.0)])
monkeypatch.setattr(design_mod, "ford_surface_drops", lambda root: [])
monkeypatch.setattr(design_mod, "design_elevation_from_longitudinal", lambda lon, ch: 100.0)
monkeypatch.setattr(design_mod, "curve_widening_args", lambda section: {})
monkeypatch.setattr(
design_mod, "compute_cross_design", lambda samples, elev, **kw: {"paved": kw["paved"]}
)
sections = [
{"chainage_m": 20.0, "design": {"paved": False}}, # 범위 안 · 끔 → 켬
{"chainage_m": 80.0, "design": {"paved": True}}, # 범위 밖 · 켬 → 끔
{"chainage_m": 30.0, "design": {"paved": True}}, # 그대로
{"chainage_m": 90.0, "design": {"paved": False}}, # 그대로
]
assert design_mod.enforce_pavement_ranges({}, sections, Path("."), None) == 2
assert [s["design"]["paved"] for s in sections] == [True, False, True, False]
# 노면공을 다 지워도(범위 없음) 옛 켬은 꺼진다
monkeypatch.setattr(design_mod, "pavement_ranges", lambda root: [])
assert design_mod.enforce_pavement_ranges({}, sections, Path("."), None) == 2
assert not any(s["design"]["paved"] for s in sections)
# ── 독립 기슭막이 ─────────────────────────────────────────────────────────
+18 -9
View File
@@ -84,6 +84,11 @@ def _read(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8"))
def _stuck(got: dict) -> list:
"""못 돈 줄(까닭) — 층 셈 꼴(142-1)."""
return [line for line in got["줄"] if line.get("까닭")]
def _auto_values(root: Path) -> dict[str, Any]:
"""담은 로직마다 자동값을 확인한 값으로 — 사용자가 다 채운 것과 같게."""
labor = _read(root / "templates/structures" / FOLDER / "일위대가.json")
@@ -170,12 +175,13 @@ def _run(master: Any, key: str, values: dict) -> str:
# ── 계산 · 마스터 바뀜 · 새로 가져오기 ───────────────────
def test_입력이_없으면_빠진_입력_목록(world):
def test_입력이_없으면_빠진_입력_목록_셈은_자동값(world):
"""142-1 — 빈 입력은 M02 층 셈과 같이 자동값으로 돎 · 빠진 입력은 「빈 값 n」 표시용 목록."""
root = world["root"]
_save(root, [_pipe()])
got = copies.calc(root, FOLDER)
assert got["계"] == 0 and len(got["멈춤"]) == 3
assert {"로직": "GF000176", "입력": "관경"} in got["빠진입력"]
assert _stuck(got) == [] and got["기준합"]["계"] > 0
def test_마스터_단가를_바꿔도_계산_그대로_새로_가져오면_바뀜(world):
@@ -183,7 +189,7 @@ def test_마스터_단가를_바꿔도_계산_그대로_새로_가져오면_바
_save(root, [_pipe()])
values = _fill_values(root)
before = copies.calc(root, FOLDER)
assert before["빠진입력"] == [] and before["멈춤"] == [] and before["계"] > 0
assert before["빠진입력"] == [] and _stuck(before) == [] and before["기준합"]["계"] > 0
files, whole = m01.loaded()
ua = next(
r
@@ -194,7 +200,8 @@ def test_마스터_단가를_바꿔도_계산_그대로_새로_가져오면_바
)
given = {k: {n: v["값"] for n, v in one.items()} for k, one in values.items()}
expected = prices.to_json(prices.mcb.run(whole, ua, given))
assert before["계"] == pytest.approx(expected["계"]) and before["노무비"] > 0
assert before["기준합"]["계"] == pytest.approx(expected["계"])
assert before["기준합"]["노무비"] > 0
labor_file = master_dir / "인력.json"
original = labor_file.read_bytes()
try:
@@ -206,7 +213,8 @@ def test_마스터_단가를_바꿔도_계산_그대로_새로_가져오면_바
assert copies.list_items(root)[0]["마스터바뀜"] is True
copies.refresh(root, FOLDER)
after = copies.calc(root, FOLDER)
assert after["노무비"] > before["노무비"] and after["재료비"] == before["재료비"]
assert after["기준합"]["노무비"] > before["기준합"]["노무비"]
assert after["기준합"]["재료비"] == before["기준합"]["재료비"]
assert copies.read_file(root, FOLDER, "입력값")["내용"] == values # 같은 키 살림
assert copies.list_items(root)[0]["마스터바뀜"] is False
finally:
@@ -301,7 +309,7 @@ def test_옛_복사본은_읽을_때_채움(world):
assert copies.read_file(root, FOLDER, "입력값")["내용"] == {}
filled = _read(folder / "manifest.json")
assert filled["가져온날"] == filled["적용일"] and filled["옮겨받음"]
assert (folder / "단가.json").is_file() and copies.calc(root, FOLDER)["멈춤"]
assert (folder / "단가.json").is_file() and copies.calc(root, FOLDER)["빠진입력"]
assert copies.list_items(root)[0]["마스터바뀜"] is False # 원본이 안 바뀐 옛 복사본
@@ -330,7 +338,7 @@ def test_길_권한_남의_프로젝트는_403(world, monkeypatch):
stale = client.put(f"{base}/{FOLDER}/입력값", json={"판": got["판"] + "x", "내용": {}})
assert stale.status_code == 409 and stale.json()["detail"]["판"] == put.json()["판"]
assert client.get(f"{base}/{FOLDER}/모름").status_code == 422
assert client.post(f"{base}/{FOLDER}/calc").json()["멈춤"]
assert client.post(f"{base}/{FOLDER}/calc").json()["빠진입력"]
wall = {"structure": "st9", "layer": "system", "owner": None}
assert client.post(base, json={**wall, "summary_item": {"layer": "x"}}).status_code == 400
assert client.post(base, json={"layer": "system", "key": "SS000022"}).status_code == 400
@@ -390,8 +398,9 @@ def test_같은_항목_구조물_둘은_따로(world):
assert copies.read_file(root, "a1", "면적")["내용"]["메모"] == "a1 만 고침"
assert "메모" not in copies.read_file(root, "a2", "면적")["내용"] # 다른 쪽 그대로
_fill_values(root) # pipe@40.0 에만 값
assert copies.calc(root, FOLDER)["멈춤"] == [] and copies.calc(root, FOLDER)["계"] > 0
assert len(copies.calc(root, "pipe@60.0")["멈춤"]) == 3 # 계산도 각자
got = copies.calc(root, FOLDER)
assert _stuck(got) == [] and got["기준합"]["계"] > 0
assert copies.calc(root, "pipe@60.0")["빠진입력"] and not got["빠진입력"] # 계산도 각자
flags = {i["폴더"]: i["고침"] for i in copies.list_items(root)}
assert flags == {"a1": True, "a2": False, FOLDER: True, "pipe@60.0": False}
+36 -25
View File
@@ -82,7 +82,8 @@ def test_재적용은_덮임을_돌려주고_계산_한_줄(out):
assert out["reapplyCall"][0] == "POST /api/projects/P1/items/pipe%4085.05/reapply"
assert out["reapply"]["covered"] == ["면적"] # 덮이는 것을 알림으로
assert out["reapply"]["info"]["마스터바뀜"] is False
assert out["calc"] == {"total": 12345.6, "gaps": 2, "stuck": 1, "base": 100, "reflected": 300}
# 142-1 — 계 = 반영합 · 빈 값 = 빠진입력 · 못 돈 줄 = 까닭 있는 줄
assert out["calc"] == {"total": 300, "gaps": 2, "stuck": 1, "base": 100, "reflected": 300}
assert out["calcMissing"] is None # 없는 복사본 = 계산 줄 없음
@@ -140,28 +141,20 @@ def test_넣을_값_카드는_sub2_파일_한_벌(out):
assert "M02_Labor_Values" in text and 'InputsCard"' not in text, name
def test_일위대가_요약_복사본_원본_넣을_값_전부(out):
"""39-1c — 복사본이면 복사본 일위대가 · 계산 · 아니면 원본 층 calc ·
넣을 값 = 담은 로직 입력 전부."""
assert out["laborCopy"] == {
"번호": "SU-000007",
"로직": ["GA1", "GA2"],
"자재": 1,
"base": 100,
"reflected": 300,
"gaps": 2,
"stuck": 1,
}
assert out["laborOrigin"] == {
"번호": None,
"로직": ["GA1", "GA2"],
"자재": 1,
"base": 500,
"reflected": 1500,
"gaps": 0,
"stuck": 1,
}
assert out["laborNone"] is None # 일위대가 없는 열(층 calc 404)
def test_일위대가_카드_셈은_복사본_셈_답_통째_문서는_복사본_값(out):
"""142-1 — 모달 일위대가 카드 = M02 카드 · 셈 = 복사본 셈(층 셈 꼴) · 문서 = 복사본 입력값 ·
고른자재를 줄 입력값 · 고름으로(빈 값 = 자동값이라 안 얹음 · 서버 `with_values` 와 같은 규칙)."""
assert out["laborCalc"] == {"줄": 2, "반영합": {"계": 300}, "빠진입력": 2}
assert out["laborCalcMissing"] == "복사본이 없음" # 서버 까닭 그대로(카드 안 글)
lines = out["withValues"]["담은로직"]
assert lines[0] == {"로직": "GA1", "입력값": {"지역": 15}, "고름": {"0": "MT9"}}
assert lines[1] == {
"로직": "GA2",
"입력값": {},
"고름": {"0": "MT1"},
} # 복사본에 값 없음 = 자동값
assert lines[2] == {"참조": {"층": "system", "주인": None, "키": "SC-000001"}}
assert out["withValues"]["일위대가번"] == "SU-000007"
values = out["laborValues"]
assert values["keys"] == ["GA1", "GA2"]
assert values["logics"] == [["GA1", ["지역", "소량감률"]], ["GA2", ["지역"]]]
@@ -169,9 +162,27 @@ def test_일위대가_요약_복사본_원본_넣을_값_전부(out):
def test_모달_카드_차례_도면_일위대가_면적():
"""142-1 — 일위대가 카드 = M02 카드 한 벌(`createItemCards` 의 `labor`) · 옛 요약 카드 · 넣을 값
카드 · 머리 옛 「일위대가 {조합 이름}」 줄 없음."""
detail = (UI / "ui_template_item_picker_detail.ts").read_text(encoding="utf-8")
assert 'import("./ui_template_item_picker_labor")' in detail
assert "cards.root.insertBefore(labor.root, cards.root.children[1]" in detail
assert not (UI / "ui_template_item_picker_labor.ts").exists()
assert "ui_template_item_picker_labor" not in detail
assert 'labor: () => void edit("inputs"),' in detail # [수정] = 넣을 값 편집 화면
assert "createInputsValueCard" not in detail and "extraCards" not in detail
assert "folderLines" not in detail and "unitPrice" not in detail
def test_모달_일위대가_카드_읽기_전용():
"""142-1 — 모달 자료 자리엔 쓰는 길(`write`)이 없고 카드는 그때 보기만(원본 층 [저장]을 안 엶)."""
detail = (UI / "ui_template_item_picker_detail.ts").read_text(encoding="utf-8")
body = detail[detail.index("const makeSource") : detail.index("const badge")]
assert "write" not in body and "labor: {" in body and "items.calcLabor(at)" in body
cards = (M02 / "M02_MasterTemplete_ItemCards.ts").read_text(encoding="utf-8")
assert "const laborWritable = (): boolean => opts.editable() && !!opts.source().write;" in cards
assert "readOnly: !laborWritable()," in cards
assert 'c.kind === "labor" ? laborWritable()' in cards
picker = (UI / "ui_template_item_picker.ts").read_text(encoding="utf-8")
assert '"personal" : "system"' in picker # 처음 펼침 = 개인(없으면 마스터)
def test_모달은_원본_층에_쓰는_길을_안_부름():
+10 -10
View File
@@ -60,10 +60,8 @@ def test_실패는_카드_안_글과_다시_불러오기():
assert "showToast" not in cards # 토스트 안 씀(저장 알림은 틀)
assert "c.shell.fail(" in cards and "reload" in cards
picker = (ROOT / "ui_template" / "ui_template_item_picker_detail.ts").read_text("utf-8")
# 일위대가 카드 · 값 카드가 다시 불러오기 하나(retry)를 같이 씀(39-1c)
assert "const retry = (): void => void paintLabor(row, seq);" in picker
assert "inputs.set(null, why(error), retry);" in picker
assert "const stops = [labor.loading(), inputs.loading()];" in picker
# 모달 일위대가 카드도 M02 카드 틀 안 실패 글(142-1 · 옛 요약 · 값 카드 retry 없음)
assert "paintLabor" not in picker and 'labor: () => void edit("inputs"),' in picker
def test_일위대가_카드는_상세_카드_갈래():
@@ -75,13 +73,14 @@ def test_일위대가_카드는_상세_카드_갈래():
assert 'makeCard("labor", PART_WORD.labor, laborHost, () => saveLabor())' in cards
save = cards[cards.index("async function saveLabor()") :]
assert save.index("laborEd.saveArea()") < save.index('saveTemplate(layer, "labor"')
assert "onChanged: (dirty) => laborCard.saver.mark(dirty && opts.editable())," in cards
# 고칠 수 있음 = editable + 쓰는 길(142-1 · 고르기 모달은 보기만)
assert "onChanged: (dirty) => laborCard.saver.mark(dirty && laborWritable())," in cards
assert "Labor_View" not in cards # 한 줄 표(40-2) 안 씀
detail = _text("M02_MasterTemplete_Labor_Detail.ts")
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 +150,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")
+21 -2
View File
@@ -169,14 +169,15 @@ def test_계산_반영_합은_손셈_원본과_같음(world):
_save(root)
_fill_auto(root)
got = copies.calc(root, FOLDER)
assert got["멈춤"] == [], got["줄"]
assert [line for line in got["줄"] if line.get("까닭")] == [], got["줄"]
lines = {line["로직"]: line for line in got["줄"]}
hand, area = lines[LOGICS[1]], lines[LOGICS[2]]
assert (hand["수량"], hand["수량출처"]) == (HAND, "손")
assert (area["수량"], area["수량출처"]) == (AREA_QTY, "칸")
assert lines[LOGICS[0]]["수량출처"] == "없음" and lines[LOGICS[0]]["반영"] is None
for line in (hand, area):
expect = Decimal(str(line["계"])) * Decimal(str(line["수량"])) / Decimal(str(line["배"]))
base = Decimal(str(line["기준"]["계"]))
expect = base * Decimal(str(line["수량"])) / Decimal(str(line["배"]))
assert Decimal(str(line["반영"]["계"])) == pytest.approx(expect)
(mat,) = got["자재"]
assert mat["코드"] == MATERIAL and mat["수량"] == MAT_QTY and mat["기준단가"]
@@ -208,3 +209,21 @@ def test_일위대가_쓰기는_막힘(world):
assert current["내용"]["줄"]["열"] == COLUMN
with pytest.raises(ValueError, match="원본"):
copies.write_file(root, FOLDER, "일위대가", current["판"], current["내용"])
def test_같은_문서_같은_단가면_사본_셈은_층_셈(world):
"""142-1 — 사본 셈 = 층 셈 한 벌(`M02_Labor_Doc.calc`) · 빈 입력은 둘 다 자동값 · 줄 꼴 같음."""
root = world["root"]
_save(root)
got = copies.calc(root, FOLDER)
given = {world["area_pill"]: AREA_QTY, labor_doc.material_key(MATERIAL): MAT_QTY}
origin = labor_doc.calc(world["doc"], given)
keys = ("로직", "알약", "분모", "배", "기준", "수량", "수량출처", "반영", "까닭")
assert [{k: line.get(k) for k in keys} for line in got["줄"]] == [
{k: line.get(k) for k in keys} for line in origin["줄"]
]
assert got["자재"] == origin["자재"]
assert got["기준합"] == origin["기준합"] and got["반영합"] == origin["반영합"]
assert got["반영합"]["계"] > 0
assert {p["키"] for p in got["알약"]} == set(world["pills"]) # 사본 알약(가져온 때 것)
assert got["빠진입력"] # 비운 입력은 목록으로(화면 「빈 값 n」)
+5 -8
View File
@@ -5,7 +5,7 @@
- GC000260 90% = 100% × 0.9(비목 · 계 · 줄 수량 · 금액) · 단가는 그대로.
- clean = 0 초과 200 이하 · 소수 한 자리 · 100 · 대상 밖은 안 둠.
- 프로젝트 사본 계산(`M02_Item_Prices.calc`)도 같은 율 · 같은 값.
- 서고(단가승격)도 율을 담고 같은 값.
- 옛 단가승격 서고 문서도 율을 따라 같은 값(134-2 · 프로젝트 사본 셈).
"""
from __future__ import annotations
@@ -78,16 +78,13 @@ def test_문서_계산과_프로젝트_사본_같음() -> None:
got = prices.calc(copy, prices.to_json(prices.collect([CODE])), values, {})
origin = labor.calc(doc, {})
(line,) = got["줄"]
assert line["계"] == pytest.approx(origin["줄"][0]["기준"]["계"])
assert line["기준"]["계"] == pytest.approx(origin["줄"][0]["기준"]["계"]) # 층 셈 꼴(142-1)
full = labor.calc({**doc, "담은로직": [{**doc["담은로직"][0], "켜쌓기율": None}]}, {})
assert _d(line["계"]) == pytest.approx(_d(full["줄"][0]["기준"]["계"]) * Decimal("0.9"))
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"})
@@ -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]
+140 -78
View File
@@ -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):
@@ -300,12 +373,12 @@ def test_프로젝트는_참조사본으로_굳힘_원본을_지워도_같은_
(world["system"] / kind / f"{key}.json").unlink()
got = prices.calc(rows, bundle, {}, {}, None)
line = got["줄"][0]
assert line["ok"] and line["참조"] == ref
assert line["계"] == pytest.approx(live, rel=1e-9)
assert not line["까닭"] and line["참조"] == ref # 층 셈 꼴(142-1)
assert line["기준"]["계"] == pytest.approx(live, rel=1e-9)
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):
@@ -387,7 +458,7 @@ def test_사전조합_로직_줄_섞임_합은_손셈_지우기_409_굳힘(world
for key in (sc, sp): # 원본이 사라져도 굳힌 사본으로 같은 값
(world["system"] / refs.kind_of(key) / f"{key}.json").unlink()
got = prices.calc(rows, bundle, {}, {}, None)["줄"][0]
assert got["계"] == pytest.approx(float(want), rel=1e-9)
assert got["기준"]["계"] == pytest.approx(float(want), rel=1e-9)
def test_사전조합이_자기_자신을_담으면_422_수량_기본_1(world):
@@ -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,15 +489,13 @@ 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)
def test_사전조합_굳힘은_안_로직_알약도_사본_값_142_2(world, monkeypatch):
@@ -452,10 +514,10 @@ def test_사전조합_굳힘은_안_로직_알약도_사본_값_142_2(world, mon
assert [(p["로직"], p.get("배")) for p in kept] == [([TEN], 10)]
assert "알약" not in _read(world["system"], "precombo", sc) # 층 문서는 그대로
bundle = prices.to_json(prices.collect([TEN]))
before = prices.calc(rows, bundle, {}, {}, None)["줄"][0]["계"]
before = prices.calc(rows, bundle, {}, {}, None)["줄"][0]["기준"]["계"]
changed = {"물량": [{"키": "x|㎡", "이름": "x", "단위": "㎡", "로직": [TEN]}]} # 배 1 로 바뀜
monkeypatch.setattr(combo_store, "combo_pills", lambda row: changed)
assert prices.calc(rows, bundle, {}, {}, None)["줄"][0]["계"] == pytest.approx(before)
assert prices.calc(rows, bundle, {}, {}, None)["줄"][0]["기준"]["계"] == pytest.approx(before)
with store.at(world["system"]):
live = labor.calc(doc, {})["줄"][0]["기준"]["계"]
assert live == pytest.approx(before * 10) # 층 셈은 지금 M01(배 1)
+3 -3
View File
@@ -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
+5 -2
View File
@@ -201,7 +201,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 +215,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 # 도면 양식은 시스템 층에만
@@ -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
+2 -1
View File
@@ -245,7 +245,8 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
for (const id of pathIds(start, labels())) opened.add(id);
chosen = start;
} else {
opened.add("system"); // 지정된 게 없으면 첫 그룹만 펼침
// 지정된 게 없으면 개인(142-1 · M02 기본 층 134-4 와 같게) — 개인 항목이 없으면(회사 없음 등) 마스터
opened.add(rows.some((row) => layerOf(row) === "personal") ? "personal" : "system");
}
paintTree();
paintDetail();
+50 -72
View File
@@ -1,9 +1,10 @@
/* =============================================================================
* ui_template_item_picker_detail.ts
* 항목 고르기 모달 오른쪽 상세(계획서 25-2d · 26-3b · 39-1c) — 카드 = 도면 · 일위대가 · 면적산출 ·
* 넣을 값. 카드는 M02 와 같은 한 벌(`M02_MasterTemplete_ItemCards` · `M02_Labor_Values`) + 일위대가 카드
* (`ui_template_item_picker_labor`)를 늦게 받아 씀(B05 · B06 번들에 CAD · 스프레드시트가 안 섞이게 `import()`).
* 넣을 값 = 일위대가 담은 로직 입력 전부(복사본이면 복사본 입력값).
* 항목 고르기 모달 오른쪽 상세(계획서 25-2d · 26-3b · 39-1c · 142-1) — 카드 = 도면 · 일위대가 · 면적산출.
* 카드는 M02 와 같은 한 벌(`M02_MasterTemplete_ItemCards` · 일위대가 = `Labor_Detail` 카드 갈래)을 늦게 받아 씀
* (B05 · B06 번들에 CAD · 스프레드시트가 안 섞이게 `import()`). 일위대가 카드 = 자료 자리 길(`ItemSource.labor`) ·
* 읽기 전용(쓰는 길 없음) · 복사본이면 복사본 문서 + 복사본 입력값 · 고른자재 · 셈은 복사본 셈(층 셈 한 벌).
* 넣을 값 고침 = 일위대가 카드 [수정] → 넣을 값 편집 화면.
*
* 자료 자리 — **그 구조물의 복사본**(폴더 = 구조물 id · 같은 항목을 고른 구조물도 복사본은 따로)을 읽고,
* 그 구조물에 아직 없으면 **원본 층**을 읽음(읽기만). [수정] 은 복사본만 고침 — 아직이면 「이 구조물로 가져와 고침」
@@ -14,14 +15,12 @@
import { createButton, el, showConfirmDialog, showToast } from "./ui_template_elements";
import { readTemplate } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
import { calcLabor, type LaborDoc } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api";
import type { EditKind, ItemCardsHandle } from "../M02_MasterTemplete/M02_MasterTemplete_ItemCards";
import type { ItemSource } from "../M02_MasterTemplete/M02_MasterTemplete_ItemSource";
import type { InputsValueHandle } from "../M02_MasterTemplete/M02_Labor_Values";
import type { ValueDoc } from "../M02_MasterTemplete/M02_Labor_Values_Model";
import type { LaborCardHandle } from "./ui_template_item_picker_labor";
import { askFields } from "./ui_template_item_picker_ask";
import {
folderLines,
itemName,
layerOf,
pathOf,
@@ -30,8 +29,6 @@ import {
type PickerRow,
} from "./ui_template_item_picker_data";
import {
laborOf,
laborValueData,
PART_OF,
projectItems,
sameItem,
@@ -39,6 +36,7 @@ import {
type CreatedItem,
type ProjectItemInfo,
type ProjectItems,
withCopyValues,
} from "./ui_template_item_picker_project";
/** 편집 화면을 여는 데 필요한 것 — 「넣을 값」 도 한 종류 */
@@ -105,10 +103,9 @@ const TEXT = {
empty: "{label} 을(를) 넣을 것",
importFailed: "이 구조물로 가져오지 못함 — {value}",
listFailed: "프로젝트 항목을 못 읽음 — {value}",
calc: "복사본 계산 — 계 {total}원 · 빠진 입력 {gaps}개",
calcStuck: " · 멈춘 로직 {stuck}개",
calc: "복사본 계산 — 계 {total}원 · 빈 값 {gaps}개(자동값)",
calcStuck: " · 못 돈 줄 {stuck}개",
noCopy: "복사본에 없음",
price: "일위대가",
parts: {
도면: "도면",
면적: "면적산출",
@@ -131,8 +128,6 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail {
let seq = 0;
let infos: ProjectItemInfo[] | null = null;
let cards: ItemCardsHandle | null = null;
let inputs: InputsValueHandle | null = null;
let labor: LaborCardHandle | null = null;
let source: ItemSource = { layer: "system", projectId: null, read: () => Promise.reject() };
/** 이 구조물에 있는 복사본 — 고른 항목의 것일 때만(다른 항목의 것이면 `other`) */
let info: ProjectItemInfo | null = null;
@@ -149,7 +144,6 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail {
const where = [opts.layerLabels[layerOf(row)], ...pathOf(row).slice(0, -1)]
.filter(Boolean)
.join(" › ");
const pairs = folderLines(row).filter((line) => line.kind === "unitPrice");
head.replaceChildren(
el("h4", {
className: "ui-item-picker__name",
@@ -158,29 +152,46 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail {
}),
small(where),
...(row.단위 ? [small(`${opts.unit} ${row.단위}`)] : []),
...pairs
.filter((line) => line.name || line.key)
.map((line) => small(`${TEXT.price} ${line.name || line.key}`)),
);
};
/** 이 구조물 복사본 또는 원본 층에서 열 하나의 문서를 읽음 — 없으면 던짐(카드가 「없음」) */
const makeSource = (row: PickerRow): ItemSource =>
info && opts.projectId
? {
layer: "project",
projectId: opts.projectId,
read: async (part) => {
const doc = await items?.read(info!.폴더, PART_OF[part]);
if (doc == null) throw new Error(TEXT.noCopy);
return { 문서: doc };
/** 이 구조물 복사본 또는 원본 층에서 열 하나의 문서를 읽음 — 없으면 던짐(카드가 「없음」) ·
* 쓰는 길(`write`)은 없음 — 카드는 보기만(원본 층 · 복사본 고침은 편집 화면 · 142-1) */
const makeSource = (row: PickerRow): ItemSource => {
if (info && opts.projectId && items) {
const at = info.폴더;
return {
layer: "project",
projectId: opts.projectId,
read: async (part) => {
const doc = await items.read(at, PART_OF[part]);
if (doc == null) throw new Error(TEXT.noCopy);
return { 문서: doc };
},
labor: {
read: async () => {
const [labor, values, picks] = await Promise.all([
items.read(at, "일위대가") as Promise<{ 줄?: LaborDoc } | null>,
items.read(at, "입력값") as Promise<ValueDoc | null>,
items.read(at, "고른자재") as Promise<Record<string, unknown> | null>,
]);
return { 문서: labor?.줄 ? withCopyValues(labor.줄, values, picks) : {} };
},
}
: {
layer: layerOf(row),
projectId: null,
read: (part, column) => readTemplate(layerOf(row), part, column, null),
};
calc: () => items.calcLabor(at),
},
};
}
const layer = layerOf(row);
return {
layer,
projectId: null,
read: (part, column) => readTemplate(layer, part, column, null),
labor: {
read: (column) => readTemplate(layer, "labor", column, null),
calc: (column, doc, 수량) => calcLabor(layer, column, doc, 수량, null),
},
};
};
const badge = (text: string, className = ""): HTMLElement =>
el("span", { className: `ui-item-picker__badge ${className}`.trim(), text });
@@ -293,46 +304,17 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail {
opts.onEdit({ kind, row, info, items, projectId: opts.projectId });
}
/** 일위대가 카드 · 넣을 값 카드 — 복사본이 있으면 복사본(값도) · 아니면 원본 층(39-1c) */
async function paintLabor(row: PickerRow, mine: number): Promise<void> {
if (!inputs || !labor) return;
const stops = [labor.loading(), inputs.loading()]; // 카드 안 「불러오는 중」(M02 31-7)
try {
const copy = info && items ? info.폴더 : null;
const summary = await laborOf(row, copy ? items : null, copy);
const values = copy ? (((await items?.read(copy, "입력값")) as ValueDoc | null) ?? {}) : {};
const data = summary ? await laborValueData(summary.로직, values) : null;
stops.forEach((stop) => stop());
if (mine !== seq) return;
labor.set(summary);
inputs.set(data);
} catch (error) {
stops.forEach((stop) => stop());
if (mine !== seq) return;
const retry = (): void => void paintLabor(row, seq);
labor.set(null, why(error), retry);
inputs.set(null, why(error), retry);
}
}
async function ensureCards(): Promise<void> {
if (cards && inputs && labor) return;
const [cardModule, inputModule, laborModule] = await Promise.all([
import("../M02_MasterTemplete/M02_MasterTemplete_ItemCards"),
import("../M02_MasterTemplete/M02_Labor_Values"),
import("./ui_template_item_picker_labor"),
]);
labor = laborModule.createLaborCard();
inputs = inputModule.createInputsValueCard({ mode: "view", onEdit: () => void edit("inputs") });
if (cards) return;
const cardModule = await import("../M02_MasterTemplete/M02_MasterTemplete_ItemCards");
// 차례 = 도면 · 일위대가 · 면적산출(M02 카드 한 벌) · 일위대가 [수정] = 넣을 값 편집 화면
cards = cardModule.createItemCards({
source: () => source,
editable: () => canCopy,
onEdit: (kind) => void edit(kind),
warnOnFail: () => false,
extraCards: [inputs.root],
labor: () => void edit("inputs"),
});
// 차례 = 도면 · 일위대가 · 면적산출 — 일위대가 카드를 도면 카드 바로 뒤에
cards.root.insertBefore(labor.root, cards.root.children[1] ?? null);
cardsHost.replaceChildren(cards.root);
}
@@ -344,8 +326,6 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail {
status.hidden = true;
cardsHost.replaceChildren();
cards = null;
inputs = null;
labor = null;
return;
}
paintHead(row);
@@ -362,14 +342,12 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail {
other = own && !info ? own : null;
source = makeSource(row);
await ensureCards();
if (mine !== seq || !cards || !inputs || !labor) return;
if (mine !== seq || !cards) return;
const label = !info && canCopy ? TEXT.importEdit : null;
cards.relabel({ structure: label, area: label });
inputs.setEditLabel(label ?? "넣을 값 수정");
cards.root.hidden = false;
paintStatus(row, mine);
void cards.load({ id: row.열, label: itemName(row) });
void paintLabor(row, mine);
})();
}
@@ -1,60 +0,0 @@
/* =============================================================================
* ui_template_item_picker_labor.ts
* 항목 고르기 모달 「일위대가」 카드(계획서 39-1c) — 담은 로직 수 · 자재 수 · 기준 합 · 반영 합 글.
* 보기만 · 고치기는 원본 층 일위대가 상세. 일위대가 보기 부품(39-3 C)이 오면 몸을 그것으로 바꿈.
* 자료 = `ui_template_item_picker_project.laborOf`(복사본이면 복사본 계산 · 아니면 원본 층 계산).
* 상세(`ui_template_item_picker_detail.ts`)가 카드를 받을 때 함께 `import()`.
* ========================================================================== */
import { el } from "./ui_template_elements";
import { cardNote, cardShell } from "../M02_MasterTemplete/M02_MasterTemplete_CardShell";
import type { LaborSummary } from "./ui_template_item_picker_project";
const TEXT = {
title: "일위대가",
none: "일위대가 없음",
failed: "일위대가를 못 읽음 — {value}",
logics: "단가산출 로직 {value}개",
materials: "자재 {value}개",
base: "기준 합 {value}원",
reflected: "반영 합 {value}원",
gaps: "빠진 입력 {value}개",
stuck: "멈춘 로직 {value}개",
};
const fill = (text: string, value: string | number): string =>
text.replace("{value}", String(value));
const won = (value: number | null): string =>
value === null ? "—" : Math.round(value).toLocaleString();
export interface LaborCardHandle {
root: HTMLElement;
loading: () => () => void;
/** `null` = 일위대가 없음 · `note` 가 있으면 못 읽은 까닭 */
set: (summary: LaborSummary | null, note?: string, retry?: () => void) => void;
}
export function createLaborCard(): LaborCardHandle {
const number = el("span", { className: "m02-struct__no" });
const body = el("div", { className: "m02-struct__sheet ui-item-picker__labor" });
const shell = cardShell({ title: TEXT.title, sub: [number], body });
const line = (text: string, className = ""): HTMLElement =>
el("p", { className: `ui-item-picker__labor-line ${className}`.trim(), text });
return {
root: shell.root,
loading: () => shell.loading(),
set: (summary, note, retry) => {
number.textContent = summary?.번호 ?? "";
if (note && retry) return shell.fail(fill(TEXT.failed, note), retry);
if (!summary) return void body.replaceChildren(cardNote(TEXT.none));
body.replaceChildren(
line(`${fill(TEXT.logics, summary.로직.length)} · ${fill(TEXT.materials, summary.자재)}`),
line(fill(TEXT.base, won(summary.base))),
line(fill(TEXT.reflected, won(summary.reflected)), "is-strong"),
...(summary.gaps ? [line(fill(TEXT.gaps, summary.gaps), "is-warn")] : []),
...(summary.stuck ? [line(fill(TEXT.stuck, summary.stuck), "is-warn")] : []),
);
},
};
}
+49 -65
View File
@@ -9,13 +9,15 @@
* GET|PUT /api/projects/{id}/items/{폴더}/{부분} → {종류, 판, 내용} · 고침은 {판, 내용}(판이 다르면 409)
* POST …/{폴더}/reapply 재적용 → {폴더, 가져온날, 원본판, 덮임: [부분]}
* POST …/{폴더}/save-as {to, 경로, 이름} 개인에 저장 → {key, 열, 이름, summary_item}
* POST …/{폴더}/calc 복사본만으로 계산(계 · 빠진 입력 · 기준합 · 반영합)
* 일위대가(39-1c) — 복사본 `일위대가` 부분 · 원본 층은 `calcLabor` · 넣을 값 목록은 `fetchLogicInputs`
* POST …/{폴더}/calc 복사본만으로 계산 — 층 셈 꼴(줄 · 자재 · 기준합 · 반영합) + 알약 · 빠진입력(142-1)
* 일위대가(39-1c · 142-1) — 모달 일위대가 카드(M02 카드 · 읽기 전용)가 `calcLabor` 로 셈 · 넣을 값 목록은 `fetchLogicInputs`
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import type { ItemInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
import { calcLabor, fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api";
import { fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api";
import type { LaborDoc } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api";
import type { LaborCalc } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Calc";
import type { ValueDoc } from "../M02_MasterTemplete/M02_Labor_Values_Model";
import { layerOf, ownerOf, type PickerRow } from "./ui_template_item_picker_data";
@@ -63,7 +65,10 @@ export interface CreatedItem {
summary_item: { layer: string; owner: string | null; key: string; name: string };
}
/** 복사본 계산 결과에서 모달이 보이는 것 — 계 · 빠진 입력 수 · 멈춘 로직 수 · 일위대가 기준합 · 반영합 계 */
/** 복사본 셈 답(142-1) — 층 셈 꼴 + 사본 알약 · 빈 입력 목록(셈은 자동값으로 돎) */
export type CopyCalc = LaborCalc & { 알약: unknown[]; 빠진입력?: unknown[] };
/** 복사본 계산 결과에서 모달이 보이는 것 — 계 · 빈 입력 수 · 못 돈 줄 수 · 일위대가 기준합 · 반영합 계 */
export interface ProjectCalc {
total: number;
gaps: number;
@@ -127,6 +132,8 @@ export interface ProjectItems {
) => Promise<CreatedItem>;
/** 복사본만으로 계산 — 계산할 것이 없거나 못 하면 null */
calc: (folder: string) => Promise<ProjectCalc | null>;
/** 복사본 셈 답 통째 — 모달 일위대가 카드(Labor_Detail)가 씀 · 못 하면 서버 까닭 그대로 던짐 */
calcLabor: (folder: string) => Promise<CopyCalc>;
}
const clients = new Map<string, ProjectItems>();
@@ -142,6 +149,12 @@ export function projectItems(projectId: string): ProjectItems {
for (const key of [...versions.keys()]) if (key.startsWith(`${folder}/`)) versions.delete(key);
};
const at = (folder: string, part: string): string => `${base}/${enc(folder)}/${enc(part)}`;
/** 복사본 셈(142-1) — 못 하면 서버 까닭 그대로 던짐 */
const calcCopy = async (folder: string): Promise<CopyCalc> => {
const reply = await call(`${base}/${enc(folder)}/calc`, post({}));
if (reply.status !== 200) throw new Error(reasonOf(reply));
return reply.body as CopyCalc;
};
const client: ProjectItems = {
list: async () => {
@@ -213,78 +226,23 @@ export function projectItems(projectId: string): ProjectItems {
return reply.body as CreatedItem;
},
calc: async (folder) => {
const reply = await call(`${base}/${enc(folder)}/calc`, post({}));
if (reply.status !== 200) return null;
const got = reply.body as {
계?: number;
빠진입력?: unknown[];
멈춤?: unknown[];
기준합?: { 계?: number };
반영합?: { 계?: number };
};
const got = await calcCopy(folder).catch(() => null);
if (!got) return null;
return {
total: Number(got.계 ?? 0),
total: Number(got.반영합?.계 ?? got.기준합?.계 ?? 0),
gaps: got.빠진입력?.length ?? 0,
stuck: got.멈춤?.length ?? 0,
stuck: (got.줄 ?? []).filter((line) => line.까닭).length,
base: got.기준합?.계 ?? null,
reflected: got.반영합?.계 ?? null,
};
},
calcLabor: (folder) => calcCopy(folder),
};
clients.set(projectId, client);
return client;
}
/* ── 일위대가(39-1c) — 카드 요약 · 넣을 값 자료 ─────────────────────────────── */
/** 일위대가 카드에 보이는 것 — 복사본이면 복사본 계산 · 아니면 원본 층 계산(저장 안 함) */
export interface LaborSummary {
번호: string | null;
/** 담은 로직 키(차례 그대로) — 넣을 값 목록을 받는 데도 씀 */
로직: string[];
자재: number;
base: number | null;
reflected: number | null;
gaps: number;
stuck: number;
}
interface LaborCopy {
줄?: { 일위대가번?: string; 담은로직?: { 로직?: string }[]; 자재?: unknown[] };
}
/** 그 항목의 일위대가 — 복사본(`folder`)이 있으면 그것 · 없으면 원본 층 열 · 일위대가가 없으면 null */
export async function laborOf(
row: PickerRow,
items: ProjectItems | null,
folder: string | null,
): Promise<LaborSummary | null> {
if (items && folder) {
const copy = (await items.read(folder, "일위대가")) as LaborCopy | null;
if (!copy?.줄) return null;
const got = await items.calc(folder);
return {
번호: copy.줄.일위대가번 ?? null,
로직: (copy.줄.담은로직 ?? []).map((one) => String(one.로직 ?? "")).filter(Boolean),
자재: copy.줄.자재?.length ?? 0,
base: got?.base ?? null,
reflected: got?.reflected ?? null,
gaps: got?.gaps ?? 0,
stuck: got?.stuck ?? 0,
};
}
const got = await calcLabor(layerOf(row), row.열, null).catch(() => null);
if (!got) return null;
return {
번호: null,
로직: got.줄.map((line) => line.로직),
자재: got.자재.length,
base: got.기준합?.계 ?? null,
reflected: got.반영합?.계 ?? null,
gaps: 0,
stuck: got.줄.filter((line) => line.까닭).length,
};
}
/* ── 일위대가(39-1c) — 넣을 값 자료 ─────────────────────────────── */
/** 넣을 값 카드 자료 — 담은 로직 입력 전부 */
export async function laborValueData(
@@ -295,3 +253,29 @@ export async function laborValueData(
const list: ItemInputs = { 일위대가: null, 로직: got.로직, 빠진로직: got.빠진로직 };
return { list, values };
}
/** 복사본 일위대가 문서에 복사본 입력값 · 고른자재를 줄 입력값 · 고름으로 얹음(보기용 · 142-1) —
* 빈 값은 안 얹음(= 자동값) · 셈은 서버가 같은 규칙으로(`M02_Item_Prices.with_values`) */
export function withCopyValues(
doc: LaborDoc,
values: ValueDoc | null,
picks: Record<string, unknown> | null,
): LaborDoc {
const lines = (doc.담은로직 ?? []).map((one) => {
const code = (one as { 로직?: string }).로직;
if (!code) return one;
const given = Object.fromEntries(
Object.entries(values?.[code] ?? {}).flatMap(([name, got]) => {
const v = (got as { 값?: unknown } | null)?.값;
return v === null || v === undefined || v === "" ? [] : [[name, v]];
}),
);
const pick = picks?.[code];
return {
...one,
입력값: given,
...(pick && typeof pick === "object" ? { 고름: pick } : {}),
} as typeof one;
});
return { ...doc, 담은로직: lines };
}
+3 -3
View File
@@ -447,9 +447,6 @@ export const ui_locales_b2 = {
B06_Design_DitchType_Legend: ["측구형식", "Ditch type"],
B06_Design_DitchType_Standard: ["일반", "Standard"],
B06_Design_DitchType_LType: ["L형", "L-type"],
B06_Design_Paved_Legend: ["포장", "Pavement"],
B06_Design_Paved_On: ["포장", "Paved"],
B06_Design_Paved_Off: ["비포장", "Unpaved"],
B06_Design_TwoStage_Legend: ["2단계 경사", "Two-stage slope"],
B06_Design_TwoStage_On: ["복합경사", "Compound slope"],
B06_Design_TwoStage_Off: ["단경사", "Single slope"],
@@ -475,6 +472,9 @@ export const ui_locales_b2 = {
"측구를 생성한 경우에만 형식을 선택할 수 있습니다.",
"Available only when a ditch is created.",
],
B06_Notice_PaveSuggested: ["포장 권장", "Pavement recommended"],
B06_Cross_Columns: ["한 줄 카드 수(1~6)", "Cards per row (1-6)"],
B06_Design_Detail: ["상세", "Detail"],
B06_Design_Paved_Suggested: [
"종단경사 법정 상한 초과 — 포장 권장 (임도설치 및 관리 등에 관한 규정 별표 1-2)",
"Grade exceeds legal limit — pavement recommended (Forest Road Regulation, Annex 1-2)",