fix(m02): 일위대가 별도계상 자재 셈에서 뺌 · 원문 같은 책 절 모두 · 단위 까닭 시험(222-1)

- 로직 자재 별도계상(빈 칸 = 기본) = 기준에서 빼고 일위대가 상세 줄에서 지움 · 자재목록엔 남김
- 사용자 자재 별도계상 = 합에서 뺌(파이썬 · TS settle 짝 · 거울 시험)
- 사전조합 안 줄 · 프로젝트 사본 셈도 같은 길
- 별도계상 바꾸면 다시 셈(로직 자재 = 서버 · 사용자 자재 = 짝 셈)
- 원문 출처 = 로직 출처 + 호표 · 입력 출처 같은 책 절(산림품셈 12-1-2 · 12-1-3)
- 기존 시험 셋 = 직접계산 표시 · 대조용 apart 끔

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
This commit is contained in:
2026-10-10 16:39:00 +09:00
co-authored by Claude Opus 5.5
parent 9085a51056
commit 9a850212d5
9 changed files with 230 additions and 14 deletions
+37 -3
View File
@@ -20,9 +20,11 @@
고름 = `{호표 차례: MT키}`(엔진 `choosing` 그대로) · 금액은 로직 재료비 안이라 합 · 알약에 안 더함(보기만) · 사용자 자재와
같은 품목 두 번은 [저장] 때 400.
승격(134-1) = 로직 줄 「단가승격」 표시(`true` 만 · 없음 = 단가산출) · 금액 무관 · B09 호표 몫.
자재 세 칸(148-4) = `MARKS` 허용 값만(그 밖 · 빈 것 버림 · 금액 무관) — 사용자 자재 = 줄 덧칸 ·
자재 세 칸(148-4) = `MARKS` 허용 값만(그 밖 · 빈 것 버림 · 별도계상만 금액에 닿음) — 사용자 자재 = 줄 덧칸 ·
로직 자재 = 로직 줄 `자재표시 {호표 차례: {세 칸}}`(차례가 재료 줄인 것만 · 틀리면 버림) ·
빈 칸 = 읽을 때 `MARK_DEFAULT`(`mark_of` · 저장은 비움 · 옛 「일반」 = 버림 → 사급 · 162-1).
별도계상(222-1) = 그 자재 금액은 기준 · 합에서 빠지고 로직 `줄` 에서 지움(`drop_apart` · `settle` ·
짝 `Labor_Calc.ts`) · `재료` · `자재`(자재목록 · B08 수량)엔 남음.
% 덧줄(149-4) = 식 「X * Y / 100」 덧줄에 덧칸 `율`(Y) · `규격`(「노무비의 %」 등 · `pct_extras`) — 표기만 ·
단위 · 수량 · 금액 그대로(「식 · 1」) · 화면이 율 · % 로 그림 · M01 시험계산 표도 이 함수.
"""
@@ -224,6 +226,35 @@ def mark_of(one: Any, key: str) -> str:
return marks(one).get(key) or MARK_DEFAULT[key]
def apart(one: Any) -> bool:
"""별도계상(222-1) — 세 칸 「별도계상」(빈 칸 = 기본) 자재는 일위대가 셈에서 빠짐(본 프로그램에서
따로 계상 · 자재목록은 상세 보기라 따로 안 더함)."""
return mark_of(one, "별도계상") == "별도계상"
def drop_apart(got: dict[str, Any], shown: Any) -> dict[str, Any]:
"""로직 줄 답(`run_line`)에서 별도계상 로직 자재 빼기(222-1) — 그 재료 줄 금액을 기준에서 빼고
`줄`(일위대가 상세)에서 지움 · `재료`(자재목록 · 세 칸 고침)는 그대로.
ponytail: 「재료비의 %」 덧줄은 엔진 값 그대로(뺀 재료 몫 안 고침) — 그런 로직이 별도계상을 쓰면 엔진에 넘김."""
shown = shown if isinstance(shown, dict) else {}
lines = got.get("줄") or []
out = {
n
for m in got.get("재료") or []
if (n := m["차례"]) < len(lines) and apart(shown.get(str(n)))
}
if not out or not got.get("기준"):
return got
base = {k: _number(v) or Decimal(0) for k, v in got["기준"].items()}
for n in out:
for k, v in (lines[n].get("비목") or {}).items():
cut = _number(v) or Decimal(0)
base[k] = base.get(k, Decimal(0)) - cut
base["계"] -= cut
kept = [x for i, x in enumerate(lines) if i not in out]
return {**got, "기준": _json(base), "줄": kept}
def _mat_marks(value: Any, logic: dict) -> dict[str, dict[str, str]] | None:
"""로직 자재 세 칸(148-4) `{호표 차례: {세 칸}}` — 맨 위 층 재료 줄 차례 · 허용 값만 ·
빈 것 = None."""
@@ -386,6 +417,7 @@ def settle(result: dict, doc: dict, given: dict[str, Any]) -> dict[str, Any]:
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 []}
aside = {one.get("코드") for one in doc.get("자재") or [] if apart(one)} # 222-1 합에서 뺌
zero = dict.fromkeys((*COSTS, "계"), Decimal(0))
lines, mats, bases, reflects = [], [], [], []
for line in result.get("줄") or []:
@@ -399,9 +431,11 @@ def settle(result: dict, doc: dict, given: dict[str, Any]) -> dict[str, Any]:
qty, source = _pick(mhands.get(one.get("코드")), one.get("알약"), have)
price = _number(one.get("기준단가"))
cost = price * qty if price is not None and qty is not None else None
mats.append({**one, "수량": qty, "수량출처": source, "반영": cost})
if one.get("코드") in aside:
continue
bases.append({**zero, "재료비": price, "계": price} if price is not None else None)
reflects.append({**zero, "재료비": cost, "계": cost} if cost is not None else None)
mats.append({**one, "수량": qty, "수량출처": source, "반영": cost})
return _json({"줄": lines, "자재": mats, "기준합": _sum(bases), "반영합": _sum(reflects)})
@@ -468,7 +502,7 @@ def calc(
"배": pill.get("배") or 1,
}
got = run_line(whole, code, one.get("입력값"), one.get("켜쌓기율"), one.get("고름"))
lines.append({**line, **got})
lines.append({**line, **drop_apart(got, one.get("자재표시"))})
mats = [
{
"코드": one["코드"],
+23 -1
View File
@@ -8,6 +8,7 @@
from __future__ import annotations
import re
from decimal import Decimal
from typing import Any
@@ -16,6 +17,7 @@ from M01_MasterData import M01_MasterData_Store_Combo as combo_store
_SPEC = ("이름", "설명", "단위", "고르기", "범위")
_LAYERED = "골쌓기 기준" # 85-2 — 표 주문에 이 글이 있으면 켜쌓기율 대상 로직
_NUMBER = re.compile(r"(?<![\d-])\d+(?:-\d+)+(?![\d-])") # 품셈 절 번호(「12-1-3」)
def _num(value: Any) -> Any:
@@ -63,6 +65,26 @@ def _labels(whole: Any, spec: dict) -> dict[str, str]:
return out
def sources(logic: dict) -> Any:
"""원문 출처(222-1) — 로직 `출처` + 호표 · 입력 · 중간 · 덧줄 `출처` 가운데 같은 책(번호 앞 글이
같음)의 절 번호를 덧붙임(「산림품셈 12-1-2 · 12-1-3」 · 원문 패널이 절마다 찾음 · M01
`make.source`) · 다른 책 절은 안 붙임 · 번호 없는 출처는 그대로."""
main = str(logic.get("출처") or "")
head = _NUMBER.search(main)
if not head:
return logic.get("출처")
book, seen = main[: head.start()], set(_NUMBER.findall(main))
extra = []
for part in ("호표", "입력", "중간", "덧줄"):
for row in logic.get(part) or []:
src = str(row.get("출처") or "") if isinstance(row, dict) else ""
hit = _NUMBER.search(src)
if hit and src[: hit.start()] == book and hit.group() not in seen:
seen.add(hit.group())
extra.append(hit.group())
return " · ".join([main, *extra])
def _listed(logics: list[dict]) -> list[dict[str, Any]]:
"""로직 줄마다 `{로직, 이름, 결과단위, 출처, 켜쌓기, 입력: [{이름, 설명, 단위, 고르기?, 고르기이름?, 범위?, 자동값}]}`."""
whole = m01.loaded()[1]
@@ -84,7 +106,7 @@ def _listed(logics: list[dict]) -> list[dict[str, Any]]:
"로직": logic.get("키"),
"이름": logic.get("이름"),
"결과단위": logic.get("결과단위"),
"출처": logic.get("출처"),
"출처": sources(logic),
"켜쌓기": logic.get("키") in layers,
"입력": specs,
}
+2 -1
View File
@@ -423,6 +423,7 @@ class Expand:
got = labor.run_line(
self.whole, one["로직"], one.get("입력값"), one.get("켜쌓기율"), one.get("고름")
)
got = labor.drop_apart(got, one.get("자재표시")) # 별도계상 로직 자재 뺌(222-1)
times = labor._number(pill.get("배")) or Decimal(1)
base = got.get("기준")
return {
@@ -463,7 +464,7 @@ class Expand:
rows.append({k: v * qty for k, v in sub["기준"].items()})
for mat in doc.get("자재") or []: # 사용자 자재(175-2) = 기준단가 × 손 · 빈칸 = 안 셈
qty = labor._number((mat.get("수량") or {}).get("손"))
if qty is None:
if qty is None or labor.apart(mat): # 별도계상 = 안 셈 · 안 줄에도 없음(222-1)
continue
got = labor.material_price(self.whole, mat["코드"], mat.get("단위"))
price = labor._number(got["기준단가"])
@@ -4,6 +4,7 @@
* 거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`.
* 기준(결과단위 그대로)은 서버 calc 길이 셈 · 여기는 받은 기준에
* 수량(손이 칸보다 앞섬 · 칸 = 알약 키 값) · 반영 = 기준 × 수량 ÷ 배 · 자재 = 재료비만.
* 별도계상 자재(222-1 · 빈 칸 = 기본 별도계상) = 반영은 보이되 합에 안 더함(짝 `apart`).
* 참조 줄(단가승격 · 사전조합 · 50-3)은 `lineId` = 「참조|층|주인|키」(짝 `M02_Labor_Ref.ref_id`). */
import type { CalcLine as LogicLine } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
@@ -79,7 +80,7 @@ export interface LaborCalc {
/** 수량만 보는 문서 조각 — 담은 로직 · 자재의 손 값 */
export interface LaborQtyDoc {
담은로직?: { 로직?: string; 참조?: LaborRef; 수량?: Qty }[];
자재?: { 코드: string; 수량?: Qty }[];
자재?: { 코드: string; 수량?: Qty; 별도계상?: string }[];
}
function num(raw: unknown): number | null {
@@ -130,6 +131,10 @@ export function settle(
}
const hands = new Map((doc.담은로직 ?? []).map((one) => [lineId(one), one.수량 ?? null]));
const mhands = new Map((doc.자재 ?? []).map((one) => [one.코드, one.수량 ?? null]));
// 별도계상(222-1) — 「직접계산」 아니면(빈 · 옛 값 = 기본 별도계상) 합에서 뺌(짝 `M02_Labor_Doc.apart`)
const aside = new Set(
(doc.자재 ?? []).filter((m) => m.별도계상 !== "직접계산").map((m) => m.코드),
);
const base = zero();
const done = zero();
const add = (sum: Money, m: Money | null): void => {
@@ -146,8 +151,8 @@ export function settle(
const [qty, source] = pick(mhands.get(one.코드), one.알약, have);
const price = num(one.기준단가);
const cost = price !== null && qty !== null ? price * qty : null;
if (price !== null) add(base, { ...zero(), 재료비: price, 계: price });
if (cost !== null) add(done, { ...zero(), 재료비: cost, 계: cost });
if (price !== null && !aside.has(one.코드)) add(base, { ...zero(), 재료비: price, 계: price });
if (cost !== null && !aside.has(one.코드)) add(done, { ...zero(), 재료비: cost, 계: cost });
return { ...one, 수량: qty, 수량출처: source, 반영: cost };
});
return { 줄, 자재, 기준합: base, 반영합: done };
@@ -383,14 +383,16 @@ export function mountLaborDetail(
else delete cur.담은로직[i].승격;
opt.onChanged(dirty());
}
// 자재 세 칸(148-4) — 화면 캐시만 · 금액 무관이라 다시 셈 없음
// 자재 세 칸(148-4) — 화면 캐시만 · 별도계상만 금액에 닿음(222-1 · 사용자 자재 = 짝 셈 · 로직 자재 = 서버 기준)
function markMat(i: number, key: MatMarkKey, v: string): void {
setMark(cur.자재[i], key, v);
opt.onChanged(dirty());
if (key === "별도계상") changed(true);
else opt.onChanged(dirty());
}
function markLogicMat(i: number, at: number, key: MatMarkKey, v: string): void {
setLogicMark(cur.담은로직[i], at, key, v);
opt.onChanged(dirty());
if (key === "별도계상") changed();
else opt.onChanged(dirty());
}
function removeMat(i: number): void {
cur.자재.splice(i, 1);