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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 }) },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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})
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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).
|
||||
// 카드 안에서 한 장씩 하면 쓰기 뒤 읽기가 카드 수만큼 반복돼 브라우저가 레이아웃을
|
||||
|
||||
@@ -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 사용자 확정: 「일단은 브라우저 계산으로」).
|
||||
|
||||
@@ -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 사용자 지시).
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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 {}),
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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": ("담은로직",),
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
@@ -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]
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
# ── 독립 기슭막이 ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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_모달은_원본_층에_쓰는_길을_안_부름():
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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,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]
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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();
|
||||
|
||||
@@ -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")] : []),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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)",
|
||||
|
||||
Reference in New Issue
Block a user