From 76ac1c499e76cb8b8a6dcd2e56d02ca17d028676 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 2 Nov 2026 10:14:07 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(727-1):=20=EA=B3=A0=EB=A5=B4=EA=B8=B0?= =?UTF-8?q?=20=EC=B0=BD=20=EA=B8=B0=EC=B4=88=20=3D=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=EB=A7=88=EB=8B=A4=20=ED=95=9C=20=EC=A4=84=20=C2=B7=20[?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=20=EB=A1=9C=EC=A7=81=20=EC=84=A0=ED=83=9D]?= =?UTF-8?q?=20=EB=8B=A8=EC=B6=94=20=EA=B1=B7=EC=9D=8C=20=C2=B7=20=EC=B2=AB?= =?UTF-8?q?=20=EA=B8=B0=EC=B4=88=20=3D=20=EB=B0=94=ED=83=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기준 목록 옵션 줄(바꿈) 뺀 바탕 줄만 · 규격 칸 = 「옵션 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 --- .../M02_MasterTemplete_Find_Pick.ts | 80 +++++++++---------- .../M02_MasterTemplete_Labor_Pick.ts | 5 +- .../M02_MasterTemplete_Sangeun_Detail.ts | 40 ++++------ M02_MasterTemplete/logic/M02_Find_Heads.ts | 27 +++++++ resources/tester/test_721_find_pick.py | 13 +-- resources/tester/test_727_sk_base_add.py | 67 ++++++++++++++++ ui_template/ui_template_locale_m2.ts | 2 +- 7 files changed, 160 insertions(+), 74 deletions(-) create mode 100644 M02_MasterTemplete/logic/M02_Find_Heads.ts create mode 100644 resources/tester/test_727_sk_base_add.py diff --git a/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts index 191848fd2..ec5c7764a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts @@ -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 { - if (opt.logicOnly) return; const mine = ++seq; const q = search.input.value.trim(); const get = (kind: "job" | "price"): Promise => @@ -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); } /** 기준 목록이 오기 전 · 못 받으면 — 로직 하나 = 줄 하나(금액 빈칸) */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts index 3e30c7c38..3e91c8a51 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts @@ -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(); }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts index 1aa58d037..632731bc5 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts @@ -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[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 { + /** `fresh` = 막 넣은 바탕 — 단위가 비거나 「식」이면 기초 결과단위(앞 수 뗌 · 배는 서버가 나눔) */ + async function loadBase(fresh = false): Promise { 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(); diff --git a/M02_MasterTemplete/logic/M02_Find_Heads.ts b/M02_MasterTemplete/logic/M02_Find_Heads.ts new file mode 100644 index 000000000..0e75d1c1b --- /dev/null +++ b/M02_MasterTemplete/logic/M02_Find_Heads.ts @@ -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; +} + +export function logicHeads(rows: T[], label: (n: number) => string): T[] { + const axes = new Map>(); + 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) : "" }; + }); +} diff --git a/resources/tester/test_721_find_pick.py b/resources/tester/test_721_find_pick.py index 43aec8847..e22947946 100644 --- a/resources/tester/test_721_find_pick.py +++ b/resources/tester/test_721_find_pick.py @@ -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 없이 부름 diff --git a/resources/tester/test_727_sk_base_add.py b/resources/tester/test_727_sk_base_add.py new file mode 100644 index 000000000..d5194a689 --- /dev/null +++ b/resources/tester/test_727_sk_base_add.py @@ -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 diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index 965184cde..8e0f29d28 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -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; From 81a7f8d80198168f15a2df9f3d68bea2ebd6fb7c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 2 Nov 2026 10:24:50 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(727-2=20=C2=B7=20727-3):=20=EB=91=98?= =?UTF-8?q?=EC=A7=B8=20=EA=B8=B0=EC=B4=88=20=EC=84=B8=EB=B6=80=20=EC=A4=84?= =?UTF-8?q?=20=ED=8E=BC=EC=B9=A8=20=C2=B7=20=EC=84=B8=EB=B6=80=20=EB=8D=AE?= =?UTF-8?q?=EA=B0=9C=20=C2=B7=20=EB=BA=80=EC=A4=84=20=C2=B7=20=EC=B6=9C?= =?UTF-8?q?=EC=B2=98=20=EA=BC=AC=EB=A6=AC=ED=91=9C=20=C2=B7=20=EB=84=A3?= =?UTF-8?q?=EC=9D=80=20=EB=92=A4=20=EA=B3=A0=EB=A5=B8=20=EC=98=B5=EC=85=98?= =?UTF-8?q?=20SK=20=EC=9E=85=EB=A0=A5=EA=B0=92=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버 Sangeun_Doc: 더한줄 로직 = 덮개{세부 cid} · 뺀줄[세부 cid] 받음 · SK 입력값 = 바탕 입력 + 더한줄 기초 입력(727-3) - 서버 Sangeun_Calc: 고친 둘째 기초 = 고친 기초 줄을 엔진으로 셈한 값묶음 줄(B09 굳힘도 같은 값) · 화면 셈 길만 펼침(세부 × 수량) · 뺀세부 · 바탕판 · 꼬리표(원문번호) · 저장 때 세부 원값 · 바탕판 - 화면 Cover_Model: 세부 줄 열쇠 add:#k/세부 cid · 품 = 화면 품 ÷ 수량 · 줄 빼기 · 되살림 · 기초 빼기 · 꼬리표 = 기초 둘 이상일 때만 - 화면 Sangeun_Detail: 옵션 칸 = 바탕 입력 + 더한줄 기초 입력 · 고르면 SK 입력값(열린입력만 시험 값) · M01 개정 알림에 둘째 기초 · [기초 빼기] - 공용 상세 LogicLab_Ho: 줄 덧칸 꼬리표 배지(칸 폭 고정 · 말줄임 + 풍선 · M01 화면은 꼬리표 없음 = 전후 같음) - 이음: 둘째 기초 수량 = 그 기초 기준단위 몇 개(처음 1) — 단위 · 배는 728 - 새 시험 test_727_sk_base_add(세부 둘 0.023 · 0.01546 · 기초 둘 계 = 서버 셈 · 덮개 · 뺀줄 · 굳힘 같은 값 · 암석작업 옵션 5,346.93) · 기존 SK 넷 금액 전후 같음 Co-Authored-By: Claude Opus 5.5 --- .../M01_MasterData_UI_LogicLab_Ho.ts | 13 ++ .../M02_MasterTemplete_Sangeun.css | 10 + .../M02_MasterTemplete_Sangeun_Detail.ts | 60 ++++-- M02_MasterTemplete/M02_Sangeun_Calc.py | 112 +++++++++++- M02_MasterTemplete/M02_Sangeun_Doc.py | 46 ++++- .../logic/M02_Sangeun_Cover_Model.ts | 148 ++++++++++++--- resources/tester/test_727_sk_base_add.py | 172 +++++++++++++++++- ui_template/ui_template_locale_m2.ts | 1 + 8 files changed, 521 insertions(+), 41 deletions(-) diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts index 3b9ff7be2..0cc25a967 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts @@ -195,6 +195,8 @@ interface Entry extends HoRowLike { unitRef?: string; unitText?: string; warn?: string; + /** (M02 727-2) 출처 꼬리표 — 줄 덧칸 `꼬리표` · 칸 폭 고정 · 넘치면 말줄임 + 풍선 */ + source?: string; } /** 서버 조각(`/text` 줄의 `조각`) → 칸 조각 — 요소·표·로직은 알약(글 = 조각 글) · 나머지는 글자 */ @@ -329,6 +331,7 @@ function entries(ctx: HoContext): Entry[] { logic: item.종류 === "로직", part: OF_COST[item.비목 ?? ""] ? PART_OF[OF_COST[item.비목 ?? ""]] : PART_OF[group], open: (self) => modalOf(self, ctx, { cond: cond ?? undefined, item }), + source: tagOf(item), }; return spread(entry, line); }); @@ -354,6 +357,7 @@ function entries(ctx: HoContext): Entry[] { logic: false, part: PART_OF[group], open: (self) => modalOf(self, ctx, {}), + source: tagOf(extra), }; out.push(...spread(entry, line)); }); @@ -442,10 +446,19 @@ function formulaCell(e: Entry, ctx: HoContext, text = ""): HTMLElement { return cell; } +const tagOf = (x: object): string | undefined => (x as { 꼬리표?: string }).꼬리표 || undefined; +const sourceTag = (text: string): HTMLElement => { + const tag = createBadge(text, "neutral"); + tag.classList.add("m01lab__src"); + tag.title = text; + return tag; +}; + function nameCell(e: Entry, label: string, tag: string): HTMLElement { return el("div", { children: [ el("div", { text: label }), + ...(e.source ? [sourceTag(e.source)] : []), ...(e.share && label === e.name ? [createBadge(`${e.share} ${tl("Share")}`, "accent")] : []), ...(e.spec ? [el("div", { className: "ui-text ui-text--muted", text: e.spec })] : []), ...(e.warn ? [createBadge(`${tx("Calc_UnitWarn")} · ${e.warn}`, "warning")] : []), diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun.css b/M02_MasterTemplete/M02_MasterTemplete_Sangeun.css index 07dc98951..8b20d7adc 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun.css @@ -51,3 +51,13 @@ text-align: end; font-weight: var(--font-weight-bold); } + +/* 727-2 기초 출처 꼬리표 — 칸 폭 고정 · 넘치면 말줄임(풍선 = 온 글) · 이름 칸 폭을 안 바꿈 */ +.m02-sk .m01lab__src { + display: inline-block; + max-width: 6em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: middle; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts index 632731bc5..9117b578f 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts @@ -5,7 +5,9 @@ * HoEdit → SK 덮개 · 더한줄 · 줄 빼기 · 별도계상) · 인력·재료·경비(공용 계산서 · SK 셈 답) · * 시험 계산(`Logic_Calc` · 바탕 M01 로직 + 덮개 품 · 단가 사본) · 텍스트 수식(같은 사본 식) · 기본정보 = 바탕 로직(맨 위 접힘) * 고침 = SK 덮개 · 더한줄(`Sangeun_Pick`) · 뺀줄(꼴 = 서버 `M02_Sangeun_Doc`) · 값 = 서버 셈 길 최신 응답만 - * 시험 입력 = SK 셈 `입력값`(저장 안 함) · [저장] = 화면 문서 → 서버가 다시 셈(원값 · 바탕판) + * 옵션 칸 = SK `입력값`(727-3 · 저장 · 열린입력만 시험 값) · [저장] = 화면 문서 → 서버가 다시 셈(원값 · 바탕판) + * 둘째 기초(727-2) = 서버 `펼침` 세부 줄 · 인력 · 재료 · 경비 상자에 함께 + 꼬리표(기초 둘 이상) · 세부 덮개 · + * 뺀줄 · [기초 빼기] · M01 개정 알림 · 옵션 칸 = 바탕 입력 + 더한줄 기초 입력 * [항목 추가] 기초 = 바탕 비었으면 바탕(727-2 · 단위가 비거나 「식」이면 기초 결과단위) · 있으면 더한줄 · 바탕 없이 줄만도 됨 * ========================================================================== */ @@ -37,10 +39,13 @@ import { calcSangeun, removedStrip } from "./M02_MasterTemplete_Sangeun_Cover"; import { openSangeunLinePicker } from "./M02_MasterTemplete_Sangeun_Pick"; import { baseCovers, + dropBase, dropLine, dropText, hoView, + removedSubs, restoreLine, + revisedAdds, rowsOf, setApart, setLineCover, @@ -82,7 +87,7 @@ export function mountSangeunDetail( let ans: SkAnswer | null = null; let bare: SkAnswer | null = null; let fail = ""; - let extra: LogicInput[] = []; // 바탕 없는 SK — 더한줄 로직 입력 모음 = 공통 옵션 칸(725-6) + let extra: LogicInput[] = []; // 더한줄 로직 입력 중 바탕에 없는 것 = 옵션 칸(725-6 · 727-3) const inputsOf = new Map(); let seq = 0; let alive = true; @@ -130,9 +135,10 @@ export function mountSangeunDetail( ); const opened = (): boolean => (doc.열린입력 ?? []).length > 0; - // 바탕 없는 SK 의 지문 = 이름 · 단위 따라 바뀜 — 바탕이 있을 때만 M01 개정으로 봄(725-3) + // 바탕 없는 SK 의 지문 = 이름 · 단위 따라 바뀜 — 바탕이 있을 때만 M01 개정으로 봄(725-3) · 둘째 기초 지문(727-2) const revisedBase = (): boolean => - !!(doc.바탕 && doc.바탕판 && ans?.바탕판 && doc.바탕판 !== ans.바탕판); + !!(doc.바탕 && doc.바탕판 && ans?.바탕판 && doc.바탕판 !== ans.바탕판) || + revisedAdds(ans, doc).length > 0; const baseKey = (): string | null => doc.바탕?.로직 ?? null; /** 바탕 없는 · 참조 바탕 SK 의 기본정보 자리 줄 */ @@ -148,12 +154,10 @@ export function mountSangeunDetail( 덧줄: [], }); - /** 바탕 없을 때만 — 더한줄 로직들의 입력을 모음(같은 이름은 하나) · 바탕 있으면 바탕 입력 그대로 */ + /** 더한줄 로직들의 입력을 모음(같은 이름은 하나 · 바탕 입력에 있는 이름 뺌) */ async function syncExtra(): Promise { const keys = [ - ...new Set( - doc.바탕 ? [] : (doc.더한줄 ?? []).flatMap((x) => ("로직" in x ? [String(x.로직)] : [])), - ), + ...new Set((doc.더한줄 ?? []).flatMap((x) => ("로직" in x ? [String(x.로직)] : []))), ]; await Promise.all( keys @@ -165,7 +169,7 @@ export function mountSangeunDetail( ), ), ); - const seen = new Set(); + const seen = new Set((base?.logic.입력 ?? []).map((i) => i.이름)); extra = keys .flatMap((k) => inputsOf.get(k) ?? []) .filter((i) => !seen.has(i.이름) && seen.add(i.이름)); @@ -265,8 +269,11 @@ export function mountSangeunDetail( baseHint.hidden = !base; const rows = ans ? rowsOf(ans, bare, doc) : []; const names = new Map((bare ? rowsOf(bare, null, {}) : []).map((r) => [r.cid, r.이름])); - const removed = (doc.뺀줄 ?? []).map((cid) => ({ cid, 이름: names.get(cid) ?? cid })); - const row = base?.logic ?? standIn(); + const removed = [ + ...(doc.뺀줄 ?? []).map((cid) => ({ cid, 이름: names.get(cid) ?? cid })), + ...removedSubs(ans), + ]; + const row = base ? { ...base.logic, 입력: [...(base.logic.입력 ?? []), ...extra] } : standIn(); const view = ans ? hoView(ans, bare, base?.logic ?? null, doc) : null; const baseNames = [...(row.호표 ?? []), ...(row.덧줄 ?? [])].map((x) => String(x.이름 ?? ("요소" in x ? x.요소 : "")), @@ -305,7 +312,7 @@ export function mountSangeunDetail( : [createText(L("M02_SangeunWait"))], }); buildLabDetail(editor, { - row, + row: base?.logic ?? row, file: base?.file ?? "", reasons: base?.reasons ?? [], prices: base?.prices ?? {}, @@ -346,6 +353,17 @@ export function mountSangeunDetail( onClick: () => (close(), dropLine(doc, r), touch()), }); drop.dataset.skDrop = ""; + const sub = r.sub; + const whole = sub + ? [ + createButton({ + label: L("M02_SangeunDropBase"), + variant: "danger-ghost", + size: 28, + onClick: () => (close(), dropBase(doc, sub.k), touch()), + }), + ] + : []; const apart = r.apartPick ? [ createCheckbox(L("M02_SangeunApart"), { @@ -356,7 +374,7 @@ export function mountSangeunDetail( }).root, ] : []; - return [el("div", { className: "m02-sk__tags", children: [...apart, drop] })]; + return [el("div", { className: "m02-sk__tags", children: [...apart, drop, ...whole] })]; }, }; } @@ -366,9 +384,17 @@ export function mountSangeunDetail( calcHost.querySelector("[data-calc-run]")?.click(); } - /** 텍스트 수식 · 계산서 입력 단추 — 시험 입력 고침 → 시험 계산 다시 그림 · 셈 */ + /** 옵션 칸 · 텍스트 수식 · 계산서 입력 단추 — 입력 고침 → SK `입력값`(727-3 · 열린입력 = 시험 값만) · + * 시험 계산 다시 그림 · 셈 */ function onInput(name: string, v: string): void { values[name] = v; + if (edit && !(doc.열린입력 ?? []).includes(name)) { + const kept = { ...(doc.입력값 ?? {}) }; + if (String(v).trim()) kept[name] = v; + else delete kept[name]; + doc.입력값 = kept; + touch(false); + } drawCalc(); calcHost.querySelector("[data-calc-run]")?.click(); if (!base) void recalc(); // 바탕 없음 — 시험 계산 단추 없음 · SK 셈을 바로 @@ -423,6 +449,12 @@ export function mountSangeunDetail( return { getDoc: () => { if (ans?.바탕판 && doc.바탕) doc.바탕판 = ans.바탕판; // 저장 = 지금 바탕 확인(서버가 다시 적음) + for (const l of ans?.줄 ?? []) { + const one = l.바탕판 + ? doc.더한줄?.[Number(/^add:#(\d+)$/.exec(l.cid)?.[1] ?? -1)] + : undefined; + if (one) one.바탕판 = l.바탕판; // 둘째 기초도 같음(727-2) + } saved = JSON.stringify(doc); return clone(doc); }, diff --git a/M02_MasterTemplete/M02_Sangeun_Calc.py b/M02_MasterTemplete/M02_Sangeun_Calc.py index 9a49015cb..4cc7e3534 100644 --- a/M02_MasterTemplete/M02_Sangeun_Calc.py +++ b/M02_MasterTemplete/M02_Sangeun_Calc.py @@ -7,6 +7,10 @@ 켜쌓기율 · 고름 · % 덧줄을 엔진이 다시 셈. 1 단위 = 기준 ÷ 배(바탕 로직 결과단위 앞 수). 계산 자리(CLAUDE.md 5장 ③) = 서버 단독 · 화면은 셈 길(저장 안 함 · 최신 응답만). 저장(`clean_doc`) = 꼴 검사 + 참조 위 층 · 있는 문서 · 순환 422 + 서버가 `원값` · `바탕판` 적음. +둘째 기초(727-2 · 더한줄 로직) = 세부 덮개 · 뺀줄 있으면 고친 기초 줄을 엔진으로 셈한 `값묶음` 줄 +(B09 굳힘도 그 값) · 화면 셈 길(`calc_doc`) = 그 줄 덧칸 `펼침`(세부 줄 × 수량) · `뺀세부` · +`바탕판` · +`꼬리표`(원문번호) · 답 `꼬리표` = 바탕 원문번호. """ from __future__ import annotations @@ -98,9 +102,14 @@ def resolve( hit = re.match(r"^add:#(\d+)$", cid) if hit and int(hit[1]) in lack: items[at] = _gap(items[at], lack[int(hit[1])]) + elif hit and sd.sub_patched(doc["더한줄"][int(hit[1])]): # 고친 둘째 기초 = 셈한 값묶음 + sub = sub_run(doc["더한줄"][int(hit[1])], whole) + got["짝잃음"] += [{**x, "cid": f"{cid}/{x['cid']}"} for x in sub["짝잃음"]] + items[at] = _bundled(items[at], sub) rate = doc.get("켜쌓기율", base["켜쌓기율"]) return { **got, + "채운더한줄": doc["더한줄"], "짝잃음": base["짝잃음"] + got["짝잃음"], "입력값": {**base["입력값"], **(doc.get("입력값") or {})}, "켜쌓기율": rate, @@ -160,6 +169,78 @@ def _gap(item: dict, why: str) -> dict: return {**head, "종류": "로직", "요소": None, "수량": item["수량"], "값묶음": zero, "빠짐": why} +# ── 둘째 기초 세부(727-2) ───────────────────────────────────────────────── +def sub_run(one: dict, whole: Any, patched: bool = True) -> dict[str, Any]: + """더한줄 로직 하나 = 그 기초 줄(+ 세부 덮개 · 뺀줄)을 엔진으로 — `run_line` 답 + 정체 · + 짝잃음 · + 바탕판(덮기 전 기초 지문) · 입력 = 채운 줄 값(`_fill`). `patched` False = 덮기 전.""" + code = str(one["로직"]) + try: + row = whole.logic(code) + except Exception as e: # noqa: BLE001 — 엔진 FormulaError + return {"기준": None, "까닭": str(e), "정체": [], "짝잃음": [], "바탕판": ""} + got = sd.patched(row, one if patched else {}) + over = _Over(whole, code, {**got["줄"], "키": code}) + out = labor.run_line(over, code, one.get("입력값"), None, got["고름"] or None) + return {**out, "정체": got["정체"], "짝잃음": got["짝잃음"], "바탕판": fingerprint(row)} + + +def _bundled(item: dict, sub: dict) -> dict: + """고친 둘째 기초 줄 → 셈한 1 기준단위 노 · 재 · 경 `값묶음` 줄(수량 그대로) · + 못 셈 = `_gap`.""" + if not sub.get("기준"): + return _gap(item, str(sub.get("까닭") or "돈 아닌 로직")) + head = {k: item.get(k) or "" for k in ("이름", "단위")} + bundle = {k: Decimal(str(sub["기준"][k])) for k in COSTS} + return {**head, "종류": "로직", "요소": None, "수량": item["수량"], "값묶음": bundle} + + +def tag_of(whole: Any, code: str) -> str: + """꼬리표 = 기초 원문번호(「9-12-1」) · 없으면 키.""" + try: + return str(whole.logic(code).get("원문번호") or code) + except Exception: # noqa: BLE001 + return code + + +def _detail(one: dict, at: int, whole: Any) -> dict[str, Any]: + """둘째 기초 세부 줄(화면) — 수량 · 금액 · 비목 = 세부 × 더한줄 수량 · cid `add:#k/세부 cid` · + `바탕값` = 덮기 전 1 기준단위 품 · 단가 · `뺀세부` = 뺀 줄 이름.""" + sub = sub_run(one, whole) + plain = sub_run(one, whole, False) if sd.sub_patched(one) else sub + was = dict(zip(plain["정체"], plain.get("줄") or [])) + covers, times = one.get("덮개") or {}, Decimal(str(one.get("수량") or 0)) + rows = [] + for cid, line in zip(sub["정체"], sub.get("줄") or []): + mine = covers.get(cid) or {} + scaled = { + k: None if line.get(k) is None else Decimal(str(line[k])) * times + for k in ("수량", "금액") + } + parts = { + k: None if v is None else Decimal(str(v)) * times + for k, v in (line.get("비목") or {}).items() + } + rows.append( + { + **line, + **scaled, + "비목": parts, + "cid": f"add:#{at}/{cid}", + "바탕값": _was(was[cid]) if cid in was else None, + **({"덮개": [k for k in sd.COVER_KEYS if k in mine]} if mine else {}), + **({"원값": mine["원값"]} if "원값" in mine else {}), + } + ) + gone = [ + {"cid": f"add:#{at}/{c}", "이름": was[c].get("이름") or ""} + for c in one.get("뺀줄") or [] + if c in was + ] + tag = tag_of(whole, str(one["로직"])) + return {"펼침": rows, "뺀세부": gone, "바탕판": sub["바탕판"], "꼬리표": tag} + + def _bundles(row: dict, whole: Any, expand: Any, trail: tuple) -> None: """더한줄 참조 · 고정 줄 `값묶음` = 1 단위 노 · 재 · 경 — 못 셈 줄 = `_gap`(나머지 셈 계속).""" for at, item in enumerate(row["호표"]): @@ -182,9 +263,11 @@ def run( read: Any = refs.read_ref, expand: Any = None, trail: tuple = (), + detail: bool = False, ) -> dict[str, Any]: """SK 셈(저장 안 함) — `run_line` 답 + `{1단위, 배, 정체, 짝잃음, 바탕판}` · 줄마다 `cid` · - 고친 칸 `덮개`(칸 이름) · `원값`. 틀린 문서 · 순환 = `sd.DocError`.""" + 고친 칸 `덮개`(칸 이름) · `원값`. 틀린 문서 · 순환 = `sd.DocError` · + `detail` = 둘째 기초 세부 펼침(`_detail` · 화면 셈 길만).""" from M02_MasterTemplete.M02_Labor_Expand import Expand got = resolve(doc, whole, read, trail, given) @@ -205,6 +288,10 @@ def run( mark = {"덮개": [k for k in sd.COVER_KEYS if k in mine]} if mine else {} if at in gaps: # 못 셈 줄 = 금액 — · 까닭(725) mark |= {"단가": None, "금액": None, "비목": {}, "까닭": gaps[at]["빠짐"]} + hit = re.match(r"^add:#(\d+)$", cid) + one = got["채운더한줄"][int(hit[1])] if hit else {} + if detail and one.get("종류") == "로직" and at not in gaps: + mark |= _detail(one, int(hit[1]), whole) lines.append( {**line, "cid": cid, **mark, **({"원값": mine["원값"]} if "원값" in mine else {})} ) @@ -215,7 +302,9 @@ def run( {"cid": got["정체"][at], "이름": x.get("이름") or "", "까닭": x["빠짐"]} for at, x in gaps.items() ] - return {**out, **head, "1단위": unit, "빠진줄": gone} + base = doc.get("바탕") or {} + tag = tag_of(whole, base["로직"]) if "로직" in base else (base.get("참조") or {}).get("키") + return {**out, **head, "1단위": unit, "빠진줄": gone, "꼬리표": tag} def unit(doc: dict, whole: Any, expand: Any, trail: tuple) -> dict[str, Any]: @@ -285,11 +374,28 @@ def clean_doc(doc: Any, here_: refs.Here) -> dict[str, Any]: if cid in was: one["원값"] = _was(was[cid]) out["바탕판"] = fingerprint(base["줄"]) + _stamp_adds(out, base, whole) except sd.DocError as e: raise _fail(e) from e return out +def _stamp_adds(out: dict, base: dict, whole: Any) -> None: + """둘째 기초 저장 칸(727-2) — `바탕판` = 그 기초 지문 · 세부 덮개 `원값` = 덮기 전 + 1 기준단위 값.""" + filled, _ = _fill(out, whole, {**base["입력값"], **out["입력값"]}) + for one, mine in zip(filled["더한줄"], out["더한줄"]): + if one.get("종류") != "로직": + continue + plain = sub_run(one, whole, False) + if plain["바탕판"]: + mine["바탕판"] = plain["바탕판"] + was = dict(zip(plain["정체"], plain.get("줄") or [])) + for cid, cover in (mine.get("덮개") or {}).items(): + if cid in was: + cover["원값"] = _was(was[cid]) + + def _was(line: dict) -> dict[str, Any]: keep = {"qty": line.get("율", line.get("수량")), "단가": line.get("단가")} return {k: sd.plain(Decimal(str(v))) for k, v in keep.items() if v is not None} @@ -304,7 +410,7 @@ def calc_doc(doc: Any, given: dict | None = None) -> dict[str, Any]: clean = sd.clean(doc, None, whole) _check_refs(clean, refs.here()) # 저장 전 문서도 위 층만 · 있는 문서 · 순환 422 me = refs.ref_id({"층": refs.here()[0], "주인": refs.here()[1], "키": clean["키"]}) - got = run(clean, whole, given, trail=(me,)) + got = run(clean, whole, given, trail=(me,), detail=True) except sd.DocError as e: raise _fail(e) from e return labor._json({**got, "머리": head_of(clean)}) diff --git a/M02_MasterTemplete/M02_Sangeun_Doc.py b/M02_MasterTemplete/M02_Sangeun_Doc.py index 0a0427eb3..d8e47fb4e 100644 --- a/M02_MasterTemplete/M02_Sangeun_Doc.py +++ b/M02_MasterTemplete/M02_Sangeun_Doc.py @@ -8,8 +8,13 @@ 덮개 {cid: {qty | 배율, labor, material, expense, 원값}}, 더한줄 [{종류: 인력|재료|기계, 요소, 이름, 단위, 수량, 비목?, 단가?, 별도계상?(재료 · 없음 = 계 안)} - | {종류: 로직, 로직, 입력값, 수량} | {참조 | 고정, 이름?, 단위?, 수량}], + | {종류: 로직, 로직, 입력값, 수량, 덮개?, 뺀줄?, 바탕판?} + | {참조 | 고정, 이름?, 단위?, 수량}], 뺀줄 [cid], 바탕판} +둘째 기초(727-2) = 더한줄 로직 — 수량 = 그 기초 기준단위 몇 개 · `덮개` {세부 cid: 덮개} · +`뺀줄` [세부 cid](세부 cid = 그 기초 줄 `kind:키#n` · 화면 열쇠 `add:#k/세부 cid`) · +`바탕판` = 저장 때 서버가 그 기초 지문. +SK `입력값` = 바탕 입력 + 더한줄 로직 입력(727-3 · 넣은 뒤 고른 옵션). 줄 정체 cid = `kind:키#n`(B09 702 덮개 꼴) — kind = labor · material · machine · logic · pct(덧줄) · 키 = 요소 키(로직 줄 = 부르는 GC 키 · 조건 재료 = 이름 · 덧줄 = 이름 · 더한줄 참조 = 그 키) · n = 같은 머리 차례 · 바탕 줄(M01 로직 줄 · 바탕 SK 의 고친 줄)에서 셈 — 뺀줄 · 더한줄 뒤 같은 줄. @@ -171,6 +176,7 @@ def _added(raw: Any, where: str, whole: Any) -> dict: if row is not None: head["입력값"] = {k: v for k, v in given.items() if k in names} name = name or str(row.get("이름") or code) + head |= _sub_patch(raw, where) elif raw.get("종류") in ADD_PART: key = str(raw.get("요소") or "") got = _element(whole, key, where) @@ -189,6 +195,41 @@ def _added(raw: Any, where: str, whole: Any) -> dict: return {**head, "이름": name, "단위": unit, "수량": qty} +def _sub_patch(raw: dict, where: str) -> dict: + """둘째 기초 세부 고침(727-2) — `덮개` {세부 cid: 덮개} · `뺀줄` [세부 cid] · 빈 칸은 안 둠 · + `원값` · `바탕판` = 서버 몫(버림).""" + covers = { + _cid(c, f"{where} 덮개"): _cover(one, f"{where} 덮개 「{c}」", False) + for c, one in (raw.get("덮개") or {}).items() + } + drop = [_cid(x, f"{where} 뺀줄") for x in raw.get("뺀줄") or []] + if len(set(drop)) != len(drop): + raise DocError(f"{where} · 뺀줄에 같은 줄 두 번") + return {**({"덮개": covers} if covers else {}), **({"뺀줄": drop} if drop else {})} + + +def sub_patched(one: dict) -> bool: + """고친 둘째 기초 — 더한줄 로직에 세부 덮개 · 뺀줄.""" + return one.get("종류") == "로직" and bool(one.get("덮개") or one.get("뺀줄")) + + +def _added_names(whole: Any, adds: Any) -> set[str]: + """더한줄 로직 입력 이름(727-3 · SK 입력값에 남길 옵션) — 못 읽는 로직은 뺌 + (검사가 따로 막음).""" + out: set[str] = set() + if whole is None: + return out + for one in adds or []: + if isinstance(one, dict) and one.get("종류") == "로직": + try: + out |= { + str(s.get("이름")) for s in whole.logic(str(one.get("로직"))).get("입력") or [] + } + except Exception: # noqa: BLE001 + continue + return out + + def _logic(whole: Any, code: str, where: str) -> dict | None: if whole is None: return None @@ -230,7 +271,8 @@ def clean(doc: Any, base_row: dict | None = None, whole: Any = None) -> dict[str given = _given(doc.get("입력값"), "입력값") opened = [str(x) for x in doc.get("열린입력") or []] if base_row is not None: - given = {k: v for k, v in given.items() if k in names} + keep = names | _added_names(whole, doc.get("더한줄")) + given = {k: v for k, v in given.items() if k in keep} bad = [x for x in opened if x not in names or x in given] if bad: raise DocError(f"열린입력은 바탕 입력 중 값 안 넣은 것만 {bad}") diff --git a/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts b/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts index 26d66fb2e..0734af3ad 100644 --- a/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts +++ b/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts @@ -7,6 +7,8 @@ * 줄 정체 cid = `kind:키#n`(바탕 줄) · `add:#k`(더한줄 k 째) · 덮개 원값 = 저장 때 서버가 다시 적음 * (화면은 고친 첫때 바탕 값을 적어 둠 — 곁 글용) · 열린입력 있는 SK = 품은 배율만 * 일위대가 줄 → 이름 없는 SK 문서 = `lineDoc`(서버 `M02_Labor_Patch.as_doc` 꼴) + * 둘째 기초(727-2) = 더한줄 로직 줄을 서버 `펼침`(세부 줄)으로 바꿔 놓음 · 열쇠 `add:#k/세부 cid` · + * 고침 = 그 더한줄의 `덮개` · `뺀줄`(품 = 화면 품 ÷ 수량 · 1 기준단위 몫) · 꼬리표 = 기초 둘 이상일 때만 * 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장). * ========================================================================== */ @@ -33,6 +35,10 @@ export interface SkAdded { 종류?: string; 수량: number; 단가?: number; + /** 둘째 기초 세부 고침(727-2) — 열쇠 = 세부 cid */ + 덮개?: Record; + 뺀줄?: string[]; + 바탕판?: string; [k: string]: unknown; } @@ -65,6 +71,13 @@ export interface SkLine { 율?: number; /** 못 셈 줄(725) — 금액 없음 · 계에 안 듦 */ 까닭?: string; + /** 둘째 기초(727-2) — 세부 줄 · 뺀 세부 · 그 기초 지문 · 원문번호 */ + 펼침?: SkLine[]; + 뺀세부?: { cid: string; 이름: string }[]; + 바탕판?: string; + 꼬리표?: string; + /** 세부 줄 — 덮기 전 1 기준단위 품 · 단가 */ + 바탕값?: { qty?: number; 단가?: number } | null; } export interface SkMat { @@ -87,6 +100,8 @@ export interface SkAnswer { 짝잃음: { cid: string; 까닭: string; 바탕?: string }[]; 바탕판: string; 머리?: string; + /** 바탕 원문번호(참조 바탕 = 그 키) */ + 꼬리표?: string | null; } export interface CoverRow { @@ -115,11 +130,57 @@ export interface CoverRow { priceEdit: boolean; /** 더한 재료 줄 — 별도계상 고름(기본 계 안) */ apartPick: boolean; + /** 둘째 기초 세부 줄 — 더한줄 차례 · 세부 cid · 수량(배) · 바탕 줄 · 그 밖 = null */ + sub: { k: number; cid: string; scale: number } | null; } /* ── 순수 셈 ── */ const BUNDLE_OF: Record = { 노무비: "인력", 재료비: "재료", 경비: "경비" }; const ADD_RE = /^add:#(\d+)$/; +const SUB_RE = /^add:#(\d+)\/(.+)$/; + +/** 세부 열쇠 `add:#k/세부 cid` → {k, cid} */ +export function subOf(key: string): { k: number; cid: string } | null { + const m = SUB_RE.exec(key); + return m ? { k: Number(m[1]), cid: m[2] } : null; +} + +/** 셈 답 줄 — 둘째 기초 줄은 세부 줄로 바꿔 놓음(정체도 같은 차례) */ +export function flatLines(ans: SkAnswer): SkLine[] { + return ans.줄.flatMap((l) => l.펼침 ?? [l]); +} +function flatIds(ans: SkAnswer): string[] { + const by = new Map(ans.줄.map((l) => [l.cid, l])); + return ans.정체.flatMap((c) => by.get(c)?.펼침?.map((x) => x.cid) ?? [c]); +} + +/** 기초 수 — 로직 바탕 · 더한줄 로직 */ +const baseCount = (doc: SkDoc): number => + (doc.바탕?.로직 ? 1 : 0) + (doc.더한줄 ?? []).filter((x) => x.종류 === "로직").length; + +/** 줄 꼬리표(기초 둘 이상일 때만) — 바탕 줄 = 바탕 원문번호 · 세부 줄 = 그 기초 원문번호 · 그 밖 = "" */ +export function tagsOf(ans: SkAnswer, doc: SkDoc): Map { + const out = new Map(); + if (baseCount(doc) < 2) return out; + for (const l of ans.줄) { + if (l.펼침) for (const x of l.펼침) out.set(x.cid, l.꼬리표 ?? ""); + else if (!ADD_RE.test(l.cid) && ans.꼬리표) out.set(l.cid, ans.꼬리표); + } + return out; +} + +/** 뺀 세부 줄(되살림 띠) */ +export const removedSubs = (ans: SkAnswer | null): { cid: string; 이름: string }[] => + (ans?.줄 ?? []).flatMap((l) => l.뺀세부 ?? []); + +/** M01 개정된 둘째 기초 — 저장 때 지문 ≠ 지금 지문 */ +export function revisedAdds(ans: SkAnswer | null, doc: SkDoc): number[] { + return (ans?.줄 ?? []).flatMap((l) => { + const hit = ADD_RE.exec(l.cid); + const one = hit ? doc.더한줄?.[Number(hit[1])] : undefined; + return one?.바탕판 && l.바탕판 && one.바탕판 !== l.바탕판 ? [Number(hit![1])] : []; + }); +} const COVER_KEYS = ["qty", "배율", ...PARTS] as const; const partsOf = (l: { 비목?: Record }): Record => @@ -156,15 +217,17 @@ export function rowsOf(ans: SkAnswer, bare: SkAnswer | null, doc: SkDoc): CoverR 금액: number | null; }, parts: Record, - extra: { 묶음?: string | null; 율?: number; apart?: boolean }, + extra: { 묶음?: string | null; 율?: number; apart?: boolean; 바탕값?: SkLine["바탕값"] }, ): CoverRow => { const hit = ADD_RE.exec(cid); const added = hit ? Number(hit[1]) : null; + const at = subOf(cid); const one = added === null ? undefined : doc.더한줄?.[added]; - const cover = added === null ? doc.덮개?.[cid] : undefined; - const pct = cid.startsWith("pct:"); + const owner = at ? doc.더한줄?.[at.k] : undefined; + const cover = at ? owner?.덮개?.[at.cid] : added === null ? doc.덮개?.[cid] : undefined; + const pct = (at?.cid ?? cid).startsWith("pct:"); const keys = Object.keys(parts); - const base = added === null ? (was.get(cid) ?? null) : null; + const base = at ? subWas(extra.바탕값) : added === null ? (was.get(cid) ?? null) : null; const old = cover?.원값; return { cid, @@ -191,9 +254,10 @@ export function rowsOf(ans: SkAnswer, bare: SkAnswer | null, doc: SkDoc): CoverR (old.단가 != null && !near(old.단가, base.단가))), priceEdit: !pct && (added === null || ["인력", "재료", "기계"].includes(String(one?.종류))), apartPick: one?.종류 === "재료", + sub: at ? { ...at, scale: Number(owner?.수량 ?? 1) } : null, }; }; - for (const l of ans.줄) rows.push(row(l.cid, l, partsOf(l), l)); + for (const l of flatLines(ans)) rows.push(row(l.cid, l, partsOf(l), l)); const seen = new Set(rows.map((r) => r.cid)); for (const m of ans.재료 ?? []) { const cid = ans.정체[m.차례]; @@ -203,6 +267,9 @@ export function rowsOf(ans: SkAnswer, bare: SkAnswer | null, doc: SkDoc): CoverR return rows; } +const subWas = (v: SkLine["바탕값"]): CoverRow["was"] => + v ? { qty: v.qty ?? null, 단가: v.단가 ?? null } : null; + const memo = (base: CoverRow["was"]): SkCover["원값"] | undefined => { if (!base) return undefined; const out: { qty?: number; 단가?: number } = {}; @@ -212,12 +279,16 @@ const memo = (base: CoverRow["was"]): SkCover["원값"] | undefined => { }; function putCover(doc: SkDoc, r: CoverRow, cover: SkCover): void { - const covers = { ...(doc.덮개 ?? {}) }; + const owner = r.sub ? doc.더한줄?.[r.sub.k] : doc; + if (!owner) return; + const key = r.sub?.cid ?? r.cid; + const covers = { ...(owner.덮개 ?? {}) }; if (COVER_KEYS.some((k) => cover[k] != null)) { const old = cover.원값 ?? memo(r.was); - covers[r.cid] = { ...cover, ...(old ? { 원값: old } : {}) }; - } else delete covers[r.cid]; - doc.덮개 = covers; + covers[key] = { ...cover, ...(old ? { 원값: old } : {}) }; + } else delete covers[key]; + if (r.sub && !Object.keys(covers).length) delete owner.덮개; + else owner.덮개 = covers; } /** 공용 상세 고침 칸(`LogicLab_Cover.LineCover` 와 같은 꼴) — 품 · 비목 단가(로직 줄 = 1 단위 노 · 재 · 경) */ @@ -236,10 +307,12 @@ export function setLineCover(doc: SkDoc, r: CoverRow, c: LineCover | null, opene return; } const cover: SkCover = {}; - const qty = c?.qty; + // 세부 줄 품 = 화면 품 ÷ 더한줄 수량(1 기준단위 몫 · 수량 0 = 그대로) + const qty = r.sub && c?.qty != null && r.sub.scale ? c.qty / r.sub.scale : c?.qty; + const open = opened && !r.sub; // 열린입력 = 바탕 입력 — 세부 줄은 품 그대로 // ponytail: 열린입력 배율 = 고친 품 ÷ 바탕 품(바탕 품 0 · 없음 = 품 고침 못 함) - if (qty != null && opened && r.was?.qty) cover.배율 = qty / r.was.qty; - if (qty != null && !opened && !near(qty, r.was?.qty)) cover.qty = qty; + if (qty != null && open && r.was?.qty) cover.배율 = qty / r.was.qty; + if (qty != null && !open && !near(qty, r.was?.qty)) cover.qty = qty; if (r.priceEdit) for (const k of PARTS) if (c?.[k] != null) cover[k] = c[k]; if (cover.배율 != null && near(cover.배율, 1)) delete cover.배율; putCover(doc, r, cover); @@ -247,6 +320,12 @@ export function setLineCover(doc: SkDoc, r: CoverRow, c: LineCover | null, opene /** 줄 빼기 — 더한줄 = 그 줄 지움 · 바탕 줄 = 뺀줄에 cid(덮개 · 고름 · 자재표시도 지움) */ export function dropLine(doc: SkDoc, r: CoverRow): void { + const one = r.sub ? doc.더한줄?.[r.sub.k] : undefined; + if (one && r.sub) { + one.뺀줄 = [...(one.뺀줄 ?? []).filter((c) => c !== r.sub!.cid), r.sub.cid]; + if (one.덮개) delete one.덮개[r.sub.cid]; + return; + } if (r.added !== null) { doc.더한줄 = (doc.더한줄 ?? []).filter((_, i) => i !== r.added); return; @@ -267,11 +346,23 @@ export function setApart(doc: SkDoc, r: CoverRow, on: boolean): void { else delete one.별도계상; } -/** 뺀 줄 되살림 */ +/** 뺀 줄 되살림 — 세부 열쇠면 그 더한줄의 뺀줄 */ export function restoreLine(doc: SkDoc, cid: string): void { + const at = subOf(cid); + const one = at ? doc.더한줄?.[at.k] : undefined; + if (one && at) { + one.뺀줄 = (one.뺀줄 ?? []).filter((c) => c !== at.cid); + if (!one.뺀줄.length) delete one.뺀줄; + return; + } doc.뺀줄 = (doc.뺀줄 ?? []).filter((c) => c !== cid); } +/** 둘째 기초 통째 빼기 — 그 더한줄 지움 */ +export function dropBase(doc: SkDoc, k: number): void { + doc.더한줄 = (doc.더한줄 ?? []).filter((_, i) => i !== k); +} + const NUM = new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 4 }); const num = (v: number | null): string => (v == null ? "—" : NUM.format(v)); @@ -295,7 +386,7 @@ export function sheetGroups(rows: CoverRow[]): SheetGroup[] { 글: r.pct ? `${num(r.수량)} %` : `${num(share)} * ${num(r.수량)} = ${num(amount)}`, 금액: amount, 묶음: r.묶음, - 장비: entries.length > 1 && r.cid.startsWith("logic:"), + 장비: entries.length > 1 && (r.sub?.cid ?? r.cid).startsWith("logic:"), }); } } @@ -313,11 +404,14 @@ export interface HoItem { 단위?: string; 수량: string; 비목?: string; + /** 출처 꼬리표(727-2 · 기초 둘 이상) */ + 꼬리표?: string; } export interface HoExtra { 이름: string; 식: string; 비목?: string; + 꼬리표?: string; } export interface HoView { 호표: HoItem[]; @@ -349,18 +443,28 @@ export function hoView( const it = i < head.length ? head[i] : base.덧줄?.[i - head.length]; if (it) known.set(cid, it); }); - const lineBy = new Map(ans.줄.map((l) => [l.cid, l])); + const lineBy = new Map(flatLines(ans).map((l) => [l.cid, l])); + const tags = tagsOf(ans, doc); const single = (l?: SkLine): string | undefined => { const parts = Object.entries(l?.비목 ?? {}).filter(([, v]) => v); return parts.length === 1 ? parts[0][0] : undefined; }; const ho: [string, HoItem][] = []; const extra: [string, HoExtra][] = []; - for (const cid of ans.정체) { + for (const cid of flatIds(ans)) { const l = lineBy.get(cid); const name = l?.이름 ?? cid; - if (cid.startsWith("pct:")) { - extra.push([cid, (known.get(cid) as HoExtra) ?? { 이름: name, 식: "" }]); + const at = subOf(cid); + const tag = tags.has(cid) ? { 꼬리표: tags.get(cid) } : {}; + if ((at?.cid ?? cid).startsWith("pct:")) { + extra.push([cid, { ...((known.get(cid) as HoExtra) ?? { 이름: name, 식: "" }), ...tag }]); + continue; + } + if (at) { + const kind = KIND_OF[at.cid.split(":")[0]] ?? "로직"; + const qty = String(l?.율 ?? l?.수량 ?? ""); + const item = { 종류: kind, 요소: "", 이름: name, 단위: l?.단위 ?? "", 수량: qty }; + ho.push([cid, { ...item, 비목: single(l), ...tag }]); continue; } const hit = ADD_RE.exec(cid); @@ -378,7 +482,7 @@ export function hoView( 수량: String(a?.수량 ?? l?.율 ?? l?.수량 ?? ""), 비목: typeof a?.비목 === "string" ? a.비목 : single(l), }; - ho.push([cid, a ? made : ((known.get(cid) as HoItem) ?? made)]); + ho.push([cid, { ...(a ? made : ((known.get(cid) as HoItem) ?? made)), ...tag }]); } const cids = [...ho, ...extra].map(([cid]) => cid); const covers: Record = {}; @@ -387,11 +491,13 @@ export function hoView( const a = hit ? doc.더한줄?.[Number(hit[1])] : undefined; const part = PART_OF[String(a?.비목 ?? "")]; if (a) covers[i] = { qty: a.수량, ...(a.단가 != null && part ? { [part]: a.단가 } : {}) }; - const c = hit ? undefined : doc.덮개?.[cid]; + const at = subOf(cid); + const owner = at ? doc.더한줄?.[at.k] : undefined; + const c = hit ? undefined : at ? owner?.덮개?.[at.cid] : doc.덮개?.[cid]; if (!c) return; const l = lineBy.get(cid); const out: LineCover = {}; - if (c.qty != null) out.qty = c.qty; + if (c.qty != null) out.qty = at ? c.qty * Number(owner?.수량 ?? 1) : c.qty; else if (c.배율 != null && l) out.qty = l.율 ?? l.수량 ?? undefined; for (const k of PARTS) if (c[k] != null) out[k] = c[k]; covers[i] = out; diff --git a/resources/tester/test_727_sk_base_add.py b/resources/tester/test_727_sk_base_add.py index d5194a689..5bc9d0f35 100644 --- a/resources/tester/test_727_sk_base_add.py +++ b/resources/tester/test_727_sk_base_add.py @@ -1,7 +1,8 @@ """727 M02 단가산출 기초 넣기(요구서_내역19 · 내역 20-1) — 임시 storage · 실제 M01 읽기만. - 727-1 고르기 창 기초 = 로직마다 한 줄(`logic/M02_Find_Heads.ts` · 옛 창도) · 규격 칸 = 「옵션 N」 -- 727-2 첫 기초 = 바탕 · 둘째부터 = 더한줄 세부 펼침(덮개 · 뺀줄 · M01 개정 지문 · 꼬리표) · B09 굳힘 같은 값 +- 727-2 첫 기초 = 바탕 · 둘째부터 = 더한줄 세부 펼침(덮개 · 뺀줄 · M01 개정 지문 · 꼬리표) · + B09 굳힘 같은 값 - 727-3 넣은 뒤 고른 옵션 = SK `입력값` → 일위대가(펼침) · 굳힘도 그 옵션 """ @@ -12,8 +13,17 @@ import re import subprocess from pathlib import Path +import pytest + from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Item_Prices as prices +from M02_MasterTemplete import M02_Item_Source as source +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_Logic_Standard as standard +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Sangeun_Calc as calc +from resources.tester.test_m02_labor_ref import MINE, world # noqa: F401 — 같은 임시 층 ROOT = Path(__file__).resolve().parents[2] M02 = ROOT / "M02_MasterTemplete" @@ -65,3 +75,163 @@ def test_727_1_두_창_모두_기초_한_줄(): assert "std = s?.줄.length ? logicHeads(s.줄) : null;" in find assert "std = got.줄.length ? logicHeads(got.줄) : null;" in old assert "logicOnly" not in find + + +# ── 727-2 · 727-3 서버(임시 storage) ─────────────────────────────────────── +# 금속기와 해체 3-2-1 · 원/㎡ · 지붕잇기공 0.018 · 보통인부 0.012 · 덧줄 공구손료 3 % +PCT = "GC001109" +SECOND = {"종류": "로직", "로직": PCT, "입력값": {}, "수량": 1} +COVER = {"labor:LB000036#0": {"qty": 0.036}} +DROP = ["pct:공구손료 및 경장비#0"] + + +def _sk(folder, **doc): + with store.at(folder): + made = refs.create("sangeun", {"이름": "sub7임시", "단위": "㎥", "더한줄": [], **doc}) + return store.read("sangeun", made["이름"])["문서"] + + +def _run(folder, doc, given=None): + with store.at(folder): + return calc.calc_doc(doc, given) + + +def _line(got, cid): + return next(x for x in got["줄"] if x["cid"] == cid) + + +def _m01(code, values=None): + return float(labor.run_line(m01.loaded()[1], code, values or {}, None, None)["기준"]["계"]) + + +def test_727_2_첫_기초_바탕_세부_줄(world): # noqa: F811 + doc = _sk(world["personal"], 바탕={"로직": GF}) + got = _run(world["personal"], doc) + assert _line(got, "labor:LB000002#0")["수량"] == pytest.approx(0.023) + assert _line(got, "logic:GC000267#0")["수량"] == pytest.approx(0.01546, abs=1e-5) + assert got["꼬리표"] == "9-12-1" + + +def test_727_2_기초_둘_세부_펼침_계(world): # noqa: F811 + doc = _sk(world["personal"], 바탕={"로직": GF}, 더한줄=[SECOND]) + bare = _run(world["personal"], _sk(world["personal"], 바탕={"로직": GF})) + got = _run(world["personal"], doc) + add = _line(got, "add:#0") + assert add["꼬리표"] == "3-2-1" and add["바탕판"] and add["뺀세부"] == [] + sub = {x["cid"]: x for x in add["펼침"]} + assert list(sub) == [ + "add:#0/labor:LB000036#0", + "add:#0/labor:LB000002#0", + "add:#0/pct:공구손료 및 경장비#0", + ] + assert sub["add:#0/labor:LB000036#0"]["수량"] == pytest.approx(0.018) + assert sum(x["금액"] for x in add["펼침"]) == pytest.approx(add["금액"], rel=1e-9) + assert add["금액"] == pytest.approx(_m01(PCT), rel=1e-9) + assert got["기준"]["계"] == pytest.approx(bare["기준"]["계"] + _m01(PCT), rel=1e-9) + with store.at(world["personal"]): # 펼침 없는 셈(쓰는 자리) = 같은 계 + plain = calc.run(doc, m01.loaded()[1]) + assert float(plain["기준"]["계"]) == pytest.approx(got["기준"]["계"], rel=1e-9) + + +def test_727_2_둘째_기초_덮개_뺀줄_저장(world): # noqa: F811 + one = {**SECOND, "수량": 2, "덮개": COVER, "뺀줄": DROP} + doc = _sk(world["personal"], 바탕={"로직": GF}, 더한줄=[one]) + saved = doc["더한줄"][0] + assert saved["덮개"]["labor:LB000036#0"] == { + "qty": 0.036, + "원값": {"qty": 0.018, "단가": 233250}, + } + assert saved["뺀줄"] == DROP and saved["바탕판"] + got = _run(world["personal"], doc) + add = _line(got, "add:#0") + want = 2 * (0.036 * 233250 + 0.012 * 172068) + assert add["금액"] == pytest.approx(want, rel=1e-9) + sub = {x["cid"]: x for x in add["펼침"]} + assert sub["add:#0/labor:LB000036#0"]["덮개"] == ["qty"] + assert sub["add:#0/labor:LB000036#0"]["수량"] == pytest.approx(0.072) + assert add["뺀세부"] == [{"cid": "add:#0/" + DROP[0], "이름": "공구손료 및 경장비"}] + lost = {**one, "덮개": {"labor:LB999999#0": {"qty": 1}}} + got = _run(world["personal"], {**doc, "더한줄": [lost]}) + assert any(x["cid"] == "add:#0/labor:LB999999#0" for x in got["짝잃음"]) + + +def test_727_2_굳힘_같은_값(world, monkeypatch): # noqa: F811 + one = {**SECOND, "덮개": COVER, "뺀줄": DROP} + key = _sk( + world["personal"], + 바탕={"로직": GF}, + 더한줄=[one], + 입력값={"보정작업": "암석작업(연암・보통암・경암)"}, + ) + ref = {**MINE, "키": key["키"]} + with store.at(world["personal"]): + top = labor.clean({"열": "rv", "담은로직": [{"참조": ref, "수량": {"손": 2}}]}) + live = labor.calc(top, {})["줄"][0] + rows = source.labor_doc_rows(top) + bundle = source.price_bundle(rows, world["personal"]) + frozen = rows["참조사본"][refs.ref_id(ref)] + assert frozen["굳힌줄"]["호표"][-1]["값묶음"] # 고친 둘째 기초 = 셈한 값묶음 줄 + got = prices.calc(rows, json.loads(json.dumps(bundle, ensure_ascii=False)), {}, {}, None) + line = got["줄"][0] + assert not line["까닭"] and not live["까닭"] + for part in ("노무비", "재료비", "경비", "계"): + assert line["기준"][part] == pytest.approx(live["기준"][part], rel=1e-9) + + +def test_727_3_옵션_저장_일위대가_그_옵션(world): # noqa: F811 + rock = {"지역": "전국평균", "보정작업": "암석작업(연암・보통암・경암)"} + doc = _sk( + world["personal"], 바탕={"로직": GF}, 입력값={"보정작업": "암석작업(연암・보통암・경암)"} + ) + assert doc["입력값"] == {"보정작업": "암석작업(연암・보통암・경암)"} # 다시 열면 그대로 + with store.at(world["personal"]): + up = refs.Expand(m01.loaded()[1])({**MINE, "키": doc["키"]}) + assert float(up["기준"]["계"]) == pytest.approx(_m01(GF, rock), rel=1e-9) + assert _m01(GF, rock) > _m01(GF, {"지역": "전국평균", "보정작업": "해당 없음"}) + # 둘째 기초 입력(바탕에 없는 이름)도 SK 입력값에 남고 그 옵션으로 셈 + two = _sk(world["personal"], 바탕={"로직": PCT}, 더한줄=[{**SECOND, "로직": GF}], 입력값=rock) + assert two["입력값"] == rock + got = _run(world["personal"], two) + assert _line(got, "add:#0")["금액"] == pytest.approx(_m01(GF, rock), rel=1e-9) + + +def _model(ans, doc, script): + body = ( + f"\nconst ans = {json.dumps(ans, ensure_ascii=False)};" + f"\nconst doc = {json.dumps(doc, ensure_ascii=False)};\n{script}" + ) + return _node(["M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts"], body) + + +def test_727_2_화면_꼴_세부_줄_고침(world): # noqa: F811 + doc = _sk(world["personal"], 바탕={"로직": GF}, 더한줄=[{**SECOND, "수량": 2}]) + ans = _run(world["personal"], doc) + got = _model( + ans, + doc, + """ +const rows = rowsOf(ans, null, doc); +const view = hoView(ans, null, null, doc); +const sub = rows.find((r) => r.cid === "add:#0/labor:LB000036#0"); +setLineCover(doc, sub, { qty: 0.072 }, false); +const covered = JSON.parse(JSON.stringify(doc.더한줄[0])); +dropLine(doc, rows.find((r) => r.cid === "add:#0/pct:공구손료 및 경장비#0")); +const dropped = [...doc.더한줄[0].뺀줄]; +restoreLine(doc, "add:#0/pct:공구손료 및 경장비#0"); +const one = { ...doc, 더한줄: [] }; +console.log(JSON.stringify({ + cids: rows.map((r) => r.cid), boxes: rows.map((r) => r.묶음), sub, covered, dropped, + back: doc.더한줄[0].뺀줄 ?? null, tags: view.호표.map((x) => x.꼬리표 ?? ""), + extraTags: view.덧줄.map((x) => x.꼬리표 ?? ""), viewCids: view.cids, + single: [...tagsOf(ans, one).values()], +})); +""", + ) + assert "add:#0" not in got["cids"] and "add:#0/labor:LB000036#0" in got["cids"] + assert got["sub"]["sub"] == {"k": 0, "cid": "labor:LB000036#0", "scale": 2} + assert got["sub"]["수량"] == pytest.approx(0.036) and got["sub"]["묶음"] == "인력" + assert got["covered"]["덮개"]["labor:LB000036#0"]["qty"] == pytest.approx(0.036) # ÷ 수량 2 + assert got["covered"]["덮개"]["labor:LB000036#0"]["원값"] == {"qty": 0.018, "단가": 233250} + assert got["dropped"] == DROP and got["back"] is None + assert got["tags"] == ["9-12-1", "9-12-1", "3-2-1", "3-2-1"] # 바탕 둘 · 둘째 기초 인력 둘 + assert got["extraTags"] == ["3-2-1"] and got["single"] == [] # 기초 하나면 꼬리표 없음 diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index 8e0f29d28..cdd1288e8 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -205,6 +205,7 @@ export const ui_locales_m2 = { M02_SangeunNeed: ["비우면 저장 안 됨", "Required"], M02_SangeunAddLine: ["항목 추가", "Add item"], M02_SangeunDrop: ["빼기", "Remove"], + M02_SangeunDropBase: ["기초 빼기", "Remove base"], M02_SangeunRestore: ["되살리기", "Restore"], M02_SangeunWas: ["바탕", "Base"], M02_SangeunEditedHere: ["단가산출에서 고침", "Edited in this analysis"],