From ab36bcd18c84c8307236ca18d51a9d28412cfeb0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 5 Nov 2026 18:38:29 +0900 Subject: [PATCH 1/3] =?UTF-8?q?knowledge(=EC=9A=94=EA=B5=AC):=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20794-2a=20=E2=80=94=20M01=20=EB=B3=B4=EA=B0=95=20?= =?UTF-8?q?=ED=9B=84=EB=B3=B4=20=C2=B7=20=ED=92=88=EC=85=88=20=ED=8C=90=20?= =?UTF-8?q?=EC=B0=A8=EC=9D=B4=20=C2=B7=20=ED=8C=90=20=ED=91=9C=EC=A7=80=20?= =?UTF-8?q?=EA=BC=B4=20=C2=B7=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EA=B3=A0?= =?UTF-8?q?=EB=A5=BC=20=EA=B2=83=20=EC=97=AC=EB=8D=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../_분석_652-794-2a_M01보강_판차이.md | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 docs/raw/요구_1104/_분석_652-794-2a_M01보강_판차이.md diff --git a/docs/raw/요구_1104/_분석_652-794-2a_M01보강_판차이.md b/docs/raw/요구_1104/_분석_652-794-2a_M01보강_판차이.md new file mode 100644 index 000000000..452a72ffc --- /dev/null +++ b/docs/raw/요구_1104/_분석_652-794-2a_M01보강_판차이.md @@ -0,0 +1,114 @@ +# 794-2a M01 보강 · 품셈 판 차이 — 조사 · 제안 + +- 근거 = 793-3a 근거 제안 엔진(`M02_San_Basis`)을 실무 여덟 파일 459 블록에 다시 돌린 판정 — 확정 125 · 근접 135 · 사람 114 · 없음 85 +- 짝 자료 = `794_2a_M01보강_판차이.json`(갈래 · 블록 목록 · 원문 자리 · 근접 43 줄 판정 · 어림 수) · 만든 스크립트 `dump.py` · `build.py` · 갈래 묶음 `groups.json`(같은 폴더) +- 원문 자리 앞머리 `resources/knowledge/original/원가계산/` 줄임 · 「건설」 = 2026 건설공사 표준품셈 · 「산림」 = 산림사업 표준품셈(2026-01-01 시행) +- 읽기만 — M01 마스터 · 코드 · 지식DB 고침 없음 + +## 1. 한눈에 + +| 묶음 | 갈래(번) | 블록 | +| --- | --- | --- | +| M01 보강 대상(없음 · 꼴 모자람) | 1 · 2 · 3 · 6 · 9 | 37 | +| 현행 판 원문에 없음(옛 판 · 실무만) | 5 · 10 · 11 · 15 | 4 | +| M01 있음 — 엔진이 못 이음 · 판 차이 | 4 · 7 · 8 · 13 | 33 | +| 조합 블록 · 빈 블록(보강 아님) | 12 · 14 · 16 | 11 | +| 계 | | 85 | + +- 「없음」 85 가운데 M01 에 정말 없는 품셈 항목은 다섯 갈래 37 블록 — 나머지 48 은 M01 에 있으나 이름 · 옛 번호 · 조합 때문에 엔진이 못 이은 것 +- 판 차이 — 근접 후보 중 품 다름 43 줄 판정: 확정 4 · 의심 7 · 옛 임도품셈 3 · 못 가름 6 · 판 아님 19 · 기초 잘못 짚음 4 +- 철근 조립 3.4 ↔ 3.5(×0.9714)는 봉화#98 하나 — 판 차이 확정(4 장) +- M01 값 = 현행 판(건설 2026 · 산림 2026-01-01) — 예정가격작성기준 제6조② 「표준품셈을 이용하여 원가계산을 하는 경우에는 가장 최근의 표준품셈을 이용」(`행정규칙/(계약예규) 예정가격작성기준/현행_20260825.md:51`) → M01 값은 그대로 · 옛 판은 표지로만 + +## 2. M01 에 없는 항목 — 보강 후보 + +| 번 | 갈래 | 판정 | 블록 · 파일 | 원문 자리 | 원문 표 · 식 | M01 에 들 꼴 | 우선 | +| --- | --- | --- | --- | --- | --- | --- | --- | +| 1 | 자재 운반 — 구역화물 운임 + 하차비(철근 · 시멘트 · 철강재) | M01 없음 · 원문 있음 | 10 · 북부청임도 · 북부청사방 · 거창 · 봉화 · 동부청영월 · 울진대흥 | 산림 10-1 `산림_표준품셈/본문/제10장_자재․장비_운반/10-01_시멘트운반.md:16` · 10-2 `10-02_철근운반.md:16` · 건설 공통 1-2-7 `건설공사_표준품셈/본문/01_공통부문/제01장_적용기준/1-02_설계_및_수량.md:445` | 표 있음(운반비 = 10.5ton 구역화물 · 하차비 1회) · 품 수 없음 — 하차 품(철근 11분/ton · 시멘트 적상 0.18 · 적하 0.13 인)은 옛 건설품셈 「품종별 적상·적하 기준」 값 · 현행 원문에 없음 | `로직_산림품셈_10장_자재․장비_운반.json` GF 두 줄 — 입력 = 구역화물 톤급 · 거리(운임 = 자재단가 구역화물 줄) · 하차 = 입력 칸 | 상 | +| 3 | 덤프 운반 조합 — 적재기계(굴착기 0.2~1.0 · 로더) · 덤프 톤급(8 · 15 · 24) | M01 일부 있음 · 고르기 꼴 없음 | 14 · 여덟 파일 중 일곱 | 산림 10-12-1 `제10장_자재․장비_운반/10-12_덤프운반.md:18` · 10-3 `10-03_골재운반.md:16` · 건설 공통 8-2-8 `제08장_건설기계/8-02-08_덤프트럭.md:16` · 8-2-3 `8-02-03_굴착기.md:16` · 8-2-5 `8-02-05_로더.md:16` | 식 있음(8-2-8 Cm = t1+t2+t3+t4 · 8-2-3 · 8-2-5 Q 식) · 산림 10-12-1 = 굴착기 0.7 + 덤프 15ton 고정 | GF000284 입력에 적재기계 · 덤프 톤급 고르기 더함(계수 = 8-2-3 · 8-2-5 · 8-2-8 표) 또는 자체 GX 조합 한 줄 | 상 | +| 9 | 야면석 · 깬잡석 · 고임돌 · 파쇄석 기계 채집 — 굴착기 1회 N개 | M01 없음 · 원문 품 없음 | 8 · 봉화 · 울진대흥 · 울진소광 | 산림 13-2-4 `제13장_구조물/13-02_채집_및_세척.md:76` · 13-2-3 같은 파일 `:43` · 건설 공통 8-2-3 | 13-2-4 주② 「기계화 시공이 가능한 지역은 상기 품을 적용하지 않는다」 · 13-2-3 주⑪ 「건설 8-2-3 굴착기(2025) 참조」 — 기계 채집 품 없음 · 실무 = 8-2-3 Q 식 + 1회 개수 | 자체 GX 한 줄(`로직_자체.json`) — 입력 = 돌 뒷길이 · 1회 개수 · 굴착기 규격 · 소운반 덤프 → GC001147 부름 | 상 | +| 6 | 잔토처리(인력) 단독 — ㎥당 보통인부 0.2인 | M01 옵션만 있음 · 단독 로직 없음 | 3 · 북부청임도 · 북부청사방 · 동부청영월 | 건설 공통 3-2-1 `제03장_토공사/3-02_굴착.md:18`('08 · '20 · '25 보완) | 표 비고 한 줄 | `로직_건설품셈_03장_토공사.json` GC 한 줄(호표 보통인부 = QC000172 「잔토처리 보통인부」 · GC000083 옵션과 같은 표) | 중 | +| 2 | 소형 기계 운반 — 구역화물 대당(믹서 · 소형 중기) | 원문 없음 · 실무 근거 | 2 · 봉화 · 울진대흥 | 산림 10-4 `10-04_중기운반.md:16`(트레일러만) | 구역화물 대당 운반 원문 없음 · 실무 = 운임 ÷ 1.1 | 1번과 한 꼴(자체 GX) | 중 | +| 5 | 노면정리(옛 임도품셈 7-3) | 현행 판 원문 없음 | 1 · 봉화 | 산림 9-19 `제09장_토공/9-19_면고르기.md:16`(가까운 절) | 현행 산림품셈에 노면정리 절 없음 · 블록은 건설 8-2-3 식 | 보강 보류 — 4번 길로 덮임 | 하 | +| 10 | 글자새김(2011 건설품셈 7-5) | 현행 판 원문 없음 · 옛 판 표 | 1 · 봉화 | 없음 | 블록 글에 2011 판 표(글자 6~30cm × 한글 · 한문 × 둥글게 · 보통) | 자체 GX + 계수 표(출처 「건설품셈 2011 7-5」) | 하 | +| 11 | 컨테이너 운반 — 트럭탑재형 크레인 10ton(옛 9-38-2) | 현행 판 원문 없음 | 1 · 동부청영월 | 산림 10-4 · 건설 공통 8-3(손료만) | 현행 8-2 에 트럭탑재형 크레인 작업량 절 없음 | 자체 GX(왕복 시간 식 · 크레인 시간당 경비) | 하 | +| 15 | 떼 하차비 — 인력 적하(2인 × 16분/450분) | 현행 판 원문 없음 | 1 · 울진소광 | 건설 공통 1-2-7 | 현행 1-2-7 = 인력운반 기본공식만 | 1번 하차비 칸과 한 꼴 | 하 | + +- 블록 번호 전부는 json `없음갈래[].블록` +- 우선 = 걸린 블록 수 · 임도 · 사방에서 자주 씀(운반 · 채집 · 덤프는 여덟 파일 거의 모두) + +## 3. M01 에 있는데 「없음」 — 보강 아님 + +| 번 | 갈래 | 블록 | 이을 M01 | 몫 | +| --- | --- | --- | --- | --- | +| 4 | 굴착기 단독 작업(잔토정리 · 사토처리 · 관리도로개설 · 토사절취 · 노면 고르기 …) | 13 | GC001147 · GC001148(건설 공통 8-2-3) | 엔진 — 제목에 품셈 이름 없는 Q 식 블록도 기계 작업량 길로 | +| 7 | 구조물터파기(인력) — 옛 3-1-3 ㎥당 0.26인 · 2025 3-2-1 세 토질 평균 | 3 | GC000083 | 판 표지 + 여러 토질 평균 = 현장 값 | +| 8 | 암 터파기(대형브레이커 + 굴착기) | 6 | GF000263 · GF000265 · GC000090 | 판 표지(옛 임도품셈 5-1-2 Q 3.2~3.8 평균 3.5) + 여러 암 평균 | +| 13 | 이름 · 옛 번호 · 단위로 못 이음 | 11 | 휠라 = GC000227 · 잡관목제거 = GC000129 · 줄떼 = GF000033 · 평떼 = GC000143 · 단끊기 = GF000298 · 씨뿌리기(줄) = GF000029(원/m ↔ ㎡) · 기계비빔 = GF000156 · 뒷채움 = GF000215 · GF000218 · 보조기층 = GC000281~283 · 구조물터파기 토사 = GF000101 · GF000103 · 콘크리트 소운반 = GC001140 | 엔진 같은 말 묶음 · 옛 번호 대조표 · 단위 환산 · 콘크리트 소운반만 GC001140 입력(V · t) 넓힘 | +| 12 | 교통통제(신호수 · 공사안내판 · PE드럼) | 1 | GC000052(건설 공통 2-9-4 · 블록은 옛 2-8-20) | 조합 · 안내판 설치 품은 현행 원문 없음(2-9-2 주③ 별도 계상) | +| 14 | 조합 블록(연결철근 30% · 발생석 50% · 배수관 유출 · 유입구 · 차수벽) | 5 | 793-2 SK 조합 | 보강 아님 | +| 16 | 빈 블록(무대 0 원 · 글 없음) | 5 | — | 보강 아님 | + +## 4. 판 차이 + +### 4-1. 블록별 + +| 블록(파일 연도) | M01 기초 · 절 | 원문 개정 | 엑셀 ↔ M01 | 판정 | +| --- | --- | --- | --- | --- | +| 봉화#98 (2024) | GC000207 철근 현장조립 · 건설 공통 6-2-3 `제06장_철근콘크리트공사/6-02_철근.md:59` | '08 · '14 · '22 · '24 | 철근공 6/3.5 = 1.714 ↔ 6/3.4 = 1.765 (×0.9714) | 판 차이 확정 — 같은 해 동부청영월#60(「24년 건설표준품셈 6-2-3」)은 3.4 로 확정 → 봉화 = '24 보완 전 판 | +| 거창#45 (2025) | GC000211 합판거푸집 · 6-3-1 | 2017 뒤 '18 · '22 · '24 · '26 | 보통인부 0.02 ↔ 0.04 | 판 차이 확정 — 블록 글 「2017년 품셈 6-3-1」 | +| 동부청영월#644 (2024) | GC000263 찰붙임 · 7-2-2 `제07장_돌공사/7-02_돌붙임.md:35` | '12 · '19 · '26 | ㎡당 석공 0.05 · 보통인부 0.02 · 굴착기 0.12h ↔ 3/30 · 1/30 · 8/30 | 판 차이 확정 — '26 보완이 ㎡당 표를 일당 편성 꼴로 바꿈 | +| 동부청영월#1843 (2024) | GC000262 메붙임 · 7-2-1 `7-02_돌붙임.md:18` | '12 · '19 · '26 | ㎡당 석공 0.13 · 굴착기 0.25h ↔ 3/26 · 8/26 | 판 차이 확정 · 블록 글은 「찰붙임(7-2-1)」 — 기초 고름도 확인 | +| 거창#15 (2025) | GC000264 전석쌓기 · 7-3-1 | '92 · '12 · '18 · '26 | 품 대조 못 함 | 판 차이 의심 | +| 봉화#55 (2024) | GC000094 암발파 TYPE-Ⅳ · 3-3-4 | '20 · '26 | 크롤러드릴 ×0.56 · 블록은 「공통품셈 3-1-8 … TYPE-Ⅲ」(옛 번호) | 판 차이 의심 + 유형 확인 | +| 거창#28 (2025) · 봉화#8 (2024) | GC000106 뒤채움 및 다짐(소형장비) · 3-4-4 | '20 · '25 | 보통인부 0.018 ↔ 0.009091 (×1.98 · 두 파일 같은 값 · 울진대흥#159 「3-4-2 기초다짐 및 뒷채움」도 0.018) | 판 차이 의심 — 옛 3-4-2 판 값 · 기초 고름 확인 | +| 봉화#23 · 봉화#38 · 동부청영월#27 (2024) | GF000101 · GF000123 · 산림 9-13-1 · 9-18 | 산림 2026-01-01 제정 | 굴착기 ×1.36 · ×1.65 · ×3.18 · 블록 「임도표준품셈 5-1-2 · 3-7」 | 판 차이(옛 임도품셈) — E · K 값 다름 | +| 울진대흥#2276 · 2289 · 2294 (2024) | GF000091 암절취 집토 · 산림 9-4-2 | 산림 2026-01-01 | 세 블록 같은 배 ×0.6776 · 블록 「건설표준품셈 11-3(굴삭기)」(옛 번호) | 판 차이 의심 — 덤프 조합(3번 갈래) 쪽 | +| 동부청영월#111 · 울진대흥#2264 · 2269 · 2290 | GF000132 섞기 · 9-22 | — | ×0.89 ~ ×1.52 | 기초 잘못 짚음(굴착기 0.7 Q 값 우연 · 임목파쇄는 현행 8-2-32 GC001189) — 판 아님 | + +- 못 가름 6 줄(거창#12 · 울진 불도저 운반 다섯)은 운반거리 · 현장 값과 섞여 판을 가르지 못함 · 판 아님 19 줄은 2026 파일(같은 판)의 현장 값 · 단위 기준(÷10) · 감률 — json `근접_원문개정` +- 같은 배수 묶음: ×0.9714(봉화#98 두 줄) · ×1.98(거창#28 · 봉화#8) · ×0.6776(울진대흥 셋) 만 판 차이 쪽 · ×3.3 · ×2.917 · ×0.08 · ×0.5 묶음은 현장 값 · 소요량 · 표기(공구손료 %) · 감률 + +### 4-2. 파일별 옛 번호 인용 + +| 파일(연도) | 블록 | 옛 번호 인용 | +| --- | --- | --- | +| 북부청임도(2026) | 95 | 9 | +| 북부청사방(2026) | 67 | 2 | +| 거창(2025) | 61 | 1 | +| 봉화(2024) | 65 | 18 | +| 동부청영월(2024) | 69 | 43 | +| 울진대흥(2024) | 47 | 26 | +| 영덕계류(2025) | 15 | 7 | +| 울진소광(2025) | 40 | 8 | + +- 옛 번호 = 엔진이 「건설품셈(옛) · 임도품셈(옛) · 사방품셈(옛)」 으로 본 출처 — 2024 파일일수록 옛 판 번호 · 값을 씀 +- 지식DB 에는 현행 판 원문만(건설 2026 · 산림 2026-01-01 · 산림 CHANGELOG 「교본 이후 개정 1회」) — 옛 판 값은 원문으로 확인 못 함 · 엑셀 값과 절 머리 개정 연도로만 판정 · 건설 「2026 개정사항」 PDF 는 md 없음 + +## 5. 판 표지 꼴 제안 + +- M01 로직 파일 머리 `판`(건설 「2026」 · 산림 「2026-01-01」)은 이미 있음 — 줄 단위 판 정보가 없음 +- 로직 줄 칸 「개정」 더함 — 원문 절 머리 개정 연도 목록(예 GC000207 = [2008, 2014, 2022, 2024]) · 빌더가 본문 md 머리에서 뽑음(사람 입력 없음) · 산림 줄 = [2026](제정) +- 로직 줄 칸 「옛판」(선택) — `[{판, 값, 근거}]` · 사용자 확정분만(예 GC000207 「'24 보완 전 · 토목 Type-Ⅰ 시공량 3.5」 근거 실무 봉화 2024) · 지식DB 에 옛 원문이 없어 엑셀 값만 근거 +- 근거 제안 근접 까닭 「판 차이」에 판 번호 붙임 — 블록 판(글 속 「20xx년 품셈」 · 옛 번호 · 파일 연도) < 기초 절 최신 개정 연도 이면 까닭 글 = 「판 차이('24 보완 · 파일 2024)」 · 같은 값이 둘 이상 파일에서 같은 배로 어긋나면 「판 차이 강함」 · 「옛판」 값과 같으면 「옛 판 값 일치」 +- 크기 어림: + +| 대상 | 수 | +| --- | --- | +| 로직 줄 | 1,515 (GC 1,189 · GF 326) | +| 「개정」 자동 채움 | 1,237 (GC 911 · GF 326) · 못 찾음 278(건설 절 머리에 연도 표기 없음 · 원문번호 장 단위 등) | +| 2024~2026 개정 든 GC | 462 — 옛 판 실무와 어긋날 수 있는 줄 | +| 2026 개정 든 GC | 199 | +| 꼴 바꿈 | 로직 줄 칸 둘(`_틀.md` 7 장 열 차례) · 빌더 한 곳 · M01 화면 읽기만 표시 · 엔진 까닭 글(`M02_San_Basis_Check.causes`) 한 곳 · 지킴 시험 하나 | +| 값 바꿈 | 없음 — M01 셈 값 · 사전조합 · 공사 금액 전후 같음 | + +## 6. 사용자가 고를 것 + +1. 9번 기계 채집(8 블록) — 자체 GX 한 줄로 넣을지 · 입력 칸(뒷길이 · 1회 개수 · 굴착기 규격) 그대로 둘지 +2. 1번 자재 운반(10 블록) — 하차 품을 옛 판 값(철근 11분/ton · 시멘트 0.18 · 0.13 인)으로 넣을지 · 입력 칸으로 둘지(원문 「인도조건에 따라」) +3. 3번 덤프 조합(14 블록) — GF000284 입력 넓힘 · 자체 조합 새 줄 중 하나 +4. 6번 잔토처리 단독 줄(3 블록) — 만들지 +5. 2 · 10 · 11 · 15번 현행 판에 없는 품(소형 기계 운반 · 글자새김 · 크레인트럭 컨테이너 · 떼 하차) — 자체로 넣을지 뺄지 +6. 판 표지 — 「개정」 칸만(자동) · 「옛판」 칸까지(확정분만) · 근거 제안 까닭 글만 중 고름 · M01 값은 현행 판 유지(제6조②) +7. 판 차이 확정 4 블록의 옛 값(철근 조립 3.5 · 합판거푸집 2017 · 돌붙임 '19 ㎡당 표) — 「옛판」 칸 첫 자료로 넣을지 +8. 엔진 몫(4 · 13번 24 블록 · 섞기 오인 4) — 793-3a 후속(같은 말 묶음 · 옛 번호 대조표 · 기계 작업량 길 넓힘)으로 넘길지 — M01 보강과 따로 From 80c3f72a248e52cbe2232b957107c5b931d8080b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 5 Nov 2026 18:44:03 +0900 Subject: [PATCH 2/3] =?UTF-8?q?perf(792-2):=20=EC=8B=9C=ED=8A=B8=20?= =?UTF-8?q?=EB=8B=A4=EC=8B=9C=20=EC=85=88=20=EC=84=9C=EB=B2=84=20=EB=8B=A4?= =?UTF-8?q?=EB=93=AC=EA=B8=B0=20=E2=80=94=20=EC=9E=90=EC=9E=AC=20=EC=83=89?= =?UTF-8?q?=EC=9D=B8=20=C2=B7=20=EC=B0=BE=EA=B8=B0=20=EB=8B=B5=EC=9D=84=20?= =?UTF-8?q?M01=20=EC=83=89=EC=9D=B8=EC=97=90=20=ED=95=9C=20=EB=B2=88=20?= =?UTF-8?q?=C2=B7=20=ED=95=A9=ED=8C=90=201.2~1.6=20s=20=E2=86=92=2062=20ms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `_Over` 덮개가 자재 색인(`_market` · `_by_class`) · 찾기 답(`_found`)을 속 M01 색인에 둠 — 셈마다 3만 줄 다시 안 지음 · M01 파일 자국이 바뀌면 새 색인 = 새로 - `master_pick.search` = 마스터 · 조건 · 지역 · 단위마다 한 번(`_search` 가 옛 몸) · 사본을 줌 - `calc_doc` = M01 `steady()` 안 — 한 셈 동안 파일 자국 한 번(37 번 → 1) - 측정(8001 · 10 번 중앙값 · 옵션/상수/식/변수 줄) 합판 62/62/62/66 · 지게 30 · 굴착기 31~36 · 봉화 27 ms · 금액 전후 같음 - 엔진 일곱 번은 남김 — 한 번 ~5 ms · 목표 아래 - 시험 test_792_2_calc_speed(색인 다시 안 지음 · 자국 바뀌면 다시 · 층 섞임 없음 · 셈 답 같음) Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Sangeun_Calc.py | 30 +++-- resources/master_data/scripts/master_pick.py | 13 ++ resources/tester/test_792_2_calc_speed.py | 122 +++++++++++++++++++ 3 files changed, 155 insertions(+), 10 deletions(-) create mode 100644 resources/tester/test_792_2_calc_speed.py diff --git a/M02_MasterTemplete/M02_Sangeun_Calc.py b/M02_MasterTemplete/M02_Sangeun_Calc.py index 8dd8e61db..ca53d62f8 100644 --- a/M02_MasterTemplete/M02_Sangeun_Calc.py +++ b/M02_MasterTemplete/M02_Sangeun_Calc.py @@ -41,6 +41,7 @@ from M02_MasterTemplete import M02_Sangeun_Views as views COSTS = labor.COSTS Ref = dict[str, Any] +_SHARED = ("_market", "_by_class", "_found") # 마스터에만 따르는 자재 색인 · 찾기 답 class _Over: @@ -55,6 +56,14 @@ class _Over: def __getattr__(self, name: str) -> Any: return getattr(self._whole, name) + def __setattr__(self, name: str, value: Any) -> None: + """자재 색인(`master_material`)은 속 M01 색인에 둠 — 셈마다 다시 안 지음(792-2) · + M01 파일 자국이 바뀌면 새 색인 = 새로 지음 · 그 밖은 이 덮개에만.""" + if name in _SHARED: + setattr(self._whole, name, value) + else: + super().__setattr__(name, value) + def _fail(e: sd.DocError) -> store.StoreError: return store.StoreError(e.status, str(e)) @@ -570,16 +579,17 @@ def calc_doc(doc: Any, given: dict | None = None, seen: bool = True) -> dict[str `seen` = 보기 덧칸(`글` · `중간`) 지음.""" from M01_MasterData import M01_MasterData_Store as m01 - whole = m01.loaded()[1] - try: - clean = sd.clean(doc, None, whole) - _check_refs(clean, refs.here()) # 저장 전 문서도 위 층만 · 있는 문서 · 순환 422 - me = refs.ref_id({"층": refs.here()[0], "주인": refs.here()[1], "키": clean["키"]}) - line = isinstance(doc, dict) and doc.get("줄모드") is True # 일위대가 줄 모달(750-1) - got = run(clean, whole, given, trail=(me,), detail=True, line=line, seen=seen) - except sd.DocError as e: - raise _fail(e) from e - return const.answer(got, clean, doc, given, (me,)) # 옛 답 + 원래 · 시트(790-2) + with m01.cache.steady(): # 한 셈 동안 M01 파일 자국 한 번(792-2) + whole = m01.loaded()[1] + try: + clean = sd.clean(doc, None, whole) + _check_refs(clean, refs.here()) # 저장 전 문서도 위 층만 · 있는 문서 · 순환 422 + me = refs.ref_id({"층": refs.here()[0], "주인": refs.here()[1], "키": clean["키"]}) + line = isinstance(doc, dict) and doc.get("줄모드") is True # 일위대가 줄 모달(750-1) + got = run(clean, whole, given, trail=(me,), detail=True, line=line, seen=seen) + except sd.DocError as e: + raise _fail(e) from e + return const.answer(got, clean, doc, given, (me,)) # 옛 답 + 원래 · 시트(790-2) def from_line( diff --git a/resources/master_data/scripts/master_pick.py b/resources/master_data/scripts/master_pick.py index 6b9b38599..6f6a2b7c1 100644 --- a/resources/master_data/scripts/master_pick.py +++ b/resources/master_data/scripts/master_pick.py @@ -67,6 +67,19 @@ def _words(term) -> tuple[list[str], list[str]]: def search(master, cond: dict, env: dict | None = None, unit: str | None = None) -> list[str]: + """`_search` 를 마스터 색인 하나에 조건마다 한 번만(792-2) — + 답은 마스터 · 조건 · 지역 · 단위만 따름.""" + place = mm.plain(env[mm.REGION]) if (env or {}).get(mm.REGION) else "" + memo = getattr(master, "_found", None) + if memo is None: + memo = master._found = {} + key = tuple(str(cond.get(k) or "") for k in ("검색어", "구분", "상세구분")) + (place, str(unit)) + if key not in memo: + memo[key] = _search(master, cond, env, unit) + return list(memo[key]) + + +def _search(master, cond: dict, env: dict | None = None, unit: str | None = None) -> list[str]: """검색어 안 자재품목 키 — 낱말이 모두 이름 또는 규격에 든 줄(요소의 구분 · 상세구분 안에서) · 단위는 같은 성격까지(하나도 없으면 다 — 경고가 붙음) · 고르는 차례: 값 있는 줄 → 줄 단위와 똑같은 단위 → 지역 낱말(입력 `자재지역` 포함)과 맞는 줄 → 키 차례.""" diff --git a/resources/tester/test_792_2_calc_speed.py b/resources/tester/test_792_2_calc_speed.py new file mode 100644 index 000000000..3c61d56a3 --- /dev/null +++ b/resources/tester/test_792_2_calc_speed.py @@ -0,0 +1,122 @@ +# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 +"""792-2 시트 다시 셈 서버 다듬기 — 자재 색인 · 찾기 답을 M01 색인에 한 번만. + +- 셈마다 새 `_Over` 덮개를 지어도 자재 색인(`_market` · `_by_class`) · 찾기 답(`_found`)은 속 M01 + 색인에 붙음 — 두 번째 셈은 다시 안 지음 +- M01 파일 자국이 바뀌면 새 색인 = 새로 지음(지문 열쇠 = `M01_MasterData_Store_Cache.loaded`) +- 층 섞임 없음 — 기억은 M01 자재품목 키만 · 개인 자재(PM)는 그때 층에서만 찾음 +- 셈 답 같음 — 기억한 찾기 = 날 찾기 · SK 금액 견본(합판거푸집 51,419 · 공구손료 2 % 51,802) +""" + +from __future__ import annotations + +import json +import os +from decimal import Decimal + +from M01_MasterData import M01_MasterData_Store as m01 +from M01_MasterData import M01_MasterData_Store_Cache as cache +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Material_Own as own +from M02_MasterTemplete import M02_Sangeun_Calc as calc +from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층 + +mm = m01.cm.mf.mp.mm +mp = m01.cm.mf.mp +F211 = { + "자재지역": "포항", + "유형": "보통", + "사용횟수": "6회", + "높이감률": 0, + "지붕슬래브": "아님", + "폼타이": "아님", + "폼타이규격": "12.7㎜", + "측압": "3t/㎡", + "구멍땜": 0, +} + + +def _sk(**more): + doc = {"키": "SK-000001", "이름": "속도", "단위": "식", "바탕": {"로직": "GC000211"}} + return {**doc, "입력값": F211, **more} + + +def test_색인은_M01_색인에_한_번_두번째_셈은_안_지음(monkeypatch): + first = calc.calc_doc(_sk()) + whole = m01.loaded()[1] + held = {n: vars(whole)[n] for n in ("_market", "_found")} + fresh = mp._search + found = [] + monkeypatch.setattr(mp, "_search", lambda *a: found.append(a) or fresh(*a)) + again = calc.calc_doc(_sk()) + assert again["기준"] == first["기준"] and first["기준"]["계"] == 51419 + assert all(vars(whole)[n] is v for n, v in held.items()) + assert found == [] # 같은 조건 찾기 = 기억한 답(자재품목 3만 줄을 안 훑음) + over = calc._Over(whole, "SK-000001", {}) + assert over._market is whole._market and "_market" not in vars(over) + over.mine = 1 # 그 밖 칸은 덮개에만 + assert "mine" not in vars(whole) + + +def test_M01_파일이_바뀌면_새_색인_새로_지음(tmp_path): + def write(rows): + path = tmp_path / "자재품목.json" + path.write_text(json.dumps({"그룹": "재료", "줄": rows}, ensure_ascii=False), "utf-8") + return path + + one = {"키": "MT000001", "이름": "합판", "규격": "12mm", "단위": "장", "물가자료": 100} + two = {**one, "키": "MT000002", "이름": "각재"} + try: + write([one]) + old = cache.loaded(tmp_path)[1] + assert [k for *_, k in mm._market_index(old)] == ["MT000001"] + path = write([one, two]) + os.utime(path, ns=(1, 1)) # 자국(시각 · 크기)이 꼭 바뀌게 + new = cache.loaded(tmp_path)[1] + assert new is not old and not hasattr(new, "_market") + assert [k for *_, k in mm._market_index(new)] == ["MT000001", "MT000002"] + assert cache.loaded(tmp_path)[1] is new # 안 바뀌면 그대로 + finally: + cache.forget() # 다음 시험은 M01 폴더를 다시 읽음 + + +def test_기억한_찾기_답은_날_찾기와_같고_사본을_줌(): + whole = m01.loaded()[1] + conds = [ + ({"검색어": "합판 12mm", "구분": "목재"}, {}, "㎡"), + ({"검색어": "철선"}, {mm.REGION: "포항"}, "kg"), + ({"검색어": "레미콘"}, {mm.REGION: "서울"}, None), + ] + for cond, env, unit in conds: + got = mp.search(whole, cond, env, unit) + assert got == mp._search(whole, cond, env, unit) + got.append("MT999999") + assert "MT999999" not in mp.search(whole, cond, env, unit) + assert mp.search(whole, conds[1][0], {mm.REGION: "서울"}, "kg") == mp._search( + whole, conds[1][0], {mm.REGION: "서울"}, "kg" + ) + + +def test_층_섞임_없음_개인_자재는_그때_층에서만(world): + calc.calc_doc(_sk()) # M01 색인에 기억이 생긴 뒤 + whole = m01.loaded()[1] + assert all(k.startswith(mm.MARKET) for *_, k in whole._market) + assert all(k.startswith(mm.MARKET) for keys in whole._found.values() for k in keys) + with store.at(world["personal"]): + code = own.add(world["personal"], {"이름": "내 자재", "단위": "개", "값": 1234})["키"] + mine = labor.material_price(whole, code, "개") + other = world["personal"].parent.parent / "43" / world["personal"].name + other.mkdir(parents=True) + with store.at(other): + theirs = labor.material_price(whole, code, "개") + assert Decimal(str(mine["기준단가"])) == 1234 + assert theirs["기준단가"] is None + assert code not in whole.index.get(code[:2], {}) + + +def test_상수_덮개_셈_답_전후_같음(): + got = calc.calc_doc(_sk(상수덮개={"pct:공구손료 및 경장비#0": {"0": 2}})) + assert got["기준"]["계"] == 51802 + assert got["원래"]["계"] == 51419 + assert got["시트"]["자리"]["pct:공구손료 및 경장비#0"]["덮개"] == ["상수"] From 2e7328d3a51a7be3c1a171e6c3d8462c4c6eceb7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 5 Nov 2026 18:45:48 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat(794-1):=20M01=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=EC=A4=84=20=EC=8B=9D=20=EC=8B=9C=ED=8A=B8=20?= =?UTF-8?q?=E2=80=94=20=EB=A1=9C=EC=A7=81=20=EC=83=81=EC=84=B8=20=EC=B2=AB?= =?UTF-8?q?=20=EB=B3=B4=EA=B8=B0=20=E3=80=8C=EC=8B=9C=ED=8A=B8=E3=80=8D=20?= =?UTF-8?q?=C2=B7=20=EC=8B=9D=20=EC=B9=B8=20=EC=B9=A0=20=EB=95=8C=EB=A7=88?= =?UTF-8?q?=EB=8B=A4=20=EA=B2=80=EC=82=AC=20=C2=B7=20=EA=B8=B0=EC=A1=B4=20?= =?UTF-8?q?[=EC=A0=80=EC=9E=A5]=20=EA=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 로직 상세 보기 첫 칸 「시트」(기본) — 이름 | 식 | 값 | 비목 · 근거 · 옵션(시험 값) · 변수 · 계산 · 자원 · 율 · 덧줄 · 단계 · 끝수 · 소계 · 합계 · 상태줄 지금 · 고치기 전 - 식 글 = 이름 꼴(M02 단가산출 시트와 같은 번역 · 새 서버 M01_MasterData_Sheet.py · POST /api/m01/logic/sheet · /logic/sheet/check · 저장 때와 같은 엔진 검사 · 쓰기 없음) - 식 고침 = 시스템 관리자만 · 되는 식만 줄 사본에 · 캐시 → 기존 [저장](개정 확인 · 서버 검사 422) · [원래로] - 식 칸 몸 = 공용 A00_Common/sheet_formula_cell.ts — M02 단가산출 시트도 같은 것을 씀(동작 그대로) - 자체 로직(GX) 편집기 식 날 입력칸 → 읽기만(시트 식 칸으로 갈음) - 시험: test_794_1_m01_sheet 새로 · test_790_1 식 칸 문구 자리를 공용 파일로 옮김(식 칸 몸이 공용으로 감) · test_717_2b · test_722 LogicLab.ts 줄 상한 627 → 637(시트 이음 10 줄 · 셈 · 고침은 시트 파일로 뺌 · 700 아래) Co-Authored-By: Claude Opus 5.5 --- A00_Common/sheet_formula_cell.ts | 101 ++++++ M01_MasterData/M01_MasterData_Router.py | 24 ++ M01_MasterData/M01_MasterData_Sheet.py | 70 ++++ M01_MasterData/M01_MasterData_UI_LogicLab.ts | 12 +- .../M01_MasterData_UI_LogicLab_Detail.ts | 9 +- .../M01_MasterData_UI_LogicLab_Sheet.ts | 324 ++++++++++++++++++ .../M01_MasterData_UI_LogicLab_Style.css | 1 + .../M01_MasterData_UI_LogicLab_Text.ts | 2 + .../M01_MasterData_UI_LogicLab_View.ts | 11 +- .../M01_MasterData_UI_Logic_Calc.ts | 3 + .../M01_MasterData_UI_Logic_Edit.ts | 15 +- M01_MasterData/logic/M01_Logic_Sheet_Model.ts | 158 +++++++++ ...M02_MasterTemplete_Sangeun_SheetFormula.ts | 73 +--- .../tester/test_717_2b_sangeun_detail.py | 4 +- resources/tester/test_722_ho_detail.py | 2 +- resources/tester/test_790_1_sangeun_sheet.py | 6 +- resources/tester/test_794_1_m01_sheet.py | 234 +++++++++++++ 17 files changed, 970 insertions(+), 79 deletions(-) create mode 100644 A00_Common/sheet_formula_cell.ts create mode 100644 M01_MasterData/M01_MasterData_Sheet.py create mode 100644 M01_MasterData/M01_MasterData_UI_LogicLab_Sheet.ts create mode 100644 M01_MasterData/logic/M01_Logic_Sheet_Model.ts create mode 100644 resources/tester/test_794_1_m01_sheet.py diff --git a/A00_Common/sheet_formula_cell.ts b/A00_Common/sheet_formula_cell.ts new file mode 100644 index 000000000..1a36ab683 --- /dev/null +++ b/A00_Common/sheet_formula_cell.ts @@ -0,0 +1,101 @@ +/* ============================================================================= + * A00_Common/sheet_formula_cell.ts + * 줄 식 시트 식 칸 한 벌(PLAN 792 · 794-1) — M02 단가산출 시트 · M01 로직 시트가 같이 씀. + * 칠 때마다 검사(`check` · 150 ms 쉼 · 앞 요청 끊음) → 틀리면 빨간 칸 + 까닭 · 알림 한 줄 + * 바꿀 때(Enter · 떠남) — 빈 칸 · 원래 식 그대로 = `put(null)` · 되면 `put(엔진 식)` · 틀리면 안 넣음 + * 치는 중 글 = `drafts`(다시 그려도 안 잃음) · 부품 = 입력 · 글 · 서버 검사 길은 부르는 쪽 몫 + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import { createInputField } from "@ui/ui_template_input"; +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createText } from "@ui/ui_template_text"; + +const TEXT = { + Formula: ["식", "Formula"], + Hint: ["예) 무게 / 운반량 × (1 + 할증율 / 100)", "e.g. weight / load × 1.2"], + Bad: ["식이 틀려 안 넣음", "Invalid formula — not applied"], + Checking: ["검사 중", "Checking…"], +} as const satisfies Record; +const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0]; + +/** 검사 답 — 되나 · 까닭 · 알림(없으면 빈) · 엔진 식 */ +export interface FormulaVerdict { + 되나: boolean; + 까닭: string[]; + 알림?: string[]; + 식: string | null; +} + +export interface FormulaCellOptions { + /** 시트 칸 열쇠(초점 되찾기 · 치는 중 글) */ + key: string; + /** 칸 그릇 이름표 값(`data-sheet-fx`) */ + spot: string; + shown: string; + /** 원래 식 글 — 그대로 두면 `put(null)` */ + origin: string; + drafts: Map; + check: (text: string, signal: AbortSignal) => Promise; + put: (engine: string | null) => void; + className?: string; +} + +export function createFormulaCell(o: FormulaCellOptions): HTMLElement { + const box = createInputField({ + value: o.drafts.get(o.key) ?? o.shown, + compact: true, + size: 24, + placeholder: t("Hint"), + }); + box.input.setAttribute("aria-label", t("Formula")); + box.input.dataset.sheetCell = o.key; + box.input.spellcheck = false; + const note = createText("", "muted", 12); + note.dataset.sheetCheck = o.key; + let timer = 0; + let ctl: AbortController | null = null; + const run = (text: string): Promise => { + ctl?.abort(); + const mine = (ctl = new AbortController()); + note.textContent = t("Checking"); + return o.check(text, mine.signal).then( + (got) => { + if (mine !== ctl) return got; + box.setError(got.되나 ? undefined : got.까닭.join(" · ")); + note.textContent = (got.알림 ?? []).join(" · "); + return got; + }, + (error: unknown) => { + if (mine !== ctl || mine.signal.aborted) return null; + box.setError(error instanceof Error ? error.message : String(error)); + note.textContent = ""; + return null; + }, + ); + }; + box.input.addEventListener("input", () => { + o.drafts.set(o.key, box.input.value); + window.clearTimeout(timer); + const text = box.input.value.trim(); + if (!text) return void (ctl?.abort(), box.setError(undefined), (note.textContent = "")); + timer = window.setTimeout(() => void run(text), 150); + }); + box.input.addEventListener("change", async () => { + window.clearTimeout(timer); + const text = box.input.value.trim(); + if (!text || text === o.origin) { + o.drafts.delete(o.key); + return o.put(null); + } + const got = await run(text); + if (!got?.되나 || !got.식) return void (note.textContent = t("Bad")); + o.drafts.delete(o.key); + o.put(got.식); + }); + return el("div", { + className: o.className ?? "m02-sks__fx", + attrs: { "data-sheet-fx": o.spot }, + children: [box.root, note], + }); +} diff --git a/M01_MasterData/M01_MasterData_Router.py b/M01_MasterData/M01_MasterData_Router.py index eafc850b1..ea70058c7 100644 --- a/M01_MasterData/M01_MasterData_Router.py +++ b/M01_MasterData/M01_MasterData_Router.py @@ -12,6 +12,7 @@ from typing import Any, Literal from fastapi import APIRouter, HTTPException from pydantic import BaseModel +from M01_MasterData import M01_MasterData_Sheet as line_sheet 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 @@ -347,6 +348,29 @@ def post_logic_draft(body: DraftLogic) -> dict: return _call(make.logic_draft, body.text, body.decided, body.head, body.save, body.owner) +class SheetRow(BaseModel): + row: dict[str, Any] + + +class SheetCheck(BaseModel): + row: dict[str, Any] + 갈래: Literal["중간", "호표", "덧줄"] + 차례: int + 식: str + + +@router.post("/logic/sheet") +def post_logic_sheet(body: SheetRow) -> dict: + """줄 식 시트(794-1) — 줄마다 이름 꼴 식 글 · 식 칸 이름표(셈 · 쓰기 없음).""" + return _call(line_sheet.names, body.row) + + +@router.post("/logic/sheet/check") +def post_logic_sheet_check(body: SheetCheck) -> dict: + """시트 식 칸 칠 때마다 — {되나, 까닭, 식(엔진), 글(이름 꼴)} · 저장 때와 같은 엔진 검사.""" + return _call(line_sheet.check, body.row, body.갈래, body.차례, body.식) + + # ── 일위대가 조합 — 조합 길은 M02 층 길(`/api/m02/layers/{layer}/combos` · PLAN 27-2a) ── @router.get("/logic/combos") def get_logic_combos(key: str) -> dict: diff --git a/M01_MasterData/M01_MasterData_Sheet.py b/M01_MasterData/M01_MasterData_Sheet.py new file mode 100644 index 000000000..60169d6ba --- /dev/null +++ b/M01_MasterData/M01_MasterData_Sheet.py @@ -0,0 +1,70 @@ +"""M01 로직 줄 식 시트(PLAN 794-1 · 요구 650~652 · 요구서_652 1 장 6) — 관리자 시트 식 칸 서버 몫. + +이름 꼴 ↔ 엔진 식 · 상한 = M02 단가산출 식 칸과 한 벌(`M02_Sangeun_Formula` 불러 씀 · 고치지 않음) · +검사 = 저장 때와 같은 엔진 검사(`check_logic`) 중 그 자리 까닭만 · 셈 · 쓰기 없음(③ 서버 단독). +자리 = (갈래 중간 | 호표 | 덧줄, 차례 0 부터) — 저장은 화면 캐시 줄 → 기존 M01 [저장](`/save`). +짝 = `logic/M01_Logic_Sheet_Model.ts`(줄 고치기 — 서버 짝 함수 없음 · 자리 꼴만 같음) · +시험 `resources/tester/test_794_1_m01_sheet.py`. +""" + +from __future__ import annotations + +import copy +from typing import Any + +from M01_MasterData import M01_MasterData_Store as store + +FIELD = {"중간": "식", "호표": "수량", "덧줄": "식"} + + +def _fx() -> Any: + from M02_MasterTemplete import M02_Sangeun_Formula as fx # M02 가 M01 을 부름 — 늦게 + + return fx + + +def _rows(row: dict, part: str) -> list: + got = row.get(part) + return got if isinstance(got, list) else [] + + +def names(row: dict) -> dict[str, Any]: + """줄마다 이름 꼴 식 글 {중간: [글], 호표: [글], 덧줄: [글], 이름표: [{글, 갈래}]}.""" + fx, out = _fx(), {} + for part, field in FIELD.items(): + out[part] = [fx.to_name(str(x.get(field) or ""), row) for x in _rows(row, part)] + try: + out["이름표"] = fx.name_table(row, {}) + except (KeyError, TypeError, ValueError): + out["이름표"] = [] + return out + + +def _where(row: dict, part: str, at: int) -> str: + """`check_logic` 까닭 머리(그 자리).""" + key = f"{row.get('키')} {row.get('원문번호')}" + name = _rows(row, part)[at].get("이름", "") + return f"{key} {part} {name}" + (" 수량" if part == "호표" else "") + + +def check(row: dict, part: str, at: int, text: str) -> dict[str, Any]: + """식 글 하나 → {되나, 까닭, 식(엔진), 글(이름 꼴)} — 그 자리에 넣은 사본을 엔진 검사.""" + fx = _fx() + text = str(text or "").strip() + out: dict[str, Any] = {"되나": False, "까닭": [], "식": None, "글": None} + if part not in FIELD or not 0 <= at < len(_rows(row, part)): + return out | {"까닭": [f"없는 자리 「{part} {at}」"]} + if not text: + return out | {"까닭": ["식이 빔"]} + if bad := fx.limits(text): + return out | {"까닭": bad} + got, why = fx.to_engine(text, row) + if why: + return out | {"까닭": why} + out |= {"식": got, "글": fx.to_name(got, row)} + trial = copy.deepcopy(row) + trial[part][at][FIELD[part]] = got + head = _where(trial, part, at) + found = store.cm.mf.check_logic(store.loaded()[1], trial)[0] + mine = [w[len(head) :].lstrip(" ·") for w in found if w.startswith(head)] + return out | {"되나": not mine, "까닭": mine} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab.ts b/M01_MasterData/M01_MasterData_UI_LogicLab.ts index aee6cc173..fc06fced4 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab.ts @@ -1,6 +1,6 @@ /* ============================================================================= * M01_MasterData_UI_LogicLab.ts - * 관리자 화면 — 로직 칸. 로직을 열면 두 칸 — 왼쪽 단가산출 상세(호표) · 시험 계산 / 오른쪽 흐름도 + * 관리자 화면 — 로직 칸. 로직을 열면 두 칸 — 왼쪽 시트(794-1 · 식 고침 = 관리자) · 단가산출 상세 · 시험 계산 / 오른쪽 흐름도 * 로직 목록(원문·장 고르기 · 찾기 · 막힘 거름)은 왼쪽 패널 「단가산출 로직」 컨테이너(`listHost`) 안 * * 데이터 흐름(CLAUDE.md 5장) — 고친 것은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `POST /save`. @@ -54,6 +54,7 @@ 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 { createLabSheet } from "./M01_MasterData_UI_LogicLab_Sheet"; import { buildHeadBar } from "./M01_MasterData_UI_Head"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; import type { CfLogic } from "../M02_MasterTemplete/DataFlow/M02_DF_Model"; @@ -128,6 +129,12 @@ export async function mountM01LogicLab( .then((u) => u?.role === "SYSTEM_ADMIN") .catch(() => false); let recalc: number | undefined; + const sheet = createLabSheet({ + admin, + onInput: (name, v) => opened && syncLeft(opened, { [name]: v }, true), + edited: () => (touch(), drawEditor(), drawCalc()), // 줄 사본 → 캐시 → [저장](기존 길) + run: () => calc.querySelector("[data-calc-run]")?.click(), + }); const editor = el("div", { className: "m01-logic__editor" }); // 로직 상세 — 왼쪽 지금 내용 · 오른쪽 접는 패널 둘(흐름도 · 원문 · 하나만 열림 · 폭 공유) const panes = buildLogicSide(editor); @@ -225,6 +232,7 @@ export async function mountM01LogicLab( if (text.ok) current.lastText = text; drawEditor(); // [계산] 전 미리보기 응답도 왼쪽 이름 식 칸에 바로 비치게 — onLines 와 같은 자리 }, + onAnswer: sheet.answer, onLines: (lines) => { current.lines = lines; drawEditor(); @@ -260,7 +268,9 @@ export async function mountM01LogicLab( calcHost: calc, flowHost: flowOf(current.row) ? current.flowHost : undefined, admin, + sheet: sheet.root, }); + sheet.draw(current.row as LogicRow, current.original, current.values); flowNow?.redrawLeft(); }; diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts index c2c91174a..7d4a659c2 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts @@ -39,6 +39,8 @@ export interface DetailContext extends HoContext { onTrial?: () => void; /** 717-2b M02 단가산출 — 단가산출 상세 · 인력·재료·경비 덩이를 이 칸으로(SK 셈 답 · 고침 표) */ cover?: { ho: HTMLElement; est: HTMLElement; open?: ReturnType }; + /** 794-1 M01 줄 식 시트(`_LogicLab_Sheet`) 그릇 — 있으면 「시트」 덩이(M02 는 안 넘김) */ + sheet?: HTMLElement; } const section = (title: string, body: HTMLElement[], hint = ""): HTMLElement => @@ -166,7 +168,11 @@ function ownEditor(ctx: DetailContext): HTMLElement { return el("details", { className: "m01lab__edit", attrs: { open: "" }, - children: [el("summary", { text: tl("Edit_Title") }), ctx.ownHost], + children: [ + el("summary", { text: tl("Edit_Title") }), + el("p", { className: "ui-text ui-text--muted", text: tl("Edit_SheetHint") }), + ctx.ownHost, + ], }); } @@ -230,6 +236,7 @@ export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void { // 보기 고르기(`_View.ts`) — 덩이마다 이름표 · 고른 덩이만 보임 · 시험 계산은 숨어도 그려 둠 host.replaceChildren( ...infoBox(ctx), + ...(ctx.sheet ? [block(ctx.sheet, "sheet")] : []), block(section(tl("Calc_Title"), [ctx.calcHost]), "calc"), block( section(tl("View_Est"), [ diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Sheet.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Sheet.ts new file mode 100644 index 000000000..3c099811d --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Sheet.ts @@ -0,0 +1,324 @@ +/* ============================================================================= + * M01_MasterData_UI_LogicLab_Sheet.ts + * M01 단가산출 로직 상세 「시트」 보기(PLAN 794-1 · 요구 650~652 · 요구서_652 1 장 6) — M02 단가산출 시트와 같은 꼴. + * 한 줄 「이름 | 식 | 값 | 비목」(줄 꼴 = `logic/M01_Logic_Sheet_Model`) · 상태줄 = 지금 · 고치기 전 + [계산] + * 식 글 = 서버 이름 꼴(`POST /api/m01/logic/sheet` · 줄이 바뀔 때만 · 옛 요청 끊음) + * 고침 = 시스템 관리자만 — [식 고침] → 식 칸(공용 `sheet_formula_cell` · 칠 때마다 `/logic/sheet/check`) + * → 되면 엔진 식을 줄 사본에(`onEdit`) → 상세 캐시(drafts) → 기존 M01 [저장](서버 검사 · 개정 확인) + * 옵션 칸 = 시험 값(저장 안 함 · 고치면 시험 계산) · 자동저장 없음 · 부품 = calc_row · 입력 · 고르기 · 단추 · 배지 + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import { createBadge } from "@ui/ui_template_badge"; +import { createButton } from "@ui/ui_template_button"; +import { createCalcFormula, createCalcSheetRow } from "@ui/ui_template_calc_row"; +import { el } from "@ui/ui_template_elements"; +import { createInputField, createSelectField } from "@ui/ui_template_input"; +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createText } from "@ui/ui_template_text"; +import { createToolbar } from "@ui/ui_template_toolbar"; +import { createFormulaCell, type FormulaVerdict } from "../A00_Common/sheet_formula_cell"; +import { formatLineMoney, formatMoney } from "../common_util/common_util_money"; +import type { CalcAnswer, LogicRow } from "./M01_MasterData_UI_Logic_Api"; +import { + formulaAt, + labItems, + setLabFormula, + spotKey, + type LabItem, + type LabKind, + type LabNames, + type LabPart, +} from "./logic/M01_Logic_Sheet_Model"; +import "../M02_MasterTemplete/M02_MasterTemplete_Sangeun_Sheet.css"; + +const TEXT = { + Sheet: ["시트", "Sheet"], + Now: ["지금", "Now"], + Before: ["고치기 전", "Before edit"], + Run: ["계산", "Calculate"], + 근거: ["근거", "Basis"], + 옵션: ["옵션 · 시험 값", "Options · trial values"], + 계산: ["변수 · 계산", "Variables"], + 자원: ["자원", "Resources"], + 율: ["율 · 덧줄", "Rates"], + 단계: ["단계", "Stages"], + 소계: ["소계 · 합계", "Subtotals"], + Edit: ["식 고침", "Edit formula"], + Close: ["식 닫기", "Close formula"], + Back: ["원래로", "Revert"], + Wait: ["[계산] 을 누르면 값이 채워짐", "Press Calculate to fill values"], + Flow: ["흐름 로직 — 시트로 못 봄", "Flow logic — no sheet"], + Address: ["번지", "Address"], +} as const satisfies Record; +const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0]; + +const SECTION: Record = { + 근거: "근거", + 옵션: "옵션", + 계산: "계산", + 자원: "자원", + 율: "율", + 단계: "단계", + 소계: "소계", + 합계: "소계", +}; +const ORDER: (keyof typeof TEXT)[] = ["근거", "옵션", "계산", "자원", "율", "단계", "소계"]; +const NUM = new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 4 }); + +export interface LabSheetDraw { + row: LogicRow; + /** 저장본 줄(새 로직 = null) */ + original: LogicRow | null; + values: Record; + answer: CalcAnswer | null; +} + +export interface LabSheetOptions { + /** 시스템 관리자 — 식 칸 · [식 고침] · [원래로] */ + admin: boolean; + onInput: (name: string, value: string) => void; + /** 줄 식 칸을 고친 뒤(줄 객체는 여기서 고침) — 상세가 캐시(touch) · 다시 그림 */ + edited: () => void; + /** 시험 계산 [계산] 누름 */ + run: () => void; +} + +async function post(path: string, body: unknown, signal?: AbortSignal): Promise { + const r = await fetch(`${API_BASE_URL}/m01${path}`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal, + }); + const data = (await r.json().catch(() => ({}))) as T & { detail?: unknown }; + if (!r.ok) throw new Error(typeof data.detail === "string" ? data.detail : r.statusText); + return data; +} + +export function createLabSheet(opt: LabSheetOptions) { + const status = el("div", { className: "m02-sks__status", attrs: { "data-lab-status": "" } }); + const body = el("div", { className: "m02-sks__body" }); + const run = createButton({ label: t("Run"), variant: "ghost", size: 28, onClick: opt.run }); + const root = el("div", { + className: "m02-sks", + attrs: { "data-lab-sheet": "" }, + children: [createToolbar({ items: [run, "divider", status], label: t("Sheet") }), body], + }); + let last: LabSheetDraw | null = null; + let names: LabNames | null = null; + let asked = ""; + let ctl: AbortController | null = null; + let before: number | null = null; + let beforeAt = ""; // 고치기 전 값을 잰 시험 값(옵션) — 옵션이 바뀌면 고친 줄의 「고치기 전」 은 모름 + const open = new Set(); + const drafts = new Map(); + + /** 줄이 바뀌면 이름 꼴 식 글을 다시 받음(옛 요청 끊음) */ + const askNames = (row: LogicRow): void => { + const text = JSON.stringify(row); + if (text === asked) return; + asked = text; + ctl?.abort(); + const mine = (ctl = new AbortController()); + post("/logic/sheet", { row }, mine.signal).then( + (got) => { + if (mine !== ctl) return; + names = got; + if (last) paint(last); + }, + () => undefined, // 못 받으면 엔진 식 그대로 보임 + ); + }; + + const check = + (row: LogicRow, part: LabPart, at: number) => + (text: string, signal: AbortSignal): Promise => + post("/logic/sheet/check", { row, 갈래: part, 차례: at, 식: text }, signal); + + const money = (it: LabItem): string => { + const v = it.값; + if (typeof v !== "number") return it.kind === "옵션" ? (it.input?.단위 ?? "") : String(v ?? ""); + if (it.kind === "자원" || it.kind === "율") return formatLineMoney(v); + return it.kind === "소계" || it.kind === "합계" ? formatMoney(v) : NUM.format(v); + }; + + function optionCell(d: LabSheetDraw, it: LabItem): HTMLElement { + const spec = it.input!; + const value = d.values[spec.이름] ?? ""; + if (spec.고르기?.length) { + const sel = createSelectField({ + options: ["", ...spec.고르기.map(String)].map((o) => ({ value: o, text: o })), + value, + compact: true, + size: 24, + ariaLabel: spec.이름, + onChange: (v) => opt.onInput(spec.이름, v), + }); + sel.select.dataset.sheetCell = `opt|${spec.이름}`; + return sel.root; + } + const box = createInputField({ value, compact: true, size: 24 }); + box.input.setAttribute("aria-label", spec.이름); + box.input.dataset.sheetCell = `opt|${spec.이름}`; + box.input.addEventListener("change", () => opt.onInput(spec.이름, box.input.value.trim())); + return box.root; + } + + function tail(d: LabSheetDraw, it: LabItem): HTMLElement { + const kids: HTMLElement[] = it.비목 ? [createBadge(it.비목, "neutral")] : []; + if (it.번지) kids.push(createBadge(`${t("Address")} ${it.번지}`, "info")); + if (opt.admin && it.part && it.at !== undefined) { + const key = spotKey(it.part, it.at); + const on = open.has(key); + const toggle = createButton({ + label: on ? t("Close") : t("Edit"), + variant: "ghost", + size: 22, + onClick: () => { + if (on) open.delete(key); + else open.add(key); + drafts.delete(key); + paint(d); + }, + }); + toggle.dataset.sheetFxToggle = key; + kids.push(toggle); + if (it.changed && d.original) { + const { part, at } = it; + const back = createButton({ + label: t("Back"), + variant: "ghost", + size: 22, + onClick: () => edit(d, part, at, null), + }); + back.dataset.sheetRevert = key; + kids.push(back); + } + } + return el("span", { children: kids }); + } + + function rowOf(d: LabSheetDraw, it: LabItem): HTMLElement { + const key = it.part && it.at !== undefined ? spotKey(it.part, it.at) : ""; + const editing = opt.admin && key && open.has(key) && it.part && it.at !== undefined; + const middle = + it.kind === "옵션" + ? optionCell(d, it) + : el("div", { + className: "m02-sks__mid", + children: [ + createCalcFormula(it.식 || (["근거", "소계", "합계"].includes(it.kind) ? "" : "—")), + ...(editing + ? [ + createFormulaCell({ + key, + spot: key, + shown: it.식, + origin: it.식, + drafts, + check: check(d.row, it.part!, it.at!), + put: (engine) => { + if (engine === null) return; + open.delete(key); + edit(d, it.part!, it.at!, engine); + }, + }), + ] + : []), + ], + }); + const value = el("span", { + className: `ui-calc-row__out${it.kind === "소계" || it.kind === "합계" ? " is-key" : ""}`, + text: money(it), + }); + const row = createCalcSheetRow(it.이름, middle, value, tail(d, it)); + row.dataset.sheetKind = it.kind; + if (key) row.dataset.sheetSpot = key; + if (it.changed) row.classList.add("is-changed"); + return row; + } + + function drawStatus(d: LabSheetDraw): void { + const ok = d.answer?.ok ? d.answer : null; + const now = ok?.sums?.계 ?? null; + const at = JSON.stringify(d.values); + const same = !d.original || JSON.stringify(d.row) === JSON.stringify(d.original); + if (same && now !== null) + [before, beforeAt] = [now, at]; // 저장본 그대로 = 지금이 고치기 전 + else if (!same && at !== beforeAt) before = null; + const part = (k: string, label: string, v: number | null): HTMLElement => + el("span", { + className: "m02-sks__amount", + attrs: { "data-lab-amount": k }, + children: [createText(`${label} ${v == null ? "—" : formatMoney(v)}`, "muted", 14)], + }); + const diff = now !== null && before !== null && now !== before ? now - before : 0; + status.replaceChildren( + part("now", t("Now"), now), + part("before", t("Before"), before), + ...(diff + ? [createBadge(`${diff > 0 ? "+" : "−"}${NUM.format(Math.abs(diff))}`, "warning")] + : []), + ); + } + + function paint(d: LabSheetDraw): void { + last = d; + drawStatus(d); + if (d.row.흐름) return void body.replaceChildren(createText(t("Flow"), "muted")); + const items = labItems({ ...d, names }); + const active = document.activeElement as HTMLElement | null; + const keep = active && body.contains(active) ? active.dataset.sheetCell : ""; + const typed = active instanceof HTMLInputElement && keep ? active.value : null; // 치던 글 지킴 + const out: HTMLElement[] = []; + for (const sec of ORDER) { + const mine = items.filter((it) => SECTION[it.kind] === sec); + if (!mine.length) continue; + out.push( + el("div", { + className: "m02-sks__head", + attrs: { "data-sheet-section": sec }, + children: [createText(t(sec), "muted", 14)], + }), + ...mine.map((it) => rowOf(d, it)), + ); + } + if (!d.answer) out.push(createText(t("Wait"), "muted")); + body.replaceChildren(...out); + const back = keep + ? body.querySelector(`[data-sheet-cell="${CSS.escape(keep)}"]`) + : null; + if (back instanceof HTMLInputElement && typed !== null) back.value = typed; + back?.focus(); + } + + /** 식 칸 고침(엔진 식) · null = 저장본 식 → 상세 캐시 · 시험 계산 다시 */ + function edit(d: LabSheetDraw, part: LabPart, at: number, engine: string | null): void { + const text = engine ?? (d.original ? formulaAt(d.original, part, at) : null); + if (text === null || !setLabFormula(d.row, part, at, text)) return; + opt.edited(); + opt.run(); + } + + let answer: CalcAnswer | null = null; + let answerOf: Record | null = null; // 답을 받은 로직(시험 값 그릇 = 연 로직마다 하나) + let base: { src: string | null; row: LogicRow | null } = { src: null, row: null }; + return { + root, + /** 시험 계산 답 — 상세 `buildCalc` onAnswer */ + answer: (a: CalcAnswer) => void (answer = a), + /** 상세가 그릴 때마다 — 줄 · 저장본 글(`original`) · 시험 값 그릇 */ + draw: (row: LogicRow, original: string | null, values: Record) => { + if (answerOf !== values) [answer, answerOf] = [null, values]; // 다른 로직 · 저장 뒤 + if (base.src !== original) { + base = { src: original, row: original ? (JSON.parse(original) as LogicRow) : null }; + open.clear(); // 연 식 칸 · 치던 글 버림 + drafts.clear(); + } + askNames(row); + paint({ row, original: base.row, values, answer }); + }, + }; +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css b/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css index e1902999b..cdf9ea8ed 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css @@ -199,6 +199,7 @@ } /* 보기 고르기(710-2 · `_View.ts`) — 그릇 data-view 와 다른 이름표 덩이는 숨김(시험 계산은 숨어도 돎) */ +.m01-logic__editor[data-view="sheet"] [data-block]:not([data-block="sheet"]), .m01-logic__editor[data-view="ho"] [data-block]:not([data-block="ho"]), .m01-logic__editor[data-view="est"] [data-block]:not([data-block="est"]), .m01-logic__editor[data-view="calc"] [data-block]:not([data-block="calc"]), diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts index cbfee37de..50f9d3a74 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts @@ -33,6 +33,8 @@ const TEXT = { ], Calc_Title: ["시험 계산", "Trial calculation"], View_Est: ["인력·재료·경비", "Labor·material·expense"], + View_Sheet: ["시트", "Sheet"], + Edit_SheetHint: ["식은 「시트」 보기에서 고침", "Edit formulas in the Sheet view"], View_Label: ["보기", "View"], Now_Inputs: ["지금 입력", "Current inputs"], Now_None: ["없음", "none"], diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_View.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_View.ts index 577eb6d07..549778772 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_View.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_View.ts @@ -1,10 +1,10 @@ /* ============================================================================= * M01_MasterData_UI_LogicLab_View.ts * 「단가산출 로직」 상세 보기 고르기 — 머리줄 [신규 로직] 왼쪽 「분할 고르기 | 세로 나눔선」 - * 보기 넷 = 단가산출 상세(기본) · 인력·재료·경비 · 시험 계산 · 텍스트 수식 — 고른 덩이만 보임 + * 보기 다섯 = 시트(기본 · 794-1 줄 식 시트) · 단가산출 상세 · 인력·재료·경비 · 시험 계산 · 텍스트 수식 — 고른 덩이만 보임 * 덩이 = `data-block` 이름표 · 숨김 = 그릇 `data-view` + CSS(`LogicLab_Style.css`) — 다시 그려도 고름 유지 * 시험 계산은 숨겨도 그려 둠(자동 계산이 숨은 칸에서도 돎) · 기본정보는 이름표 없음(늘 맨 위) - * 기억 = 이 화면 안에서만(로직을 바꿔도 유지 · 새로 들어오면 단가산출 상세) + * 기억 = 이 화면 안에서만(로직을 바꿔도 유지 · 새로 들어오면 시트) * ========================================================================== */ import { el } from "@ui/ui_template_elements"; @@ -12,7 +12,7 @@ import { createSegment } from "@ui/ui_template_segment"; import { createToolbar } from "@ui/ui_template_toolbar"; import { tl } from "./M01_MasterData_UI_LogicLab_Text"; -export type LabView = "ho" | "est" | "calc" | "formula"; +export type LabView = "sheet" | "ho" | "est" | "calc" | "formula"; /** 덩이에 보기 이름표를 붙여 돌려줌 */ export const block = (node: T, view: LabView): T => { @@ -22,15 +22,16 @@ export const block = (node: T, view: LabView): T => { /** 보기 고르기 — `host`(상세 그릇)에 `data-view` 를 둠 · `root` 는 머리줄에 놓음 */ export function createViewPicker(host: HTMLElement): { root: HTMLElement } { - host.dataset.view = "ho"; + host.dataset.view = "sheet"; const segment = createSegment({ options: [ + { value: "sheet", label: tl("View_Sheet") }, { value: "ho", label: tl("Ho_Title") }, { value: "est", label: tl("View_Est") }, { value: "calc", label: tl("Calc_Title") }, { value: "formula", label: tl("Formula_Title") }, ], - value: "ho", + value: "sheet", size: 32, ariaLabel: tl("View_Label"), onChange: (v) => (host.dataset.view = v), diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts b/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts index 5014c2dfa..81d441dcf 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts @@ -70,6 +70,8 @@ export interface CalcContext { /** 로직마다 넣은 값 — 다시 그려도 남음 */ values: Record; onLines?: (lines: CalcLine[] | null) => void; + /** 794-1 셈 답 통째(중간 값 · 소계) — M01 줄 식 시트 값 */ + onAnswer?: (answer: CalcAnswer) => void; /** 읽는 식 줄(`/text`)도 같이 받고 싶을 때 — 호출이 `onLines` 보다 먼저 */ onText?: (answer: TextAnswer) => void; /** (M02 750-2) 있으면 [계산] = 이 셈(SK 셈 답 하나) · `/calc` · `/text` · 미리보기 안 부름 */ @@ -215,6 +217,7 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void { }; const answer = await runCalc(body); if (ctx.onText) ctx.onText(await runText(body)); + ctx.onAnswer?.(answer); ctx.onLines?.(answer.ok ? (answer.lines ?? null) : null); if (answer.ok && answer.lines) showItems(host, ctx.values, answer.lines); out.replaceChildren(...answerView(answer, draft, money)); diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Edit.ts b/M01_MasterData/M01_MasterData_UI_Logic_Edit.ts index 70a84fe90..a24eb679f 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Edit.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Edit.ts @@ -18,6 +18,7 @@ import type { } from "./M01_MasterData_UI_Logic_Api"; import { tx } from "./M01_MasterData_UI_Logic_Text"; import { createBadge } from "@ui/ui_template_badge"; +import { createCalcFormula } from "@ui/ui_template_calc_row"; import { createText } from "@ui/ui_template_text"; import { createIconButton } from "@ui/ui_template_button"; import { formatLineMoney, formatNumber, formatPrice } from "../common_util/common_util_money"; @@ -107,6 +108,13 @@ function grid(headers: string[], rows: HTMLElement[][], className: string): HTML return el("div", { className: "m01-logic__scroll", children: [table] }); } +/** 식 칸 — 읽기만(고침은 「시트」 보기 식 칸 · 칠 때마다 검사 · 794-1) */ +const sheetOnly = (formula: string): HTMLElement => { + const box = createCalcFormula(formula || "—"); + box.dataset.sheetOnly = ""; + return box; +}; + export function qtyKind(qty: string): string { const text = qty.trim(); if (/^-?\d+(\.\d+)?$/.test(text)) return tx("Ho_QtyNumber"); @@ -308,10 +316,7 @@ function hoSection( }); const brief = ctx.prices[item.요소]; const spec = item.규격 ?? brief?.규격 ?? ""; - const qty = textBox(item.수량, (v) => { - set(() => (item.수량 = v)); - tag.textContent = qtyKind(v); - }); + const qty = sheetOnly(item.수량); // 794-1 식 = 「시트」 보기 식 칸(날 입력칸 갈음) const tag = createBadge(qtyKind(item.수량), "accent"); const table = createButton({ label: tx("Ho_PickTable"), @@ -387,7 +392,7 @@ function formulaRows( return list.map((item, i) => { const cells: HTMLElement[] = [ textBox(item.이름, (v) => set(() => (item.이름 = v))), - textBox(item.식, (v) => set(() => (item.식 = v)), "m01-logic__formula"), + sheetOnly(item.식), ]; if (withCost) { cells.push( diff --git a/M01_MasterData/logic/M01_Logic_Sheet_Model.ts b/M01_MasterData/logic/M01_Logic_Sheet_Model.ts new file mode 100644 index 000000000..0e569cf0f --- /dev/null +++ b/M01_MasterData/logic/M01_Logic_Sheet_Model.ts @@ -0,0 +1,158 @@ +/* ============================================================================= + * M01_MasterData/logic/M01_Logic_Sheet_Model.ts + * M01 로직 줄 식 시트 꼴(PLAN 794-1 · 요구 650~652 · 요구서_652 1 장 6) — M02 단가산출 시트와 같은 꼴 + * 「이름 | 식 | 값 | 비목」 · 줄 갈래 = 근거(출처 · 비고 · 끝수) · 옵션(입력) · 계산(중간) · 자원(호표) · + * 율(덧줄) · 단계 · 소계 · 합계 · 식 글 = 서버 이름 꼴(`/logic/sheet` · 없으면 엔진 식 「*」 → 「×」) + * 값 = 시험 계산 답(줄 금액 = 호표 → 덧줄 차례 · 중간 값 · 비목 소계) + * 고침 = 줄 사본 식 칸 하나(`setLabFormula`) — 저장은 화면이 기존 M01 [저장] 길로 + * 짝 = 서버 `M01_MasterData_Sheet.py`(자리 = 갈래 · 차례 꼴만 같음) · 화면 · fetch · sessionStorage 없음 + * ========================================================================== */ + +import type { + CalcAnswer, + CalcLine, + LogicInput, + LogicRow, + NamedFormula, +} from "../M01_MasterData_UI_Logic_Api"; + +export type LabPart = "중간" | "호표" | "덧줄"; +export type LabKind = "근거" | "옵션" | "계산" | "자원" | "율" | "단계" | "소계" | "합계"; + +/** 줄마다 식 칸 이름(갈래 → 칸) — 서버 `FIELD` 와 같음 */ +export const FIELD: Record = { 중간: "식", 호표: "수량", 덧줄: "식" }; + +export interface LabNames { + 중간: string[]; + 호표: string[]; + 덧줄: string[]; + 이름표?: { 글: string; 갈래: string }[]; +} + +export interface LabItem { + kind: LabKind; + 이름: string; + /** 이름 꼴 식 글 · 빈 글 = 식 없음 */ + 식: string; + 값: number | string | null; + /** 비목 줄임(노 · 재 · 경) */ + 비목: string; + /** 식 칸 자리(중간 · 호표 · 덧줄) */ + part?: LabPart; + at?: number; + input?: LogicInput; + /** 금액번지(791-1) */ + 번지?: string; + /** 저장본과 식이 다름 */ + changed: boolean; +} + +const SHORT: Record = { 노무비: "노", 재료비: "재", 경비: "경" }; +const short = (v: unknown): string => (typeof v === "string" ? (SHORT[v] ?? v) : ""); + +/** 엔진 식 → 보기 글(이름 꼴이 아직 없을 때) */ +export const plain = (s: string): string => s.replace(/\s*\*\s*/g, " × "); + +export const spotKey = (part: LabPart, at: number): string => `${part}#${at}`; + +/** 줄 식 칸 값(엔진 식) */ +export function formulaAt(row: LogicRow, part: LabPart, at: number): string { + const list = (row[part] ?? []) as unknown as Record[]; + return String(list[at]?.[FIELD[part]] ?? ""); +} + +/** 줄 식 칸 하나를 엔진 식으로 바꿈(줄 객체 그대로 고침) · 없는 자리 = false */ +export function setLabFormula(row: LogicRow, part: LabPart, at: number, engine: string): boolean { + const list = row[part] as unknown as Record[] | undefined; + if (!list || at < 0 || at >= list.length) return false; + list[at][FIELD[part]] = engine; + return true; +} + +/** 끝수 글 — 글자면 그대로 · 묶음이면 「대상 방법 자리」 */ +export function cutText(rule: unknown): string { + if (rule == null || rule === "") return ""; + if (typeof rule === "string") return rule; + const r = rule as Record; + return [r.대상, r.방법, r.자리 != null ? `${String(r.자리)} 자리` : ""] + .filter((x) => x !== undefined && x !== "") + .join(" · "); +} + +const valueOf = (v: unknown): number | string | null => + typeof v === "number" || typeof v === "string" ? v : null; + +/** 시트 줄 — 근거 · 옵션 · 계산 · 자원 · 율 · 단계 · 소계 · 합계 */ +export function labItems(o: { + row: LogicRow; + /** 저장본 줄(새 로직 = null) — 식이 다르면 고친 줄 */ + original: LogicRow | null; + values: Record; + names: LabNames | null; + answer: CalcAnswer | null; +}): LabItem[] { + const { row, original } = o; + const out: LabItem[] = []; + const item = (x: Partial & { kind: LabKind; 이름: string }): void => + void out.push({ 식: "", 값: null, 비목: "", changed: false, ...x }); + if (row.출처) item({ kind: "근거", 이름: row.원문번호 || "", 식: row.출처 }); + for (const line of (row.비고 ?? "").split(/\r?\n/).filter((x) => x.trim())) + item({ kind: "근거", 이름: "", 식: line.trim() }); + for (const input of row.입력 ?? []) + item({ kind: "옵션", 이름: input.이름, 값: o.values[input.이름] ?? "", input }); + const ok = o.answer?.ok ? o.answer : null; + const mid = ok?.middle ?? {}; + const shown = (part: LabPart, at: number): string => + o.names?.[part]?.[at] ?? plain(formulaAt(row, part, at)); + const changed = (part: LabPart, at: number): boolean => + !!original && formulaAt(original, part, at) !== formulaAt(row, part, at); + const spot = (part: LabPart, at: number) => ({ + part, + at, + 식: shown(part, at), + changed: changed(part, at), + }); + (row.중간 ?? []).forEach((f: NamedFormula, at) => + item({ kind: "계산", 이름: f.이름, 값: valueOf(mid[f.이름]), ...spot("중간", at) }), + ); + const ho = row.호표 ?? []; + const extra = row.덧줄 ?? []; + const lines: (CalcLine | undefined)[] = + ok?.lines && ok.lines.length === ho.length + extra.length ? ok.lines : []; + ho.forEach((h, at) => { + const b = (h as { 번지?: string }).번지; + item({ + kind: "자원", + 이름: h.단위 ? `${h.이름 ?? ""} (${h.단위})` : (h.이름 ?? ""), + 값: lines[at]?.금액 ?? null, + 비목: short(h.비목), + ...(b ? { 번지: b } : {}), + ...spot("호표", at), + }); + }); + extra.forEach((x, at) => { + const b = (x as { 번지?: string }).번지; + item({ + kind: "율", + 이름: x.이름, + 값: lines[ho.length + at]?.금액 ?? null, + 비목: short(x.비목), + ...(b ? { 번지: b } : {}), + ...spot("덧줄", at), + }); + }); + const steps = (row.단계 ?? []) as { 이름?: string; 줄?: unknown }[]; + for (const s of Array.isArray(steps) ? steps : []) + item({ + kind: "단계", + 이름: String(s.이름 ?? ""), + 식: Array.isArray(s.줄) ? s.줄.map(String).join(" + ") : "", + }); + const cut = cutText(row.끝수); + if (cut) item({ kind: "근거", 이름: "끝수", 식: cut }); + const sums = ok?.sums ?? null; + for (const k of Object.keys(SHORT)) + if (sums && k in sums) item({ kind: "소계", 이름: k, 값: sums[k], 비목: SHORT[k] }); + if (sums && "계" in sums) item({ kind: "합계", 이름: "계", 값: sums.계 }); + return out; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_SheetFormula.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_SheetFormula.ts index d95517573..1118cf5ea 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_SheetFormula.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_SheetFormula.ts @@ -14,7 +14,7 @@ import { createCalcSheetRow } from "@ui/ui_template_calc_row"; import { el } from "@ui/ui_template_elements"; import { createInputField } from "@ui/ui_template_input"; import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { createText } from "@ui/ui_template_text"; +import { createFormulaCell } from "../A00_Common/sheet_formula_cell"; import { call, type Layer } from "./M02_MasterTemplete_Api_Fetch"; import type { EditCtx } from "./M02_MasterTemplete_Sangeun_SheetEdit"; import { @@ -28,16 +28,12 @@ import { import { num, type SpotInfo } from "./logic/M02_Sangeun_Sheet_Model"; const TEXT = { - Formula: ["식", "Formula"], Edit: ["식 고침", "Edit formula"], Close: ["식 닫기", "Close formula"], - Hint: ["예) 무게 / 운반량 × (1 + 할증율 / 100)", "e.g. weight / load × 1.2"], Var: ["변수", "Variable"], VarName: ["변수 이름", "Variable name"], AddVar: ["+ 변수 줄", "+ Variable"], DropVar: ["변수 빼기", "Remove variable"], - Bad: ["식이 틀려 안 넣음", "Invalid formula — not applied"], - Checking: ["검사 중", "Checking…"], } as const satisfies Record; const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0]; @@ -97,7 +93,7 @@ export function clearNums(doc: T, spot: string): T { return out as T; } -/** 식 칸 — 칠 때마다 검사(앞 요청 끊음) · 바꿀 때 넣음 · `put(엔진 식 | null)` */ +/** 식 칸 — 칠 때마다 검사(앞 요청 끊음) · 바꿀 때 넣음 · `put(엔진 식 | null)` · 몸 = 공용 `sheet_formula_cell` */ function formulaBox( c: FormulaCtx, spot: string, @@ -106,63 +102,14 @@ function formulaBox( origin: string, put: (engine: string | null) => void, ): HTMLElement { - const box = createInputField({ - value: c.drafts.get(key) ?? shown, - compact: true, - size: 24, - placeholder: t("Hint"), - }); - box.input.setAttribute("aria-label", t("Formula")); - box.input.dataset.sheetCell = key; - box.input.spellcheck = false; - const note = createText("", "muted", 12); - note.dataset.sheetCheck = key; - let timer = 0; - let ctl: AbortController | null = null; - let last: Promise | null = null; - const run = (text: string): Promise => { - ctl?.abort(); - const mine = (ctl = new AbortController()); - note.textContent = t("Checking"); - last = checkFormula(c, spot, text, mine.signal).then( - (got) => { - if (mine !== ctl) return got; - box.setError(got.되나 ? undefined : got.까닭.join(" · ")); - note.textContent = got.알림.join(" · "); - return got; - }, - (error: unknown) => { - if (mine !== ctl || mine.signal.aborted) return null; - box.setError(error instanceof Error ? error.message : String(error)); - note.textContent = ""; - return null; - }, - ); - return last; - }; - box.input.addEventListener("input", () => { - c.drafts.set(key, box.input.value); - window.clearTimeout(timer); - const text = box.input.value.trim(); - if (!text) return void (ctl?.abort(), box.setError(undefined), (note.textContent = "")); - timer = window.setTimeout(() => void run(text), 150); - }); - box.input.addEventListener("change", async () => { - window.clearTimeout(timer); - const text = box.input.value.trim(); - if (!text || text === origin) { - c.drafts.delete(key); - return put(null); - } - const got = await run(text); - if (!got?.되나) return void (note.textContent = t("Bad")); - c.drafts.delete(key); - put(got.식); - }); - return el("div", { - className: "m02-sks__fx", - attrs: { "data-sheet-fx": spot }, - children: [box.root, note], + return createFormulaCell({ + key, + spot, + shown, + origin, + drafts: c.drafts, + check: (text, signal) => checkFormula(c, spot, text, signal), + put, }); } diff --git a/resources/tester/test_717_2b_sangeun_detail.py b/resources/tester/test_717_2b_sangeun_detail.py index eaf7b1cbf..50aa950ab 100644 --- a/resources/tester/test_717_2b_sangeun_detail.py +++ b/resources/tester/test_717_2b_sangeun_detail.py @@ -192,7 +192,9 @@ def test_상세_부품_길_부품상자(): side = _text(ROOT / "M01_MasterData" / "M01_MasterData_UI_Side.ts") modal = _text(ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_Modal.ts") assert "export const hasMasterJump" in side and "go.hidden = !hasMasterJump()" in modal - assert len(_text(ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab.ts").splitlines()) <= 627 + assert ( + len(_text(ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab.ts").splitlines()) <= 637 + ) # 794-1 시트 이음 10 줄 def test_화면_문서_꼴_서버_저장_셈(world): # noqa: F811 — 가져온 fixture diff --git a/resources/tester/test_722_ho_detail.py b/resources/tester/test_722_ho_detail.py index c57fa313f..f8c3cd1be 100644 --- a/resources/tester/test_722_ho_detail.py +++ b/resources/tester/test_722_ho_detail.py @@ -56,7 +56,7 @@ def test_상세가_공용_부품을_씀(): assert "export function varRow(" in _text(ROOT / "A00_Common" / "calc_sheet.ts") for p in (HO, COVER, M01 / "M01_MasterData_UI_LogicLab.ts"): assert len(_text(p).splitlines()) <= ( - 627 if p.name == "M01_MasterData_UI_LogicLab.ts" else 700 + 637 if p.name == "M01_MasterData_UI_LogicLab.ts" else 700 # 794-1 시트 이음 10 줄 ) diff --git a/resources/tester/test_790_1_sangeun_sheet.py b/resources/tester/test_790_1_sangeun_sheet.py index be9d5658b..4ceb58f3c 100644 --- a/resources/tester/test_790_1_sangeun_sheet.py +++ b/resources/tester/test_790_1_sangeun_sheet.py @@ -276,26 +276,28 @@ def test_790_3_식_조각_글줄_산출서_열쇠_밂(): # ── 792 식 칸 화면(줄 식 고침 · 칠 때마다 검사 · 변수 줄) ──────────────────────────── FX = M02 / "M02_MasterTemplete_Sangeun_SheetFormula.ts" +CELL = ROOT / "A00_Common" / "sheet_formula_cell.ts" # 794-1 식 칸 몸 = 공용(M01 시트와 한 벌) def test_792_식_칸_정적(): s = _text(FX) assert " str: + return p.read_text("utf-8") + + +def _hashes(folder: Path) -> dict[str, str]: + return {p.name: hashlib.md5(p.read_bytes()).hexdigest() for p in sorted(folder.glob("*.json"))} + + +# ── 정적 ───────────────────────────────────────────────────────────── +def test_정적_부품_상자만_관리자만_저장은_기존_길() -> None: + for p in (SHEET, MODEL, CELL): + s = _text(p) + assert "= 2 # [식 고침] · 식 칸 = 관리자만 + lab = _text(LAB) + assert "const sheet = createLabSheet({\n admin," in lab.replace("\r\n", "\n") + assert "sheet: sheet.root," in lab and "onAnswer: sheet.answer," in lab + assert "edited: () => (touch(), drawEditor(), drawCalc())" in lab # 캐시(touch) → [저장] + assert "setLabFormula(d.row, part, at, text)" in sheet and "opt.edited();" in sheet + assert len(lab.splitlines()) <= 637 + view = _text(M01 / "M01_MasterData_UI_LogicLab_View.ts") + assert ( + 'host.dataset.view = "sheet";' in view + and '{ value: "sheet", label: tl("View_Sheet") }' in view + ) + css = _text(M01 / "M01_MasterData_UI_LogicLab_Style.css") + assert '[data-view="sheet"] [data-block]:not([data-block="sheet"])' in css + edit = _text(M01 / "M01_MasterData_UI_Logic_Edit.ts") + assert "textBox(item.수량" not in edit and "textBox(item.식" not in edit # GX 날 식 칸 갈음 + m02 = _text(ROOT / "M02_MasterTemplete" / "M02_MasterTemplete_Sangeun_SheetFormula.ts") + assert "return createFormulaCell({" in m02 # M02 시트도 같은 식 칸 + main = _text(ROOT / "main.py") + assert "app.include_router(m01_master_data_router, dependencies=system_admin_only)" in main + + +# ── 서버 ───────────────────────────────────────────────────────────── +@pytest.fixture +def folder(tmp_path: Path, monkeypatch) -> Path: + master = tmp_path / "master" + master.mkdir() + for path in REAL.glob("*.json"): + shutil.copy(path, master / path.name) + monkeypatch.setattr(store, "FOLDER", master) + return master + + +@pytest.fixture +def client(folder: Path) -> TestClient: + app = FastAPI() + app.include_router(m01_router.router) + return TestClient(app) + + +def _post(client: TestClient, url: str, body: dict, want: int = 200) -> dict: + res = client.post(url, json=body) + assert res.status_code == want, res.text + return res.json() + + +def test_이름_꼴_검사_쓰기_없음(client: TestClient, folder: Path) -> None: + before, real = _hashes(folder), _hashes(REAL) + one = client.get("/api/m01/logic", params={"key": KEY}).json() + row = one["logic"] + names = _post(client, "/api/m01/logic/sheet", {"row": row}) + assert names["덧줄"][0] == TOOL_1 + assert names["중간"][3] == "[합판거푸집설치및해체일당].형틀목공 / 시공량" + assert ( + len(names["호표"]) == len(row["호표"]) + and {"글": "시공량", "갈래": "중간"} in names["이름표"] + ) + ask = lambda part, at, text: _post( # noqa: E731 + client, "/api/m01/logic/sheet/check", {"row": row, "갈래": part, "차례": at, "식": text} + ) + good = ask("덧줄", 0, TOOL_2) + assert good["되나"] and good["식"] == "(형틀목공 * LB000007 + 보통인부 * LB000002) * 2 / 100" + assert good["글"] == TOOL_2 + assert ask("덧줄", 0, "형틀목공 × [노임 없음]")["까닭"] == ["없는 이름 「[노임 없음]」"] + below = ask("중간", 0, "시공량 × 2") # 시공량 = 아래 줄 + assert not below["되나"] and "시공량" in below["까닭"][0] + assert not ask("중간", 2, "(1 +")["되나"] + assert ask("호표", 0, " ")["까닭"] == ["식이 빔"] + assert not ask("호표", 0, "1" + " + 1" * 200)["되나"] # 길이 상한 + assert ask("덧줄", 9, "1")["까닭"] == ["없는 자리 「덧줄 9」"] + assert ask("호표", 0, "형틀목공 × 2")["되나"] + assert row == client.get("/api/m01/logic", params={"key": KEY}).json()["logic"] # 줄 안 바뀜 + assert _hashes(folder) == before and _hashes(REAL) == real + + +def test_저장_길_개정_확인_다시_셈_틀린_식_422(client: TestClient, folder: Path) -> None: + real = _hashes(REAL) + one = client.get("/api/m01/logic", params={"key": KEY}).json() + row = copy.deepcopy(one["logic"]) + got = line_sheet.check(row, "덧줄", 0, TOOL_2) + row["덧줄"][0]["식"] = got["식"] + body = {"files": [{"file": one["file"], "version": one["version"], + "changes": [{"op": "edit", "key": KEY, "row": row}]}]} # fmt: skip + assert client.post("/api/m01/save", json=body).status_code == 409 # 정본 = 개정 확인 + _post(client, "/api/m01/save", {**body, "개정확인": True}) + saved = client.get("/api/m01/logic", params={"key": KEY}).json() + assert saved["logic"]["덧줄"][0]["식"] == got["식"] + sums = _post(client, "/api/m01/calc", {"key": KEY, "inputs": INPUTS})["sums"] + assert sums["계"] == 51802 and sums["경비"] == 765 # _분석_650-H2 4 장 공구손료 2 % + bad = copy.deepcopy(saved["logic"]) + bad["덧줄"][0]["식"] = "형틀목공 * 없는이름" + body = {"files": [{"file": saved["file"], "version": saved["version"], + "changes": [{"op": "edit", "key": KEY, "row": bad}]}], "개정확인": True} # fmt: skip + before = _hashes(folder) + assert client.post("/api/m01/save", json=body).status_code == 422 + assert _hashes(folder) == before and _hashes(REAL) == real + + +def test_시스템_관리자가_아니면_403(folder: Path) -> None: + app = FastAPI() + app.include_router( + m01_router.router, dependencies=[Depends(verify_session), Depends(require_system_admin)] + ) + app.dependency_overrides[verify_session] = lambda: {"role": "USER", "company_id": 1} + client = TestClient(app) + for url, body in (("/api/m01/logic/sheet", {"row": {}}), + ("/api/m01/logic/sheet/check", {"row": {}, "갈래": "중간", "차례": 0, "식": "1"})): # fmt: skip + assert client.post(url, json=body).status_code == 403 + + +# ── 모델 ───────────────────────────────────────────────────────────── +ROW = { + "키": "GX1", "원문번호": "1-1", "이름": "견본", "결과단위": "원/㎡", "출처": "견본 1-1", "비고": "한 줄\n둘째 줄", + "입력": [{"이름": "폭", "단위": "m"}], + "중간": [{"이름": "넓이", "식": "폭 * 2"}], + "호표": [{"종류": "인력", "요소": "LB000002", "이름": "보통인부", "단위": "인", "수량": "넓이 * 0.1", "비목": "노무비", "번지": "A01"}], + "덧줄": [{"이름": "공구손료", "식": "노무비 * 1 / 100", "비목": "경비"}], + "단계": [{"이름": "노무 소계", "단": 1, "줄": ["A01"]}], + "끝수": {"대상": "비목", "방법": "버림", "자리": 0}, +} # fmt: skip + + +def _run() -> dict: + src = IMPORTS.sub("", _text(MODEL)) + body = ( + f"\nconst R = {json.dumps(ROW, ensure_ascii=False)};" + "\nconst now = structuredClone(R);" + "\nconst put = setLabFormula(now, '호표', 0, '넓이 * 0.2');" + "\nconst miss = setLabFormula(now, '덧줄', 5, '1');" + "\nconst answer = { ok: true, middle: { 넓이: 6 }, sums: { 노무비: 1000, 재료비: 0, 경비: 10, 계: 1010 }," + " lines: [{ 금액: 1000 }, { 금액: 10.5 }] };" + "\nconst items = labItems({ row: now, original: R, values: { 폭: '3' }," + " names: { 중간: ['폭 × 2'], 호표: ['넓이 × 0.2'], 덧줄: ['노무비 × 1 / 100'] }, answer });" + "\nconst bare = labItems({ row: R, original: null, values: {}, names: null, answer: { ok: false, reason: 'x' } });" + "\nconsole.log(JSON.stringify({ put, miss, items, bare, cut: cutText('글자 끝수') }));" + ) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript", "-"], + input=src + body, capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def test_모델_줄_갈래_값_고친_줄() -> None: + got = _run() + assert got["put"] is True and got["miss"] is False + kinds = [(x["kind"], x["이름"]) for x in got["items"]] + assert kinds == [("근거", "1-1"), ("근거", ""), ("근거", ""), ("옵션", "폭"), ("계산", "넓이"), + ("자원", "보통인부 (인)"), ("율", "공구손료"), ("단계", "노무 소계"), ("근거", "끝수"), + ("소계", "노무비"), ("소계", "재료비"), ("소계", "경비"), ("합계", "계")] # fmt: skip + by = {(x["kind"], x["이름"]): x for x in got["items"]} + assert by[("옵션", "폭")]["값"] == "3" + assert by[("계산", "넓이")]["값"] == 6 and not by[("계산", "넓이")]["changed"] + res = by[("자원", "보통인부 (인)")] + assert res["값"] == 1000 and res["비목"] == "노" and res["번지"] == "A01" and res["changed"] + assert res["식"] == "넓이 × 0.2" and res["part"] == "호표" and res["at"] == 0 + assert by[("율", "공구손료")]["값"] == 10.5 and by[("율", "공구손료")]["part"] == "덧줄" + assert by[("단계", "노무 소계")]["식"] == "A01" + assert by[("근거", "끝수")]["식"] == "비목 · 버림 · 0 자리" + assert by[("합계", "계")]["값"] == 1010 + bare = {(x["kind"], x["이름"]): x for x in got["bare"]} + assert bare[("계산", "넓이")]["식"] == "폭 × 2" # 이름 꼴 전 = 엔진 식 「*」 → 「×」 + assert bare[("자원", "보통인부 (인)")]["값"] is None and ("합계", "계") not in bare + assert got["cut"] == "글자 끝수"