Merge remote-tracking branches 'origin/sub_desktop_1', 'origin/sub_laptop_2', 'origin/sub_laptop_4' and 'origin/sub_laptop_7' into main_laptop_1
This commit is contained in:
@@ -109,11 +109,22 @@ def seed_values(labor: Any) -> dict[str, Any]:
|
||||
return out
|
||||
|
||||
|
||||
def seed_picks(labor: Any) -> dict[str, Any]:
|
||||
"""일위대가 문서 고름 → 프로젝트 고른자재 첫 값 `{로직: {호표 차례: MT키}}`(103-1)."""
|
||||
doc = labor.get("줄") if isinstance(labor, dict) else None
|
||||
return {
|
||||
str(line["로직"]): dict(line["고름"])
|
||||
for line in (doc or {}).get("담은로직") or []
|
||||
if isinstance(line, dict) and line.get("로직") and isinstance(line.get("고름"), dict)
|
||||
}
|
||||
|
||||
|
||||
def build_snapshot(item: dict[str, Any]) -> dict[str, Any]:
|
||||
"""원본 내용 + 프로젝트 값(입력값 첫 값 · 빈 고른자재) — `{파일 이름: 내용}` · SS 줄이 없으면
|
||||
LookupError."""
|
||||
"""원본 내용 + 프로젝트 값(입력값 · 고른자재 첫 값 = 일위대가 문서 것) — `{파일 이름: 내용}` ·
|
||||
SS 줄이 없으면 LookupError."""
|
||||
files = build_source(item)
|
||||
return {**files, VALUES_FILE: seed_values(files.get(LABOR_FILE)), PICKS_FILE: {}}
|
||||
labor = files.get(LABOR_FILE)
|
||||
return {**files, VALUES_FILE: seed_values(labor), PICKS_FILE: seed_picks(labor)}
|
||||
|
||||
|
||||
def source_files(files: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
@@ -150,7 +150,11 @@ def build_source(item: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
logic_keys = [str(one.get("키")) for one in (labor or {}).get("로직", [])]
|
||||
logic_keys += refs.frozen_logics((labor or {}).get("참조사본") or {}) # 굳힌 단가승격 로직
|
||||
materials = [str(m.get("코드")) for m in ((labor or {}).get("줄") or {}).get("자재") or []]
|
||||
doc = (labor or {}).get("줄") or {}
|
||||
materials = [str(m.get("코드")) for m in doc.get("자재") or []]
|
||||
materials += [ # 고른 로직 자재(103-1) 품목 줄도 — 복사본 계산이 그 단가를 씀
|
||||
str(key) for one in doc.get("담은로직") or [] for key in (one.get("고름") or {}).values()
|
||||
]
|
||||
wanted = logic_keys + [code for code in materials if code]
|
||||
files[PRICE_FILE] = prices.to_json(prices.collect(logic_keys, materials)) if wanted else {}
|
||||
return files
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""M02 일위대가 문서 `labor/<열id>.json` — 정리 · 검사 · 계산 · 알약(PLAN 39-1a · 설계_정함 1장).
|
||||
|
||||
문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬, 담은로직: [{로직, 별칭, 메모, 입력값, 수량, 켜쌓기율?}
|
||||
| {참조: {층, 주인, 키}, 별칭, 메모, 수량}],
|
||||
문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬,
|
||||
담은로직: [{로직, 별칭, 메모, 입력값, 수량, 켜쌓기율?, 고름?} | {참조: {층, 주인, 키}, 별칭, 메모, 수량}],
|
||||
자재: [{코드, 이름, 규격, 단위, 수량}], 계산값}` — 이름은 구조물 열 이름(따로 안 둠).
|
||||
수량 = `null`(면적산출 알약 칸 값 · 로직 알약 「이름|단위」 · 자재 알약 「자재|MT키」) | `{손: 수}`
|
||||
(손이 칸보다 앞섬) · 같은 로직 두 번 금지 · 입력값 = 그 로직 입력 이름만(빈 값 = 자동값).
|
||||
@@ -14,11 +14,15 @@
|
||||
분모 = 그 문서 단위 · 배 1 · 알약 키 = `ref_id` · 같은 참조 두 번 금지 · 위 층만.
|
||||
짝(CLAUDE.md 5장 ①): 수량 · 반영 · 합 `settle` ↔ `M02_MasterTemplete_Labor_Calc.ts` —
|
||||
거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`.
|
||||
로직 자재(103-1) = 담은 로직 맨 위 층 호표 재료 줄(`logic_mats` · 줄마다 `재료`) ·
|
||||
고름 = `{호표 차례: MT키}`(엔진 `choosing` 그대로) · 금액은 로직 재료비 안이라 합 · 알약에 안 더함(보기만) · 사용자 자재와
|
||||
같은 품목 두 번은 [저장] 때 400.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
|
||||
@@ -34,6 +38,7 @@ COSTS = mf.COST_ITEMS # 노무비 · 재료비 · 경비
|
||||
MATERIAL_HEAD = "자재|"
|
||||
_MAT_COLS = ("코드", "이름", "규격", "단위")
|
||||
ALIAS_MAX = 40 # 별칭(44-6) 글자 수 상한 — 화면 칸과 같음
|
||||
_BRACE = re.compile(r"\{([^{}]+)\}") # 품목 조건의 {입력}
|
||||
|
||||
|
||||
def _bad(text: str) -> store.StoreError:
|
||||
@@ -109,6 +114,7 @@ def clean(doc: Any) -> dict[str, Any]:
|
||||
raise _bad(f"{where} · 입력값이 묶음 아님")
|
||||
memo, alias = one.get("메모"), _alias(one.get("별칭"), where)
|
||||
rate = _rate(one.get("켜쌓기율"), code, where)
|
||||
picks = _picks(one.get("고름"), logic, whole, where)
|
||||
lines.append(
|
||||
{
|
||||
"로직": code,
|
||||
@@ -117,6 +123,7 @@ def clean(doc: Any) -> dict[str, Any]:
|
||||
"입력값": {k: v for k, v in given.items() if k in names and v not in (None, "")},
|
||||
"수량": _qty(one.get("수량"), where),
|
||||
**({"켜쌓기율": rate} if rate is not None else {}),
|
||||
**({"고름": picks} if picks else {}),
|
||||
}
|
||||
)
|
||||
mats, codes = [], set()
|
||||
@@ -162,6 +169,28 @@ def _alias(alias: Any, where: str) -> str | None:
|
||||
return alias or None
|
||||
|
||||
|
||||
def _picks(value: Any, logic: dict, whole: Any, where: str) -> dict[str, str] | None:
|
||||
"""고름(103-1) = `{호표 차례: MT키}` — 맨 위 층 재료 줄 · M01 자재품목 키만 · 빈 것 = None."""
|
||||
if not value:
|
||||
return None
|
||||
if not isinstance(value, dict):
|
||||
raise _bad(f"{where} · 고름은 {{호표 차례: MT키}}")
|
||||
rows = logic.get("호표") or []
|
||||
out = {}
|
||||
for at, key in value.items():
|
||||
n = int(at) if str(at).isdigit() else -1
|
||||
if not 0 <= n < len(rows) or rows[n].get("종류") != "재료":
|
||||
raise _bad(f"{where} · 고름 {at}째는 재료 줄 아님")
|
||||
try:
|
||||
ok = str(key)[:2] == mm.MARKET and whole.get(str(key)) is not None
|
||||
except mf.FormulaError:
|
||||
ok = False
|
||||
if not ok:
|
||||
raise _bad(f"{where} · 고름 「{key}」 M01 자재품목에 없음")
|
||||
out[str(n)] = str(key)
|
||||
return out
|
||||
|
||||
|
||||
def _rate(value: Any, code: str, where: str) -> int | float | None:
|
||||
"""켜쌓기율(85-2) — 0 초과 200 이하 · 소수 한 자리 · 100 · 대상 밖 로직은 None(안 둠)."""
|
||||
if value is None or value == "":
|
||||
@@ -372,7 +401,8 @@ def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"분모": pill.get("단위"),
|
||||
"배": pill.get("배") or 1,
|
||||
}
|
||||
lines.append({**line, **run_line(whole, code, one.get("입력값"), one.get("켜쌓기율"))})
|
||||
got = run_line(whole, code, one.get("입력값"), one.get("켜쌓기율"), one.get("고름"))
|
||||
lines.append({**line, **got})
|
||||
mats = [
|
||||
{
|
||||
"코드": one["코드"],
|
||||
@@ -385,19 +415,58 @@ def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
return settle(_json({"줄": lines, "자재": mats}), doc, have)
|
||||
|
||||
|
||||
def run_line(whole: Any, code: str, given: Any, rate: Any = None) -> dict[str, Any]:
|
||||
"""로직 한 줄 기준(서버 단독 · M01 엔진 · 빈 입력 = 자동값 · × 켜쌓기율) — `{기준, 까닭}` ·
|
||||
돈 아닌 로직은 `{결과, 기준: None}`(반영 없음)."""
|
||||
def run_line(
|
||||
whole: Any, code: str, given: Any, rate: Any = None, picks: Any = None
|
||||
) -> dict[str, Any]:
|
||||
"""로직 한 줄 기준(서버 단독 · M01 엔진 · 빈 입력 = 자동값 · × 켜쌓기율 · 고른 재료 = 엔진
|
||||
`choosing`) — `{기준, 줄, 재료, 까닭}` · 돈 아닌 로직은 `{결과, 기준: None}`(반영 없음)."""
|
||||
try:
|
||||
logic = whole.logic(code)
|
||||
auto = {k: v for k, v in m01.mau.values(whole, logic).items() if v is not None}
|
||||
got = layered(mf.run(whole, code, {**auto, **m01._dec(given or {})}), rate)
|
||||
with mf.mp.choosing(picks):
|
||||
got = layered(mf.run(whole, code, {**auto, **m01._dec(given or {})}), rate)
|
||||
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e:
|
||||
return {"기준": None, "까닭": str(e) or type(e).__name__}
|
||||
if "결과" in got:
|
||||
return {"결과": got["결과"], "기준": None, "까닭": None}
|
||||
# 「줄」 = 그 로직 재료 · 노무 · 경비 줄(줄 누름 상세 · 50-4)
|
||||
return {"기준": {k: got[k] for k in (*COSTS, "계")}, "줄": got["줄"], "까닭": None}
|
||||
money = {k: got[k] for k in (*COSTS, "계")}
|
||||
mats = logic_mats(whole, logic, got["줄"], picks)
|
||||
return {"기준": money, "줄": got["줄"], "재료": mats, "까닭": None}
|
||||
|
||||
|
||||
def logic_mats(whole: Any, logic: dict, lines: list[dict], picks: Any) -> list[dict[str, Any]]:
|
||||
"""로직 자재(103-1) — 맨 위 층 호표 재료 줄마다 엔진 답 줄(같은 차례) 그대로 · 금액은 로직
|
||||
재료비 안(합에 안 더함) · `걸림` = 품목 조건에 든 입력 이름(중간값이면 입력 전부 — 그 입력이
|
||||
바뀌면 화면이 고름을 풂 · 짝 `M02_Labor_LogicMats_Model.ts` `releaseTied`)."""
|
||||
if isinstance(logic.get("흐름"), dict):
|
||||
return [] # 흐름 로직 재료 자리는 입력 이름 — 이번엔 안 보임
|
||||
names = {str(spec.get("이름")) for spec in _specs(logic)}
|
||||
out = []
|
||||
for n, item in enumerate(logic.get("호표") or []):
|
||||
if item.get("종류") != "재료" or n >= len(lines):
|
||||
continue
|
||||
line, goods = lines[n], lines[n].get("품목")
|
||||
try:
|
||||
row = (whole.get(goods["키"]) if goods else None) or {}
|
||||
except mf.FormulaError:
|
||||
row = {}
|
||||
cond = item.get("요소")
|
||||
texts = cond.values() if isinstance(cond, dict) else [cond]
|
||||
tied = {w for t in texts if isinstance(t, str) for w in _BRACE.findall(t)}
|
||||
out.append(
|
||||
{
|
||||
"차례": n,
|
||||
"이름": item.get("이름") or "",
|
||||
"단위": item.get("단위") or "",
|
||||
**{k: line.get(k) for k in ("수량", "단가", "금액", "까닭")},
|
||||
"품목": goods,
|
||||
"가격": {slot: row.get(slot) for slot in mm.SLOTS},
|
||||
"고름": str(n) in (picks or {}),
|
||||
"걸림": sorted(tied if tied <= names else names) if tied else [],
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def ref_line(ref: dict, got: dict[str, Any]) -> dict[str, Any]:
|
||||
@@ -421,6 +490,12 @@ def _json(value: Any) -> Any:
|
||||
|
||||
|
||||
def server(doc: Any) -> dict[str, Any]:
|
||||
"""[저장] · 복사 때 서버 정본 — 정리 + 지금 면적 값으로 `계산값`(브라우저 값 버림)."""
|
||||
"""[저장] · 복사 때 서버 정본 — 정리 + 지금 면적 값으로 `계산값`(브라우저 값 버림) ·
|
||||
사용자 자재가 로직 자재 품목과 같으면 400(103-1 · 같은 품목 두 번 셈)."""
|
||||
got = clean(doc)
|
||||
return {**got, "계산값": calc(got)}
|
||||
out = calc(got)
|
||||
inside = {x["품목"]["키"] for one in out["줄"] for x in one.get("재료") or [] if x.get("품목")}
|
||||
twice = sorted({m["코드"] for m in got["자재"]} & inside)
|
||||
if twice:
|
||||
raise _bad(f"자재 「{', '.join(twice)}」 는 로직 자재에 있음 — 같은 품목 두 번 담음")
|
||||
return {**got, "계산값": out}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/* =============================================================================
|
||||
* M02_Labor_LogicMats_Model.ts
|
||||
* 로직 자재(103-1) 자료 · 규칙(화면 없음) — 서버 calc `줄[].재료`(짝 `M02_Labor_Doc.logic_mats`) ·
|
||||
* 입력이 바뀌면 그 입력에 걸린 고름을 풂(`releaseTied`) · 사용자 자재와 같은 품목 막기(`insideKeys`).
|
||||
* 시험 `resources/tester/test_m02_labor_logic_mats.py`(Node 헬퍼).
|
||||
* ========================================================================== */
|
||||
|
||||
/** 잡힌 품목 — 엔진 답 `품목`(값칸 = 단가가 나온 값 열) */
|
||||
export interface LogicMatItem {
|
||||
키: string;
|
||||
이름?: string | null;
|
||||
규격?: string | null;
|
||||
단위?: string | null;
|
||||
값칸?: string | null;
|
||||
}
|
||||
|
||||
/** 로직 자재 한 줄 — 맨 위 층 호표 재료 줄(금액은 로직 재료비 안 · 합에 안 더함) */
|
||||
export interface LogicMat {
|
||||
차례: number;
|
||||
이름: string;
|
||||
단위: string;
|
||||
수량: number | null;
|
||||
단가: number | null;
|
||||
금액: number | null;
|
||||
까닭?: string | null;
|
||||
품목: LogicMatItem | null;
|
||||
/** M01 자재품목 값 열 다섯(빈 값 null) */
|
||||
가격?: Record<string, number | null>;
|
||||
고름: boolean;
|
||||
/** 품목 조건에 든 입력 이름 — 그 입력이 바뀌면 고름을 풂 */
|
||||
걸림: string[];
|
||||
}
|
||||
|
||||
/** 입력값이 `before` 에서 바뀐 이름에 걸린 고름을 지움(다 풀리면 `고름` 칸째) — `row` 를 고쳐 돌려줌 */
|
||||
export function releaseTied<
|
||||
T extends { 입력값: Record<string, unknown>; 고름?: Record<string, string> },
|
||||
>(row: T, before: Record<string, unknown>, mats: readonly LogicMat[]): T {
|
||||
if (!row.고름) return row;
|
||||
const text = (v: unknown): string => (v === null || v === undefined ? "" : String(v));
|
||||
const keys = new Set([...Object.keys(before), ...Object.keys(row.입력값)]);
|
||||
const changed = [...keys].filter((k) => text(before[k]) !== text(row.입력값[k]));
|
||||
for (const mat of mats)
|
||||
if (mat.걸림.some((n) => changed.includes(n))) delete row.고름[String(mat.차례)];
|
||||
if (!Object.keys(row.고름).length) delete row.고름;
|
||||
return row;
|
||||
}
|
||||
|
||||
/** calc 답의 로직 자재 품목 키 전부 — 사용자 자재 추가 막기 */
|
||||
export function insideKeys(lines: readonly { 재료?: LogicMat[] }[] | undefined): Set<string> {
|
||||
return new Set(
|
||||
(lines ?? []).flatMap((l) => (l.재료 ?? []).flatMap((m) => (m.품목 ? [m.품목.키] : []))),
|
||||
);
|
||||
}
|
||||
@@ -370,7 +370,9 @@ class Expand:
|
||||
|
||||
line = (doc.get("담은로직") or [{}])[0]
|
||||
code = str(line.get("로직") or "")
|
||||
got = labor.run_line(self.whole, code, line.get("입력값"), line.get("켜쌓기율"))
|
||||
got = labor.run_line(
|
||||
self.whole, code, line.get("입력값"), line.get("켜쌓기율"), line.get("고름")
|
||||
)
|
||||
times = labor._number(doc.get("배")) or Decimal(1)
|
||||
base = got.get("기준")
|
||||
why = got.get("까닭") or (None if base else "돈 아닌 로직")
|
||||
@@ -397,7 +399,9 @@ class Expand:
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
|
||||
pill = pill or {}
|
||||
got = labor.run_line(self.whole, one["로직"], one.get("입력값"), one.get("켜쌓기율"))
|
||||
got = labor.run_line(
|
||||
self.whole, one["로직"], one.get("입력값"), one.get("켜쌓기율"), one.get("고름")
|
||||
)
|
||||
times = labor._number(pill.get("배")) or Decimal(1)
|
||||
base = got.get("기준")
|
||||
return {
|
||||
|
||||
@@ -189,6 +189,9 @@ export interface StructureNumbers {
|
||||
/** 일위대가 합계(86-3) — 반영합 · 없으면 기준합(`합계기준` 참) · 계산값 없으면 칸 없음 */
|
||||
일위대가합계?: number;
|
||||
합계기준?: boolean;
|
||||
/** 합계 칸 색(101-1) — 붉음 `error` > 노랑 `short` > 초록 `ok` · 까닭 글은 칸 툴팁 */
|
||||
합계상태?: "ok" | "short" | "error";
|
||||
합계까닭?: string;
|
||||
}
|
||||
|
||||
/** `{열 id: {도번, 면적번}}` — 구조물 열만(글 열 · 없는 열은 키 없음 = 「없음」) */
|
||||
|
||||
@@ -53,6 +53,16 @@
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
/* 로직 자재 줄(103-1) — 금액은 로직 단가 안(합에 안 더함 · 흐리게) · 바꾼 줄 = 소속 글 강조 */
|
||||
.m02-labrow__logic-mat .m02-labrow__money {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.m02-labrow__logic-mat.is-picked-mat .m02-labrow__key {
|
||||
color: var(--color-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.m02-labrow__money,
|
||||
.m02-labrow__num {
|
||||
text-align: right;
|
||||
@@ -90,10 +100,10 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 순서(85-3) — 손잡이 + ▲▼ 작은 단추 · 끌어 놓을 줄 위 선 */
|
||||
/* 순서(85-3 · 102-1) — ▲ · ▼ 작은 단추 위아래 · 끌어 놓을 줄 위(위로) · 아래(아래로) 선 */
|
||||
.m02-labrow__order {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
@@ -116,14 +126,37 @@
|
||||
box-shadow: inset 0 2px 0 var(--color-primary);
|
||||
}
|
||||
|
||||
/* 순서 칸 2행(51-14) — 위 손잡이 ▲▼ / 아래 [옵션]·[수정] [삭제] · 줄 사이도 단추 묶음 간격 */
|
||||
/* 줄 통째 끌기(102-2) — 끄는 줄 무리 흐리게 · 아래로 놓을 자리 = 그 무리 맨 아래 선 */
|
||||
.m02-labrow tr.is-dragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.m02-labrow tr.is-dragunder td {
|
||||
box-shadow: inset 0 -2px 0 var(--color-primary);
|
||||
}
|
||||
|
||||
/* 손잡이(102-1) — 차례 칸 앞 */
|
||||
.m02-labrow__no {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 단추 칸(51-14 · 102-1) — [▲ · ▼ 위아래][옵션|수정][삭제] · 옵션 · 삭제는 두 단추 높이(2행 병합 꼴) */
|
||||
.m02-labrow__tools {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
align-items: stretch;
|
||||
gap: var(--btn-group-gap);
|
||||
}
|
||||
|
||||
.m02-labrow__tools > .ui-btn-group {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* 구분 칸 품셈 책(102-3) — 단가산출 줄만 · 아래 작은 글 */
|
||||
.m02-labrow__book {
|
||||
display: block;
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
/* 옵션 값 줄(44-6) — 이름 ~ 합계에 걸친 한 줄 말줄임(max-width 0 = 표 폭을 안 늘림) · 위 줄과 한 덩이 */
|
||||
.m02-labrow .m02-labrow__optrow td {
|
||||
padding-top: 0;
|
||||
@@ -396,6 +429,20 @@
|
||||
grid-column: 1 / -1;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* 끌기 선(102-2) — 칸마다 말고 줄 카드 하나에 */
|
||||
.m02-labrow tr.is-dragover td,
|
||||
.m02-labrow tr.is-dragunder td {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.m02-labrow tr.is-dragover {
|
||||
box-shadow: inset 0 2px 0 var(--color-primary);
|
||||
}
|
||||
|
||||
.m02-labrow tr.is-dragunder {
|
||||
box-shadow: inset 0 -2px 0 var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
/* 수량산출 칸(40-1) — 위 결과단위 기준 수량 · 아래 지금 수량 · 누르면 손 입력 · 엑셀에서 고르기 */
|
||||
|
||||
@@ -131,6 +131,8 @@ export interface LaborRow {
|
||||
수량: LaborQty;
|
||||
/** 켜쌓기 적용율 %(85-2 · 대상 로직만 · 없음 = 100) */
|
||||
켜쌓기율?: number | null;
|
||||
/** 고른 로직 자재(103-1) — {호표 차례: MT키} · 없으면 엔진 기본 */
|
||||
고름?: Record<string, string>;
|
||||
}
|
||||
export interface LaborMat {
|
||||
코드: string;
|
||||
@@ -167,6 +169,7 @@ export function normalizeLabor(doc: unknown, column: string): LaborDoc {
|
||||
수량: r.수량 ?? null,
|
||||
...(r.참조 ? { 참조: r.참조 } : {}),
|
||||
...(r.켜쌓기율 != null ? { 켜쌓기율: r.켜쌓기율 } : {}),
|
||||
...(r.고름 && Object.keys(r.고름).length ? { 고름: { ...r.고름 } } : {}),
|
||||
})),
|
||||
자재: (d.자재 ?? []).map((m) => ({
|
||||
코드: m.코드,
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* 참조 줄(단가승격 · 사전조합 · 50-3)은 `lineId` = 「참조|층|주인|키」(짝 `M02_Labor_Ref.ref_id`). */
|
||||
|
||||
import type { CalcLine as LogicLine } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||||
import type { LogicMat } from "./M02_Labor_LogicMats_Model";
|
||||
|
||||
export const MONEY_KEYS = ["노무비", "재료비", "경비", "계"] as const;
|
||||
export type Money = Record<(typeof MONEY_KEYS)[number], number>;
|
||||
@@ -36,6 +37,8 @@ export interface CalcLine {
|
||||
결과?: unknown;
|
||||
/** 그 로직 재료 · 노무 · 경비 줄(M01 시험계산 표와 같은 모양 · 50-4) */
|
||||
줄?: LogicLine[];
|
||||
/** 로직 자재(103-1) — 맨 위 층 재료 줄 · 금액은 `기준` 안(여기서 안 더함) */
|
||||
재료?: LogicMat[];
|
||||
/** 참조 줄 — 가리킨 문서 이름 · 단가승격의 원 로직 키 · 사전조합 안 줄(한 층 · 50-3 ④ ·
|
||||
* 참조 줄 = `참조` · 로직 줄(51-11) = `로직`) */
|
||||
이름?: string | null;
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
* [확인] = 화면 캐시만 · 저장은 [저장]
|
||||
* · 금액 = 서버 calc(기준 ③ · 최신 응답만 · 저장 안 함) · 고칠 때마다 뒤에서 다시 셈
|
||||
* · 같은 로직 두 번 · 같은 자재 두 번 막음 · 빈 일위대가도 저장 됨
|
||||
* · 자재목록 = 로직 자재 줄(103-1 · `Labor_LogicMats` · [바꾸기] → `담은로직[].고름` · 입력이 바뀌면 걸린 고름 풂) +
|
||||
* 사용자 자재 줄([삭제]) · 로직 자재와 같은 품목은 사용자 자재로 못 담음
|
||||
* · 오른쪽 「원문」 접는 패널(47-4 · 상세만 · 공용 `ui_template_side_panels`) = 고른 줄 로직의 출처 원문
|
||||
* · 서고 모드(50-3 · `mode`) = 단가승격 · 사전조합 — 이름 칸 · 단가산출 로직 표 · 원문만(자재 · 면적 · 다이어그램 없음) ·
|
||||
* 정보 칸 구분 · 상세구분(51-12 · 승격 = 읽기만 + 연결 · 사전조합 = 고름) ·
|
||||
@@ -57,9 +59,12 @@ import {
|
||||
import { openLaborInput, type GoArea } from "./M02_MasterTemplete_Labor_Input";
|
||||
import { openQtyModal } from "./M02_MasterTemplete_Labor_QtyModal";
|
||||
import { openRefEdit } from "./M02_MasterTemplete_Labor_RefEdit";
|
||||
import { afterRowDrag } from "./M02_MasterTemplete_Labor_RowDrag";
|
||||
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
|
||||
import { logicMatRows } from "./M02_MasterTemplete_Labor_LogicMats";
|
||||
import { logicTable, matTable, qtyShort } from "./M02_MasterTemplete_Labor_Rows";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import { insideKeys, releaseTied } from "./M02_Labor_LogicMats_Model";
|
||||
import { keepKnown } from "./M02_Labor_Values_Model";
|
||||
import { pickMaterial } from "./M02_Material_Pick";
|
||||
import { createUnitPick, NO_UNIT } from "./DataFlow/M02_DF_UnitPick";
|
||||
@@ -103,6 +108,8 @@ export function mountLaborDetail(
|
||||
const promote = lib === "promote";
|
||||
const logics = new Map<string, ItemLogic>();
|
||||
let calc: LaborCalc | null = null;
|
||||
/** 마지막으로 받은 calc — 다시 세는 동안 로직 자재 줄을 그대로 둠(줄이 사라졌다 나타나지 않게) */
|
||||
let last: LaborCalc | null = null;
|
||||
let fail: string | null = null;
|
||||
let seq = 0;
|
||||
let timer = 0;
|
||||
@@ -180,29 +187,43 @@ export function mountLaborDetail(
|
||||
};
|
||||
dgBox.addEventListener("toggle", drawDg);
|
||||
|
||||
// 줄 끄는 동안(102-2)엔 다시 그리지 않음 — 끝난 뒤 한 번
|
||||
const drawLogic = (): void =>
|
||||
logicHost.replaceChildren(
|
||||
logicTable(
|
||||
{ rows: cur.담은로직, logics, calc, fail, cells: cells(), selected: picked },
|
||||
// 읽기만이면 순서 · 삭제 없음([옵션] = 보기 모달은 둠)
|
||||
edit && !promote
|
||||
? {
|
||||
open: pick,
|
||||
option: openRow,
|
||||
ref: openRef,
|
||||
qty: openQty,
|
||||
move: reorder,
|
||||
remove: removeRow,
|
||||
alias: setAlias,
|
||||
}
|
||||
: { open: pick, option: openRow, ref: openRef },
|
||||
afterRowDrag(logicHost, () =>
|
||||
logicHost.replaceChildren(
|
||||
logicTable(
|
||||
{ rows: cur.담은로직, logics, calc, fail, cells: cells(), selected: picked },
|
||||
// 읽기만이면 순서 · 삭제 없음([옵션] = 보기 모달은 둠)
|
||||
edit && !promote
|
||||
? {
|
||||
open: pick,
|
||||
option: openRow,
|
||||
ref: openRef,
|
||||
qty: openQty,
|
||||
move: reorder,
|
||||
remove: removeRow,
|
||||
alias: setAlias,
|
||||
}
|
||||
: { open: pick, option: openRow, ref: openRef },
|
||||
),
|
||||
),
|
||||
);
|
||||
const draw = (): void => {
|
||||
drawLogic();
|
||||
matHost.replaceChildren(
|
||||
matTable(
|
||||
{ mats: cur.자재, calc, fail, cells: cells() },
|
||||
{
|
||||
mats: cur.자재,
|
||||
calc,
|
||||
fail,
|
||||
cells: cells(),
|
||||
lead: logicMatRows(
|
||||
cur.담은로직,
|
||||
calc ?? last,
|
||||
!calc && !fail,
|
||||
edit ? { swap: swapMat, reset: resetMat } : undefined,
|
||||
),
|
||||
},
|
||||
edit ? { open: openMat, remove: removeMat } : { open: openMat },
|
||||
),
|
||||
);
|
||||
@@ -222,7 +243,7 @@ export function mountLaborDetail(
|
||||
// 저장 안 한 칸 연결이 있으면 그 칸 값으로(없으면 서버가 저장된 면적산출을 읽음)
|
||||
const got = await calcLabor(opt.layer, opt.column, cur, areaDirty ? given() : undefined);
|
||||
if (!alive || mine !== seq) return;
|
||||
calc = got;
|
||||
calc = last = got;
|
||||
pills = new Map(
|
||||
(got.알약 as { 키: string; 이름?: string; 단위?: string }[]).map((p) => [p.키, p]),
|
||||
);
|
||||
@@ -343,6 +364,25 @@ export function mountLaborDetail(
|
||||
cur.자재.splice(i, 1);
|
||||
changed();
|
||||
}
|
||||
/** 로직 자재 [바꾸기](103-1) — 사용자 자재와 같은 품목은 막음 */
|
||||
function swapMat(i: number, at: number, key: string): void {
|
||||
const own = cur.자재.find((m) => m.코드 === key);
|
||||
if (own)
|
||||
return showToast(
|
||||
tc("Labor_AlreadyMat").replace("{value}", `${key} ${own.이름 ?? ""}`),
|
||||
"error",
|
||||
);
|
||||
const row = cur.담은로직[i];
|
||||
row.고름 = { ...row.고름, [String(at)]: key };
|
||||
changed();
|
||||
}
|
||||
function resetMat(i: number, at: number): void {
|
||||
const row = cur.담은로직[i];
|
||||
if (!row.고름) return;
|
||||
delete row.고름[String(at)];
|
||||
if (!Object.keys(row.고름).length) delete row.고름;
|
||||
changed();
|
||||
}
|
||||
|
||||
function openRow(i: number): void {
|
||||
const row: LaborRow = cur.담은로직[i];
|
||||
@@ -359,7 +399,9 @@ export function mountLaborDetail(
|
||||
const same =
|
||||
JSON.stringify(values) === JSON.stringify(row.입력값) &&
|
||||
next.켜쌓기율 === (row.켜쌓기율 ?? null);
|
||||
const before = row.입력값;
|
||||
row.입력값 = values;
|
||||
releaseTied(row, before, lineOf(row.로직)?.재료 ?? []); // 바뀐 입력에 걸린 고름 = 기본(103-1)
|
||||
row.메모 = next.메모;
|
||||
if (next.켜쌓기율 === null) delete row.켜쌓기율;
|
||||
else row.켜쌓기율 = next.켜쌓기율;
|
||||
@@ -434,6 +476,11 @@ export function mountLaborDetail(
|
||||
variant: "filled",
|
||||
onClick: () =>
|
||||
pickMaterial((m) => {
|
||||
if (insideKeys((calc ?? last)?.줄).has(m.코드))
|
||||
return showToast(
|
||||
tc("Labor_InLogicMat").replace("{value}", `${m.코드} ${m.이름 ?? ""}`),
|
||||
"error",
|
||||
);
|
||||
if (cur.자재.some((x) => x.코드 === m.코드))
|
||||
return showToast(
|
||||
tc("Labor_AlreadyMat").replace("{value}", `${m.코드} ${m.이름 ?? ""}`),
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Labor_LogicMats.ts
|
||||
* 자재목록 표의 로직 자재 줄(103-1) — 담은 로직 맨 위 층 재료 줄(서버 calc `줄[].재료`) · 사용자 자재 줄과 같은 칸
|
||||
* (코드 · 이름 · 규격 · 수량 · 단위 · 가격 다섯 · 단가 / 금액) · 사용자 자재 앞.
|
||||
* · 삭제 없음 · [바꾸기] = M01 자재품목 표에서 고름(문서 `담은로직[].고름` · 엔진 `choosing`) · 바꾼 줄만 [기본](고름 풂)
|
||||
* · 금액은 로직 단가 안이라 합에 다시 안 더함(보기만) · 수량은 로직 것(누름 · 알약 없음)
|
||||
* · 하위 로직 안 재료는 안 보임(엔진이 맨 위 층만 고름)
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money";
|
||||
import { openPickTable } from "../M01_MasterData/M01_MasterData_UI_Logic_PickTable";
|
||||
import { headWord } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Modal";
|
||||
import type { LaborCalc, LaborRow } from "./M02_MasterTemplete_Labor_Api";
|
||||
import { lineId } from "./M02_MasterTemplete_Labor_Calc";
|
||||
import { moneyCell, PRICE_SLOTS } from "./M02_MasterTemplete_Labor_Rows";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import type { LogicMat } from "./M02_Labor_LogicMats_Model";
|
||||
|
||||
export interface LogicMatHandlers {
|
||||
/** [바꾸기]로 고른 품목 키 — `row` = 담은 로직 차례 · `at` = 호표 차례 */
|
||||
swap: (row: number, at: number, key: string) => void;
|
||||
/** [기본] — 그 줄 고름 풂 */
|
||||
reset: (row: number, at: number) => void;
|
||||
}
|
||||
|
||||
const num = (v: number | null): string =>
|
||||
v === null
|
||||
? "—"
|
||||
: Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 });
|
||||
|
||||
/** 담은 로직 차례대로 그 줄 로직 자재 — calc 답이 없으면 빈 것(답이 오면 다시 그림) */
|
||||
export function logicMatRows(
|
||||
rows: LaborRow[],
|
||||
calc: LaborCalc | null,
|
||||
wait: boolean,
|
||||
on?: LogicMatHandlers,
|
||||
): HTMLElement[] {
|
||||
return rows.flatMap((row, r) => {
|
||||
const line = row.참조 ? undefined : calc?.줄.find((l) => lineId(l) === lineId(row));
|
||||
return (line?.재료 ?? []).map((mat) => matRow(row, r, mat, wait, on));
|
||||
});
|
||||
}
|
||||
|
||||
function matRow(
|
||||
row: LaborRow,
|
||||
r: number,
|
||||
mat: LogicMat,
|
||||
wait: boolean,
|
||||
on?: LogicMatHandlers,
|
||||
): HTMLElement {
|
||||
const item = mat.품목;
|
||||
const name = item?.이름 ?? mat.이름;
|
||||
const owner = `${row.별칭 ?? row.로직} · ${mat.이름}`;
|
||||
const priceCell = (slot: (typeof PRICE_SLOTS)[number]): HTMLElement => {
|
||||
const base = !!item?.값칸 && item.값칸 === slot;
|
||||
const v = mat.가격?.[slot];
|
||||
return el("td", {
|
||||
className: `m02-labrow__num m02-labrow__price${base ? " is-base-price" : ""}`,
|
||||
text: wait ? "…" : v === null || v === undefined ? "—" : formatMoney(v),
|
||||
attrs: { "data-label": slot, "data-price-slot": slot },
|
||||
});
|
||||
};
|
||||
const money = moneyCell(mat.단가, mat.금액, wait, tc("Labor_AppliedSum"));
|
||||
money.title = tc("LogicMat_Inside");
|
||||
const tools = on
|
||||
? [
|
||||
el("td", {
|
||||
className: "m02-labrow__move",
|
||||
children: [
|
||||
el("div", {
|
||||
className: "ui-btn-group",
|
||||
children: [
|
||||
createButton({
|
||||
label: tc("LogicMat_Swap"),
|
||||
variant: "ghost",
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
openPickTable({
|
||||
kind: "재료",
|
||||
title: `${tc("LogicMat_PickTitle")} · ${mat.이름}`,
|
||||
seed: headWord(name),
|
||||
alt: [headWord(mat.이름)],
|
||||
isCurrent: (x) => String(x["키"]) === item?.키,
|
||||
onPick: (x) => on.swap(r, mat.차례, String(x["키"])),
|
||||
});
|
||||
},
|
||||
}),
|
||||
...(mat.고름
|
||||
? [
|
||||
createButton({
|
||||
label: tc("LogicMat_Reset"),
|
||||
variant: "ghost",
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
on.reset(r, mat.차례);
|
||||
},
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: [];
|
||||
return el("tr", {
|
||||
className: `m02-labrow__row m02-labrow__logic-mat${mat.고름 ? " is-picked-mat" : ""}`,
|
||||
attrs: { "data-labor-logic-mat": `${row.로직}|${mat.차례}` },
|
||||
children: [
|
||||
el("td", { className: "m02-labrow__code", text: item?.키 ?? "—" }),
|
||||
el("td", {
|
||||
className: "m02-labrow__name",
|
||||
children: [
|
||||
el("strong", { text: name, attrs: { title: name } }),
|
||||
el("small", {
|
||||
className: "m02-labrow__key",
|
||||
text: mat.고름 ? `${owner} · ${tc("LogicMat_Picked")}` : owner,
|
||||
attrs: { title: owner },
|
||||
}),
|
||||
...(mat.까닭 ? [el("small", { className: "m02-labrow__empty", text: mat.까닭 })] : []),
|
||||
],
|
||||
}),
|
||||
el("td", {
|
||||
className: "m02-labrow__spec",
|
||||
text: item?.규격 ?? "",
|
||||
attrs: { title: item?.규격 ?? "" },
|
||||
}),
|
||||
el("td", {
|
||||
className: "m02-labrow__qty",
|
||||
text: wait ? "…" : num(mat.수량),
|
||||
attrs: { "data-label": tc("Labor_QtyCol") },
|
||||
}),
|
||||
el("td", {
|
||||
className: "m02-labrow__unit",
|
||||
text: mat.단위,
|
||||
attrs: { "data-label": tc("Labor_Unit") },
|
||||
}),
|
||||
...PRICE_SLOTS.map(priceCell),
|
||||
money,
|
||||
...tools,
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Labor_RowDrag.ts
|
||||
* 일위대가 담은 로직 줄 통째 끌기(102-2) — 줄 · 옵션 값 줄 · 하위 줄 어디서나(손잡이도 같은 길).
|
||||
* 6px 움직여야 끌기(B05 `Profile_Wheel` 선례) · 그 아래는 누름(열고 닫기) · 끈 뒤 누름 한 번 삼킴(열림 · 닫힘 그대로) ·
|
||||
* 단추 · 입력칸 · 수량 여는 자리에서는 시작 안 함(별칭 글 고르기 85-3) · 놓은 줄 자리로 `move` ·
|
||||
* 끄는 동안 표를 다시 그리지 않음(`afterRowDrag` — 끝난 뒤 한 번).
|
||||
* ========================================================================== */
|
||||
|
||||
/** 이만큼 움직여야 끌기 — 그 아래는 누름(B05 와 같음 · 4px 은 손떨림에 넘어감) */
|
||||
export const DRAG_THRESHOLD_PX = 6;
|
||||
const BLOCK = "button, input, select, textarea, a, [data-labor-qty-open]";
|
||||
const HEAD = "tr[data-labor-logic]";
|
||||
|
||||
let active = false;
|
||||
const queued = new Map<Element, () => void>();
|
||||
|
||||
/** 다시 그림 — 끄는 동안이면 끝난 뒤로 미룸(자리마다 마지막 것만) */
|
||||
export function afterRowDrag(host: Element, draw: () => void): void {
|
||||
if (active) return void queued.set(host, draw);
|
||||
queued.delete(host);
|
||||
draw();
|
||||
}
|
||||
|
||||
/** 문턱 — 가로 · 세로 어느 쪽이든 6px 이면 끌기 */
|
||||
export const pastThreshold = (dx: number, dy: number): boolean =>
|
||||
Math.abs(dx) >= DRAG_THRESHOLD_PX || Math.abs(dy) >= DRAG_THRESHOLD_PX;
|
||||
|
||||
/** 그 줄이 속한 로직 줄(옵션 값 줄 · 하위 줄은 위로 올라가 찾음) */
|
||||
const headOf = (tr: Element | null | undefined): Element | null => {
|
||||
let t = tr ?? null;
|
||||
while (t && !t.matches(HEAD)) t = t.previousElementSibling;
|
||||
return t;
|
||||
};
|
||||
|
||||
/** 로직 줄 무리 — 로직 줄 + 옵션 값 줄 + 하위 줄 */
|
||||
const groupOf = (head: Element): Element[] => {
|
||||
const out = [head];
|
||||
for (let t = head.nextElementSibling; t && !t.matches(HEAD); t = t.nextElementSibling)
|
||||
out.push(t);
|
||||
return out;
|
||||
};
|
||||
|
||||
/** `root` = 표 감싼 칸 · 줄 차례 = tbody 안 로직 줄 차례(같은 로직 두 줄이어도 자리로 셈) */
|
||||
export function attachRowDrag(root: HTMLElement, move: (from: number, to: number) => void): void {
|
||||
let id: number | null = null;
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
let moved = false;
|
||||
let from = -1;
|
||||
let to = -1;
|
||||
let marks: Element[] = [];
|
||||
const heads = (): Element[] => [...root.querySelectorAll(`tbody > ${HEAD}`)];
|
||||
const unmark = (): void => {
|
||||
for (const m of marks) m.classList.remove("is-dragging", "is-dragover", "is-dragunder");
|
||||
marks = [];
|
||||
};
|
||||
const mark = (els: Element[], cls: string): void => {
|
||||
for (const e of els) e.classList.add(cls);
|
||||
marks.push(...els);
|
||||
};
|
||||
|
||||
const end = (drop: boolean): void => {
|
||||
const pid = id;
|
||||
id = null;
|
||||
if (pid === null || !moved) return;
|
||||
moved = false;
|
||||
if (root.hasPointerCapture(pid)) root.releasePointerCapture(pid);
|
||||
unmark();
|
||||
root.style.userSelect = "";
|
||||
root.style.cursor = "";
|
||||
active = false;
|
||||
// 끈 뒤 누름 한 번 삼킴(캡처 · 다음 프레임에 걷음) — 열림 · 닫힘 그대로
|
||||
const swallow = (e: MouseEvent): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
};
|
||||
window.addEventListener("click", swallow, { capture: true, once: true });
|
||||
requestAnimationFrame(() => window.removeEventListener("click", swallow, { capture: true }));
|
||||
if (drop && to >= 0 && to !== from) move(from, to);
|
||||
const later = [...queued.values()];
|
||||
queued.clear();
|
||||
for (const draw of later) draw();
|
||||
};
|
||||
|
||||
root.addEventListener("pointerdown", (e) => {
|
||||
if (e.button !== 0 || moved) return;
|
||||
const t = e.target as Element;
|
||||
if (t.closest(BLOCK)) return;
|
||||
const head = headOf(t.closest("tbody > tr"));
|
||||
from = head ? heads().indexOf(head) : -1;
|
||||
if (from < 0) return;
|
||||
id = e.pointerId;
|
||||
x = e.clientX;
|
||||
y = e.clientY;
|
||||
to = -1;
|
||||
});
|
||||
|
||||
root.addEventListener("pointermove", (e) => {
|
||||
if (id !== e.pointerId) return;
|
||||
if (!moved) {
|
||||
if (!(e.buttons & 1)) return void (id = null); // 누름 없이 들어옴 — 끌기 아님
|
||||
if (!pastThreshold(e.clientX - x, e.clientY - y)) return;
|
||||
moved = true;
|
||||
active = true;
|
||||
// 이제부터 끌기 — 표 밖으로 나가도 이어지게 포인터를 잡음 · 글자 고름 막음
|
||||
root.setPointerCapture(e.pointerId);
|
||||
root.style.userSelect = "none";
|
||||
root.style.cursor = "grabbing";
|
||||
window.getSelection()?.removeAllRanges();
|
||||
}
|
||||
e.preventDefault();
|
||||
unmark();
|
||||
const list = heads();
|
||||
if (list[from]) mark(groupOf(list[from]), "is-dragging");
|
||||
const over = headOf(document.elementFromPoint(e.clientX, e.clientY)?.closest("tbody > tr"));
|
||||
to = over ? list.indexOf(over) : -1;
|
||||
if (!over || to < 0 || to === from) return;
|
||||
// 위로 = 놓을 줄 위 선 · 아래로 = 놓을 줄 무리 아래 선(그 뒤로 감)
|
||||
if (to < from) mark([over], "is-dragover");
|
||||
else mark(groupOf(over).slice(-1), "is-dragunder");
|
||||
});
|
||||
|
||||
root.addEventListener("pointerup", () => end(true));
|
||||
root.addEventListener("pointercancel", () => end(false));
|
||||
root.addEventListener("lostpointercapture", () => end(false));
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
LaborRow,
|
||||
} from "./M02_MasterTemplete_Labor_Api";
|
||||
import { lineId } from "./M02_MasterTemplete_Labor_Calc";
|
||||
import { attachRowDrag } from "./M02_MasterTemplete_Labor_RowDrag";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import "@ui/ui_template_general_layout.css";
|
||||
import "./M02_MasterTemplete_Labor.css";
|
||||
@@ -47,6 +48,9 @@ export const kindOf = (row: Pick<LaborRow, "로직" | "참조">): string => {
|
||||
);
|
||||
};
|
||||
|
||||
/** 품셈 책(102-3) — 출처 첫 낱말 */
|
||||
const BOOK = /^(건설|산림)품셈/;
|
||||
|
||||
const num = (v: number): string =>
|
||||
Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 });
|
||||
|
||||
@@ -121,7 +125,7 @@ function qtyCell(
|
||||
}
|
||||
|
||||
/** 금액 칸 하나 — 위 기준(작게) · 아래 반영 · `label` = 좁은 칸에서 칸 이름(머리 줄 대신) */
|
||||
function moneyCell(
|
||||
export function moneyCell(
|
||||
base: number | null | undefined,
|
||||
applied: number | null | undefined,
|
||||
wait: boolean,
|
||||
@@ -148,9 +152,6 @@ function moneyCell(
|
||||
}
|
||||
|
||||
/** 이름 칸 글마다 title = 전체 글(한 줄 말줄임 · 45-7) */
|
||||
/** 끌고 있는 줄 차례(85-3) — 손잡이 dragstart ~ drop */
|
||||
let dragFrom: number | null = null;
|
||||
|
||||
const titled = (td: HTMLElement): HTMLElement => {
|
||||
for (const c of td.children) c.setAttribute("title", c.textContent ?? "");
|
||||
return td;
|
||||
@@ -182,7 +183,7 @@ export interface RowHandlers {
|
||||
ref?: (i: number) => void;
|
||||
/** 수량산출 칸 누름(손 입력 · 엑셀에서 고르기) — 없으면 줄 누름과 같음 */
|
||||
qty?: (i: number) => void;
|
||||
/** 줄 순서(85-3) — from 줄을 to 자리로(▲▼ = 옆 자리 · 손잡이 끌기 = 놓은 줄 자리) */
|
||||
/** 줄 순서(85-3) — from 줄을 to 자리로(▲▼ = 옆 자리 · 줄 끌기 102-2 = 놓은 줄 자리) */
|
||||
move?: (from: number, to: number) => void;
|
||||
remove?: (i: number) => void;
|
||||
/** 별칭 칸 고침(44-6 · 글자마다 · 빈 글 = null) — 없으면 글만 보임 */
|
||||
@@ -315,6 +316,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
row.켜쌓기율 != null ? tc("Labor_Layer").replace("{n}", String(row.켜쌓기율)) : "";
|
||||
const note = [values.text, layer].filter(Boolean).join(" · ");
|
||||
const why = line?.까닭 ?? (data.fail && i === 0 ? data.fail : null);
|
||||
const book = row.참조 ? undefined : BOOK.exec(logic?.출처 ?? "")?.[0];
|
||||
const small = (
|
||||
label: string,
|
||||
run: () => void,
|
||||
@@ -328,7 +330,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
run();
|
||||
},
|
||||
});
|
||||
// 순서(85-3) — ▲▼ 작은 단추(글은 title · aria) + 손잡이 끌기(줄 전체는 안 끎 · 별칭 칸 글 고르기)
|
||||
// 순서(85-3) — ▲▼ 작은 단추(글은 title · aria) · 손잡이 = 차례 칸 앞(102-1) · 끌기는 줄 통째(102-2 · `Labor_RowDrag`)
|
||||
const arrow = (mark: string, label: string, to: number): HTMLElement => {
|
||||
const b = small(mark, () => move?.(i, to));
|
||||
b.classList.add("m02-labrow__arrow");
|
||||
@@ -341,17 +343,9 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
const g = el("span", {
|
||||
className: "m02-labrow__grip",
|
||||
text: "⠿",
|
||||
attrs: { draggable: "true", title: tc("Labor_Drag"), "data-labor-grip": String(i) },
|
||||
});
|
||||
g.addEventListener("click", (e) => e.stopPropagation());
|
||||
g.addEventListener("dragstart", (ev) => {
|
||||
dragFrom = i;
|
||||
ev.dataTransfer?.setData("text/plain", String(i));
|
||||
if (ev.dataTransfer) ev.dataTransfer.effectAllowed = "move";
|
||||
});
|
||||
g.addEventListener("dragend", () => {
|
||||
dragFrom = null;
|
||||
attrs: { title: tc("Labor_Drag"), "data-labor-grip": String(i) },
|
||||
});
|
||||
g.addEventListener("click", (e) => e.stopPropagation()); // 손잡이 누름은 열고 닫지 않음
|
||||
return g;
|
||||
};
|
||||
const sel = data.selected === id ? " is-selected" : "";
|
||||
@@ -359,8 +353,18 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
className: (on ? "m02-labrow__row is-open" : "m02-labrow__row") + sel,
|
||||
attrs: { "data-labor-logic": id },
|
||||
children: [
|
||||
el("td", { className: "m02-labrow__no", text: String(i + 1) }),
|
||||
el("td", { className: "m02-labrow__kind", text: kindOf(row) }),
|
||||
el("td", {
|
||||
className: "m02-labrow__no",
|
||||
children: move ? [grip(), String(i + 1)] : [String(i + 1)],
|
||||
}),
|
||||
el("td", {
|
||||
className: "m02-labrow__kind",
|
||||
children: [
|
||||
kindOf(row),
|
||||
// 102-3 — 단가산출 줄만 품셈 책(출처 첫 낱말)
|
||||
...(book ? [el("small", { className: "m02-labrow__book", text: book })] : []),
|
||||
],
|
||||
}),
|
||||
titled(
|
||||
el("td", {
|
||||
className: "m02-labrow__name",
|
||||
@@ -406,7 +410,6 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
el("div", {
|
||||
className: "m02-labrow__order",
|
||||
children: [
|
||||
grip(),
|
||||
arrow("▲", tc("Up"), i - 1),
|
||||
arrow("▼", tc("Down"), i + 1),
|
||||
],
|
||||
@@ -437,21 +440,6 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
],
|
||||
});
|
||||
if (on) tr.addEventListener("click", () => on.open(i));
|
||||
if (move) {
|
||||
tr.addEventListener("dragover", (ev) => {
|
||||
if (dragFrom === null || dragFrom === i) return;
|
||||
ev.preventDefault();
|
||||
tr.classList.add("is-dragover");
|
||||
});
|
||||
tr.addEventListener("dragleave", () => tr.classList.remove("is-dragover"));
|
||||
tr.addEventListener("drop", (ev) => {
|
||||
ev.preventDefault();
|
||||
tr.classList.remove("is-dragover");
|
||||
const from = dragFrom;
|
||||
dragFrom = null;
|
||||
if (from !== null && from !== i) move(from, i);
|
||||
});
|
||||
}
|
||||
const subs = sel ? subRows(line, wait, tools) : [];
|
||||
if (!note) return [tr, ...subs];
|
||||
// 옵션 값 줄(44-6) — 이름 ~ 합계 칸에 걸친 한 줄(말줄임 · 전체 글은 title) · 행 높이 안 커짐
|
||||
@@ -509,13 +497,16 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
...COSTS.map((c) => tc(HEAD[c])),
|
||||
...(tools ? [tc("Col_Move")] : []),
|
||||
];
|
||||
return data.rows.length
|
||||
? table(heads, trs, foot)
|
||||
: el("p", { className: "m02-labrow__muted", text: tc("NoLogic") });
|
||||
if (!data.rows.length) return el("p", { className: "m02-labrow__muted", text: tc("NoLogic") });
|
||||
const out = table(heads, trs, foot);
|
||||
if (move) attachRowDrag(out, move);
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface MatTableData {
|
||||
mats: LaborMat[];
|
||||
/** 로직 자재 줄(103-1 · `Labor_LogicMats`) — 사용자 자재 앞 · 같은 칸 */
|
||||
lead?: HTMLElement[];
|
||||
calc: LaborCalc | null;
|
||||
fail?: string | null;
|
||||
cells?: ReadonlyMap<string, string>;
|
||||
@@ -609,7 +600,8 @@ export function matTable(data: MatTableData, on?: Omit<RowHandlers, "move">): HT
|
||||
`${tc("Labor_BasePrice")} · ${tc("Labor_AppliedSum")}`,
|
||||
...(remove ? [tc("Col_Move")] : []),
|
||||
];
|
||||
return data.mats.length
|
||||
? table(heads, trs, [])
|
||||
const lead = data.lead ?? [];
|
||||
return data.mats.length || lead.length
|
||||
? table(heads, [...lead, ...trs], [])
|
||||
: el("p", { className: "m02-labrow__muted", text: tc("Labor_NoMat") });
|
||||
}
|
||||
|
||||
@@ -110,6 +110,16 @@ const TEXT = {
|
||||
Labor_Spec: ["규격", "Spec"],
|
||||
Labor_Already: ["이미 담은 단가산출 로직 — {value}", "Already added — {value}"],
|
||||
Labor_AlreadyMat: ["이미 담은 자재 — {value}", "Already added — {value}"],
|
||||
// 로직 자재(103-1)
|
||||
Labor_InLogicMat: ["로직 자재에 있는 품목 — {value}", "Already a logic material — {value}"],
|
||||
LogicMat_Swap: ["바꾸기", "Change"],
|
||||
LogicMat_Reset: ["기본", "Default"],
|
||||
LogicMat_PickTitle: ["로직 자재 바꾸기", "Change logic material"],
|
||||
LogicMat_Inside: [
|
||||
"로직 단가 안 금액 — 합계에 다시 안 더함",
|
||||
"Inside the logic price — not added again",
|
||||
],
|
||||
LogicMat_Picked: ["바꾼 품목", "Changed item"],
|
||||
In_Title: ["넣을 값 — {value}", "Inputs — {value}"],
|
||||
In_None: ["이 로직은 넣을 값이 없음", "This logic has no inputs"],
|
||||
In_Memo: ["메모", "Memo"],
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
type LaborDoc,
|
||||
} from "./M02_MasterTemplete_Labor_Api";
|
||||
import { lineId, settle } from "./M02_MasterTemplete_Labor_Calc";
|
||||
import { logicMatRows } from "./M02_MasterTemplete_Labor_LogicMats";
|
||||
import { logicTable, matTable } from "./M02_MasterTemplete_Labor_Rows";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import "./M02_MasterTemplete_Labor.css";
|
||||
@@ -92,7 +93,10 @@ export function createLaborView(onRow?: (row: LaborViewRow) => void): LaborViewH
|
||||
onRow ? { open: logicOpen } : undefined,
|
||||
),
|
||||
el("h4", { className: "m02-labview__title", text: tc("Labor_Mats") }),
|
||||
matTable({ mats: d.자재, calc, fail, cells }, onRow ? { open: matOpen } : undefined),
|
||||
matTable(
|
||||
{ mats: d.자재, calc, fail, cells, lead: logicMatRows(d.담은로직, calc, !calc && !fail) },
|
||||
onRow ? { open: matOpen } : undefined,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -237,20 +237,39 @@ def _total(sums: Any) -> float | None:
|
||||
return None
|
||||
|
||||
|
||||
def _sum_state(calc: dict[str, Any]) -> dict[str, Any]:
|
||||
"""합계 칸 색 상태(101-1) — 붉음 `error` > 노랑 `short` > 초록 `ok` + 까닭 글.
|
||||
붉음 = 기준이 안 나온 돈 줄(까닭 있음) · 기준 단가 없는 자재 / 노랑 = 수량 없는 줄 · 자재 /
|
||||
돈 아닌 로직 줄(`결과`)은 판정에서 뺌."""
|
||||
bad = short = 0
|
||||
for one in (*(calc.get("줄") or []), *(calc.get("자재") or [])):
|
||||
if not isinstance(one, dict):
|
||||
continue
|
||||
if one.get("기준", one.get("기준단가")) is None:
|
||||
bad += 1 if one.get("까닭") else 0
|
||||
elif one.get("수량") is None:
|
||||
short += 1
|
||||
why = [f"{t} {n}" for t, n in (("기준 단가 없음", bad), ("수량 없는 줄", short)) if n]
|
||||
return {
|
||||
"합계상태": "error" if bad else "short" if short else "ok",
|
||||
"합계까닭": " · ".join(why) or "모두 반영",
|
||||
}
|
||||
|
||||
|
||||
def _labor_sum(doc: dict[str, Any]) -> dict[str, Any]:
|
||||
"""일위대가 합계 칸(86-3) — 반영합 `계`(0 아님) · 없으면 기준합 `계` + `합계기준` 참 ·
|
||||
담은 로직 0 · 계산값 없음 = 빈 칸 (목록 `labor` 합계와 같은 규칙)."""
|
||||
담은 로직 0 · 계산값 없음 = 빈 칸 (목록 `labor` 합계와 같은 규칙) · `합계상태`(101-1)."""
|
||||
if not doc.get("담은로직"):
|
||||
return {}
|
||||
calc = doc.get("계산값") or {}
|
||||
if total := _total(calc.get("반영합")):
|
||||
return {"일위대가합계": total, "합계기준": False}
|
||||
return {"일위대가합계": total, "합계기준": False, **_sum_state(calc)}
|
||||
base = _total(calc.get("기준합"))
|
||||
return {} if base is None else {"일위대가합계": base, "합계기준": True}
|
||||
return {} if base is None else {"일위대가합계": base, "합계기준": True, **_sum_state(calc)}
|
||||
|
||||
|
||||
def structure_numbers() -> dict[str, dict[str, Any]]:
|
||||
"""`{열 id: {도번, 일위대가번, 면적번, (일위대가합계, 합계기준)}}` — 집계표 줄 · 좌측 줄이 한 번에
|
||||
"""`{열 id: {도번, 일위대가번, 면적번, (일위대가합계, 합계기준, 합계상태, 합계까닭)}}` — 집계표 줄 · 좌측 줄이 한 번에
|
||||
받음(번호 없는 쪽은 빈 글 · 합계는 일위대가 계산값이 있을 때만)."""
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
for kind, key, _, _ in PARTS:
|
||||
|
||||
@@ -58,7 +58,6 @@ const TEXT = {
|
||||
noPills: "이 열 일위대가에 단가산출 로직 · 자재가 없어 알약 없음 — 일위대가 상세에서 담음",
|
||||
pillsFailed: "일위대가 알약을 못 받음 — {value}",
|
||||
discardCards: "카드(도면 · 면적산출)에 저장 안 한 고침이 있음 — 버리고 넘어갈까?",
|
||||
baseNote: "(기준)",
|
||||
};
|
||||
|
||||
/** 집계표 줄 열쇠 → 문서 */
|
||||
@@ -74,11 +73,17 @@ const fill = (text: string, value: string): string => text.replace("{value}", va
|
||||
/** `{열 id: {도번, 면적번}}` — 집계표 번호 줄(없는 열은 키 없음 = 「없음」) */
|
||||
export type ColumnNumbers = Record<string, StructureNumbers>;
|
||||
|
||||
/** 집계표 「일위대가 합계」 줄 글(86-3) — 반영합 · 수량이 없으면 기준합 + 「(기준)」 · 계산값 없음 = 빈 글(「—」) */
|
||||
/** 집계표 「일위대가 합계」 줄 글(86-3) — 반영합 · 없으면 기준합 · 계산값 없음 = 빈 글(「—」) · 색은 `laborSumState` */
|
||||
export function laborSumText(numbers: StructureNumbers | undefined): string {
|
||||
if (typeof numbers?.일위대가합계 !== "number") return "";
|
||||
const text = numbers.일위대가합계.toLocaleString("ko-KR", { maximumFractionDigits: 0 });
|
||||
return numbers.합계기준 ? `${text} ${TEXT.baseNote}` : text;
|
||||
return numbers.일위대가합계.toLocaleString("ko-KR", { maximumFractionDigits: 0 });
|
||||
}
|
||||
|
||||
/** 합계 칸 색 상태 · 툴팁 까닭(101-1) — 계산값 없음 · 담은 것 없음 = 색 없음 */
|
||||
export function laborSumState(
|
||||
numbers: StructureNumbers | undefined,
|
||||
): { state: string; why: string } | undefined {
|
||||
return numbers?.합계상태 ? { state: numbers.합계상태, why: numbers.합계까닭 ?? "" } : undefined;
|
||||
}
|
||||
|
||||
/** 집계표 열 — 머리 글(제목행 종류 · 분류1 · 분류2 · 분류3) · 꼴만 씀 */
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
comboMerge,
|
||||
comboView,
|
||||
createStructurePanel,
|
||||
laborSumState,
|
||||
laborSumText,
|
||||
pillsFor,
|
||||
STRUCTURE_TABLE,
|
||||
@@ -372,6 +373,7 @@ export function createMain(
|
||||
drawingLabel: (colId: string) => struct.numbers()[colId]?.도번 ?? "",
|
||||
areaLabel: (colId: string) => struct.numbers()[colId]?.면적번 ?? "",
|
||||
laborSumLabel: (colId: string) => laborSumText(struct.numbers()[colId]), // 86-3
|
||||
laborSumState: (colId: string) => laborSumState(struct.numbers()[colId]), // 101-1
|
||||
combo: {
|
||||
...(readOnly || !extras.onHeadRename
|
||||
? {}
|
||||
|
||||
Reference in New Issue
Block a user