feat(M02): 일위대가 로직 줄 구분 드롭다운 단가산출 · 단가승격 — 줄 승격 칸 저장(134-1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
This commit is contained in:
2026-10-09 14:46:51 +09:00
co-authored by Claude Opus 5.5
parent 2163584bbd
commit 1384d6a84d
7 changed files with 167 additions and 6 deletions
+3 -1
View File
@@ -1,7 +1,7 @@
"""M02 일위대가 문서 `labor/<열id>.json` — 정리 · 검사 · 계산 · 알약(PLAN 39-1a · 설계_정함 1장).
문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬,
담은로직: [{로직, 별칭, 메모, 입력값, 수량, 켜쌓기율?, 고름?} | {참조: {층, 주인, 키}, 별칭, 메모, 수량}],
담은로직: [{로직, 별칭, 메모, 입력값, 수량, 켜쌓기율?, 고름?, 승격?} | {참조: {층, 주인, 키}, 별칭, 메모, 수량}],
자재: [{코드, 이름, 규격, 단위, 수량}], 계산값}` — 이름은 구조물 열 이름(따로 안 둠).
수량 = `null`(면적산출 알약 칸 값 · 로직 알약 「이름|단위」 · 자재 알약 「자재|MT키」) | `{손: 수}`
(손이 칸보다 앞섬) · 같은 로직 두 번 금지 · 입력값 = 그 로직 입력 이름만(빈 값 = 자동값).
@@ -17,6 +17,7 @@
로직 자재(103-1) = 담은 로직 맨 위 층 호표 재료 줄(`logic_mats` · 줄마다 `재료`) ·
고름 = `{호표 차례: MT키}`(엔진 `choosing` 그대로) · 금액은 로직 재료비 안이라 합 · 알약에 안 더함(보기만) · 사용자 자재와
같은 품목 두 번은 [저장] 때 400.
승격(134-1) = 로직 줄 「단가승격」 표시(`true` 만 · 없음 = 단가산출) · 금액 무관 · B09 호표 몫.
"""
from __future__ import annotations
@@ -124,6 +125,7 @@ def clean(doc: Any) -> dict[str, Any]:
"수량": _qty(one.get("수량"), where),
**({"켜쌓기율": rate} if rate is not None else {}),
**({"고름": picks} if picks else {}),
**({"승격": True} if one.get("승격") is True else {}),
}
)
mats, codes = [], set()
@@ -204,6 +204,16 @@
color: var(--color-text-secondary);
}
/* 구분 드롭다운(134-1) — 칸 너비만 · 품셈 책 글은 아래 */
.m02-labrow__kind .ui-field {
display: block;
}
.m02-labrow__kind .ui-select {
width: auto;
min-width: 0;
}
/* 줄 접고 폄 표(51-2) · 고른 줄 하위 행 — 이름 들여쓰기 · 흐린 바탕 · 위 줄보다 촘촘(51-7) */
.m02-labrow .m02-labrow__sub td {
padding: var(--spacing-4) var(--spacing-8);
@@ -133,6 +133,8 @@ export interface LaborRow {
켜쌓기율?: number | null;
/** 고른 로직 자재(103-1) — {호표 차례: MT키} · 없으면 엔진 기본 */
고름?: Record<string, string>;
/** 단가승격(134-1 · 로직 줄 구분 드롭다운) — 없음 = 단가산출 · 금액 무관 · B09 호표 몫 */
승격?: boolean;
}
export interface LaborMat {
코드: string;
@@ -170,6 +172,7 @@ export function normalizeLabor(doc: unknown, column: string): LaborDoc {
...(r.참조 ? { 참조: r.참조 } : {}),
...(r.켜쌓기율 != null ? { 켜쌓기율: r.켜쌓기율 } : {}),
...(r.고름 && Object.keys(r.고름).length ? { 고름: { ...r.고름 } } : {}),
...(r.승격 === true && !r.참조 ? { 승격: true } : {}),
})),
자재: (d.자재 ?? []).map((m) => ({
코드: m.코드,
@@ -204,6 +204,8 @@ export function mountLaborDetail(
move: reorder,
remove: removeRow,
alias: setAlias,
// 134-1 — 구조물 일위대가 로직 줄만(사전조합 안 로직 줄엔 없음)
...(lib ? {} : { promote: setPromote }),
}
: { open: pick, option: openRow, ref: openRef },
),
@@ -361,6 +363,12 @@ export function mountLaborDetail(
cur.담은로직[i].별칭 = value;
opt.onChanged(dirty());
}
/** 구분 드롭다운(134-1) — 화면 캐시만 · 금액 무관이라 다시 셈 없음 */
function setPromote(i: number, on: boolean): void {
if (on) cur.담은로직[i].승격 = true;
else delete cur.담은로직[i].승격;
opt.onChanged(dirty());
}
function removeMat(i: number): void {
cur.자재.splice(i, 1);
changed();
@@ -12,7 +12,7 @@
* 상세는 줄 누름 = 고름(`selected` 줄 하이라이트 · 그 줄 바로 아래 하위 행 · 51-2 · 화살표 없음 51-7) · 넣을 값 모달은 [옵션](47-5).
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import { createButton, createSelectField, el } from "@ui/ui_template_elements";
import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money";
import type { ItemLogic } from "./M02_MasterTemplete_Api_Fetch";
import type {
@@ -41,14 +41,36 @@ const HEAD: Record<(typeof COSTS)[number], Parameters<typeof tc>[0]> = {
/** 옵션 값 줄이 걸치는 칸 수 — 구분 · 이름 · 별칭(113-1 · 나머지 칸은 위 줄에서 rowspan 2) */
const SPAN = 3;
/** 구분(50-4 · 저장 안 함) — 줄 모양(참조 키 머리)에서 읽음 · SP- = 단가승격 · SC- = 일위조합(50-3) */
export const kindOf = (row: Pick<LaborRow, "로직" | "참조">): string => {
/** 구분(50-4) — 로직 줄 = `승격`(134-1 · 단가승격 · 없음 = 단가산출) · 참조 줄 = 키 머리(SC- = 일위조합 ·
* 옛 SP- = 단가승격 글 고정 · 134-2) */
export const kindOf = (row: Pick<LaborRow, "로직" | "참조" | "승격">): string => {
const key = row.참조?.키 ?? row.로직;
return tc(
key.startsWith("SP-") ? "Kind_Promote" : key.startsWith("SC-") ? "Kind_Combo" : "Kind_Logic",
row.승격 || key.startsWith("SP-")
? "Kind_Promote"
: key.startsWith("SC-")
? "Kind_Combo"
: "Kind_Logic",
);
};
/** 구분 드롭다운(134-1) — 로직 줄만 [단가산출 | 단가승격] · 줄 누름과 따로 */
function kindPick(on: boolean, set: (on: boolean) => void): HTMLElement {
const pick = createSelectField({
options: [
{ value: "", text: tc("Kind_Logic") },
{ value: "1", text: tc("Kind_Promote") },
],
value: on ? "1" : "",
compact: true,
ariaLabel: tc("Sub"),
onChange: (v) => set(v === "1"),
});
pick.select.dataset.laborKind = "";
pick.root.addEventListener("click", (e) => e.stopPropagation()); // 줄 누름(고름)과 따로
return pick.root;
}
/** 품셈 책(102-3) — 출처 첫 낱말 */
const BOOK = /^(건설|산림)품셈/;
@@ -189,6 +211,8 @@ export interface RowHandlers {
remove?: (i: number) => void;
/** 별칭 칸 고침(44-6 · 글자마다 · 빈 글 = null) — 없으면 글만 보임 */
alias?: (i: number, value: string | null) => void;
/** 구분 드롭다운(134-1 · 로직 줄 단가산출 ↔ 단가승격) — 없으면 글만 */
promote?: (i: number, on: boolean) => void;
}
/** 별칭 칸(44-6) — 고칠 수 있으면 칸 안 글상자(누르면 바로 고침 · 줄 누름과 따로) · 아니면 글 · 비면 빈 칸 */
@@ -361,7 +385,9 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
el("td", {
className: "m02-labrow__kind",
children: [
kindOf(row),
on?.promote && !row.참조
? kindPick(!!row.승격, (v) => on.promote?.(i, v))
: kindOf(row),
// 102-3 — 단가산출 줄만 품셈 책(출처 첫 낱말)
...(book ? [el("small", { className: "m02-labrow__book", text: book })] : []),
],
@@ -0,0 +1,22 @@
/* M02 일위대가 문서 화면 정리(`M02_MasterTemplete_Labor_Api.ts` `normalizeLabor`) 헬퍼(134-1) —
* 그 자리에서 트랜스파일해 Node 로 돌림 · 가져오는 길은 빈 대역 · stdin = {doc, column} · stdout = 정리한 문서 JSON. */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const file = path.join(
__dirname,
"..",
"..",
"M02_MasterTemplete",
"M02_MasterTemplete_Labor_Api.ts",
);
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const stub = new Proxy({}, { get: () => function () {} });
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, () => stub);
const input = JSON.parse(fs.readFileSync(0, "utf8"));
process.stdout.write(JSON.stringify(box.exports.normalizeLabor(input.doc, input.column)));
@@ -0,0 +1,90 @@
"""일위대가 로직 줄 단가승격 표시(PLAN 134-1) — 줄에 `승격: true` · 금액 무관.
- 서버 `clean` = `true` 만 남김(거짓 · 글 · 참조 줄은 버림) · 저장 왕복에 남음 · 금액 그대로
- 사전조합 안 로직 줄엔 승격 없음(정리 때 버림)
- 화면 `normalizeLabor` 도 `true` 만 통과(Node 헬퍼)
"""
import json
import os
import subprocess
import pytest
from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from resources.tester.test_m02_structure import write_sample_table
HERE = os.path.dirname(os.path.abspath(__file__))
NET = "GC000337"
BARE = "GC000994"
def _doc(lines):
return {"열": "rv", "담은로직": lines, "자재": []}
def test_clean_은_참만_남김():
got = labor.clean(_doc([{"로직": NET, "승격": True}, {"로직": BARE, "승격": "yes"}]))[
"담은로직"
]
assert got[0]["승격"] is True and "승격" not in got[1]
def test_승격은_금액을_안_바꿈():
plain = labor.calc(labor.clean(_doc([{"로직": NET, "수량": {"손": 2}}])), {})
flag = labor.calc(labor.clean(_doc([{"로직": NET, "수량": {"손": 2}, "승격": True}])), {})
assert flag["반영합"] == plain["반영합"] and flag["기준합"] == plain["기준합"]
def test_사전조합_안_로직_줄엔_승격_없음(monkeypatch):
monkeypatch.setattr(refs, "here", lambda: ("system", None))
lines = refs._combo_lines({"담은로직": [{"로직": NET, "승격": True}]}, "SC-000009")
assert "승격" not in lines[0]
@pytest.fixture()
def system(tmp_path, monkeypatch):
write_sample_table(tmp_path)
monkeypatch.setattr(store, "FOLDER", tmp_path)
store.create_structure("rv")
return tmp_path
def test_저장_왕복에_승격_남음(system):
have = store.read("labor", "rv")
sent = {
**have["문서"],
"담은로직": [
{"로직": NET, "수량": None, "승격": True},
{"로직": BARE, "수량": None},
],
}
store.write("labor", "rv", have["판"], sent)
rows = store.read("labor", "rv")["문서"]["담은로직"]
assert rows[0]["승격"] is True and "승격" not in rows[1]
# 내리면(드롭다운 단가산출) 칸째 사라짐
have = store.read("labor", "rv")
store.write("labor", "rv", have["판"], {**have["문서"], "담은로직": [{"로직": NET}]})
assert "승격" not in store.read("labor", "rv")["문서"]["담은로직"][0]
def test_화면_정리도_참만_통과():
doc = {
"담은로직": [
{"로직": NET, "승격": True},
{"로직": BARE, "승격": False},
{"로직": "", "참조": {"층": "system", "주인": None, "키": "SC-000001"}, "승격": True},
]
}
proc = subprocess.run(
["node", os.path.join(HERE, "helper_m02_labor_normalize.cjs")],
input=json.dumps({"doc": doc, "column": "rv"}, ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)
rows = json.loads(proc.stdout)["담은로직"]
assert [r.get("승격") for r in rows] == [True, None, None]