From 91c743382ba237f77cf1c3db06e7caa1a412f637 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 16:06:32 +0900 Subject: [PATCH] =?UTF-8?q?feat(m02-cf):=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8=20?= =?UTF-8?q?=EA=B2=AC=EB=B3=B8=20=EC=85=8B=20=C2=B7=20M02=5FCF=5FSample(?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=C2=B7=20=EB=B3=B5=EC=82=AC=ED=95=B4?= =?UTF-8?q?=EC=84=9C=20=EC=97=B4=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _sample/masonry.json — 돌쌓기 메쌓기(인력): 변수 · 품표(QF000421) · 증가율표(QF000422) · 인력 → 증가율 찾기 → 품(둘) → 노무비(둘) → 합 → 출력 노무비 - _sample/rate.json — 공구손료(비율): 노무비 × 율 → 출력 재료비 - _sample/value.json — 덤프트럭 시간당 작업량: q · f · E · Cm → 출력 값 - 시험 test_m02_cf_sample.py — 모양 · 알약 이름 규칙 · 견본 ① 노무비 148,470.504 = /calc GF000219 · ③ = GF000133 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt --- M02_MasterTemplete/CostFlow/M02_CF_Sample.ts | 65 ++ .../CostFlow/_sample/masonry.json | 785 ++++++++++++++++++ M02_MasterTemplete/CostFlow/_sample/rate.json | 146 ++++ .../CostFlow/_sample/value.json | 200 +++++ resources/tester/test_m02_cf_sample.py | 249 ++++++ 5 files changed, 1445 insertions(+) create mode 100644 M02_MasterTemplete/CostFlow/M02_CF_Sample.ts create mode 100644 M02_MasterTemplete/CostFlow/_sample/masonry.json create mode 100644 M02_MasterTemplete/CostFlow/_sample/rate.json create mode 100644 M02_MasterTemplete/CostFlow/_sample/value.json create mode 100644 resources/tester/test_m02_cf_sample.py diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Sample.ts b/M02_MasterTemplete/CostFlow/M02_CF_Sample.ts new file mode 100644 index 000000000..3673fe54e --- /dev/null +++ b/M02_MasterTemplete/CostFlow/M02_CF_Sample.ts @@ -0,0 +1,65 @@ +/* ============================================================================= + * M02_CF_Sample.ts + * 단가산출 다이어그램 견본 — 처음 열었을 때 바로 보고 따라 만들 견본 셋(`_sample/*.json` · 손으로 짬) + * ① 돌쌓기 메쌓기(인력) — 변수 · 품표 · 증가율표 · 인력 → 함수(표 찾기 · 높이 할증 · 직렬 · 병렬) → 출력 노무비 + * ② 공구손료(비율) — 노무비 × 율 → 출력 재료비 + * ③ 덤프트럭 시간당 작업량(값) — 변수 넷 → 함수 하나 → 출력 값 + * 견본은 읽기만 — `sampleLoad` 는 「복사해서 열기」 사본(key 없음 · 저장하면 `_data` 에 새 번호). + * 입력 값(노임 · 표)은 견본에 안 들고 열 때 · 저장 때 마스터에서 받음(정함 「계산」). + * 모양 = `M02_CF_Model` CfLogic · 알약 이름 규칙(입력 = 요소 이름 · 「표」 · 변수 이름 / 함수 = inputs[].port · + * output.name / 출력 = 「값」) — 정함 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` 11. 이 폴더째 지워도 됨. + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import type { CfLogic } from "./M02_CF_Model"; + +const FILES = import.meta.glob("./_sample/*.json", { eager: true, import: "default" }); + +/** 견본마다 한 줄 설명 — [한, 영] */ +const NOTE: Record = { + masonry: [ + "변수 · 품표 · 증가율표 · 인력 → 표 찾기 · 높이 할증 → 품 × 노임 → 노무비", + "Variables, tables and labor → lookup, height surcharge → crew × wage → labor cost", + ], + rate: ["노무비 × 공구손료율 → 재료비", "Labor cost × small-tools rate → material cost"], + value: [ + "q · f · E · Cm → 60 × q × f × E ÷ Cm → 값(시간당 작업량)", + "q, f, E, Cm → 60 × q × f × E ÷ Cm → value (output per hour)", + ], +}; + +/** 보여 줄 차례 */ +const ORDER = ["masonry", "rate", "value"]; + +export interface CfSampleItem { + /** 파일 이름(확장자 뺌) */ + id: string; + name: string; + resultUnit: string; + /** 쉬운 말 한 줄 */ + note: string; +} + +const idOf = (path: string): string => path.replace(/^.*\//, "").replace(/\.json$/, ""); +const byId = (): Map => + new Map(Object.entries(FILES).map(([path, lg]) => [idOf(path), lg])); + +/** 견본 목록 — 정한 차례 먼저 · 나머지는 이름 차례 */ +export function sampleList(): CfSampleItem[] { + const all = byId(); + const rank = (id: string): number => (ORDER.includes(id) ? ORDER.indexOf(id) : ORDER.length); + return [...all.entries()] + .sort(([a, x], [b, y]) => rank(a) - rank(b) || x.name.localeCompare(y.name)) + .map(([id, lg]) => ({ + id, + name: lg.name, + resultUnit: lg.resultUnit, + note: NOTE[id]?.[currentLanguageIndex === 1 ? 1 : 0] ?? "", + })); +} + +/** 견본 하나 「복사해서 열기」 — 깊은 사본 · key 없음(저장하면 서버가 새 번호) · 없으면 null */ +export function sampleLoad(id: string): CfLogic | null { + const lg = byId().get(id); + return lg ? { ...(JSON.parse(JSON.stringify(lg)) as CfLogic), key: null } : null; +} diff --git a/M02_MasterTemplete/CostFlow/_sample/masonry.json b/M02_MasterTemplete/CostFlow/_sample/masonry.json new file mode 100644 index 000000000..687251cd9 --- /dev/null +++ b/M02_MasterTemplete/CostFlow/_sample/masonry.json @@ -0,0 +1,785 @@ +{ + "key": "SAMPLE-masonry", + "name": "견본 · 돌쌓기 메쌓기(인력)", + "resultUnit": "원/㎡", + "boxes": [ + { + "id": "in-var", + "kind": "input", + "name": "쌓기 조건", + "color": "#3b82f6", + "unit": "-", + "x": 40, + "y": 40, + "input": { + "mode": "variable", + "elements": [], + "variables": [ + { + "name": "뒷길이", + "type": "choice", + "unit": "㎝", + "example": 30, + "choices": [25, 30, 35, 45, 55, 60, 75] + }, + { + "name": "돌", + "type": "choice", + "unit": "-", + "example": "깬돌", + "choices": ["깬잡석", "견치돌", "깬돌", "호박돌 및 야면석"] + }, + { + "name": "쌓기", + "type": "choice", + "unit": "-", + "example": "골쌓기", + "choices": ["골쌓기", "켜쌓기"] + }, + { + "name": "높이", + "type": "number", + "unit": "m", + "example": 4.5 + } + ] + } + }, + { + "id": "in-poom", + "kind": "input", + "name": "메쌓기 품표", + "color": "#3b82f6", + "unit": "-", + "x": 40, + "y": 300, + "input": { + "mode": "group", + "group": "소요량", + "elements": [], + "table": { + "key": "QF000421" + }, + "variables": [] + } + }, + { + "id": "in-rate", + "kind": "input", + "name": "높이 증가율표", + "color": "#3b82f6", + "unit": "-", + "x": 40, + "y": 460, + "input": { + "mode": "group", + "group": "계수", + "elements": [], + "table": { + "key": "QF000422" + }, + "variables": [] + } + }, + { + "id": "in-labor", + "kind": "input", + "name": "인력", + "color": "#3b82f6", + "unit": "-", + "x": 40, + "y": 620, + "input": { + "mode": "group", + "group": "인력", + "variables": [], + "elements": [ + { + "ref": "LB000033", + "name": "석공", + "spec": "", + "unit": "원/일" + }, + { + "ref": "LB000002", + "name": "보통인부", + "spec": "", + "unit": "원/일" + } + ] + } + }, + { + "id": "fn-rate", + "kind": "function", + "name": "증가율 찾기", + "color": "#f59e0b", + "unit": "%", + "x": 340, + "y": 420, + "func": { + "sheet": { + "id": "cf-rate", + "이름": "증가율 찾기", + "칸": { + "A1": { + "값": "이름" + }, + "B1": { + "값": "값" + }, + "C1": { + "값": "설명" + }, + "A2": { + "값": "높이" + }, + "C2": { + "값": "받는 값 · m" + }, + "A3": { + "값": "표에서 찾은 증가율" + }, + "B3": { + "식": "SUMIFS($G$2:$G$100,$E$2:$E$100,\"<\"&B2,$F$2:$F$100,\">=\"&B2)" + }, + "C3": { + "값": "높이가 「아래 < 높이 ≤ 위」 인 줄" + }, + "A4": { + "값": "증가율" + }, + "B4": { + "식": "IF(B2<=3,0,B3)" + }, + "C4": { + "값": "3m 까지는 할증 없음" + } + } + }, + "inputs": [ + { + "port": "높이", + "cell": "B2" + }, + { + "port": "증가율표", + "cell": "E1" + } + ], + "variables": [], + "output": { + "cell": "B4", + "name": "증가율", + "unit": "%" + } + } + }, + { + "id": "fn-poom0", + "kind": "function", + "name": "석공 품", + "color": "#f59e0b", + "unit": "인/㎡", + "x": 640, + "y": 80, + "func": { + "sheet": { + "id": "cf-poom0", + "이름": "석공 품", + "칸": { + "A1": { + "값": "이름" + }, + "B1": { + "값": "값" + }, + "C1": { + "값": "설명" + }, + "A2": { + "값": "뒷길이" + }, + "C2": { + "값": "받는 값 · ㎝" + }, + "A3": { + "값": "돌" + }, + "C3": { + "값": "받는 값" + }, + "A4": { + "값": "쌓기" + }, + "C4": { + "값": "받는 값" + }, + "A5": { + "값": "증가율" + }, + "C5": { + "값": "받는 값 · %" + }, + "A6": { + "값": "표에서 찾은 품" + }, + "B6": { + "식": "SUMIFS($H$2:$H$200,$E$2:$E$200,B2,$F$2:$F$200,B3,$G$2:$G$200,B4)" + }, + "C6": { + "값": "세 조건이 맞는 줄의 품" + }, + "A7": { + "값": "석공 품" + }, + "B7": { + "식": "B6*(1+B5/100)" + }, + "C7": { + "값": "높이 할증을 얹음" + } + } + }, + "inputs": [ + { + "port": "뒷길이", + "cell": "B2" + }, + { + "port": "돌", + "cell": "B3" + }, + { + "port": "쌓기", + "cell": "B4" + }, + { + "port": "증가율", + "cell": "B5" + }, + { + "port": "품표", + "cell": "E1" + } + ], + "variables": [], + "output": { + "cell": "B7", + "name": "석공 품", + "unit": "인/㎡" + } + } + }, + { + "id": "fn-poom1", + "kind": "function", + "name": "보통인부 품", + "color": "#f59e0b", + "unit": "인/㎡", + "x": 640, + "y": 300, + "func": { + "sheet": { + "id": "cf-poom1", + "이름": "보통인부 품", + "칸": { + "A1": { + "값": "이름" + }, + "B1": { + "값": "값" + }, + "C1": { + "값": "설명" + }, + "A2": { + "값": "뒷길이" + }, + "C2": { + "값": "받는 값 · ㎝" + }, + "A3": { + "값": "돌" + }, + "C3": { + "값": "받는 값" + }, + "A4": { + "값": "쌓기" + }, + "C4": { + "값": "받는 값" + }, + "A5": { + "값": "증가율" + }, + "C5": { + "값": "받는 값 · %" + }, + "A6": { + "값": "표에서 찾은 품" + }, + "B6": { + "식": "SUMIFS($I$2:$I$200,$E$2:$E$200,B2,$F$2:$F$200,B3,$G$2:$G$200,B4)" + }, + "C6": { + "값": "세 조건이 맞는 줄의 품" + }, + "A7": { + "값": "보통인부 품" + }, + "B7": { + "식": "B6*(1+B5/100)" + }, + "C7": { + "값": "높이 할증을 얹음" + } + } + }, + "inputs": [ + { + "port": "뒷길이", + "cell": "B2" + }, + { + "port": "돌", + "cell": "B3" + }, + { + "port": "쌓기", + "cell": "B4" + }, + { + "port": "증가율", + "cell": "B5" + }, + { + "port": "품표", + "cell": "E1" + } + ], + "variables": [], + "output": { + "cell": "B7", + "name": "보통인부 품", + "unit": "인/㎡" + } + } + }, + { + "id": "fn-pay0", + "kind": "function", + "name": "석공 노무비", + "color": "#f59e0b", + "unit": "원/㎡", + "x": 940, + "y": 80, + "func": { + "sheet": { + "id": "cf-pay0", + "이름": "석공 노무비", + "칸": { + "A1": { + "값": "이름" + }, + "B1": { + "값": "값" + }, + "C1": { + "값": "설명" + }, + "A2": { + "값": "석공 품" + }, + "C2": { + "값": "받는 값 · 인/㎡" + }, + "A3": { + "값": "석공 노임" + }, + "C3": { + "값": "받는 값 · 원/인" + }, + "A4": { + "값": "석공 노무비" + }, + "B4": { + "식": "B2*B3" + }, + "C4": { + "값": "품 × 노임" + } + } + }, + "inputs": [ + { + "port": "석공 품", + "cell": "B2" + }, + { + "port": "석공 노임", + "cell": "B3" + } + ], + "variables": [], + "output": { + "cell": "B4", + "name": "석공 노무비", + "unit": "원/㎡" + } + } + }, + { + "id": "fn-pay1", + "kind": "function", + "name": "보통인부 노무비", + "color": "#f59e0b", + "unit": "원/㎡", + "x": 940, + "y": 300, + "func": { + "sheet": { + "id": "cf-pay1", + "이름": "보통인부 노무비", + "칸": { + "A1": { + "값": "이름" + }, + "B1": { + "값": "값" + }, + "C1": { + "값": "설명" + }, + "A2": { + "값": "보통인부 품" + }, + "C2": { + "값": "받는 값 · 인/㎡" + }, + "A3": { + "값": "보통인부 노임" + }, + "C3": { + "값": "받는 값 · 원/인" + }, + "A4": { + "값": "보통인부 노무비" + }, + "B4": { + "식": "B2*B3" + }, + "C4": { + "값": "품 × 노임" + } + } + }, + "inputs": [ + { + "port": "보통인부 품", + "cell": "B2" + }, + { + "port": "보통인부 노임", + "cell": "B3" + } + ], + "variables": [], + "output": { + "cell": "B4", + "name": "보통인부 노무비", + "unit": "원/㎡" + } + } + }, + { + "id": "fn-sum", + "kind": "function", + "name": "노무비 합", + "color": "#f59e0b", + "unit": "원/㎡", + "x": 1240, + "y": 190, + "func": { + "sheet": { + "id": "cf-sum", + "이름": "노무비 합", + "칸": { + "A1": { + "값": "이름" + }, + "B1": { + "값": "값" + }, + "C1": { + "값": "설명" + }, + "A2": { + "값": "석공 노무비" + }, + "C2": { + "값": "받는 값" + }, + "A3": { + "값": "보통인부 노무비" + }, + "C3": { + "값": "받는 값" + }, + "A4": { + "값": "노무비" + }, + "B4": { + "식": "B2+B3" + }, + "C4": { + "값": "둘을 더함" + } + } + }, + "inputs": [ + { + "port": "석공 노무비", + "cell": "B2" + }, + { + "port": "보통인부 노무비", + "cell": "B3" + } + ], + "variables": [], + "output": { + "cell": "B4", + "name": "노무비", + "unit": "원/㎡" + } + } + }, + { + "id": "out-pay", + "kind": "output", + "name": "노무비", + "color": "#10b981", + "unit": "원/㎡", + "x": 1520, + "y": 190, + "output": { + "cost": "노무비", + "unit": "원/㎡" + } + } + ], + "links": [ + { + "id": "l1", + "from": { + "box": "in-var", + "name": "높이" + }, + "to": { + "box": "fn-rate", + "name": "높이" + } + }, + { + "id": "l2", + "from": { + "box": "in-rate", + "name": "표" + }, + "to": { + "box": "fn-rate", + "name": "증가율표" + } + }, + { + "id": "l3", + "from": { + "box": "in-var", + "name": "뒷길이" + }, + "to": { + "box": "fn-poom0", + "name": "뒷길이" + } + }, + { + "id": "l4", + "from": { + "box": "in-var", + "name": "돌" + }, + "to": { + "box": "fn-poom0", + "name": "돌" + } + }, + { + "id": "l5", + "from": { + "box": "in-var", + "name": "쌓기" + }, + "to": { + "box": "fn-poom0", + "name": "쌓기" + } + }, + { + "id": "l6", + "from": { + "box": "fn-rate", + "name": "증가율" + }, + "to": { + "box": "fn-poom0", + "name": "증가율" + } + }, + { + "id": "l7", + "from": { + "box": "in-poom", + "name": "표" + }, + "to": { + "box": "fn-poom0", + "name": "품표" + } + }, + { + "id": "l8", + "from": { + "box": "in-var", + "name": "뒷길이" + }, + "to": { + "box": "fn-poom1", + "name": "뒷길이" + } + }, + { + "id": "l9", + "from": { + "box": "in-var", + "name": "돌" + }, + "to": { + "box": "fn-poom1", + "name": "돌" + } + }, + { + "id": "l10", + "from": { + "box": "in-var", + "name": "쌓기" + }, + "to": { + "box": "fn-poom1", + "name": "쌓기" + } + }, + { + "id": "l11", + "from": { + "box": "fn-rate", + "name": "증가율" + }, + "to": { + "box": "fn-poom1", + "name": "증가율" + } + }, + { + "id": "l12", + "from": { + "box": "in-poom", + "name": "표" + }, + "to": { + "box": "fn-poom1", + "name": "품표" + } + }, + { + "id": "l13", + "from": { + "box": "fn-poom0", + "name": "석공 품" + }, + "to": { + "box": "fn-pay0", + "name": "석공 품" + } + }, + { + "id": "l14", + "from": { + "box": "in-labor", + "name": "석공" + }, + "to": { + "box": "fn-pay0", + "name": "석공 노임" + } + }, + { + "id": "l15", + "from": { + "box": "fn-poom1", + "name": "보통인부 품" + }, + "to": { + "box": "fn-pay1", + "name": "보통인부 품" + } + }, + { + "id": "l16", + "from": { + "box": "in-labor", + "name": "보통인부" + }, + "to": { + "box": "fn-pay1", + "name": "보통인부 노임" + } + }, + { + "id": "l17", + "from": { + "box": "fn-pay0", + "name": "석공 노무비" + }, + "to": { + "box": "fn-sum", + "name": "석공 노무비" + } + }, + { + "id": "l18", + "from": { + "box": "fn-pay1", + "name": "보통인부 노무비" + }, + "to": { + "box": "fn-sum", + "name": "보통인부 노무비" + } + }, + { + "id": "l19", + "from": { + "box": "fn-sum", + "name": "노무비" + }, + "to": { + "box": "out-pay", + "name": "값" + } + } + ], + "userElements": [] +} diff --git a/M02_MasterTemplete/CostFlow/_sample/rate.json b/M02_MasterTemplete/CostFlow/_sample/rate.json new file mode 100644 index 000000000..5a1faa636 --- /dev/null +++ b/M02_MasterTemplete/CostFlow/_sample/rate.json @@ -0,0 +1,146 @@ +{ + "key": "SAMPLE-rate", + "name": "견본 · 공구손료(비율)", + "resultUnit": "원", + "boxes": [ + { + "id": "in-var", + "kind": "input", + "name": "바탕 값", + "color": "#3b82f6", + "unit": "-", + "x": 40, + "y": 80, + "input": { + "mode": "variable", + "elements": [], + "variables": [ + { + "name": "노무비", + "type": "number", + "unit": "원", + "example": 100000 + }, + { + "name": "공구손료율", + "type": "number", + "unit": "%", + "example": 3 + } + ] + } + }, + { + "id": "fn-rate", + "kind": "function", + "name": "공구손료", + "color": "#f59e0b", + "unit": "원", + "x": 340, + "y": 80, + "func": { + "sheet": { + "id": "cf-tool", + "이름": "공구손료", + "칸": { + "A1": { + "값": "이름" + }, + "B1": { + "값": "값" + }, + "C1": { + "값": "설명" + }, + "A2": { + "값": "노무비" + }, + "C2": { + "값": "받는 값 · 원" + }, + "A3": { + "값": "공구손료율" + }, + "C3": { + "값": "받는 값 · %" + }, + "A4": { + "값": "공구손료" + }, + "B4": { + "식": "B2*B3/100" + }, + "C4": { + "값": "노무비 × 율 ÷ 100" + } + } + }, + "inputs": [ + { + "port": "노무비", + "cell": "B2" + }, + { + "port": "공구손료율", + "cell": "B3" + } + ], + "variables": [], + "output": { + "cell": "B4", + "name": "공구손료", + "unit": "원" + } + } + }, + { + "id": "out-mat", + "kind": "output", + "name": "재료비", + "color": "#10b981", + "unit": "원", + "x": 640, + "y": 80, + "output": { + "cost": "재료비", + "unit": "원" + } + } + ], + "links": [ + { + "id": "l1", + "from": { + "box": "in-var", + "name": "노무비" + }, + "to": { + "box": "fn-rate", + "name": "노무비" + } + }, + { + "id": "l2", + "from": { + "box": "in-var", + "name": "공구손료율" + }, + "to": { + "box": "fn-rate", + "name": "공구손료율" + } + }, + { + "id": "l3", + "from": { + "box": "fn-rate", + "name": "공구손료" + }, + "to": { + "box": "out-mat", + "name": "값" + } + } + ], + "userElements": [] +} diff --git a/M02_MasterTemplete/CostFlow/_sample/value.json b/M02_MasterTemplete/CostFlow/_sample/value.json new file mode 100644 index 000000000..a9f8e823b --- /dev/null +++ b/M02_MasterTemplete/CostFlow/_sample/value.json @@ -0,0 +1,200 @@ +{ + "key": "SAMPLE-value", + "name": "견본 · 덤프트럭 시간당 작업량(값)", + "resultUnit": "㎥/시간", + "boxes": [ + { + "id": "in-var", + "kind": "input", + "name": "덤프트럭 조건", + "color": "#3b82f6", + "unit": "-", + "x": 40, + "y": 80, + "input": { + "mode": "variable", + "elements": [], + "variables": [ + { + "name": "q", + "type": "number", + "unit": "㎥", + "example": 6 + }, + { + "name": "f", + "type": "number", + "unit": "-", + "example": 1.2 + }, + { + "name": "E", + "type": "number", + "unit": "-", + "example": 0.9 + }, + { + "name": "Cm", + "type": "number", + "unit": "분", + "example": 25 + } + ] + } + }, + { + "id": "fn-q", + "kind": "function", + "name": "시간당 작업량", + "color": "#f59e0b", + "unit": "㎥/시간", + "x": 340, + "y": 80, + "func": { + "sheet": { + "id": "cf-q", + "이름": "시간당 작업량", + "칸": { + "A1": { + "값": "이름" + }, + "B1": { + "값": "값" + }, + "C1": { + "값": "설명" + }, + "A2": { + "값": "q" + }, + "C2": { + "값": "적재량 · ㎥" + }, + "A3": { + "값": "f" + }, + "C3": { + "값": "토량환산계수" + }, + "A4": { + "값": "E" + }, + "C4": { + "값": "작업효율" + }, + "A5": { + "값": "Cm" + }, + "C5": { + "값": "1회 사이클 시간 · 분" + }, + "A6": { + "값": "시간당 작업량" + }, + "B6": { + "식": "ROUND(60*B2*B3*B4/B5,3)" + }, + "C6": { + "값": "60 × q × f × E ÷ Cm · 소수 셋째 자리" + } + } + }, + "inputs": [ + { + "port": "q", + "cell": "B2" + }, + { + "port": "f", + "cell": "B3" + }, + { + "port": "E", + "cell": "B4" + }, + { + "port": "Cm", + "cell": "B5" + } + ], + "variables": [], + "output": { + "cell": "B6", + "name": "시간당 작업량", + "unit": "㎥/시간" + } + } + }, + { + "id": "out-q", + "kind": "output", + "name": "시간당 작업량", + "color": "#10b981", + "unit": "㎥/시간", + "x": 640, + "y": 80, + "output": { + "cost": "값", + "unit": "㎥/시간" + } + } + ], + "links": [ + { + "id": "l1", + "from": { + "box": "in-var", + "name": "q" + }, + "to": { + "box": "fn-q", + "name": "q" + } + }, + { + "id": "l2", + "from": { + "box": "in-var", + "name": "f" + }, + "to": { + "box": "fn-q", + "name": "f" + } + }, + { + "id": "l3", + "from": { + "box": "in-var", + "name": "E" + }, + "to": { + "box": "fn-q", + "name": "E" + } + }, + { + "id": "l4", + "from": { + "box": "in-var", + "name": "Cm" + }, + "to": { + "box": "fn-q", + "name": "Cm" + } + }, + { + "id": "l5", + "from": { + "box": "fn-q", + "name": "시간당 작업량" + }, + "to": { + "box": "out-q", + "name": "값" + } + } + ], + "userElements": [] +} diff --git a/resources/tester/test_m02_cf_sample.py b/resources/tester/test_m02_cf_sample.py new file mode 100644 index 000000000..da6c1e7c8 --- /dev/null +++ b/resources/tester/test_m02_cf_sample.py @@ -0,0 +1,249 @@ +"""M02 단가산출 다이어그램 견본(35-7) 시험 — `M02_MasterTemplete/CostFlow/_sample/*.json` · `M02_CF_Sample.ts`. + +· 견본 셋 모양 — 상자(입력 · 함수 · 출력) · 알약 이름 규칙 · 선(받는 자리마다 하나 · 출력에서 안 나감 · 순환 없음) +· 견본 ① 원가 = 지금 `/calc`(GF000219 · 같은 예시값 · 끝수 원 미만) — 함수 상자 시트를 우리 스프레드시트 엔진 + (Node 번들)으로 차례대로 셈 · 입력 값(노임 · 품셈 표)은 마스터에서 읽기만 +· ③ 값 = `/calc`(GF000133) · ② = 노무비 × 율 / 100 +· `sampleList` · `sampleLoad`(사본 · key 없음) — Node 로 바로 돌림 · 파일 안 씀 +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from decimal import Decimal +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +FOLDER = ROOT / "M02_MasterTemplete" / "CostFlow" +SAMPLE_TS = FOLDER / "M02_CF_Sample.ts" +SAMPLES = {p.stem: json.loads(p.read_text(encoding="utf-8")) for p in sorted((FOLDER / "_sample").glob("*.json"))} # fmt: skip + +HOOKS = r""" +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { stripTypeScriptTypes } from "node:module"; +export async function load(url, ctx, next) { + if (!url.endsWith(".ts")) return next(url, ctx); + let src = readFileSync(fileURLToPath(url), "utf8"); + src = src.replace(/import\.meta\.glob(?:<[\s\S]*?>)?\([^)]*\)/g, "globalThis.__samples"); + src = src.replace(/import\s*\{([^}]*)\}\s*from\s*"([^"]+)";/g, (m, names) => { + const list = names.split(",").map((x) => x.trim()).filter((x) => x && !x.startsWith("type ")); + return list.length ? `const { ${list.join(", ")} } = globalThis.__stub;` : ""; + }); + return { format: "module", source: stripTypeScriptTypes(src, { mode: "transform" }), shortCircuit: true }; +} +""" + + +def _node(body: str) -> object: + files = {f"./_sample/{k}.json": v for k, v in SAMPLES.items()} + script = ( + "import { register } from 'node:module';\n" + f"register('data:text/javascript,' + encodeURIComponent({json.dumps(HOOKS)}));\n" + "globalThis.__stub = { currentLanguageIndex: 0 };\n" + f"globalThis.__samples = {json.dumps(files, ensure_ascii=False)};\n" + f"const S = await import({json.dumps(SAMPLE_TS.as_uri())});\n" + "const out = (x) => process.stdout.write(JSON.stringify(x));\n" + body + ) + result = subprocess.run( + ["node", "--no-warnings", "--input-type=module", "-e", script], + capture_output=True, text=True, encoding="utf-8", timeout=120, cwd=ROOT, + ) # fmt: skip + assert result.returncode == 0, result.stderr[-2000:] + return json.loads(result.stdout) + + +# ── 모양 ───────────────────────────────────────────────────────────────── +def _ports_out(box: dict) -> list[str]: + if box["kind"] == "function": + return [box["func"]["output"]["name"]] + if box["kind"] == "output": + return [] + inp = box["input"] + return ( + [e["name"] for e in inp.get("elements", [])] + + (["표"] if inp.get("table") else []) + + [v["name"] for v in inp.get("variables", [])] + ) + + +def _ports_in(box: dict) -> list[str]: + if box["kind"] == "function": + return [i["port"] for i in box["func"]["inputs"]] + return ["값"] if box["kind"] == "output" else [] + + +def _order(lg: dict) -> list[dict]: + """함수 상자 차례(앞 값을 받는 것이 뒤) — 순환이면 실패""" + boxes = {b["id"]: b for b in lg["boxes"]} + need = {b["id"]: {link["from"]["box"] for link in lg["links"] if link["to"]["box"] == b["id"]} for b in lg["boxes"]} # fmt: skip + done: list[str] = [] + while len(done) < len(boxes): + ready = [i for i in boxes if i not in done and need[i] <= set(done)] + assert ready, "선이 돌고 돎" + done += ready + return [boxes[i] for i in done] + + +@pytest.mark.parametrize("name", ["masonry", "rate", "value"]) +def test_견본_모양(name: str) -> None: + lg = SAMPLES[name] + assert lg["key"] and lg["name"] and lg["resultUnit"] and lg["userElements"] == [] + boxes = {b["id"]: b for b in lg["boxes"]} + assert len(boxes) == len(lg["boxes"]) + sheets = [b["func"]["sheet"]["id"] for b in lg["boxes"] if b["kind"] == "function"] + assert len(set(sheets)) == len(sheets) + for b in lg["boxes"]: + assert b["kind"] in ("input", "function", "output") + assert ( + b["color"].startswith("#") + and b["unit"] + and isinstance(b["x"], int) + and isinstance(b["y"], int) + ) + if b["kind"] == "function": + fn = b["func"] + cells = fn["sheet"]["칸"] + assert "식" in cells[fn["output"]["cell"]] # 출력 하나 = 수식 칸 + assert len({i["cell"] for i in fn["inputs"]}) == len(fn["inputs"]) + if b["kind"] == "output": + assert b["output"]["cost"] in ("노무비", "재료비", "경비", "값") + for link in lg["links"]: + a, z = boxes[link["from"]["box"]], boxes[link["to"]["box"]] + assert link["from"]["name"] in _ports_out(a), link + assert link["to"]["name"] in _ports_in(z), link + # 받는 자리마다 선 하나 · 출력에서 나가는 선 없음 · 왼 → 오 + for b in lg["boxes"]: + for port in _ports_in(b): + got = [x for x in lg["links"] if x["to"]["box"] == b["id"] and x["to"]["name"] == port] + assert len(got) == 1, (b["id"], port) + for link in lg["links"]: + assert boxes[link["from"]["box"]]["kind"] != "output" + assert boxes[link["from"]["box"]]["x"] < boxes[link["to"]["box"]]["x"] + _order(lg) + + +def test_견본_목록_열기() -> None: + got = _node( + "out({ list: S.sampleList(), one: S.sampleLoad('masonry'), none: S.sampleLoad('없음') });" + ) + assert [x["id"] for x in got["list"]] == ["masonry", "rate", "value"] + assert all(x["note"] for x in got["list"]) + assert got["one"]["key"] is None and got["one"]["boxes"] == SAMPLES["masonry"]["boxes"] + assert got["none"] is None + + +# ── 값 ─────────────────────────────────────────────────────────────────── +@pytest.fixture(scope="module") +def master(): + from M01_MasterData import M01_MasterData_Store as store + + cm = store.cm + files = cm.load(folder=store.FOLDER) + return cm, files + + +def _table_rows(table: dict) -> list[list]: + """품셈 표 → 블록(머리 줄 + 줄) — 서버 「표」 와 같은 규칙(범위 = 「이름·아래」 「이름·위」 · 숫자 하나면 둘 다)""" + conds, lines = table.get("조건") or {}, table.get("줄") or [] + names = [*conds, *(table.get("값칸") or {})] + split = { + n for n in names if conds.get(n) == "범위" or any(isinstance(r.get(n), list) for r in lines) + } + head = [h for n in names for h in ((f"{n}·아래", f"{n}·위") if n in split else (n,))] + rows = [] + for r in lines: + line: list = [] + for n in names: + v = r.get(n) + if n in split: + line += list(v) if isinstance(v, list) else [v, v] + else: + line.append(v) + rows.append(line) + return [head, *rows] + + +def _plain(v): + return float(v) if isinstance(v, Decimal) else v + + +def _run(lg: dict, master) -> dict: + """상자 차례대로 — 입력 값(예시값 · 노임 · 표) → 함수 상자 시트에 넣고 엔진으로 셈 → 출력 상자 값""" + from common_util.common_util_spreadsheet import recalc_workbooks + + cm, files = master + m = cm.mf.Master(files) + tables = {str(t["키"]): t for d in files.values() if d.get("그룹") in cm.mf.TABLE_GROUPS for t in d.get("표") or []} # fmt: skip + ports: dict[str, dict] = {} + for b in _order(lg): + into = {x["to"]["name"]: ports[x["from"]["box"]][x["from"]["name"]] for x in lg["links"] if x["to"]["box"] == b["id"]} # fmt: skip + if b["kind"] == "input": + inp = b["input"] + got = {v["name"]: v["example"] for v in inp.get("variables", [])} + got |= { + e["name"]: _plain(cm.mf.element(m, e["ref"], {})[0]) + for e in inp.get("elements", []) + } + if inp.get("table"): + got["표"] = _table_rows(tables[inp["table"]["key"]]) + ports[b["id"]] = got + elif b["kind"] == "function": + fn = b["func"] + sheet = json.loads(json.dumps(fn["sheet"])) + for i in fn["inputs"]: + value = into[i["port"]] + col, row = ord(i["cell"][0]) - 65, int(i["cell"][1:]) + block = value if isinstance(value, list) else [[value]] + for r, line in enumerate(block): + for c, x in enumerate(line): + if x is not None: + sheet["칸"][f"{chr(65 + col + c)}{row + r}"] = {"값": _plain(x)} + book = {"종류": "통합문서", "판": 1, "열": "cf", "서식": [{}], "시트": [sheet]} + out = recalc_workbooks([book])[0][sheet["id"]][fn["output"]["cell"]] + assert "수" in out, out + ports[b["id"]] = {fn["output"]["name"]: float(out["수"])} + else: + ports[b["id"]] = {"값": into["값"]} + return {b["id"]: ports[b["id"]]["값"] for b in lg["boxes"] if b["kind"] == "output"} + + +def test_견본1_원가_calc_와_같음(master) -> None: + from M01_MasterData.LogicBuilder import M01_LB_Calc as lb + + cm, files = master + row = next( + r + for d in files.values() + if d.get("그룹") == "로직" + for r in d["줄"] + if r["키"] == "GF000219" + ) + given = {v["name"]: v["example"] for v in SAMPLES["masonry"]["boxes"][0]["input"]["variables"]} + want = lb.calc(json.loads(json.dumps(row, default=_plain)), {**given, "초과증가율": 80}, {}) + assert want["ok"], want + got = _run(SAMPLES["masonry"], master)["out-pay"] + print(f"\n견본 ① 노무비 {got} · /calc {want['sums']['계']}") + assert abs(got - float(want["sums"]["계"])) < 1 + + +def test_견본3_값_calc_와_같음_견본2_비율(master) -> None: + from M01_MasterData.LogicBuilder import M01_LB_Calc as lb + + cm, files = master + row = next( + r + for d in files.values() + if d.get("그룹") == "로직" + for r in d["줄"] + if r["키"] == "GF000133" + ) + given = {v["name"]: v["example"] for v in SAMPLES["value"]["boxes"][0]["input"]["variables"]} + given["qt"] = given.pop("q") # 견본은 정함대로 「q」 · 기존 로직 입력 이름은 「qt」 + want = lb.calc(json.loads(json.dumps(row, default=_plain)), given, {}) + assert _run(SAMPLES["value"], master)["out-q"] == float(want["result"]) + assert _run(SAMPLES["rate"], master)["out-mat"] == 100000 * 3 / 100