Merge origin/sub_laptop_7 into sub_laptop_8 — PLAN 704-4 규격 표 1차 · M02 일위대가 셈 짝 머리 설명 겹침 양쪽 살림(705-1 고정 줄 · 704-4 규격 열)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
"""B07 구조물 도면(376 · 요구 241-1) — B05 · B06 배치에 연결된 구조물 조합 도면 목록 · 단건.
|
||||
|
||||
조합 키 = 항목 사본 폴더 이름(`snapshot_folder_name`) — 같은 조합을 여러 배치가 써도 한 장 ·
|
||||
조합 키 = 항목 사본 폴더 이름(`snapshot_folder_name`) + 규격 열(704-4 · `-s해시`) — 같은 조합 ·
|
||||
같은 열을 여러 배치가 써도 한 장 · 열이 다르면 따로(표제 = 항목 이름 「… (H=2.0)」) ·
|
||||
더한장은 장마다 · id `structure_{키}[_{장id}]` · 차례 = 도번 · 도면 = 프로젝트 사본 `도면.json` +
|
||||
얹는 도각(M02 `structure_frame` · 도번 · 도면명). 고치기는 M02 · B08 — 여기는 읽기만.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
@@ -38,6 +40,8 @@ def structure_sheets(project_root: Path) -> list[dict[str, Any]]:
|
||||
if "도면" not in copy["부분"] or not item.get("key"):
|
||||
continue
|
||||
key = snapshot_folder_name(item)
|
||||
if item.get("spec"): # 규격 열(704-4) — 열 이름은 아무 글이라 id 에는 해시만
|
||||
key += f"-s{hashlib.sha1(str(item['spec']).encode()).hexdigest()[:8]}"
|
||||
if key in docs:
|
||||
continue
|
||||
doc = read_json(snapshot.structures_dir(project_root) / copy["폴더"] / "도면.json")
|
||||
|
||||
@@ -86,10 +86,11 @@ def now_text() -> str:
|
||||
|
||||
|
||||
def same_item(manifest: Any, item: dict[str, Any]) -> bool:
|
||||
"""같은 항목 · 같은 규격 열(704-4 · 열이 바뀌면 다른 사본 · 덮개는 폴더 열쇠라 그대로 남음)."""
|
||||
origin = manifest.get("원본") if isinstance(manifest, dict) else None
|
||||
if not isinstance(origin, dict):
|
||||
return False
|
||||
return all(origin.get(k) == item.get(k) for k in ("layer", "owner", "key"))
|
||||
return all(origin.get(k) == item.get(k) for k in ("layer", "owner", "key", "spec"))
|
||||
|
||||
|
||||
def origin_of(item: dict[str, Any]) -> dict[str, Any]:
|
||||
@@ -142,6 +143,9 @@ def write_snapshot(target: Path, item: dict[str, Any], owner_id: str, extra: dic
|
||||
atomic_write_json(target / name, content)
|
||||
stamp = now_text()
|
||||
source = version_of(source_files(files))
|
||||
spec = ((files.get(LABOR_FILE) or {}).get("줄") or {}).get("규격") # 굳힌 규격 열(704-4)
|
||||
if isinstance(spec, dict):
|
||||
extra = {**extra, "규격": {"이름": spec.get("이름"), "열": spec.get("기본")}}
|
||||
manifest = {
|
||||
"원본": origin_of(item),
|
||||
"판": source,
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
모으는 것 = `{파일 이름: 내용}`:
|
||||
`집계.json` SS 줄 + 열 정의 · `도면.json` SD · `면적.json` SA ·
|
||||
`일위대가.json` = 층 `labor/<열id>.json` 문서(39-1c · 없으면 옛 UA 조합 줄)
|
||||
`일위대가.json` = 층 `labor/<열id>.json` 문서(39-1c · 없으면 옛 UA 조합 줄 ·
|
||||
규격 열 = summary_item `spec` 열 수량을 손으로 굳힘 · 704-4)
|
||||
+ 담은 로직 줄(M01 시스템) + 알약(가져온 때 그대로) +
|
||||
참조사본(단가승격 · 사전조합을 값으로 굳힘) ·
|
||||
`단가.json` 계산에 닿는 마스터 줄 전부(담은 로직 + 일위대가 자재 MT)
|
||||
@@ -146,7 +147,9 @@ def build_source(item: dict[str, Any]) -> dict[str, Any]:
|
||||
files[name] = part
|
||||
doc = read_json(root / LABOR_KIND / f"{column_id}.json") if column_id else None
|
||||
if isinstance(doc, dict):
|
||||
labor = labor_doc_rows(doc)
|
||||
from M02_MasterTemplete import M02_Labor_Spec as labor_spec
|
||||
|
||||
labor = labor_doc_rows(labor_spec.freeze(doc, item.get("spec"))) # 고른 규격 열(704-4)
|
||||
else:
|
||||
labor = labor_rows(str(row["일위대가"])) if row.get("일위대가") else None
|
||||
if labor is not None:
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
분모 = 그 문서 단위 · 배 1 · 알약 키 = `ref_id` · 같은 참조 두 번 금지 · 위 층만.
|
||||
짝(CLAUDE.md 5장 ①): 수량 · 반영 · 합 `settle` ↔ `M02_MasterTemplete_Labor_Calc.ts` —
|
||||
거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`.
|
||||
규격 열(704-4) = 문서 `규격?` 칸 · 열마다 줄 수량(`M02_Labor_Spec` · settle `col`) · 계산값 `규격합`.
|
||||
로직 자재(103-1) = 담은 로직 맨 위 층 호표 재료 줄(`logic_mats` · 줄마다 `재료`) ·
|
||||
고름 = `{호표 차례: MT키}`(엔진 `choosing` 그대로) · 금액은 로직 재료비 안이라 합 · 알약에 안 더함(보기만) · 사용자 자재와
|
||||
같은 품목 두 번은 [저장] 때 400.
|
||||
@@ -42,6 +43,7 @@ from M01_MasterData import M01_MasterData_Store_Combo as combo_store
|
||||
from M02_MasterTemplete import M02_Labor_Fixed as fixed
|
||||
from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_Labor_Spec as spec
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_Material_Own as own
|
||||
|
||||
@@ -192,7 +194,11 @@ def clean(doc: Any) -> dict[str, Any]:
|
||||
}
|
||||
if doc.get("일위대가번"):
|
||||
out["일위대가번"] = doc["일위대가번"]
|
||||
return out
|
||||
try: # 규격 열(704-4 · `M02_Labor_Spec`) — 없으면 칸 없음(옛 문서)
|
||||
cols = spec.clean(doc.get("규격"), {spec.line_key(x) for x in [*lines, *mats]})
|
||||
except spec.SpecError as e:
|
||||
raise _bad(str(e)) from e
|
||||
return {**out, "규격": cols} if cols else out
|
||||
|
||||
|
||||
def _alias(alias: Any, where: str) -> str | None:
|
||||
@@ -424,9 +430,10 @@ def line_id(one: dict) -> Any:
|
||||
return refs.ref_id(one["참조"]) if _is_ref(one) else one.get("로직")
|
||||
|
||||
|
||||
def settle(result: dict, doc: dict, given: dict[str, Any]) -> dict[str, Any]:
|
||||
def settle(result: dict, doc: dict, given: dict[str, Any], col: Any = None) -> dict[str, Any]:
|
||||
"""기준이 선 계산 답에 수량 · 반영 · 합을 채움 — 수량만 바뀌면 이것만 다시
|
||||
(짝: `Labor_Calc.ts` `settle` · 자재 = 재료비)."""
|
||||
(짝: `Labor_Calc.ts` `settle` · 자재 = 재료비 · `col` = 규격 열 · 없으면 기본 · 704-4)."""
|
||||
doc = spec.at(doc, col)
|
||||
have = {str(k): v for k, raw in given.items() if (v := _number(raw)) is not None}
|
||||
hands = {line_id(one): one.get("수량") for one in doc.get("담은로직") or []}
|
||||
mhands = {one.get("코드"): one.get("수량") for one in doc.get("자재") or []}
|
||||
@@ -658,7 +665,14 @@ def server(doc: Any) -> dict[str, Any]:
|
||||
got = clean(doc)
|
||||
out = calc(got)
|
||||
no_twice(got, out)
|
||||
return {**got, "계산값": out}
|
||||
return {**got, "계산값": {**out, **spec_sums(got, out)}}
|
||||
|
||||
|
||||
def spec_sums(doc: dict, out: dict) -> dict[str, Any]:
|
||||
"""규격 열마다 반영합(704-4 · 고르기 창 열 단추) `{규격합: {열: 합}}` — 규격 없음 = 빈 것."""
|
||||
cols = (doc.get("규격") or {}).get("열") or []
|
||||
have = spec.given_of(out)
|
||||
return {"규격합": {c: settle(out, doc, have, c)["반영합"] for c in cols}} if cols else {}
|
||||
|
||||
|
||||
def no_twice(got: dict, out: dict) -> None:
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
"""일위대가 규격 열(PLAN 704-4) — 문서 `규격` 칸 검사 · 열 끼우기 · 사본 굳히기 · 쓰는 사본 찾기.
|
||||
|
||||
규격 = `{이름: "H", 열: ["1.5", "2.0"], 기본: "2.0", 수량: {줄 id: {열: 수}}}` —
|
||||
줄 id = 로직 키 · 참조 `ref_id`(`M02_Labor_Doc.line_id`) · 자재 코드.
|
||||
규격 수량이 있는 줄 = 고른 열 값이 `{손}` 자리 · 없는 줄 · 빈 열 = 문서 `수량` 그대로(공통).
|
||||
규격 없음 = 옛 문서(열 하나 · 셈 그대로 · 옮김 없음).
|
||||
짝(CLAUDE.md 5장 ①): `at` ↔ `M02_MasterTemplete_Labor_Calc.ts` `atSpec` —
|
||||
거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`.
|
||||
사본(`M02_Item_Source.build_source`) = 설계가 고른 열(summary_item `spec`)을 `{손}` 으로 굳히고
|
||||
`규격` 은 그 열 하나만 남김(`freeze`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
|
||||
NAME_MAX = 20 # 규격 이름 · 열 이름 글자 수 상한 — 화면 칸과 같음
|
||||
COLS_MAX = 20
|
||||
|
||||
|
||||
class SpecError(ValueError):
|
||||
"""규격 칸 꼴이 틀림 — 부르는 쪽이 400 으로."""
|
||||
|
||||
|
||||
def _num(raw: Any) -> int | float | None:
|
||||
if raw is None or isinstance(raw, bool):
|
||||
return None
|
||||
try:
|
||||
got = Decimal(str(raw).replace(",", "").strip())
|
||||
except InvalidOperation:
|
||||
return None
|
||||
if not got.is_finite():
|
||||
return None
|
||||
return int(got) if got == got.to_integral_value() else float(got)
|
||||
|
||||
|
||||
def _text(raw: Any, what: str) -> str:
|
||||
text = str(raw).strip() if isinstance(raw, (str, int, float)) and raw is not True else ""
|
||||
if not text or len(text) > NAME_MAX:
|
||||
raise SpecError(f"규격 {what}은 1~{NAME_MAX}자 글")
|
||||
return text
|
||||
|
||||
|
||||
def line_key(one: dict) -> Any:
|
||||
"""줄 id — 참조 줄 = `ref_id` · 로직 줄 = 로직 키 · 자재 줄 = 코드."""
|
||||
if isinstance(one.get("참조"), dict):
|
||||
return refs.ref_id(one["참조"])
|
||||
return one.get("로직") or one.get("코드")
|
||||
|
||||
|
||||
def clean(raw: Any, ids: set[Any]) -> dict[str, Any] | None:
|
||||
"""받은 `규격` → 저장 꼴 — 열이 없으면 None(규격 없음) · 이름 · 열 이름이 비거나 겹치면 ·
|
||||
수량이 수 아니면 SpecError · 없는 줄 · 없는 열 수량은 버림(지운 줄)."""
|
||||
if raw is None:
|
||||
return None
|
||||
if not isinstance(raw, dict):
|
||||
raise SpecError("규격은 묶음")
|
||||
cols: list[str] = []
|
||||
for one in raw.get("열") or []:
|
||||
col = _text(one, "열 이름")
|
||||
if col in cols:
|
||||
raise SpecError(f"같은 규격 열 두 번 「{col}」")
|
||||
cols.append(col)
|
||||
if not cols:
|
||||
return None
|
||||
if len(cols) > COLS_MAX:
|
||||
raise SpecError(f"규격 열은 {COLS_MAX}개까지")
|
||||
qty: dict[str, dict[str, int | float]] = {}
|
||||
for lid, row in (raw.get("수량") or {}).items():
|
||||
if lid not in ids or not isinstance(row, dict):
|
||||
continue
|
||||
kept = {}
|
||||
for col, value in row.items():
|
||||
if col not in cols or value in (None, ""):
|
||||
continue
|
||||
got = _num(value)
|
||||
if got is None:
|
||||
raise SpecError(f"규격 수량 「{lid} · {col}」 은 수")
|
||||
kept[col] = got
|
||||
if kept:
|
||||
qty[str(lid)] = kept
|
||||
return {
|
||||
"이름": _text(raw.get("이름"), "이름"),
|
||||
"열": cols,
|
||||
"기본": raw.get("기본") if raw.get("기본") in cols else cols[0],
|
||||
"수량": qty,
|
||||
}
|
||||
|
||||
|
||||
def column(doc: Any, col: Any = None) -> str | None:
|
||||
"""쓸 열 — 고른 열이 규격에 있으면 그것 · 없으면 기본 · 규격 없음 = None."""
|
||||
spec = doc.get("규격") if isinstance(doc, dict) else None
|
||||
if not isinstance(spec, dict) or not spec.get("열"):
|
||||
return None
|
||||
return col if col in spec["열"] else spec.get("기본") or spec["열"][0]
|
||||
|
||||
|
||||
def at(doc: dict, col: Any = None) -> dict:
|
||||
"""열 끼우기 — 그 열 규격 수량이 있는 줄 · 자재의 `수량` 을 `{손: 값}` 으로
|
||||
(규격 없음 = 그대로)."""
|
||||
use = column(doc, col)
|
||||
if use is None:
|
||||
return doc
|
||||
qty = doc["규격"].get("수량") or {}
|
||||
|
||||
def put(one: Any) -> Any:
|
||||
if not isinstance(one, dict):
|
||||
return one
|
||||
value = (qty.get(line_key(one)) or {}).get(use)
|
||||
return {**one, "수량": {"손": value}} if value is not None else one
|
||||
|
||||
return {
|
||||
**doc,
|
||||
"담은로직": [put(one) for one in doc.get("담은로직") or []],
|
||||
"자재": [put(one) for one in doc.get("자재") or []],
|
||||
}
|
||||
|
||||
|
||||
def given_of(result: dict) -> dict[str, Any]:
|
||||
"""셈 답에서 칸 값 되살림 `{알약: 수량}` — 열마다 다시 셀 때 면적 칸 값
|
||||
(문서 · 층을 다시 안 읽음)."""
|
||||
rows = [*(result.get("줄") or []), *(result.get("자재") or [])]
|
||||
return {r["알약"]: r["수량"] for r in rows if r.get("수량출처") == "칸" and r.get("알약")}
|
||||
|
||||
|
||||
def freeze(doc: dict, col: Any = None) -> dict:
|
||||
"""사본 굳히기 — 고른 열(없거나 지운 열 = 기본)을 `{손}` 으로 끼우고 규격은 그 열 하나만."""
|
||||
use = column(doc, col)
|
||||
if use is None:
|
||||
return doc
|
||||
got = {k: v for k, v in at(doc, use).items() if k != "계산값"} # 기본 열 계산값은 버림
|
||||
return {**got, "규격": {"이름": doc["규격"].get("이름"), "열": [use], "기본": use, "수량": {}}}
|
||||
|
||||
|
||||
def users(layer_root: Path, keys: set[str], cols: set[str]) -> list[str]:
|
||||
"""그 층 항목(`keys`) · 그 규격 열(`cols`)을 쓰는 프로젝트 사본 —
|
||||
`프로젝트 폴더/사본 폴더` 목록.
|
||||
|
||||
ponytail: 저장소 프로젝트 사본 manifest 를 통째로 훑음 — 열 지우기 · 이름 바꾸기 [저장] 때만 ·
|
||||
느리면 사본 색인을 둠."""
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
from M02_MasterTemplete.M02_Item_Source import layer_root as root_of
|
||||
from M02_MasterTemplete.M02_Item_Source import read_json
|
||||
|
||||
found = []
|
||||
here = Path(layer_root).resolve()
|
||||
for path in layers.storage_root().glob("*/*/*/templates/structures/*/manifest.json"):
|
||||
origin = (read_json(path) or {}).get("원본")
|
||||
if not isinstance(origin, dict) or origin.get("key") not in keys:
|
||||
continue
|
||||
if origin.get("spec") not in cols:
|
||||
continue
|
||||
try:
|
||||
same = root_of(origin).resolve() == here
|
||||
except (KeyError, ValueError, TypeError):
|
||||
same = False
|
||||
if same:
|
||||
found.append(f"{path.parents[3].name}/{path.parent.name}")
|
||||
return found
|
||||
|
||||
|
||||
def guard(path: Path, new: Any, column: str) -> None:
|
||||
"""[저장] 때 빠진 규격 열(지움 · 이름 바꿈)을 쓰는 프로젝트 사본이 있으면 409 —
|
||||
지금 층(`store.root`) 그 열 항목(SS 키 · 옛 키) 기준."""
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete.M02_Item_Source import read_json
|
||||
|
||||
old = (read_json(path) or {}).get("규격") if path.is_file() else None
|
||||
gone = set((old or {}).get("열") or []) - set(((new or {}).get("규격") or {}).get("열") or [])
|
||||
if not gone:
|
||||
return
|
||||
rows = [r for r in store.read_combos().get("줄", []) if str(r.get("열")) == column]
|
||||
keys = {str(k) for r in rows for k in [r.get("키"), *(r.get("옛키") or [])] if k}
|
||||
used = users(store.root(), keys, gone)
|
||||
if used:
|
||||
raise store.StoreError(
|
||||
409,
|
||||
f"규격 열 「{', '.join(sorted(gone))}」 을 쓰는 프로젝트 사본 {len(used)}개 — 지울 수 없음 "
|
||||
f"({', '.join(used[:3])}{' …' if len(used) > 3 else ''})",
|
||||
)
|
||||
@@ -119,6 +119,14 @@ def _list_extras(folder: Path, col: str) -> dict[str, Any]:
|
||||
"기준": _number(calc.get("기준합")), # 수량 없는 일위대가 = 단가 합(39-8 B2)
|
||||
"단위": doc.get("단위"), # 일위대가 문서 단위(구조물 열 단위 아님 · B3)
|
||||
}
|
||||
spec = doc.get("규격") if isinstance(doc.get("규격"), dict) else None
|
||||
if spec and spec.get("열"): # 규격 열(704-4) — 고르기 창 열 단추 · 열마다 합계
|
||||
sums = calc.get("규격합") or {}
|
||||
labor["규격"] = {
|
||||
"이름": spec.get("이름"),
|
||||
"열": [{"이름": c, "합계": _number(sums.get(c))} for c in spec["열"]],
|
||||
"기본": spec.get("기본"),
|
||||
}
|
||||
return {"수정일": stamps, "labor": labor}
|
||||
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
type LogicSummary,
|
||||
type SubBrief,
|
||||
} from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||||
import type { LaborCalc, LaborRef } from "./M02_MasterTemplete_Labor_Calc";
|
||||
import type { LaborCalc, LaborRef, LaborSpec } from "./M02_MasterTemplete_Labor_Calc";
|
||||
import {
|
||||
call,
|
||||
getActiveLayer,
|
||||
@@ -238,6 +238,8 @@ export interface LaborDoc {
|
||||
담은로직: LaborRow[];
|
||||
자재: LaborMat[];
|
||||
계산값?: unknown;
|
||||
/** 규격 열(704-4 · `Labor_Spec`) — 없으면 옛 문서(열 하나) */
|
||||
규격?: LaborSpec | null;
|
||||
}
|
||||
/** 받은 문서를 화면 모양으로 — 빈 칸 · 옛 모양도 받음(빈 일위대가) */
|
||||
export function normalizeLabor(doc: unknown, column: string): LaborDoc {
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
* 수량(손이 칸보다 앞섬 · 칸 = 알약 키 값) · 반영 = 기준 × 수량 ÷ 배 · 자재 = 재료비만.
|
||||
* 별도계상 자재(222-1 · 빈 칸 = 기본 별도계상) = 반영은 보이되 합에 안 더함(짝 `apart`).
|
||||
* 참조 줄(단가승격 · 사전조합 · 50-3)은 `lineId` = 「참조|층|주인|키」(짝 `M02_Labor_Ref.ref_id`) ·
|
||||
* 고정 줄(705-1)은 「고정|이름|규격|단위」(짝 `M02_Labor_Fixed.fixed_id`) · 기준 = 서버가 낸 줄 단가. */
|
||||
* 고정 줄(705-1)은 「고정|이름|규격|단위」(짝 `M02_Labor_Fixed.fixed_id`) · 기준 = 서버가 낸 줄 단가.
|
||||
* 규격 열(704-4) = 문서 `규격` · 그 열 수량이 있는 줄 = `{손}` 자리(`atSpec` · 짝 `M02_Labor_Spec.at`). */
|
||||
|
||||
import type { CalcLine as LogicLine } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||||
import type { LogicMat } from "./M02_Labor_LogicMats_Model";
|
||||
@@ -91,10 +92,19 @@ export interface LaborCalc {
|
||||
반영합?: Money;
|
||||
}
|
||||
|
||||
/** 수량만 보는 문서 조각 — 담은 로직 · 자재의 손 값 */
|
||||
/** 규격 열(704-4) — 이름(H · Φ) · 열(화면 차례) · 기본 · 줄 id 마다 열 수량 */
|
||||
export interface LaborSpec {
|
||||
이름: string;
|
||||
열: string[];
|
||||
기본: string;
|
||||
수량: Record<string, Record<string, number>>;
|
||||
}
|
||||
|
||||
/** 수량만 보는 문서 조각 — 담은 로직 · 자재의 손 값 · 규격 열 */
|
||||
export interface LaborQtyDoc {
|
||||
담은로직?: { 로직?: string; 참조?: LaborRef; 고정?: LaborFixed; 수량?: Qty }[];
|
||||
자재?: { 코드: string; 수량?: Qty; 별도계상?: string }[];
|
||||
규격?: LaborSpec | null;
|
||||
}
|
||||
|
||||
function num(raw: unknown): number | null {
|
||||
@@ -116,6 +126,37 @@ export function lineId(one: {
|
||||
return one.로직;
|
||||
}
|
||||
|
||||
/** 쓸 규격 열 — 고른 열이 있으면 그것 · 없으면 기본 · 규격 없음 = null(짝 `M02_Labor_Spec.column`) */
|
||||
export function specColumn(doc: LaborQtyDoc, col?: string | null): string | null {
|
||||
const cols = doc.규격?.열 ?? [];
|
||||
if (!cols.length) return null;
|
||||
return col && cols.includes(col) ? col : doc.규격?.기본 || cols[0];
|
||||
}
|
||||
|
||||
/** 열 끼우기 — 그 열 규격 수량이 있는 줄 · 자재의 수량을 `{손}` 으로(짝 `M02_Labor_Spec.at`) */
|
||||
export function atSpec<D extends LaborQtyDoc>(doc: D, col?: string | null): D {
|
||||
const use = specColumn(doc, col);
|
||||
if (use === null) return doc;
|
||||
const qty = doc.규격?.수량 ?? {};
|
||||
const put = <T extends { 수량?: Qty }>(one: T, id: string | undefined): T => {
|
||||
const v = id !== undefined ? qty[id]?.[use] : undefined;
|
||||
return v !== undefined && v !== null ? { ...one, 수량: { 손: v } } : one;
|
||||
};
|
||||
return {
|
||||
...doc,
|
||||
담은로직: (doc.담은로직 ?? []).map((one) => put(one, lineId(one))),
|
||||
자재: (doc.자재 ?? []).map((one) => put(one, one.코드)),
|
||||
};
|
||||
}
|
||||
|
||||
/** 셈 답에서 칸 값 되살림 `{알약: 수량}` — 열마다 다시 셀 때(짝 `M02_Labor_Spec.given_of`) */
|
||||
export function givenOf(result: LaborCalc): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
for (const r of [...result.줄, ...result.자재])
|
||||
if (r.수량출처 === "칸" && r.알약 && r.수량 != null) out[r.알약] = r.수량;
|
||||
return out;
|
||||
}
|
||||
|
||||
function zero(): Money {
|
||||
return { 노무비: 0, 재료비: 0, 경비: 0, 계: 0 };
|
||||
}
|
||||
@@ -139,12 +180,15 @@ export function reflect(base: Money, qty: number | null, times: number): Money |
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 기준이 선 계산 답 + 문서 손 값 + 알약 값(`{키: 값}`) → 수량 · 반영 · 합을 새로 채운 답 */
|
||||
/** 기준이 선 계산 답 + 문서 손 값 + 알약 값(`{키: 값}`) → 수량 · 반영 · 합을 새로 채운 답 ·
|
||||
* `col` = 규격 열(없으면 기본 · 704-4) */
|
||||
export function settle(
|
||||
result: LaborCalc,
|
||||
doc: LaborQtyDoc,
|
||||
whole: LaborQtyDoc,
|
||||
given: Record<string, unknown>,
|
||||
col?: string | null,
|
||||
): LaborCalc {
|
||||
const doc = atSpec(whole, col);
|
||||
const have: Record<string, number> = {};
|
||||
for (const [k, raw] of Object.entries(given)) {
|
||||
const v = num(raw);
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* · 서고 모드(50-3 · `mode`) = 사전조합 — 이름 칸 · 단가산출 로직 표 · 자재목록(175-1) · 원문(면적 · 다이어그램 없음) ·
|
||||
* 정보 칸 구분 · 상세구분(51-12 · 고름) · 일위대가 상세와 같은 [로직 추가] · 표 · 동작(51-11) · 수량 손 입력(기본 1)
|
||||
* · 로직 줄 구분 드롭다운(134-1 · 단가산출 | 단가승격 · 서고 모드엔 없음)
|
||||
* · 맨 위 엑셀 꼴 격자(704-3 · `Labor_Grid`) · 읽기만 카드는 격자만
|
||||
* · 맨 위 엑셀 꼴 격자(704-3 · `Labor_Grid`) · 읽기만 카드는 격자만 · 규격 표(704-4 · `Labor_Spec`)
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
@@ -218,7 +218,7 @@ export function mountLaborDetail(
|
||||
),
|
||||
);
|
||||
const draw = (): void => {
|
||||
gridHost.replaceChildren(laborGrid(cur, calc ?? last, logics));
|
||||
gridHost.replaceChildren(laborGrid(cur, calc ?? last, logics, edit && !lib && changed));
|
||||
drawLogic();
|
||||
matHost.replaceChildren(
|
||||
matTable(
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* 로직 줄 = 이름(별칭 앞섬) · 규격(옵션 값 · 켜쌓기) · 수량 · 분모 · 단가 = 기준 ÷ 배 · 금액 = 반영 · 꼬리표 품셈
|
||||
* 자재 줄 = 이름 · 규격 · 수량 · 단위 · 재료비 단가 = 기준단가 · 금액 = 반영 · 별도계상은 비고 「합계 밖」
|
||||
* 빈 줄(금액 모름 · 수량 없음) = 합계 줄 「빈 줄 n」 붉게 — 701-4 경고와 같은 줄
|
||||
* 규격 열(704-4 · `Labor_Spec`) = 수량 칸 열마다 · 고칠 때 격자 위 규격 줄 + 수량 칸 고침
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
@@ -20,6 +21,7 @@ import type { LaborCalc, LaborCost, LaborDoc } from "./M02_MasterTemplete_Labor_
|
||||
import { lineId } from "./M02_MasterTemplete_Labor_Calc";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import { valueLine } from "./M02_Labor_Values_Model";
|
||||
import { specBar, specCells, withSpecs, type SpecEdit } from "./M02_MasterTemplete_Labor_Spec";
|
||||
|
||||
const money = (m: LaborCost | null | undefined, times = 1): HopyoMoney =>
|
||||
m
|
||||
@@ -77,7 +79,7 @@ export function laborBlock(
|
||||
};
|
||||
});
|
||||
const lines = [...logicLines, ...matLines];
|
||||
return {
|
||||
const block: HopyoBlock = {
|
||||
key: doc.열,
|
||||
name: doc.일위대가번 || doc.열,
|
||||
unit: doc.단위 ?? "",
|
||||
@@ -85,11 +87,20 @@ export function laborBlock(
|
||||
lines,
|
||||
...(calc ? {} : { blank: 0 }),
|
||||
};
|
||||
return withSpecs(block, doc, calc);
|
||||
}
|
||||
|
||||
/** 격자 하나(읽기 전용) */
|
||||
/** 격자 하나 — `edit` 있으면 위에 규격 줄 · 규격 수량 칸 고침(704-4) */
|
||||
export const laborGrid = (
|
||||
doc: LaborDoc,
|
||||
calc: LaborCalc | null,
|
||||
logics: ReadonlyMap<string, ItemLogic>,
|
||||
): HTMLElement => createHopyoGrid([laborBlock(doc, calc, logics)]).root;
|
||||
edit?: SpecEdit | false,
|
||||
): HTMLElement => {
|
||||
const block = laborBlock(doc, calc, logics);
|
||||
if (!edit) return createHopyoGrid([block]).root;
|
||||
const grid = createHopyoGrid([block], specCells(block, doc, edit)).root;
|
||||
const box = document.createElement("div");
|
||||
box.append(specBar(doc, calc, edit), grid);
|
||||
return box;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Labor_Spec.ts
|
||||
* 일위대가 규격 표(PLAN 704-4) — 704-3 격자에 규격 열(H=1.5 | 2.0 …)을 폄 · 고칠 때 격자 위 규격 줄.
|
||||
* 문서 `규격` = {이름, 열, 기본, 수량: {줄 id: {열: 수}}}(짝 `M02_Labor_Spec.py`) · 화면 캐시만(저장은 [저장])
|
||||
* 규격 줄 = 규격 이름 · 열마다 이름 칸 · [기본] · [×] · [+ 열](새 열 수량 = 지금 기본 열 값 그대로)
|
||||
* 격자 수량 칸(열마다) 고침 = 그 줄 · 그 열 수량(빈 글 = 공통 수량을 따름)
|
||||
* 열마다 수량 · 1 단위 합계 = 짝 셈 `settle(…, 열)`(서버 안 부름) · 금액 칸 = 기본 열
|
||||
* 쓰는 사본이 있는 열 지움 · 이름 바꿈은 [저장] 때 서버가 막음(409 까닭 그대로)
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, createInputField, el } from "@ui/ui_template_elements";
|
||||
import type { DataTableOptions } from "@ui/ui_template_data_table";
|
||||
import { hopyoGrid, hopyoSpecKey, type HopyoBlock } from "@ui/ui_template_hopyo_grid";
|
||||
import { createToolbar } from "@ui/ui_template_toolbar";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import type { LaborCalc, LaborDoc } from "./M02_MasterTemplete_Labor_Api";
|
||||
import { givenOf, lineId, settle, specColumn } from "./M02_MasterTemplete_Labor_Calc";
|
||||
|
||||
/** 고칠 때 — 규격이 바뀌면 `true`(수량만 · 기준 그대로 · 짝 셈으로 바로) */
|
||||
export type SpecEdit = (qtyOnly: boolean) => void;
|
||||
|
||||
const TEXT = {
|
||||
none: "규격 열 없음 — [+ 열] = H 같은 규격마다 줄 수량만 다른 열",
|
||||
add: "+ 열",
|
||||
name: "규격 이름",
|
||||
base: "기본",
|
||||
remove: "열 지움",
|
||||
first: "1",
|
||||
next: "새 열",
|
||||
};
|
||||
|
||||
/** 줄 id 차례 — 격자 줄 차례(로직 줄 → 자재 줄)와 같음 */
|
||||
const lineIds = (doc: LaborDoc): string[] => [
|
||||
...doc.담은로직.map((r) => lineId(r) ?? ""),
|
||||
...doc.자재.map((m) => m.코드),
|
||||
];
|
||||
|
||||
/** 열마다 셈 → 블록 `specs` · 줄 `qtys`(규격 없음 = 그대로) */
|
||||
export function withSpecs(block: HopyoBlock, doc: LaborDoc, calc: LaborCalc | null): HopyoBlock {
|
||||
const spec = doc.규격;
|
||||
if (!spec?.열.length) return block;
|
||||
const per = calc ? spec.열.map((c) => settle(calc, doc, givenOf(calc), c)) : [];
|
||||
const ids = lineIds(doc);
|
||||
const logics = doc.담은로직.length;
|
||||
const qtyOf = (p: LaborCalc, n: number): number | null =>
|
||||
(n < logics
|
||||
? p.줄.find((l) => lineId(l) === ids[n])?.수량
|
||||
: p.자재.find((m) => m.코드 === ids[n])?.수량) ?? null;
|
||||
return {
|
||||
...block,
|
||||
specs: {
|
||||
name: spec.이름,
|
||||
cols: spec.열,
|
||||
pick: specColumn(doc),
|
||||
totals: per.map((p) => p.반영합?.계 ?? null),
|
||||
},
|
||||
lines: block.lines.map((ln, n) => ({ ...ln, qtys: per.map((p) => qtyOf(p, n)) })),
|
||||
};
|
||||
}
|
||||
|
||||
/** 격자 수량 칸 고침(열마다) — 공용 표 옵션 덧 */
|
||||
export function specCells(
|
||||
block: HopyoBlock,
|
||||
doc: LaborDoc,
|
||||
edit: SpecEdit,
|
||||
): Partial<DataTableOptions> {
|
||||
const spec = doc.규격;
|
||||
if (!spec?.열.length) return {};
|
||||
const g = hopyoGrid([block]);
|
||||
const ids = lineIds(doc);
|
||||
const col = (key: string): string | undefined => spec.열.find((_, n) => hopyoSpecKey(n) === key);
|
||||
return {
|
||||
editable: (i, key) => g.at.has(i) && col(key) !== undefined,
|
||||
onEdit: (i, key, text) => {
|
||||
const id = ids[g.at.get(i)?.[1] ?? -1];
|
||||
const name = col(key);
|
||||
if (id === undefined || name === undefined) return;
|
||||
const v = Number(text.replace(/,/g, "").trim());
|
||||
const row = { ...(spec.수량[id] ?? {}) };
|
||||
if (text.trim() === "" || !Number.isFinite(v)) delete row[name];
|
||||
else row[name] = v;
|
||||
if (Object.keys(row).length) spec.수량[id] = row;
|
||||
else delete spec.수량[id];
|
||||
edit(true);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 새 열 이름 — 겹치면 뒤에 번호 */
|
||||
const fresh = (cols: string[], want: string): string => {
|
||||
let out = want;
|
||||
for (let n = 2; cols.includes(out); n++) out = `${want} ${n}`;
|
||||
return out;
|
||||
};
|
||||
|
||||
/** [+ 열] — 규격이 없으면 지금 수량 열(1) + 새 열 · 새 열 수량 = 기본 열 손 값 그대로 */
|
||||
function addColumn(doc: LaborDoc, calc: LaborCalc | null): void {
|
||||
const spec = (doc.규격 ??= { 이름: "H", 열: [TEXT.first], 기본: TEXT.first, 수량: {} });
|
||||
const col = fresh(spec.열, TEXT.next);
|
||||
const base = specColumn(doc) ?? spec.열[0];
|
||||
spec.열.push(col);
|
||||
const ids = lineIds(doc);
|
||||
const logics = doc.담은로직.length;
|
||||
ids.forEach((id, n) => {
|
||||
const own = spec.수량[id]?.[base];
|
||||
const line =
|
||||
n < logics ? calc?.줄.find((l) => lineId(l) === id) : calc?.자재.find((m) => m.코드 === id);
|
||||
const v = own ?? (line?.수량출처 === "손" ? line.수량 : null);
|
||||
if (v != null) spec.수량[id] = { ...(spec.수량[id] ?? {}), [col]: v };
|
||||
});
|
||||
}
|
||||
|
||||
function renameColumn(doc: LaborDoc, from: string, to: string): boolean {
|
||||
const spec = doc.규격;
|
||||
const name = to.trim();
|
||||
if (!spec || !name || name.length > 20 || name === from || spec.열.includes(name)) return false;
|
||||
spec.열 = spec.열.map((c) => (c === from ? name : c));
|
||||
if (spec.기본 === from) spec.기본 = name;
|
||||
for (const row of Object.values(spec.수량))
|
||||
if (from in row) {
|
||||
row[name] = row[from];
|
||||
delete row[from];
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function removeColumn(doc: LaborDoc, col: string): void {
|
||||
const spec = doc.규격;
|
||||
if (!spec) return;
|
||||
spec.열 = spec.열.filter((c) => c !== col);
|
||||
for (const [id, row] of Object.entries(spec.수량)) {
|
||||
delete row[col];
|
||||
if (!Object.keys(row).length) delete spec.수량[id];
|
||||
}
|
||||
if (!spec.열.length)
|
||||
doc.규격 = null; // 마지막 열 = 규격 없음(옛 문서 꼴)
|
||||
else if (spec.기본 === col) spec.기본 = spec.열[0];
|
||||
}
|
||||
|
||||
/** 격자 위 규격 줄(고칠 때만) */
|
||||
export function specBar(doc: LaborDoc, calc: LaborCalc | null, edit: SpecEdit): HTMLElement {
|
||||
const add = createButton({
|
||||
label: TEXT.add,
|
||||
variant: "ghost",
|
||||
onClick: () => {
|
||||
addColumn(doc, calc);
|
||||
edit(true);
|
||||
},
|
||||
});
|
||||
const spec = doc.규격;
|
||||
if (!spec?.열.length)
|
||||
return createToolbar({ items: [createText(TEXT.none, "muted"), add], label: TEXT.name });
|
||||
const name = createInputField({ value: spec.이름, compact: true, placeholder: TEXT.name });
|
||||
name.input.title = TEXT.name;
|
||||
name.root.style.width = "6em"; // 짧은 글 칸 — 열 이름 칸도 같은 폭(카드 한 줄에)
|
||||
name.input.addEventListener("change", () => {
|
||||
const v = name.input.value.trim();
|
||||
if (v && v.length <= 20) spec.이름 = v;
|
||||
edit(true);
|
||||
});
|
||||
const cols = spec.열.flatMap((col) => {
|
||||
const box = createInputField({ value: col, compact: true });
|
||||
box.input.setAttribute("data-spec-col", col);
|
||||
box.root.style.width = "6em";
|
||||
box.input.addEventListener("change", () => {
|
||||
renameColumn(doc, col, box.input.value);
|
||||
edit(true);
|
||||
});
|
||||
const base = createButton({
|
||||
label: TEXT.base,
|
||||
variant: col === spec.기본 ? "filled" : "ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
spec.기본 = col;
|
||||
edit(true);
|
||||
},
|
||||
});
|
||||
const drop = createButton({
|
||||
label: "×",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
removeColumn(doc, col);
|
||||
edit(true);
|
||||
},
|
||||
});
|
||||
drop.title = TEXT.remove;
|
||||
return [el("span", { children: [box.root] }), base, drop, "divider" as const];
|
||||
});
|
||||
return createToolbar({ items: [name.root, "divider", ...cols, add], label: TEXT.name });
|
||||
}
|
||||
@@ -166,6 +166,10 @@ def write(kind: str, name: str, version: str, doc: Any, screen: bool = False) ->
|
||||
if kind == "table" and name == STRUCTURE_TABLE:
|
||||
doc = tree.normalize_table(doc) # 경로 · 머리 줄 수 · 빈 폴더(표 저장 · 나무 고침 한 줄기)
|
||||
doc = _server(kind, doc)
|
||||
if kind == "labor": # 쓰는 규격 열 지움 막음(704-4)
|
||||
from M02_MasterTemplete import M02_Labor_Spec as labor_spec
|
||||
|
||||
labor_spec.guard(path, doc, name)
|
||||
fresh: list[dict[str, Any]] = []
|
||||
with _LOCK:
|
||||
have = version_of(path.read_bytes()) if path.is_file() else ""
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
"""구조물 집계표 항목 참조 `summary_item` — 모양 검사 · 스냅샷 폴더 이름 (PLAN 21-1).
|
||||
|
||||
summary_item = {layer: "system"|"company"|"personal",
|
||||
owner: null|"{회사}"|"{회사}/{사용자}", key: "SS000004", name: "…"}
|
||||
owner: null|"{회사}"|"{회사}/{사용자}", key: "SS000004", name: "…",
|
||||
spec?: "2.0"}
|
||||
|
||||
`spec`(PLAN 704-4) = 그 항목 일위대가 규격 열 이름 — 없으면 기본 열(옛 기록 · 규격 없는 항목).
|
||||
|
||||
항목 키(PLAN 26-4a) — 마스터 `SS000004` · 회사 `SC{회사}-000001` · 개인 `SP{사용자}-000001`
|
||||
(머리의 번호 = owner 의 회사 · 사용자). 옛 `SS` 키는 어느 층이든 그대로 받음 — 회사 · 개인 층이 새
|
||||
@@ -28,15 +31,17 @@ _OWNER = {
|
||||
"personal": re.compile(r"^\d+/\d+$"),
|
||||
}
|
||||
_NAME_MAX = 200
|
||||
_SPEC_MAX = 20 # 규격 열 이름 상한(`M02_Labor_Spec.NAME_MAX`)
|
||||
|
||||
|
||||
def parse_summary_item(value: Any) -> dict[str, Any] | None:
|
||||
"""모양이 맞으면 `{layer, owner, key, name}` 새 dict · 빈 값은 None · 틀리면 ValueError."""
|
||||
"""모양이 맞으면 `{layer, owner, key, name, spec?}` 새 dict · 빈 값은 None ·
|
||||
틀리면 ValueError."""
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("집계 항목(summary_item)은 객체여야 합니다.")
|
||||
extra = set(value) - {"layer", "owner", "key", "name"}
|
||||
extra = set(value) - {"layer", "owner", "key", "name", "spec"}
|
||||
if extra:
|
||||
raise ValueError(f"집계 항목에 모르는 칸이 있습니다: {', '.join(sorted(extra))}")
|
||||
layer = value.get("layer")
|
||||
@@ -59,7 +64,11 @@ def parse_summary_item(value: Any) -> dict[str, Any] | None:
|
||||
name = value.get("name")
|
||||
if not isinstance(name, str) or not name.strip() or len(name) > _NAME_MAX:
|
||||
raise ValueError(f"집계 항목 name 은 1~{_NAME_MAX}자 글입니다.")
|
||||
return {"layer": layer, "owner": owner, "key": key, "name": name}
|
||||
spec = value.get("spec")
|
||||
if spec is not None and (not isinstance(spec, str) or not 0 < len(spec.strip()) <= _SPEC_MAX):
|
||||
raise ValueError(f"집계 항목 spec(규격 열)은 1~{_SPEC_MAX}자 글입니다.")
|
||||
out = {"layer": layer, "owner": owner, "key": key, "name": name}
|
||||
return {**out, "spec": spec.strip()} if spec is not None else out
|
||||
|
||||
|
||||
#: 옵션 그룹 id(PLAN 46 · `M02_SO_Groups.GROUP_ID` 와 같은 규칙)
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
* 구조물 집계표 항목 참조(PLAN 21-1) — 구조물 · 관 지점이 「어느 집계표 항목」인지 가리키는 값.
|
||||
*
|
||||
* summary_item: { layer: "system"|"company"|"personal", owner: null|"{회사}"|"{회사}/{사용자}",
|
||||
* key: "SS000004", name: "돌기슭막이 메쌓기 1.2m" }
|
||||
* key: "SS000004", name: "돌기슭막이 메쌓기 1.2m", spec?: "2.0" }
|
||||
*
|
||||
* spec(PLAN 704-4) = 그 항목 일위대가 규격 열 이름 — 없으면 기본 열(서버 `parse_summary_item` 과 짝).
|
||||
*
|
||||
* 없으면 null(= 미지정 · 집계표에 안 들어감). 옵션(options) 밖의 칸이다.
|
||||
* import 없음 — 시험이 이 파일만 옮겨 돌림.
|
||||
@@ -16,6 +18,8 @@ export interface SummaryItem {
|
||||
owner: string | null;
|
||||
key: string;
|
||||
name: string;
|
||||
/** 규격 열 이름(704-4) — 없으면 기본 열 */
|
||||
spec?: string;
|
||||
}
|
||||
|
||||
const LAYERS: readonly string[] = ["system", "company", "personal"];
|
||||
@@ -30,7 +34,8 @@ export function isSummaryItem(value: unknown): value is SummaryItem {
|
||||
(item.owner === null || typeof item.owner === "string") &&
|
||||
typeof item.key === "string" &&
|
||||
item.key !== "" &&
|
||||
typeof item.name === "string"
|
||||
typeof item.name === "string" &&
|
||||
(item.spec === undefined || typeof item.spec === "string")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* M02 일위대가 수량 · 반영 · 합(`M02_MasterTemplete_Labor_Calc.ts` `settle`) 거울 헬퍼 —
|
||||
* 그 자리에서 트랜스파일해 Node 로 돌림 · stdin = {답, 문서, 값} · stdout = settle 결과 JSON. */
|
||||
* 그 자리에서 트랜스파일해 Node 로 돌림 · stdin = {답, 문서, 값, 열?} · stdout = settle 결과 JSON. */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
@@ -18,4 +18,4 @@ const box = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(box.exports, box, require);
|
||||
|
||||
const input = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
process.stdout.write(JSON.stringify(box.exports.settle(input.답, input.문서, input.값)));
|
||||
process.stdout.write(JSON.stringify(box.exports.settle(input.답, input.문서, input.값, input.열)));
|
||||
|
||||
@@ -95,6 +95,7 @@ CALC = {"줄": [{"로직": "GF1", "분모": "㎡", "배": 10, "수량": 2.09,
|
||||
|
||||
def test_M02_문서_calc_를_격자_줄로():
|
||||
files = [GRID, "M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts",
|
||||
"M02_MasterTemplete/M02_MasterTemplete_Labor_Spec.ts",
|
||||
"M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts"] # fmt: skip
|
||||
logics = [
|
||||
["GF1", {"이름": "돌쌓기 찰쌓기", "입력": [], "결과단위": "㎡"}],
|
||||
@@ -134,7 +135,7 @@ def test_M02_문서_calc_를_격자_줄로():
|
||||
|
||||
def test_세_곳_같은_부품():
|
||||
detail = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts").read_text("utf-8")
|
||||
assert "laborGrid(cur, calc ?? last, logics)" in detail # 카드 · 상세 = 같은 Labor_Detail
|
||||
assert "laborGrid(cur, calc ?? last, logics, " in detail # 카드 · 상세 = 같은 Labor_Detail
|
||||
m02 = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts").read_text("utf-8")
|
||||
b09 = (ROOT / "B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts").read_text("utf-8")
|
||||
assert '"@ui/ui_template_hopyo_grid"' in m02 and '"@ui/ui_template_hopyo_grid"' in b09
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
"""704-4 규격 열 — 설계 고르기 = 열(summary_item `spec`) · 사본이 고른 열을 굳힘 ·
|
||||
열 바꿈 뒤 B09 덮개 남음 · B07 한 장 판정 = 항목 + 열 · 열 지움 막음(쓰는 사본).
|
||||
임시 저장소(실제 마스터 · storage 안 씀 · M01 읽기만).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Structure import structure_sheets
|
||||
from B09_Estimation.B09_Estimation_Calc_Labor import compute_labor
|
||||
from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs
|
||||
from common_util.common_util_summary_item import parse_summary_item
|
||||
from config import config_system
|
||||
from M02_MasterTemplete import M02_Item_Snapshot as snap
|
||||
from M02_MasterTemplete import M02_Labor_Spec as spec
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
from resources.tester.helper_m02_master_sample import SAMPLE
|
||||
|
||||
MS = {"layer": "system", "owner": None, "key": "SS000022", "name": "돌쌓기"}
|
||||
SPEC = {
|
||||
"이름": "H",
|
||||
"열": ["1.5", "2.5"],
|
||||
"기본": "1.5",
|
||||
"수량": {"GF000219": {"1.5": 2, "2.5": 3}},
|
||||
}
|
||||
LABOR = {
|
||||
"종류": "일위대가",
|
||||
"열": "ms",
|
||||
"단위": "m",
|
||||
"담은로직": [
|
||||
{"로직": "GF000219", "수량": {"손": 2}}, # 메쌓기 — 열마다 수량
|
||||
{"로직": "GF000119", "수량": {"손": 0.5}}, # 노체포설 — 공통
|
||||
],
|
||||
"자재": [],
|
||||
"규격": SPEC,
|
||||
}
|
||||
|
||||
|
||||
# ── summary_item spec ──────────────────────────────────────────────────────
|
||||
def test_summary_item_spec_있음_없음():
|
||||
assert parse_summary_item({**MS, "spec": " 2.5 "}) == {**MS, "spec": "2.5"}
|
||||
assert parse_summary_item(MS) == MS # 옛 기록 = 칸 없음 그대로
|
||||
with pytest.raises(ValueError, match="모르는 칸"):
|
||||
parse_summary_item({**MS, "열": "2.5"})
|
||||
for bad in ("", "x" * 21, 2.5):
|
||||
with pytest.raises(ValueError, match="spec"):
|
||||
parse_summary_item({**MS, "spec": bad})
|
||||
|
||||
|
||||
# ── 굳히기 ─────────────────────────────────────────────────────────────────
|
||||
def test_굳히기_고른_열_지운_열_옛_문서():
|
||||
got = spec.freeze({**LABOR, "계산값": {"x": 1}}, "2.5")
|
||||
assert got["담은로직"][0]["수량"] == {"손": 3} and got["담은로직"][1]["수량"] == {"손": 0.5}
|
||||
assert got["규격"] == {"이름": "H", "열": ["2.5"], "기본": "2.5", "수량": {}}
|
||||
assert "계산값" not in got
|
||||
assert spec.freeze(LABOR, "지운열")["담은로직"][0]["수량"] == {"손": 2} # = 기본
|
||||
old = {k: v for k, v in LABOR.items() if k != "규격"}
|
||||
assert spec.freeze(old, "2.5") is old
|
||||
|
||||
|
||||
# ── 임시 저장소 사본 ───────────────────────────────────────────────────────
|
||||
@pytest.fixture
|
||||
def world(tmp_path: Path, monkeypatch) -> dict[str, Any]:
|
||||
system = tmp_path / "master"
|
||||
system.mkdir()
|
||||
shutil.copy2(SAMPLE / layers.COMBO_FILE, system / layers.COMBO_FILE)
|
||||
for kind in ("table", "structure", "area"):
|
||||
shutil.copytree(SAMPLE / kind, system / kind)
|
||||
(system / "labor").mkdir()
|
||||
(system / "labor" / "ms.json").write_text(json.dumps(LABOR), encoding="utf-8")
|
||||
monkeypatch.setattr(layers, "SYSTEM_ROOT", system)
|
||||
storage = tmp_path / "storage"
|
||||
monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage))
|
||||
root = storage / "3" / "12" / "proj"
|
||||
(root / "B05_Profile/route").mkdir(parents=True)
|
||||
(root / "B04_PreProcess/drainage/edits").mkdir(parents=True)
|
||||
return {"system": system, "root": root}
|
||||
|
||||
|
||||
def _save(root: Path, specs: list[str | None]) -> dict[str, list[str]]:
|
||||
rows = [
|
||||
{
|
||||
"structure_id": f"st{n}",
|
||||
"type_id": "masonry_dry",
|
||||
"placement": "interval",
|
||||
"chainage_m": 85 + n * 100,
|
||||
"start_m": 80 + n * 100,
|
||||
"end_m": 90 + n * 100,
|
||||
"options": {"height_m": 2.0, "length_m": 10},
|
||||
"summary_item": {**MS, **({"spec": s} if s else {})},
|
||||
}
|
||||
for n, s in enumerate(specs)
|
||||
]
|
||||
(root / "B05_Profile/route/structures.json").write_text(
|
||||
json.dumps({"revision": 1, "structures": rows}, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
(root / "B04_PreProcess/drainage/edits/pipe_points.json").write_text(
|
||||
'{"points": []}', encoding="utf-8"
|
||||
)
|
||||
return snap.sync_snapshots(root)
|
||||
|
||||
|
||||
def _block(root: Path, owner: str) -> tuple[dict, dict]:
|
||||
got = daega_inputs(root)
|
||||
return got, next(b for b in got["blocks"] if b["id"] == got["owners"][owner])
|
||||
|
||||
|
||||
def test_사본이_고른_열을_굳힘_manifest_규격(world):
|
||||
root = world["root"]
|
||||
assert _save(root, ["2.5"])["만듦"] == ["st0"]
|
||||
folder = snap.structures_dir(root) / "st0"
|
||||
doc = json.loads((folder / "일위대가.json").read_text(encoding="utf-8"))["줄"]
|
||||
assert [x["수량"] for x in doc["담은로직"]] == [{"손": 3}, {"손": 0.5}]
|
||||
manifest = json.loads((folder / snap.MANIFEST_NAME).read_text(encoding="utf-8"))
|
||||
assert manifest["규격"] == {"이름": "H", "열": "2.5"} and manifest["원본"]["spec"] == "2.5"
|
||||
assert _save(root, ["2.5"])["만듦"] == [] # 같은 항목 · 같은 열 = 그대로
|
||||
|
||||
|
||||
def test_열_바꿈_뒤_덮개_남음(world):
|
||||
root = world["root"]
|
||||
_save(root, ["1.5"])
|
||||
_, before = _block(root, "st0")
|
||||
cids = [ln["cid"] for ln in before["lines"]]
|
||||
assert before["cover"] == "S:st0" and [ln["qty"] for ln in before["lines"]] == [2, 0.5]
|
||||
cover = {before["cover"]: {cids[1]: {"qty": 0.7}}} # 공통 줄 품을 고침
|
||||
got = _save(root, ["2.5"])
|
||||
assert got["지움"] == ["st0"] and got["만듦"] == ["st0"] # 열 바꿈 = 새 사본
|
||||
after_all, after = _block(root, "st0")
|
||||
assert after["cover"] == before["cover"] and [ln["cid"] for ln in after["lines"]] == cids
|
||||
sheets = {"labor": [], "material": [], "machine": [], "gaps": []}
|
||||
out = compute_labor(after_all, sheets, {}, cover)
|
||||
lines = next(b for b in out["daega_blocks"] if b["id"] == after["id"])["lines"]
|
||||
assert lines[0]["qty"] == 3 # 새 열 수량
|
||||
assert lines[1]["qty"] == 0.7 and lines[1]["covered"] == ["qty"] # 덮개 그대로
|
||||
assert out["daega_orphans"] == []
|
||||
|
||||
|
||||
def test_B07_한_장_판정_항목_더하기_열(world):
|
||||
root = world["root"]
|
||||
_save(root, ["1.5", "1.5", "2.5"])
|
||||
ids = [s["id"] for s in structure_sheets(root) if s["id"].startswith("structure_system")]
|
||||
assert len(set(ids)) == 2 # 같은 열 둘 = 한 장 · 다른 열 = 따로
|
||||
|
||||
|
||||
def test_열_지움_쓰는_사본_찾기(world):
|
||||
root = world["root"]
|
||||
_save(root, ["2.5", None])
|
||||
assert spec.users(world["system"], {"SS000022"}, {"2.5"}) == ["proj/st0"]
|
||||
assert spec.users(world["system"], {"SS000022"}, {"1.5"}) == []
|
||||
assert spec.users(world["system"], {"SS000001"}, {"2.5"}) == []
|
||||
|
||||
|
||||
def test_쓰는_열_지움_409_안_쓰는_열_됨(world):
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
|
||||
root = world["root"]
|
||||
_save(root, ["2.5"])
|
||||
path = world["system"] / "labor" / "ms.json"
|
||||
with store.at(world["system"]):
|
||||
with pytest.raises(store.StoreError) as got:
|
||||
spec.guard(path, {"규격": {**SPEC, "열": ["1.5"]}}, "ms")
|
||||
assert got.value.status == 409 and "proj/st0" in str(got.value.detail)
|
||||
spec.guard(path, {"규격": {**SPEC, "열": ["2.5", "3.0"]}}, "ms") # 1.5 = 안 씀
|
||||
spec.guard(path, {"규격": SPEC}, "ms")
|
||||
|
||||
|
||||
# ── 화면 자료(TS · Node) — 격자 규격 열 · 고르기 창 spec ─────────────────────
|
||||
def test_격자_규격_열_수량_칸_열마다():
|
||||
from resources.tester import test_704_3_hopyo_grid as g
|
||||
|
||||
files = [g.GRID, "M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts",
|
||||
"M02_MasterTemplete/M02_MasterTemplete_Labor_Spec.ts",
|
||||
"M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts"] # fmt: skip
|
||||
doc = {**g.DOC, "규격": {"이름": "H", "열": ["1.5", "2.0"], "기본": "2.0",
|
||||
"수량": {"GF1": {"1.5": 1.5, "2.0": 2.09}, "MT1": {"1.5": 4}}}} # fmt: skip
|
||||
doc["자재"] = [{**doc["자재"][0], "수량": {"손": 5}}, {**doc["자재"][1], "수량": {"손": 2}}]
|
||||
out = g._node(files, (
|
||||
f"\nconst b = laborBlock({json.dumps(doc, ensure_ascii=False)}, {json.dumps(g.CALC, ensure_ascii=False)}, new Map());"
|
||||
"\nconst grid = hopyoGrid([b]);"
|
||||
"\nconsole.log(JSON.stringify({ cols: grid.columns.map((c) => [c.key, c.label, c.group]),"
|
||||
" rows: grid.rows, specs: b.specs }));"
|
||||
), g.STUBS) # fmt: skip
|
||||
cols = [c for c in out["cols"] if c[0].startswith("qty")]
|
||||
assert cols == [["qty_0", "1.5", ["H 열"]], ["qty_1", "2.0 (기본)", ["H 열"]]]
|
||||
line = out["rows"][1] # 첫 구성 줄
|
||||
assert line["qty_0"] == 1.5 and line["qty_1"] == 2.09
|
||||
mt1 = next(r for r in out["rows"] if r["name"] == "ㄴ 큰돌")
|
||||
assert mt1["qty_0"] == 4 and mt1["qty_1"] == 5 # 2.0 빈 열 = 공통 수량
|
||||
t15, t20 = out["specs"]["totals"]
|
||||
assert t20 == pytest.approx(112559.55) # 기본 열 = 금액 칸 합
|
||||
assert t15 == pytest.approx(529950 * 1.5 / 10 + 1800) # 열 수량 · 별도계상 MT1 = 합 밖
|
||||
|
||||
|
||||
def test_고르기_창_spec_돌려줌():
|
||||
import subprocess
|
||||
|
||||
data = Path(__file__).resolve().parents[2] / "ui_template/ui_template_item_picker_data.ts"
|
||||
body = (
|
||||
data.read_text("utf-8")
|
||||
+ """
|
||||
const row = { key: "SS000022", 열: "ms", 경로: ["석축", "메쌓기"],
|
||||
labor: { 규격: { 이름: "H", 기본: "2.0", 열: [{ 이름: "1.5", 합계: 1 }, { 이름: "2.0", 합계: 2 }] } } };
|
||||
const one = { ...row, labor: { 규격: { 이름: "H", 기본: "1.5", 열: [{ 이름: "1.5", 합계: 1 }] } } };
|
||||
const plain = { key: "SS000001", 열: "rv" };
|
||||
const cur = { layer: "system", owner: null, key: "SS000022", name: "x", spec: "1.5" };
|
||||
console.log(JSON.stringify([toSummary(row, "1.5"), toSummary(one, "1.5"), toSummary(plain, "1.5"),
|
||||
startSpec(row, cur), startSpec(row, null), startSpec(row, { ...cur, spec: "지운" }), startSpec(plain, cur)]));
|
||||
"""
|
||||
)
|
||||
got = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript"],
|
||||
input=body, capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert got.returncode == 0, got.stderr
|
||||
two, single, plain, *starts = json.loads(got.stdout)
|
||||
assert two == {"layer": "system", "owner": None, "key": "SS000022",
|
||||
"name": "석축 메쌓기 (H=1.5)", "spec": "1.5"} # fmt: skip
|
||||
assert single["name"] == "석축 메쌓기" and single["spec"] == "1.5" # 열 하나 = 이름 그대로
|
||||
assert "spec" not in plain # 규격 없는 옛 항목
|
||||
assert starts == ["1.5", "2.0", "2.0", None]
|
||||
|
||||
|
||||
def test_B08_B09_금액_열_수량_곱하기_단가(world):
|
||||
"""B08 = 사본 1 단위 값(사본 셈 반영합) = 층 문서 그 열 셈 · B09 = 대가 줄 금액 = 열 수량 × 단가."""
|
||||
from M02_MasterTemplete import M02_Item_Prices as item_prices
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
|
||||
root = world["root"]
|
||||
_save(root, ["2.5"])
|
||||
folder = snap.structures_dir(root) / "st0"
|
||||
read = lambda name: json.loads((folder / name).read_text(encoding="utf-8")) # noqa: E731
|
||||
copy = item_prices.calc(read("일위대가.json"), read("단가.json"), read("입력값.json"), {}, None)
|
||||
layer = labor.clean(LABOR)
|
||||
want = labor.settle(labor.calc(layer, {}), layer, {}, "2.5")["반영합"]
|
||||
assert copy["반영합"]["계"] == pytest.approx(want["계"]) # B08 1 단위 값 = 2.5 열
|
||||
first = labor.settle(labor.calc(layer, {}), layer, {}, "1.5")["반영합"]
|
||||
assert want["계"] != pytest.approx(first["계"])
|
||||
got, block = _block(root, "st0")
|
||||
price = {ln["key"]: {"labor": 1000.0, "material": 0.0, "expense": 0.0} for ln in block["lines"]}
|
||||
sheets = {"labor": [], "material": [], "machine": [], "gaps": []}
|
||||
out = compute_labor(got, sheets, price, {})
|
||||
lines = next(b for b in out["daega_blocks"] if b["id"] == block["id"])["lines"]
|
||||
assert [ln["amounts"]["labor"] for ln in lines] == [3000, 500] # 3 × 1000 · 공통 0.5 × 1000
|
||||
@@ -69,3 +69,84 @@ def test_파이썬과_TS_가_같은_답(values):
|
||||
py = labor.settle(answer, doc, values)
|
||||
_same(py, _ts(answer, doc, values))
|
||||
assert py == labor.calc(doc, values) # settle 만 다시 = 처음부터 다시
|
||||
|
||||
|
||||
# ── 규격 열(704-4) — 열마다 줄 수량 · 열 없는 옛 문서는 위 시험 그대로 ──
|
||||
SPEC_DOC = {
|
||||
"열": "rv",
|
||||
"담은로직": [
|
||||
{"로직": TEN, "수량": None},
|
||||
{"로직": THOUSAND, "수량": {"손": 4}},
|
||||
{"로직": PLAIN, "수량": None},
|
||||
],
|
||||
"자재": [{"코드": MAT, "수량": {"손": 1}, "별도계상": "직접계산"}],
|
||||
"규격": {
|
||||
"이름": "H",
|
||||
"열": ["1.5", "2.0", "2.5"],
|
||||
"기본": "2.0",
|
||||
"수량": {
|
||||
THOUSAND: {"1.5": 3, "2.0": "4.5", "2.5": 6},
|
||||
MAT: {"1.5": 0.8, "2.5": 1.4}, # 2.0 빈 열 = 공통 손 1
|
||||
"없는줄": {"1.5": 9}, # 버림
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("col", [None, "1.5", "2.0", "2.5", "모름"])
|
||||
def test_규격_열_파이썬과_TS_가_같은_답(col):
|
||||
doc = labor.clean(SPEC_DOC)
|
||||
assert doc["규격"]["수량"] == {
|
||||
THOUSAND: {"1.5": 3, "2.0": 4.5, "2.5": 6},
|
||||
MAT: {"1.5": 0.8, "2.5": 1.4},
|
||||
}
|
||||
answer = labor.calc(doc, {})
|
||||
values = {"사석 수상고르기|㎡": 2.5}
|
||||
py = labor.settle(answer, doc, values, col)
|
||||
_same(py, _ts(answer, doc, values) if col is None else _ts_col(answer, doc, values, col))
|
||||
use = col if col in ("1.5", "2.0", "2.5") else "2.0" # 모름 = 기본
|
||||
line = next(x for x in py["줄"] if x["로직"] == THOUSAND)
|
||||
assert line["수량"] == {"1.5": 3, "2.0": 4.5, "2.5": 6}[use] and line["수량출처"] == "손"
|
||||
mat = py["자재"][0]
|
||||
assert mat["수량"] == {"1.5": 0.8, "2.0": 1, "2.5": 1.4}[use]
|
||||
|
||||
|
||||
def _ts_col(answer: dict, doc: dict, values: dict, col: str) -> dict:
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_m02_labor_calc.cjs")],
|
||||
input=json.dumps({"답": answer, "문서": doc, "값": values, "열": col}, ensure_ascii=False),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
check=True,
|
||||
)
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_규격합_은_열마다_다시_센_반영합():
|
||||
doc = labor.clean(SPEC_DOC)
|
||||
out = labor.calc(doc, {"사석 수상고르기|㎡": 2.5})
|
||||
sums = labor.spec_sums(doc, out)["규격합"]
|
||||
assert list(sums) == ["1.5", "2.0", "2.5"]
|
||||
for col, total in sums.items():
|
||||
assert total == labor.settle(out, doc, {"사석 수상고르기|㎡": 2.5}, col)["반영합"]
|
||||
assert sums["1.5"]["계"] < sums["2.0"]["계"] < sums["2.5"]["계"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad",
|
||||
[
|
||||
{"이름": "", "열": ["1.5"]},
|
||||
{"이름": "H", "열": ["1.5", "1.5"]},
|
||||
{"이름": "H", "열": ["1.5"], "수량": {THOUSAND: {"1.5": "글"}}},
|
||||
],
|
||||
)
|
||||
def test_규격_칸_꼴이_틀리면_400(bad):
|
||||
with pytest.raises(labor.store.StoreError) as got:
|
||||
labor.clean({**SPEC_DOC, "규격": bad})
|
||||
assert got.value.status == 400
|
||||
|
||||
|
||||
def test_열_없는_규격_은_옛_문서():
|
||||
assert "규격" not in labor.clean({**SPEC_DOC, "규격": {"이름": "H", "열": []}})
|
||||
assert "규격" not in labor.clean({k: v for k, v in SPEC_DOC.items() if k != "규격"})
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
* 구성 줄 = 「ㄴ 품명」 · 규격 · 품/수량 · 단위 · 합계 · 노무비 · 재료비 · 경비(단가 / 금액 쌍) · 비고 · 구분(줄 꼬리표)
|
||||
* 합계 줄 = 비목 금액 합 · 빈 줄(금액 모름 · 수량 없음) 있으면 비고 「빈 줄 n」 붉게 — 합계는 아는 줄만
|
||||
* 줄 꼬리표 = 「품셈」(우리 로직이 품을 셈) · 「고정」 갈래는 705 몫(자리만 · 같은 칸)
|
||||
* 규격 열(704-4 · 첫 호표 `specs`) = 수량 칸이 열 수만큼(`qty_n` · 머리 「H 열」) · 합계 줄 그 칸 = 열마다 1 단위 합계 ·
|
||||
* 금액 칸 = 기본 열 값
|
||||
* 값은 부르는 쪽이 준 것 그대로(서버 계산 값) — 여기서 셈하지 않음
|
||||
*
|
||||
* const g = createHopyoGrid([block]); host.append(g.root); g.set([block2]);
|
||||
@@ -44,6 +46,16 @@ export interface HopyoLine {
|
||||
note?: string | null;
|
||||
/** 줄 꼬리표 — 「품셈」 · 「고정」(705) · 빈 값 = 없음 */
|
||||
tag?: string | null;
|
||||
/** 규격 열마다 품 · 수량(704-4 · 블록 `specs.cols` 차례) — 없으면 `qty` */
|
||||
qtys?: (number | null)[];
|
||||
}
|
||||
|
||||
/** 규격 열(704-4) — 이름(H) · 열 이름 · 기본 열 · 열마다 1 단위 합계 */
|
||||
export interface HopyoSpecs {
|
||||
name: string;
|
||||
cols: string[];
|
||||
pick?: string | null;
|
||||
totals: (number | null)[];
|
||||
}
|
||||
|
||||
export interface HopyoBlock {
|
||||
@@ -63,6 +75,8 @@ export interface HopyoBlock {
|
||||
/** 빈 줄 수 · 이름 — 없으면 금액 모르는 줄에서 셈 */
|
||||
blank?: number;
|
||||
blank_names?: string[];
|
||||
/** 규격 열(704-4) — 첫 호표 것만 열로 폄 */
|
||||
specs?: HopyoSpecs | null;
|
||||
}
|
||||
|
||||
/** 줄 꼬리표 낱말 */
|
||||
@@ -106,6 +120,23 @@ export const HOPYO_COLUMNS: DataColumn[] = [
|
||||
hopyoText("tag", "구분", 56),
|
||||
];
|
||||
|
||||
/** 규격 열 수량 칸 열쇠(704-4) */
|
||||
export const hopyoSpecKey = (n: number): string => `qty_${n}`;
|
||||
|
||||
/** 열 — 첫 호표에 규격 열이 있으면 수량 칸을 열마다 폄(기본 열 = 「(기본)」) */
|
||||
export function hopyoColumns(blocks: HopyoBlock[]): DataColumn[] {
|
||||
const specs = blocks[0]?.specs;
|
||||
if (!specs?.cols.length) return HOPYO_COLUMNS;
|
||||
const qty = HOPYO_COLUMNS.find((c) => c.key === "qty") as DataColumn;
|
||||
const cols = specs.cols.map((col, n): DataColumn => ({
|
||||
...qty,
|
||||
key: hopyoSpecKey(n),
|
||||
label: col === specs.pick ? `${col} (기본)` : col,
|
||||
group: [`${specs.name} 열`],
|
||||
}));
|
||||
return HOPYO_COLUMNS.flatMap((c) => (c.key === "qty" ? cols : [c]));
|
||||
}
|
||||
|
||||
export interface HopyoGrid {
|
||||
columns: DataColumn[];
|
||||
rows: DataRow[];
|
||||
@@ -126,6 +157,7 @@ export function hopyoGrid(blocks: HopyoBlock[]): HopyoGrid {
|
||||
const rows: DataRow[] = [];
|
||||
const titles = new Set<number>();
|
||||
const at = new Map<number, [number, number]>();
|
||||
const specCount = blocks[0]?.specs?.cols.length ?? 0;
|
||||
blocks.forEach((b, bi) => {
|
||||
titles.add(rows.length);
|
||||
rows.push({
|
||||
@@ -153,6 +185,7 @@ export function hopyoGrid(blocks: HopyoBlock[]): HopyoGrid {
|
||||
note: ln.note ?? "",
|
||||
tag: ln.tag ?? "",
|
||||
};
|
||||
for (let k = 0; k < specCount; k++) row[hopyoSpecKey(k)] = ln.qtys?.[k] ?? ln.qty;
|
||||
for (const [key] of HOPYO_COSTS) {
|
||||
row[`${key}_price`] = ln.prices[key] ?? null;
|
||||
row[key] = ln.amounts[key] ?? null;
|
||||
@@ -161,7 +194,14 @@ export function hopyoGrid(blocks: HopyoBlock[]): HopyoGrid {
|
||||
});
|
||||
const blank = hopyoBlank(b);
|
||||
titles.add(rows.length);
|
||||
const sums = Object.fromEntries(
|
||||
Array.from({ length: specCount }, (_, k) => {
|
||||
const sum = b.specs?.totals[k];
|
||||
return [hopyoSpecKey(k), sum == null ? null : Math.round(sum * 10) / 10]; // 금액 칸처럼 한 자리
|
||||
}),
|
||||
);
|
||||
rows.push({
|
||||
...sums,
|
||||
key: `${b.key}|합계`,
|
||||
name: "합 계",
|
||||
sum: b.total.sum ?? null,
|
||||
@@ -173,7 +213,7 @@ export function hopyoGrid(blocks: HopyoBlock[]): HopyoGrid {
|
||||
blank_names: blank.names,
|
||||
});
|
||||
});
|
||||
return { columns: HOPYO_COLUMNS, rows, titles, at };
|
||||
return { columns: hopyoColumns(blocks), rows, titles, at };
|
||||
}
|
||||
|
||||
/** 칸 꼴 — 꼬리표 · 빈 줄 알림 */
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
* 224-1 — 맨 위 = M02 구조물조합 상단 표(구조물집계표 · 보기 전용 · 고른 항목의 열이 골라짐).
|
||||
* 173-2 — 나무 = M02 나무 꼴: 층 단추 셋(하나만 보임 · 뿌리 줄 없음) · 개인 맨 위 「새 항목」 = 빈 자리 오른쪽 클릭.
|
||||
* 280-2 — 위 표 · 나무 · 오른쪽 카드 = 한 상태: 위 표 = 보이는 층 표 · 표에서 열을 고르면 나무(펼쳐 굴림) · 카드도 그 항목.
|
||||
* 704-4 — 고른 항목에 규격 열이 둘 이상이면 표 아래 열 단추(열마다 1 단위 합계) · [적용] = `spec` 붙은 참조 ·
|
||||
* 처음 = 지금 지정 열(같은 항목) · 아니면 기본 · 열 하나 = 단추 없음(그 열로).
|
||||
* ========================================================================== */
|
||||
|
||||
import "./ui_template_item_picker.css";
|
||||
@@ -25,6 +27,8 @@ import {
|
||||
ownerOf,
|
||||
pathIds,
|
||||
rowId,
|
||||
specOf,
|
||||
startSpec,
|
||||
tableKey,
|
||||
toSummary,
|
||||
type PickNode,
|
||||
@@ -47,6 +51,8 @@ import { projectItems, type CreatedItem } from "./ui_template_item_picker_projec
|
||||
import { createItemDetail, type EditRequest } from "./ui_template_item_picker_detail";
|
||||
import { createEditView, type EditView } from "./ui_template_item_picker_edit";
|
||||
import { createComboTable } from "./ui_template_item_picker_table";
|
||||
import { createToolbar } from "./ui_template_toolbar";
|
||||
import { createText } from "./ui_template_text";
|
||||
|
||||
export type { SummaryItem } from "./ui_template_item_picker_data";
|
||||
|
||||
@@ -166,6 +172,10 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
|
||||
children: [comboTable.root],
|
||||
});
|
||||
const editHost = el("div", { className: "ui-item-picker__edit-host", attrs: { hidden: "" } });
|
||||
/** 규격 열(704-4) — 고른 열 · 그 열을 정한 항목 */
|
||||
let specPick: string | null = null;
|
||||
let specFor = "";
|
||||
const specHost = el("div", { className: "ui-item-picker__spec", attrs: { hidden: "" } });
|
||||
let editView: EditView | null = null;
|
||||
const applyButton = createButton({ label: t("M02_PickerApply"), variant: "filled" });
|
||||
const clearButton = createButton({ label: t("M02_PickerClear"), variant: "ghost" });
|
||||
@@ -194,9 +204,37 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
|
||||
);
|
||||
};
|
||||
|
||||
const paintSpec = (): void => {
|
||||
if (chosen && rowId(chosen) !== specFor) {
|
||||
specFor = rowId(chosen);
|
||||
specPick = startSpec(chosen, opts.current);
|
||||
}
|
||||
const spec = chosen ? specOf(chosen) : null;
|
||||
specHost.hidden = !spec || spec.열.length < 2;
|
||||
if (!spec || specHost.hidden) return void specHost.replaceChildren();
|
||||
const sum = (n: number | null): string =>
|
||||
n === null ? "" : ` · ${n.toLocaleString("ko-KR", { maximumFractionDigits: 0 })}`;
|
||||
const buttons = spec.열.map((col) => {
|
||||
const button = createButton({
|
||||
label: `${spec.이름}=${col.이름}${sum(col.합계)}`,
|
||||
variant: col.이름 === specPick ? "filled" : "ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
specPick = col.이름;
|
||||
paintSpec();
|
||||
},
|
||||
});
|
||||
button.setAttribute("aria-pressed", String(col.이름 === specPick));
|
||||
return button;
|
||||
});
|
||||
const head = createText(`${spec.이름} 규격 열`, "muted");
|
||||
specHost.replaceChildren(createToolbar({ items: [head, ...buttons], label: spec.이름 }));
|
||||
};
|
||||
|
||||
const paintDetail = (): void => {
|
||||
detailView.show(chosen);
|
||||
paintTable();
|
||||
paintSpec();
|
||||
const layer = chosen ? layerOf(chosen) : null;
|
||||
applyButton.disabled = !chosen;
|
||||
clearButton.disabled = !opts.current;
|
||||
@@ -337,7 +375,7 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
|
||||
});
|
||||
applyButton.addEventListener(
|
||||
"click",
|
||||
() => chosen && finish({ action: "apply", item: toSummary(chosen) }),
|
||||
() => chosen && finish({ action: "apply", item: toSummary(chosen, specPick) }),
|
||||
);
|
||||
clearButton.addEventListener("click", () => finish({ action: "clear" }));
|
||||
manageButton.addEventListener("click", () => {
|
||||
@@ -407,6 +445,7 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
|
||||
editHost.replaceChildren(view.root);
|
||||
cols.hidden = true;
|
||||
tableWrap.hidden = true;
|
||||
specHost.hidden = true;
|
||||
actions.hidden = true;
|
||||
headButtons.hidden = true;
|
||||
editHost.hidden = false;
|
||||
@@ -419,6 +458,7 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
|
||||
editHost.hidden = true;
|
||||
cols.hidden = false;
|
||||
tableWrap.hidden = false;
|
||||
paintSpec();
|
||||
actions.hidden = false;
|
||||
headButtons.hidden = false;
|
||||
if (saved) detailView.markEdited();
|
||||
@@ -440,7 +480,7 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
|
||||
return true;
|
||||
},
|
||||
mount: (body) => {
|
||||
body.append(tableWrap, cols, editHost, ...(opts.projectId ? [actions] : []));
|
||||
body.append(tableWrap, specHost, cols, editHost, ...(opts.projectId ? [actions] : []));
|
||||
paintDetail();
|
||||
void load(null);
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* ui_template_item_picker_data.ts
|
||||
* 항목 고르기의 자료 쪽(화면 없음) — 항목 목록 → 나무 · 찾기 · 폴더 줄 · 돌려줄 `summary_item`.
|
||||
* 모양은 PLAN.md 21-1 약속 그대로: 항목 목록 API 한 줄 = `PickerRow`, 참조 = `SummaryItem`.
|
||||
* 규격 열(704-4) = 줄 `labor.규격` · 돌려줄 값에 `spec`(열 둘 이상이면 이름 뒤 「(H=2.0)」).
|
||||
* ========================================================================== */
|
||||
|
||||
export type PickerLayer = "system" | "company" | "personal";
|
||||
@@ -12,6 +13,15 @@ export interface SummaryItem {
|
||||
owner: string | null;
|
||||
key: string;
|
||||
name: string;
|
||||
/** 규격 열 이름(704-4) — 없으면 기본 열 */
|
||||
spec?: string;
|
||||
}
|
||||
|
||||
/** 규격 열(704-4 · 서버 목록 `labor.규격`) — 열마다 1 단위 합계 */
|
||||
export interface PickerSpec {
|
||||
이름: string;
|
||||
열: { 이름: string; 합계: number | null }[];
|
||||
기본: string;
|
||||
}
|
||||
|
||||
/** 항목 줄이 가리키는 키 칸 — 서버가 `names` 로 이름을 함께 주면 이름을 보임 */
|
||||
@@ -36,6 +46,8 @@ export interface PickerRow {
|
||||
names?: Partial<Record<NameField, string | null>>;
|
||||
layer?: PickerLayer;
|
||||
owner?: string | null;
|
||||
/** 일위대가 문서 요약(39-5) — 규격 열만 씀 */
|
||||
labor?: { 규격?: PickerSpec } | null;
|
||||
}
|
||||
|
||||
/** 그룹 차례 = 마스터 · 회사 · 개인 */
|
||||
@@ -88,8 +100,25 @@ export const rowId = (row: PickerRow): string => `${layerOf(row)}|${ownerOf(row)
|
||||
/** 상단 표(224-1)가 보이는 집계표 = 고른 항목이 속한 층(+ 주인) 것 — 같은 키면 표는 그대로 두고 열만 옮김 */
|
||||
export const tableKey = (row: PickerRow): string => `${layerOf(row)}|${ownerOf(row) ?? ""}`;
|
||||
|
||||
export function toSummary(row: PickerRow): SummaryItem {
|
||||
return { layer: layerOf(row), owner: ownerOf(row), key: row.key, name: itemName(row) };
|
||||
/** 그 항목 규격 열 — 없으면 null */
|
||||
export const specOf = (row: PickerRow): PickerSpec | null =>
|
||||
row.labor?.규격?.열?.length ? row.labor.규격 : null;
|
||||
|
||||
/** 처음 고른 열 — 지금 지정이 같은 항목이고 그 열이 있으면 그것 · 아니면 기본 */
|
||||
export function startSpec(row: PickerRow, current: SummaryItem | null): string | null {
|
||||
const spec = specOf(row);
|
||||
if (!spec) return null;
|
||||
const want = isCurrent(current, row) ? current?.spec : undefined;
|
||||
return spec.열.some((c) => c.이름 === want) ? (want as string) : spec.기본;
|
||||
}
|
||||
|
||||
/** 돌려줄 참조 — 규격 열이 있으면 `spec`(열 둘 이상 = 이름 뒤 「(H=열)」) */
|
||||
export function toSummary(row: PickerRow, col: string | null = null): SummaryItem {
|
||||
const out = { layer: layerOf(row), owner: ownerOf(row), key: row.key, name: itemName(row) };
|
||||
const spec = specOf(row);
|
||||
if (!spec || col === null) return out;
|
||||
const name = spec.열.length > 1 ? `${out.name} (${spec.이름}=${col})` : out.name;
|
||||
return { ...out, name, spec: col };
|
||||
}
|
||||
|
||||
export function isCurrent(current: SummaryItem | null, row: PickerRow): boolean {
|
||||
|
||||
Reference in New Issue
Block a user