From 69d989e5c804b1914652a592e47f540d33c58b68 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 15:39:36 +0900 Subject: [PATCH 01/19] =?UTF-8?q?feat(717-1):=20=EB=8B=A8=EA=B0=80?= =?UTF-8?q?=EC=82=B0=EC=B6=9C=20=EC=83=81=EC=84=B8=20=EB=B6=80=ED=92=88=20?= =?UTF-8?q?=EA=B8=B8=20=EC=A3=BC=EC=9E=85=20=C2=B7=20M02=20logic-lib=20?= =?UTF-8?q?=EC=9D=BD=EA=B8=B0=20=C2=B7=20=EC=85=88=20=EA=B8=B8=20=EB=84=93?= =?UTF-8?q?=ED=9E=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Logic_Api.ts · Api_Fetch.ts · Logic_Note.ts — `/m01` 박힘 대신 setLogicApiPaths({ read, calc }) 길 묶음 · 안 넘기면 /m01 그대로 · 저장 · 만들기 · 관리자 길은 늘 /m01 - 새 M02_Logic_Lib_Read.py — /api/m02/logic-lib/ 로그인 길(logic/auto · logic/flow · elements · pick · materials · rows(인력 · 재료 · 기계만) · table(소요량 · 계수만) · calc · text) · M01 길 함수 그대로 부름 · row = 저장 안 한 줄 셈만 · 쓰기 없음 - main.py 등록(로그인만) · 지킴 시험 test_717_1_logic_lib.py + helper Co-Authored-By: Claude Opus 5.5 --- M01_MasterData/M01_MasterData_Api_Fetch.ts | 26 ++++-- M01_MasterData/M01_MasterData_UI_Logic_Api.ts | 39 +++++--- .../M01_MasterData_UI_Logic_Note.ts | 4 +- M02_MasterTemplete/M02_Logic_Lib_Read.py | 73 +++++++++++++++ main.py | 5 + .../tester/helper_717_1_logic_api_paths.cjs | 47 ++++++++++ resources/tester/test_717_1_logic_lib.py | 93 +++++++++++++++++++ 7 files changed, 268 insertions(+), 19 deletions(-) create mode 100644 M02_MasterTemplete/M02_Logic_Lib_Read.py create mode 100644 resources/tester/helper_717_1_logic_api_paths.cjs create mode 100644 resources/tester/test_717_1_logic_lib.py diff --git a/M01_MasterData/M01_MasterData_Api_Fetch.ts b/M01_MasterData/M01_MasterData_Api_Fetch.ts index c2eaeb337..05f9f82d2 100644 --- a/M01_MasterData/M01_MasterData_Api_Fetch.ts +++ b/M01_MasterData/M01_MasterData_Api_Fetch.ts @@ -4,6 +4,7 @@ * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; +import { logicApiPaths } from "./M01_MasterData_UI_Logic_Api"; /** 줄·표 한 개 — 파일 그대로(한글 칸). */ export type Row = Record; @@ -89,8 +90,12 @@ export type SaveResult = | { status: 422; errors: string[] } | { status: number; detail: string }; -async function call(path: string, init: RequestInit = {}): Promise<{ status: number; body: T }> { - const response = await fetch(`${API_BASE_URL}/m01${path}`, { +async function call( + path: string, + init: RequestInit = {}, + base = "/m01", +): Promise<{ status: number; body: T }> { + const response = await fetch(`${API_BASE_URL}${base}${path}`, { credentials: "include", headers: { "Content-Type": "application/json" }, ...init, @@ -98,9 +103,13 @@ async function call(path: string, init: RequestInit = {}): Promise<{ status: return { status: response.status, body: (await response.json()) as T }; } -async function get(path: string, params: Record): Promise { +async function get( + path: string, + params: Record, + base = "/m01", +): Promise { const query = new URLSearchParams(Object.entries(params).map(([k, v]) => [k, String(v)])); - const { status, body } = await call(`${path}?${query}`); + const { status, body } = await call(`${path}?${query}`, {}, base); if (status !== 200) throw new Error(String(body.detail ?? `HTTP ${status}`)); return body; } @@ -121,7 +130,11 @@ export const fetchRows = ( detail = "", state = "", ): Promise => - get("/rows", { file, page, size, q, unlinked: unlinked ? 1 : 0, sub, detail, state }); + get( + "/rows", + { file, page, size, q, unlinked: unlinked ? 1 : 0, sub, detail, state }, + logicApiPaths().read, // 고르기 표 · 품목 찾기 — 상세 부품 길(717-1) + ); /** 한 테이블 파일은 `file` · 장 그룹(소요량 · 계수 · 로직)은 `group` 으로 부름. */ export const fetchSubsAll = (where: { @@ -174,7 +187,8 @@ export interface PickItem { export const fetchPick = ( kind: PickKind, q: string, -): Promise<{ total: number; items: PickItem[] }> => get("/pick", { kind, q, limit: 50 }); +): Promise<{ total: number; items: PickItem[] }> => + get("/pick", { kind, q, limit: 50 }, logicApiPaths().read); /** 자재 찾기 — `/pick?kind=price` 감쌈 · 이름·규격 낱말이 모두 든 줄 */ export const searchMaterials = async ( diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts index 947a8a044..2ebcfbef4 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts @@ -190,8 +190,22 @@ export class ApiError extends Error { } } -async function request(path: string, body?: unknown): Promise { - const response = await fetch(`${API_BASE_URL}/m01${path}`, { +/** 상세 부품이 부르는 길 앞머리(717-1) — 읽기(로직 · 요소 · 재료 · 표 · 줄) · 셈(`/calc` · `/text`) + * · 기본 = M01(`/m01` · 시스템 관리자) · M02 = `{ read: "/m02/logic-lib", calc: "/m02/logic-lib" }`(로그인) */ +export interface LogicApiPaths { + read: string; + calc: string; +} +const M01_PATHS: LogicApiPaths = { read: "/m01", calc: "/m01" }; +let paths = M01_PATHS; +/** 상세를 그리기 전에 한 번 — 안 넘기면 M01 길로 되돌림 · 저장 · 만들기 길은 늘 `/m01` */ +export const setLogicApiPaths = (next?: LogicApiPaths): void => { + paths = next ?? M01_PATHS; +}; +export const logicApiPaths = (): LogicApiPaths => paths; + +async function request(path: string, body?: unknown, base = "/m01"): Promise { + const response = await fetch(`${API_BASE_URL}${base}${path}`, { method: body === undefined ? "GET" : "POST", credentials: "include", headers: { "Content-Type": "application/json" }, @@ -205,13 +219,16 @@ async function request(path: string, body?: unknown): Promise { const query = (params: Record): string => new URLSearchParams(params).toString(); export const fetchLogics = (): Promise => - request<{ logics: LogicSummary[] }>("/logics").then((d) => d.logics); + request<{ logics: LogicSummary[] }>("/logics", undefined, paths.read).then((d) => d.logics); /** 왼쪽 트리 목록 — 서버가 준 구분·상세구분(장 차례대로) */ export const fetchLogicSubs = (): Promise => - request<{ subs: SubBrief[] }>(`/subs?${query({ group: "로직" })}`).then((d) => d.subs); + request<{ subs: SubBrief[] }>(`/subs?${query({ group: "로직" })}`, undefined, paths.read).then( + (d) => d.subs, + ); -export const fetchLogic = (key: string): Promise => request(`/logic?${query({ key })}`); +export const fetchLogic = (key: string): Promise => + request(`/logic?${query({ key })}`, undefined, paths.read); export const fetchLogicFiles = (): Promise => request<{ files: LogicFile[] }>(`/groups/${encodeURIComponent("로직")}/files`).then( @@ -222,10 +239,10 @@ export const searchElements = ( group: string, q: string, ): Promise<{ total: number; items: ElementBrief[] }> => - request(`/elements?${query({ group, q, limit: "100" })}`); + request(`/elements?${query({ group, q, limit: "100" })}`, undefined, paths.read); export const searchPrice = (q: string): Promise<{ total: number; items: ElementBrief[] }> => - request(`/pick?${query({ kind: "price", q, limit: "100" })}`); + request(`/pick?${query({ kind: "price", q, limit: "100" })}`, undefined, paths.read); export const fetchMaterials = (cond: { sub: string; @@ -235,11 +252,11 @@ export const fetchMaterials = (cond: { /** 호표 줄 단위 — 주면 그 단위 줄만 */ unit?: string; }): Promise<{ total: number; 기본: string | null; items: ElementBrief[] }> => - request(`/materials?${query({ ...cond, limit: "100" })}`); + request(`/materials?${query({ ...cond, limit: "100" })}`, undefined, paths.read); /** 자동값 길(`GET /logic/auto`) — 입력 이름 → 견본값 · 서버에 아직 없거나 값이 없으면 빈 채로(화면은 그냥 안 채움) */ export const fetchAuto = (key: string): Promise> => - request<{ 값?: Record }>(`/logic/auto?${query({ key })}`) + request<{ 값?: Record }>(`/logic/auto?${query({ key })}`, undefined, paths.read) .then((d) => d.값 ?? {}) .catch(() => ({})); @@ -250,7 +267,7 @@ export const runCalc = (body: { file?: string; /** 시험 계산에서 고른 재료 — 호표 줄 차례 → 자재품목 키 · 저장 안 함 */ 고름?: Record; -}): Promise => request("/calc", body); +}): Promise => request("/calc", body, paths.calc); export const runText = (body: { key: string; @@ -259,7 +276,7 @@ export const runText = (body: { file?: string; /** 시험 계산에서 고른 재료 — 호표 줄 차례 → 자재품목 키 · 저장 안 함 */ 고름?: Record; -}): Promise => request("/text", body); +}): Promise => request("/text", body, paths.calc); export const saveFiles = ( files: SaveFile[], diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Note.ts b/M01_MasterData/M01_MasterData_UI_Logic_Note.ts index 40e41ed6f..f9936df40 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Note.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Note.ts @@ -9,7 +9,7 @@ import { API_BASE_URL } from "@config/config_frontend"; import { el } from "@ui/ui_template_elements"; -import type { ElementBrief, NamedFormula } from "./M01_MasterData_UI_Logic_Api"; +import { logicApiPaths, type ElementBrief, type NamedFormula } from "./M01_MasterData_UI_Logic_Api"; import { formatNumber } from "./M01_MasterData_UI_Logic_Edit"; import { tx } from "./M01_MasterData_UI_Logic_Text"; @@ -31,7 +31,7 @@ const tableCache = new Map>(); async function getJson(path: string): Promise { try { - const res = await fetch(`${API_BASE_URL}/m01${path}`); + const res = await fetch(`${API_BASE_URL}${logicApiPaths().read}${path}`); if (!res.ok) return null; return (await res.json()) as T; } catch { diff --git a/M02_MasterTemplete/M02_Logic_Lib_Read.py b/M02_MasterTemplete/M02_Logic_Lib_Read.py new file mode 100644 index 000000000..ae6231d52 --- /dev/null +++ b/M02_MasterTemplete/M02_Logic_Lib_Read.py @@ -0,0 +1,73 @@ +"""단가산출 로직 상세 읽기 · 셈 길(PLAN 717-1) — `/api/m02/logic-lib/…` · 로그인만(`main.py`). + +M01 상세 부품(`M01_MasterData_UI_Logic_Api.ts` · `Api_Fetch.ts` · `Logic_Note.ts`)이 길 앞머리만 +`/m02/logic-lib` 로 바꿔 부름 — 길 · 몸 · 답 = M01 `/api/m01/…` 와 같음(M01 길 함수를 부름). +쓰기 없음 — `/calc` · `/text` 의 `row`(저장 안 한 고친 줄)는 메모리 사본에서만 셈(`Store._swap`). +`/logics` · `/subs` · `/logic` · 원문은 `M02_MasterTemplete_Router_Combos.py` 에 있음. +""" + +from __future__ import annotations + +from fastapi import APIRouter, HTTPException + +from M01_MasterData import M01_MasterData_Router as m01 + +router = APIRouter(prefix="/api/m02/logic-lib", tags=["M02 Logic Lib Read"]) + +#: `/rows` 로 줄을 내줄 요소 파일 앞머리 — 인력 · 재료 · 기계(고르기 표 · 품목 찾기) +ROW_FILES = ("인력", "재료", "기계") +#: `/table` 로 내줄 표 파일 앞머리 — 소요량 · 계수(설명 카드 원문 표) +TABLE_FILES = ("소요량_", "계수_") + + +@router.get("/logic/auto") +def get_auto(key: str) -> dict: + return m01.get_logic_auto(key) + + +@router.get("/logic/flow") +def get_flow(key: str, file: str = "") -> dict: + """흐름 보기(읽기 · 변환해도 파일 안 바뀜).""" + return m01.get_logic_flow(key, file) + + +@router.get("/elements") +def get_elements(group: str, q: str = "", limit: int = 50) -> dict: + return m01.get_elements(group, q, min(limit, 200)) + + +@router.get("/pick") +def get_pick(kind: str, q: str = "", limit: int = 50) -> dict: + return m01.get_pick(kind, q, min(limit, 200)) + + +@router.get("/materials") +def get_materials( + sub: str, detail: str = "", spec: str = "", region: str = "", unit: str = "", limit: int = 50 +) -> dict: + return m01.get_materials(sub, detail, spec, region, unit, min(limit, 200)) + + +@router.get("/rows") +def get_rows(file: str, page: int = 1, size: int = 50, q: str = "", sub: str = "") -> dict: + if not file.startswith(ROW_FILES): + raise HTTPException(status_code=403, detail="요소 파일(인력 · 재료 · 기계)만 읽음") + return m01.get_rows(file, page, size, q, False, sub, "", "") + + +@router.get("/table") +def get_table(file: str, key: str) -> dict: + if not file.startswith(TABLE_FILES): + raise HTTPException(status_code=403, detail="소요량 · 계수 표만 읽음") + return m01.get_table(file, key) + + +@router.post("/calc") +def post_calc(body: m01.CalcBody) -> dict: + """M01 `/calc` 와 같음 — `row` = 저장 안 한 고친 줄(셈만 · 쓰기 없음).""" + return m01.post_calc(body) + + +@router.post("/text") +def post_text(body: m01.CalcBody) -> dict: + return m01.post_text(body) diff --git a/main.py b/main.py index d375c0544..982dfdf54 100644 --- a/main.py +++ b/main.py @@ -563,6 +563,11 @@ app.include_router(m02_items_router, dependencies=protected) app.include_router(m02_combos_router, dependencies=protected) app.include_router(m02_drawing_router, dependencies=protected) app.include_router(m02_labor_router, dependencies=protected) +# 단가산출 로직 상세 읽기 · 셈(PLAN 717-1) — 로그인만 · 쓰기 없음 +app.include_router( + __import__("M02_MasterTemplete.M02_Logic_Lib_Read", fromlist=["router"]).router, + dependencies=protected, +) # 단가산출(SK) 셈 길(PLAN 715-1b) — 로그인 · 층 읽기 권한은 길 안에서 app.include_router( __import__("M02_MasterTemplete.M02_Sangeun_Router", fromlist=["router"]).router, diff --git a/resources/tester/helper_717_1_logic_api_paths.cjs b/resources/tester/helper_717_1_logic_api_paths.cjs new file mode 100644 index 000000000..b67d706d8 --- /dev/null +++ b/resources/tester/helper_717_1_logic_api_paths.cjs @@ -0,0 +1,47 @@ +/* 717-1 헬퍼 — 상세 부품 길 주입 · 가짜 fetch 로 부른 주소만 모음. 짝 = `test_717_1_logic_lib.py`. */ +const path = require("path"); +const { makeLoader } = require("./helper_ts_stub_loader.cjs"); + +const ROOT = path.join(__dirname, "..", ".."); +const { load } = makeLoader({ + root: ROOT, + stubs: { M01_MasterData_UI_Logic_Edit: {}, M01_MasterData_UI_Logic_Text: {} }, +}); +const urls = []; +global.fetch = async (url) => { + urls.push(String(url).replace("http://fake", "")); + return { + ok: true, + status: 200, + statusText: "", + json: async () => ({ logics: [], subs: [], items: [], rows: [] }), + }; +}; + +const api = load("M01_MasterData", "M01_MasterData_UI_Logic_Api.ts"); +const rows = load("M01_MasterData", "M01_MasterData_Api_Fetch.ts"); +const note = load("M01_MasterData", "M01_MasterData_UI_Logic_Note.ts"); + +async function round(tag) { + await api.fetchLogic("K"); + await api.fetchAuto("K"); + await api.searchElements("재료", "q"); + await api.fetchMaterials({ sub: "s" }); + await api.runCalc({ key: "K", inputs: {} }); + await api.runText({ key: "K", inputs: {} }); + await rows.fetchRows("기계.json", 1, 5, "q"); + await rows.fetchPick("price", "q"); + await note.loadTable(`T-${tag}`); + await rows.fetchGroups(); // 관리자 길 — 늘 /m01 + await api.saveFiles([]); // 저장 — 늘 /m01 + return urls.splice(0).map((u) => u.split("?")[0]); +} + +(async () => { + const out = { before: await round("a") }; + api.setLogicApiPaths({ read: "/m02/logic-lib", calc: "/m02/calc-x" }); + out.injected = await round("b"); + api.setLogicApiPaths(); + out.after = await round("c"); + process.stdout.write(JSON.stringify(out)); +})(); diff --git a/resources/tester/test_717_1_logic_lib.py b/resources/tester/test_717_1_logic_lib.py new file mode 100644 index 000000000..aa97c99c2 --- /dev/null +++ b/resources/tester/test_717_1_logic_lib.py @@ -0,0 +1,93 @@ +"""717-1 상세 부품 공용 — 길 주입(TS) · M02 `logic-lib` 읽기 · 셈 길(로그인 · 쓰기 없음). + +TS = `helper_717_1_logic_api_paths.cjs`(가짜 fetch 주소) · 서버 = 정본 읽기만(파일 지문 같음). +""" + +from __future__ import annotations + +import hashlib +import json +import subprocess +from pathlib import Path + +import pytest +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient + +from common_util.common_util_auth import verify_session +from M01_MasterData import M01_MasterData_Store as store +from M02_MasterTemplete import M02_Logic_Lib_Read as lib + +ROOT = Path(__file__).resolve().parents[2] +HELPER = Path(__file__).parent / "helper_717_1_logic_api_paths.cjs" +KEY = "GF000001" # 경계표시 — 보통인부 한 줄 +BASE = "/api/m02/logic-lib" + + +@pytest.mark.skipif(not (ROOT / "config" / "node_modules").is_dir(), reason="의존성 미설치") +def test_길_주입_없으면_m01_있으면_넘긴_길() -> None: + done = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HELPER)], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8" + ) + assert done.returncode == 0, done.stderr + out = json.loads(done.stdout) + assert out["before"] == out["after"] # 안 넘기면 지금 그대로 + assert all(u.startswith("/m01/") for u in out["before"]) + shared, admin = out["injected"][:-2], out["injected"][-2:] + assert all(u.startswith(("/m02/logic-lib/", "/m02/calc-x/")) for u in shared) + assert [u for u in shared if u.startswith("/m02/calc-x/")] == [ + "/m02/calc-x/calc", + "/m02/calc-x/text", + ] + assert admin == ["/m01/groups", "/m01/save"] # 관리자 · 저장 길은 늘 M01 + + +@pytest.fixture(scope="module") +def user() -> TestClient: + """main.py 와 같은 등록(로그인만) · 일반 사용자.""" + app = FastAPI() + app.include_router(lib.router, dependencies=[Depends(verify_session)]) + app.dependency_overrides[verify_session] = lambda: { + "user_id": 42, "company_id": 7, "role": "USER", "is_master": False, + } # fmt: skip + return TestClient(app) + + +def _prints() -> dict[str, str]: + return {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in store.FOLDER.glob("*.json")} + + +def test_일반_계정_읽기(user: TestClient) -> None: + assert user.get(f"{BASE}/logic/auto", params={"key": KEY}).status_code == 200 + assert user.get(f"{BASE}/elements", params={"group": "인력", "q": "LB000002"}).json()["items"] + assert user.get(f"{BASE}/pick", params={"kind": "price", "q": "시멘트"}).status_code == 200 + assert user.get(f"{BASE}/rows", params={"file": "기계.json", "size": 3}).json()["rows"] + assert user.get(f"{BASE}/materials", params={"sub": "시멘트"}).status_code == 200 + flow = user.get(f"{BASE}/logic/flow", params={"key": KEY}) + assert flow.status_code == 200 and "flow" in flow.json() + found = user.get(f"{BASE}/elements", params={"group": "소요량", "q": "QF000025"}).json() + file = next(i["file"] for i in found["items"] if i["ref"] == "QF000025") + assert user.get(f"{BASE}/table", params={"file": file, "key": "QF000025"}).json()["table"] + + +def test_요소_표_밖_파일은_막음(user: TestClient) -> None: + logic_file = store.logics("", "", "", None, "")[0]["file"] + assert user.get(f"{BASE}/rows", params={"file": logic_file}).status_code == 403 + assert user.get(f"{BASE}/table", params={"file": "기계.json", "key": "x"}).status_code == 403 + assert user.get(f"{BASE}/rows", params={"file": "재료_../x.json"}).status_code == 404 + + +def test_저장_안_한_줄_셈_쓰기_없음(user: TestClient) -> None: + before = _prints() + saved = user.post(f"{BASE}/calc", json={"key": KEY, "inputs": {}}).json() + row = store.logic(KEY)["logic"] + same = user.post(f"{BASE}/calc", json={"key": KEY, "inputs": {}, "row": row}).json() + line = row["호표"][0] + row2 = {**row, "호표": [{**line, "수량": f"({line['수량']}) * 2"}, *row["호표"][1:]]} + double = user.post(f"{BASE}/calc", json={"key": KEY, "inputs": {}, "row": row2}).json() + text = user.post(f"{BASE}/text", json={"key": KEY, "inputs": {}, "row": row2}).json() + assert saved["ok"] and same["sums"] == saved["sums"] + assert double["sums"]["노무비"] == pytest.approx(saved["sums"]["노무비"] * 2) + assert text["ok"] and text["계"] == pytest.approx(double["sums"]["계"], rel=1e-6) + assert store.logic(KEY)["logic"]["호표"][0]["수량"] == line["수량"] # 정본 그대로 + assert _prints() == before From 3d4bcaea5997d9d197f8a5f66ceb06b2686f7369 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 15:42:00 +0900 Subject: [PATCH 02/19] =?UTF-8?q?feat(713-2):=20M01=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EC=A0=95=EB=B3=B8=20=EA=B0=9C=EC=A0=95=20=ED=99=95=EC=9D=B8?= =?UTF-8?q?=20=EC=B0=BD=20=E2=80=94=20/save=20=EA=B0=9C=EC=A0=95=20409=20?= =?UTF-8?q?=EB=B0=9B=EC=9C=BC=EB=A9=B4=20=EB=B0=94=EB=80=8C=EB=8A=94=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B8=20=EC=A4=84(=ED=8C=8C=EC=9D=BC=20=C2=B7=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=C2=B7=20=EB=B0=94=EB=80=9C)=20=EC=B0=BD?= =?UTF-8?q?=20=C2=B7=20=E3=80=8C=EC=9B=90=EB=AC=B8=20=EA=B0=9C=EC=A0=95=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=E3=80=8D=20=ED=99=95=EC=9D=B8=20=EB=92=A4=20?= =?UTF-8?q?=EA=B0=99=EC=9D=80=20=EB=AA=B8=EC=97=90=20=EA=B0=9C=EC=A0=95?= =?UTF-8?q?=ED=99=95=EC=9D=B8=20true=20=EB=8B=A4=EC=8B=9C=20=EB=B3=B4?= =?UTF-8?q?=EB=83=84=20=C2=B7=20=EC=B7=A8=EC=86=8C=20=3D=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=20=EA=B7=B8=EB=8C=80=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 M01_MasterData_UI_LogicLab_Revise.ts(reviseHits · askRevise · saveRevised) · 부품 상자 모달 · 목록 줄 · 배지 - LogicLab [저장](흐름 저장 · 지우기 · 더하기 같은 길)이 saveRevised 로 · 줄 수 그대로(627) - Logic_Api saveFiles 에 개정확인 인자(기본 false) - 옛 409 stale 은 토스트 그대로 - 새 지킴 시험 test_713_2_revise_ui Co-Authored-By: Claude Opus 5.5 --- M01_MasterData/M01_MasterData_UI_LogicLab.ts | 6 +- .../M01_MasterData_UI_LogicLab_Revise.ts | 103 ++++++++++++++++++ M01_MasterData/M01_MasterData_UI_Logic_Api.ts | 3 +- resources/tester/test_713_2_revise_ui.py | 34 ++++++ 4 files changed, 142 insertions(+), 4 deletions(-) create mode 100644 M01_MasterData/M01_MasterData_UI_LogicLab_Revise.ts create mode 100644 resources/tester/test_713_2_revise_ui.py diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab.ts b/M01_MasterData/M01_MasterData_UI_LogicLab.ts index 877fb2cf8..7af76d2a3 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab.ts @@ -51,6 +51,7 @@ import { type LogicFlow, } from "./M01_MasterData_UI_LogicLab_Flow"; import { askBasicInfo } from "./M01_MasterData_UI_LogicLab_Basic"; +import { saveRevised } from "./M01_MasterData_UI_LogicLab_Revise"; import { buildLogicSide } from "./M01_MasterData_UI_LogicLab_Side"; import { createViewPicker } from "./M01_MasterData_UI_LogicLab_View"; import { buildHeadBar } from "./M01_MasterData_UI_Head"; @@ -501,9 +502,8 @@ export async function mountM01LogicLab( showLoadingOverlay(); try { // 키 없는 새 줄은 서버가 새 키를 줌(added) — 저장 뒤 그 로직을 엶 - const saved = (await saveFiles([...byFile.values()])) as { - added?: { file: string; key: string }[]; - }; + const saved = await saveRevised((ok) => saveFiles([...byFile.values()], ok)); + if (!saved) return; // 개정 확인 취소 = 캐시 그대로 const was = opened; drafts = {}; await reload(); diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Revise.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Revise.ts new file mode 100644 index 000000000..624452773 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Revise.ts @@ -0,0 +1,103 @@ +/* ============================================================================= + * M01_MasterData_UI_LogicLab_Revise.ts + * 로직 정본 개정 확인 창(PLAN 713-2 · 요구서 내역12 4장 10) — `/save` 409 `{개정, 까닭}` 를 받으면 + * 바뀌는 정본 줄(파일 · 로직 · 바뀜)을 보이고 「원문 개정 반영」 확인을 받음. + * 확인 = 부르는 쪽이 같은 몸에 `개정확인: true` 로 다시 보냄 · 취소 = 아무것도 안 바뀜(캐시 그대로) + * 옛 409(`stale` — 그 사이 파일 바뀜)는 이 창 대상 밖 · `reviseHits` 가 null + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, el, hideLoadingOverlay, showLoadingOverlay } from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import { createListRow } from "@ui/ui_template_list_row"; +import { createBadge, type BadgeTone } from "@ui/ui_template_badge"; +import { ApiError } from "./M01_MasterData_UI_Logic_Api"; + +/** 서버 `Store_Revise.changed` 한 줄 */ +export interface ReviseHit { + file: string; + key: string | null; + op: "edit" | "add" | "delete"; + 이름?: string | null; +} + +const TEXT = { + Title: ["원문 개정 반영", "Apply source revision"], + Lead: [ + "로직 정본(품셈 원문) 줄을 바꿈 — 원문이 개정된 때만 저장 · 아래 {n} 줄", + "This changes canonical logic rows (source text) — save only for a source revision · {n} rows below", + ], + Ok: ["원문 개정 반영", "Apply revision"], + Cancel: ["취소", "Cancel"], + New: ["새 줄", "New row"], + edit: ["고침", "Edit"], + add: ["더함", "Add"], + delete: ["지움", "Delete"], +} as const satisfies Record; + +const t = (key: keyof typeof TEXT, n = 0): string => + (TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]).replace("{n}", String(n)); + +const TONE: Record = { edit: "warning", add: "info", delete: "danger" }; + +/** 개정 409 면 바뀌는 줄 · 아니면 null */ +export function reviseHits(error: unknown): ReviseHit[] | null { + if (!(error instanceof ApiError) || error.status !== 409) return null; + const hits = (error.detail as { 개정?: ReviseHit[] } | null)?.개정; + return Array.isArray(hits) && hits.length ? hits : null; +} + +/** 확인 창 — 「원문 개정 반영」이면 true · 취소 · 닫기 · Esc · 바깥 누름이면 false */ +export function askRevise(hits: ReviseHit[]): Promise { + return new Promise((resolve) => { + openModal({ + title: t("Title"), + closeLabel: t("Cancel"), + width: "40rem", + // [취소] · Esc · 바깥 누름 = 닫기 길(묻는 길) · 확인은 `shut` 이라 이 길을 안 거침 + beforeClose: () => { + resolve(false); + return true; + }, + mount: (body, shut, actions) => { + body.append( + el("p", { text: t("Lead", hits.length) }), + ...hits.map((h) => + createListRow({ + label: `${h.key ?? t("New")} ${h.이름 ?? ""}`.trim(), + sub: h.file, + trailing: createBadge(t(h.op), TONE[h.op]), + size: 36, + }), + ), + ); + actions.append( + createButton({ + label: t("Ok"), + variant: "danger", + onClick: () => { + shut(); + resolve(true); + }, + }), + ); + }, + }); + }); +} + +/** 저장 한 번 — 개정 409 면 창을 띄워 확인 뒤 `개정확인: true` 로 다시 보냄 · 취소면 null(캐시 그대로) */ +export async function saveRevised( + save: (confirmed: boolean) => Promise, +): Promise<{ added?: { file: string; key: string }[] } | null> { + try { + return (await save(false)) as { added?: { file: string; key: string }[] }; + } catch (error) { + const hits = reviseHits(error); + if (!hits) throw error; + hideLoadingOverlay(); // 창이 가림막 아래로 숨지 않게 + const ok = await askRevise(hits); + showLoadingOverlay(); + return ok ? ((await save(true)) as { added?: { file: string; key: string }[] }) : null; + } +} diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts index 947a8a044..1f4264f10 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts @@ -263,7 +263,8 @@ export const runText = (body: { export const saveFiles = ( files: SaveFile[], -): Promise<{ files: { file: string; version: string }[] }> => request("/save", { files }); + 개정확인 = false, +): Promise<{ files: { file: string; version: string }[] }> => request("/save", { files, 개정확인 }); /** 본떠 만들기 — 정본·자체 둘 다 본뜸 · 새 키(`GX…`)는 서버가 줌(계약 5장) */ export const copyLogic = ( diff --git a/resources/tester/test_713_2_revise_ui.py b/resources/tester/test_713_2_revise_ui.py new file mode 100644 index 000000000..274ff2bfd --- /dev/null +++ b/resources/tester/test_713_2_revise_ui.py @@ -0,0 +1,34 @@ +"""713-2 정본 개정 확인 창 지킴 시험 — 글로 잡음(화면 · 서버 없음). + +- `/save` 몸에 `개정확인` 이 실림 · 로직 화면 [저장]은 `saveRevised` 한 길(개정 409 → 창 → 다시 보냄) +- 창은 부품 상자(모달 · 목록 줄 · 배지)만 · 옛 `stale` 409 는 이 창 대상 밖 +""" + +from pathlib import Path + +M01 = Path(__file__).resolve().parents[2] / "M01_MasterData" + + +def _read(name: str) -> str: + return (M01 / f"M01_MasterData_UI_{name}.ts").read_text(encoding="utf-8") + + +def test_save_body_carries_confirm(): + api = _read("Logic_Api") + assert 'request("/save", { files, 개정확인 })' in api + + +def test_lab_save_goes_through_revise(): + lab = _read("LogicLab") + assert "saveRevised((ok) => saveFiles(" in lab + assert "await saveFiles(" not in lab.replace("saveRevised((ok) => saveFiles(", "") + + +def test_revise_window_parts_only(): + rev = _read("LogicLab_Revise") + for part in ("ui_template_modal", "ui_template_list_row", "ui_template_badge"): + assert f"@ui/{part}" in rev + assert " Date: Sun, 1 Nov 2026 15:42:14 +0900 Subject: [PATCH 03/19] =?UTF-8?q?fix(716-3):=20Hopyo=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=20=EA=B8=80=20=E3=80=8C=EC=82=AC=EC=A0=84=EC=A1=B0=ED=95=A9?= =?UTF-8?q?=E3=80=8D=20=E2=86=92=20=E3=80=8C=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=E3=80=8D=20=E2=80=94=20M02=5FPrecomboTitle=20?= =?UTF-8?q?=ED=82=A4=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 확인 창 [일위대가 생성] 단추 · 엑셀 보기 · 다시 잇기 · 장부 · 고르기 알림 · 구조물 조합으로 설명 - 서버 줄 상태 글 「확인된 일위대가」 - test_m02_hopyo_screen 단추 글 단언을 키 꼴로 고침 Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Hopyo_Line.py | 2 +- M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts | 11 ++++++++--- .../M02_MasterTemplete_Hopyo_CheckRows.ts | 6 +++++- .../M02_MasterTemplete_Hopyo_Ledger.ts | 3 ++- M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts | 3 ++- .../M02_MasterTemplete_Hopyo_Relink.ts | 5 +++-- M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts | 12 ++++++++---- .../M02_MasterTemplete_Hopyo_ToItem.ts | 3 ++- resources/tester/test_m02_hopyo_screen.py | 2 +- 9 files changed, 32 insertions(+), 15 deletions(-) diff --git a/M02_MasterTemplete/M02_Hopyo_Line.py b/M02_MasterTemplete/M02_Hopyo_Line.py index 3cb722f25..501c1633a 100644 --- a/M02_MasterTemplete/M02_Hopyo_Line.py +++ b/M02_MasterTemplete/M02_Hopyo_Line.py @@ -251,7 +251,7 @@ def match_line(line: dict[str, Any], ctx: Ctx, full: bool = True) -> dict[str, A target = {"호표": no, "이름": h.get("이름", ""), "단위": h.get("단위"), "입력값": {}} target.update(못읽음=[], 출처={}, 환산=conv, 몫=None, 배=conv["배"]) ok = bool(made) and conv["배"] is not None and conv["갈래"] != "추정" - text = f"확인된 사전조합 {made}" if made else f"제{no}호표 먼저 확인" + text = f"확인된 일위대가 {made}" if made else f"제{no}호표 먼저 확인" return {**out, "상태": "호표", "체크": ok, "대상": [target], "글": text} if kind == "노임": return {**out, "상태": "노임", "글": "노임 · 그릇 없음"} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts index f293e9ce3..09f4e5166 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts @@ -26,6 +26,7 @@ import { } from "@ui/ui_template_elements"; import { loadingError, showLoading } from "@ui/ui_template_loading"; import { openModal } from "@ui/ui_template_modal"; +import { t as L } from "@ui/ui_template_locale"; import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source"; import { buildHopyo, @@ -429,7 +430,7 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement { const ask = whole ? `호표 통째로 만듦 — ${h.통째!.대상.map(targetText).join(" · ")}` : `품셈 줄 ${put.length}${diff ? `(품 다름 ${diff})` : ""} · 고정 줄 ${h.줄.length - put.length - outs}` + - ` · 뺌 ${outs} — ${money}(미리 셈 · 끝수 차 있음) — 사전조합 생성`; + ` · 뺌 ${outs} — ${money}(미리 셈 · 끝수 차 있음) — ${L("M02_PrecomboTitle")} 생성`; if (!(await showConfirmDialog(ask, "생성"))) return; const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: opt.key, 번: opt.no }; showLoadingOverlay(); @@ -530,7 +531,11 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement { draw(); }); const whole = !!h.통째 && (d.통째 ?? h.통째.체크); - const go = createButton({ label: "사전조합 생성", variant: "filled", size: "sm" }); + const go = createButton({ + label: `${L("M02_PrecomboTitle")} 생성`, + variant: "filled", + size: "sm", + }); go.setAttribute("data-hopyo-make", ""); go.addEventListener("click", () => void make(h)); tools.push(all); @@ -573,7 +578,7 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement { ...(h.만든 ? [ note( - `만든 사전조합 ${h.만든.키}${h.만든.확인 ? " · 확인함" : ""}` + + `만든 ${L("M02_PrecomboTitle")} ${h.만든.키}${h.만든.확인 ? " · 확인함" : ""}` + (h.만든.품셈 ? ` · 품셈 줄 ${h.만든.품셈.length} · 고정 줄 ${h.만든.고정 ?? 0}` : ""), diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts index 5b3d90d3a..577378746 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts @@ -12,6 +12,7 @@ * ========================================================================== */ import { createButton, el } from "@ui/ui_template_elements"; +import { t as L } from "@ui/ui_template_locale"; import type { HopyoLine, HopyoPart } from "./M02_MasterTemplete_Hopyo_Api"; import { checkable, @@ -170,7 +171,10 @@ function actsCell(line: HopyoLine, parts: HopyoPart[], ctx: RowsContext): HTMLEl createButton({ label, variant: "ghost", size: "sm", onClick: run }); const out = el("select", { className: "ui-input ui-input--sm", - attrs: { title: "로직없음 · 사용안함 — 사전조합에 안 넣고 장부에 남김", "data-out": "" }, + attrs: { + title: `로직없음 · 사용안함 — ${L("M02_PrecomboTitle")}에 안 넣고 장부에 남김`, + "data-out": "", + }, children: OUT_TEXT.map(([value, text]) => el("option", { text, attrs: { value } })), }) as HTMLSelectElement; out.value = ctx.d.뺌[line.차례] ?? ""; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Ledger.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Ledger.ts index 0407a7891..f4aaa3a11 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Ledger.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Ledger.ts @@ -11,6 +11,7 @@ import { createButton, el, showToast } from "@ui/ui_template_elements"; import { loadingError, showLoading } from "@ui/ui_template_loading"; +import { t as L } from "@ui/ui_template_locale"; import { readLedger, saveLedger, @@ -326,7 +327,7 @@ export function buildHopyoLedger(opt: LedgerOptions): HTMLElement { className: "ui-toolbar m02-hopyo__bar", children: [ el("strong", { text: LEDGER_TITLE }), - note(`${LEDGER_HINT} · 사전조합 생성 때 남음 · 아래 층이 이김`), + note(`${LEDGER_HINT} · ${L("M02_PrecomboTitle")} 생성 때 남음 · 아래 층이 이김`), search, kind, ...(opt.canWrite ? [save, drop] : []), diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts index def3c122d..a24be99b8 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts @@ -7,6 +7,7 @@ * ========================================================================== */ import { showToast } from "@ui/ui_template_elements"; +import { t as L } from "@ui/ui_template_locale"; import { listHopyoPicks, type HopyoPickRow } from "./M02_MasterTemplete_Hopyo_Api"; import type { LibraryPick, PickExtra } from "./M02_MasterTemplete_Labor_Pick"; import { tc } from "./M02_MasterTemplete_Labor_Text"; @@ -82,7 +83,7 @@ export function hopyoExtra(row: HopyoPickRow, lib: LibraryPick): PickExtra { export async function pickHopyo(row: HopyoPickRow, lib: LibraryPick): Promise { const alias = row.이름.slice(0, ALIAS_MAX).trim() || null; if (row.확인) { - showToast(`「${row.이름}」 → 확인한 사전조합 ${row.확인.키}`, "success"); + showToast(`「${row.이름}」 → 확인한 ${L("M02_PrecomboTitle")} ${row.확인.키}`, "success"); return lib.pick(row.확인, row.이름, alias); } try { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Relink.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Relink.ts index 128d7003a..8f038fb87 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Relink.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Relink.ts @@ -12,6 +12,7 @@ import { createButton, showToast } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; import { createDataTable, type DataRow } from "@ui/ui_template_data_table"; import { createText } from "@ui/ui_template_text"; +import { t as L } from "@ui/ui_template_locale"; import { pageScope, relinkApply, @@ -43,7 +44,7 @@ const won = (v: number | null | undefined): string => export function relinkKeys(keys: string[]): RelinkSource { const scope = pageScope(); return { - title: `이 공사 단가로 다시 잇기 — 사전조합 ${keys.length}`, + title: `이 공사 단가로 다시 잇기 — ${L("M02_PrecomboTitle")} ${keys.length}`, priceLabel: "최신 판 단가", rows: () => relinkRows(scope, { 키: keys }), apply: async (changes) => { @@ -51,7 +52,7 @@ export function relinkKeys(keys: string[]): RelinkSource { const sum = (k: "전" | "후"): number => got.문서.reduce((a, d) => a + (d[k] ?? 0), 0); return { back: got.되돌림, - note: `바뀐 줄 ${got.바뀜} · 사전조합 ${got.문서.length} · 1 단위 값 합 ${won(sum("전"))} → ${won(sum("후"))}`, + note: `바뀐 줄 ${got.바뀜} · ${L("M02_PrecomboTitle")} ${got.문서.length} · 1 단위 값 합 ${won(sum("전"))} → ${won(sum("후"))}`, }; }, }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts index ddb3f3de5..8fadc13bf 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts @@ -24,6 +24,7 @@ import { showToast, } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; +import { t as L } from "@ui/ui_template_locale"; import type { Sheet } from "../A00_Common/spreadsheet/spreadsheet_types"; import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet"; import { StaleError } from "./M02_MasterTemplete_Api_Fetch"; @@ -239,7 +240,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { variant: "danger", size: "sm", onClick: async () => { - const ask = `실무자료 「${opt.파일}」을 지움 — 만든 사전조합 · 별칭 장부는 그대로`; + const ask = `실무자료 「${opt.파일}」을 지움 — 만든 ${L("M02_PrecomboTitle")} · 별칭 장부는 그대로`; if (!(await showConfirmDialog(ask, "삭제"))) return; try { await deleteHopyo(opt.scope, opt.key); @@ -427,7 +428,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { const nos = [...checked].sort((a, b) => a - b); const fresh = nos.filter((no) => !hasDraft(draftKey(opt.scope, opt.key, no))).length; const ask = - `고른 호표 ${nos.length}개를 체크한 줄로 사전조합 생성` + + `고른 호표 ${nos.length}개를 체크한 줄로 ${L("M02_PrecomboTitle")} 생성` + (fresh ? ` — 확인 창에서 안 고친 ${fresh}개는 서버 제안 체크 줄` : ""); if (!(await showConfirmDialog(ask, "저장"))) return; go.disabled = true; @@ -510,7 +511,10 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { } if (ok) document.dispatchEvent(new Event(HOPYO_BUILT)); result.replaceChildren( - table(["호표", "사전조합", "빠진 줄", "값 차이(참고)"], [...rows, ...skipped]), + table( + ["호표", `${L("M02_PrecomboTitle")}`, "빠진 줄", "값 차이(참고)"], + [...rows, ...skipped], + ), ); const all = order.length + skipped.length; showToast(`만듦 ${ok} / ${all}`, ok === all ? "success" : "warning"); @@ -526,7 +530,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { /** [모두 고정으로 들여오기](705-3) — 물음 한 번 · 서버가 호표 모두를 하위부터(숫자 = 서버가 파일에서) */ async function importAll(): Promise { const ask = - `호표 ${briefs.length}개를 엑셀 숫자 그대로(고정 줄) 사전조합으로 들여옴 — ` + + `호표 ${briefs.length}개를 엑셀 숫자 그대로(고정 줄) ${L("M02_PrecomboTitle")}으로 들여옴 — ` + "이미 만든 호표는 그대로 · 품셈 맞춤은 뒤에 호표 [수정]에서 [품셈으로 바꾸기]"; if (!(await showConfirmDialog(ask, "들여오기"))) return; fixAll.disabled = true; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ToItem.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ToItem.ts index 01f117d13..708ce9d96 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ToItem.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ToItem.ts @@ -17,6 +17,7 @@ import { createCheckbox } from "@ui/ui_template_checkbox"; import { createInputField } from "@ui/ui_template_input"; import { openModal } from "@ui/ui_template_modal"; import { createText } from "@ui/ui_template_text"; +import { t as L } from "@ui/ui_template_locale"; import { buildHopyoItem, hopyoSiblings, @@ -101,7 +102,7 @@ export async function toItem(o: ToItemOptions): Promise { actions.append(go); body.append( createText( - "체크한 줄 = 품셈 줄 · 나머지 = 고정 줄(엑셀 숫자 그대로) — 사전조합을 거치지 않고 구조물 조합 항목을 바로 만듦", + `체크한 줄 = 품셈 줄 · 나머지 = 고정 줄(엑셀 숫자 그대로) — ${L("M02_PrecomboTitle")}을 거치지 않고 구조물 조합 항목을 바로 만듦`, "muted", ), name.root, diff --git a/resources/tester/test_m02_hopyo_screen.py b/resources/tester/test_m02_hopyo_screen.py index 892adfe59..4bc8efb8f 100644 --- a/resources/tester/test_m02_hopyo_screen.py +++ b/resources/tester/test_m02_hopyo_screen.py @@ -308,7 +308,7 @@ def test_옛_즉시_확정과_바로_만들기_없음(): users = sorted(name for name, text in texts.items() if "buildHopyo(" in text) assert users == ["Hopyo_Check", "Hopyo_Sheet"] # 확인 창 · 엑셀 보기 [저장] 물음 뒤 check = texts["Hopyo_Check"] - assert '"사전조합 생성"' in check and "맞음 · 별칭 일괄 체크" in check + assert '`${L("M02_PrecomboTitle")} 생성`' in check and "맞음 · 별칭 일괄 체크" in check assert "확인하고" not in check # 359-4 명사형 assert "showLoadingOverlay()" in check and "dropDraft(k)" in check assert "m02-hopyo-check:" in check and "POLL_MS" in check From 915feb436eba2f31fb06851bec5281e4b38a5f38 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 15:49:52 +0900 Subject: [PATCH 04/19] =?UTF-8?q?feat(714-3):=20M01=20=ED=85=8D=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=88=98=EC=8B=9D=20=EC=9E=85=EB=A0=A5=20=EB=8B=A8?= =?UTF-8?q?=EC=B6=94=20=E2=80=94=20=EC=A4=84=EC=9D=B4=20=EC=93=B0=EB=8A=94?= =?UTF-8?q?=20=EC=8B=9C=ED=97=98=20=EC=9E=85=EB=A0=A5(=ED=91=9C=20?= =?UTF-8?q?=EC=B0=BE=EA=B8=B0=20=ED=96=89=20=EC=97=B4=EC=87=A0=20=C2=B7=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EC=A1=B0=EA=B0=81)=EC=9D=84=20=EA=B0=92?= =?UTF-8?q?=20=EC=8B=9D=20=EC=95=84=EB=9E=98=20=EB=8B=A8=EC=B6=94=EB=A1=9C?= =?UTF-8?q?=20=C2=B7=20=EB=88=84=EB=A5=B4=EB=A9=B4=20=EA=B7=B8=20=EC=9E=90?= =?UTF-8?q?=EB=A6=AC=20=EA=B3=A0=EB=A5=B4=EA=B8=B0/=EA=B8=80=20=EC=B9=B8?= =?UTF-8?q?=20=C2=B7=20=EB=B0=94=EA=BE=B8=EB=A9=B4=20=EC=8B=9C=ED=97=98=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0=20=EC=9E=85=EB=A0=A5=20=EA=B3=A0=EC=B9=A8=20?= =?UTF-8?q?+=20=EB=8B=A4=EC=8B=9C=20=EC=85=88=20=C2=B7=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B8=20=C2=B7=20=EC=BA=90=EC=8B=9C=20=EC=95=88=20=EB=B0=94?= =?UTF-8?q?=EB=80=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Formula.ts usedInputs(순수) · inputChip(부품 상자 단추 · 고르기 · 입력 칸) - Detail → Formula 에 입력 · 값 · onInput 넘김 · LogicLab syncLeft(loud) = 흐름 예시값도 같은 길 · 줄 수 그대로(627) - Logic_Api TextPiece 에 이름 조각 값 칸 - 새 지킴 시험 test_714_3_formula_inputs Co-Authored-By: Claude Opus 5.5 --- M01_MasterData/M01_MasterData_UI_LogicLab.ts | 6 +- .../M01_MasterData_UI_LogicLab_Detail.ts | 5 +- .../M01_MasterData_UI_LogicLab_Formula.css | 12 +++ .../M01_MasterData_UI_LogicLab_Formula.ts | 100 +++++++++++++++++- .../M01_MasterData_UI_LogicLab_Text.ts | 4 + M01_MasterData/M01_MasterData_UI_Logic_Api.ts | 2 + resources/tester/test_714_3_formula_inputs.py | 62 +++++++++++ 7 files changed, 185 insertions(+), 6 deletions(-) create mode 100644 resources/tester/test_714_3_formula_inputs.py diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab.ts b/M01_MasterData/M01_MasterData_UI_LogicLab.ts index 7af76d2a3..e32601f29 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab.ts @@ -99,7 +99,6 @@ const calcRow = (row: LogicRow | null): LogicRow | null => { const cacheKey = "m01_logic_drafts_lab"; const ownHost = (): HTMLElement => el("div", { className: "m01lab__own" }); - const clone = (v: T): T => JSON.parse(JSON.stringify(v)) as T; function loadDrafts(): Record { @@ -257,6 +256,7 @@ export async function mountM01LogicLab( values: current.values, ownHost: current.ownHost, onChange: touch, + onInput: (name, v) => syncLeft(current, { [name]: v }, true), calcHost: calc, flowHost: flowOf(current.row) ? current.flowHost : undefined, admin, @@ -332,11 +332,11 @@ export async function mountM01LogicLab( }; /** 흐름 예시값 → 왼쪽 시험 계산 입력(이름이 있는 칸만) · 바뀌면 왼쪽을 다시 그리고 셈 */ - const syncLeft = (o: Opened, ex: Record): void => { + const syncLeft = (o: Opened, ex: Record, loud = false): void => { const names = new Set((calcRow(o.row)?.입력 ?? []).map((x) => x.이름)); const changed = Object.entries(ex).filter(([k, v]) => names.has(k) && o.values[k] !== v); if (!changed.length) return; - quiet = true; + quiet = !loud; // 텍스트 수식 고침(loud)은 흐름 예시값에도 — 시험 계산 칸 고침과 같은 길 for (const [k, v] of changed) o.values[k] = v; quiet = false; drawCalc(); diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts index b67d95ca6..ed14e9e49 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts @@ -82,6 +82,8 @@ export interface DetailContext { flowHost?: HTMLElement; /** 시스템 관리자 — 기본 로직 안내 글이 다름(흐름도에서 고침 / 보기만) */ admin?: boolean; + /** 714-3 텍스트 수식 입력 단추 — 시험 계산 입력 고침 + 다시 셈 */ + onInput?: (name: string, value: string) => void; } type Group = "labor" | "material" | "cost" | "other"; @@ -511,7 +513,8 @@ function hoBox(ctx: DetailContext): HTMLElement { /** 상세 화면 넷을 `host` 에 쌓음 */ export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void { const formula = el("div", { className: "m01lab__formula" }); - buildFormula(formula, { text: ctx.text }); + const fx = { inputs: ctx.row.입력, values: ctx.values, onInput: ctx.onInput }; + buildFormula(formula, { text: ctx.text, ...fx }); // 다시 그리면 포커스가 빠짐 — 고치던 칸(편집기 · 기본정보)을 기억해 되돌림 const active = document.activeElement; const kept = diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.css b/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.css index 3a69e8091..0b09d07e7 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.css +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.css @@ -37,6 +37,18 @@ padding: var(--spacing-4); } +/* 714-3 줄이 쓰는 입력 — 누르면 그 자리 고르기/글 칸 */ +.m01lab-fx__vars { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-4); + margin-top: var(--spacing-4); +} + +.m01lab-fx__var .ui-field { + min-width: 6rem; +} + @media (max-width: 600px) { .m01lab-fx, .m01lab-fx__head, diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts index bec7c9a5d..d14afae32 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts @@ -3,10 +3,13 @@ * 「텍스트 수식」 컨테이너 — 서버(`POST /text`)가 준 줄을 그대로 찍음(화면에서 다시 계산하지 않음) * 두 칸 — 왼쪽 = 변수 이름 식(`이름글`) · 오른쪽 = 값이 들어간 식(`글`) · 줄은 `짝` 번호로 같은 높이 * 한쪽에 올리거나 누르면 같은 줄이 밝아짐 · 비목 제목 · 소계 · 끝수 한 마디 · 까닭 있는 줄은 옅게 + * 714-3 — 줄이 쓰는 입력(표 찾기 행 값 · 이름 조각)을 오른쪽 칸 아래 작은 단추로 · 누르면 그 자리에 고르기/글 칸 → + * 바꾸면 `onInput`(시험 계산 입력 고침 + 다시 셈) · 정본은 안 바뀜(요구서 4-1 확정 8) * ========================================================================== */ -import { el } from "@ui/ui_template_elements"; -import type { TextAnswer } from "./M01_MasterData_UI_Logic_Api"; +import { createButton, el } from "@ui/ui_template_elements"; +import { createInputField, createSelectField } from "@ui/ui_template_input"; +import type { LogicInput, TextAnswer, TextLine } from "./M01_MasterData_UI_Logic_Api"; import { formatMoney } from "./M01_MasterData_UI_Logic_Money"; import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text"; import { tl } from "./M01_MasterData_UI_LogicLab_Text"; @@ -17,6 +20,77 @@ export type PreviewTextAnswer = TextAnswer & { 계산됨?: boolean }; export interface FormulaContext { text: PreviewTextAnswer | null; + /** 714-3 — 시험 계산 입력 · 지금 값 · 고친 뒤 할 일(값 넣고 다시 셈) · 없으면 단추 없음 */ + inputs?: LogicInput[]; + values?: Record; + onInput?: (name: string, value: string) => void; +} + +/* ── 순수 셈: 줄이 쓰는 입력 ── */ +/** 줄의 조각에서 시험 입력 이름 → 그 줄이 쓴 값 — 표 찾기 행 열쇠 · 이름 조각(`값` 이 붙은 이름) · 차례 = 처음 나온 차례 */ +export function usedInputs( + line: Pick, + names: string[], +): [string, string][] { + const out = new Map(); + for (const p of [...(line.조각 ?? []), ...(line.요소조각 ?? [])]) { + const row = p.kind === "표" ? p.참조?.행 : null; + if (row && typeof row === "object") + for (const [k, v] of Object.entries(row)) + if (names.includes(k) && !out.has(k)) out.set(k, String(v)); + if (p.kind === "값" && p.값 !== undefined && names.includes(p.글) && !out.has(p.글)) + out.set(p.글, String(p.값)); + } + return [...out]; +} +/* ── 순수 셈 끝 ── */ + +/** 입력 단추 하나 — 누르면 그 자리에 고르기(고르기 있는 입력) · 글 칸 · 바꾸면 `onInput` */ +function inputChip(spec: LogicInput, shown: string, onInput: (v: string) => void): HTMLElement { + const wrap = el("span", { className: "m01lab-fx__var", attrs: { "data-fx-var": spec.이름 } }); + const chip = createButton({ + label: `${spec.이름} ${shown}${spec.단위 ? ` ${spec.단위}` : ""}`, + variant: "ghost", + size: 24, + onClick: (ev) => { + ev.stopPropagation(); // 줄 누름(밝히기 고정)과 안 겹치게 + let over = false; // Enter 뒤 blur · 고름 뒤 blur 가 두 번 부르지 않게 + const done = (v: string | null): void => { + if (over) return; + over = true; + if (v !== null && v.trim() !== "" && v !== shown) onInput(v.trim()); + else wrap.replaceChildren(chip); + }; + let field: HTMLElement; + if (spec.고르기?.length) { + const pick = createSelectField({ + options: spec.고르기.map((o) => ({ value: String(o), text: String(o) })), + value: shown, + size: 24, + ariaLabel: spec.이름, + onChange: (v) => done(v), + }); + pick.select.addEventListener("blur", () => done(null)); + field = pick.root; + } else { + const box = createInputField({ value: shown, size: 24 }); + box.input.setAttribute("aria-label", spec.이름); + box.input.addEventListener("keydown", (k) => { + if (k.isComposing) return; + if (k.key === "Enter") done(box.input.value); + if (k.key === "Escape") (k.preventDefault(), done(null)); + }); + box.input.addEventListener("blur", () => done(box.input.value)); + field = box.root; + } + field.addEventListener("click", (e) => e.stopPropagation()); + wrap.replaceChildren(field); + field.querySelector("select, input")?.focus(); + }, + }); + chip.title = tl("Formula_Var"); + wrap.append(chip); + return wrap; } const muted = (text: string): HTMLElement => el("p", { className: "ui-text ui-text--muted", text }); @@ -47,6 +121,27 @@ export function buildFormula(host: HTMLElement, ctx: FormulaContext): void { ], }); const rows: HTMLElement[] = []; + const specs = ctx.onInput ? (ctx.inputs ?? []) : []; + const vars = (line: TextLine): HTMLElement[] => { + const used = usedInputs( + line, + specs.map((x) => x.이름), + ); + if (!used.length || !ctx.onInput) return []; + const onInput = ctx.onInput; + return [ + el("div", { + className: "m01lab-fx__vars", + children: used.map(([name, was]) => + inputChip( + specs.find((x) => x.이름 === name)!, + ctx.values?.[name] || was, + (v) => onInput(name, v), + ), + ), + }), + ]; + }; const calculated = text.계산됨 !== false; // [계산] 뒤 응답은 표식이 안 붙음 — 미리보기만 false const light = (pair: string, on: boolean): void => rows.forEach((r) => r.dataset.pair === pair && r.classList.toggle("is-on", on)); @@ -64,6 +159,7 @@ export function buildFormula(host: HTMLElement, ctx: FormulaContext): void { children: calculated ? [ el("div", { text: `${line.이름} : ${line.글}` }), + ...vars(line), ...(line.까닭 ? [ el("div", { diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts index 8ecee9c54..52b4329de 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts @@ -61,6 +61,10 @@ const TEXT = { Modal_Close: ["닫기", "Close"], Formula_Left: ["변수 이름 식", "Formula with names"], Formula_Right: ["값이 들어간 식", "Formula with values"], + Formula_Var: [ + "누르면 시험 계산 입력을 고침 · 다시 셈(정본 안 바뀜)", + "Click to change the trial input and recalculate (source unchanged)", + ], Modal_Sum: ["한 줄 풀이", "In one line"], Pill_Hint: ["쓰인 자료 — 누르면 펼침", "Data used — tap to open"], Modal_UnitPrice: ["단가", "Unit price"], diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts index 379832f60..6890e0432 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts @@ -124,6 +124,8 @@ export type CalcAnswer = export interface TextPiece { kind: "요소" | "표" | "로직" | "값" | "기호" | "글"; 글: string; + /** 이름 조각(`kind: "값"` · 글 = 이름)이 가진 그 값 — 714-3 입력 단추 */ + 값?: string | number; 참조?: { 테이블?: string; 키: string; diff --git a/resources/tester/test_714_3_formula_inputs.py b/resources/tester/test_714_3_formula_inputs.py new file mode 100644 index 000000000..8b527c437 --- /dev/null +++ b/resources/tester/test_714_3_formula_inputs.py @@ -0,0 +1,62 @@ +"""714-3 텍스트 수식 입력 단추 지킴 시험 — 줄이 쓰는 입력 고르기(순수 함수 · node) + 이음 글. + +`M01_MasterData_UI_LogicLab_Formula.ts` 「순수 셈」 구간만 떼어 돌림(화면 · 서버 없음). +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +M01 = Path(__file__).resolve().parents[2] / "M01_MasterData" +FX = M01 / "M01_MasterData_UI_LogicLab_Formula.ts" + +pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") + + +def _run(body: str) -> object: + text = FX.read_text(encoding="utf-8") + pure = text[text.index("/* ── 순수 셈: 줄이 쓰는 입력 ── */") : text.index("/* ── 순수 셈 끝 ── */")] + out = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", pure + "\n" + body], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert out.returncode == 0, out.stderr + return json.loads(out.stdout) + + +def test_표_행_열쇠와_이름_조각(): + got = _run( + """ +const line = { + 조각: [ + { kind: "표", 글: "25 · 깬잡석", 참조: { 키: "QF1", 행: { 뒷길이: 25, 돌: "깬잡석", 줄번호: 3 } } }, + { kind: "기호", 글: "*" }, + { kind: "값", 글: "두께", 값: "20" }, + { kind: "값", 글: "0.009" }, + { kind: "값", 글: "뒷길이", 값: "99" }, + ], + 요소조각: [{ kind: "표", 글: "x", 참조: { 키: "QF2", 행: 4 } }], +}; +console.log(JSON.stringify(usedInputs(line, ["뒷길이", "돌", "두께", "쌓기"]))); +""" + ) + # 처음 나온 값 · 입력 아닌 열쇠(줄번호) · 이름 없는 수(0.009) · 행 번호(4) 는 뺌 + assert got == [["뒷길이", "25"], ["돌", "깬잡석"], ["두께", "20"]] + + +def test_빈_줄(): + assert _run('console.log(JSON.stringify(usedInputs({}, ["a"])))') == [] + + +def test_이음_시험_입력만_고침(): + lab = (M01 / "M01_MasterData_UI_LogicLab.ts").read_text(encoding="utf-8") + detail = (M01 / "M01_MasterData_UI_LogicLab_Detail.ts").read_text(encoding="utf-8") + # 텍스트 수식 고침 = 시험 계산 입력 + 다시 셈(흐름 예시값과 같은 길) · 캐시(drafts)에 안 들어감 + assert "onInput: (name, v) => syncLeft(current, { [name]: v }, true)" in lab + assert "onInput: ctx.onInput" in detail + fx = FX.read_text(encoding="utf-8") + assert "touch" not in fx and "drafts" not in fx From c0851ee63b3555faea369273299bf6446ecd49fe Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 15:59:51 +0900 Subject: [PATCH 05/19] =?UTF-8?q?feat(717-2a):=20=EB=8B=A8=EA=B0=80?= =?UTF-8?q?=EC=82=B0=EC=B6=9C=20=EB=AA=A9=EB=A1=9D=20=EC=A4=84=20=EB=8D=A7?= =?UTF-8?q?=EC=B9=B8=20=E2=80=94=20=EB=B0=94=ED=83=95(=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=ED=82=A4=20=C2=B7=20=EC=9D=B4=EB=A6=84=20|=20=EC=B0=B8?= =?UTF-8?q?=EC=A1=B0=20SK=20|=20=EC=97=86=EC=9D=8C)=20=C2=B7=20=EA=B3=A0?= =?UTF-8?q?=EC=B9=9C=20=EC=A4=84=20=EC=88=98=20=C2=B7=20=EC=93=B0=EB=8A=94?= =?UTF-8?q?=20=EA=B3=B3=20=EC=88=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M02_Labor_Ref.summary sangeun 갈래만(브레인 답 A · 그 함수 밖 안 만짐) - 지킴 시험 test_717_2a_sangeun_list 2(임시 storage) Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Labor_Ref.py | 13 +++++ resources/tester/test_717_2a_sangeun_list.py | 54 ++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 resources/tester/test_717_2a_sangeun_list.py diff --git a/M02_MasterTemplete/M02_Labor_Ref.py b/M02_MasterTemplete/M02_Labor_Ref.py index ae47ac4be..f8ede9600 100644 --- a/M02_MasterTemplete/M02_Labor_Ref.py +++ b/M02_MasterTemplete/M02_Labor_Ref.py @@ -299,6 +299,19 @@ def summary(folder: Path, kind: str, key: str, expand: Expand | None = None) -> layer, owner = where(folder) base = expand({"층": layer, "주인": owner, "키": key}).get("기준") or {} out["기준금액"] = float(base["계"]) if "계" in base else None + # 717-2a 목록 열 — 바탕(로직 「키 이름」 | 참조 키 | None) · 고친 줄 수 · 쓰는 곳 수 + if kind == "sangeun": + top, (layer, owner) = doc.get("바탕") or {}, where(folder) + logic = top.get("로직") + out["바탕"] = logic or (top.get("참조") or {}).get("키") + if logic and expand is not None: + try: + out["바탕"] = f"{logic} {expand.whole.logic(logic).get('이름') or ''}".strip() + except Exception: # noqa: BLE001 — 없는 로직 = 키만(셈 때 까닭) + pass + out["고친줄수"] = sum(len(doc.get(k) or ()) for k in ("덮개", "더한줄", "뺀줄")) + # ponytail: SK 마다 모든 층 훑음(users) — 목록이 느려지면 층 훑기 한 번 셈으로 + out["쓰는곳수"] = len(users({"층": layer, "주인": owner, "키": key})) return out diff --git a/resources/tester/test_717_2a_sangeun_list.py b/resources/tester/test_717_2a_sangeun_list.py new file mode 100644 index 000000000..ac19b209a --- /dev/null +++ b/resources/tester/test_717_2a_sangeun_list.py @@ -0,0 +1,54 @@ +"""M02 단가산출 칸 목록 줄(PLAN 717-2a · `M02_Labor_Ref.summary` sangeun 갈래) — 임시 storage. + +바탕 = 로직 「키 이름」 · 참조 = SK 키 · 없음 = None · 고친 줄 수 = 덮개 + 더한줄 + 뺀줄 · +쓰는 곳 수 = 그 SK 를 바탕 · 더한줄로 담은 문서 수 · 일위대가(precombo) 줄엔 안 붙음. +""" + +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete.M02_Labor_Expand import Expand +from resources.tester.test_m02_labor_ref import MINE, world # noqa: F401 — 같은 임시 층 + +PCT = "GC001109" + + +def _sk(folder, **doc): + with store.at(folder): + return refs.create("sangeun", {"이름": "목록 시험", "단위": "㎡", "더한줄": [], **doc})[ + "이름" + ] + + +def _row(folder, kind, key): + return refs.summary(folder, kind, key, Expand(m01.loaded()[1])) + + +def test_sangeun_row_base_edits_users(world): + mine = world["personal"] + a = _sk(mine, 바탕={"로직": PCT}, 덮개={"labor:LB000036#0": {"qty": 0.5}}, 뺀줄=["pct:x#0"]) + name = m01.loaded()[1].logic(PCT)["이름"] + row = _row(mine, "sangeun", a) + assert row["바탕"] == f"{PCT} {name}".strip() + assert row["고친줄수"] == 2 and row["쓰는곳수"] == 0 + assert row["제목"] == "목록 시험" and row["단위"] == "㎡" + # 바탕 참조 SK · 더한줄 하나 → 그 SK 쓰는 곳 1 + b = _sk( + mine, + 바탕={"참조": {**MINE, "키": a}}, + 더한줄=[{"종류": "인력", "요소": "LB000036", "이름": "보통인부", "단위": "인", "수량": 1}], + ) + row_b = _row(mine, "sangeun", b) + assert row_b["바탕"] == a and row_b["고친줄수"] == 1 + assert _row(mine, "sangeun", a)["쓰는곳수"] == 1 + # 바탕 없음 + c = _sk(mine) + row_c = _row(mine, "sangeun", c) + assert row_c["바탕"] is None and row_c["고친줄수"] == 0 + + +def test_precombo_row_has_no_sangeun_fields(world): + with store.at(world["personal"]): + sc = refs.create("precombo", {"이름": "조합", "담은로직": []})["이름"] + row = _row(world["personal"], "precombo", sc) + assert not {"바탕", "고친줄수", "쓰는곳수"} & set(row) From a64e3ca398668e45f358e82374180d631cf86fae Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 16:00:03 +0900 Subject: [PATCH 06/19] =?UTF-8?q?feat(717-2a):=20M02=20=EC=99=BC=EC=AA=BD?= =?UTF-8?q?=20=EB=A7=A8=20=EC=9C=84=20=E3=80=8C=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=E3=80=8D=20=EC=B9=B8=20=E2=80=94=20=EC=B8=B5=20?= =?UTF-8?q?=EB=8B=A8=EC=B6=94=20=EB=94=B0=EB=A1=9C(=EA=B8=B0=EB=B3=B8=20?= =?UTF-8?q?=EA=B0=9C=EC=9D=B8)=20=C2=B7=20=ED=8F=B4=EB=8D=94=20=EB=82=98?= =?UTF-8?q?=EB=AC=B4=20=C2=B7=20=EB=AA=A9=EB=A1=9D=ED=91=9C=20=C2=B7=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EC=9E=90=EB=A6=AC=20=C2=B7=20=EB=8C=80?= =?UTF-8?q?=EC=8B=9C=EB=B3=B4=EB=93=9C=20=EC=A4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 UI_Side_Sangeun.ts — 서고 칸(716-4) kind sangeun · 목록표 고름 sangeunList - 새 Sangeun_View.ts — 이름 · 단위 · 바탕 · 덮개 · 더한 줄 · 뺀 줄 · 열린 입력 요약 + 상세 자리표(717-2b 가 갈음) - UI_Main 종류 sangeun 갈래 · 목록표 조건에 sangeun · Library_List 열 표(단가산출 = 바탕 · 고친 줄 · 쓰는 곳) · sangeunBlank(단위 식 · 바탕 없음) - UI_Page 단가산출 층 상태 sk · UI_Side 맨 위 칸 · reveal 「단가산출」 · Layer open 「단가산출」 · Api Kind sangeun - m_settings_nav 대시보드 마스터 템플릿 묶음 「단가산출」 줄(?open=단가산출) · locale_m2 키 - 낡은 글 대조 시험 넷 고침(197_1 · b01_settings_cards · m02_layer_screen · m02_precombo_scope) — 대시보드 줄 하나 늘고 OPENS · reveal · 목록 열 표 · 칸 차례가 바뀜 Co-Authored-By: Claude Opus 5.5 --- A00_Common/m_settings_nav.ts | 1 + .../M02_MasterTemplete_Api_Fetch.ts | 5 +- .../M02_MasterTemplete_Layer.ts | 6 +- .../M02_MasterTemplete_Library_List.ts | 82 ++++++++++++------- .../M02_MasterTemplete_Sangeun_View.ts | 61 ++++++++++++++ .../M02_MasterTemplete_UI_Main.ts | 8 +- .../M02_MasterTemplete_UI_Page.ts | 12 +++ .../M02_MasterTemplete_UI_Side.ts | 47 +++++++++-- .../M02_MasterTemplete_UI_Side_Sangeun.ts | 41 ++++++++++ .../test_197_1_dashboard_option_card.py | 15 ++-- resources/tester/test_b01_settings_cards.py | 16 ++-- resources/tester/test_m02_layer_screen.py | 3 +- resources/tester/test_m02_precombo_scope.py | 3 +- ui_template/ui_template_locale_m2.ts | 16 ++++ 14 files changed, 259 insertions(+), 57 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Sangeun_View.ts create mode 100644 M02_MasterTemplete/M02_MasterTemplete_UI_Side_Sangeun.ts diff --git a/A00_Common/m_settings_nav.ts b/A00_Common/m_settings_nav.ts index fff8b3264..8c7cbeef3 100644 --- a/A00_Common/m_settings_nav.ts +++ b/A00_Common/m_settings_nav.ts @@ -65,6 +65,7 @@ export function settingLinks(user: SettingUser, companyId: number | string | nul m01("로직", L("B01_Dashboard_CardLogic"), read, "로직"), m01("옵션", L("B01_Dashboard_CardStructOptionDef"), desc, "옵션"), m01("별칭", L("B01_Dashboard_CardAliasLedger"), desc, "별칭"), + m02(L("B01_Dashboard_CardSangeun"), "단가산출", comboRoot("master"), desc), m02(L("B01_Dashboard_CardPrecombo"), "일위대가조합", comboRoot("master"), desc), m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", comboRoot("master"), desc), m02(L("B01_Dashboard_CardDrawing"), "도면", "master", desc), diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index 205097490..9d608315a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -10,8 +10,9 @@ export type Layer = "system" | "company" | "personal" | "project"; /** 서버 종류 — 화면의 「표 양식」 = table · 「도면 양식」 = drawing · 「구조물 도면」 = structure · * 「면적산출」 = area(구조물집계표 열마다 한 통합문서) · * 「일위대가」 = labor(열마다 문서 · 39장) · - * 「사전조합」 = precombo(일위대가 사전조합 서고 · 50-3 · 키가 이름 · 옛 단가승격 promote 는 134-2 에서 뺌) */ -export type Kind = "table" | "drawing" | "structure" | "area" | "labor" | "precombo"; + * 「사전조합」 = precombo(일위대가 사전조합 서고 · 50-3 · 키가 이름 · 옛 단가승격 promote 는 134-2 에서 뺌) · + * 「단가산출」 = sangeun(SK- 서고 · 715-2 · 717-2a) */ +export type Kind = "table" | "drawing" | "structure" | "area" | "labor" | "precombo" | "sangeun"; export interface TemplateInfo { 종류: Kind; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Layer.ts b/M02_MasterTemplete/M02_MasterTemplete_Layer.ts index 242e0fa69..721f67c2a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Layer.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Layer.ts @@ -1,7 +1,7 @@ /* ============================================================================= * M02_MasterTemplete_Layer.ts * M02 한 화면의 뿌리 셋(마스터 · 회사 · 개인 · PLAN 27-1) — 진입 해시 - * `#/m02-master-template?open=구조물조합|옵션|도면&root=master|company|personal&owner=…&company=…&pre=…` + * `#/m02-master-template?open=구조물조합|옵션|도면|일위대가조합|단가산출&root=master|company|personal&owner=…&company=…&pre=…` * (옛 `?layer=` 도 root 로 읽음 · `pre` = 일위대가 사전조합 층 · 134-3) · 고칠 권한(`accessFor` — 서버 `M02_Layer_Access` 와 짝 · 27-1c) · * 회사 없음 안내 · 회사용 읽기만일 때 [개인용 복사] 막대. * ========================================================================== */ @@ -16,7 +16,7 @@ import { copyItemToPersonal, fetchStructureCombos } from "./M02_MasterTemplete_A export type ManageLayer = "system" | "company" | "personal"; /** 해시 · 화면의 뿌리 이름 — 서버 층 이름은 `layerOfRoot` */ export type Root = "master" | "company" | "personal"; -export type OpenTarget = "구조물조합" | "옵션" | "도면" | "일위대가조합"; +export type OpenTarget = "구조물조합" | "옵션" | "도면" | "일위대가조합" | "단가산출"; export interface Entry { root: Root; @@ -29,7 +29,7 @@ export interface Entry { pre: Root | null; } -const OPENS: OpenTarget[] = ["구조물조합", "옵션", "도면", "일위대가조합"]; +const OPENS: OpenTarget[] = ["구조물조합", "옵션", "도면", "일위대가조합", "단가산출"]; const ROOTS: Root[] = ["master", "company", "personal"]; export const layerOfRoot = (root: Root): ManageLayer => (root === "master" ? "system" : root); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Library_List.ts b/M02_MasterTemplete/M02_MasterTemplete_Library_List.ts index 7a1be7e48..7a3446ca4 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Library_List.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Library_List.ts @@ -1,7 +1,8 @@ /* ============================================================================= * M02_MasterTemplete_Library_List.ts - * 일위대가 사전조합 서고 목록표(51-4) — 좌측 사전조합 층 단추를 누르면 상세 칸에 뜸. - * 표 = 고름 · 키 · 구분 · 상세구분(51-12) · 이름 · 단위 · 기준 금액(서버 1 단위 계) · 고친 날 · 줄 누름 = 그 문서 상세. + * 서고 목록표(51-4 · 717-2a) — 좌측 일위대가 · 단가산출 층 단추를 누르면 상세 칸에 뜸. + * 표 = 고름 · 키 · 구분 · 상세구분(51-12) · 이름 · 단위 · 기준 금액(서버 1 단위 계) · 고친 날 · 줄 누름 = 그 문서 상세 · + * 단가산출 = 구분 둘 대신 바탕(M01 로직 · 없으면 「바탕 없음」) · 고친 줄 수 · 쓰는 곳 수(서버 `summary`). * 머리 [생성] · [복사](고른 줄) · [삭제](고른 줄 · 쓰는 곳 있으면 서버 409 까닭 그대로). * 꼴 = M01 단가산출 로직 목록 — 찾기 줄(`buildHeadFilters`) · 쪽(`buildPager`) · 표(`m01-master__grid`) 를 M01 에서 import. * ========================================================================== */ @@ -28,10 +29,10 @@ import { saveTemplate, type Layer, type LibraryBody, + type LibraryKind, type TemplateInfo, } from "./M02_MasterTemplete_Api_Fetch"; import { tc } from "./M02_MasterTemplete_Labor_Text"; -import type { PrecomboKind } from "./M02_MasterTemplete_UI_Side_Precombo"; /** 목록 줄 — 서버가 문서 `이름`(제목) · 단위 · 1 단위 기준 계를 실어 보냄 */ type Row = TemplateInfo & { @@ -40,13 +41,16 @@ type Row = TemplateInfo & { 기준금액?: number | null; 구분?: string | null; 상세구분?: string | null; + 바탕?: string | null; + 고친줄수?: number; + 쓰는곳수?: number; }; export interface LibraryListOptions { layer: Layer; /** 그 층 주인(134-3 · 사전조합 층 · null = 자기 층) */ owner: string | null; - kind: PrecomboKind; + kind: LibraryKind; canEdit: boolean; /** 줄 누름 · 생성 · 복사 뒤 — 그 문서 상세 */ open: (key: string, label: string) => void; @@ -60,6 +64,36 @@ const money = (v: number | null | undefined): string => v == null ? "" : v.toLocaleString("ko-KR", { maximumFractionDigits: 2 }); export const libraryLabel = (row: Row): string => (row.제목 ? `${row.이름} ${row.제목}` : row.이름); +/** 새 단가산출 몸(717-2a) — 바탕 없음 · 빈 더한줄 · 단위 「식」(서버 필수 · 상세에서 고침 · 717-2b) */ +export const sangeunBlank = (name: string): Record => ({ + 이름: name, + 단위: "식", + 바탕: null, + 더한줄: [], +}); +/** 표 열 — 머리 글 · 줄 값(일위대가 = 구분 둘 · 단가산출 = 바탕 · 고친 줄 · 쓰는 곳) */ +const COLUMNS: Record [string, (row: Row) => string][]> = { + precombo: () => [ + [tc("Lib_Key"), (r) => r.이름], + [tc("Sub"), (r) => r.구분 || tc("Lib_Unsorted")], // 빈 구분 = 미분류(149-2 · 나무와 같은 말) + [tc("Detail"), (r) => r.상세구분 ?? ""], + [tc("Lib_Name"), (r) => r.제목 ?? ""], + [tc("Lib_Unit"), (r) => r.단위 ?? ""], + [tc("Lib_Base"), (r) => money(r.기준금액)], + [tc("Lib_Date"), (r) => r.수정일], + ], + sangeun: () => [ + [tc("Lib_Key"), (r) => r.이름], + [tc("Lib_Name"), (r) => r.제목 ?? ""], + [tc("Lib_Unit"), (r) => r.단위 ?? ""], + [L("M02_SangeunBase"), (r) => r.바탕 || L("M02_SangeunNoBase")], + [L("M02_SangeunEdited"), (r) => String(r.고친줄수 ?? 0)], + [L("M02_SangeunUsers"), (r) => String(r.쓰는곳수 ?? 0)], + [tc("Lib_Base"), (r) => money(r.기준금액)], + [tc("Lib_Date"), (r) => r.수정일], + ], +}; + /** 이름 창(생성 · 복사 · 사전조합 나무 폴더 메뉴 149-1) — [생성] = `onOk` 성공하면 닫힘 */ export const nameDialog = ( title: string, @@ -101,6 +135,7 @@ export function mountLibraryList( tools: HTMLElement, opt: LibraryListOptions, ): { getDoc: () => null; destroy: () => void } { + const columns = COLUMNS[opt.kind](); let rows: Row[] = []; let picked: string | null = null; let page = 1; @@ -144,15 +179,9 @@ export function mountLibraryList( attrs: { "data-lib-key": row.이름 }, children: [ pick, - ...[ - row.이름, - row.구분 || tc("Lib_Unsorted"), // 빈 구분 = 미분류(149-2 · 나무와 같은 말) - row.상세구분 ?? "", - row.제목 ?? "", - row.단위 ?? "", - money(row.기준금액), - row.수정일, - ].map((v) => el("td", { text: v, attrs: { title: v } })), + ...columns + .map(([, value]) => value(row)) + .map((v) => el("td", { text: v, attrs: { title: v } })), ], }); tr.addEventListener("click", () => opt.open(row.이름, libraryLabel(row))); @@ -160,7 +189,12 @@ export function mountLibraryList( }) : [ el("tr", { - children: [el("td", { text: L("M02_NoTemplates"), attrs: { colspan: "8" } })], + children: [ + el("td", { + text: L("M02_NoTemplates"), + attrs: { colspan: String(columns.length + 1) }, + }), + ], }), ]), ); @@ -191,7 +225,10 @@ export function mountLibraryList( /** 사전조합 = 이름 · 구분 · 상세구분 빈 값(미분류 · 134-5 · 상세에서 고름) · 빈 묶음 */ btnNew.addEventListener("click", () => { nameDialog(L("M02_NewTitle"), "", async (name) => { - const 문서 = { 이름: name, 구분: "", 상세구분: "", 단위: null, 담은로직: [] }; + const 문서 = + opt.kind === "sangeun" + ? sangeunBlank(name) + : { 이름: name, 구분: "", 상세구분: "", 단위: null, 담은로직: [] }; await create({ 문서 }, name); }); }); @@ -244,16 +281,7 @@ export function mountLibraryList( page = 1; draw(); }); - const heads = [ - "", - "Lib_Key", - "Sub", - "Detail", - "Lib_Name", - "Lib_Unit", - "Lib_Base", - "Lib_Date", - ] as const; + const heads = ["", ...columns.map(([head]) => head)]; const gridBox = el("div", { className: "ui-table-wrap m01-master__grid-wrap", attrs: { "data-lib-list": opt.kind }, @@ -262,9 +290,7 @@ export function mountLibraryList( className: "ui-table m01-master__grid", children: [ el("thead", { - children: [ - el("tr", { children: heads.map((h) => el("th", { text: h ? tc(h) : "" })) }), - ], + children: [el("tr", { children: heads.map((h) => el("th", { text: h })) })], }), body, ], diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_View.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_View.ts new file mode 100644 index 000000000..da3307701 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_View.ts @@ -0,0 +1,61 @@ +/* ============================================================================= + * M02_MasterTemplete_Sangeun_View.ts + * 단가산출(SK) 상세 자리(717-2a) — 편집 칸 종류 sangeun 이 여는 곳(UI_Main 갈래 한 줄). + * 지금 = 이름 · 단위 · 바탕 · 고친 줄 요약(덮개 · 더한 줄 · 뺀 줄 · 열린 입력) + 상세 자리표. + * 717-2b(sub2) = 자리표 줄을 `mountSangeunDetail(slot, doc, opt)`(`Sangeun_Detail.ts`)로 갈음 · + * [저장]은 그 손잡이 `getDoc` · 고침 알림은 `opt.onChanged`. 지금 [저장] = 읽은 문서 그대로. + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import { createEmpty } from "@ui/ui_template_empty"; +import { createKv } from "@ui/ui_template_kv"; +import { t as L } from "@ui/ui_template_locale"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; +import type { Selection } from "./M02_MasterTemplete_UI_Main"; + +/** SK 문서 중 이 자리가 읽는 칸(꼴 = 서버 `M02_Sangeun_Doc`) */ +export interface SangeunDoc { + 키?: string; + 이름?: string; + 단위?: string; + 바탕?: { 로직?: string; 참조?: { 키?: string } } | null; + 덮개?: Record; + 더한줄?: unknown[]; + 뺀줄?: unknown[]; + 열린입력?: string[]; +} + +/** 고름(층 · 주인 · SK 키 = name) + 읽기만 · 고침 알림 */ +export type SangeunViewOptions = Pick & { + readOnly: boolean; + onChanged: (dirty: boolean) => void; +}; + +/** 바탕 한 줄 — 로직 키 · 참조 SK 키 · 없으면 「바탕 없음」 */ +export const baseText = (doc: SangeunDoc): string => + doc.바탕?.로직 ?? doc.바탕?.참조?.키 ?? L("M02_SangeunNoBase"); + +export function mountSangeunView( + host: HTMLElement, + doc: unknown, + _opt: SangeunViewOptions, +): { getDoc: () => unknown; destroy: () => void } { + const sk = (doc ?? {}) as SangeunDoc; + const count = (v: unknown[] | Record | undefined): number => + Array.isArray(v) ? v.length : Object.keys(v ?? {}).length; + const head = createKv([ + { label: L("M02_Name"), value: sk.이름 ?? "" }, + { label: tc("Lib_Unit"), value: sk.단위 ?? "" }, + { label: L("M02_SangeunBase"), value: baseText(sk) }, + { label: L("M02_SangeunCover"), value: count(sk.덮개) }, + { label: L("M02_SangeunAdded"), value: count(sk.더한줄) }, + { label: L("M02_SangeunRemoved"), value: count(sk.뺀줄) }, + { label: L("M02_SangeunOpened"), value: (sk.열린입력 ?? []).join(" · ") || null }, + ]); + const slot = el("div", { + attrs: { "data-sangeun-detail": sk.키 ?? "" }, + children: [createEmpty({ text: L("M02_SangeunDetailSoon") })], + }); + host.replaceChildren(el("div", { attrs: { "data-sangeun-view": "" }, children: [head, slot] })); + return { getDoc: () => doc, destroy: () => host.replaceChildren() }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index a0a616c8f..8a9182d22 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -357,6 +357,12 @@ export function createMain( const readOnly = !writable(at); const mine = seq; // 모듈 · 집계표 번호를 기다리는 사이 다른 화면(옵션 정의 등)이 열렸으면 그만둠 host.replaceChildren(); + if (at.kind === "sangeun") { + const { mountSangeunView } = await import("./M02_MasterTemplete_Sangeun_View"); // 717-2a + const onChanged = (d: boolean): boolean => (dirty = d && !readOnly); + if (mine === seq) editor = mountSangeunView(host, doc, { ...at, readOnly, onChanged }); + return; + } if (at.kind === "table") { const create = await findExport<(h: HTMLElement, d: unknown, o: object) => EditorHandle>( SHEET_MODS, @@ -503,7 +509,7 @@ export function createMain( return empty(""); } title.textContent = isCombo(next) ? L("M02_KindTable") : (next.label ?? next.name); // 26-7a - if (isPrecombo(next.kind) && !next.name) { + if ((isPrecombo(next.kind) || next.kind === "sangeun") && !next.name) { // 서고 목록표(51-4) — 이름 "" · 저장할 것 없음 · 줄 누름 = 그 문서 상세 save.hidden = true; const { mountLibraryList } = await import("./M02_MasterTemplete_Library_List"); diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index 436262468..4ff1e2265 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -53,6 +53,7 @@ import { canEdit, createMain, newItemButton, type Selection } from "./M02_Master import { buildSide } from "./M02_MasterTemplete_UI_Side"; import { ITEM_BUILT } from "./M02_MasterTemplete_Hopyo_ToItem"; import { precomboList, type PrecomboScope } from "./M02_MasterTemplete_UI_Side_Precombo"; +import { sangeunList } from "./M02_MasterTemplete_UI_Side_Sangeun"; import { buildTwoWay, pickedName } from "./M02_MasterTemplete_ItemPick"; import "./M02_MasterTemplete_UI_Style.css"; @@ -248,6 +249,16 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise void main.confirmLeave().then((ok) => ok && void main.open(precomboList(pre))), }); + // 단가산출 층(717-2a) — 일위대가 층과 따로 · 기본 개인(못 가면 마스터) · 주소에 안 실음 + let sk = preScope("personal"); + const skBar = (): HTMLElement => + createLayerBar({ + user, + active: { root: sk.root, owner: sk.owner }, + company, + go: (next) => ((sk = preScope(next)), void side.refreshSangeun()), + reselect: () => void main.confirmLeave().then((ok) => ok && void main.open(sangeunList(sk))), + }); const side = buildSide({ layer, isAdmin, @@ -264,6 +275,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise share.precomboMenu(pre.root === "personal" && !pre.owner, key, name), }, + sangeun: { scope: () => sk, bar: skBar }, }); const layout = el("div", { className: "ui-workflow-layout m02-master" }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts index 7f1d5b88d..5a86c8d5a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts @@ -1,8 +1,8 @@ /* ============================================================================= * M02_MasterTemplete_UI_Side.ts - * 마스터 템플릿 좌측 패널 차례(716-3) — 「일위대가」(층 단추 따로 · 2단 폴더 나무 · `UI_Side_Precombo` · + * 마스터 템플릿 좌측 패널 차례(716-3 · 717-2a) — 「단가산출」(층 단추 따로 · 기본 개인 · `UI_Side_Sangeun`) › 「일위대가」(층 단추 따로 · 2단 폴더 나무 · `UI_Side_Precombo` · * 50-3 · 134-3) › 「구조물 조합」(층 단추 · 나무) › 수평 구분선 › 「일위대가 불러오기」(구조물 조합 층 · - * `Hopyo_Side` · 324-4) › 「도면 양식」(새로 · 본떠 · 지우기) › 실무문서. 「단가산출」 칸은 맨 위(717-2a). + * `Hopyo_Side` · 324-4) › 「도면 양식」(새로 · 본떠 · 지우기) › 실무문서. * 구조물 옵션 정의는 M01 로 옮김(716-1). * 서버 길은 계약 `6_계약.md`. * 구조물 도면 · 일위대가 · 면적산출 상세는 좌측 줄이 아니라 항목 카드 [수정]으로(PLAN 45-2). @@ -36,6 +36,7 @@ import { HOPYO_BUILT } from "./M02_MasterTemplete_Hopyo_Check"; import { buildHopyoSection } from "./M02_MasterTemplete_Hopyo_Side"; import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main"; import { buildPrecomboSection, type PrecomboOptions } from "./M02_MasterTemplete_UI_Side_Precombo"; +import { buildSangeunSection, type SangeunOptions } from "./M02_MasterTemplete_UI_Side_Sangeun"; import { createEmpty } from "@ui/ui_template_empty"; import { createDivider } from "@ui/ui_template_divider"; @@ -67,6 +68,8 @@ export interface SideOptions { tree?: HTMLElement[]; /** 「일위대가」 층(134-3 · 페이지 층과 따로 · 층 단추는 페이지가 만듦) */ precombo: Pick; + /** 「단가산출」 층(717-2a · 일위대가 층과 따로 · 층 단추는 페이지가 만듦) */ + sangeun: Pick; } export interface SideHandle { @@ -75,9 +78,11 @@ export interface SideHandle { /** 고름 표시를 지금 열린 것 한 줄로 — 편집 칸이 무엇을 열든(카드 [도면 수정] 등) 부름 */ paint: () => void; /** 진입 해시 `open`(PLAN 27-1d) — 그 컨테이너를 펼침 · 도면은 시스템 층에만 · 옵션은 M01 로 옮겨 아무 일 없음 */ - reveal: (target: "옵션" | "도면" | "일위대가조합") => void; + reveal: (target: "옵션" | "도면" | "일위대가조합" | "단가산출") => void; /** 사전조합 층이 바뀜(134-3) — 그 컨테이너만 다시 그림 */ refreshPrecombo: () => Promise; + /** 단가산출 층이 바뀜(717-2a) — 그 컨테이너만 다시 그림 */ + refreshSangeun: () => Promise; } /** 서버 이름 규칙과 같게 — 화면에서 먼저 막음 */ @@ -133,7 +138,7 @@ export function buildSide(opt: SideOptions): SideHandle { openOnExpand(summary, async () => { if (!isPicked("table", STRUCTURE_TABLE) && (await opt.confirmLeave())) opt.onOpen(null); }); - // 일위대가 서고(50-3 · 134-3) — 맨 위 칸 · 층은 페이지 층과 따로 + // 일위대가 서고(50-3 · 134-3) — 단가산출 다음 칸 · 층은 페이지 층과 따로 const precombo = buildPrecomboSection({ ...opt.precombo, confirmLeave: opt.confirmLeave, @@ -141,10 +146,18 @@ export function buildSide(opt: SideOptions): SideHandle { getOpen: opt.getOpen, }); const precomboSec = section(L("M02_PrecomboTitle"), precombo.body); + // 단가산출 서고(717-2a) — 맨 위 칸 · 층은 일위대가 층과 따로 + const sangeun = buildSangeunSection({ + ...opt.sangeun, + confirmLeave: opt.confirmLeave, + onOpen: opt.onOpen, + getOpen: opt.getOpen, + }); + const sangeunSec = section(L("M02_SangeunTitle"), sangeun.body); // 실무자료로 일위대가를 만들었음(확인 창 · 엑셀 보기 창) → 목록 다시 · 이 패널이 내려갔으면 듣기 끝 const onBuilt = (): void => precombo.body.isConnected - ? void precombo.refresh() + ? void Promise.all([precombo.refresh(), sangeun.refresh()]) : document.removeEventListener(HOPYO_BUILT, onBuilt); document.addEventListener(HOPYO_BUILT, onBuilt); // 도면 양식은 시스템 층(마스터 템플릿)에만 @@ -171,6 +184,7 @@ export function buildSide(opt: SideOptions): SideHandle { const listHost = el("div", { className: "m02-side__lists ui-sidebar-panel", children: [ + sangeunSec, precomboSec, summary, createDivider(), @@ -229,6 +243,7 @@ export function buildSide(opt: SideOptions): SideHandle { * (양식이 열려 있으면 실무문서 고름은 지움) */ function paint(): void { paintList(); + sangeun.paint(); precombo.paint(); hopyo.paint(); paintButtons(); @@ -253,7 +268,7 @@ export function buildSide(opt: SideOptions): SideHandle { if (mine !== seq) return; paintList(); paintButtons(); - await precombo.refresh(); + await Promise.all([precombo.refresh(), sangeun.refresh()]); } /* --- 시스템 층 새로 · 본떠 · 지우기(도면 양식만 — 구조물 도면은 집계표 열이 정함) --- */ @@ -333,11 +348,25 @@ export function buildSide(opt: SideOptions): SideHandle { } } - const reveal = (target: "옵션" | "도면" | "일위대가조합"): void => { - const sec = target === "일위대가조합" ? precomboSec : target === "도면" ? drawing : null; + const reveal = (target: "옵션" | "도면" | "일위대가조합" | "단가산출"): void => { + const sec = + target === "단가산출" + ? sangeunSec + : target === "일위대가조합" + ? precomboSec + : target === "도면" + ? drawing + : null; sec?.classList.remove("is-collapsed"); }; void refresh(); - return { root, refresh, paint, reveal, refreshPrecombo: precombo.refresh }; + return { + root, + refresh, + paint, + reveal, + refreshPrecombo: precombo.refresh, + refreshSangeun: sangeun.refresh, + }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Sangeun.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Sangeun.ts new file mode 100644 index 000000000..724755454 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Sangeun.ts @@ -0,0 +1,41 @@ +/* ============================================================================= + * M02_MasterTemplete_UI_Side_Sangeun.ts + * 좌측 맨 위 「단가산출」 칸(717-2a · 요구서 4장 12) — 서고 칸(`UI_Side_Library` · 716-4)에 단가산출 몫만 줌: + * 종류 sangeun(SK-) · 그 층 목록표 고름 · 새 문서 몸 · 글. 층 단추는 페이지가 만듦(칸마다 따로 · 기본 개인). + * 문서 누름 = 편집 칸(UI_Main 종류 sangeun = `Sangeun_View`) · 켜진 층 단추 다시 누름 = 목록표(`Library_List`). + * ========================================================================== */ + +import { t as L } from "@ui/ui_template_locale"; +import { sangeunBlank } from "./M02_MasterTemplete_Library_List"; +import type { Selection } from "./M02_MasterTemplete_UI_Main"; +import { + buildLibrarySection, + type LibraryHandle, + type LibraryOptions, + type LibraryScope, +} from "./M02_MasterTemplete_UI_Side_Library"; + +/** 그 층 목록표 고름(켜진 층 단추 다시 누름) */ +export const sangeunList = (scope: LibraryScope): Selection => ({ + layer: scope.layer, + owner: scope.owner, + canWrite: scope.canWrite, + projectId: null, + kind: "sangeun", + name: "", + label: L("M02_SangeunTitle"), +}); + +export type SangeunOptions = Pick< + LibraryOptions, + "scope" | "bar" | "confirmLeave" | "onOpen" | "getOpen" +>; + +export const buildSangeunSection = (opt: SangeunOptions): LibraryHandle => + buildLibrarySection({ + ...opt, + kind: "sangeun", + list: sangeunList, + blank: sangeunBlank, + text: { none: L("M02_SangeunNone"), newHere: L("M02_SangeunNewHere") }, + }); diff --git a/resources/tester/test_197_1_dashboard_option_card.py b/resources/tester/test_197_1_dashboard_option_card.py index fe1701798..2fe8b5116 100644 --- a/resources/tester/test_197_1_dashboard_option_card.py +++ b/resources/tester/test_197_1_dashboard_option_card.py @@ -49,16 +49,17 @@ def test_줄_해시_옵션은_M01(): from test_b01_settings_cards import _cards cards = _cards({"role": "SYSTEM_ADMIN", "is_master": False}, 3) - heads = [c[2].split("?")[0] + "?" + c[2].split("?")[1].split("&")[0] for c in cards[1:7]] + heads = [c[2].split("?")[0] + "?" + c[2].split("?")[1].split("&")[0] for c in cards[1:8]] assert heads == [ "m01-master-data?open=로직", "m01-master-data?open=옵션", "m01-master-data?open=별칭", # 718-3 + "m02-master-template?open=단가산출", # 717-2a "m02-master-template?open=일위대가조합", "m02-master-template?open=구조물조합", "m02-master-template?open=도면", ] - assert all("&root=master" in c[2] for c in cards[4:7]) + assert all("&root=master" in c[2] for c in cards[4:8]) @needs_node @@ -77,7 +78,7 @@ def test_해시_진입_셋(): + fn.group(0).replace("export function", "function") + "\nconsole.log(JSON.stringify(entryFromHash(null)));" ) - for want in ("구조물조합", "옵션", "도면", "일위대가조합"): + for want in ("구조물조합", "옵션", "도면", "일위대가조합", "단가산출"): got = _run(script, f"open={want}&root=master&company=3") assert got["open"] == want and got["root"] == "master" and got["company"] == "3" @@ -85,8 +86,12 @@ def test_해시_진입_셋(): def test_reveal_옵션은_M01_로(): side = _text(M02 / "M02_MasterTemplete_UI_Side.ts") body = side.split("const reveal = ")[1].split("\n };")[0] - # 716-1 · 716-3 — 옵션 정의는 M01 로 옮김 · M02 reveal 은 일위대가 · 도면만 펼침 - assert 'target === "일위대가조합" ? precomboSec : target === "도면" ? drawing : null' in body + # 716-1 · 716-3 · 717-2a — 옵션 정의는 M01 로 옮김 · M02 reveal 은 단가산출 · 일위대가 · 도면만 펼침 + flat = " ".join(body.split()) + assert ( + 'target === "단가산출" ? sangeunSec : target === "일위대가조합" ? precomboSec' + ' : target === "도면" ? drawing : null' + ) in flat assert 'sec?.classList.remove("is-collapsed");' in body assert "options" not in body and "옵션: section(" not in side page = _text(M02 / "M02_MasterTemplete_UI_Page.ts") diff --git a/resources/tester/test_b01_settings_cards.py b/resources/tester/test_b01_settings_cards.py index ae486748a..d89b76154 100644 --- a/resources/tester/test_b01_settings_cards.py +++ b/resources/tester/test_b01_settings_cards.py @@ -97,22 +97,23 @@ WRITE_CO = READ_CO.replace("CompanyRead", "CompanyWrite") @pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") -def test_시스템_관리자_줄_칸별_여덟_고른_회사_해시(): - # 716-2 · 718-3 — 대시보드 설계 기본값 = 칸별 줄: M01 4(마스터 요소 · 단가산출 로직 · 구조물 옵션 정의 · - # 별칭 장부) + - # M02 3(일위대가 · 구조물 조합 · 도면 양식) + 계산 자리 +def test_시스템_관리자_줄_칸별_아홉_고른_회사_해시(): + # 716-2 · 718-3 · 717-2a — 대시보드 설계 기본값 = 칸별 줄: M01 4(마스터 요소 · 단가산출 로직 · + # 구조물 옵션 정의 · 별칭 장부) + + # M02 4(단가산출 · 일위대가 · 구조물 조합 · 도면 양식) + 계산 자리 assert _cards({"role": "SYSTEM_ADMIN", "is_master": False}, 3) == [ ["M01_Title", READ_SRC, M01], ["B01_Dashboard_CardLogic", READ_SRC, f"{M01}?open=로직"], ["B01_Dashboard_CardStructOptionDef", WRITE_M, f"{M01}?open=옵션"], ["B01_Dashboard_CardAliasLedger", WRITE_M, f"{M01}?open=별칭"], + ["B01_Dashboard_CardSangeun", WRITE_M, f"{M02}?open=단가산출&root=master&company=3"], ["B01_Dashboard_CardPrecombo", WRITE_M, f"{M02}?open=일위대가조합&root=master&company=3"], ["B01_Dashboard_CardStructCombo", WRITE_M, f"{M02}?open=구조물조합&root=master&company=3"], ["B01_Dashboard_CardDrawing", WRITE_M, f"{M02}?open=도면&root=master&company=3"], CALC_SITES, ] # 회사가 없으면 해시에 안 실음 - assert _cards({"role": "SYSTEM_ADMIN", "is_master": False})[5][2] == ( + assert _cards({"role": "SYSTEM_ADMIN", "is_master": False})[6][2] == ( f"{M02}?open=구조물조합&root=master" ) @@ -243,7 +244,8 @@ def test_구조물_조합_기본_층_개인(): 134-4 — 층 없는 주소도 개인(회사 없으면 마스터) · 사전조합 기본 층도 같은 규칙.""" combo = f"{M02}?open=구조물조합&root=personal" rows = _cards({"role": "SYSTEM_ADMIN", "is_master": False, "company_id": 1}, 3) - assert rows[4:7] == [ + assert rows[4:8] == [ + ["B01_Dashboard_CardSangeun", WRITE_M, f"{M02}?open=단가산출&root=personal&company=3"], ["B01_Dashboard_CardPrecombo", WRITE_M, f"{M02}?open=일위대가조합&root=personal&company=3"], ["B01_Dashboard_CardStructCombo", WRITE_M, f"{combo}&company=3"], ["B01_Dashboard_CardDrawing", WRITE_M, f"{M02}?open=도면&root=master&company=3"], @@ -254,7 +256,7 @@ def test_구조물_조합_기본_층_개인(): assert _cards({"role": "USER", "is_master": True, "company_id": 1})[0][2] == combo assert _cards({"role": "USER", "is_master": False, "company_id": 1})[0][2] == combo # 개인 층 없음(회사 없음) = 지금 규칙 - assert _cards({"role": "SYSTEM_ADMIN", "is_master": False, "company_id": None})[5][2] == ( + assert _cards({"role": "SYSTEM_ADMIN", "is_master": False, "company_id": None})[6][2] == ( f"{M02}?open=구조물조합&root=master" ) assert _cards({"role": "ADMIN", "is_master": False, "company_id": None})[0][2] == ( diff --git a/resources/tester/test_m02_layer_screen.py b/resources/tester/test_m02_layer_screen.py index 4dff150c1..a74df5040 100644 --- a/resources/tester/test_m02_layer_screen.py +++ b/resources/tester/test_m02_layer_screen.py @@ -89,7 +89,8 @@ def test_주소_인자를_라우트에서_뗌(): assert ( 'raw === "master" || raw === "company" || raw === "personal"' in layer ) # 없음 = 기본(134-4) - assert '["구조물조합", "옵션", "도면", "일위대가조합"]' in layer # 일위대가 조합 서고 = 50-3 + # 일위대가 조합 서고 = 50-3 · 단가산출 = 717-2a + assert '["구조물조합", "옵션", "도면", "일위대가조합", "단가산출"]' in layer picker = _text(ROOT / "ui_template" / "ui_template_item_picker.ts") assert 'open: "구조물조합", root: "personal"' in picker and "layer=personal" not in picker diff --git a/resources/tester/test_m02_precombo_scope.py b/resources/tester/test_m02_precombo_scope.py index 1271ecae6..921f89074 100644 --- a/resources/tester/test_m02_precombo_scope.py +++ b/resources/tester/test_m02_precombo_scope.py @@ -129,7 +129,7 @@ def test_분류_고정_폴더_지움_자유_폴더_메뉴_끌어_옮기기(): def test_목록표_빈_구분_미분류_고르기_창_새_분류_거름(): """149-2 — 목록표 빈 구분 = 미분류 · 고르기 창 거름 = 품셈 구분 + 사전조합 새 분류""" lib = _text("M02_MasterTemplete_Library_List.ts") - assert 'row.구분 || tc("Lib_Unsorted"),' in lib + assert '(r) => r.구분 || tc("Lib_Unsorted")],' in lib # 717-2a 열 표(COLUMNS) pick = _text("M02_MasterTemplete_Labor_Pick.ts") assert "new Map([...subs, ...classes].map((s) => [s.name, new Set(s.details)]))" in pick assert "classes = precomboClasses(def);" in pick @@ -143,6 +143,7 @@ def test_왼쪽_칸_차례_일위대가_구조물조합_구분선_불러오기_7 side = _text("M02_MasterTemplete_UI_Side.ts") order = side[side.index("m02-side__lists") :] marks = [ + "sangeunSec,", # 717-2a 맨 위 "precomboSec,", "summary,", "createDivider()", diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index 6ae515608..cfc15a584 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -191,4 +191,20 @@ export const ui_locales_m2 = { M02_HopyoImport: ["일위대가 불러오기", "Import unit-price sheets"], B01_Dashboard_CardPrecombo: ["일위대가", "Unit-cost items"], M02_PrecomboTitle: ["일위대가", "Unit-cost items"], + B01_Dashboard_CardSangeun: ["단가산출", "Unit-price analyses"], + M02_SangeunTitle: ["단가산출", "Unit-price analyses"], + M02_SangeunNone: ["단가산출 없음", "No unit-price analyses"], + M02_SangeunNewHere: ["여기에 새 단가산출", "New unit-price analysis here"], + M02_SangeunBase: ["바탕", "Base"], + M02_SangeunNoBase: ["바탕 없음", "No base"], + M02_SangeunEdited: ["고친 줄", "Edited rows"], + M02_SangeunUsers: ["쓰는 곳", "Used by"], + M02_SangeunCover: ["덮개", "Overrides"], + M02_SangeunAdded: ["더한 줄", "Added rows"], + M02_SangeunRemoved: ["뺀 줄", "Removed rows"], + M02_SangeunOpened: ["열린 입력", "Open inputs"], + M02_SangeunDetailSoon: [ + "단가산출 상세 표 · 고침 — 준비 중(717-2b)", + "Unit-price detail table and editing — coming next (717-2b)", + ], } as const; From 6d5b78f83eecbbd2df814356c0f5a43b23484c86 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 16:02:13 +0900 Subject: [PATCH 07/19] =?UTF-8?q?feat(712-1):=20=EC=9D=B8=EB=A0=A5=20?= =?UTF-8?q?=C2=B7=20=EC=9E=AC=EB=A3=8C=20=C2=B7=20=EA=B2=BD=EB=B9=84=20?= =?UTF-8?q?=EB=AC=B6=EC=9D=8C=20=EC=85=88=20=EB=95=8C=20=ED=8C=8C=EC=83=9D?= =?UTF-8?q?=20=C2=B7=20=EA=B8=B0=EA=B3=84=EA=B2=BD=EB=B9=84=20=ED=8C=90?= =?UTF-8?q?=EB=B3=84=20=ED=95=9C=20=EA=B3=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 master_bundle — 호표 · 덧줄 = 비목 따라 · 비목 없는 로직 줄 = 기계경비면 경비 + 장비 · 아니면 하위 - 기계경비 판별 = 결과단위 원/시간(is_machine) — M02 엑셀 맞춤(이름 「시간당」) · B09 산근 · 산근 텍스트(입력 「기계」) 갈아 끼움 - 엔진 run 줄 덧칸 「묶음」 · 기계경비 줄 「장비」(출력 꼬리표만 · 노 · 재 · 경 몫은 줄 비목 그대로) - 정본 칸 안 더함 · 5,911 줄 중 하위 31 · 기계경비 줄 1,044 - 지킴 시험 test_712_1_bundle Co-Authored-By: Claude Opus 5.5 --- B09_Estimation/B09_Estimation_Sangeun.py | 2 +- B09_Estimation/B09_Estimation_Sangeun_Text.py | 2 +- M02_MasterTemplete/M02_Hopyo_Ours.py | 3 +- .../master_data/scripts/master_bundle.py | 60 +++++++++++++++++ .../master_data/scripts/master_formula.py | 2 + resources/tester/test_712_1_bundle.py | 64 +++++++++++++++++++ 6 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 resources/master_data/scripts/master_bundle.py create mode 100644 resources/tester/test_712_1_bundle.py diff --git a/B09_Estimation/B09_Estimation_Sangeun.py b/B09_Estimation/B09_Estimation_Sangeun.py index 02549d389..6364407a4 100644 --- a/B09_Estimation/B09_Estimation_Sangeun.py +++ b/B09_Estimation/B09_Estimation_Sangeun.py @@ -242,7 +242,7 @@ def call_of(whole: Any, item: dict, env: dict) -> dict[str, Any] | None: target = None if not target: return None - if any(s.get("이름") == "기계" for s in target.get("입력", [])): + if mf.mb.is_machine(target): # 기계경비 판별 한 곳(712-1) keys = _captured(whole, node, env) return {"machine": keys[0] if keys else None} given = {name: mf.evaluate(expr, env, whole, 1) for name, expr in node[2]} diff --git a/B09_Estimation/B09_Estimation_Sangeun_Text.py b/B09_Estimation/B09_Estimation_Sangeun_Text.py index 84c005b79..53d112e4d 100644 --- a/B09_Estimation/B09_Estimation_Sangeun_Text.py +++ b/B09_Estimation/B09_Estimation_Sangeun_Text.py @@ -276,7 +276,7 @@ def _logic_lines(whole, item, env, qty_text, q, price_of) -> list[tuple[str, dic except mf.FormulaError: target = None name = item.get("이름", "") - if target and any(s.get("이름") == "기계" for s in target.get("입력", [])): + if mf.mb.is_machine(target): # 기계경비 판별 한 곳(712-1) keys = sg._captured(whole, node, env) got = price_of(keys[0]) if keys else None if not got: diff --git a/M02_MasterTemplete/M02_Hopyo_Ours.py b/M02_MasterTemplete/M02_Hopyo_Ours.py index 92272c4ad..90c6619c1 100644 --- a/M02_MasterTemplete/M02_Hopyo_Ours.py +++ b/M02_MasterTemplete/M02_Hopyo_Ours.py @@ -32,6 +32,7 @@ from M01_MasterData import M01_MasterData_Store_Cache as m01_cache from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match from M02_MasterTemplete.M02_Hopyo_Words import conds, mtypes, specnums +mb = m01.cm.mf.mb # 묶음 · 기계경비 판별(712-1) FP_VERSION = 1 # 지문 꼴이 바뀌면 올림 — 옛 캐시 버림 ROOT = Path(__file__).resolve().parents[1] _PER = re.compile(r"원\s*/\s*(\d[\d,]*\.?\d*|\.\d+)?") @@ -84,7 +85,7 @@ def whole() -> Any: def is_mach_logic(row: dict[str, Any]) -> bool: - return "시간당" in str(row.get("이름") or "") + return mb.is_machine(row) # 기계경비 판별 한 곳(712-1 · 결과단위 원/시간) def eq_of(code: Any) -> tuple[set[str], set[float]]: diff --git a/resources/master_data/scripts/master_bundle.py b/resources/master_data/scripts/master_bundle.py new file mode 100644 index 000000000..282e813cc --- /dev/null +++ b/resources/master_data/scripts/master_bundle.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +"""인력 · 재료 · 경비 묶음 — **셈 때 파생**(정본에 칸 없음 · PLAN 712-1 · 요구서 원칙 5). + +규칙 한 줄: +- 호표 · 덧줄 = `비목` 따라(노무비 → 인력 · 재료비 → 재료 · 경비 → 경비). +- 비목 없는 로직 줄 = 부르는 로직이 기계경비(`is_machine`)면 경비 + `장비` · 아니면 하위. + 두 줄 모두 엔진 줄 `비목` 에 노무비 · 재료비 · 경비 세 몫이 그대로 남음(한 줄 · 흩어 넣기 둘 다). + +기계경비 판별 = `is_machine` 한 곳 — M01 엔진 · M02 엑셀 맞춤(`M02_Hopyo_Ours`) · +B09 산근(`B09_Estimation_Sangeun` · `_Text`)이 같이 씀. +엔진 줄 덧칸 = `묶음`(출력 꼬리표만 · 입력 값 덧칸 `값묶음` 은 715-1b 몫) · 기계경비 줄 `장비` true. +""" + +from __future__ import annotations + +import re + +import master_formula as mf + +BUNDLES = ("인력", "재료", "경비", "하위") +BY_ITEM = {"노무비": "인력", "재료비": "재료", "경비": "경비"} +BY_KIND = {"인력": "인력", "재료": "재료", "기계": "경비"} # 비목 빈 옛 줄 받침 + + +def is_machine(row: dict | None) -> bool: + """기계경비 로직 — 돈 로직이고 결과단위 「원/시간」(작업량 「㎥/시간」 · 이름 「시간당」 아님).""" + if not row or "결과" in row or isinstance(row.get("흐름"), dict): + return False + return re.sub(r"\s", "", str(row.get("결과단위") or "")) == "원/시간" + + +def called(master, item: dict, env: dict | None = None) -> dict | None: + """로직 줄이 부르는 로직 줄(정본) — 못 찾으면 None.""" + try: + node = mf.parse(item.get("요소") or "") + if not node or node[0] != "logic": + return None + key = mf.fill(node[1], env) if env is not None and "{" in node[1] else node[1] + return master.logic(key) + except (mf.FormulaError, mf.EmptyInput, KeyError, TypeError): + return None + + +def bundle(master, item: dict, env: dict | None = None) -> tuple[str, bool]: + """줄 하나(호표 · 덧줄 정본 줄) → (묶음, 장비인가).""" + if item.get("비목") in BY_ITEM: + return BY_ITEM[item["비목"]], False + if item.get("종류") == "로직": + return ("경비", True) if is_machine(called(master, item, env)) else ("하위", False) + return BY_KIND.get(str(item.get("종류")), "하위"), False + + +def tag(master, row: dict, lines: list[dict], env: dict | None = None) -> list[dict]: + """엔진 `run` 줄에 `묶음`(+ 기계경비 줄 `장비`) — 줄 차례 = 호표 줄 하나씩 뒤 덧줄 하나씩.""" + for item, line in zip([*row.get("호표", []), *row.get("덧줄", [])], lines): + name, machine = bundle(master, item, env) + line["묶음"] = name + if machine: + line["장비"] = True + return lines diff --git a/resources/master_data/scripts/master_formula.py b/resources/master_data/scripts/master_formula.py index 4fdda195a..29c4665bb 100644 --- a/resources/master_data/scripts/master_formula.py +++ b/resources/master_data/scripts/master_formula.py @@ -657,6 +657,7 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict: } ) sums = {k: cut(row, v, "계") for k, v in sums.items()} # 비목 합마다 끊고 계는 그 합 + mb.tag(master, row, lines, env) # 줄마다 인력 · 재료 · 경비 · 하위 묶음(712-1) return {"줄": lines, **sums, "계": sum(sums.values()), "중간": _shown(env)} @@ -676,6 +677,7 @@ from master_material import ( # noqa: E402, F401 pick, ) import master_pick as mp # noqa: E402 — 검색어 · 고름 · 단위 +import master_bundle as mb # noqa: E402 — 인력 · 재료 · 경비 묶음 from master_formula_check import ( # noqa: E402, F401 — 검사는 나눈 파일 · 이름은 여기서 그대로 _check_cut, _check_node, diff --git a/resources/tester/test_712_1_bundle.py b/resources/tester/test_712_1_bundle.py new file mode 100644 index 000000000..272a93b0c --- /dev/null +++ b/resources/tester/test_712_1_bundle.py @@ -0,0 +1,64 @@ +"""712-1 지킴 — 인력 · 재료 · 경비 묶음 셈 때 파생 · 기계경비 판별 한 곳(`master_bundle`).""" + +from __future__ import annotations + +import sys +from decimal import Decimal +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "resources/master_data/scripts")) +sys.path.insert(0, str(ROOT)) + +import check_master as cm # noqa: E402 +import master_auto as mau # noqa: E402 +import master_bundle as mb # noqa: E402 +import master_formula as mf # noqa: E402 + +FILES = cm.load() +MASTER = mf.Master(FILES) +LOGICS = {r["키"]: r for d in FILES.values() if d.get("그룹") == "로직" for r in d.get("줄") or []} + + +def test_every_line_bundled(): + """호표 · 덧줄 전수 — 하위(못 가름) ≤ 31 · 나머지는 인력 · 재료 · 경비 · 기계경비 줄 = 경비.""" + count = dict.fromkeys(mb.BUNDLES, 0) + machine = 0 + for row in LOGICS.values(): + for item in [*row.get("호표", []), *row.get("덧줄", [])]: + name, is_mach = mb.bundle(MASTER, item) + count[name] += 1 + machine += is_mach + if is_mach: + assert name == "경비" + assert sum(count.values()) > 5_000 + assert count["하위"] <= 31, count + assert machine >= 1_000, machine + + +def test_machine_rule_one_place(): + """기계경비 = 결과단위 원/시간 돈 로직 — 작업량(㎥/시간) · 이름 「시간당」 은 아님.""" + machines = {k for k, r in LOGICS.items() if mb.is_machine(r)} + assert {"GC000267", "GC000268", "GF000145"} <= machines + assert not machines & {"GF000102", "GC000422", "GC000260"} # 작업량 · ㎡/시간 · 원/㎡ + sys.path.insert(0, str(ROOT)) + from M02_MasterTemplete import M02_Hopyo_Ours as ours + + assert all(ours.is_mach_logic(r) == mb.is_machine(r) for r in LOGICS.values()) + + +def test_run_lines_tagged(): + """엔진 `run` 줄마다 `묶음` · 기계경비 줄은 `장비` + 비목 노 · 재 · 경 세 몫.""" + seen = set() + for key in ("GF000169", "GF000103"): + row = LOGICS[key] + given = {k: v for k, v in mau.values(MASTER, row).items() if v is not None} + got = mf.run(MASTER, key, given) + for line in got["줄"]: + assert line["묶음"] in mb.BUNDLES + seen.add(line["묶음"]) + if line.get("장비"): + assert set(line["비목"]) == set(mf.COST_ITEMS) + total = sum((line["비목"].get(k) or Decimal(0)) for line in got["줄"] for k in line["비목"]) + assert abs(total - got["계"]) < 1 + assert {"인력", "재료", "하위"} <= seen From cddb28b63a6fdf2d2b9c1d3a1a0ecd8fa03f8527 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 16:03:36 +0900 Subject: [PATCH 08/19] =?UTF-8?q?feat(714-2):=20=EA=B3=B5=EC=9A=A9=20?= =?UTF-8?q?=EB=8B=A8=EA=B0=80=EC=82=B0=EC=B6=9C=20=EA=B3=84=EC=82=B0?= =?UTF-8?q?=EC=84=9C=20=ED=8B=80=20=E2=80=94=20A00=5FCommon/calc=5Fsheet.t?= =?UTF-8?q?s(704-2=20=EA=B3=84=EC=82=B0=EC=84=9C=20=EC=A4=84=20=EC=98=AC?= =?UTF-8?q?=EB=A6=BC=20=C2=B7=20=EB=AC=B6=EC=9D=8C=20=EB=A8=B8=EB=A6=AC=20?= =?UTF-8?q?=EB=B3=80=EC=88=98=20=EC=B9=B8=20=C2=B7=20=EB=B0=94=EA=BE=B8?= =?UTF-8?q?=EB=A9=B4=20onInput)=20=C2=B7=20M02=20=EA=B3=84=EC=82=B0?= =?UTF-8?q?=EC=84=9C=20=EB=B3=B4=EA=B8=B0=20=C2=B7=20M01=20=E3=80=8C?= =?UTF-8?q?=EC=9D=B8=EB=A0=A5=C2=B7=EC=9E=AC=EB=A3=8C=C2=B7=EA=B2=BD?= =?UTF-8?q?=EB=B9=84=E3=80=8D=20=EB=B3=B4=EA=B8=B0=EA=B0=80=20=ED=95=9C=20?= =?UTF-8?q?=EB=B2=8C=EB=A1=9C=20=EC=94=80(=EC=9E=A5=EB=B9=84=20=ED=95=9C?= =?UTF-8?q?=20=EC=A4=84=20=C2=B7=20=EA=B8=B0=EC=A4=80=EB=8B=A8=EC=9C=84=20?= =?UTF-8?q?=C2=B7=20=ED=99=98=EC=82=B0=20=EA=B7=BC=EA=B1=B0=EB=8A=94=20714?= =?UTF-8?q?-1=20=EB=92=A4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 704-2 Labor_Sheet 줄 · 소계 · 계 = calcSheetRows(표본 셋 DOM 전후 같음) - 새 M01_MasterData_UI_LogicLab_Est.ts — /text 묶음 그대로 · 묶음이 쓰는 시험 입력(714-3 usedInputs) = 머리 변수 칸 · 「준비 중」 자리 걷음 - 낡은 시험 test_704_logic_standard 고침 — 계산서가 계산 줄 부품을 공용 계산서 안에서 씀(import 자리만 바뀜) - 새 지킴 시험 test_714_2_calc_sheet Co-Authored-By: Claude Opus 5.5 --- A00_Common/calc_sheet.ts | 106 ++++++++++++++++++ .../M01_MasterData_UI_LogicLab_Detail.ts | 7 +- .../M01_MasterData_UI_LogicLab_Est.ts | 49 ++++++++ .../M01_MasterData_UI_LogicLab_Style.css | 8 ++ .../M01_MasterData_UI_LogicLab_Text.ts | 4 - .../M02_MasterTemplete_Labor_Sheet.ts | 27 ++--- resources/tester/test_704_logic_standard.py | 3 +- resources/tester/test_714_2_calc_sheet.py | 47 ++++++++ 8 files changed, 224 insertions(+), 27 deletions(-) create mode 100644 A00_Common/calc_sheet.ts create mode 100644 M01_MasterData/M01_MasterData_UI_LogicLab_Est.ts create mode 100644 resources/tester/test_714_2_calc_sheet.py diff --git a/A00_Common/calc_sheet.ts b/A00_Common/calc_sheet.ts new file mode 100644 index 000000000..b8422bdc3 --- /dev/null +++ b/A00_Common/calc_sheet.ts @@ -0,0 +1,106 @@ +/* ============================================================================= + * calc_sheet.ts + * 공용 「단가산출 계산서」(PLAN 714-2 · 요구서 내역12 4장 5) — 704-2 M02 계산서 보기를 올린 것. + * 쓰는 곳 = M01 「인력·재료·경비」 보기 · M02 기준 목록 계산서 보기(704-2) · (뒤에) 일위대가 모달 · B09 단가산출근거 + * 꼴 = 묶음마다 [머리(변수 칸이 있을 때만) · 쓰는 입력 값 줄] · 식 줄(이름 | 단가 × 품 | 금액) · 소계 · 맨 아래 계 + * 값 = 서버 셈 그대로 찍음(화면에서 다시 셈 안 함) · 변수 칸 바꾸면 `onInput(이름, 값)` — 부르는 쪽이 다시 셈 + * 부품 = `ui_template_calc_row`(값 줄 · 식 줄) · 입력 · 고르기 · 글 — 날 요소 · 페이지 CSS 없음 + * ========================================================================== */ + +import { createCalcFormula, createCalcFormulaRow, createCalcRow } from "@ui/ui_template_calc_row"; +import { el } from "@ui/ui_template_elements"; +import { createInputField, createSelectField } from "@ui/ui_template_input"; +import { createText } from "@ui/ui_template_text"; + +export interface SheetLine { + 이름: string; + /** 서버 값 식 「단가 * 품 = 금액」 — 「= 금액」 은 떼고 「*」 → 「×」 */ + 글: string; + 금액: number | null; +} +export interface SheetGroup { + 비목: string; + 소계: number | null; + 끝수?: string; + 줄: SheetLine[]; +} +export interface SheetInput { + 이름: string; + 단위?: string; + 고르기?: (string | number)[]; +} +export interface SheetOptions { + 묶음: SheetGroup[]; + 계: number | null; + /** 맨 아래 계 줄 이름(「계 (원/㎡)」) */ + sumLabel: string; + /** 변수 칸 — 입력 꼴 · 지금 값 · 묶음이 쓰는 입력 이름 · 바꾼 뒤 할 일(없으면 변수 칸 없음) */ + inputs?: SheetInput[]; + values?: Record; + uses?: (group: SheetGroup, index: number) => string[]; + onInput?: (name: string, value: string) => void; +} + +const won = (v: number | null, digits = 0): string => + v === null ? "-" : v.toLocaleString("ko-KR", { maximumFractionDigits: digits }); + +const out = (v: number | null, key = false, digits = 0): HTMLElement => + el("span", { className: `ui-calc-row__out${key ? " is-key" : ""}`, text: won(v, digits) }); + +/** 값 식 → 식 칸 글 — 「268,908 * 0.12 = 32,269」 → 「268,908 × 0.12」 */ +export const sheetFormula = (text: string): string => + text.replace(/ = [^=]*$/, "").replaceAll(" * ", " × "); + +/** 변수 칸 하나 — 고르기 있으면 고르기 · 아니면 글 칸(Enter · 떠남 때 넘김) */ +function varRow(spec: SheetInput, value: string, onInput: (v: string) => void): HTMLElement { + let control: HTMLElement; + if (spec.고르기?.length) { + control = createSelectField({ + options: spec.고르기.map((o) => ({ value: String(o), text: String(o) })), + value, + compact: true, + ariaLabel: spec.이름, + onChange: onInput, + }).root; + } else { + const box = createInputField({ value, compact: true }); + box.input.setAttribute("aria-label", spec.이름); + box.input.addEventListener("change", () => { + if (box.input.value.trim() && box.input.value !== value) onInput(box.input.value.trim()); + }); + control = box.root; + } + const row = createCalcRow(spec.단위 ? `${spec.이름} (${spec.단위})` : spec.이름, control); + row.dataset.sheetVar = spec.이름; + return row; +} + +/** 계산서 줄들 — 부르는 쪽 세로 칸(flex column)에 그대로 붙임 */ +export function calcSheetRows(o: SheetOptions): HTMLElement[] { + const rows: HTMLElement[] = []; + const specs = new Map((o.onInput ? (o.inputs ?? []) : []).map((s) => [s.이름, s])); + o.묶음.forEach((g, gi) => { + const vars = (o.uses?.(g, gi) ?? []).filter((n) => specs.has(n)); + if (vars.length && o.onInput) { + const onInput = o.onInput; + const head = createText(g.비목, "muted", 14); + head.dataset.sheetHead = g.비목; + rows.push(head); + for (const n of vars) + rows.push(varRow(specs.get(n)!, o.values?.[n] ?? "", (v) => onInput(n, v))); + } + for (const line of g.줄) + rows.push( + createCalcFormulaRow( + line.이름, + createCalcFormula(sheetFormula(line.글)), + out(line.금액, false, 1), + ), + ); + rows.push( + createCalcFormulaRow(`${g.비목}${g.끝수 ? ` (${g.끝수})` : ""}`, null, out(g.소계, true)), + ); + }); + rows.push(createCalcFormulaRow(o.sumLabel, null, out(o.계, true))); + return rows; +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts index ed14e9e49..bedd8c879 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts @@ -24,6 +24,7 @@ import { openPicker } from "./M01_MasterData_UI_Logic_Pick"; import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text"; import { pickMaterial } from "./M01_MasterData_UI_Logic_CalcPick"; import { buildFormula } from "./M01_MasterData_UI_LogicLab_Formula"; +import { buildEst } from "./M01_MasterData_UI_LogicLab_Est"; import { condText, openMaterialModal, @@ -38,7 +39,6 @@ import { tl, type LabTextKey } from "./M01_MasterData_UI_LogicLab_Text"; import "./M01_MasterData_UI_LogicLab_Style.css"; import "./M01_MasterData_UI_LogicLab_Pill.css"; import { createBadge } from "@ui/ui_template_badge"; -import { createEmpty } from "@ui/ui_template_empty"; import { block, nowInputs } from "./M01_MasterData_UI_LogicLab_View"; /** 서버 조각(`/text` 줄의 `조각`) → 수량 칸 조각 — 요소·표·로직은 알약(글 = 조각 글) · 나머지는 글자 */ @@ -525,7 +525,10 @@ export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void { host.replaceChildren( ...infoBox(ctx), block(section(tl("Calc_Title"), [ctx.calcHost]), "calc"), - block(section(tl("View_Est"), [createEmpty({ text: tl("View_Soon"), small: true })]), "est"), + block( + section(tl("View_Est"), [buildEst({ text: ctx.text, unit: ctx.row.결과단위, ...fx })]), + "est", + ), ...(ctx.flowHost ? [block(nowInputs(ctx.values), "ho"), ctx.flowHost] : [hoBox(ctx), block(section(tl("Formula_Title"), [formula]), "formula")]), diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Est.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Est.ts new file mode 100644 index 000000000..adef8655f --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Est.ts @@ -0,0 +1,49 @@ +/* ============================================================================= + * M01_MasterData_UI_LogicLab_Est.ts + * 「인력·재료·경비」 보기(PLAN 714-2) — 공용 단가산출 계산서(`A00_Common/calc_sheet.ts`)에 시험 계산 읽는 식(`/text`)을 그대로. + * 묶음 머리 변수 칸 = 그 묶음 줄들이 쓰는 시험 입력(714-3 `usedInputs`) · 바꾸면 시험 계산 입력 고침 + 다시 셈(정본 안 바뀜) + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import { createText } from "@ui/ui_template_text"; +import { calcSheetRows } from "../A00_Common/calc_sheet"; +import type { LogicInput } from "./M01_MasterData_UI_Logic_Api"; +import { safeReason } from "./M01_MasterData_UI_Logic_Text"; +import { usedInputs, type PreviewTextAnswer } from "./M01_MasterData_UI_LogicLab_Formula"; +import { tl } from "./M01_MasterData_UI_LogicLab_Text"; + +export interface EstContext { + text: PreviewTextAnswer | null; + unit: string; + inputs: LogicInput[]; + values: Record; + onInput?: (name: string, value: string) => void; +} + +export function buildEst(ctx: EstContext): HTMLElement { + const host = el("div", { className: "m01lab-est", attrs: { "data-est": "" } }); + const text = ctx.text; + if (!text || (text.ok && text.계산됨 === false)) { + host.append(createText(tl("Formula_Slot"), "muted")); + return host; + } + if (!text.ok) { + host.append(createText(safeReason(text.reason), "error")); + return host; + } + const names = ctx.inputs.map((x) => x.이름); + host.append( + ...calcSheetRows({ + 묶음: text.groups, + 계: text.계, + sumLabel: `${tl("Total")} (${ctx.unit || "원"})`, + inputs: ctx.inputs, + values: ctx.values, + onInput: ctx.onInput, + uses: (_, gi) => [ + ...new Set(text.groups[gi].줄.flatMap((line) => usedInputs(line, names).map(([n]) => n))), + ], + }), + ); + return host; +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css b/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css index 0292d5cd7..a1fbc0bd6 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css @@ -225,3 +225,11 @@ max-height: 40vh; overflow: auto; } + +/* 714-2 「인력·재료·경비」 — 공용 계산서 줄을 세로로 */ +.m01lab-est { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + min-width: 0; +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts index 52b4329de..aea6cd7b6 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts @@ -30,10 +30,6 @@ const TEXT = { Calc_Title: ["시험 계산", "Trial calculation"], View_Est: ["인력·재료·경비", "Labor·material·expense"], View_Label: ["보기", "View"], - View_Soon: [ - "준비 중 — 인력 · 재료 · 경비 묶음 보기", - "Coming soon — labor · material · expense view", - ], Now_Inputs: ["지금 입력", "Current inputs"], Now_None: ["없음", "none"], G_labor: ["인력", "Labor"], diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Sheet.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Sheet.ts index 1c22a7caa..d21620c12 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Sheet.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Sheet.ts @@ -2,23 +2,17 @@ * M02_MasterTemplete_Labor_Sheet.ts * 계산서 보기(704-2) — 기준 목록 줄을 누르면 담기 전에 한 장: 맨 위 「출처 · 판」 · 비목마다 * 「이름 | 노임 × 품 | 금액」 줄 · 소계 · 계(서버 M01 엔진 값 · `/logic-lib/sheet`) · 곁에 원문 띠 · - * [담기] = 부르는 쪽 담기 동작 그대로(`put`). + * [담기] = 부르는 쪽 담기 동작 그대로(`put`). 줄 · 소계 · 계 = 공용 계산서(`A00_Common/calc_sheet.ts` · 714-2). * ========================================================================== */ import { createButton, el, showToast } from "@ui/ui_template_elements"; -import { createCalcFormula, createCalcFormulaRow } from "@ui/ui_template_calc_row"; import { openModal } from "@ui/ui_template_modal"; import { createText } from "@ui/ui_template_text"; +import { calcSheetRows } from "../A00_Common/calc_sheet"; import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source"; import { fetchStandardSheet, type StandardRow } from "./M02_MasterTemplete_Labor_Api"; import { tc } from "./M02_MasterTemplete_Labor_Text"; -const won = (v: number | null, digits = 0): string => - v === null ? "-" : v.toLocaleString("ko-KR", { maximumFractionDigits: digits }); - -const out = (v: number | null, key = false, digits = 0): HTMLElement => - el("span", { className: `ui-calc-row__out${key ? " is-key" : ""}`, text: won(v, digits) }); - export function openStandardSheet(row: StandardRow, put: () => void): void { const sheet = el("div", { className: "m02-std-sheet__body", attrs: { "data-std-sheet": "" } }); sheet.append(createText(tc("Loading"))); @@ -51,19 +45,12 @@ export function openStandardSheet(row: StandardRow, put: () => void): void { createText(head, "muted", 14), createText([got.출처, got.판].filter(Boolean).join(" · "), "hint"), ]; - for (const g of got.묶음) { - for (const line of g.줄) { - const text = line.글.replace(/ = [^=]*$/, "").replaceAll(" * ", " × "); - lines.push( - createCalcFormulaRow(line.이름, createCalcFormula(text), out(line.금액, false, 1)), - ); - } - lines.push( - createCalcFormulaRow(`${g.비목}${g.끝수 ? ` (${g.끝수})` : ""}`, null, out(g.소계, true)), - ); - } lines.push( - createCalcFormulaRow(`${tc("Std_Sum")} (원/${got.단위 || "식"})`, null, out(got.계, true)), + ...calcSheetRows({ + 묶음: got.묶음, + 계: got.계, + sumLabel: `${tc("Std_Sum")} (원/${got.단위 || "식"})`, + }), ); sheet.replaceChildren(...lines); source.show(got.출처); diff --git a/resources/tester/test_704_logic_standard.py b/resources/tester/test_704_logic_standard.py index bfd9f02b8..133ed7766 100644 --- a/resources/tester/test_704_logic_standard.py +++ b/resources/tester/test_704_logic_standard.py @@ -120,7 +120,8 @@ def test_화면_기준_목록_계산서_담기() -> None: assert "openStandardSheet(row, () => {\n close();\n done(logic);" in pick assert "(!work.select.value || x.공종 === work.select.value)" in pick sheet = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor_Sheet.ts").read_text("utf-8") - assert 'from "@ui/ui_template_calc_row"' in sheet and "createSourceView(" in sheet + # 줄 · 소계 · 계 = 공용 단가산출 계산서(714-2 · 계산 줄 부품을 그 안에서 씀) + assert 'from "../A00_Common/calc_sheet"' in sheet and "createSourceView(" in sheet for name in ("Labor_Pick", "Labor_Sheet"): text = (ROOT / f"M02_MasterTemplete/M02_MasterTemplete_{name}.ts").read_text("utf-8") assert len(text.splitlines()) <= 700 diff --git a/resources/tester/test_714_2_calc_sheet.py b/resources/tester/test_714_2_calc_sheet.py new file mode 100644 index 000000000..b2c8772d5 --- /dev/null +++ b/resources/tester/test_714_2_calc_sheet.py @@ -0,0 +1,47 @@ +"""714-2 공용 단가산출 계산서 지킴 시험 — 글 · 순수 함수(화면 · 서버 없음). + +- 704-2 M02 계산서 보기 · M01 「인력·재료·경비」 보기가 한 벌(`A00_Common/calc_sheet.ts`)을 씀 +- 계산서는 부품 상자만(날 단추 · 입력 · 표 없음) · 화면에서 다시 셈 안 함(값 = 서버) +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +SHEET = ROOT / "A00_Common" / "calc_sheet.ts" + + +def _text(p: Path) -> str: + return p.read_text(encoding="utf-8") + + +def test_한_벌을_씀(): + m02 = _text(ROOT / "M02_MasterTemplete" / "M02_MasterTemplete_Labor_Sheet.ts") + est = _text(ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_Est.ts") + detail = _text(ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_Detail.ts") + assert "calcSheetRows(" in m02 and "createCalcFormulaRow" not in m02 + assert "calcSheetRows(" in est and "buildEst(" in detail + + +def test_부품_상자만(): + s = _text(SHEET) + assert " Date: Sun, 1 Nov 2026 16:03:54 +0900 Subject: [PATCH 09/19] =?UTF-8?q?feat(712-2):=20=EB=8B=A8=EC=9C=84=20?= =?UTF-8?q?=ED=95=9C=20=EB=B2=8C=20master=5Funit=20=E2=80=94=20=EB=B3=84?= =?UTF-8?q?=EC=B9=AD=20=C2=B7=20=EA=B0=9C=EC=88=98=20=EB=AC=B6=EC=9D=8C=20?= =?UTF-8?q?=C2=B7=20=EA=B2=B0=EA=B3=BC=EB=8B=A8=EC=9C=84=20=EB=B0=B0=20?= =?UTF-8?q?=EC=9D=BD=EA=B8=B0=20=C2=B7=20=EA=B0=99=EC=9D=80=20=EC=84=B1?= =?UTF-8?q?=EA=B2=A9=20=ED=99=98=EC=82=B0=20=C2=B7=20=EA=B7=BC=EA=B1=B0=20?= =?UTF-8?q?=EA=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 별칭(master_material) · 같은 성격 환산(master_pick) · 결과단위 배 읽기 셋(Store_Combo · Hopyo_Ours · master_basis) · 보이는 꼴 별칭(master_basis) 갈아 끼움 · 엑셀 맞춤(Hopyo_Match)은 master_pick 이름 그대로 이음 - 개수 묶음(개 · EA · 본 · 매) 1:1 — 본 ↔ 개 단위경고 4 줄 사라짐 · 값 같음 - 부피 ↔ 무게 = 단위중량 표 CC000337 고른 줄 받을 때만(기본 줄 없음) · 밀도 · ㎾h 경고 글만 - B08 /1000 · /40 손 식 = 실무 엑셀 G열 식이라 그대로(시험이 같은 값 대조) - 지킴 시험 test_712_2_unit Co-Authored-By: Claude Opus 5.5 --- M01_MasterData/M01_MasterData_Store_Combo.py | 12 +- M02_MasterTemplete/M02_Hopyo_Ours.py | 7 +- resources/master_data/scripts/master_basis.py | 8 +- .../master_data/scripts/master_material.py | 6 +- resources/master_data/scripts/master_pick.py | 31 +---- resources/master_data/scripts/master_unit.py | 121 ++++++++++++++++++ resources/tester/test_712_2_unit.py | 59 +++++++++ 7 files changed, 195 insertions(+), 49 deletions(-) create mode 100644 resources/master_data/scripts/master_unit.py create mode 100644 resources/tester/test_712_2_unit.py diff --git a/M01_MasterData/M01_MasterData_Store_Combo.py b/M01_MasterData/M01_MasterData_Store_Combo.py index 2b7163325..8034e63eb 100644 --- a/M01_MasterData/M01_MasterData_Store_Combo.py +++ b/M01_MasterData/M01_MasterData_Store_Combo.py @@ -17,6 +17,7 @@ from config import config_system from M01_MasterData import M01_MasterData_Store as store cm, mk, mcb = store.cm, store.mk, store.mcb +import master_unit as mu # noqa: E402 — `store` 가 scripts 를 길에 둔 뒤(712-2) FILE = mcb.FILE OWNERS = mcb.OWNERS @@ -150,9 +151,6 @@ def combo(key: str) -> dict: } -_PER = re.compile( - r"원\s*/\s*(\d[\d,]*\.?\d*|\.\d+)?\s*([^\s·(\d,.][^\s·(]*)" -) # 「원/1,000본」 = 배 1000 _TAIL = re.compile(r"\s*\([^()]*\)\s*$") @@ -177,7 +175,7 @@ def combo_pills(row: dict) -> dict: got = whole.logic(code) except cm.mf.FormulaError: continue - per = _PER.findall(str(got.get("결과단위") or "")) + per = mu.per(got.get("결과단위")) # 결과단위 배 읽기 한 벌(712-2) if not per: continue # ponytail: 분모가 여럿(「원/㎡ · 원/㎥(단위당 따름)」)이면 「㎡ · ㎥」 한 글 — 입력이 고름 @@ -187,9 +185,9 @@ def combo_pills(row: dict) -> dict: (name, unit), {"키": f"{name}|{unit}", "이름": name, "단위": unit, "로직": []} ) pill["로직"].append(code) - times = per[0][0].replace(",", "") - if len(per) == 1 and times and float(times) != 1: - pill["배"] = float(times) if "." in times else int(times) + times = per[0][0] + if len(per) == 1 and times != 1: + pill["배"] = int(times) if times == times.to_integral_value() else float(times) return {"키": row.get("키"), "이름": row.get("이름"), "물량": list(pills.values()), "조건": []} diff --git a/M02_MasterTemplete/M02_Hopyo_Ours.py b/M02_MasterTemplete/M02_Hopyo_Ours.py index 90c6619c1..f46178624 100644 --- a/M02_MasterTemplete/M02_Hopyo_Ours.py +++ b/M02_MasterTemplete/M02_Hopyo_Ours.py @@ -32,10 +32,9 @@ from M01_MasterData import M01_MasterData_Store_Cache as m01_cache from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match from M02_MasterTemplete.M02_Hopyo_Words import conds, mtypes, specnums -mb = m01.cm.mf.mb # 묶음 · 기계경비 판별(712-1) +mb, mu = m01.cm.mf.mb, m01.cm.mf.mp.mm.mu # 묶음(712-1) · 단위(712-2) 한 벌 FP_VERSION = 1 # 지문 꼴이 바뀌면 올림 — 옛 캐시 버림 ROOT = Path(__file__).resolve().parents[1] -_PER = re.compile(r"원\s*/\s*(\d[\d,]*\.?\d*|\.\d+)?") _SUB = re.compile(r"^\s*로직\(\s*(\w+)\s*(?:,(.*))?\)\s*$") EQ_CODE = re.compile(r"^\d{4}-\d{4}$") _PCT = re.compile(r"\*\s*([\d.]+)\s*/\s*100") @@ -96,8 +95,8 @@ def eq_of(code: Any) -> tuple[set[str], set[float]]: def times_of(row: dict[str, Any]) -> float: - per = _PER.findall(str(row.get("결과단위") or "")) - return float(per[0].replace(",", "")) if per and per[0] else 1.0 + per = mu.per(row.get("결과단위")) # 결과단위 배 읽기 한 벌(712-2) + return float(per[0][0]) if per else 1.0 def _args(text: str | None) -> dict[str, str]: diff --git a/resources/master_data/scripts/master_basis.py b/resources/master_data/scripts/master_basis.py index cf60c6928..ba753509b 100644 --- a/resources/master_data/scripts/master_basis.py +++ b/resources/master_data/scripts/master_basis.py @@ -15,10 +15,9 @@ import re from decimal import Decimal from functools import lru_cache +import master_unit as mu from check_master_body import section_of -# ponytail: 단위 별칭은 여기 짧은 표 — 712-2 단위 모듈이 서면 그쪽 별칭으로 갈아 끼움 -_ALIAS = {"ea": "개", "톤": "ton", "t": "ton", "㎞": "km", "hr": "시간", "min": "분", "㎏": "kg"} _UNITS = sorted( ( "㎡ ㎥ ㎢ ㎞ km ㎜ mm ㎝ cm m ha ton 톤 t kg ㎏ 개소 개 본 대 회 조 기 틀 " @@ -33,11 +32,10 @@ _UNIT_AT_END = re.compile( ) _HANGUL = re.compile(r"[가-힣]") _HEAD = re.compile(r"^\(\s*(?:단위\s*:\s*)?([^()]*당[^()]*)\)$") # 원문 머리 줄 「(단위: ㎥당)」 -_RESULT = re.compile(r"원\s*/\s*(\d[\d,]*\.?\d*)?\s*([^\s·(\d,.][^\s·(]*)") # 「원/10㎡」 def unit(text: str) -> str: - return _ALIAS.get(text, text) + return mu.shown(text) # 보이는 꼴 별칭 한 벌(712-2 `master_unit`) def _times(text: str | None) -> Decimal: @@ -97,7 +95,7 @@ def basis(table: dict) -> dict: def result_per(result_unit) -> list[tuple[Decimal, str]]: """로직 결과단위 분모 — 「원/10㎡」 → [(10, ㎡)] · 여럿이면 여럿.""" - return [(_times(n), unit(u)) for n, u in _RESULT.findall(str(result_unit or ""))] + return [(n, unit(u)) for n, u in mu.per(result_unit)] def _formula_text(row: dict) -> str: diff --git a/resources/master_data/scripts/master_material.py b/resources/master_data/scripts/master_material.py index 569ea6025..beac0589c 100644 --- a/resources/master_data/scripts/master_material.py +++ b/resources/master_data/scripts/master_material.py @@ -12,6 +12,8 @@ import re import unicodedata from decimal import Decimal +import master_unit as mu + LINKED = "MP" # 품셈재료 테이블ID MARKET = "MT" # 자재품목 테이블ID PRICED = "MO" # 유가전력 테이블ID — 값 한 칸 @@ -27,7 +29,6 @@ PICK_KEYS = ( ) # 재료 고르기 조건 칸 (`_틀.md` 7장) REGION = "자재지역" # 로직 입력 — 「지역」 은 유가 고르기가 씀 TARIFF = "전력 계약종별" # 로직 입력 — 품셈·계약예규에 계약종별 규정 없음 -_UNITS = {"ea": "개", "ton": "톤", "t": "톤", "l": "ℓ", "리터": "ℓ", "set": "조", "세트": "조"} def plain(s) -> str: @@ -41,8 +42,7 @@ def plain_spec(s) -> str: def plain_unit(s) -> str: - s = plain(s).replace("ℓ", "l") - return _UNITS.get(s, s) + return mu.plain(s) # 별칭 · 맞대기 꼴 한 벌(712-2) def _offers(master: mf.Master, key: str) -> list[tuple[Decimal, str]]: diff --git a/resources/master_data/scripts/master_pick.py b/resources/master_data/scripts/master_pick.py index b656e47fc..fdbcb02a2 100644 --- a/resources/master_data/scripts/master_pick.py +++ b/resources/master_data/scripts/master_pick.py @@ -9,18 +9,10 @@ from __future__ import annotations from contextlib import contextmanager from contextvars import ContextVar -from decimal import Decimal import master_material as mm +from master_unit import UNITS, convert, kind_of, same_kind # noqa: F401 — 단위 한 벌(712-2) -# 「같은 성격」 단위 — {성격: {정규 단위: 기준 단위 몇 배}} (키는 `plain_unit` 을 거친 꼴) -UNITS = { - "무게": {"g": 1, "kg": 1000, "톤": 1000000}, - "길이": {"mm": 1, "cm": 10, "m": 1000, "km": 1000000}, - "넓이": {"cm2": 1, "m2": 10000}, - "부피": {"cm3": 1, "ℓ": 1000, "m3": 1000000}, -} -_SCALE = {u: (kind, n) for kind, group in UNITS.items() for u, n in group.items()} REGIONS = ( "전국평균 전국 서울 경기 강원 충북 충남 전북 전남 경북 경남 부산 대구 인천 대전 울산 세종 제주 광주" " 전남광주" @@ -60,27 +52,6 @@ def machine(spec: dict, value) -> bool: return bool(picks) and str(value).strip() in known and all(str(o) in known for o in picks) -# ── 단위 ─────────────────────────────────────────────────────────────── -def kind_of(unit) -> str | None: - got = _SCALE.get(mm.plain_unit(unit)) - return got[0] if got else None - - -def same_kind(a, b) -> bool: - """단위가 같거나 같은 성격(무게 · 길이 · 넓이 · 부피).""" - pa, pb = mm.plain_unit(a), mm.plain_unit(b) - return pa == pb or (kind_of(a) is not None and kind_of(a) == kind_of(b)) - - -def convert(price, have, want): - """`have` 단위 단가 → `want` 단위 단가 — 같은 성격이 아니면 그대로.""" - if price is None or not want or mm.plain_unit(have) == mm.plain_unit(want): - return price - if not same_kind(have, want) or kind_of(have) is None: - return price - return price * Decimal(_SCALE[mm.plain_unit(want)][1]) / Decimal(_SCALE[mm.plain_unit(have)][1]) - - # ── 찾기 ─────────────────────────────────────────────────────────────── def _words(term) -> tuple[list[str], list[str]]: """(이름·규격에 든 낱말, 지역 낱말) — 지역 낱말은 고르는 차례에만 쓰임.""" diff --git a/resources/master_data/scripts/master_unit.py b/resources/master_data/scripts/master_unit.py new file mode 100644 index 000000000..07d8e56f2 --- /dev/null +++ b/resources/master_data/scripts/master_unit.py @@ -0,0 +1,121 @@ +# -*- coding: utf-8 -*- +"""단위 한 벌 — 별칭 · 개수 묶음 · 결과단위 배 읽기 · 같은 성격 환산 · 환산 근거 글(PLAN 712-2). + +쓰는 곳: 재료 단가 → 줄 단위(`master_pick`) · 자재 맞대기 별칭(`master_material.plain_unit`) · +조합 알약 배(`M01_MasterData_Store_Combo`) · 엑셀 맞춤 배(`M02_Hopyo_Ours` · `_Match`) · +표 기준배(`master_basis`) · 계산서 줄 환산 근거(714-1). 서버 단독(③) — TS 는 서버가 준 글만 씀. +- 개수 묶음(개 · EA · 본 · 매) = 1 : 1 같은 성격 · 조 · 세트는 묶음 밖(한 조 = 여럿). +- 부피 ↔ 무게 = 단위중량 표 CC000337 에서 고른 줄(`unit_weight`)을 받을 때만 · 기본 줄 없음 + (사용자 확인 거리) · 밀도(kg ↔ ℓ) · ㎾h 등은 환산 없이 경고 글만. +""" + +from __future__ import annotations + +import re +import unicodedata +from decimal import Decimal + +# 맞대기 꼴(`plain`) 별칭 — NFKC · 소문자 · 빈칸 없앤 뒤 +ALIAS = {"ea": "개", "ton": "톤", "t": "톤", "l": "ℓ", "리터": "ℓ", "set": "조", "세트": "조"} +# 보이는 꼴(`shown`) 별칭 — 표 기준 · 결과단위 분모(원문 기호 ㎡ · ㎥ 그대로) +SHOWN = {"ea": "개", "톤": "ton", "t": "ton", "㎞": "km", "hr": "시간", "min": "분", "㎏": "kg"} +# 같은 성격 — {성격: {맞대기 단위: 기준 단위 몇 배}} +UNITS = { + "무게": {"g": 1, "kg": 1000, "톤": 1000000}, + "길이": {"mm": 1, "cm": 10, "m": 1000, "km": 1000000}, + "넓이": {"cm2": 1, "m2": 10000}, + "부피": {"cm3": 1, "ℓ": 1000, "m3": 1000000}, + "개수": {"개": 1, "본": 1, "매": 1}, +} +_SCALE = {u: (kind, n) for kind, group in UNITS.items() for u, n in group.items()} +WEIGHT_TABLE = "CC000337" # 건설품셈 공통 1-2-4 재료의 단위중량(㎏/㎥) +# 결과단위 분모 — 「원/10㎡」 → (10, ㎡) · 「원/1,000본」 → (1000, 본) +_PER = re.compile(r"원\s*/\s*(\d[\d,]*\.?\d*|\.\d+)?\s*([^\s·(\d,.][^\s·(]*)") + + +def plain(unit) -> str: + """맞대기 꼴 — 「㎥」 = 「m3」 · 「EA」 = 「개」 · 「Ton」 = 「톤」 · 「리터」 = 「ℓ」.""" + s = re.sub(r"\s+", "", unicodedata.normalize("NFKC", str(unit or "")).lower()) + s = s.replace("ℓ", "l") + return ALIAS.get(s, s) + + +def shown(unit) -> str: + return SHOWN.get(unit, unit) + + +def per(result_unit) -> list[tuple[Decimal, str]]: + """결과단위 분모마다 (배, 단위 원문 글) — 배 없으면 1 · 분모가 여럿이면 여럿.""" + return [ + (Decimal(n.replace(",", "")) if n else Decimal(1), u) + for n, u in _PER.findall(str(result_unit or "")) + ] + + +def kind_of(unit) -> str | None: + got = _SCALE.get(plain(unit)) + return got[0] if got else None + + +def same_kind(a, b) -> bool: + """단위가 같거나 같은 성격(무게 · 길이 · 넓이 · 부피 · 개수).""" + return plain(a) == plain(b) or (kind_of(a) is not None and kind_of(a) == kind_of(b)) + + +def _weighs(a, b) -> bool: + return {kind_of(a), kind_of(b)} == {"무게", "부피"} + + +def factor(have, want, density=None) -> Decimal | None: + """`have` 1 단위가 `want` 몇 단위인가 — 단가는 ÷, 수량은 ×. + 부피 ↔ 무게는 단위중량(㎏/㎥) `density` 가 있을 때만 · 못 하면 None.""" + a, b = plain(have), plain(want) + if a == b: + return Decimal(1) + if same_kind(a, b): + return Decimal(_SCALE[a][1]) / Decimal(_SCALE[b][1]) + if density and _weighs(a, b): + grams_per_cm3 = Decimal(str(density)) / Decimal(1000) + base = Decimal(_SCALE[a][1]) * ( + grams_per_cm3 if kind_of(a) == "부피" else 1 / grams_per_cm3 + ) + return base / Decimal(_SCALE[b][1]) + return None + + +def convert(price, have, want, density=None): + """`have` 단위 단가 → `want` 단위 단가 — 못 바꾸면 그대로(경고는 부르는 쪽).""" + if price is None or not want or not have or plain(have) == plain(want): + return price + if same_kind(have, want): # × 뒤 ÷ — 옛 셈과 같은 Decimal 꼴(자릿수) + return price * Decimal(_SCALE[plain(want)][1]) / Decimal(_SCALE[plain(have)][1]) + got = factor(want, have, density) # want 1 단위 = have 몇 단위 + return price if got is None or got == 1 else price * got + + +def note(have, want, density=None) -> str: + """환산 근거 글 — 「kg → 톤 ×1000」 · 「본 = 개(개수 1:1)」 · 못 바꾸면 까닭.""" + if not have or not want or plain(have) == plain(want): + return "" + got = factor(want, have, density) + if got is not None: + if kind_of(have) == "개수" and got == 1: + return f"{have} = {want}(개수 1:1)" + how = f" · 단위중량 {density}㎏/㎥" if _weighs(have, want) else "" + return f"{have} → {want} ×{_num(got)}{how}" + if _weighs(have, want): + return f"{have} ↔ {want} — 단위중량({WEIGHT_TABLE}) 골라야 환산 · 지금 그대로" + return f"{have} ↔ {want} — 성격 다름 · 환산 없음(경고)" + + +def _num(x: Decimal) -> str: + return format(x.normalize(), "f") if x == x.to_integral_value() else format(x, "f")[:10] + + +def unit_weight(master, kind: str, shape: str, end: str = "위") -> Decimal | None: + """단위중량 표에서 고른 줄의 ㎏/㎥ — 기본 줄 없음(고르지 않으면 None).""" + for row in master.get(WEIGHT_TABLE).get("줄") or []: + if row.get("종별") == kind and row.get("형상") == shape: + got = row.get(end) + return None if got is None else Decimal(str(got)) + return None diff --git a/resources/tester/test_712_2_unit.py b/resources/tester/test_712_2_unit.py new file mode 100644 index 000000000..c09f638b5 --- /dev/null +++ b/resources/tester/test_712_2_unit.py @@ -0,0 +1,59 @@ +"""712-2 지킴 — 단위 한 벌(`master_unit`) · 다섯 자리가 같은 모듈을 씀 · 알약 배 전수.""" + +from __future__ import annotations + +import sys +from decimal import Decimal +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "resources/master_data/scripts")) +sys.path.insert(0, str(ROOT)) + +import check_master as cm # noqa: E402 +import master_basis as mbs # noqa: E402 +import master_formula as mf # noqa: E402 +import master_material as mm # noqa: E402 +import master_pick as mp # noqa: E402 +import master_unit as mu # noqa: E402 + + +def test_alias_and_count_group(): + assert mu.plain("EA") == mu.plain("ea") == "개" + assert mu.plain("Ton") == mu.plain("t") == mu.plain("톤") == "톤" + assert mu.plain("㎥") == "m3" and mu.plain("리터") == mu.plain("ℓ") == "ℓ" + assert mu.same_kind("본", "개") and mu.same_kind("매", "EA") + assert not mu.same_kind("조", "개") # 한 조 = 여럿 — 묶음 밖 + assert mu.note("본", "개") == "본 = 개(개수 1:1)" + assert mm.plain_unit("EA") == mu.plain("EA") + assert mp.convert is mu.convert and mp.UNITS is mu.UNITS + + +def test_same_kind_convert(): + assert mu.convert(Decimal(1000), "톤", "kg") == Decimal(1) # 톤 당 1000 원 = kg 당 1 원 + assert mu.convert(Decimal(5), "kg", "톤") == Decimal(5000) + assert mu.convert(Decimal(7), "m", "km") == Decimal(7000) + assert mu.factor("kg", "ton") == Decimal("0.001") # B08 「/1000」(kg → ton) 손 식과 같은 값 + assert mu.note("kg", "톤") == "kg → 톤 ×1000" + assert mu.convert(Decimal(3), "kg", "ℓ") == Decimal(3) # 밀도 = 경고만 · 그대로 + assert "성격 다름" in mu.note("ℓ", "㎾h") + + +def test_volume_weight_only_when_picked(): + """부피 ↔ 무게 = 단위중량 표 고른 줄이 있을 때만 · 기본 줄 없음.""" + master = mf.Master(cm.load()) + assert mu.factor("㎥", "톤") is None + assert "단위중량(CC000337)" in mu.note("㎥", "톤") + rho = mu.unit_weight(master, "모래", "건조") + assert rho is not None and rho > 1000 + assert mu.factor("㎥", "톤", rho) == rho / 1000 # 1 ㎥ = ρ/1000 톤 + assert mu.convert(Decimal(100), "㎥", "톤", rho) == Decimal(100) * 1000 / rho + assert mu.unit_weight(master, "없는 종별", "건조") is None + + +def test_result_per_one_parser(): + assert mu.per("원/10㎡") == [(Decimal(10), "㎡")] + assert mu.per("원/1,000본") == [(Decimal(1000), "본")] + assert mu.per("원/㎡ · 원/㎥(단위 따름)") == [(Decimal(1), "㎡"), (Decimal(1), "㎥")] + assert mu.per("㎥/시간") == [] + assert mbs.result_per("원/ton") == [(Decimal(1), "ton")] From 98757431816a6c0919e5e6525e76314f09264093 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 16:04:02 +0900 Subject: [PATCH 10/19] =?UTF-8?q?feat(714-1):=20=EA=B3=84=EC=82=B0?= =?UTF-8?q?=EC=84=9C=20=EC=A4=84=20=EB=8D=A7=EC=B9=B8=20=E2=80=94=20?= =?UTF-8?q?=EC=93=B0=EB=8A=94=20=EC=9E=85=EB=A0=A5=20=C2=B7=20=EB=AC=B6?= =?UTF-8?q?=EC=9D=8C=20=C2=B7=20=EA=B8=B0=EC=A4=80=EB=8B=A8=EC=9C=84=20?= =?UTF-8?q?=C2=B7=20=ED=99=98=EC=82=B0=20=EA=B7=BC=EA=B1=B0=20=C2=B7=20M01?= =?UTF-8?q?=20/text=20=EC=99=80=20M02=20=EA=B3=84=EC=82=B0=EC=84=9C=20?= =?UTF-8?q?=EA=B0=99=EC=9D=80=20=EC=A4=84=20=EA=BC=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 master_sheet_line.annotate — master_text.lines 답에 줄마다 덧칸(줄 차례 = 호표 _shares 비목 · 뒤 덧줄) · 머리 기준단위(결과단위 분모) - 쓰는입력 = 수량 식 · 요소 · 덧줄 식이 쓰는 로직 입력(중간값 거슬러 감) · 기준단위 = 줄이 찾는 표 기준(712-3 basis) · 환산근거 = 표 기준배 ↔ 결과단위 배 · 재료 품목 단위 ↔ 줄 단위(712-2 note) - M01 /text = 새 Store_Sheet 가 감쌈(Store.py · master_text.py 700 줄 근처라 안 늘림) · M02 /logic-lib/sheet 줄 칸에 같은 KEYS - 지킴 시험 test_714_1_sheet_line(두 길 같은 덧칸) Co-Authored-By: Claude Opus 5.5 --- M01_MasterData/M01_MasterData_Router.py | 3 +- M01_MasterData/M01_MasterData_Store_Sheet.py | 27 +++ M02_MasterTemplete/M02_Logic_Standard.py | 8 +- .../master_data/scripts/master_sheet_line.py | 161 ++++++++++++++++++ resources/tester/test_714_1_sheet_line.py | 62 +++++++ 5 files changed, 258 insertions(+), 3 deletions(-) create mode 100644 M01_MasterData/M01_MasterData_Store_Sheet.py create mode 100644 resources/master_data/scripts/master_sheet_line.py create mode 100644 resources/tester/test_714_1_sheet_line.py diff --git a/M01_MasterData/M01_MasterData_Router.py b/M01_MasterData/M01_MasterData_Router.py index 273f709ad..9a4be5281 100644 --- a/M01_MasterData/M01_MasterData_Router.py +++ b/M01_MasterData/M01_MasterData_Router.py @@ -16,6 +16,7 @@ from M01_MasterData import M01_MasterData_Store as store from M01_MasterData import M01_MasterData_Store_Combo as combo_store from M01_MasterData import M01_MasterData_Store_Flow as flow from M01_MasterData import M01_MasterData_Store_Make as make +from M01_MasterData import M01_MasterData_Store_Sheet as sheet import master_basis as mbs # noqa: E402 (`store` 가 scripts 를 길에 둠) @@ -269,7 +270,7 @@ def post_text(body: CalcBody) -> dict: if got is not None: return got with store.cm.mf.mp.choosing(body.고름): - return _call(store.text, body.key, body.inputs, body.row, body.file) + return _call(sheet.text, body.key, body.inputs, body.row, body.file) # 줄 덧칸(714-1) @router.post("/save") diff --git a/M01_MasterData/M01_MasterData_Store_Sheet.py b/M01_MasterData/M01_MasterData_Store_Sheet.py new file mode 100644 index 000000000..7d893f62b --- /dev/null +++ b/M01_MasterData/M01_MasterData_Store_Sheet.py @@ -0,0 +1,27 @@ +"""M01 `/text` 계산서 줄 덧칸(PLAN 714-1) — `store.text` 답에 줄마다 쓰는 입력 · 묶음 · 기준단위 · +환산 근거(`master_sheet_line.annotate` — M02 `/logic-lib/sheet` 와 한 벌) · 서버 단독(③) · 저장 안 함. +`M01_MasterData_Store.py` 는 700 줄 근처라 여기서 감쌈.""" + +from __future__ import annotations + +from M01_MasterData import M01_MasterData_Store as store + +mf = store.cm.mf +import master_sheet_line as msl # noqa: E402 — `store` 가 scripts 를 길에 둔 뒤 + + +def text(key: str, inputs: dict, row: dict | None = None, file: str | None = None) -> dict: + got = store.text(key, inputs, row, file) + if not isinstance(got, dict) or not got.get("ok"): + return got + given = {k: store._dec(v) for k, v in inputs.items()} + try: + if row is None: + master = store.loaded()[1] + else: # 저장 전 시험 — `store.text` 와 같은 메모리 사본 + files = store.cm.load(folder=store.FOLDER) + key = store._swap(files, str(row.get("키") or key), row, file) + master = mf.Master(files) + return msl.annotate(master, key, mf.fill_empty(master.logic(key), given), got) + except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError): + return got diff --git a/M02_MasterTemplete/M02_Logic_Standard.py b/M02_MasterTemplete/M02_Logic_Standard.py index df5b668b5..fd9959833 100644 --- a/M02_MasterTemplete/M02_Logic_Standard.py +++ b/M02_MasterTemplete/M02_Logic_Standard.py @@ -31,6 +31,9 @@ from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs from M02_MasterTemplete import M02_Logic_Settle as settle from M02_MasterTemplete import M02_Template_Layers as layers +mf = m01.cm.mf +import master_sheet_line as msl # noqa: E402 — 계산서 줄 덧칸(714-1) · scripts 길은 `m01` 이 둠 + SHAPE = 3 # 줄 꼴이 바뀌면 올림 — 옛 저장본을 안 씀 KIND = "m02_logic_standard" REGION = "지역" # 지역 = 자동값 한 벌(정함 8) @@ -331,7 +334,7 @@ def sheet(key: str, change: dict) -> dict: raise m01.StoreError(404, str(e)) from e given, ax = _given(whole, logic, change) filled = m01.cm.mf.fill_empty(logic, given) - got = m01.mtx.lines(files, key, filled) + got = msl.annotate(whole, key, filled, m01.mtx.lines(files, key, filled)) # 줄 덧칸(714-1) done = settle.settle(whole, key, filled) if "결과" not in done: # 돈 로직 — 줄마다 버림(B09 산근 규칙) settle.apply(got.get("groups") or [], m01.cm.mf.run(whole, key, filled), done) @@ -341,7 +344,7 @@ def sheet(key: str, change: dict) -> dict: "비목": g["비목"], "소계": g.get("소계"), "끝수": g.get("끝수") or "", - "줄": [{k: line.get(k) for k in ("이름", "글", "금액")} for line in g["줄"]], + "줄": [{k: line.get(k) for k in ("이름", "글", "금액", *msl.KEYS)} for line in g["줄"]], } for g in got.get("groups") or [] ] @@ -356,5 +359,6 @@ def sheet(key: str, change: dict) -> dict: "입력": given, "묶음": groups, "계": got.get("계"), + "기준단위": got.get("기준단위"), } ) diff --git a/resources/master_data/scripts/master_sheet_line.py b/resources/master_data/scripts/master_sheet_line.py new file mode 100644 index 000000000..1fe93a425 --- /dev/null +++ b/resources/master_data/scripts/master_sheet_line.py @@ -0,0 +1,161 @@ +# -*- coding: utf-8 -*- +"""계산서 줄 덧칸 — 「쓰는 입력」 · 묶음 · 기준단위 · 환산 근거(PLAN 714-1 · 요구서 4장 5 · 8). + +`master_text.lines` 답(비목 묶음 줄)에 줄마다 덧칸을 얹음 — M01 `/text` 와 M02 `/logic-lib/sheet` +두 길이 같은 함수(`annotate`)를 거쳐 같은 줄 꼴(`KEYS`)을 냄. 셈은 `master_formula.run` 한 벌. +- `쓰는입력` = 그 줄 값(수량 식 · 요소 · 덧줄 식)에 쓰인 로직 입력 이름 — 중간값을 거슬러 감 · + 덧줄이 부르는 비목 합 · 줄 금액은 거스르지 않음(율 식에 직접 쓴 입력만). +- `묶음` · `장비` = 712-1 `master_bundle`(엔진 줄 덧칸 그대로). +- `기준단위` = 그 줄 수량 식이 찾는 표의 기준(「10㎡」 · 712-3 `master_basis.basis`) · 답 머리 + `기준단위` = 로직 결과단위 분모(「10㎡」 · 여럿이면 「㎡ · ㎥」). +- `환산근거` = 표 기준배 ↔ 결과단위 배(「10㎡당 → 1㎡당 ÷10」) · 재료 품목 단위 ↔ 줄 단위 + (712-2 `master_unit.note`) · 없으면 「」. +줄 차례 맞춤 = `master_text.lines` 와 같은 차례(호표 줄마다 `_shares` 비목 · 뒤 덧줄). +""" + +from __future__ import annotations + +import re +from decimal import Decimal + +import master_basis as mbs +import master_bundle as mb +import master_formula as mf +import master_text as mtx +import master_unit as mu + +KEYS = ("쓰는입력", "묶음", "장비", "기준단위", "환산근거") +_HOLE = re.compile(r"\{([^{}]+)\}") +_ERRORS = (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) + + +def _names(expr) -> tuple[set[str], set[str]]: + """식 하나의 (이름 · 찾는 표 키) — `{입력}` 구멍 이름도 셈.""" + if isinstance(expr, dict): # 재료 고르기 조건 묶음 — 값 글의 구멍만 + return {h for v in expr.values() for h in _HOLE.findall(str(v))}, set() + if expr in (None, ""): + return set(), set() + try: + tree = mf.parse(expr) + except mf.FormulaError: + return set(_HOLE.findall(str(expr))), set() + names, tables = set(), set() + for node in mf.walk(tree): + if node[0] == "name": + names.add(node[1]) + elif node[0] in ("find", "logic", "ref"): + names |= set(_HOLE.findall(node[1])) + if node[0] == "find": + tables.add(node[1]) + return names, tables + + +def _closure(row: dict, exprs: list) -> tuple[list[str], list[str]]: + """식들이 쓰는 (입력 이름 · 표 키) — 중간값은 그 식으로 거슬러 감 · 입력 차례대로.""" + steps = {s["이름"]: s["식"] for s in row.get("중간", [])} + inputs = [s["이름"] for s in row.get("입력", [])] + seen, tables, todo = set(), [], list(exprs) + while todo: + names, found = _names(todo.pop()) + tables += [t for t in sorted(found) if t not in tables] + for name in names - seen: + seen.add(name) + if name in steps: + todo.append(steps[name]) + return [n for n in inputs if n in seen], tables + + +def _times(x: Decimal) -> str: + return format(x.normalize(), "f") + + +def _per_text(per: list[tuple[Decimal, str]]) -> str: + return " · ".join(dict.fromkeys(f"{_times(n) if n != 1 else ''}{u}" for n, u in per)) + + +def _basis(master, tables: list[str], env: dict) -> dict | None: + for ref in tables: + try: + got = mbs.basis(master.get(mf.fill(ref, env) if "{" in ref else ref)) + except (*_ERRORS, mf.EmptyInput): + continue + if got["갈래"] == "단위": + return got + return None + + +def _note(basis: dict | None, per: list, line: dict) -> str: + notes = [] + if basis and len(per) == 1: + times, one = per[0] + if mu.plain(basis["기준단위"]) == mu.plain(mbs.unit(one)) and basis["기준배"] != times: + ratio = basis["기준배"] / times + how = f"÷{_times(ratio)}" if ratio > 1 else f"×{_times(1 / ratio)}" + notes.append( + f"{_times(basis['기준배'])}{basis['기준단위']}당 → {_times(times)}{one}당 {how}" + ) + goods = line.get("품목") or {} + if goods.get("단위") and line.get("단위"): + notes.append(mu.note(goods["단위"], line["단위"])) + return " · ".join(n for n in notes if n) + + +def _fields(master, row, exprs, env, per, line: dict, bundle: tuple[str, bool] | None) -> dict: + uses, tables = _closure(row, exprs) + basis = _basis(master, tables, env) + out = { + "쓰는입력": uses, + "기준단위": f"{_times(basis['기준배']) if basis['기준배'] != 1 else ''}{basis['기준단위']}" + if basis + else None, + "환산근거": _note(basis, per, line), + } + if bundle: + out["묶음"] = bundle[0] + if bundle[1]: + out["장비"] = True + return out + + +def annotate(master, key: str, given: dict, answer: dict) -> dict: + """`master_text.lines` 답에 줄마다 `KEYS` 덧칸 · 머리 `기준단위` — 못 셈이면 답 그대로.""" + if not answer.get("ok") or "groups" not in answer: + return answer + row = master.logic(key) + per = mbs.result_per(row.get("결과단위")) + answer["기준단위"] = _per_text(per) or None + try: + env = mtx._env_of(master, row, given) + except _ERRORS: + env = {} + if "결과" in row: + for line in answer["groups"][0]["줄"]: + line.update(_fields(master, row, [row["결과"]["식"]], env, per, {}, None)) + return answer + try: + result = mf.run(master, key, given) + except _ERRORS: + result = None + slots = {g["비목"]: iter(g["줄"]) for g in answer["groups"]} + items = row.get("호표") or [] + for n, item in enumerate(items): + line = result["줄"][n] if result else {} + if result: + parts = [p for p, _ in mtx._shares(line)] + elif item.get("종류") == "로직" and "비목" not in item: + parts = list(mf.COST_ITEMS) + else: + parts = [item.get("비목")] + exprs = [item.get("수량"), item.get("요소")] + got = _fields(master, row, exprs, env, per, line, mb.bundle(master, item, env)) + for part in parts: + target = next(slots.get(part, iter(())), None) + if target is not None: + target.update(got) + for extra in row.get("덧줄") or []: + target = next(slots.get(extra["비목"], iter(())), None) + if target is not None: + target.update( + _fields(master, row, [extra["식"]], env, per, {}, mb.bundle(master, extra)) + ) + return answer diff --git a/resources/tester/test_714_1_sheet_line.py b/resources/tester/test_714_1_sheet_line.py new file mode 100644 index 000000000..f2266a8e5 --- /dev/null +++ b/resources/tester/test_714_1_sheet_line.py @@ -0,0 +1,62 @@ +"""714-1 지킴 — 계산서 줄 덧칸(쓰는 입력 · 묶음 · 기준단위 · 환산 근거) · M01 `/text` 와 +M02 `/logic-lib/sheet` 가 같은 줄 꼴(`master_sheet_line`).""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from M01_MasterData import M01_MasterData_Store as store # noqa: E402 +from M01_MasterData import M01_MasterData_Store_Sheet as sheet # noqa: E402 +from M02_MasterTemplete import M02_Logic_Standard as standard # noqa: E402 + +msl = sheet.msl + + +def _text(key: str) -> dict: + auto = {k: v for k, v in store.auto(key)["값"].items() if v is not None} + return sheet.text(key, auto) + + +def _lines(got: dict, groups: str = "groups") -> list[dict]: + return [line for g in got[groups] for line in g["줄"]] + + +def test_line_keys_and_bundle(): + got = _text("GF000103") # 구조물터파기 — 인력 + 굴착기 기계경비 줄 + assert got["ok"] and got["기준단위"] == "㎥" + lines = _lines(got) + assert all({"쓰는입력", "묶음", "기준단위", "환산근거"} <= set(line) for line in lines) + machine = [line for line in lines if line.get("장비")] + assert machine and all(line["묶음"] == "경비" for line in machine) + assert {"지역", "보정작업"} <= set(machine[0]["쓰는입력"]) + assert any(line["묶음"] == "인력" for line in lines) + + +def test_uses_follow_middle_values(): + """중간값을 거슬러 입력까지 — 돌쌓기 석공 품은 높이 · 쌓기 · 돌 입력을 씀.""" + lines = _lines(_text("GF000219")) + assert {"높이", "쌓기", "돌"} <= set(lines[0]["쓰는입력"]) + + +def test_basis_note_tenfold(): + """표 10㎡당 → 결과 원/㎡ 「÷10」 · 결과도 원/10㎡ 면 근거 없음.""" + one = _lines(_text("GC000037")) + assert one[0]["기준단위"] == "10㎡" and one[0]["환산근거"] == "10㎡당 → 1㎡당 ÷10" + ten = _text("GF000124") + assert ten["기준단위"] == "10㎡" and all(not x["환산근거"] for x in _lines(ten)) + + +def test_two_routes_same_shape(): + """M01 `/text` 와 M02 계산서 — 줄 차례 · 덧칸 값이 같음(금액 끝수만 다를 수 있음).""" + for key in ("GF000169", "GF000103", "GF000219"): + m01 = _lines(_text(key)) + m02 = _lines(standard.sheet(key, {}), "묶음") + assert len(m01) == len(m02) + for a, b in zip(m01, m02): + assert set(b) == {"이름", "글", "금액", *msl.KEYS} + assert a["이름"] == b["이름"] + assert [a.get(k) for k in msl.KEYS] == [b.get(k) for k in msl.KEYS] From b62a1d935646c9fd1f6011f5695b6ad24971e788 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 16:04:31 +0900 Subject: [PATCH 11/19] =?UTF-8?q?feat(715-3):=20=EC=9D=BC=EC=9C=84?= =?UTF-8?q?=EB=8C=80=EA=B0=80=20=EC=A4=84=20=EB=8D=AE=EA=B0=9C(=ED=8C=A8?= =?UTF-8?q?=EC=B9=98)=20=C2=B7=20SK=20=EC=B0=B8=EC=A1=B0=20=EC=A4=84=20?= =?UTF-8?q?=C2=B7=20[=EB=8B=A8=EA=B0=80=EC=82=B0=EC=B6=9C=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5]=20=EA=B8=B8=20=C2=B7=20=EC=9D=BC=EC=9C=84?= =?UTF-8?q?=EB=8C=80=EA=B0=80=20=EB=8B=A8=EC=9C=84=20=EB=B9=88=EC=B9=B8=20?= =?UTF-8?q?=EB=A7=89=EA=B8=B0=20=C2=B7=20=E3=80=8C1=20=EB=8B=A8=EC=9C=84?= =?UTF-8?q?=20=EB=8B=B9=E3=80=8D=20=EB=A8=B8=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 M02_Labor_Patch.py — 줄 칸 패치 = SK 꼴 {덮개, 뺀줄, 더한줄}(한 벌) · 로직 줄 · SK 참조 줄만 · 줄 고름 · 자재표시(차례)를 cid 로 바꿔 뺀줄 뒤 제자리 · 셈 = 이름 없는 SK 로 M02_Sangeun_Calc - M02_Labor_Doc — clean 이 패치 정리 · calc 로직 줄 · 참조 줄 패치 갈래(몇 줄 · 690 줄) - M02_Labor_Expand — 사전조합 안 줄 패치 갈래 · 머리 「1 ㎡ 당」 - M02_Labor_Ref — 사전조합 줄에 패치 남김 · 줄 있는 일위대가 단위 빈칸 400(빈 새 문서는 받음 — 화면 새로 단추가 단위 null) · 409 글 종류 이름 사전조합 → 일위대가 · labor → 구조물 조합 - POST /api/m02/layers/{층}/sangeun/from-line(층 쓰기 권한) — 새 SK + 바꿀 참조 줄 · 셈 답에 머리 - Labor_Calc.ts 짝은 그대로(settle 은 서버 기준을 받음 · 거울 시험 통과) - 낡은 시험 고침: test_m02_labor_ref · test_175_precombo_mats · test_204_2_b09_bill_library 의 줄 있는 사전조합에 단위(빈칸 막기) · 참조 줄 분모 = 그 사전조합 단위 - 새 시험 test_715_3_labor_patch.py(임시 storage) Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Labor_Doc.py | 8 +- M02_MasterTemplete/M02_Labor_Expand.py | 26 +++- M02_MasterTemplete/M02_Labor_Patch.py | 123 ++++++++++++++++ M02_MasterTemplete/M02_Labor_Ref.py | 12 +- M02_MasterTemplete/M02_Sangeun_Calc.py | 24 +++- M02_MasterTemplete/M02_Sangeun_Router.py | 21 ++- resources/tester/test_175_precombo_mats.py | 14 +- .../tester/test_204_2_b09_bill_library.py | 2 +- resources/tester/test_715_3_labor_patch.py | 134 ++++++++++++++++++ resources/tester/test_715_sangeun_store.py | 9 +- resources/tester/test_m02_labor_ref.py | 12 +- 11 files changed, 359 insertions(+), 26 deletions(-) create mode 100644 M02_MasterTemplete/M02_Labor_Patch.py create mode 100644 resources/tester/test_715_3_labor_patch.py diff --git a/M02_MasterTemplete/M02_Labor_Doc.py b/M02_MasterTemplete/M02_Labor_Doc.py index dff01a191..71d496dd0 100644 --- a/M02_MasterTemplete/M02_Labor_Doc.py +++ b/M02_MasterTemplete/M02_Labor_Doc.py @@ -42,6 +42,7 @@ from M01_MasterData import M01_MasterData_Store as m01 from M01_MasterData import M01_MasterData_Store_Combo as combo_store from M02_MasterTemplete import M02_Labor_Fixed as fixed from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs +from M02_MasterTemplete import M02_Labor_Patch as patch from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_Labor_Spec as spec from M02_MasterTemplete import M02_MasterTemplete_Store as store @@ -127,6 +128,7 @@ def clean(doc: Any) -> dict[str, Any]: "별칭": alias, "메모": str(memo) if memo else None, "수량": _qty(one.get("수량"), where), + **patch.ref_clean(one, ref, where), # 그 줄 덮개(715-3) } ) continue @@ -155,6 +157,7 @@ def clean(doc: Any) -> dict[str, Any]: **({"켜쌓기율": rate} if rate is not None else {}), **({"고름": picks} if picks else {}), **({"승격": True} if one.get("승격") is True else {}), + **patch.clean(one, where, whole, logic), # 그 줄 덮개(715-3) **( {"자재표시": shown} if (shown := _mat_marks(one.get("자재표시"), logic)) else {} ), @@ -512,7 +515,7 @@ def calc( lines = [] for one in doc.get("담은로직") or []: if _is_ref(one): - lines.append(ref_line(one["참조"], expand(one["참조"]))) + lines.append(ref_line(one["참조"], patch.ref(one, expand))) continue if fixed.is_fixed(one): lines.append(fixed.line(whole, one[fixed.FIXED])) @@ -525,6 +528,9 @@ def calc( "분모": pill.get("단위"), "배": pill.get("배") or 1, } + if one.get(patch.FIELD): # 줄 덮개(715-3) = 이름 없는 단가산출 셈 · 별도계상까지 + lines.append({**line, **patch.run(whole, one, read)}) + continue got = run_line(whole, code, one.get("입력값"), one.get("켜쌓기율"), one.get("고름")) lines.append({**line, **drop_apart(got, one.get("자재표시"))}) mats = [ diff --git a/M02_MasterTemplete/M02_Labor_Expand.py b/M02_MasterTemplete/M02_Labor_Expand.py index 35a73300c..10054f5c7 100644 --- a/M02_MasterTemplete/M02_Labor_Expand.py +++ b/M02_MasterTemplete/M02_Labor_Expand.py @@ -11,6 +11,7 @@ from decimal import Decimal from typing import Any from M02_MasterTemplete import M02_Labor_Fixed as fixed +from M02_MasterTemplete import M02_Labor_Patch as patch from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_Sangeun_Calc as calc @@ -81,10 +82,13 @@ 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("켜쌓기율"), one.get("고름") - ) - got = labor.drop_apart(got, one.get("자재표시")) # 별도계상 로직 자재 뺌(222-1) + if one.get(patch.FIELD): # 줄 덮개(715-3) = 이름 없는 단가산출 · 별도계상까지 + got = patch.run(self.whole, one, self.read) + else: + 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 { @@ -109,9 +113,10 @@ class Expand: inner, rows, why = [], [], None for one in lines: ref = one.get("참조") - if isinstance(ref, dict): - head, sub = {"참조": ref}, self(ref, trail) - elif one.get("로직"): # 로직 줄(51-11) = 1 단위 값(기준 ÷ 배 · 승격과 같음) + if isinstance(ref, dict): # 줄 덮개(715-3) 있는 SK 참조 = 그 줄만 다시 셈 + head = {"참조": ref} + sub = self(ref, trail) if not one.get(patch.FIELD) else patch.ref(one, self) + elif one.get("로직"): # 로직 줄(51-11) = 1 단위 값(기준 ÷ 배 · 승격과 같음) · 덮개 있음 head, sub = {"로직": one["로직"]}, self._logic(one, by_logic.get(one["로직"])) elif fixed.is_fixed(one): # 고정 줄(705-3) = 줄 단가 · 비율 줄 = 끝에(`ratio_row`) head, sub = {"고정": one["고정"]}, fixed.line(self.whole, one["고정"]) @@ -156,7 +161,14 @@ class Expand: return { "이름": doc.get("이름"), "단위": doc.get("단위"), + "머리": head_of(doc), "기준": None if why else labor._sum(rows), "까닭": why, "안": inner, } + + +def head_of(doc: dict) -> str | None: + """상세 머리 「1 ㎡ 당」(715-3 · 일위대가 = 기준단위 1 당) — 단위 빈 옛 문서는 None.""" + unit = str(doc.get("단위") or "").strip() + return f"1 {unit} 당" if unit else None diff --git a/M02_MasterTemplete/M02_Labor_Patch.py b/M02_MasterTemplete/M02_Labor_Patch.py new file mode 100644 index 000000000..aa0b3db09 --- /dev/null +++ b/M02_MasterTemplete/M02_Labor_Patch.py @@ -0,0 +1,123 @@ +"""일위대가 · 사전조합 줄 덮개(PLAN 715-3 · 요구서 내역12 4장 16 · 4-1 확정 5). + +줄 칸 `패치` = 단가산출(SK) 문서 꼴 `{덮개, 뺀줄, 더한줄}`(한 벌 · `M02_Sangeun_Doc`) — 그 줄에만 · +로직 줄(`{로직, 입력값, 고름, 자재표시, 패치}`) · 단가산출 참조 줄(`{참조: SK, 패치}`)만 받음 · +줄 `고름` · `자재표시`(호표 차례 열쇠)는 바탕 줄 차례 그대로 — 셈 때 cid 로 바꿔 뺀줄 뒤에도 제자리. +더한줄은 인력 · 재료 · 기계 · 로직 · 고정만(참조는 일위대가에 참조 줄로). +셈 = 줄을 이름 없는 SK 문서로(`as_doc`) → `M02_Sangeun_Calc` 한 벌 · 로직 줄 기준 = 결과단위 값(배는 +알약 · `settle` 그대로) · 참조 줄 = 1 단위 값. [단가산출로 저장] = `as_doc` + 이름 · 단위 → 층 SK. +""" + +from __future__ import annotations + +from typing import Any + +from M02_MasterTemplete import M02_Sangeun_Doc as sd + +FIELD = "패치" +KEEP = ("덮개", "뺀줄", "더한줄") +TEMP = "SK-000000" # 이름 없는 줄 단가산출 — 저장 안 함 + + +def _bad(text: str): + from M02_MasterTemplete import M02_MasterTemplete_Store as store + + return store.StoreError(400, text) + + +def clean(one: dict, where: str, whole: Any, row: dict | None, opened: list | None = None) -> dict: + """줄 칸 `패치` 정리 — 없거나 비면 `{}`(칸 안 둠) · 틀리면 400.""" + raw = one.get(FIELD) + if not raw: + return {} + if not isinstance(raw, dict): + raise _bad(f"{where} · 패치는 묶음") + if any("참조" in x for x in raw.get("더한줄") or [] if isinstance(x, dict)): + raise _bad(f"{where} · 줄 더한줄엔 참조 못 넣음 — 일위대가에 참조 줄로") + body = {k: raw.get(k) for k in KEEP} + try: + got = sd.clean( + {"키": TEMP, "이름": "줄", "단위": "식", **body, "열린입력": opened or []}, row, whole + ) + except sd.DocError as e: + raise _bad(f"{where} · {e}") from e + out = {k: got[k] for k in KEEP if got[k]} + return {FIELD: out} if out else {} + + +def ref_clean(one: dict, ref: dict, where: str) -> dict: + """단가산출 참조 줄 패치 — 그 SK 의 열린입력이면 품 배율만.""" + from M02_MasterTemplete import M02_Labor_Ref as refs + + if not one.get(FIELD): + return {} + if refs.kind_of(ref["키"]) != sd.KIND: + raise _bad(f"{where} · 줄 덮개는 로직 줄 · 단가산출 참조 줄만") + from M01_MasterData import M01_MasterData_Store as m01 + + doc = refs.read_ref(ref) or {} + return clean(one, where, m01.loaded()[1], None, doc.get("열린입력")) + + +def _keyed(value: Any, ids: list[str]) -> dict: + """차례 열쇠 → cid 열쇠(바탕 줄 차례).""" + out = {} + for n, v in (value or {}).items() if isinstance(value, dict) else (): + at = int(n) if str(n).isdigit() else -1 + if 0 <= at < len(ids): + out[ids[at]] = v + return out + + +def as_doc(one: dict, whole: Any, name: str = "줄", unit: str = "식") -> dict[str, Any]: + """일위대가 줄 → SK 문서(바탕 = 그 로직 · 그 SK) — 고름 · 자재표시는 cid 로.""" + body = {k: (one.get(FIELD) or {}).get(k) or ([] if k != "덮개" else {}) for k in KEEP} + head = {"키": TEMP, "이름": name, "단위": unit, **body} + if "참조" in one: + return {**head, "바탕": {"참조": one["참조"]}} + try: + ids = sd.line_ids(whole.logic(one["로직"])) + except Exception: # noqa: BLE001 — 없는 로직 = 셈이 까닭 + ids = [] + extra = {"켜쌓기율": one["켜쌓기율"]} if one.get("켜쌓기율") is not None else {} + return { + **head, + **extra, + "바탕": {"로직": one["로직"]}, + "입력값": dict(one.get("입력값") or {}), + "고름": _keyed(one.get("고름"), ids), + "자재표시": _keyed(one.get("자재표시"), ids), + } + + +def run(whole: Any, one: dict, read: Any = None) -> dict[str, Any]: + """패치 있는 로직 줄 기준(`run_line` 꼴 · 별도계상 뺀 뒤 · 기준 = 결과단위 값).""" + from M02_MasterTemplete import M02_Labor_Ref as refs + from M02_MasterTemplete import M02_Sangeun_Calc as calc + + try: + doc = sd.clean(as_doc(one, whole), None, whole) + return calc.run(doc, whole, None, read or refs.read_ref) + except sd.DocError as e: + return {"기준": None, "까닭": str(e)} + + +def ref(one: dict, expand: Any) -> dict[str, Any]: + """참조 줄 펼침 — 패치 있는 SK 참조는 그 줄만 다시 셈(캐시 안 씀) · 그 밖 `expand` 그대로.""" + from M02_MasterTemplete import M02_Sangeun_Calc as calc + + if not one.get(FIELD): + return expand(one["참조"]) + doc = as_doc(one, expand.whole) + base = expand.read(one["참조"]) or {} + doc = {**doc, "이름": base.get("이름") or doc["이름"], "단위": base.get("단위") or doc["단위"]} + return calc.unit(doc, expand.whole, expand, ()) + + +def to_sangeun(one: dict, name: str, unit: str, whole: Any) -> dict[str, Any]: + """[단가산출로 저장] 문서 — 줄의 바탕 · 입력값 · 고름 · 덮개 → 이름 붙인 SK(키는 서버).""" + if "로직" not in one and "참조" not in one: + raise _bad("로직 줄 · 단가산출 참조 줄만 단가산출로 저장") + doc = as_doc(one, whole, name, unit) + doc.pop("키") + return doc diff --git a/M02_MasterTemplete/M02_Labor_Ref.py b/M02_MasterTemplete/M02_Labor_Ref.py index ae47ac4be..7bf6f9943 100644 --- a/M02_MasterTemplete/M02_Labor_Ref.py +++ b/M02_MasterTemplete/M02_Labor_Ref.py @@ -163,7 +163,10 @@ def clean_doc(kind: str, doc: Any) -> dict[str, Any]: out: dict[str, Any] = {"종류": TITLE[kind], "키": key} if not name: raise _bad("사전조합 이름이 빔") - out.update(이름=name, 단위=unit or None, 담은로직=_combo_lines(doc, key)) + lines = _combo_lines(doc, key) + if not unit and (lines or doc.get("자재")): # 일위대가 = 기준단위 1 당(요구 27 · 715-3) · + raise _bad("일위대가 단위가 빔 — 1 단위 값의 단위") # 빈 새 문서(이름만)는 받음 + out.update(이름=name, 단위=unit or None, 담은로직=lines) out["자재"] = _combo_mats(doc, out["담은로직"]) out.update({k: str(doc.get(k) or "").strip() for k in ("구분", "상세구분")}) out["본뜬"] = doc["본뜬"] if isinstance(doc.get("본뜬"), dict) else None @@ -226,9 +229,9 @@ def _combo_lines(doc: dict, key: str) -> list[dict[str, Any]]: lines = [] for one in labor.clean({"담은로직": doc.get("담은로직") or []})["담은로직"]: - head = {k: one[k] for k in ("참조", "고정") if k in one} or {"로직": one["로직"]} + head = {k: one[k] for k in ("참조", "고정", "패치") if k in one} # 패치 = 줄 덮개(715-3) if "로직" in one: - head["입력값"] = one["입력값"] + head.update(로직=one["로직"], 입력값=one["입력값"]) head.update({k: one[k] for k in ("켜쌓기율", "고름", "자재표시") if k in one}) head.update({"별칭": one["별칭"]} if one["별칭"] else {}) # 실무 품명(324-3) lines.append({**head, "수량": one["수량"] or {"손": 1}, "메모": one["메모"]}) @@ -318,7 +321,8 @@ def users(ref: Ref) -> list[str]: """`ref` 를 담은 일위대가 · 사전조합 · 단가산출(바탕 · 더한줄) — 「어디 이름」 목록(모든 층 훑음 · 프로젝트 뺌).""" rid, out = ref_id(ref), [] - for kind, what in (("labor", "일위대가"), ("precombo", "사전조합"), ("sangeun", "단가산출")): + # 「일위대가」 = 사전조합 새 이름(내역 14 확정 1) · 구조물 조합 일위대가 = 「구조물 조합」 + for kind, what in (("labor", "구조물 조합"), ("precombo", "일위대가"), ("sangeun", "단가산출")): for at, path in layers.layer_files(kind): try: doc = json.loads(path.read_bytes()) diff --git a/M02_MasterTemplete/M02_Sangeun_Calc.py b/M02_MasterTemplete/M02_Sangeun_Calc.py index 0ea7b5e10..a6f0743fd 100644 --- a/M02_MasterTemplete/M02_Sangeun_Calc.py +++ b/M02_MasterTemplete/M02_Sangeun_Calc.py @@ -170,6 +170,10 @@ def unit(doc: dict, whole: Any, expand: Any, trail: tuple) -> dict[str, Any]: return {**head, "기준": got["1단위"], "까닭": why, "줄": lines, "짝잃음": got["짝잃음"]} +def head_of(doc: dict) -> str: + return f"1 {doc.get('단위')} 당" + + # ── 저장 ────────────────────────────────────────────────────────────── def _check_refs(doc: dict, here_: refs.Here) -> None: me = {"층": here_[0], "주인": here_[1], "키": doc["키"]} @@ -227,4 +231,22 @@ def calc_doc(doc: Any, given: dict | None = None) -> dict[str, Any]: got = run(clean, whole, given, trail=(me,)) except sd.DocError as e: raise _fail(e) from e - return labor._json(got) + return labor._json({**got, "머리": head_of(clean)}) + + +def from_line(line: Any, name: str, unit: str) -> dict[str, Any]: + """[단가산출로 저장](715-3) — 일위대가 줄(바탕 · 입력값 · 고름 · 줄 덮개) → 지금 층 새 SK · + 답 = `{이름: 새 키, 줄: 그 SK 참조 줄}`(일위대가 문서는 화면 [저장] 때 바뀜).""" + from M01_MasterData import M01_MasterData_Store as m01 + from M02_MasterTemplete import M02_Labor_Patch as patch + + if not isinstance(line, dict): + raise store.StoreError(400, "줄이 묶음 아님") + doc = patch.to_sangeun(line, name, unit, m01.loaded()[1]) + made = refs.create(sd.KIND, doc) + at = refs.here() + keep = {k: line[k] for k in ("별칭", "메모", "수량") if k in line} + return { + "이름": made["이름"], + "줄": {"참조": {"층": at[0], "주인": at[1], "키": made["이름"]}, **keep}, + } diff --git a/M02_MasterTemplete/M02_Sangeun_Router.py b/M02_MasterTemplete/M02_Sangeun_Router.py index bbc516045..72d0a415e 100644 --- a/M02_MasterTemplete/M02_Sangeun_Router.py +++ b/M02_MasterTemplete/M02_Sangeun_Router.py @@ -1,4 +1,5 @@ -"""M02 단가산출(SK) 셈 길(PLAN 715-1b) — `POST /api/m02/layers/{층}/sangeun/calc`. +"""M02 단가산출(SK) 셈 길(PLAN 715-1b) — `POST /api/m02/layers/{층}/sangeun/calc` · +[단가산출로 저장](715-3) `POST /api/m02/layers/{층}/sangeun/from-line`(층 쓰기 권한). 저장 안 함 · 로그인 사용자 · 층 읽기 권한(`Router_Items._root`) · 화면은 최신 응답만. 몸 = `문서`(저장 전 화면 문서) 또는 `키`(그 층 저장본) + `입력값`(열린입력 · 시험 값). @@ -37,6 +38,24 @@ def _calc(body: SangeunCalcBody) -> dict[str, Any]: return calc.calc_doc(doc, body.입력값) +class FromLineBody(BaseModel): + 줄: dict[str, Any] + 이름: str + 단위: str + + +@router.post("/layers/{layer}/sangeun/from-line") +def sangeun_from_line( + layer: Layer, + body: FromLineBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """[단가산출로 저장](715-3) — 일위대가 줄 → 그 층 새 단가산출 · 답 = 새 키 + 바꿀 참조 줄.""" + folder = _root(session, layer, owner, write=True) + return _call(folder, calc.from_line, body.줄, body.이름, body.단위) + + @router.post("/layers/{layer}/sangeun/calc") def calc_sangeun( layer: Layer, diff --git a/resources/tester/test_175_precombo_mats.py b/resources/tester/test_175_precombo_mats.py index 248695cfd..3688f2103 100644 --- a/resources/tester/test_175_precombo_mats.py +++ b/resources/tester/test_175_precombo_mats.py @@ -30,7 +30,7 @@ SYS = {"층": "system", "주인": None} def _save(folder, doc): with store.at(folder): - key = refs.create("precombo", {"이름": "자재 조합", **doc})["이름"] + key = refs.create("precombo", {"이름": "자재 조합", "단위": "㎡", **doc})["이름"] return key, json.loads((folder / "precombo" / f"{key}.json").read_bytes()) @@ -82,9 +82,9 @@ def _world_combo(world): # noqa: F811 # MAT = 직접계산(222-1 · 기본 별도계상이면 셈에서 빠짐) mats = [{"코드": MAT, "수량": {"손": 2}, "별도계상": "직접계산"}, {"코드": WIRE, "수량": None}] with store.at(world["system"]): - key = refs.create("precombo", {"이름": "자재 조합", "담은로직": lines, "자재": mats})[ - "이름" - ] + key = refs.create( + "precombo", {"이름": "자재 조합", "단위": "㎡", "담은로직": lines, "자재": mats} + )["이름"] stone = Decimal(str(labor.run_line(m01.loaded()[1], STONE, {})["기준"]["계"])) return key, stone + _price(MAT) * 2 @@ -114,7 +114,11 @@ def test_B08_참조_줄_재료에_사전조합_자재_손_곱(world): # noqa: F with store.at(world["system"]): outer = refs.create( "precombo", - {"이름": "겹", "담은로직": [{"참조": {**SYS, "키": key}, "수량": {"손": 3}}]}, + { + "이름": "겹", + "단위": "㎡", + "담은로직": [{"참조": {**SYS, "키": key}, "수량": {"손": 3}}], + }, )["이름"] got = _ref_mats(m01.loaded()[1], refs.read_ref, {**SYS, "키": outer}) mats = [(m["품목"]["키"], q) for m, q in got if m.get("자재")] diff --git a/resources/tester/test_204_2_b09_bill_library.py b/resources/tester/test_204_2_b09_bill_library.py index e0cfa1ebf..1a6a9a5c6 100644 --- a/resources/tester/test_204_2_b09_bill_library.py +++ b/resources/tester/test_204_2_b09_bill_library.py @@ -62,7 +62,7 @@ def world(tmp_path, monkeypatch): company = layers.company_dir(7) company.mkdir(parents=True) with store.at(company): - other = refs.create("precombo", {"이름": "회사 조합", "담은로직": lines[:1]})["이름"] + other = refs.create("precombo", {"이름": "회사 조합", "단위": "㎡", "담은로직": lines[:1]})["이름"] project = tmp_path / "storage" / "7" / "1" / "proj" project.mkdir(parents=True) return {"system": system, "key": key, "company": other, "root": project} diff --git a/resources/tester/test_715_3_labor_patch.py b/resources/tester/test_715_3_labor_patch.py new file mode 100644 index 000000000..23b87f316 --- /dev/null +++ b/resources/tester/test_715_3_labor_patch.py @@ -0,0 +1,134 @@ +# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 +"""일위대가 · 사전조합 줄 덮개 · [단가산출로 저장] · 단위 빈칸 막기(PLAN 715-3 · `M02_Labor_Patch`) — 임시 storage. + +줄 덮개 = 그 줄에만(단가산출 원본 그대로) · 값 = 같은 덮개 SK 값 = 손셈 · 줄 고름(차례)이 뺀줄 뒤 제자리 · +[단가산출로 저장] = 새 SK + 참조 줄 · 같은 값 · 일위대가 단위 빈칸 400 · 덮개 없는 줄 = 옛 값 그대로. +""" + +from decimal import Decimal + +import pytest + +from M01_MasterData import M01_MasterData_Store as m01 +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 M02_MasterTemplete import M02_Sangeun_Calc as calc +from resources.tester.test_m02_labor_ref import MINE, world # noqa: F401 — 같은 임시 층 + +PCT = "GC001109" # 원/㎡ · 인력 둘(LB000036 · LB000002) + 「노무비 * 3 / 100」 +TWO_MATS = "GC000565" # 원/m · 재료 줄 둘 +COVER = {"덮개": {"labor:LB000036#0": {"qty": 0.5}}} + + +def _whole(): + return m01.loaded()[1] + + +def _sc(folder, lines, unit="㎡"): + with store.at(folder): + return refs.create("precombo", {"이름": "조합", "단위": unit, "담은로직": lines})["이름"] + + +def _value(folder, key): + with store.at(folder): + return refs.Expand(_whole())({**MINE, "키": key}) + + +def test_logic_line_cover_in_precombo(world): + """사전조합 로직 줄 덮개 = 같은 덮개 SK 값 · 손셈(품 0.5 × 단가 + 둘째 줄 · 3 % 덧줄).""" + bare = labor.run_line(_whole(), PCT, {}) + sc = _sc(world["personal"], [{"로직": PCT, "패치": COVER}]) + with store.at(world["personal"]): + kept = store.read("precombo", sc)["문서"]["담은로직"][0] + assert kept["패치"] == COVER + got = _value(world["personal"], sc)["기준"] + labor_sum = Decimal("0.5") * Decimal(str(bare["줄"][0]["단가"])) + Decimal( + str(bare["줄"][1]["금액"]) + ) + assert got["노무비"] == pytest.approx(labor_sum) + assert got["경비"] == pytest.approx(labor_sum * 3 / 100, abs=1) + + +def test_line_pick_by_order_stays_after_drop(world): + """줄 고름 = 바탕 호표 차례(2) · 패치 뺀줄로 첫 재료 줄을 빼도 그 자재가 그 줄에.""" + import master_pick as mp + + whole = _whole() + row = whole.logic(TWO_MATS) + item = row["호표"][2] + want = mp.search(whole, item["요소"], {}, item.get("단위"))[1] + one = { + "로직": TWO_MATS, + "고름": {"2": want}, + "자재표시": {"2": {"별도계상": "직접계산"}}, + "패치": {"뺀줄": ["material:" + row["호표"][1]["이름"] + "#0"]}, + } + with store.at(world["personal"]): + doc = labor.clean({"담은로직": [one]}) + got = labor.calc(doc, {})["줄"][0] + picked = [x for x in got["줄"] if x.get("품목")] + assert [x["품목"]["키"] for x in picked] == [want] + + +def test_ref_line_cover_only_that_line(world): + """SK 참조 줄 덮개 = 그 줄만 · SK 원본 · 다른 줄 값 그대로.""" + with store.at(world["personal"]): + sk = refs.create( + "sangeun", {"이름": "밑", "단위": "㎡", "바탕": {"로직": PCT}, "더한줄": []} + )["이름"] + before = store.read("sangeun", sk)["판"] + ref = {**MINE, "키": sk} + sc = _sc( + world["personal"], + [{"참조": ref, "수량": {"손": 1}, "패치": COVER}, {"로직": "GC001111", "수량": {"손": 1}}], + ) + inner = _value(world["personal"], sc)["안"] + plain = _value(world["personal"], _sc(world["personal"], [{"참조": ref}]))["기준"] + covered = calc.calc_doc( + {"키": "SK-000999", "이름": "x", "단위": "㎡", "바탕": {"로직": PCT}, **COVER} + ) + assert inner[0]["기준"]["계"] == pytest.approx(Decimal(str(covered["1단위"]["계"]))) + assert inner[0]["기준"]["계"] != plain["계"] + with store.at(world["personal"]): + assert store.read("sangeun", sk)["판"] == before + + +def test_save_as_sangeun(world): + """[단가산출로 저장] — 새 SK(바탕 · 입력값 · 고름 cid · 덮개) · 참조 줄 · 같은 1 단위 값.""" + one = {"로직": PCT, "패치": COVER, "수량": {"손": 2}, "별칭": "내 줄"} + with store.at(world["personal"]): + line_value = labor.calc(labor.clean({"담은로직": [one]}), {})["줄"][0]["기준"] + got = calc.from_line(one, "줄로 만든", "㎡") + doc = store.read("sangeun", got["이름"])["문서"] + value = calc.calc_doc(doc)["기준"] + assert got["줄"] == {"참조": {**MINE, "키": got["이름"]}, "수량": {"손": 2}, "별칭": "내 줄"} + assert doc["바탕"] == {"로직": PCT} and doc["덮개"]["labor:LB000036#0"]["qty"] == 0.5 + assert value["계"] == pytest.approx(line_value["계"]) + + +def test_refusals(world): + with store.at(world["personal"]): + with pytest.raises(store.StoreError, match="단위가 빔"): + refs.create("precombo", {"이름": "x", "단위": " ", "담은로직": [{"로직": PCT}]}) + assert refs.create("precombo", {"이름": "빈 새 문서", "단위": None, "담은로직": []})["이름"] + bad = {"로직": PCT, "패치": {"더한줄": [{"참조": {**MINE, "키": "SK-000001"}, "수량": 1}]}} + with pytest.raises(store.StoreError, match="참조 못 넣음"): + labor.clean({"담은로직": [bad]}) + with pytest.raises(store.StoreError, match="수가 아님"): + labor.clean( + {"담은로직": [{"로직": PCT, "패치": {"덮개": {"labor:LB000036#0": {"qty": "식"}}}}]} + ) + sc = _sc(world["personal"], [{"로직": PCT}]) + with pytest.raises(store.StoreError, match="줄 덮개는"): + labor.clean({"담은로직": [{"참조": {**MINE, "키": sc}, "패치": COVER}]}) + + +def test_no_patch_same_value(world): + """덮개 없는 줄 = run_line 값 그대로(옛 셈 길).""" + sc = _sc(world["personal"], [{"로직": PCT, "패치": {}}]) + with store.at(world["personal"]): + assert "패치" not in store.read("precombo", sc)["문서"]["담은로직"][0] + assert _value(world["personal"], sc)["기준"]["계"] == pytest.approx( + labor.run_line(_whole(), PCT, {})["기준"]["계"] + ) diff --git a/resources/tester/test_715_sangeun_store.py b/resources/tester/test_715_sangeun_store.py index 753028f25..0debcf8d0 100644 --- a/resources/tester/test_715_sangeun_store.py +++ b/resources/tester/test_715_sangeun_store.py @@ -1,3 +1,4 @@ +# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 """M02 단가산출(SK) 서고 · 셈(PLAN 715-1b · 715-2 · `M02_Sangeun_Calc` · `M02_Labor_Ref`) — 임시 storage. 고친 줄 값 = 손셈 · 덧줄 % 엔진이 다시 셈 · 뺀줄 뒤 고름 제자리 · 짝 잃음 남김 · 원값 서버 · @@ -134,14 +135,18 @@ def test_precombo_holds_sangeun(world): with store.at(world["personal"]): sc = refs.create( "precombo", - {"이름": "조합", "담은로직": [{"참조": {**MINE, "키": sk}, "수량": {"손": 2}}]}, + { + "이름": "조합", + "단위": "㎡", + "담은로직": [{"참조": {**MINE, "키": sk}, "수량": {"손": 2}}], + }, )["이름"] got = labor.calc({"담은로직": [{"참조": {**MINE, "키": sc}, "수량": {"손": 1}}]}, {}) with pytest.raises(store.StoreError) as e: store.delete("sangeun", sk) one = _calc(world["personal"], sk)["1단위"]["계"] assert got["반영합"]["계"] == pytest.approx(one * 2, abs=1) - assert e.value.status == 409 and "사전조합 개인 7/42" in str(e.value.detail) + assert e.value.status == 409 and "일위대가 개인 7/42" in str(e.value.detail) def test_layer_copy_round_trip(world): diff --git a/resources/tester/test_m02_labor_ref.py b/resources/tester/test_m02_labor_ref.py index 7f96f4ad3..144178dde 100644 --- a/resources/tester/test_m02_labor_ref.py +++ b/resources/tester/test_m02_labor_ref.py @@ -70,7 +70,7 @@ def _promote(folder, code=TEN, values=None, **extra): def _combo(folder, lines, name="조합"): with store.at(folder): - return refs.create("precombo", {"이름": name, "담은로직": lines})["이름"] + return refs.create("precombo", {"이름": name, "단위": "㎡", "담은로직": lines})["이름"] def _read(folder, kind, key): @@ -225,11 +225,15 @@ def test_calc_는_참조를_펼침_승격은_기준_나누기_배_사전조합 one = got["줄"][0] want = Decimal(str(_base(TEN)["계"])) / 10 * 2 assert one["기준"]["계"] == pytest.approx(float(want)) - assert (one["분모"], one["배"], one["알약"]) == (None, 1, refs.ref_id(ref)) + assert (one["분모"], one["배"], one["알약"]) == ( + "㎡", + 1, + refs.ref_id(ref), + ) # 단위 = 그 사전조합 것 assert one["반영"]["계"] == pytest.approx(float(want * 5)) assert one["안"][0]["수량"] == 2 and one["안"][0]["참조"]["키"] == sp pills = labor.pills(doc) - assert pills == [{"키": refs.ref_id(ref), "이름": "둘", "단위": "", "로직": [], "참조": ref}] + assert pills == [{"키": refs.ref_id(ref), "이름": "둘", "단위": "㎡", "로직": [], "참조": ref}] def test_승격_줄_상세는_원_로직_키_표_사전조합은_안_줄만(world): @@ -364,7 +368,7 @@ def test_길_새로_복사_권한(world): } client = TestClient(app) url = "/api/m02/layers/{}/library/{}" - body = {"문서": {"이름": "가", "담은로직": [{"로직": TEN}]}} + body = {"문서": {"이름": "가", "단위": "㎡", "담은로직": [{"로직": TEN}]}} got = client.post(url.format("personal", "precombo"), json=body).json() assert got["이름"] == "SC-000001" assert client.post(url.format("system", "precombo"), json=body).status_code == 403 From 458569046566408ac6ea4cd809fab25851ee4cc2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 16:04:57 +0900 Subject: [PATCH 12/19] =?UTF-8?q?style(715-3):=20715=20=EC=8B=9C=ED=97=98?= =?UTF-8?q?=20=EB=A8=B8=EB=A6=AC=20=EA=B8=80=20=EC=A4=84=20=EA=B8=B8?= =?UTF-8?q?=EC=9D=B4=20=C2=B7=20fixture=20=EB=B0=9B=EC=95=84=20=EC=94=80?= =?UTF-8?q?=20noqa?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- resources/tester/test_715_3_labor_patch.py | 7 ++++--- resources/tester/test_715_sangeun_store.py | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/resources/tester/test_715_3_labor_patch.py b/resources/tester/test_715_3_labor_patch.py index 23b87f316..b6b4de67a 100644 --- a/resources/tester/test_715_3_labor_patch.py +++ b/resources/tester/test_715_3_labor_patch.py @@ -1,8 +1,9 @@ # ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 -"""일위대가 · 사전조합 줄 덮개 · [단가산출로 저장] · 단위 빈칸 막기(PLAN 715-3 · `M02_Labor_Patch`) — 임시 storage. +"""일위대가 · 사전조합 줄 덮개 · [단가산출로 저장] · 단위 빈칸 막기(PLAN 715-3) — 임시 storage. -줄 덮개 = 그 줄에만(단가산출 원본 그대로) · 값 = 같은 덮개 SK 값 = 손셈 · 줄 고름(차례)이 뺀줄 뒤 제자리 · -[단가산출로 저장] = 새 SK + 참조 줄 · 같은 값 · 일위대가 단위 빈칸 400 · 덮개 없는 줄 = 옛 값 그대로. +줄 덮개 = 그 줄에만(단가산출 원본 그대로) · 값 = 같은 덮개 SK 값 = 손셈 · +줄 고름(차례)이 뺀줄 뒤 제자리 · +[단가산출로 저장] = 새 SK + 참조 줄 · 같은 값 · 단위 빈칸 400 · 덮개 없는 줄 = 옛 값. """ from decimal import Decimal diff --git a/resources/tester/test_715_sangeun_store.py b/resources/tester/test_715_sangeun_store.py index 0debcf8d0..05be42e2d 100644 --- a/resources/tester/test_715_sangeun_store.py +++ b/resources/tester/test_715_sangeun_store.py @@ -1,5 +1,5 @@ # ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 -"""M02 단가산출(SK) 서고 · 셈(PLAN 715-1b · 715-2 · `M02_Sangeun_Calc` · `M02_Labor_Ref`) — 임시 storage. +"""M02 단가산출(SK) 서고 · 셈(PLAN 715-1b · 715-2) — 임시 storage. 고친 줄 값 = 손셈 · 덧줄 % 엔진이 다시 셈 · 뺀줄 뒤 고름 제자리 · 짝 잃음 남김 · 원값 서버 · 순환 422 · 쓰는 곳 지우기 409 · 사전조합이 SK 를 담음 · 층 복사 왕복 같은 값. @@ -47,7 +47,7 @@ def _bare(code): def test_qty_cover_hand_and_pct_recomputed(world): - """품 덮개 = 손셈 · 「노무비 * 3 / 100」 덧줄이 덮인 노무비에서 다시 셈 · 원값 = 서버가 그때 M01 값.""" + """품 덮개 = 손셈 · 「노무비 * 3 / 100」 덧줄 다시 셈 · 원값 = 서버가 셈.""" bare = _bare(PCT) first, second = bare["줄"][0], bare["줄"][1] key = _sk( From fdd781ca465315d323e55d5e9225d7576cdb9ee9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 16:04:58 +0900 Subject: [PATCH 13/19] auto: 2026-11-01 16:04 (ESD_LAPTOP) --- resources/tester/test_715_sangeun_engine.py | 84 +++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 resources/tester/test_715_sangeun_engine.py diff --git a/resources/tester/test_715_sangeun_engine.py b/resources/tester/test_715_sangeun_engine.py new file mode 100644 index 000000000..e0c01a8e3 --- /dev/null +++ b/resources/tester/test_715_sangeun_engine.py @@ -0,0 +1,84 @@ +# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 +"""M02 단가산출(SK) 엔진 입력 덧칸(PLAN 715-1b · `master_pick` `단가` · `값묶음`) — 고친 줄 값 = 손셈. + +줄 단가 덮개 = 손 단가 · 로직 줄 노 · 재 · 경 덮개 = 값묶음(일부면 엔진 값 위) · 덧줄 % 가 덮인 값에서 +다시 셈 · 더한줄 참조(SK · SC) · 고정 = 먼저 셈한 1 단위 묶음. 덧칸 없는 줄 = 엔진 값 그대로. +""" + +from decimal import Decimal + +import pytest + +from M01_MasterData import M01_MasterData_Store as m01 +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 M02_MasterTemplete import M02_Sangeun_Calc as calc +from resources.tester.test_m02_labor_ref import MINE, world # noqa: F401 — 같은 임시 층 + +PCT = "GC001109" # 원/㎡ · 인력 둘 + 「노무비 * 3 / 100」 +SUB = "GC000999" # 원/주 · 인력 둘 + 로직 줄(GC000275) + + +def _whole(): + return m01.loaded()[1] + + +def _run(**doc): + from M02_MasterTemplete import M02_Sangeun_Doc as sd + + whole = _whole() + clean = sd.clean({"키": "SK-000001", "이름": "시험", "단위": "㎡", **doc}, None, whole) + return calc.run(clean, whole) + + +def test_price_cover_is_hand_price_and_pct_follows(): + bare = labor.run_line(_whole(), PCT, {}) + first, second = bare["줄"][0], bare["줄"][1] + got = _run(바탕={"로직": PCT}, 덮개={"labor:LB000036#0": {"labor": 200000}}) + line = got["줄"][0] + assert line["단가"] == 200000 and line["출처"] == "손 단가" + want = Decimal(str(first["수량"])) * 200000 + Decimal(str(second["금액"])) + assert got["기준"]["노무비"] == pytest.approx(float(want)) + assert got["기준"]["경비"] == pytest.approx(float(want * 3 / 100), abs=1) + + +def test_bundle_cover_on_logic_line_partial(): + bare = labor.run_line(_whole(), SUB, {}) + sub = bare["줄"][2] + got = _run(바탕={"로직": SUB}, 덮개={"logic:GC000275#0": {"expense": 1000}}) + line = got["줄"][2] + qty = Decimal(str(sub["수량"])) + assert line["비목"]["경비"] == pytest.approx(float(qty * 1000)) + assert line["비목"]["노무비"] == pytest.approx(float(sub["비목"]["노무비"])) # 안 덮은 칸 = 엔진 값 + + +def test_untouched_lines_same_as_engine(): + bare = labor.run_line(_whole(), SUB, {}) + got = _run(바탕={"로직": SUB}) + assert got["기준"]["계"] == pytest.approx(float(bare["기준"]["계"])) + + +def test_added_ref_and_fixed_lines(world): + """더한줄 = SK 참조 2 · 고정 줄 1 — 값 = 2 × 그 SK 1 단위 + 고정 단가.""" + with store.at(world["personal"]): + sk = refs.create("sangeun", {"이름": "밑", "단위": "㎡", "바탕": {"로직": PCT}, "더한줄": []})[ + "이름" + ] + unit = calc.calc_doc(store.read("sangeun", sk)["문서"])["1단위"]["계"] + top = refs.create( + "sangeun", + { + "이름": "위", + "단위": "식", + "바탕": None, + "더한줄": [ + {"참조": {**MINE, "키": sk}, "수량": 2}, + {"고정": {"이름": "운반", "단위": "식", "단가": {"경비": 5000}}, "수량": 1}, + ], + }, + )["이름"] + got = calc.calc_doc(store.read("sangeun", top)["문서"]) + assert got["기준"]["계"] == pytest.approx(unit * 2 + 5000, abs=1) + expense = labor.run_line(_whole(), PCT, {})["기준"]["경비"] + assert got["기준"]["경비"] == pytest.approx(5000 + 2 * float(expense), abs=1) From dfa72ac29248562e44beff3b369cecc8644865bb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 16:05:18 +0900 Subject: [PATCH 14/19] =?UTF-8?q?test(715-1b):=20auto=20=EC=BB=A4=EB=B0=8B?= =?UTF-8?q?=EC=97=90=20=EC=93=B8=EB=A0=A4=20=EC=98=AC=EB=9D=BC=EA=B0=84=20?= =?UTF-8?q?=EC=97=94=EC=A7=84=20=EB=8D=A7=EC=B9=B8=20=EC=8B=9C=ED=97=98?= =?UTF-8?q?=EC=9D=84=20=EB=BA=8C=20=E2=80=94=20=EC=97=94=EC=A7=84=20?= =?UTF-8?q?=EB=8D=A7=EC=B9=B8(sub3=20712=20=EB=92=A4)=20=EC=A0=84=EC=97=94?= =?UTF-8?q?=20=EC=8B=A4=ED=8C=A8=20=C2=B7=20715-1b=20=EB=95=8C=20=EB=8B=A4?= =?UTF-8?q?=EC=8B=9C=20=EB=84=A3=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- resources/tester/test_715_sangeun_engine.py | 84 --------------------- 1 file changed, 84 deletions(-) delete mode 100644 resources/tester/test_715_sangeun_engine.py diff --git a/resources/tester/test_715_sangeun_engine.py b/resources/tester/test_715_sangeun_engine.py deleted file mode 100644 index e0c01a8e3..000000000 --- a/resources/tester/test_715_sangeun_engine.py +++ /dev/null @@ -1,84 +0,0 @@ -# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 -"""M02 단가산출(SK) 엔진 입력 덧칸(PLAN 715-1b · `master_pick` `단가` · `값묶음`) — 고친 줄 값 = 손셈. - -줄 단가 덮개 = 손 단가 · 로직 줄 노 · 재 · 경 덮개 = 값묶음(일부면 엔진 값 위) · 덧줄 % 가 덮인 값에서 -다시 셈 · 더한줄 참조(SK · SC) · 고정 = 먼저 셈한 1 단위 묶음. 덧칸 없는 줄 = 엔진 값 그대로. -""" - -from decimal import Decimal - -import pytest - -from M01_MasterData import M01_MasterData_Store as m01 -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 M02_MasterTemplete import M02_Sangeun_Calc as calc -from resources.tester.test_m02_labor_ref import MINE, world # noqa: F401 — 같은 임시 층 - -PCT = "GC001109" # 원/㎡ · 인력 둘 + 「노무비 * 3 / 100」 -SUB = "GC000999" # 원/주 · 인력 둘 + 로직 줄(GC000275) - - -def _whole(): - return m01.loaded()[1] - - -def _run(**doc): - from M02_MasterTemplete import M02_Sangeun_Doc as sd - - whole = _whole() - clean = sd.clean({"키": "SK-000001", "이름": "시험", "단위": "㎡", **doc}, None, whole) - return calc.run(clean, whole) - - -def test_price_cover_is_hand_price_and_pct_follows(): - bare = labor.run_line(_whole(), PCT, {}) - first, second = bare["줄"][0], bare["줄"][1] - got = _run(바탕={"로직": PCT}, 덮개={"labor:LB000036#0": {"labor": 200000}}) - line = got["줄"][0] - assert line["단가"] == 200000 and line["출처"] == "손 단가" - want = Decimal(str(first["수량"])) * 200000 + Decimal(str(second["금액"])) - assert got["기준"]["노무비"] == pytest.approx(float(want)) - assert got["기준"]["경비"] == pytest.approx(float(want * 3 / 100), abs=1) - - -def test_bundle_cover_on_logic_line_partial(): - bare = labor.run_line(_whole(), SUB, {}) - sub = bare["줄"][2] - got = _run(바탕={"로직": SUB}, 덮개={"logic:GC000275#0": {"expense": 1000}}) - line = got["줄"][2] - qty = Decimal(str(sub["수량"])) - assert line["비목"]["경비"] == pytest.approx(float(qty * 1000)) - assert line["비목"]["노무비"] == pytest.approx(float(sub["비목"]["노무비"])) # 안 덮은 칸 = 엔진 값 - - -def test_untouched_lines_same_as_engine(): - bare = labor.run_line(_whole(), SUB, {}) - got = _run(바탕={"로직": SUB}) - assert got["기준"]["계"] == pytest.approx(float(bare["기준"]["계"])) - - -def test_added_ref_and_fixed_lines(world): - """더한줄 = SK 참조 2 · 고정 줄 1 — 값 = 2 × 그 SK 1 단위 + 고정 단가.""" - with store.at(world["personal"]): - sk = refs.create("sangeun", {"이름": "밑", "단위": "㎡", "바탕": {"로직": PCT}, "더한줄": []})[ - "이름" - ] - unit = calc.calc_doc(store.read("sangeun", sk)["문서"])["1단위"]["계"] - top = refs.create( - "sangeun", - { - "이름": "위", - "단위": "식", - "바탕": None, - "더한줄": [ - {"참조": {**MINE, "키": sk}, "수량": 2}, - {"고정": {"이름": "운반", "단위": "식", "단가": {"경비": 5000}}, "수량": 1}, - ], - }, - )["이름"] - got = calc.calc_doc(store.read("sangeun", top)["문서"]) - assert got["기준"]["계"] == pytest.approx(unit * 2 + 5000, abs=1) - expense = labor.run_line(_whole(), PCT, {})["기준"]["경비"] - assert got["기준"]["경비"] == pytest.approx(5000 + 2 * float(expense), abs=1) From ea1f749d992493c6fd9104d8c6abc8e343a7a535 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 16:05:26 +0900 Subject: [PATCH 15/19] =?UTF-8?q?test(717-2a):=20=EB=82=A1=EC=9D=80=20?= =?UTF-8?q?=EC=8B=9C=ED=97=98=20339=20=EA=B3=A0=EC=B9=A8=20=E2=80=94=20?= =?UTF-8?q?=EB=B6=88=EB=9F=AC=EC=98=A4=EA=B8=B0=20=EB=81=9D=20=EC=83=88?= =?UTF-8?q?=EB=A1=9C=EA=B3=A0=EC=B9=A8=EC=9D=B4=20Promise.all=20=EB=91=90?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D(=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20?= =?UTF-8?q?=C2=B7=20=EC=83=81=EC=9D=80)=EC=9C=BC=EB=A1=9C=20=EB=B0=94?= =?UTF-8?q?=EB=80=90=20=EA=BC=B4=EC=97=90=20=EB=A7=9E=EC=B6=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- resources/tester/test_m02_hopyo_screen.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/resources/tester/test_m02_hopyo_screen.py b/resources/tester/test_m02_hopyo_screen.py index 4bc8efb8f..909b26079 100644 --- a/resources/tester/test_m02_hopyo_screen.py +++ b/resources/tester/test_m02_hopyo_screen.py @@ -443,7 +443,8 @@ def test_확인_창_손질_339(): assert make.index("document.dispatchEvent(new Event(HOPYO_BUILT));") < make.index("opt.onBuilt") side = _text("M02_MasterTemplete_UI_Side.ts") assert "document.addEventListener(HOPYO_BUILT, onBuilt);" in side - assert "void precombo.refresh()" in side.split("const onBuilt")[1].split(";")[0] + onb = side.split("const onBuilt")[1].split(";")[0] + assert "precombo.refresh()" in onb and "sangeun.refresh()" in onb # 717-2a — 둘 다 새로고침 sheet = _text("M02_MasterTemplete_Hopyo_Sheet.ts") assert "poll(file);" in sheet and "poll(next);" in sheet and "POLL_MS" in sheet assert "if (ok) document.dispatchEvent(new Event(HOPYO_BUILT));" in sheet From 9d72d80b5361a70c3221df0f247e3d7a206fca82 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 16:06:31 +0900 Subject: [PATCH 16/19] =?UTF-8?q?feat(717-4):=20=EC=9D=BC=EC=9C=84?= =?UTF-8?q?=EB=8C=80=EA=B0=80=20[=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80]?= =?UTF-8?q?=20=E3=80=8C=EB=8B=A8=EA=B0=80=EC=82=B0=EC=B6=9C=E3=80=8D=20?= =?UTF-8?q?=ED=83=AD=20=C2=B7=20B09=20=EC=9E=AC=EB=A3=8C=20=EC=B0=BD?= =?UTF-8?q?=EA=B3=A0=20=EB=8B=A8=EA=B0=80=EC=82=B0=EC=B6=9C=20=EC=B6=9C?= =?UTF-8?q?=EC=B2=98=20=C2=B7=20=EB=8B=A8=EA=B0=80=EC=82=B0=EC=B6=9C=20?= =?UTF-8?q?=EB=8D=94=ED=95=9C=EC=A4=84=20=EA=B3=A0=EB=A5=B4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 M02_MasterTemplete_Sangeun_Pick — listSangeun = 위 층 SK 목록(마스터 › 회사 › 개인 · 금액 = 층 목록 기준금액) · openSangeunLinePicker = SK 더한줄 고르기(M01 로직 · 일위대가 · 단가산출 · M01 재료 → 715 더한줄 꼴) - Labor_Pick — lib.sangeun 이면 탭 [기준 목록 | 단가산출] · 단가산출 탭 = 기준 목록 표 꼴(출처 = 단가산출 · 층) · 줄 누름 = 참조 {층, 주인, SK 키} - withHopyo(일위대가 [로직 추가]) 에 sangeun 켬 · B09 단가연결 창은 그대로(SK 공사 연결 = 719-1 뒤) - B09 재료 창고 출처 탭 「단가산출」 — SK 목록 · 연결은 알림만 - locale_m2 키 넷 · 시험 test_717_4_sangeun_pick Co-Authored-By: Claude Opus 5.5 --- B09_Estimation/B09_Estimation_UI_Store.ts | 30 ++++- .../M02_MasterTemplete_Hopyo_Pick.ts | 1 + .../M02_MasterTemplete_Labor_Pick.ts | 92 ++++++++++++++-- .../M02_MasterTemplete_Sangeun_Pick.ts | 104 ++++++++++++++++++ resources/tester/test_717_4_sangeun_pick.py | 72 ++++++++++++ ui_template/ui_template_locale_m2.ts | 4 + 6 files changed, 290 insertions(+), 13 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Sangeun_Pick.ts create mode 100644 resources/tester/test_717_4_sangeun_pick.py diff --git a/B09_Estimation/B09_Estimation_UI_Store.ts b/B09_Estimation/B09_Estimation_UI_Store.ts index 81c62c5c6..893fc0ffc 100644 --- a/B09_Estimation/B09_Estimation_UI_Store.ts +++ b/B09_Estimation/B09_Estimation_UI_Store.ts @@ -1,6 +1,7 @@ /* ============================================================================= * B09_Estimation_UI_Store.ts - * B09 설계내역서 오른쪽 재료 창고(PLAN 703-1) — 찾기 하나 + 출처 탭(이 공사 · 내 것 · 회사 · 품셈 · 지난 공사). + * B09 설계내역서 오른쪽 재료 창고(PLAN 703-1) — 찾기 하나 + 출처 탭(이 공사 · 내 것 · 회사 · 단가산출 · 품셈 · 지난 공사). + * 단가산출(717-4) = 위 층 M02 SK 목록(`Sangeun_Pick` · 마스터 › 회사 › 개인) · 공사 연결은 아직 없음(목록만) * * 두 번 누름 · Enter = 고른 내역 줄(`…_Bill_Level` 고름)에 단가연결 · 끌어 놓기 = 놓은 줄에 단가연결 · * 우클릭 = [고른 줄에 연결] · 품셈 = [계산서 보기](704-2 `Labor_Sheet` · [담기] = 연결) @@ -31,17 +32,19 @@ import { } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api"; import { openStandardSheet } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Sheet"; import { ownOwner } from "../M02_MasterTemplete/M02_MasterTemplete_Layer"; +import { listSangeun } from "../M02_MasterTemplete/M02_MasterTemplete_Sangeun_Pick"; import type { Row } from "./B09_Estimation_Calc_Base"; import type { TabContext } from "./B09_Estimation_UI_Tabs"; import { applyAt, openMenuAt } from "./B09_Estimation_UI_Tabs_Bill_Edit"; import { askInputs, importLib, type ApplyRef } from "./B09_Estimation_UI_Tabs_Bill_Link"; import { pickedKey } from "./B09_Estimation_UI_Tabs_Bill_Level"; -type Source = "here" | "mine" | "company" | "poomsem" | "past"; +type Source = "here" | "mine" | "company" | "sangeun" | "poomsem" | "past"; const SOURCES: Array<[Source, string]> = [ ["here", "이 공사"], ["mine", "내 것"], ["company", "회사"], + ["sangeun", "단가산출"], ["poomsem", "품셈"], ["past", "지난 공사"], ]; @@ -115,6 +118,28 @@ async function layerItems(layer: "personal" | "company"): Promise { ]; } +/** 단가산출(717-4) — 위 층 SK(마스터 › 회사 › 개인) · 공사 연결 길 없음 = 알림만 */ +async function sangeunItems(): Promise { + const user = await fetchSessionUser().catch(() => null); + const own = (["company", "personal"] as const) + .map((층) => ({ 층, 주인: ownOwner(층, user) })) + .filter((x) => x.주인); + const layerText: Record = { system: "마스터", company: "회사", personal: "개인" }; + return (await listSangeun([{ 층: "system", 주인: null }, ...own])).map((r) => { + const name = r.제목 || r.이름; + const sub = [r.구분, r.상세구분].filter(Boolean).join(" "); + const money = r.기준금액 === null ? "" : `${Math.round(r.기준금액).toLocaleString("ko-KR")}원`; + return { + tag: `단가산출 · ${layerText[r.ref.층] ?? r.ref.층}`, + name, + sub: [sub, money].filter(Boolean).join(" · "), + unit: r.단위 ?? "", + hit: has(`단가산출 ${name} ${sub}`), + go: () => showToast("단가산출은 아직 공사에 바로 연결하지 않음 — 목록 보기만", "info"), + }; + }); +} + /** 넣을 값 창 — 대표 규격 = 계산서(704-2)가 셈에 쓴 입력 전부(목록 `바꿈` 은 기본과 다른 것만 · * 기본을 다르게 보는 칸이 빠짐 — 석축헐기 뒷길이 60 · 0) · 못 받으면 `바꿈` */ function askStandard(apply: ApplyRef, r: StandardRow): void { @@ -200,6 +225,7 @@ async function pastItems(): Promise { const LOAD: Record, () => Promise> = { mine: () => layerItems("personal"), company: () => layerItems("company"), + sangeun: sangeunItems, poomsem: poomsemItems, past: pastItems, }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts index a24be99b8..8d82f7339 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts @@ -22,6 +22,7 @@ export function withHopyo(lib: LibraryPick): LibraryPick { const scope = { layer: lib.layer, owner: lib.owner }; return { ...lib, + sangeun: true, // 717-4 — 일위대가 [로직 추가] 「단가산출」 탭 kindFilter: lib.kindFilter ?? [ { value: "logic", text: tc("Kind_Logic") }, { value: "precombo", text: tc("Kind_Combo") }, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts index 2b675b661..36381ec7c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts @@ -9,6 +9,8 @@ * 704-1 단가산출 줄 = 기준 목록 표(품명 · 규격 · 단위 · 금액 · 출처 · 대표 규격 펼침 · 공종 거름 · * 실무 낱말 = 서버가 별칭에 덧붙임) · 금액 = 서버 M01 엔진(`/logic-lib/standard`) · 오기 전엔 로직 줄만 · * 줄 누름 = 계산서 보기(704-2 · `Labor_Sheet`) → [담기] = 지금 담기 동작(`done(로직)`) + * 717-4 `lib.sangeun` = 탭 [기준 목록 | 단가산출] · 단가산출 = 위 층 SK(마스터 › 회사 › 개인 · `Sangeun_Pick`)를 + * 같은 표 꼴(출처 = 단가산출 · 층) · 줄 누름 = `lib.pick` 참조(SK 키) * ========================================================================== */ import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements"; @@ -37,6 +39,8 @@ 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 { createTabs } from "@ui/ui_template_tabs"; +import { listSangeun, type SangeunRow } from "./M02_MasterTemplete_Sangeun_Pick"; export { withHopyo } from "./M02_MasterTemplete_Hopyo_Pick"; @@ -71,6 +75,8 @@ export interface LibraryPick { more?: () => Promise; /** 종류 고르기 칸(251 · 값 logic · precombo · 덧붙인 줄 kind) */ kindFilter?: Array<{ value: string; text: string }>; + /** 717-4 「단가산출」 탭(M02 SK · 위 층 모두) — 고르면 `pick` 참조 */ + sangeun?: boolean; } /** 고를 수 있는 층 — 위 층만(마스터 → 마스터 · 회사 → 그 회사 · 마스터 · 개인 → 그 개인 · 그 회사 · 마스터) */ @@ -107,6 +113,9 @@ export function openLogicPicker( const detail = createSelectField({ options: opts([]), compact: true, label: tc("Detail") }); detail.select.disabled = true; let close = (): void => {}; + let rowPick: Array<() => void> = []; // 표 줄 누름 — 탭마다 다시 셈 + let tab: "std" | "sk" = "std"; + let skRows: SangeunRow[] | null = null; // 717-4 — 오기 전 = null /* 한 목록 — 구분(단가산출 · 일위조합) 칸 · 층 단추는 서고 줄(사전조합)의 층 */ const layerBar = el("div", { className: "ui-btn-group", attrs: { "data-pick-layers": "" } }); @@ -141,15 +150,7 @@ export function openLogicPicker( { key: "출처", label: tc("Std_Source"), width: 150 }, ], rows: [], - onRowClick: (i) => { - const row = listed[i]; - const logic = all.find((x) => x.키 === row?.키); - if (row && logic) - openStandardSheet(row, () => { - close(); - done(logic); - }); - }, + onRowClick: (i) => rowPick[i]?.(), }); /** 기준 목록이 오기 전 · 못 받으면 — 로직 하나 = 줄 하나(금액 빈칸) */ const plain = (x: LogicSummary): StandardRow => ({ @@ -170,6 +171,7 @@ export function openLogicPicker( const find = (): void => { const q = finder.search.value.trim().toLowerCase(); + if (tab === "sk") return findSangeun(q); const rows = std ?? all.map(plain); const found = rows.filter( (x) => @@ -200,6 +202,14 @@ export function openLogicPicker( ? `${shown} / ${total}` : tc("Pick_None"); listed = found; + rowPick = listed.map((row) => () => { + const logic = all.find((x) => x.키 === row.키); + if (logic) + openStandardSheet(row, () => { + close(); + done(logic); + }); + }); table.root.hidden = !!lib?.libOnly; table.setRows( found.map((x) => ({ @@ -240,6 +250,32 @@ export function openLogicPicker( })), ]); }; + /** 717-4 단가산출 탭 — 같은 표 꼴 · 찾기 = 이름 · 제목 · 구분 */ + const findSangeun = (q: string): void => { + const rows = (skRows ?? []).filter( + (x) => !q || [x.이름, x.제목, x.구분, x.상세구분].join(" ").toLowerCase().includes(q), + ); + finder.hint.textContent = !skRows + ? tc("Loading") + : rows.length + ? `${rows.length} / ${skRows.length}` + : tc("Pick_None"); + rowPick = rows.map((x) => () => { + close(); + lib?.pick(x.ref, x.제목 || x.이름); + }); + table.root.hidden = false; + table.setRows( + rows.map((x) => ({ + 품명: x.제목 || x.이름, + 규격: [x.구분, x.상세구분].filter(Boolean).join(" "), + 단위: x.단위 ?? "", + 금액: x.기준금액, + 출처: `${L("M02_PickSangeun")} · ${tc(LAYER_TEXT[x.ref.층 as keyof typeof LAYER_TEXT])}`, + })), + ); + finder.setRows([]); + }; const loadLib = async (): Promise => { if (!lib || !scope) return; const mine = ++seq; @@ -303,12 +339,46 @@ export function openLogicPicker( finder.search.addEventListener("input", find); layerBar.style.display = "none"; + const hint = createText(tc("Std_Hint")); + /** 탭 바꿈 — 단가산출 탭은 찾기 칸만(거름 · 층 단추 · 계산서 안내 숨김) */ + const tabs = lib?.sangeun + ? createTabs({ + tabs: [ + { key: "std", label: L("M02_PickStd") }, + { key: "sk", label: L("M02_PickSangeun") }, + ], + value: tab, + kind: "line", + onChange: (key) => { + tab = key === "sk" ? "sk" : "std"; + const std = tab === "std"; + for (const f of [kindSel?.root, work.root, sub.root, detail.root]) + if (f) f.style.display = std ? "" : "none"; + layerBar.style.display = std && scopes.length ? "" : "none"; + hint.hidden = !std; + find(); + }, + }) + : null; if (lib) void upper(lib.layer, lib.owner).then((got) => { scopes = got; + if (tabs) + listSangeun(got).then( + (rows) => { + skRows = rows; + tabs.setBadge("sk", rows.length); + find(); + }, + (error) => { + skRows = []; + find(); + showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"); + }, + ); scope = got[got.length - 1] ?? null; // 기본 = 가장 가까운 층 if (!got.length) return; - layerBar.style.display = ""; + layerBar.style.display = tab === "std" ? "" : "none"; drawLayers(); void loadLib(); void fetchStructureDef().then( @@ -331,7 +401,7 @@ export function openLogicPicker( body.append( el("div", { className: "m02-std-pick", - children: [finder.root, createText(tc("Std_Hint")), table.root], + children: [...(tabs ? [tabs.root] : []), finder.root, hint, table.root], }), ), })); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Pick.ts new file mode 100644 index 000000000..39d004145 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Pick.ts @@ -0,0 +1,104 @@ +/* ============================================================================= + * M02_MasterTemplete_Sangeun_Pick.ts + * M02 단가산출(SK) 고르기(PLAN 717-4) — 위 층 SK 목록 한 벌 · 단가산출 더한줄 고르기. + * · `listSangeun` = 층 목록 길(`/layers/{층}/templates` · 종류 `sangeun`)을 마스터 › 회사 › 개인 차례로 이음 · + * 금액 = 서버가 붙인 1 단위 `기준금액` · 일위대가 [로직 추가] 「단가산출」 탭 · B09 재료 창고 출처가 씀 + * · 고른 SK = 참조 `{층, 주인, 키: SK-…}` — 일위대가 줄은 옛 참조 줄 꼴(서버 `M02_Labor_Expand` 가 펼침) + * · `openSangeunLinePicker` = 단가산출 더한줄 고르기 — 같은 [로직 추가] 창(M01 로직 · 일위대가 · 단가산출 · + * M01 재료) → SK 문서 `더한줄` 한 줄(PLAN 715 이음 꼴) + * ========================================================================== */ + +import { t as L } from "@ui/ui_template_locale"; +import { call, type Layer } from "./M02_MasterTemplete_Api_Fetch"; +import type { LaborRef } from "./M02_MasterTemplete_Labor_Calc"; +import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; +import { pickMaterial } from "./M02_Material_Pick"; + +export const SANGEUN_KIND = "sangeun"; +/** 층 차례 — 위에서 아래 */ +export const LAYER_ORDER = ["system", "company", "personal"] as const; + +/** SK 목록 한 줄 */ +export interface SangeunRow { + ref: LaborRef; + 이름: string; + 제목: string | null; + 단위: string | null; + 구분: string | null; + 상세구분: string | null; + 기준금액: number | null; +} + +interface ListRow { + 종류: string; + 이름: string; + 제목?: string | null; + 단위?: string | null; + 구분?: string | null; + 상세구분?: string | null; + 기준금액?: number | null; +} + +/** 위 층 SK — 층 차례(마스터 › 회사 › 개인) · 층 안은 서버 차례 */ +export async function listSangeun(scopes: Omit[]): Promise { + const rank = (x: Omit): number => + LAYER_ORDER.indexOf(x.층 as (typeof LAYER_ORDER)[number]); + const sorted = [...scopes].sort((a, b) => rank(a) - rank(b)); + const lists = await Promise.all( + sorted.map(async (at) => { + const q = at.주인 && at.층 !== "system" ? `?owner=${encodeURIComponent(at.주인)}` : ""; + const got = await call<{ 양식: ListRow[] }>(`/layers/${at.층}/templates${q}`); + return got.양식 + .filter((r) => r.종류 === SANGEUN_KIND) + .map((r) => ({ + ref: { 층: at.층, 주인: at.층 === "system" ? null : at.주인, 키: r.이름 }, + 이름: r.이름, + 제목: r.제목 ?? null, + 단위: r.단위 ?? null, + 구분: r.구분 ?? null, + 상세구분: r.상세구분 ?? null, + 기준금액: r.기준금액 ?? null, + })); + }), + ); + return lists.flat(); +} + +/** 단가산출 더한줄 한 줄(PLAN 715 이음 꼴) */ +export type SangeunLine = + | { 종류: "로직"; 로직: string; 입력값: Record; 수량: number } + | { 참조: LaborRef; 수량: number } + | { 종류: "재료"; 요소: string; 이름: string | null; 단위: string | null; 수량: number }; + +/** 단가산출 더한줄 고르기 — `scope` = 그 SK 문서 층(고를 수 있는 서고 = 위 층만) */ +export function openSangeunLinePicker( + scope: { layer: Layer; owner: string | null }, + done: (line: SangeunLine) => void, +): void { + const element = L("M02_PickElement"); + openLogicPicker((x) => done({ 종류: "로직", 로직: x.키, 입력값: {}, 수량: 1 }), { + ...scope, + sangeun: true, + pick: (ref) => done({ 참조: ref, 수량: 1 }), + kindFilter: [ + { value: "logic", text: tc("Kind_Logic") }, + { value: "precombo", text: L("M02_PrecomboTitle") }, + { value: "element", text: element }, + ], + extra: [ + { + kind: "element", + key: "element:material", + tag: element, + name: L("M02_PickMaterial"), + sub: "", + unit: "", + onPick: () => + pickMaterial((m) => + done({ 종류: "재료", 요소: m.코드, 이름: m.이름, 단위: m.단위, 수량: 1 }), + ), + }, + ], + }); +} diff --git a/resources/tester/test_717_4_sangeun_pick.py b/resources/tester/test_717_4_sangeun_pick.py new file mode 100644 index 000000000..858eb0d6b --- /dev/null +++ b/resources/tester/test_717_4_sangeun_pick.py @@ -0,0 +1,72 @@ +"""717-4 단가산출(SK) 고르기 — SK 목록 층 차례 · 고른 SK 참조 꼴 · 일위대가 줄 금액.""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +from M02_MasterTemplete import M02_Labor_Ref as refs +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 COMPANY, MINE, world # noqa: F401 — 같은 임시 층 + +ROOT = Path(__file__).resolve().parents[2] +M02 = ROOT / "M02_MasterTemplete" +PCT = "GC001109" # 원/㎡ · 인력 둘 + 덧줄 % + + +def _text(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def test_sk_list_layer_order_and_ref_shape(): + """목록 = 마스터 › 회사 › 개인 차례 · 종류 sangeun 만 · 고른 줄 = 참조 {층, 주인, 키}.""" + pick = _text(M02 / "M02_MasterTemplete_Sangeun_Pick.ts") + order = re.search(r"LAYER_ORDER = \[([^\]]+)\]", pick).group(1) + assert [x.strip().strip('"') for x in order.split(",")] == ["system", "company", "personal"] + assert "[...scopes].sort((a, b) => rank(a) - rank(b))" in pick + assert "r.종류 === SANGEUN_KIND" in pick and 'SANGEUN_KIND = "sangeun"' in pick + assert 'ref: { 층: at.층, 주인: at.층 === "system" ? null : at.주인, 키: r.이름 }' in pick + labor = _text(M02 / "M02_MasterTemplete_Labor_Pick.ts") + assert "lib?.pick(x.ref, x.제목 || x.이름);" in labor # SK 탭 줄 누름 = 참조 + assert 'if (tab === "sk") return findSangeun(q);' in labor + assert "sangeun: true," in _text( + M02 / "M02_MasterTemplete_Hopyo_Pick.ts" + ) # 일위대가 [로직 추가] + store_ts = _text(ROOT / "B09_Estimation" / "B09_Estimation_UI_Store.ts") + assert '["sangeun", "단가산출"],' in store_ts and "sangeun: sangeunItems," in store_ts + # 더한줄 고르기 — 715 이음 꼴 셋 + assert 'done({ 종류: "로직", 로직: x.키, 입력값: {}, 수량: 1 })' in pick + assert "done({ 참조: ref, 수량: 1 })" in pick + assert 'done({ 종류: "재료", 요소: m.코드, 이름: m.이름, 단위: m.단위, 수량: 1 })' in pick + + +def _sk(folder, name): + with store.at(folder): + return refs.create( + "sangeun", {"이름": name, "단위": "㎡", "바탕": {"로직": PCT}, "더한줄": []} + )["이름"] + + +def test_list_amount_and_labor_line_holds_upper_sk(world): # noqa: F811 + """층 목록 SK 줄에 1 단위 `기준금액` · 개인 일위대가 줄이 회사 SK 를 참조 = SK 1 단위 × 수량.""" + from M02_MasterTemplete import M02_Labor_Doc as labor + + sk = _sk(world["company"], "회사 단가산출") + with store.at(world["company"]): + rows = [r for r in store.list_all() if r["종류"] == "sangeun"] + refs.summaries(world["company"], rows) + one = calc.calc_doc(store.read("sangeun", sk)["문서"])["1단위"]["계"] + assert [r["이름"] for r in rows] == [sk] + assert rows[0]["제목"] == "회사 단가산출" and rows[0]["기준금액"] == pytest.approx(one, abs=1) + with store.at(world["personal"]): + sc = refs.create( + "precombo", + {"이름": "일위대가", "담은로직": [{"참조": {**COMPANY, "키": sk}, "수량": {"손": 3}}]}, + )["이름"] + saved = store.read("precombo", sc)["문서"]["담은로직"][0]["참조"] + got = labor.calc({"담은로직": [{"참조": {**MINE, "키": sc}, "수량": {"손": 1}}]}, {}) + assert saved == {**COMPANY, "키": sk} + assert got["반영합"]["계"] == pytest.approx(one * 3, abs=1) diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index 6ae515608..771108fb6 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -191,4 +191,8 @@ export const ui_locales_m2 = { M02_HopyoImport: ["일위대가 불러오기", "Import unit-price sheets"], B01_Dashboard_CardPrecombo: ["일위대가", "Unit-cost items"], M02_PrecomboTitle: ["일위대가", "Unit-cost items"], + M02_PickStd: ["기준 목록", "Standard list"], + M02_PickSangeun: ["단가산출", "Unit-cost calcs"], + M02_PickElement: ["M01 요소", "M01 elements"], + M02_PickMaterial: ["재료 고르기", "Pick a material"], } as const; From 36a21591e264388e8b5f515d31da5367e04c2559 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 16:11:09 +0900 Subject: [PATCH 17/19] =?UTF-8?q?fix(715=20=C2=B7=20712-2=20=EC=9D=B4?= =?UTF-8?q?=EC=9D=8C):=20M02=5FSangeun=5FCalc.times=5Fof=20=E2=80=94=20?= =?UTF-8?q?=EC=98=9B=20Store=5FCombo.=5FPER=20=EB=8C=80=EC=8B=A0=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=ED=95=9C=20=EB=B2=8C=20master=5Funit.per(?= =?UTF-8?q?712-2=20=EA=B0=80=20=EC=98=AE=EA=B9=80)=20=C2=B7=20=ED=95=A9?= =?UTF-8?q?=EC=B9=9C=20=EB=92=A4=20=EC=8B=9C=ED=97=98=2063=20=ED=86=B5?= =?UTF-8?q?=EA=B3=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Sangeun_Calc.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/M02_MasterTemplete/M02_Sangeun_Calc.py b/M02_MasterTemplete/M02_Sangeun_Calc.py index a6f0743fd..a45cd402c 100644 --- a/M02_MasterTemplete/M02_Sangeun_Calc.py +++ b/M02_MasterTemplete/M02_Sangeun_Calc.py @@ -47,9 +47,8 @@ def times_of(row: dict) -> Decimal: """결과단위 분모 앞 수(원/10㎡ → 10) — 없으면 1.""" from M01_MasterData import M01_MasterData_Store_Combo as combo_store - per = combo_store._PER.findall(str(row.get("결과단위") or "")) - head = per[0][0].replace(",", "") if len(per) == 1 else "" - return Decimal(head) if head else Decimal(1) + per = combo_store.mu.per(row.get("결과단위")) # 712-2 단위 한 벌 + return per[0][0] if len(per) == 1 else Decimal(1) def fingerprint(row: dict) -> str: From 5f36e85d7d9223cf26de4580a71253dffc6e9281 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 16:17:38 +0900 Subject: [PATCH 18/19] =?UTF-8?q?fix(=ED=95=A9=EC=B9=A8):=20ui=5Ftemplate?= =?UTF-8?q?=5Flocale=5Fm2=20=EC=B6=A9=EB=8F=8C=20=ED=91=9C=EC=8B=9C=20?= =?UTF-8?q?=EA=B1=B7=EC=9D=8C=20=E2=80=94=20717-2a=20=EB=8B=A8=EA=B0=80?= =?UTF-8?q?=EC=82=B0=EC=B6=9C=20=ED=82=A4=20=C2=B7=20717-4=20=EA=B3=A0?= =?UTF-8?q?=EB=A5=B4=EA=B8=B0=20=ED=82=A4=20=EC=96=91=EC=AA=BD=20=EC=82=B4?= =?UTF-8?q?=EB=A6=BC(6561c90ab=20=ED=95=A9=EC=B9=A8=EC=97=90=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=EA=B0=80=20=EB=82=A8=EC=95=98=EB=8D=98=20=EA=B2=83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- ui_template/ui_template_locale_m2.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index e90310eac..ad7f6ee6e 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -191,7 +191,6 @@ export const ui_locales_m2 = { M02_HopyoImport: ["일위대가 불러오기", "Import unit-price sheets"], B01_Dashboard_CardPrecombo: ["일위대가", "Unit-cost items"], M02_PrecomboTitle: ["일위대가", "Unit-cost items"], -<<<<<<< HEAD B01_Dashboard_CardSangeun: ["단가산출", "Unit-price analyses"], M02_SangeunTitle: ["단가산출", "Unit-price analyses"], M02_SangeunNone: ["단가산출 없음", "No unit-price analyses"], @@ -208,10 +207,8 @@ export const ui_locales_m2 = { "단가산출 상세 표 · 고침 — 준비 중(717-2b)", "Unit-price detail table and editing — coming next (717-2b)", ], -======= M02_PickStd: ["기준 목록", "Standard list"], M02_PickSangeun: ["단가산출", "Unit-cost calcs"], M02_PickElement: ["M01 요소", "M01 elements"], M02_PickMaterial: ["재료 고르기", "Pick a material"], ->>>>>>> origin/sub_laptop_7 } as const; From cf6e2cf725c61cb2d8121d2893e5ad4133291140 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 16:21:28 +0900 Subject: [PATCH 19/19] =?UTF-8?q?test(717-4):=20=EC=9D=B4=EC=9D=8C?= =?UTF-8?q?=EB=A7=A4=20=EC=8B=9C=ED=97=98=20=EC=9E=90=EB=A3=8C=EC=97=90=20?= =?UTF-8?q?=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20=EB=8B=A8=EC=9C=84=20?= =?UTF-8?q?=E3=80=8C=E3=8E=A1=E3=80=8D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 715-3 일위대가 단위 빈칸 막기와 맞물려 단위 없는 시험 자료가 실패하던 것 수정 Co-Authored-By: Claude Sonnet 5.5 --- resources/tester/test_717_4_sangeun_pick.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/resources/tester/test_717_4_sangeun_pick.py b/resources/tester/test_717_4_sangeun_pick.py index 858eb0d6b..c2c14859a 100644 --- a/resources/tester/test_717_4_sangeun_pick.py +++ b/resources/tester/test_717_4_sangeun_pick.py @@ -64,7 +64,11 @@ def test_list_amount_and_labor_line_holds_upper_sk(world): # noqa: F811 with store.at(world["personal"]): sc = refs.create( "precombo", - {"이름": "일위대가", "담은로직": [{"참조": {**COMPANY, "키": sk}, "수량": {"손": 3}}]}, + { + "이름": "일위대가", + "단위": "㎡", + "담은로직": [{"참조": {**COMPANY, "키": sk}, "수량": {"손": 3}}], + }, )["이름"] saved = store.read("precombo", sc)["문서"]["담은로직"][0]["참조"] got = labor.calc({"담은로직": [{"참조": {**MINE, "키": sc}, "수량": {"손": 1}}]}, {})