feat(727-1): 고르기 창 기초 = 로직마다 한 줄 · [기본 로직 선택] 단추 걷음 · 첫 기초 = 바탕

- 기준 목록 옵션 줄(바꿈) 뺀 바탕 줄만 · 규격 칸 = 「옵션 N」(새 순수 함수 logic/M02_Find_Heads.ts) — 찾아 넣기 창 · 옛 창(Labor_Pick) 같이 · 옵션 줄 골라도 옵션 버리던 흠 걷음 · 서버 목록 그대로
- 단가산출 상세 [항목 추가] 기초 = 바탕 비었으면 바탕(옛 단추 동작 · 단위 비거나 식이면 기초 결과단위) · logicOnly 걷음 · locale M02_SangeunPickBase 지움
- 낡은 시험 test_721_find_pick 721-1 · 721-2 = 단추 걷음 · logicOnly 없음으로 고침 · 새 시험 test_727_sk_base_add
- 기초 줄 1,461(옛 10,053) · GF000099 한 줄 「옵션 1」

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-02 10:14:07 +09:00
co-authored by Claude Opus 5.5
parent 638d3f446d
commit 76ac1c499e
7 changed files with 160 additions and 74 deletions
@@ -7,8 +7,9 @@
* · M02 단가산출 · 일위대가 = 위 층(마스터 › 회사 › 개인) 서고 목록(`/layers/{층}/templates`) · 고름 = 참조 {층 · 주인 · 키}
* · 인력 · 재료 고름 = `element` · 일위대가 고정 줄 꼴 = `fixedOf`(705-1 이음 노임 LB · 자재 MT · 수량 1 은 부르는 쪽) ·
* `element` 없으면 그 줄 회색 막힘(까닭 = 일위대가 표 개편 뒤 · 723)
* · 부르는 곳 = M02 단가산출 [기본 로직 선택](`logicOnly`) · [항목 추가](`Sangeun_Pick`) · 일위대가 [로직 추가]
* · 부르는 곳 = M02 단가산출 [항목 추가](`Sangeun_Pick`) · 일위대가 [로직 추가]
* (`Labor_Pick` 이 `lib.sangeun` 이면 이 창) — B09 · 불러오기는 옛 창(`Labor_Pick`)
* · (727-1) 기초 = 로직마다 한 줄(`logicHeads` · 옵션은 넣은 뒤 고름 · 옛 창도 같이) · 서버 목록 그대로
* ========================================================================== */
import { createDataTable } from "@ui/ui_template_data_table";
@@ -27,6 +28,7 @@ import { openStandardSheet } from "./M02_MasterTemplete_Labor_Sheet";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import { ownOwner } from "./M02_MasterTemplete_Layer";
import { listLayerDocs, SANGEUN_KIND, type SangeunRow } from "./M02_MasterTemplete_Sangeun_Pick";
import { logicHeads as heads } from "./logic/M02_Find_Heads";
import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css"; // 창 겉틀
import "./M02_MasterTemplete_Labor.css";
@@ -53,10 +55,12 @@ export interface FindPickOptions {
ref?: (ref: LaborRef, name: string) => void;
/** M01 인력 · 재료 고름 — 없으면 그 줄 회색 막힘 */
element?: (e: M01Element) => void;
/** 로직 줄만(탭 · 인력 · 재료 없음 — [기본 로직 선택]) */
logicOnly?: boolean;
}
/** 727-1 기초마다 한 줄(옛 창 `Labor_Pick` 도 씀) — 규격 칸 = 「옵션 N」 */
export const logicHeads = (rows: StandardRow[]): StandardRow[] =>
heads(rows, (n) => L("M02_FindOptions").replace("{n}", String(n)));
/** 일위대가 고정 줄(705-1 이음 꼴) — 인력 = 노임 LB · 재료 = 자재 MT · 단가 = 지금 값(셈은 단가 책 값) */
export function fixedOf(e: M01Element): LaborFixed {
const labor = e.종류 === "인력";
@@ -202,9 +206,7 @@ export function openFindPicker(opt: FindPickOptions): void {
function draw(): void {
const q = search.input.value.trim();
if (tab === "m01") {
shown = logicLines(q);
if (!opt.logicOnly)
shown.push(...elementLines("인력", people), ...elementLines("재료", goods));
shown = [...logicLines(q), ...elementLines("인력", people), ...elementLines("재료", goods)];
} else {
const words = q.toLowerCase().split(/\s+/).filter(Boolean);
const kind = tab === "sk" ? L("M02_PickSangeun") : L("M02_PrecomboTitle");
@@ -220,7 +222,6 @@ export function openFindPicker(opt: FindPickOptions): void {
/** 인력 · 재료 = 서버 찾기(친 낱말) — 재료는 낱말 있을 때만 · 늦게 온 답은 버림 */
async function findElements(): Promise<void> {
if (opt.logicOnly) return;
const mine = ++seq;
const q = search.input.value.trim();
const get = (kind: "job" | "price"): Promise<PickItem[]> =>
@@ -244,21 +245,19 @@ export function openFindPicker(opt: FindPickOptions): void {
timer = window.setTimeout(() => void findElements(), 250);
});
const tabs = opt.logicOnly
? null
: createTabs({
tabs: [
{ key: "m01", label: L("M02_FindM01") },
{ key: "sk", label: L("M02_PickSangeun") },
{ key: "pre", label: L("M02_PrecomboTitle") },
],
value: tab,
kind: "line",
onChange: (key) => {
tab = key as Tab;
draw();
},
});
const tabs = createTabs({
tabs: [
{ key: "m01", label: L("M02_FindM01") },
{ key: "sk", label: L("M02_PickSangeun") },
{ key: "pre", label: L("M02_PrecomboTitle") },
],
value: tab,
kind: "line",
onChange: (key) => {
tab = key as Tab;
draw();
},
});
({ close } = openModal({
title: opt.title ?? L("M02_FindTitle"),
@@ -271,7 +270,7 @@ export function openFindPicker(opt: FindPickOptions): void {
el("div", {
className: "m02-std-pick",
attrs: { "data-find-pick": "" },
children: [...(tabs ? [tabs.root] : []), search.root, hint, table.root],
children: [tabs.root, search.root, hint, table.root],
}),
),
}));
@@ -283,29 +282,28 @@ export function openFindPicker(opt: FindPickOptions): void {
void Promise.all([fetchLibLogics(), fetchStandard().catch(() => null)])
.then(([l, s]) => {
logics = l;
std = s?.줄.length ? s.줄 : null; // 빈 줄 = 서버가 아직 셈 — 로직 줄 그대로
std = s?.줄.length ? logicHeads(s.줄) : null; // 빈 줄 = 서버가 아직 셈 — 로직 줄 그대로
draw();
})
.catch(fail);
void findElements();
if (tabs)
void upper(opt.layer, opt.owner)
.then((scopes) =>
Promise.all(
(["sk", "pre"] as const).map((t) =>
listLayerDocs(scopes, t === "sk" ? SANGEUN_KIND : "precombo").then((rows) => {
lists[t] = rows;
tabs.setBadge(t, rows.length);
}),
),
void upper(opt.layer, opt.owner)
.then((scopes) =>
Promise.all(
(["sk", "pre"] as const).map((t) =>
listLayerDocs(scopes, t === "sk" ? SANGEUN_KIND : "precombo").then((rows) => {
lists[t] = rows;
tabs.setBadge(t, rows.length);
}),
),
)
.catch((error) => {
lists.sk ??= [];
lists.pre ??= [];
fail(error);
})
.finally(draw);
),
)
.catch((error) => {
lists.sk ??= [];
lists.pre ??= [];
fail(error);
})
.finally(draw);
}
/** 기준 목록이 오기 전 · 못 받으면 — 로직 하나 = 줄 하나(금액 빈칸) */
@@ -9,6 +9,7 @@
* 704-1 단가산출 줄 = 기준 목록 표(품명 · 규격 · 단위 · 금액 · 출처 · 대표 규격 펼침 · 공종 거름 ·
* 실무 낱말 = 서버가 별칭에 덧붙임) · 금액 = 서버 M01 엔진(`/logic-lib/standard`) · 오기 전엔 로직 줄만 ·
* 줄 누름 = 계산서 보기(704-2 · `Labor_Sheet`) → [담기] = 지금 담기 동작(`done(로직)`)
* 727-1 기초 = 로직마다 한 줄(`logicHeads` — 옵션 줄을 고르면 옵션을 버리던 흠 걷음 · 옵션은 담은 뒤 고름)
* 721-2 `lib.sangeun`(M02 일위대가 [로직 추가] · `withHopyo`) = 찾아 넣기 창(`Find_Pick` · 탭 M01 전체 · 단가산출 ·
* 일위대가 · 실무 호표 줄 없음) · M01 인력 · 재료 = `lib.fixed`(일위대가 고정 줄 · 없으면 회색 막힘) ·
* B09 · 불러오기는 이 창 그대로
@@ -38,7 +39,7 @@ import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css"; // 창 겉틀
import "./M02_MasterTemplete_Labor.css";
import { openModal } from "@ui/ui_template_modal";
import { createText } from "@ui/ui_template_text";
import { fixedOf, openFindPicker, upper } from "./M02_MasterTemplete_Find_Pick";
import { fixedOf, logicHeads, openFindPicker, upper } from "./M02_MasterTemplete_Find_Pick";
export { withHopyo } from "./M02_MasterTemplete_Hopyo_Pick";
@@ -356,7 +357,7 @@ export function openLogicPicker(
drawSubs();
find();
return fetchStandard().then((got) => {
std = got.줄.length ? got.줄 : null; // 빈 줄 = 서버가 아직 셈 — 로직 줄 그대로
std = got.줄.length ? logicHeads(got.줄) : null; // 빈 줄 = 서버가 아직 셈 · 727-1 기초마다 한 줄
work.setOptions(opts(got.공종), "");
find();
});
@@ -6,7 +6,7 @@
* 시험 계산(`Logic_Calc` · 바탕 M01 로직 + 덮개 품 · 단가 사본) · 텍스트 수식(같은 사본 식) · 기본정보 = 바탕 로직(맨 위 접힘)
* 고침 = SK 덮개 · 더한줄(`Sangeun_Pick`) · 뺀줄(꼴 = 서버 `M02_Sangeun_Doc`) · 값 = 서버 셈 길 최신 응답만
* 시험 입력 = SK 셈 `입력값`(저장 안 함) · [저장] = 화면 문서 → 서버가 다시 셈(원값 · 바탕판)
* 바탕 없는 SK = [기본 로직 선택] → 바탕 · 단위 · 바탕 없이 줄만도 됨
* [항목 추가] 기초 = 바탕 비었으면 바탕(727-2 · 단위가 비거나 「식」이면 기초 결과단위) · 있으면 더한줄 · 바탕 없이 줄만도 됨
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
@@ -33,7 +33,6 @@ import { buildLabDetail } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Det
import { buildHo, type HoEdit } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Ho";
import { createViewPicker } from "../M01_MasterData/M01_MasterData_UI_LogicLab_View";
import type { Layer } from "./M02_MasterTemplete_Api_Fetch";
import { openFindPicker } from "./M02_MasterTemplete_Find_Pick";
import { calcSangeun, removedStrip } from "./M02_MasterTemplete_Sangeun_Cover";
import { openSangeunLinePicker } from "./M02_MasterTemplete_Sangeun_Pick";
import {
@@ -100,6 +99,7 @@ export function mountSangeunDetail(
size: 28,
onClick: () =>
openSangeunLinePicker(scope, (line) => {
if ("종류" in line && line.종류 === "로직" && !doc.바탕) return setBase(line.로직);
doc.더한줄 = [
...(doc.더한줄 ?? []),
line as unknown as NonNullable<SkDoc["더한줄"]>[number],
@@ -108,25 +108,13 @@ export function mountSangeunDetail(
}),
});
addButton.hidden = !edit;
const pick = createButton({
label: L("M02_SangeunPickBase"),
variant: "filled",
size: 28,
onClick: () =>
openFindPicker({
...scope,
logicOnly: true,
title: L("M02_SangeunPickBase"),
logic: (x) => {
doc.바탕 = { 로직: x.키 };
delete doc.바탕판; // 새 바탕 — 개정 견줄 판 없음
if (!doc.단위 || doc.단위 === "식") doc.단위 = unitOf(x.결과단위) || doc.단위;
touch(false);
void loadBase();
},
}),
});
pick.hidden = true; // 보임은 drawHead 가 정함
/** 첫 기초 = 바탕(옛 [기본 로직 선택] 동작 · 단위는 `loadBase` 가 기초 결과단위로) */
function setBase(key: string): void {
doc.바탕 = { 로직: key };
delete doc.바탕판; // 새 바탕 — 개정 견줄 판 없음
touch(false);
void loadBase(true);
}
const baseHint = createText(L("M02_SangeunBaseViews"));
host.replaceChildren(
el("div", {
@@ -135,7 +123,7 @@ export function mountSangeunDetail(
children: [
head,
notes,
el("div", { className: "m02-sk__head", children: [views.root, pick, addButton, baseHint] }),
el("div", { className: "m02-sk__head", children: [views.root, addButton, baseHint] }),
editor,
],
}),
@@ -223,7 +211,6 @@ export function mountSangeunDetail(
const baseName = base ? `${base.logic.키} ${base.logic.이름}` : null;
const baseText = baseName ?? doc.바탕?.참조?.키 ?? L("M02_SangeunNoBase");
const revised = revisedBase();
pick.hidden = !edit || !!doc.바탕 || !!doc.뺀줄?.length || !!Object.keys(doc.덮개 ?? {}).length;
head.replaceChildren(
field(L("M02_Name"), "이름"),
field(L("M02_SangeunUnit"), "단위"),
@@ -410,7 +397,8 @@ export function mountSangeunDetail(
});
}
async function loadBase(): Promise<void> {
/** `fresh` = 막 넣은 바탕 — 단위가 비거나 「식」이면 기초 결과단위(앞 수 뗌 · 배는 서버가 나눔) */
async function loadBase(fresh = false): Promise<void> {
const key = baseKey();
base = null;
text = null;
@@ -422,6 +410,10 @@ export function mountSangeunDetail(
}
}
if (!alive) return;
if (fresh && base && (!doc.단위 || doc.단위 === "식")) {
doc.단위 = unitOf(base.logic.결과단위) || doc.단위;
touch(false);
}
draw();
await recalc(); // 바탕 정체(bare) 먼저 — 시험 계산 · 텍스트 수식 = 덮개 사본
if (alive) drawCalc();
@@ -0,0 +1,27 @@
/* =============================================================================
* M02_MasterTemplete/logic/M02_Find_Heads.ts
* 727-1 기준 목록(`/logic-lib/standard` · 로직 하나를 옵션 축마다 펼친 줄) → 기초마다 한 줄 —
* 바탕 줄(`바꿈` 빈 줄)만 · 규격 칸 = 옵션 축 수(`label(n)` · 없으면 빈칸) · 옵션은 넣은 뒤 고름.
* 쓰는 곳 = 찾아 넣기 창(`Find_Pick`) · 옛 창(`Labor_Pick`). 화면 · fetch 없음(`CLAUDE.md` 4장).
* ========================================================================== */
export interface HeadRow {
키: string;
규격: string;
바꿈: Record<string, string | number>;
}
export function logicHeads<T extends HeadRow>(rows: T[], label: (n: number) => string): T[] {
const axes = new Map<string, Set<string>>();
for (const r of rows)
for (const k of Object.keys(r.바꿈 ?? {})) {
if (!axes.has(r.키)) axes.set(r.키, new Set());
axes.get(r.키)!.add(k);
}
return rows
.filter((r) => !Object.keys(r.바꿈 ?? {}).length)
.map((r) => {
const n = axes.get(r.키)?.size ?? 0;
return { ...r, 규격: n ? label(n) : "" };
});
}
+7 -6
View File
@@ -1,7 +1,7 @@
"""721 M02 찾아 넣기 창 — 단추 이름 · 자리 · 부르는 곳 · 인력 · 재료 줄 꼴(요구서_내역16 1장 5~7 · 2장 1 · 2 · 8).
- 721-1 단가산출 상세 = [기본 로직 선택]이 둘째 줄 [항목 추가] 왼쪽
- 721-2 M02 세 곳(기본 로직 선택 · 항목 추가 · 일위대가 [로직 추가])만 새 창 · B09 · 불러오기는 옛 창
- 721-1 단가산출 상세 = 둘째 줄 [항목 추가](727-2 [기본 로직 선택] 걷음 — 첫 기초가 바탕)
- 721-2 M02 두 곳(항목 추가 · 일위대가 [로직 추가])만 새 창 · B09 · 불러오기는 옛 창
- M01 전체 = 로직 · 인력(job) · 재료(price) · 기계 · 실무 호표 없음
- 고른 인력 · 재료 = 단가산출 더한줄 `{종류, 요소}` · 일위대가 고정 줄(705-1 이음 꼴) — 둘 다 서버가 받아 노임 · 자재 값으로 셈
"""
@@ -26,10 +26,10 @@ def _text(path: Path) -> str:
def test_721_1_단추_이름과_자리():
detail = _text(M02 / "M02_MasterTemplete_Sangeun_Detail.ts")
assert "children: [views.root, pick, addButton, baseHint]" in detail
assert " pick,\n );" not in detail # 머리 줄에서 뺌
assert "children: [views.root, addButton, baseHint]" in detail
assert "M02_SangeunPickBase" not in detail # 727-2 단추 걷음
locale = _text(ROOT / "ui_template" / "ui_template_locale_m2.ts")
assert 'M02_SangeunPickBase: ["기본 로직 선택"' in locale
assert "M02_SangeunPickBase" not in locale
assert 'M02_SangeunAddLine: ["항목 추가"' in locale
@@ -39,7 +39,8 @@ def test_721_2_부르는_곳_셋만_새_창():
assert 'if (lib?.sangeun && lib.layer !== "project")' in labor_pick
assert "return openFindPicker({" in labor_pick
assert "findSangeun" not in labor_pick # 옛 창의 단가산출 탭 걷음
assert "logicOnly: true," in _text(M02 / "M02_MasterTemplete_Sangeun_Detail.ts")
assert "logicOnly" not in _text(M02 / "M02_MasterTemplete_Sangeun_Detail.ts") # 727-2
assert "logicOnly" not in find
assert "openFindPicker({" in _text(M02 / "M02_MasterTemplete_Sangeun_Pick.ts")
assert "sangeun: true," in _text(M02 / "M02_MasterTemplete_Hopyo_Pick.ts") # 일위대가
# 옛 창 그대로 — B09 · 불러오기는 sangeun 없이 부름
+67
View File
@@ -0,0 +1,67 @@
"""727 M02 단가산출 기초 넣기(요구서_내역19 · 내역 20-1) — 임시 storage · 실제 M01 읽기만.
- 727-1 고르기 창 기초 = 로직마다 한 줄(`logic/M02_Find_Heads.ts` · 옛 창도) · 규격 칸 = 「옵션 N」
- 727-2 첫 기초 = 바탕 · 둘째부터 = 더한줄 세부 펼침(덮개 · 뺀줄 · M01 개정 지문 · 꼬리표) · B09 굳힘 같은 값
- 727-3 넣은 뒤 고른 옵션 = SK `입력값` → 일위대가(펼침) · 굳힘도 그 옵션
"""
from __future__ import annotations
import json
import re
import subprocess
from pathlib import Path
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Logic_Standard as standard
ROOT = Path(__file__).resolve().parents[2]
M02 = ROOT / "M02_MasterTemplete"
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
GF = "GF000099" # 측구터파기 토사 · 원/㎥ · 입력 지역 · 보정작업
def _node(files: list[str], body: str):
code = "".join(IMPORTS.sub("", (ROOT / f).read_text("utf-8")) for f in files)
got = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-"],
input=code + body, capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert got.returncode == 0, got.stderr
return json.loads(got.stdout)
def _heads(rows):
body = (
f"\nconst rows = {json.dumps(rows, ensure_ascii=False, default=str)};"
"\nconsole.log(JSON.stringify(logicHeads(rows, (n) => `옵션 ${n}`)));"
)
return _node(["M02_MasterTemplete/logic/M02_Find_Heads.ts"], body)
def test_727_1_기초마다_한_줄():
whole = m01.loaded()[1]
rows = []
for key in (GF, "GC000269", "GC001109"):
rows += standard._rows_of(whole, whole.logic(key), False)
assert sum(r["키"] == GF for r in rows) == 3 # 옵션 축 보정작업 두 값 + 바탕
got = _heads(rows)
assert [r["키"] for r in got] == [GF, "GC000269", "GC001109"]
assert all(r["바꿈"] == {} for r in got)
one = got[0]
assert one["규격"] == "옵션 1" and one["금액"] == next(r for r in rows if r["키"] == GF)["금액"]
assert got[2]["규격"] == "" # 옵션 없는 기초
listed = standard.listing()
if listed["준비"]: # 파생 저장본이 있을 때 — 1,461 안팎(로직 수)
heads = _heads([{k: r[k] for k in ("키", "규격", "바꿈")} for r in listed["줄"]])
assert len(heads) == len({r["키"] for r in listed["줄"]})
assert 1400 <= len(heads) <= 1600
def test_727_1_두_창_모두_기초_한_줄():
find = (M02 / "M02_MasterTemplete_Find_Pick.ts").read_text("utf-8")
old = (M02 / "M02_MasterTemplete_Labor_Pick.ts").read_text("utf-8")
assert "std = s?.줄.length ? logicHeads(s.줄) : null;" in find
assert "std = got.줄.length ? logicHeads(got.줄) : null;" in old
assert "logicOnly" not in find
+1 -1
View File
@@ -203,7 +203,6 @@ export const ui_locales_m2 = {
M02_SangeunRemoved: ["뺀 줄", "Removed rows"],
M02_SangeunUnit: ["단위(1 단위 값)", "Unit (value per 1)"],
M02_SangeunNeed: ["비우면 저장 안 됨", "Required"],
M02_SangeunPickBase: ["기본 로직 선택", "Select base logic"],
M02_SangeunAddLine: ["항목 추가", "Add item"],
M02_SangeunDrop: ["빼기", "Remove"],
M02_SangeunRestore: ["되살리기", "Restore"],
@@ -251,4 +250,5 @@ export const ui_locales_m2 = {
M02_FindClass: ["구분", "Class"],
M02_FindLogic: ["단가산출 로직", "Unit-cost logic"],
M02_FindElementLater: ["일위대가 표 개편 뒤", "After the unit-cost table rework"],
M02_FindOptions: ["옵션 {n}", "{n} options"],
} as const;