From 6466904997767f7745776e88ccb1299e515a162b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 23:42:18 +0900 Subject: [PATCH] =?UTF-8?q?feat(M02=20=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80?= =?UTF-8?q?):=20361-2=20=EC=A0=84=EA=B5=AD=20=EC=9E=90=EC=9E=AC=20?= =?UTF-8?q?=EC=A4=84=20=E2=80=94=20=EC=83=81=EC=84=B8=20=EC=9E=90=EC=9E=AC?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=95=84=EB=9E=98=20=EB=B3=B4=EA=B8=B0=20?= =?UTF-8?q?=EC=A0=84=EC=9A=A9=20=ED=91=9C=20=C2=B7=20=ED=99=95=EC=9D=B8=20?= =?UTF-8?q?=EC=B0=BD=20=EC=84=B8=20=EC=B9=B8=20=3D=20=EB=A7=8C=EB=93=A4?= =?UTF-8?q?=EA=B8=B0=EC=97=90=20=EC=A0=81=ED=9E=90=20=EA=B0=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 파일 `M02_MasterTemplete_Labor_National`: 사전조합 `본뜬.전국자재` 를 자재목록 아래 표로(코드 「전국」 · 이름 · 규격 · 수량 · 단위 · 세 칸 글 · 「지역은 프로젝트에서」 · 금액 칸 비움 · 단추 없음) · 없으면 안 그림 · 셈 · 문서 꼴 · 저장 길 그대로 - 상세(`Labor_Detail`) 자재 칸에 부르는 줄 하나 · `Labor_Api` 본뜬 타입에 `전국자재` 칸 - 확인 창 자재 표 세 칸: 서버가 대상마다 `세칸`(`Material.marks` · 만들기와 같은 값)을 실음 → 전국-관급 줄 = 관급(옛 기본값 「사급」) - 시험: 전국 자재 줄 있는 것 · 없는 것 · 단추 없음 · 글 명사형 · 읽기 길 · 세 칸 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte --- M02_MasterTemplete/M02_Hopyo_Line.py | 5 +- .../M02_MasterTemplete_Hopyo_Api.ts | 3 + .../M02_MasterTemplete_Hopyo_CheckRows.ts | 2 +- .../M02_MasterTemplete_Labor_Api.ts | 9 +- .../M02_MasterTemplete_Labor_Detail.ts | 2 + .../M02_MasterTemplete_Labor_National.ts | 76 +++++++++++ .../tester/helper_m02_labor_national.cjs | 57 ++++++++ resources/tester/test_m02_hopyo_match.py | 3 +- resources/tester/test_m02_hopyo_screen.py | 5 +- resources/tester/test_m02_labor_national.py | 122 ++++++++++++++++++ 10 files changed, 278 insertions(+), 6 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Labor_National.ts create mode 100644 resources/tester/helper_m02_labor_national.cjs create mode 100644 resources/tester/test_m02_labor_national.py diff --git a/M02_MasterTemplete/M02_Hopyo_Line.py b/M02_MasterTemplete/M02_Hopyo_Line.py index bfe215ac8..b53ae639e 100644 --- a/M02_MasterTemplete/M02_Hopyo_Line.py +++ b/M02_MasterTemplete/M02_Hopyo_Line.py @@ -92,6 +92,8 @@ def _pair(pair: dict, line: dict, ctx: Ctx) -> dict: {"호표": pair.get("호표")}, ) conv = {"배": pair.get("배"), "갈래": "별칭"} + if "자재" in head: # 세 칸(361-2) = 만들기에 적힐 값(`Material.marks`) + head["세칸"] = hmat.marks(line, one["이름"]) return {**head, "이름": one["이름"], "단위": one["단위"], "입력값": vals, "못읽음": []} | { "출처": {k: "별칭" for k in vals}, "환산": conv, @@ -225,9 +227,10 @@ def _material(line: dict, ctx: Ctx, full: bool) -> dict: def _mat_target(one: dict, line: dict, times: Any = None) -> dict: - """자재 · 「전국」 자리표 → 대상(배 = 환산 · `times` 주면 그것).""" + """자재 · 「전국」 자리표 → 대상(배 = 환산 · `times` 주면 그것 · 세칸 = 만들기에 적힐 세 칸).""" conv = hm.factor(line.get("단위"), one["단위"], line.get("규격")) target = {**hm._public(one), "입력값": {}, "못읽음": [], "출처": {}, "환산": conv} + target["세칸"] = hmat.marks(line, one["이름"]) ok = isinstance(times, (int, float)) and not isinstance(times, bool) and times > 0 return {**target, "몫": None, "배": times if ok else conv["배"]} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts index 7f6089c9d..3e2ddaa0f 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts @@ -9,6 +9,7 @@ import { API_BASE_URL } from "@config/config_frontend"; import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet"; import { call, getActiveLayer, getActiveOwner, type Layer } from "./M02_MasterTemplete_Api_Fetch"; import type { LaborRef } from "./M02_MasterTemplete_Labor_Calc"; +import type { MatMarks } from "./M02_MasterTemplete_Labor_Api"; /** 층 · 주인(null = 자기 층) */ export interface HopyoScope { @@ -119,6 +120,8 @@ export interface HopyoPart extends HopyoTarget { 배: number | null; /** 블록 절 제목(조합 덧 로직) */ 어디?: string; + /** 자재 세 칸(361-2) — 만들기에 적힐 값(서버 `Material.marks`) */ + 세칸?: MatMarks; } /** 품 묶음 — 직종 품 · 장비 시간 · 자재 수량 */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts index 7ef66d841..54b241eb5 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts @@ -266,7 +266,7 @@ function matRow(line: HopyoLine, ctx: RowsContext): HTMLElement { }), qtyCell(line, parts), el("td", { className: "m02-labrow__unit", text: p?.단위 ?? line.단위 }), - ...markCells({ 별도계상: line.갈래 === "별도계상" ? "별도계상" : "직접계산" }), + ...markCells(p?.세칸 ?? { 별도계상: line.갈래 === "별도계상" ? "별도계상" : "직접계산" }), ...PRICE_SLOTS.map((slot) => el("td", { className: `m02-labrow__num m02-labrow__price${first?.기준칸 === slot ? " is-base-price" : ""}`, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts index 0df44097f..10e55f950 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts @@ -146,6 +146,13 @@ export const MAT_MARKS = { } as const; export type MatMarkKey = keyof typeof MAT_MARKS; export type MatMarks = { [K in MatMarkKey]?: (typeof MAT_MARKS)[K][number] }; +/** 「전국」 자재(361-1 · `본뜬.전국자재` · 키 · 단가 없음 · 보기만 · `Labor_National`) */ +export type NationalMat = MatMarks & { + 이름: string; + 규격: string; + 단위: string; + 수량: { 손: number }; +}; /** 읽을 때 기본값(162-1 · 서버 `MARK_DEFAULT` 와 같음) — 저장은 비움 */ export const MAT_MARK_DEFAULT: Required = { 자재구분: "사급", @@ -182,7 +189,7 @@ export interface LaborDoc { 열: string; 일위대가번?: string; 단위: string | null; - 본뜬?: { 층: string; 주인: string | null; 키: string } | null; + 본뜬?: { 층: string; 주인: string | null; 키: string; 전국자재?: NationalMat[] } | null; 담은로직: LaborRow[]; 자재: LaborMat[]; 계산값?: unknown; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index 7664d8972..f1fe92e7b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -38,6 +38,7 @@ import { type Layer, } from "./M02_MasterTemplete_Api_Fetch"; import { linkAreaCell } from "./M02_MasterTemplete_AreaCellPick"; +import { nationalRows } from "./M02_MasterTemplete_Labor_National"; import { lineId, settle, type LaborRef } from "./M02_MasterTemplete_Labor_Calc"; import { areaLinks, @@ -241,6 +242,7 @@ export function mountLaborDetail( }, edit ? { open: openMat, remove: removeMat, mark: markMat } : { open: openMat }, ), + ...nationalRows(cur.본뜬), // 361-2 — 전국 자재(보기만) ); drawDg(); showSource(); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_National.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_National.ts new file mode 100644 index 000000000..5b72431c6 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_National.ts @@ -0,0 +1,76 @@ +/* ============================================================================= + * M02_MasterTemplete_Labor_National.ts + * 「전국」 자재 줄(PLAN 361-2) — 실무자료 만들기가 `본뜬.전국자재` 에 적은 자재(키 · 단가 없음 · 361-1)를 + * 상세 자재목록 아래 보기 전용 표로 · 이름 · 규격 · 수량 · 단위 · 세 칸(글만) · 금액 칸 비움 · 단추 없음. + * 없으면 아무것도 안 그림 · 셈 · 문서 꼴 · 저장 길은 그대로(보기만). + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import type { LaborDoc } from "./M02_MasterTemplete_Labor_Api"; +import { markCells, markHeads } from "./M02_MasterTemplete_Labor_MatMarks"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; + +const TEXT = { + Title: [ + "전국 자재 — 지역은 프로젝트에서 · 금액 없음(보기만)", + "Nationwide materials — region set in the project · no price (view only)", + ], + Code: ["전국", "Nationwide"], + Region: ["지역은 프로젝트에서", "Region set in the project"], +} as const; +const say = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0]; + +/** `본뜬.전국자재` 표 — 없으면 [] */ +export function nationalRows(origin: LaborDoc["본뜬"]): HTMLElement[] { + const mats = origin?.전국자재 ?? []; + if (!mats.length) return []; + const heads = [ + tc("Labor_Code"), + tc("Col_Name"), + tc("Labor_Spec"), + tc("Labor_QtyCol"), + tc("Labor_Unit"), + ...markHeads(), + tc("Labor_AppliedSum"), + ]; + const rows = mats.map((m) => + el("tr", { + className: "m02-labrow__row", + attrs: { "data-labor-national": m.이름 }, + children: [ + el("td", { className: "m02-labrow__code", text: say("Code") }), + el("td", { + className: "m02-labrow__name", + children: [ + el("strong", { text: m.이름 }), + el("small", { className: "m02-labrow__note", text: say("Region") }), + ], + }), + el("td", { className: "m02-labrow__spec", text: m.규격, attrs: { title: m.규격 } }), + el("td", { className: "m02-labrow__num", text: String(m.수량?.손 ?? "") }), + el("td", { className: "m02-labrow__unit", text: m.단위 }), + ...markCells(m), + el("td", { className: "m02-labrow__num", text: "—" }), + ], + }), + ); + return [ + el("p", { className: "m02-labrow__muted", text: say("Title") }), + el("div", { + className: "ui-general-block__table-wrap", + attrs: { "data-labor-national-table": "" }, + children: [ + el("table", { + className: "ui-general-block__table m02-labrow", + children: [ + el("thead", { + children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })], + }), + el("tbody", { children: rows }), + ], + }), + ], + }), + ]; +} diff --git a/resources/tester/helper_m02_labor_national.cjs b/resources/tester/helper_m02_labor_national.cjs new file mode 100644 index 000000000..6cea2ca9c --- /dev/null +++ b/resources/tester/helper_m02_labor_national.cjs @@ -0,0 +1,57 @@ +/* M02 「전국」 자재 줄(361-2) 헬퍼 — `Labor_National.nationalRows` 를 트랜스파일해 Node 로 돌림 · + * `el` 대역 = 나무 꼴 {tag, className, text, attrs, children} · stdin = {origin} · stdout = 나무 JSON. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const el = (tag, o = {}) => ({ tag, ...o, children: o.children ?? [] }); +const STUBS = { + "@ui/ui_template_elements": { el, createSelectField: () => ({ root: el("select") }) }, + "@ui/ui_template_locale": { currentLanguageIndex: 0 }, + "@config/config_frontend": { API_BASE_URL: "/api" }, +}; +const cache = new Map(); +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + if (request.startsWith(".") && !request.endsWith(".css")) + return loadTs(`${path.resolve(path.dirname(full), request)}.ts`); + return new Proxy({}, { get: () => () => ({}) }); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +const input = JSON.parse(fs.readFileSync(0, "utf8")); +const national = loadTs( + path.join(ROOT, "M02_MasterTemplete", "M02_MasterTemplete_Labor_National.ts"), +); +const text = (n) => (n.text ?? "") + (n.children ?? []).map(text).join(" "); +const find = (n, tag) => [ + ...(n.tag === tag ? [n] : []), + ...(n.children ?? []).flatMap((c) => find(c, tag)), +]; +const out = input.origins.map((origin) => { + const got = national.nationalRows(origin); + const table = got.flatMap((n) => find(n, "table"))[0]; + return { + count: got.length, + title: got[0]?.text ?? null, + heads: table ? find(table, "th").map((h) => h.text) : [], + rows: table + ? find(table, "tr") + .slice(1) + .map((r) => r.children.map((c) => text(c).trim())) + : [], + buttons: got.flatMap((n) => [...find(n, "button"), ...find(n, "select")]).length, + }; +}); +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_m02_hopyo_match.py b/resources/tester/test_m02_hopyo_match.py index 9a633a0c2..111e375b9 100644 --- a/resources/tester/test_m02_hopyo_match.py +++ b/resources/tester/test_m02_hopyo_match.py @@ -117,7 +117,7 @@ def test_새_상태_체크_제안(client): assert plywood["상태"] == "맞음" and set(plywood["갈리는칸"]) == {"측압", "폼타이규격"} mat = first["레미콘(25-21-120)"] # 358-2 · 361-1 — 「별도계상」 표시 · 지역 변형만 = 전국 assert mat["상태"] == "맞음" and mat["체크"] is True and "별도계상" in mat["꼬리표"] - assert mat["대상"][0] | {"환산": None} == { + assert mat["대상"][0] | {"환산": None, "세칸": None} == { "전국": True, "이름": "레미콘(전국)-관급", "규격": "25-21-120", @@ -126,6 +126,7 @@ def test_새_상태_체크_제안(client): "못읽음": [], "출처": {}, "환산": None, + "세칸": None, "몫": None, "배": 1, } # 특정 지역 키 없음 diff --git a/resources/tester/test_m02_hopyo_screen.py b/resources/tester/test_m02_hopyo_screen.py index 015cd2d70..d36d273b2 100644 --- a/resources/tester/test_m02_hopyo_screen.py +++ b/resources/tester/test_m02_hopyo_screen.py @@ -357,7 +357,7 @@ def test_좌측_판_자리와_권한(): def test_로직_추가_한_곳과_파일_규칙(): detail = _text("M02_MasterTemplete_Labor_Detail.ts") - assert len(detail.splitlines()) == 693 # 줄 수 그대로 + assert len(detail.splitlines()) == 695 # 줄 수 그대로(361-2 전국 자재 부르는 줄 둘) assert "withHopyo(library)," in detail pick = _text("M02_MasterTemplete_Labor_Pick.ts") assert "more?: () => Promise;" in pick and "...later" in pick @@ -485,7 +485,8 @@ def test_확인_창_표_일위대가_상세_꼴_359(): for word in ('"고르기"', '"원문 보기"', '"엑셀 보기"', '"옵션"', '"품 비교"'): assert word in rows, word assert 'import { moneyCell, PRICE_SLOTS } from "./M02_MasterTemplete_Labor_Rows";' in rows - assert "...markCells({ 별도계상:" in rows and "...markHeads()," in rows + assert "...markCells(p?.세칸 ?? { 별도계상:" in rows # 361-2 서버 세 칸 먼저 + assert "...markHeads()," in rows for head in ("Col_Order", "Sub", "Col_Name", "Col_Alias", "Labor_QtyCol", "Col_Unit"): assert f'tc("{head}")' in rows, head check = _text("M02_MasterTemplete_Hopyo_Check.ts") diff --git a/resources/tester/test_m02_labor_national.py b/resources/tester/test_m02_labor_national.py new file mode 100644 index 000000000..80f18a17c --- /dev/null +++ b/resources/tester/test_m02_labor_national.py @@ -0,0 +1,122 @@ +"""「전국」 자재 줄(PLAN 361-2 · `M02_MasterTemplete_Labor_National`) — 사전조합 `본뜬.전국자재` 를 +상세 자재목록 아래 보기 전용 표로(있으면 줄 · 없으면 안 보임 · 단추 없음 · 금액 칸 비움 · +글 명사형) · 읽기 길(사전조합 GET 의 `layers.read_template`)이 `본뜬.전국자재` 를 그대로 줌 · +확인 창 세 칸 = 만들기에 적힐 값(`세칸`). +층은 임시 폴더. +""" + +import json +import os +import re +import subprocess +from pathlib import Path + +# 올리기 길 시험의 `client` 마당을 같이 씀 +# ruff: noqa: F811 +from resources.tester.test_m02_hopyo_build import _build +from resources.tester.test_m02_hopyo_excel import URL, _post, client # noqa: F401 + +HERE = os.path.dirname(os.path.abspath(__file__)) +M02 = Path(HERE).parents[1] / "M02_MasterTemplete" +REMICON = { + "이름": "레미콘(전국)-관급", + "규격": "25-21-120", + "단위": "㎥", + "수량": {"손": 2.5}, + "별도계상": "별도계상", + "자재구분": "관급", +} + + +def _rows(origins: list) -> list: + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_m02_labor_national.cjs")], + input=json.dumps({"origins": origins}, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + check=False, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_전국_자재_줄_있는_것_없는_것(): + have, plain, empty, none = _rows( + [ + {"층": "personal", "주인": "7/42", "키": "HP-000001 제1호표", "전국자재": [REMICON]}, + {"층": "personal", "주인": "7/42", "키": "HP-000001 제3호표"}, + {"층": "personal", "주인": None, "키": "k", "전국자재": []}, + None, + ] + ) + assert have["title"] == "전국 자재 — 지역은 프로젝트에서 · 금액 없음(보기만)" + assert have["heads"] == [ + "코드", + "이름", + "규격", + "수량산출", + "단위", + "자재구분", + "별도계상", + "운반비", + "반영 금액", + ] + assert have["rows"] == [ + [ + "전국", + "레미콘(전국)-관급 지역은 프로젝트에서", + "25-21-120", + "2.5", + "㎥", + "관급", + "별도계상", + "포함", # 빈 칸 = 기본값 글 + "—", # 금액 칸 비움 + ] + ] + assert have["buttons"] == 0 # 고침 · 삭제 없음(보기만) + assert plain["count"] == empty["count"] == none["count"] == 0 # 없으면 안 보임 + + +def test_소스_보기만_명사형_자리(): + code = (M02 / "M02_MasterTemplete_Labor_National.ts").read_text(encoding="utf-8") + words = re.findall(r'"([^"]*[가-힣][^"]*)"', code) + assert words and not [w for w in words if re.search(r"(합니다|해요|한다|하세요)", w)] + assert "createButton" not in code and "fetch" not in code and "sessionStorage" not in code + detail = (M02 / "M02_MasterTemplete_Labor_Detail.ts").read_text(encoding="utf-8") + assert "...nationalRows(cur.본뜬)" in detail and len(detail.splitlines()) <= 700 + api = (M02 / "M02_MasterTemplete_Labor_Api.ts").read_text(encoding="utf-8") + assert "전국자재?: NationalMat[]" in api + + +def test_만든_사전조합_읽기_길에_전국자재(client): + from M02_MasterTemplete import M02_Template_Layers as layers + + assert _post(client).status_code == 200 + got = _build(client, 1, [{"차례": 0, "대상": [{"전국": True, "배": 1}]}]) + key = got["참조"]["키"] + found = layers.read_template(layers.personal_dir(7, 42), "precombo", key) + doc = found.get("문서") or found + assert doc["본뜬"]["전국자재"][0] | {"수량": None} == REMICON | {"수량": None} + assert doc["자재"] == [] # 자재 칸(M01 키만)엔 없음 — 셈 · 문서 꼴 그대로 + + +def test_확인_창_세_칸_만들기_값(client): + from M02_MasterTemplete import M02_Hopyo_Fit as fit + from M02_MasterTemplete import M02_Template_Layers as layers + + assert _post(client).status_code == 200 + fit.run_job(layers.personal_dir(7, 42), "HP-000001") + lines = client.get(f"{URL.format('personal')}/HP-000001/1").json()["호표"]["줄"] + at = next(x["차례"] for x in lines if x["이름"] == "레미콘(25-21-120)") + mat = lines[at]["맞춤"]["대상"][0] + assert mat["세칸"] == { + "별도계상": "별도계상", + "자재구분": "관급", + } # 전국-관급 = 관급(옛 「사급」) + path = f"{URL.format('personal')}/HP-000001/1/guess" + seoul = client.post(path, json={"차례": at, "대상": [{"자재": "MT004258", "배": 1}]}).json() + assert seoul["대상"][0]["세칸"]["자재구분"] == "사급" # 고른 자재 이름(-사급) 먼저 + rows = (M02 / "M02_MasterTemplete_Hopyo_CheckRows.ts").read_text(encoding="utf-8") + assert "...markCells(p?.세칸 ??" in rows