From c637ff14eaaf73ca3c8d6dbb13e184d1b434418b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 14 Oct 2026 21:56:09 +0900 Subject: [PATCH 1/6] =?UTF-8?q?chore(plan):=20324=20=EC=9D=BC=EC=9C=84?= =?UTF-8?q?=EB=8C=80=EA=B0=80=20=EC=8B=A4=EB=AC=B4=EC=9E=90=EB=A3=8C=20?= =?UTF-8?q?=EC=A1=B0=EC=82=AC=20=C2=B7=20=EC=A0=95=ED=95=A8=20=E2=80=94=20?= =?UTF-8?q?=EC=97=91=EC=85=80=20=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20?= =?UTF-8?q?=EC=8B=9C=ED=8A=B8=20=EA=BC=B4(xlrd=20=C2=B7=20=EC=97=B4=20?= =?UTF-8?q?=C2=B7=20=ED=98=B8=ED=91=9C=20=C2=B7=20=EA=B0=88=EB=9E=98=20?= =?UTF-8?q?=C2=B7=20=EA=B2=80=EC=82=B0=20=C2=B7=20=EA=B7=9C=EA=B2=A9)=20?= =?UTF-8?q?=C2=B7=20=EC=9A=B0=EB=A6=AC=20=EA=B5=AC=EC=A1=B0(=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=C2=B7=20=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20?= =?UTF-8?q?=C2=B7=20=EC=82=AC=EC=A0=84=EC=A1=B0=ED=95=A9=20=C2=B7=20?= =?UTF-8?q?=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=EC=B0=BD=20=EB=8D=A7=EC=A4=84)?= =?UTF-8?q?=20=C2=B7=20=ED=98=B8=ED=91=9C=20=3D=20=EC=82=AC=EC=A0=84?= =?UTF-8?q?=EC=A1=B0=ED=95=A9=20=ED=95=98=EB=82=98=20+=20=EC=B0=B8?= =?UTF-8?q?=EC=A1=B0=20=EC=A4=84=20=C2=B7=20=EB=B3=84=EC=B9=AD=20=3D=20?= =?UTF-8?q?=EC=97=91=EC=85=80=20=EC=9D=B4=EB=A6=84=20=C2=B7=20=EA=B7=9C?= =?UTF-8?q?=EA=B2=A9=20=E2=86=92=20=EC=9E=85=EB=A0=A5=EA=B0=92=20=C2=B7=20?= =?UTF-8?q?=ED=99=98=EC=82=B0=20=C2=B7=20=EB=A7=9E=EC=B6=A4=20=EA=B8=B0?= =?UTF-8?q?=EC=96=B5=20=C2=B7=20=EB=8B=A8=EA=B3=84=20324-1~5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK --- .../조사/2026-10-14_324_일위대가실무자료.md | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 docs/raw/조사/2026-10-14_324_일위대가실무자료.md diff --git a/docs/raw/조사/2026-10-14_324_일위대가실무자료.md b/docs/raw/조사/2026-10-14_324_일위대가실무자료.md new file mode 100644 index 000000000..6da83a569 --- /dev/null +++ b/docs/raw/조사/2026-10-14_324_일위대가실무자료.md @@ -0,0 +1,66 @@ +# 324 일위대가 실무자료 — 조사 · 정함 (요구 223-5 · 2026-10-14) + +요구(사용자 원문 요약): M02 좌측 「일위대가 실무자료」 버튼으로 엑셀 올림 → 「일위대가」 탭 분석 · 구조물조합 일위대가 [로직 추가] 때 실무자료 호표 불러오기 · 엑셀 단가산출 이름이 우리 로직 이름과 다름 → 맞으면 엑셀 이름 = 일위대가 별칭 · 옵션 값 = 규격 열 · 남의 실무문서를 우리 일위대가로 · 단위 다르면 환산한 결과로 맞춤 · 상세 분석 뒤 신규 계획 · 구현은 sub1(sub_laptop_1 가지). + +조사 = Plan 넷(엑셀 구조 · 우리 단가산출/일위대가 구조 · M02 화면 · 맞춤/단위 도구). 경로는 저장소 기준. + +## 1. 엑셀 「일위대가」 시트 (북부청 임도 공통단가 .xls 기준) + +- 읽기 = **xlrd 2.0.2**(venv 에 있음 · requirements 에 없음 → 적어 넣음) · `xlrd.open_workbook(file_contents=…, logfile=io.StringIO())`(OLE2 경고가 stdout 으로 안 나가게) · .xlsx = openpyxl(빈 칸 None). +- 시트 = `일위대가`(560×15) · 목록 = `일위대가총괄표`(검산용) · 2·3행 머리 글(빈칸 빼고)로 찾음 · 자료 4행부터 · 숨김 없음. +- 열(0부터): 1 호표 · 2 품명 · 3 규격 · 4 수량 · 5 단위 · 6/7 합계 단가/금액 · 8/9 노무비 · 10/11 재료비 · 12/13 경비 · 14 비고 — **차례 = 합계 · 노무비 · 재료비 · 경비**. +- 호표 줄 = `re.fullmatch(r'제\s*(\d+)\s*호표', 1열)` · 1 단위 값은 **금액 열(7/9/11/13)** · 단가 열 빔. +- 구성 줄 = 1열 빔 · 2열 있음 · 끝 = 다음 호표 줄 또는 시트 끝(합계 줄 없음 · 빈 줄 하나로 띄움). +- 구성 줄 갈래(415 줄): 값 있음 350 · 값 없음(별도계상) 64 · % 1(4열 = 율 · 6열 = 바탕 금액 · 규격 「재료비의」 「노무비의」) · 참조 = 비고 `^(단산|일위)(\d+)참조$`(단산 268 · 일위 1). +- 갈래 나누기(맞춤 쪽 조사): 단산 참조 = 단가산출 · 참조 없고 재료비만 = 자재(143 · 자재단가 시트로 확인 가능) · 단위 hr 등 = 기계(3) · 일위N = 다른 호표. +- 검산 규칙(68 + 27 호표 어긋남 0): 줄 금액 = floor(단가 × 수량 × 10) / 10 · 6열 = 8+10+12 · 호표 노무/재료/경비 = floor(줄 금액 합) · % 줄 = floor(바탕 × 율 / 100 × 10) / 10. +- 수: 68 호표(제1~68 빈 번호 없음) · 줄 0~21 · 제61호표 = 줄 없음 · 값 빔. +- 보기: 제1호표 배수관날개벽(유입구) 「D=1,000mm,일반형」 개소 1,040,052 · 제3호표 임도표지석 「910×600×150」 개소 1,415,169(줄 = 표지석 재료 ㎥ 1 · 구조물터파기(인력100%) 토사 ㎥ 0.14 단산93 · 되메우기 ㎥ 0.05 단산12 · 잔토처리 ㎥ 0.09 단산13) · 제46호표 고화매트재료비 ㎡(제45호표가 일위46참조로 씀). +- 규격 쓰는 법: 자유 글 많음 · `B=` `H:`(쌍점) `D=` `A×B×C` `H=` `T=` `L=` · 구분 = 쉼표 · 빈칸 · 천 단위 쉼표 함정(`D=1,000mm` 만 = 뒤 숫자 셋) → `re.sub(r'(=\d{1,3}),(\d{3})(?!\d)', r'\1\2', s)` 뒤 `([A-Za-z]\d?)\s*[=:]\s*([\d.]+)\s*(mm|cm|m)?` · 원문 늘 보관 · × Φ Ø ㎜ ㎥ ㎡ 섞임. +- 단위: 호표 = M(= m) 37 · 개소 19 · ㎡ 12 / 줄 = ㎥ ㎡ M Ton/TON EA 공/㎥ kg hr 개소 % 개 · 사방 파일 더 = 식 경간 개 인 조. +- 다른 파일: 사방(계류+댐) = 같은 꼴 27 호표 · 수목진료 · 2016 사방표준품셈(안) = 일위대가 시트 없음(단가산출 · 단가산출총괄표만) → 「일위대가 시트 없음」. +- 함정: 이름만 같은 호표 많음 → 열쇠 = 파일 + 호표 번호 · 이름 끝 전각 빈칸(strip) · 이름 안 옵션(`큰돌바닥막이(H=0.5)`) · 호표 단위 ≠ 줄 단위(개소 안 ㎥ 줄). + +## 2. 우리 쪽 구조 + +- 단가산출 로직(원본 · 손대지 않음) = `resources/master_data/로직_*.json` 1,469 줄 `{키 GF…, 이름, 결과단위 "원/㎡", 입력:[{이름, 단위, 고르기, 범위}], 중간, 호표, 덧줄, 끝수}` · 엔진 `master_formula.run` → `{줄, 노무비, 재료비, 경비, 계}` · 결과단위 읽기 `M01_MasterData_Store_Combo.combo_pills`(「원/10㎡」 = 분모 ㎡ · 배 10). +- 일위대가 문서(구조물 열 하나) = `{층}/templates/labor/<열>.json` `{종류, 열, 단위, 본뜬, 일위대가번, 담은로직:[{로직, 별칭, 메모, 입력값, 수량} | {참조:{층,주인,키}, 별칭, 메모, 수량}], 자재:[{코드, 이름, 규격, 단위, 수량, 자재구분?, 별도계상?, 운반비?}], 계산값}`(`M02_Labor_Doc.py` 머리 글) · 반영 = 기준 × 수량 ÷ 배 · **같은 로직 두 번 금지** · 별칭 ≤ 40 자(44-6 · 쓰기 됨 · B09 가 대가 이름으로 씀). +- 일위대가 사전조합 = `{층}/precombo/SC-######.json` `{종류:"사전조합", 이름, 단위, 담은로직, 자재, 구분, 상세구분, 본뜬}` · 값 = Σ(기준 ÷ 배) × 손 · 일위대가가 참조 줄로 씀(같은 층 · 위 층만) · 만들기 `POST /api/m02/layers/{l}/library/precombo`(`M02_Labor_Router.py:91` → `M02_Labor_Ref.py:257`) · **`_combo_lines`(`M02_Labor_Ref.py:202`) 가 별칭을 버림 → 한 줄 고침 필요** · 같은 로직 두 번 금지도 같음(labor.clean). +- [로직 추가] = `M02_MasterTemplete_Labor_Detail.ts:496-512`(693 줄 · 거의 끝) → `openLogicPicker`(`M02_MasterTemplete_Labor_Pick.ts:75`) = 로직 + 사전조합 한 목록 · **덧줄 자리 `LibraryPick.extra / kindFilter`(:43-54 · B09 가 이미 씀)** · 고르면 `lib.pick(ref, name)`(:487-494)이 참조 줄을 더함 · 사전조합 다듬기 = 참조 [수정] 창(`Labor_RefEdit.ts`). +- 좌측 판 = `M02_MasterTemplete_UI_Side.ts`(차례: 구조물 조합 · 일위대가 사전조합 · 구조물 옵션 정의 · 도면 양식 · 실무문서) · `section()` 꼴 · 층 = 해시 root · 사전조합은 따로 층 줄(pre) · 이름 금지 낱말(시험이 봄) = `practice` · `LaborSection` · `labor?.`. +- 「실무문서」(기존) = 올리기 없음 · `일위대가조합.json` UA 37 읽기 목록 + [통째 지우기](시스템 관리자만) — 이번 기능과 별개 · 그대로 둠. +- 올리기 본 = `M02_MasterTemplete_Router_Drawing.py:57-92`(UploadFile · 20 MB · 스레드 파싱 · 엉터리 400) + `M02_MasterTemplete_Drawing.ts:145-162 · 297-329`(숨은 file 입력 · FormData). +- 권한(`M02_Layer_Access.py`) = system 쓰기 시스템 관리자 · 회사 = 시스템 관리자 · 그 회사 관리자 · 개인 = 본인 · 회사 관리자. +- 정규화 · 단위 도구(그대로 씀): `master_material.plain / plain_spec / plain_unit`(NFKC · ㎥→m3 · M→m · Ton→톤 · EA→개) · `master_pick.UNITS / kind_of / same_kind / convert` · `M02_Labor_Inputs.logic_inputs`(입력 · 고르기 · 고르기이름 · 자동값) · 씨앗 = `일위대가조합.json` 메모(실무 이름 약 80 → 로직 43 · 읽기만) · 선례 = `resources/master_data/scripts/compare_estx.py`(PAIRS = 외부 코드 · 로직 · 입력 · 환산 배수 · `base_name` · `MACH_WORDS`). +- 맞춤 어림(북부청 단가산출 이름 95): 같음 9 · 괄호 빼면 9 · UA 씨앗 13 → 31 자동 · 64 = 후보 확정 필요(difflib 첫 후보 자주 틀림). +- 지키는 것: `resources/master_data/*.json` · `일위대가조합.json` 원본 안 고침(시험으로 sha256 확인) · M01 로직 별칭(208-1 · 원본 안) 안 씀 · `main.py`(802 줄) 안 건드림 → 길은 `M02_Labor_Router.py` 에. + +## 3. 정함(브레인 · 사용자가 맡김) + +1. 층 = 구조물 조합 층(root)과 같은 규칙 · 읽기 · 쓰기 권한 그대로(마스터 = 시스템 관리자) · 자리 = `{층 폴더}/실무자료/.json` + 맞춤 기억 `{층 폴더}/실무자료/맞춤.json` · 템플릿 목록 · 복사 · 프로젝트 씨앗에 안 섞임. +2. 올리기 = 파싱 결과만 저장(원본 파일 안 둠) · 파일 이름 · 올린 때 · 올린 이 · 시트 · 호표 · 검산 결과. +3. 호표 불러오기 = 그 호표를 우리 **일위대가 사전조합** 하나로 만들고(같은 층에 같은 파일 · 같은 호표 것이 있으면 다시 씀) 일위대가에 참조 한 줄(별칭 = 엑셀 호표 품명 · 메모 = 규격 · 수량 손 1) — 사전조합 = 「우리 일위대가」 · 여러 구조물이 같이 씀 · 엔진 안 바꿈. +4. 사전조합 안 줄 = 엑셀 구성 줄마다 맞춘 것: 로직 줄 `{로직, 별칭 = 엑셀 품명(40 자), 메모 = 「실무 0.14㎥ × 1 · 규격」, 입력값 = 규격에서, 수량 손 = 엑셀 수량 × 환산}` · 자재 줄(우리 자재와 맞은 것 · 별도계상 표시 그대로) · 일위N참조 = 그 호표 사전조합을 먼저 만들어 참조 줄(순환 막음). +5. 같은 로직 두 번(예 레미콘타설 철근 · 무근) = 둘째부터 한 줄 사전조합으로 감싸 참조(문서 규칙 그대로). +6. 맞춤 차례 = ① 층 기억(확정분) ② 이름 같음(정규화) · 씨앗(UA 메모) ③ 정리 이름 같음(괄호 빼기 · 기계 낱말) ④ 후보 5(글자 두 개 묶음 점수 + 단위 맞음 · 산림품셈 덤) — ③④ 는 사용자 확정 뒤에만 들어감 · 확정 = 층 기억에 남아 다음부터 자동. +7. 규격 → 입력값 = 고르기 글이 규격(괄호 글 포함)에 있으면 그 값 · 기계 = 고르기이름(이름 + 용량) · 이름 붙은 수(D= H= H: B= T= L= L3=)는 입력 단위로 바꿈 · 못 읽으면 빈 값(자동값) + 표시. +8. 환산(우리 수량 = 엑셀 수량 × f): 같은 단위 f = 1(10㎡ 배는 엔진) · 같은 종류 = 단위표 · 다른 종류 = 규격 T · B 로 추정(㎡→㎥ × T · m→㎡ × B · m→㎥ × B × T · 「추정」 표시 · 확정 뒤에만) · 그 밖(개소 · 회 · hr 등) = 사용자 입력 · 두 단위 로직 = 단위 입력으로 고르고 f = 1. +9. 안 들어가는 줄 = % 줄(우리 로직 덧줄이 셈) · 못 맞춘 줄 · 값 없는 줄 중 못 맞춘 것 — 화면에 「빠진 줄 · 엑셀 금액」 · 만든 사전조합 값 ↔ 엑셀 호표 단가 한 줄 비교. +10. 일위대가 시트 없는 파일 = 400 「일위대가 시트 없음」(단가산출총괄표 대체는 이번 밖). + +## 4. 단계 (PLAN 324) + +- 324-1 읽기 · 저장 · 올리기 길 — `M02_Hopyo_Excel.py`(xls · xlsx → 호표 · 줄 · 갈래 · 참조 · 검산) · `M02_Hopyo_Store.py`(층 폴더 · 잠금 · 원자 쓰기) · 길(올리기 · 목록 · 읽기 · 지우기) · requirements xlrd. +- 324-2 맞춤 — `M02_Hopyo_Match.py`(정규화 · 규격 → 입력 · 후보 · 기억 · 환산) · 길(후보 · 확정 저장). +- 324-3 사전조합 만들기 — `M02_Hopyo_Build.py`(호표 → 사전조합 · 별칭 · 감싸기 · 일위N · 자재 · 다시 씀) · `_combo_lines` 별칭 살림 · 길(만들기). +- 324-4 화면 — 좌측 「일위대가 실무자료」 판(올리기 · 파일 줄) · 본 화면(호표 표 · 찾기 · 줄 맞춤 · 확정 · 빠진 줄 · 값 비교) · [로직 추가] 고르기 창에 「실무 호표」 줄(고르면 사전조합 만들고 참조 줄). +- 324-5 확인 — 시험 + typecheck · 브레인 화면 확인(북부청 파일 올림 → 제3호표 맞춤 → 구조물 일위대가에 불러오기 → 저장) → 아티팩트 보고. + +## 5. 시험 + +- 파싱: 북부청 68 · 사방 27 · 검산 어긋남 0 · 제1 · 제3 · 제46 · 제61(빈 호표) 값 · 일위대가 없는 파일 400 · xlsx 길(작은 파일 만들어). +- 맞춤: `D=1,000mm` · `H:1.2m` · `B=2.3,H=0.4` · `Φ40×200` · 단위 정규화 · 환산(같음 · 같은 종류 · T · B 추정 · 입력) · 기억 우선. +- 길: 역할 표(시스템 관리자 · 회사 관리자 · 일반 · 남의 개인 층 403) · 엉터리 파일 400 · 20 MB · 지우기. +- 만들기: 별칭 남음 · 수량 = 엑셀 × 환산 · 같은 로직 둘 = 감쌈 · 일위N 참조 · 같은 호표 다시 = 같은 사전조합 · 빠진 줄 보고. +- 원본 지킴: 올리기 · 맞춤 · 만들기 뒤 `resources/master_data/*.json` sha256 그대로. +- 화면: 소스 확인 시험(기존 꼴) · 고르기 창 덧줄 node 시험. From 63ba44a717b83a52e6d4e85bbd15451d71b0c6f9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 14 Oct 2026 22:03:59 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat(=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80?= =?UTF-8?q?=EC=8B=A4=EB=AC=B4):=20324-1=20=EC=8B=A4=EB=AC=B4=EC=9E=90?= =?UTF-8?q?=EB=A3=8C=20=EC=97=91=EC=85=80=20=EC=9D=BD=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=C2=B7=20=EC=98=AC=EB=A6=AC=EA=B8=B0=20?= =?UTF-8?q?=EA=B8=B8=20=E2=80=94=20=E3=80=8C=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=E3=80=8D=20=EC=8B=9C=ED=8A=B8=20=E2=86=92=20=ED=98=B8?= =?UTF-8?q?=ED=91=9C=20=C2=B7=20=EC=A4=84=20=C2=B7=20=EA=B0=88=EB=9E=98=20?= =?UTF-8?q?=C2=B7=20=EC=B0=B8=EC=A1=B0=20=C2=B7=20=EA=B2=80=EC=82=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M02_Hopyo_Excel: xls(xlrd · logfile 막음) · xlsx(openpyxl) · 머리 글로 시트 · 열 찾기 · 제N호표 · 구성 줄 · 갈래(단가산출 · 자재 · 기계 · 호표 · % · 별도계상 · 기타) · 단산N/일위N 참조 · 검산(줄 금액 floor · 합계 단가 · % 줄 · 호표 비목 · 총괄표) · 규격 원문 보관 · 시트 없음 = 「일위대가 시트 없음」 - M02_Hopyo_Store: 층 폴더 실무자료/HP-######.json · 맞춤 기억 실무자료/맞춤.json · 잠금 + 원자 쓰기 · 양식 종류 밖(목록 · 복사 · 프로젝트 씨앗에 안 섞임) - M02_Labor_Router: GET/POST /layers/{층}/hopyo · GET/DELETE /layers/{층}/hopyo/{키} · 20 MB · 엉터리 · 시트 없음 400 · 권한 = 층 읽기 · 쓰기 규칙 - requirements: xlrd==2.0.2 - 시험 test_m02_hopyo_excel 15 통과(북부청 68 · 사방 27 · 검산 0 · 역할 표 · sha256) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- M02_MasterTemplete/M02_Hopyo_Excel.py | 269 +++++++++++++++++++++++ M02_MasterTemplete/M02_Hopyo_Store.py | 116 ++++++++++ M02_MasterTemplete/M02_Labor_Router.py | 66 +++++- requirements.txt | 1 + resources/tester/test_m02_hopyo_excel.py | 209 ++++++++++++++++++ 5 files changed, 659 insertions(+), 2 deletions(-) create mode 100644 M02_MasterTemplete/M02_Hopyo_Excel.py create mode 100644 M02_MasterTemplete/M02_Hopyo_Store.py create mode 100644 resources/tester/test_m02_hopyo_excel.py diff --git a/M02_MasterTemplete/M02_Hopyo_Excel.py b/M02_MasterTemplete/M02_Hopyo_Excel.py new file mode 100644 index 000000000..38d11a81e --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_Excel.py @@ -0,0 +1,269 @@ +"""M02 일위대가 실무자료 읽기(PLAN 324-1) — 설계내역서 엑셀 「일위대가」 시트 → +호표 · 줄 · 갈래 · 검산. + +읽기 = .xls `xlrd`(경고가 stdout 으로 안 나가게 logfile) · .xlsx `openpyxl`(빈 칸 None) · +시트 · 열은 머리 글(빈칸 뺌)로 찾음 — 호표 · 품명 · 규격 · 수량 · 단위 · 합계 · 노무비 · +재료비 · 경비 · 비고(돈 머리 = 단가 열 · 다음 열 = 금액). +호표 줄 = 「제N호표」 · 호표 1 단위 값 = 금액 열 · 구성 줄 = 호표 칸 빔 · 품명 있음 · +끝 = 다음 호표. +갈래 = % · 호표(비고 일위N참조) · 단가산출(단산N참조) · 기계(단위 hr) · 별도계상(값 없음) · +자재(재료비만) · 기타 — 규격은 원문 그대로 둠. +검산 = 줄 금액 floor(단가 × 수량 × 10) / 10 · 합계 단가 = 셋 합 · +% 줄 = floor(바탕 × 율 / 100 × 10) / 10 · 호표 비목 = floor(줄 금액 합) · 총괄표 합계 — +어긋남은 글 목록(막지 않음). +""" + +from __future__ import annotations + +import io +import re +from decimal import ROUND_FLOOR, Decimal, InvalidOperation +from typing import Any + +SHEET = "일위대가" +SUMMARY = "일위대가총괄표" +COSTS = ("노무비", "재료비", "경비") +HEADS = ("호표", "품명", "규격", "수량", "단위", "비고") +NO_SHEET = "일위대가 시트 없음" +_HOPYO = re.compile(r"제\s*(\d+)\s*호표") +_REF = re.compile(r"^(단산|일위)\s*(\d+)\s*참조$") +_MACHINE_UNITS = ("hr", "시간", "h") + + +class ExcelError(ValueError): + """엉터리 파일 · 시트 없음 — 길이 400 으로 답함.""" + + +def _flat(value: Any) -> str: + return re.sub(r"\s+", "", str(value or "")) + + +def _text(value: Any) -> str: + if isinstance(value, float) and value == int(value): + value = int(value) + return str(value if value is not None else "").strip() + + +def _num(value: Any) -> Decimal | None: + if value is None or isinstance(value, bool) or value == "": + return None + try: + got = Decimal(str(value).replace(",", "").strip()) + except InvalidOperation: + return None + return got if got.is_finite() else None + + +def _floor(value: Decimal, places: int = 1) -> Decimal: + step = Decimal(1).scaleb(-places) + return value.quantize(step, rounding=ROUND_FLOOR) + + +def _out(value: Decimal | None) -> int | float | None: + if value is None: + return None + return int(value) if value == value.to_integral_value() else float(value) + + +# ── 파일 → 시트 표(줄마다 값 목록) ──────────────────────────────────────── +def _sheets(name: str, data: bytes) -> dict[str, list[list[Any]]]: + """시트 이름 → 줄 목록 — 일위대가 · 총괄표만 읽음.""" + wanted = (SHEET, SUMMARY) + if not data: + raise ExcelError("빈 파일") + try: + if data[:4] == b"PK\x03\x04" or name.lower().endswith(".xlsx"): + import openpyxl + + book = openpyxl.load_workbook(io.BytesIO(data), read_only=True, data_only=True) + out = { + ws.title.strip(): [list(row) for row in ws.iter_rows(values_only=True)] + for ws in book.worksheets + if ws.title.strip() in wanted + } + book.close() + return out + import xlrd + + book = xlrd.open_workbook(file_contents=data, logfile=io.StringIO()) + return { + sh.name.strip(): [sh.row_values(r) for r in range(sh.nrows)] + for sh in book.sheets() + if sh.name.strip() in wanted + } + except ExcelError: + raise + except Exception as error: # 라이브러리마다 다른 예외 — 깨진 파일 · 엑셀 아님 + raise ExcelError("엑셀 파일을 읽지 못함 — xls · xlsx 가 아니거나 깨진 파일") from error + + +def _columns(rows: list[list[Any]], money: tuple[str, ...]) -> tuple[int, dict[str, int]]: + """(자료 첫 줄, {머리: 열}) — 머리 글은 앞 10 줄 안 「호표」 · 「품명」 이 든 줄.""" + for at, row in enumerate(rows[:10]): + flat = [_flat(v) for v in row] + if "호표" not in flat or "품명" not in flat: + continue + cols = {head: flat.index(head) for head in (*HEADS, *money) if head in flat} + below = [_flat(v) for v in rows[at + 1]] if at + 1 < len(rows) else [] + start = at + 2 if "단가" in below or "금액" in below else at + 1 + return start, cols + raise ExcelError(f"{SHEET} 시트 머리 글(호표 · 품명)을 못 찾음") + + +def _cell(row: list[Any], col: int | None) -> Any: + return row[col] if col is not None and col < len(row) else None + + +def _money(row: list[Any], cols: dict[str, int], offset: int) -> dict[str, Decimal | None]: + """비목 넷(합계 = 계) — `offset` 0 = 단가 열 · 1 = 금액 열.""" + return { + ("계" if head == "합계" else head): _num(_cell(row, None if c is None else c + offset)) + for head in ("합계", *COSTS) + for c in [cols.get(head)] + } + + +# ── 갈래 · 검산 ───────────────────────────────────────────────────────── +def kind_of(line: dict[str, Any]) -> str: + """구성 줄 갈래 — 단가산출 · 자재 · 기계 · 호표 · % · 별도계상 · 기타.""" + ref = line.get("참조") or {} + unit = _flat(line.get("단위")).lower() + price = line["단가"] + if unit == "%": + return "%" + if ref.get("종류") == "일위": + return "호표" + if ref.get("종류") == "단산": + return "단가산출" + if unit in _MACHINE_UNITS: + return "기계" + if not any(price.get(k) for k in ("계", *COSTS)): + return "별도계상" + if price.get("재료비") and not price.get("노무비") and not price.get("경비"): + return "자재" + return "기타" + + +def _check_line(where: str, line: dict[str, Any]) -> list[str]: + qty, price, amount = line["수량"], line["단가"], line["금액"] + bad = [] + if line["갈래"] == "%": + base = price.get("계") + want = _floor(base * qty / 100) if base is not None and qty else None + got = sum((amount.get(k) or Decimal(0)) for k in COSTS) + if want is not None and got != want: + bad.append(f"{where} · % 금액 {got} ≠ {want}") + return bad + if qty is None: + return bad + for k in COSTS: + if price.get(k) is None: + continue + want = _floor(price[k] * qty) + if (amount.get(k) or Decimal(0)) != want: + bad.append(f"{where} · {k} 금액 {amount.get(k)} ≠ {want}") + parts = [price.get(k) for k in COSTS] + if price.get("계") is not None and any(p is not None for p in parts): + total = sum((p or Decimal(0)) for p in parts) + if total != price["계"]: + bad.append(f"{where} · 합계 단가 {price['계']} ≠ {total}") + return bad + + +def _check_hopyo(hopyo: dict[str, Any], lines: list[dict[str, Any]]) -> list[str]: + where = f"제{hopyo['번']}호표" + bad = [] + for line in lines: + bad += _check_line(f"{where} {line['차례'] + 1}째 줄 「{line['이름']}」", line) + if not lines: + return bad + for k in COSTS: + total = sum((line["금액"].get(k) or Decimal(0)) for line in lines) + want, got = _floor(total, 0), hopyo["금액"].get(k) or Decimal(0) + if got != want: + bad.append(f"{where} · {k} {got} ≠ 줄 합 {want}") + return bad + + +def _summary(rows: list[list[Any]] | None) -> dict[int, Decimal]: + """총괄표 {호표 번: 합계} — 머리 글이 없으면 빈 것.""" + if not rows: + return {} + try: + start, cols = _columns(rows, ("합계",)) + except ExcelError: + return {} + out = {} + for row in rows[start:]: + hit = _HOPYO.fullmatch(_flat(_cell(row, cols.get("호표")))) + total = _num(_cell(row, cols.get("합계"))) + if hit and total is not None: + out[int(hit.group(1))] = total + return out + + +# ── 읽기 ────────────────────────────────────────────────────────────── +def read_book(name: str, data: bytes) -> dict[str, Any]: + """엑셀 → `{시트, 호표: [...], 검산: {어긋남: [글 …], 총괄표: 맞춘 수}}` — 값은 JSON 수.""" + sheets = _sheets(name, data) + rows = sheets.get(SHEET) + if rows is None: + raise ExcelError(NO_SHEET) + start, cols = _columns(rows, ("합계", *COSTS)) + hopyos: list[dict[str, Any]] = [] + raw: list[list[dict[str, Any]]] = [] + for row in rows[start:]: + head = _flat(_cell(row, cols.get("호표"))) + name_ = _text(_cell(row, cols.get("품명"))) + hit = _HOPYO.fullmatch(head) + if hit: + hopyos.append( + { + "번": int(hit.group(1)), + "이름": name_, + "규격": _text(_cell(row, cols.get("규격"))), + "단위": _text(_cell(row, cols.get("단위"))), + "금액": _money(row, cols, 1), + } + ) + raw.append([]) + continue + if head or not name_ or not hopyos: + continue + note = _text(_cell(row, cols.get("비고"))) + ref = _REF.match(note.replace(" ", "")) + line = { + "차례": len(raw[-1]), + "이름": name_, + "규격": _text(_cell(row, cols.get("규격"))), + "수량": _num(_cell(row, cols.get("수량"))), + "단위": _text(_cell(row, cols.get("단위"))), + "단가": _money(row, cols, 0), + "금액": _money(row, cols, 1), + "비고": note, + "참조": {"종류": ref.group(1), "번": int(ref.group(2))} if ref else None, + } + line["갈래"] = kind_of(line) + raw[-1].append(line) + if not hopyos: + raise ExcelError(f"{SHEET} 시트에 호표(제N호표)가 없음") + wrong: list[str] = [] + totals = _summary(sheets.get(SUMMARY)) + for hopyo, lines in zip(hopyos, raw, strict=True): + wrong += _check_hopyo(hopyo, lines) + total = hopyo["금액"].get("계") + if hopyo["번"] in totals and total is not None and totals[hopyo["번"]] != total: + wrong.append(f"제{hopyo['번']}호표 · 총괄표 합계 {totals[hopyo['번']]} ≠ {total}") + out = [ + {**_json(hopyo), "줄": [_json(line) for line in lines]} + for hopyo, lines in zip(hopyos, raw, strict=True) + ] + return {"시트": SHEET, "호표": out, "검산": {"어긋남": wrong, "총괄표": len(totals)}} + + +def _json(value: Any) -> Any: + if isinstance(value, Decimal): + return _out(value) + if isinstance(value, dict): + return {k: _json(v) for k, v in value.items()} + return value diff --git a/M02_MasterTemplete/M02_Hopyo_Store.py b/M02_MasterTemplete/M02_Hopyo_Store.py new file mode 100644 index 000000000..c632a409d --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_Store.py @@ -0,0 +1,116 @@ +"""M02 일위대가 실무자료 저장(PLAN 324-1) — 층 폴더 `실무자료/<키>.json` · +맞춤 기억 `실무자료/맞춤.json`. + +파일 = 읽은 결과만(원본 엑셀 안 둠) `{키, 파일, 올린때, 올린이, 시트, 호표, 검산}` · 키 `HP-######` +(층마다 다음 번호) · 맞춤 기억 = `{줄: {줄 열쇠: {로직?|자재?|빼기?, 입력값, 환산, 확정}}}`. +층 종류(`layers.KINDS`) 밖 폴더 — 양식 목록 · 복사 · 프로젝트 씨앗에 안 섞임 · 권한은 길 몫. +""" + +from __future__ import annotations + +import json +import re +import threading +from datetime import datetime +from pathlib import Path +from typing import Any + +from common_util.common_util_json import atomic_write_json +from M02_MasterTemplete import M02_MasterTemplete_Store as store + +DIRNAME = "실무자료" +MEMORY = "맞춤" +_KEY = re.compile(r"^HP-(\d{6})$") +# ponytail: 프로세스 안 잠금 — 서버를 여럿 띄우면 파일 잠금으로 +_LOCK = threading.Lock() + + +def _dir(folder: str | Path) -> Path: + return Path(folder) / DIRNAME + + +def _path(folder: str | Path, key: str) -> Path: + if not _KEY.match(str(key)): + raise store.StoreError(404, f"없는 실무자료 「{key}」") + return _dir(folder) / f"{key}.json" + + +def _load(path: Path) -> Any: + try: + return json.loads(path.read_bytes()) + except (OSError, ValueError): + return None + + +def brief(doc: dict[str, Any]) -> dict[str, Any]: + """목록 한 줄 — `{키, 파일, 올린때, 올린이, 시트, 호표수, 어긋남}`.""" + keep = ("키", "파일", "올린때", "올린이", "시트") + return { + **{k: doc.get(k) for k in keep}, + "호표수": len(doc.get("호표") or []), + "어긋남": len((doc.get("검산") or {}).get("어긋남") or []), + } + + +def rows(folder: str | Path) -> list[dict[str, Any]]: + """층 실무자료 목록(키 차례).""" + out = [] + for path in sorted(_dir(folder).glob("HP-*.json")): + doc = _load(path) + if isinstance(doc, dict) and _KEY.match(path.stem): + out.append(brief(doc)) + return out + + +def read(folder: str | Path, key: str) -> dict[str, Any]: + doc = _load(_path(folder, key)) + if not isinstance(doc, dict): + raise store.StoreError(404, f"없는 실무자료 「{key}」") + return doc + + +def save(folder: str | Path, name: str, parsed: dict[str, Any], who: Any) -> dict[str, Any]: + """읽은 결과 → 새 키로 씀 → 목록 한 줄.""" + with _LOCK: + have = [ + int(m.group(1)) for p in _dir(folder).glob("HP-*.json") if (m := _KEY.match(p.stem)) + ] + key = f"HP-{max(have, default=0) + 1:06d}" + doc = { + "키": key, + "파일": name, + "올린때": datetime.now().isoformat(timespec="seconds"), + "올린이": who, + **parsed, + } + atomic_write_json(_path(folder, key), doc) + return brief(doc) + + +def delete(folder: str | Path, key: str) -> None: + path = _path(folder, key) + with _LOCK: + if not path.is_file(): + raise store.StoreError(404, f"없는 실무자료 「{key}」") + path.unlink() + + +# ── 맞춤 기억 ───────────────────────────────────────────────────────── +def memory(folder: str | Path) -> dict[str, dict[str, Any]]: + """층 맞춤 기억 `{줄 열쇠: 확정}` — 없으면 빈 것.""" + doc = _load(_dir(folder) / f"{MEMORY}.json") + got = doc.get("줄") if isinstance(doc, dict) else None + return got if isinstance(got, dict) else {} + + +def remember(folder: str | Path, entries: dict[str, dict[str, Any] | None]) -> dict[str, Any]: + """기억 더함 · 바꿈(값 None = 지움) → 새 기억 전부.""" + with _LOCK: + got = memory(folder) + for key, value in entries.items(): + if value is None: + got.pop(key, None) + else: + got[key] = value + atomic_write_json(_dir(folder) / f"{MEMORY}.json", {"줄": got}) + return got diff --git a/M02_MasterTemplete/M02_Labor_Router.py b/M02_MasterTemplete/M02_Labor_Router.py index 5ddb1792d..a6007ed72 100644 --- a/M02_MasterTemplete/M02_Labor_Router.py +++ b/M02_MasterTemplete/M02_Labor_Router.py @@ -3,19 +3,23 @@ 문서 읽기 · 쓰기는 양식 길(`GET/PUT /api/m02/layers/{층}/templates/labor/{열}`) 그대로 · 여기는 저장하지 않는 셈만 + 서고(단가승격 · 사전조합 · 50-3) 새로 · 복사(키는 서버가 줌 · 고침 · 지우기는 양식 길 그대로) + 개인 자재 목록 · 추가(148-2 · 개인 층만 · 품번은 서버) + -사전조합 나무 자유 폴더(158-1 · `M02_Precombo_Folders` · 읽기 = 층 읽기 · 고침 = 층 쓰기). +사전조합 나무 자유 폴더(158-1 · `M02_Precombo_Folders` · 읽기 = 층 읽기 · 고침 = 층 쓰기) + +일위대가 실무자료(324 · `M02_Hopyo_*` · 올리기 · 지우기 = 층 쓰기 · 목록 · 읽기 = 층 읽기). 층 권한 = `Router_Items._root`(`M02_Layer_Access` 한 벌). ⚠ 등록은 `main.py`(로그인만). """ from __future__ import annotations +from pathlib import Path from typing import Any, Literal -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from pydantic import BaseModel from common_util.common_util_auth import verify_session +from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel +from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store from M02_MasterTemplete import M02_Labor_Doc as labor from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_Layer_Access as access @@ -159,3 +163,61 @@ def new_material( raise HTTPException(status_code=400, detail="신규 자재는 개인 층에서만") folder = _root(session, layer, owner, write=True) return _call(folder, own.add, folder, body) + + +# ── 일위대가 실무자료(324) ───────────────────────────────────────────── +HOPYO_MAX_BYTES = 20 * 1024 * 1024 + + +def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]: + try: + parsed = hopyo_excel.read_book(name, data) + except hopyo_excel.ExcelError as error: + raise store.StoreError(400, str(error)) from error + return hopyo_store.save(store.root(), name, parsed, who) + + +@router.get("/layers/{layer}/hopyo") +def hopyo_list( + layer: Layer, owner: str | None = None, session: Session = Depends(verify_session) +) -> dict: + """실무자료 목록 `{줄: [{키, 파일, 올린때, 올린이, 시트, 호표수, 어긋남}]}`.""" + folder = _root(session, layer, owner) + return {"줄": hopyo_store.rows(folder)} + + +@router.post("/layers/{layer}/hopyo") +def hopyo_upload( + layer: Layer, + file: UploadFile = File(...), + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """엑셀(xls · xlsx) 올리기 — 「일위대가」 시트를 읽어 결과만 저장 · 20 MB · + 엉터리 · 시트 없음 400.""" + folder = _root(session, layer, owner, write=True) + data = file.file.read(HOPYO_MAX_BYTES + 1) + if len(data) > HOPYO_MAX_BYTES: + raise HTTPException(status_code=400, detail="파일이 너무 큼(20 MB 넘음)") + name = Path(file.filename or "실무자료").name[:120] + who = {"id": session.get("user_id"), "이름": session.get("name")} + return _call(folder, _upload, name, data, who) + + +@router.get("/layers/{layer}/hopyo/{key}") +def hopyo_read( + layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session) +) -> dict: + """실무자료 하나(호표 · 줄 · 검산).""" + folder = _root(session, layer, owner) + return _call(folder, hopyo_store.read, folder, key) + + +@router.delete("/layers/{layer}/hopyo/{key}") +def hopyo_delete( + layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session) +) -> dict: + """실무자료 지우기(만든 사전조합 · 맞춤 기억은 그대로).""" + folder = _root(session, layer, owner, write=True) + _call(folder, hopyo_store.delete, folder, key) + return {"지움": key} diff --git a/requirements.txt b/requirements.txt index 09d89ee34..befd6bc20 100644 --- a/requirements.txt +++ b/requirements.txt @@ -62,5 +62,6 @@ uvicorn==0.24.0 watchfiles==1.2.0 wheel==0.47.0 whitebox==2.3.6 +xlrd==2.0.2 pytest>=8 httpx<0.28 diff --git a/resources/tester/test_m02_hopyo_excel.py b/resources/tester/test_m02_hopyo_excel.py new file mode 100644 index 000000000..6e6b14fdf --- /dev/null +++ b/resources/tester/test_m02_hopyo_excel.py @@ -0,0 +1,209 @@ +"""일위대가 실무자료 읽기 · 저장 · 올리기 길(PLAN 324-1 · `M02_Hopyo_Excel` · `M02_Hopyo_Store`). + +견본 = `resources/knowledge/original/실무문서/2026_실무일위대가 모음/`(북부청 임도 · 사방) · +검산 어긋남 0 · 호표 값 · 시트 없음 400 · xlsx 길 · 역할 표 · 엉터리 · 20 MB · 지우기 · +양식 목록에 안 섞임 · +master_data 원본 그대로(sha256). 층은 임시 폴더. +""" + +import hashlib +import io +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from common_util.common_util_auth import verify_session +from config import config_system +from M02_MasterTemplete import M02_Hopyo_Excel as hx +from M02_MasterTemplete import M02_Hopyo_Store as hs +from M02_MasterTemplete import M02_Labor_Router as labor_router +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester.test_m02_structure import write_sample_table + +ROOT = Path(__file__).resolve().parents[2] +SAMPLES = ROOT / "resources" / "knowledge" / "original" / "실무문서" / "2026_실무일위대가 모음" +NORTH = SAMPLES / "2026년_북부지방산림청_임도사업_설계내역서(공통단가).xls" +SABANG = SAMPLES / "2026년_북부지방산림청_사방사업(계류+댐)_설계내역서.xls" +NO_SHEET = SAMPLES / "2016_상반_산림청_사방사업표준품셈(안).xls" +URL = "/api/m02/layers/{}/hopyo" +USER = {"user_id": 42, "company_id": 7, "role": "USER", "is_master": False, "name": "갑"} + + +def master_hash() -> dict[str, str]: + """master_data 원본(*.json · 일위대가조합.json) sha256 — 올리기 · 맞춤 · 만들기 뒤 그대로.""" + folder = ROOT / "resources" / "master_data" + return {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in folder.glob("*.json")} + + +@pytest.fixture(scope="module") +def north() -> dict: + return hx.read_book(NORTH.name, NORTH.read_bytes()) + + +def _one(book: dict, no: int) -> dict: + return next(h for h in book["호표"] if h["번"] == no) + + +# ── 읽기 ────────────────────────────────────────────────────────────── +def test_북부청_68_호표_검산_어긋남_0(north): + assert [h["번"] for h in north["호표"]] == list(range(1, 69)) + assert north["검산"] == {"어긋남": [], "총괄표": 67} # 제61호표 = 값 없음 + lines = [line for h in north["호표"] for line in h["줄"]] + assert len(lines) == 415 + kinds = {k: sum(1 for x in lines if x["갈래"] == k) for k in ("단가산출", "%", "호표", "기계")} + assert kinds == {"단가산출": 268, "%": 1, "호표": 1, "기계": 3} + + +def test_사방_27_호표_검산_어긋남_0(): + got = hx.read_book(SABANG.name, SABANG.read_bytes()) + assert len(got["호표"]) == 27 and got["검산"]["어긋남"] == [] + + +def test_호표_값과_줄(north): + one = _one(north, 1) + assert (one["이름"], one["규격"], one["단위"]) == ( + "배수관날개벽(유입구)", + "D=1,000mm,일반형", + "개소", + ) + assert one["금액"] == {"계": 1040052, "노무비": 777224, "재료비": 239284, "경비": 23544} + assert one["줄"][0]["갈래"] == "별도계상" and one["줄"][0]["규격"] == "별도계상" + three = _one(north, 3) + assert [(x["이름"], x["수량"], x["단위"], x["갈래"]) for x in three["줄"]] == [ + ("임도표지석(받침,글새김포함)", 1, "㎥", "자재"), + ("구조물터파기(인력100%)", 0.14, "㎥", "단가산출"), + ("구조물되메우기(인력100%)", 0.05, "㎥", "단가산출"), + ("잔토처리(인력100%)", 0.09, "㎥", "단가산출"), + ] + assert three["줄"][1]["참조"] == {"종류": "단산", "번": 93} + assert three["금액"]["계"] == 1415169 and three["금액"]["경비"] is None + assert _one(north, 45)["줄"][0]["참조"] == {"종류": "일위", "번": 46} + pct = [x for x in _one(north, 46)["줄"] if x["갈래"] == "%"] + assert [(x["규격"], x["수량"], x["단가"]["계"]) for x in pct] == [("재료비의", 3, 42390)] + empty = _one(north, 61) + assert empty["줄"] == [] and empty["금액"]["계"] is None + + +def test_일위대가_시트_없음(): + with pytest.raises(hx.ExcelError, match="일위대가 시트 없음"): + hx.read_book(NO_SHEET.name, NO_SHEET.read_bytes()) + with pytest.raises(hx.ExcelError, match="읽지 못함"): + hx.read_book("x.xls", b"not an excel file") + + +def _xlsx() -> bytes: + import openpyxl + + book = openpyxl.Workbook() + ws = book.active + ws.title = "일위대가" + ws.append([None, "일위대가"]) + ws.append([]) + ws.append([None, "호 표", "품 명", "규 격", "수 량", "단위", "합 계", None, "노무비", None]) + ws.append([None, None, None, None, None, None, "단 가", "금 액", "단 가", "금 액"]) + ws.append([None, "제 1 호표", "표지석 ", "A×B", None, "개소", None, 1000, None, 1000]) + ws.append([None, None, "터파기", "토사", 0.5, "㎥", 2000, 1000, 2000, 1000, "비고"]) + out = io.BytesIO() + book.save(out) + return out.getvalue() + + +def test_xlsx_길(): + got = hx.read_book("a.xlsx", _xlsx()) + one = got["호표"][0] + assert (one["번"], one["이름"], one["금액"]["계"]) == (1, "표지석", 1000) + assert one["줄"][0]["수량"] == 0.5 and got["검산"]["어긋남"] == [] + + +# ── 저장 · 길 ────────────────────────────────────────────────────────── +@pytest.fixture() +def client(tmp_path, monkeypatch): + system = tmp_path / "system" + system.mkdir() + write_sample_table(system) + monkeypatch.setattr(store, "FOLDER", system) + monkeypatch.setattr(layers, "SYSTEM_ROOT", system) + (tmp_path / "storage").mkdir() + monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(tmp_path / "storage")) + app = FastAPI() + app.include_router(labor_router.router) + who = dict(USER) + app.dependency_overrides[verify_session] = lambda: who + got = TestClient(app) + got.who, got.system = who, system + return got + + +def _post(client, layer="personal", data=None, name=NORTH.name, **params): + files = {"file": (name, data if data is not None else NORTH.read_bytes())} + return client.post(URL.format(layer), files=files, params=params) + + +def test_올리기_목록_읽기_지우기(client): + before = master_hash() + res = _post(client) + assert res.status_code == 200, res.text + row = res.json() + assert (row["키"], row["파일"], row["호표수"], row["어긋남"]) == ( + "HP-000001", + NORTH.name, + 68, + 0, + ) + assert row["올린이"] == {"id": 42, "이름": "갑"} + assert client.get(URL.format("personal")).json()["줄"] == [row] + doc = client.get(URL.format("personal") + "/HP-000001").json() + assert len(doc["호표"]) == 68 and doc["시트"] == "일위대가" + folder = layers.personal_dir(7, 42) + assert (folder / hs.DIRNAME / "HP-000001.json").is_file() + assert all(r["종류"] in layers.KINDS for r in layers.list_templates(folder)) # 양식에 안 섞임 + assert not any("실무자료" in str(p) for _, p in layers.layer_files("precombo")) + assert _post(client).json()["키"] == "HP-000002" + assert client.delete(URL.format("personal") + "/HP-000001").status_code == 200 + assert [r["키"] for r in client.get(URL.format("personal")).json()["줄"]] == ["HP-000002"] + assert client.get(URL.format("personal") + "/HP-000001").status_code == 404 + assert client.get(URL.format("personal") + "/../x").status_code == 404 + assert master_hash() == before + + +def test_엉터리_시트없음_큰_파일_400(client, monkeypatch): + bad = _post(client, data=b"garbage", name="a.xls") + assert bad.status_code == 400 and "읽지 못함" in bad.json()["detail"] + none = _post(client, data=NO_SHEET.read_bytes(), name=NO_SHEET.name) + assert none.status_code == 400 and none.json()["detail"] == "일위대가 시트 없음" + monkeypatch.setattr(labor_router, "HOPYO_MAX_BYTES", 1000) + big = _post(client) + assert big.status_code == 400 and "20 MB" in big.json()["detail"] + assert client.get(URL.format("personal")).json()["줄"] == [] + + +@pytest.mark.parametrize( + ("role", "layer", "owner", "want"), + [ + ({"role": "SYSTEM_ADMIN"}, "system", None, 200), + ({"role": "USER"}, "system", None, 403), + ({"role": "ADMIN"}, "company", None, 200), + ({"role": "USER"}, "company", None, 403), + ({"role": "USER"}, "personal", None, 200), + ({"role": "USER"}, "personal", "7/43", 403), + ({"role": "USER"}, "personal", "8/50", 403), + ], +) +def test_역할_표(client, role, layer, owner, want): + client.who.update(role) + params = {"owner": owner} if owner else {} + assert _post(client, layer, **params).status_code == want + if want == 200: + assert client.get(URL.format(layer)).json()["줄"][0]["호표수"] == 68 + + +def test_마스터_층은_마스터_폴더(client): + client.who.update(role="SYSTEM_ADMIN") + assert _post(client, "system").status_code == 200 + assert (client.system / hs.DIRNAME / "HP-000001.json").is_file() + client.who.update(role="USER") + assert client.get(URL.format("system")).json()["줄"][0]["키"] == "HP-000001" # 읽기 = 누구나 + assert client.delete(URL.format("system") + "/HP-000001").status_code == 403 From 5e33dda9581d136716208aeb6cdbc4e1c99a1422 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 14 Oct 2026 22:12:00 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat(=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80?= =?UTF-8?q?=EC=8B=A4=EB=AC=B4):=20324-2=20=EB=A7=9E=EC=B6=A4=20=E2=80=94?= =?UTF-8?q?=20=EC=97=91=EC=85=80=20=EA=B5=AC=EC=84=B1=20=EC=A4=84=20?= =?UTF-8?q?=E2=86=92=20=EB=8F=88=20=EB=A1=9C=EC=A7=81=20=C2=B7=20=EC=9A=B0?= =?UTF-8?q?=EB=A6=AC=20=EC=9E=90=EC=9E=AC=20=C2=B7=20=EA=B7=9C=EA=B2=A9=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=EA=B0=92=20=C2=B7=20=ED=99=98=EC=82=B0=20?= =?UTF-8?q?=C2=B7=20=EC=B8=B5=20=EA=B8=B0=EC=96=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M02_Hopyo_Match: 정규화(master_material.plain · plain_spec · plain_unit) · 차례 = 층 기억(같은 층 → 위 층) → 이름 같음 · 일위대가조합.json 메모 씨앗(읽기만) → 정리 이름 · 후보 5(두 글자 묶음 + 단위 + 산림품셈 · 규격 같은 자재 덤) · 후보는 확정 뒤에만 들어감 - 규격 → 입력값: 고르기 글(품명 괄호 포함 · 낱말 머리) · 기계 고르기이름 · D= H= H: B= T= L= L3= · 천 단위 쉼표 · 못 읽음 표시 - 환산: 같은 단위 1 · 단위표 · ㎡→㎥ T · m→㎡ B · m→㎥ B×T = 추정 · 두 단위 로직 · 그 밖 입력 - 길: GET /hopyo/{키}(호표마다 맞춤 n/m) · GET /hopyo/{키}/{번}(줄 맞춤 · 후보) · POST …/guess(미리 채움) · POST /hopyo-memory(확정 · 빼기 · 지움 = 층 쓰기) - 시험 test_m02_hopyo_match 15 통과(+ 324-1 15) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- M02_MasterTemplete/M02_Hopyo_Match.py | 488 +++++++++++++++++++++++ M02_MasterTemplete/M02_Labor_Router.py | 57 ++- resources/tester/test_m02_hopyo_match.py | 167 ++++++++ 3 files changed, 710 insertions(+), 2 deletions(-) create mode 100644 M02_MasterTemplete/M02_Hopyo_Match.py create mode 100644 resources/tester/test_m02_hopyo_match.py diff --git a/M02_MasterTemplete/M02_Hopyo_Match.py b/M02_MasterTemplete/M02_Hopyo_Match.py new file mode 100644 index 000000000..fc8136a1e --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_Match.py @@ -0,0 +1,488 @@ +"""M02 일위대가 실무자료 맞춤(PLAN 324-2) — 엑셀 구성 줄 → 우리 돈 로직 · 자재 · 입력값 · 환산. + +차례 = ① 층 기억(같은 층 → 위 층 · 확정분) ② 이름 같음(정규화) · +씨앗(일위대가조합.json 메모 · 읽기만) +③ 정리 이름 같음(괄호 · 기계 낱말) ④ 후보 5(글자 두 개 묶음 점수 + 단위 맞음 + 산림품셈 덤) — +③ ④ 는 사용자 확정(층 기억) 뒤에만 들어감. +줄 갈래: 단가산출 · 기계 · 기타 = 로직 / 자재 · 별도계상 = 자재(M01 자재품목) / 호표 = 그 호표 / +% = 안 들어감(로직 덧줄이 셈). +규격 → 입력값 = 고르기 글이 규격(품명 괄호 글 포함)에 있으면 그 값(기계 = 고르기이름) · +이름 붙은 수 D= H= H: B= T= L= L3= 는 입력 단위로 · 못 읽으면 빈 값(자동값) + 표시. +환산(우리 수량 = 엑셀 수량 × 배): 같은 단위 1 · 같은 성격 = 단위표 · ㎡→㎥ × T · m→㎡ × B · +m→㎥ × B × T = 「추정」(확정 뒤에만) · 두 단위 로직 = 단위 입력으로 고르고 1 · 그 밖 = 사용자 입력. +M01 은 읽기만(로직 별칭 안 씀). +""" + +from __future__ import annotations + +import json +import re +import unicodedata +from datetime import datetime +from decimal import Decimal +from functools import lru_cache +from pathlib import Path +from typing import Any + +from M01_MasterData import M01_MasterData_Store as m01 +from M01_MasterData import M01_MasterData_Store_Combo as combo_store +from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store +from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_MasterTemplete_Store as store + +mf = m01.cm.mf +mp = mf.mp +mm = mp.mm +SEED_NAME = "일위대가조합.json" +LOGIC_KINDS = ("단가산출", "기계", "기타") +MATERIAL_KINDS = ("자재", "별도계상") +READY = ("기억", "같음", "호표") +MACH_WORDS = ( + ("불도우저", "불도저"), + ("굴삭기", "굴착기"), + ("브레이카", "브레이커"), + ("로울러", "롤러"), + ("크로울러", "크롤러"), +) +_SEED_TAIL = re.compile(r"\s*\(실무 수는 옛 판 값\)\s*$") +_PAREN = re.compile(r"\([^()]*\)") +_THOUSANDS = re.compile(r"(=\d{1,3}),(\d{3})(?!\d)") +_NAMED = re.compile(r"(? str: + """기억 열쇠 = 품명 | 규격 | 단위(정규화).""" + return "|".join( + ( + mm.plain(line.get("이름")), + mm.plain_spec(line.get("규격")), + mm.plain_unit(line.get("단위")), + ) + ) + + +def tidy(name: Any) -> str: + """정리 이름 — 괄호 글 · 기계 옛 낱말 · 빈칸.""" + text = str(name or "") + for old, new in MACH_WORDS: + text = text.replace(old, new) + return mm.plain(_PAREN.sub("", text)) + + +def _spec_text(text: Any) -> str: + out = str(text or "") + for old, new in MACH_WORDS: + out = out.replace(old, new) + return mm.plain_spec(out).replace(",", "").replace("~", "∼") + + +def numbers(spec: Any) -> dict[str, tuple[Decimal, str | None]]: + """규격의 이름 붙은 수 `{글자: (값, 단위)}` — 천 단위 쉼표(`D=1,000mm`)를 먼저 붙임.""" + text = _THOUSANDS.sub(r"\1\2", unicodedata.normalize("NFKC", str(spec or ""))) + return { + letter.upper(): (Decimal(value), unit.lower() if unit else None) + for letter, value, unit in _NAMED.findall(text) + } + + +def _in_unit(value: Decimal, have: str | None, want: Any) -> Decimal: + """길이 값을 입력 단위로 — 한쪽이 모르는 단위면 그대로.""" + scale = mp.UNITS["길이"] + want = mm.plain_unit(want) if want else None + if have in scale and want in scale: + return value * Decimal(scale[have]) / Decimal(scale[want]) + return value + + +def _json(value: Decimal) -> int | float: + value = value.normalize() + return int(value) if value == value.to_integral_value() else float(value) + + +def _meters(found: tuple[Decimal, str | None] | None) -> Decimal | None: + if found is None: + return None + return _in_unit(found[0], found[1] or "m", "m") + + +# ── 규격 → 입력값 ───────────────────────────────────────────────────── +def spec_inputs(line: dict[str, Any], specs: list[dict[str, Any]]) -> tuple[dict, list[str]]: + """(입력값, 못 읽은 입력 이름) — 고르기 글 · 고르기이름 · 이름 붙은 수 · 두 단위.""" + raw = f"{line.get('규격') or ''} {' '.join(_PAREN.findall(str(line.get('이름'))))}" + pool = _spec_text(raw) + named = numbers(line.get("규격")) + tokens = [_spec_text(w) for w in re.split(r"[,\s/()]+", raw)] + unit = mm.plain_unit(line.get("단위")) + values, unread = {}, [] + for spec in specs: + name = str(spec.get("이름") or "") + picks = spec.get("고르기") or [] + labels = spec.get("고르기이름") or {} + words = [(w, letter) for letter, ws in LETTERS.items() for w in ws if w in name] + letter = max(words, key=lambda x: len(x[0]))[1] if words else None + got: Any = None + if letter in named: + value = _json(_in_unit(*named[letter], spec.get("단위"))) + got = value if not picks or value in picks else None + if got is None and picks: + hits = [] + for opt in picks: + label = str(labels.get(opt) or opt) + for text in (label, _PAREN.sub("", label)): + flat = _spec_text(text) + if len(flat) > 1 and (flat in pool or (unit and flat == f"{unit}당")): + hits.append((len(flat), opt)) + # 규격 낱말이 고르기 글 머리(「4회」 → 「4회사용시」) + hits += [(len(w), opt) for w in tokens if len(w) > 1 and flat.startswith(w)] + got = max(hits, key=lambda x: x[0])[1] if hits else None + if got is not None: + values[name] = got + elif spec.get("자동값") in (None, ""): + unread.append(name) + return values, unread + + +# ── 환산 ───────────────────────────────────────────────────────────── +def factor(have: Any, want: Any, spec: Any = "") -> dict[str, Any]: + """`{배, 갈래}` — 우리 수량 = 엑셀 수량 × 배 · 배 None = 사용자 입력.""" + units = [u.strip() for u in str(want or "").split("·") if u.strip()] + plain = mm.plain_unit(have) + if len(units) > 1: + ok = plain in {mm.plain_unit(u) for u in units} + return {"배": 1 if ok else None, "갈래": "두 단위" if ok else "입력"} + if plain == mm.plain_unit(want): + return {"배": 1, "갈래": "같음"} + a, b = mp.kind_of(have), mp.kind_of(want) + if a is not None and a == b: + scale = mp.UNITS[a] + ratio = Decimal(scale[plain]) / Decimal(scale[mm.plain_unit(want)]) + return {"배": _json(ratio), "갈래": "단위표"} + found = numbers(spec) + width, thick = _meters(found.get("B")), _meters(found.get("T")) + by = {("넓이", "부피"): thick, ("길이", "넓이"): width} + if (a, b) == ("길이", "부피") and width is not None and thick is not None: + by[(a, b)] = width * thick + got = by.get((a, b)) + if got: + return {"배": _json(got), "갈래": "추정"} + return {"배": None, "갈래": "입력"} + + +def fits(have: Any, want: Any) -> bool: + """단위가 맞음 — 같음 · 같은 성격 · 두 단위 중 하나(후보 점수 덤).""" + units = {mm.plain_unit(u) for u in str(want or "").split("·") if u.strip()} + return mm.plain_unit(have) in units or mp.same_kind(have, want) + + +# ── 우리 쪽 목록(한 번 읽어 둠) ───────────────────────────────────────── +def _grams(text: str) -> set[str]: + return {text[i : i + 2] for i in range(len(text) - 1)} or {text} + + +@lru_cache(maxsize=2) +def _targets(whole_id: int) -> dict[str, Any]: + """로직(돈) · 자재품목 — 정규 이름 · 정리 이름 · 묶음 · 단위.""" + files, whole = m01.loaded() + rows = [r for d in files.values() if d.get("그룹") == "로직" for r in d.get("줄") or []] + pills = combo_store.combo_pills({"담은로직": [{"로직": r.get("키")} for r in rows]})["물량"] + unit_of = {code: pill["단위"] for pill in pills for code in pill["로직"]} # 돈 로직만 + logics, mats = [], [] + for row in rows: + if str(row.get("키")) in unit_of: + name = str(row.get("이름") or "") + logics.append( + { + "로직": str(row["키"]), + "이름": name, + "단위": unit_of[str(row["키"])], + "산림": "산림" in str(row.get("구분") or ""), + "plain": mm.plain(name), + "tidy": tidy(name), + } + ) + for key, row in whole.index.get(mm.MARKET, {}).items(): + name = str(row.get("이름") or "") + mats.append( + { + "자재": str(key), + "이름": name, + "규격": str(row.get("규격") or ""), + "단위": str(row.get("단위") or ""), + "plain": mm.plain(name), + "spec": mm.plain_spec(row.get("규격")), + "tidy": tidy(re.sub(r"-(사급|관급)$", "", name.strip())), + } + ) + names: dict[str, dict[str, list]] = {"로직": {}, "자재": {}} + for pool, group in (("로직", logics), ("자재", mats)): + for one in group: + one["grams"] = _grams(one["tidy"]) + names[pool].setdefault(one["plain"], []).append(one) + return {"로직": logics, "자재": mats, "이름": names, "씨앗": _seeds()} + + +def targets() -> dict[str, Any]: + return _targets(id(m01.loaded()[1])) + + +def _seeds() -> dict[str, set[str]]: + """씨앗 `{정규 실무 글: {로직 키}}` — 일위대가조합.json 담은로직 메모(읽기만).""" + try: + doc = json.loads((Path(m01.FOLDER) / SEED_NAME).read_bytes()) + except (OSError, ValueError): + return {} + out: dict[str, set[str]] = {} + for row in doc.get("줄") or []: + for one in row.get("담은로직") or []: + memo = _SEED_TAIL.sub("", str(one.get("메모") or "")) + if memo and one.get("로직"): + out.setdefault(mm.plain(memo), set()).add(str(one["로직"])) + return out + + +def _public(one: dict[str, Any]) -> dict[str, Any]: + keep = ("로직", "자재", "이름", "규격", "단위") + return {k: one[k] for k in keep if k in one} + + +def _exact(line: dict[str, Any], pool: str, got: dict[str, Any]) -> list[dict[str, Any]]: + """② 이름 같음 · 씨앗 — 겹치면 여럿(부르는 쪽이 후보로 돌림).""" + name, spec = mm.plain(line.get("이름")), mm.plain_spec(line.get("규격")) + same = got["이름"][pool].get(name) or [] + if pool == "자재": + return [m for m in same if m["spec"] == spec] or (same if len(same) == 1 else []) + if same: + return same + codes = got["씨앗"].get(mm.plain(f"{line.get('이름')}{line.get('규격')}")) or got["씨앗"].get( + name + ) + return [x for x in got["로직"] if x["로직"] in (codes or ())] + + +def candidates( + line: dict[str, Any], pool: str, got: dict[str, Any] | None = None, limit: int = 5 +) -> list[dict[str, Any]]: + """③ 정리 이름 같음 먼저 · ④ 글자 두 개 묶음 점수 + 단위 맞음 + 산림품셈 덤 — 위 `limit`.""" + me = tidy(line.get("이름")) + grams, unit = _grams(me), line.get("단위") + text = mm.plain_spec(f"{line.get('이름')}{line.get('규격')}") + scored, seen = [], set() + for one in (got or targets())[pool]: + if not grams & one["grams"]: + continue + score = 2 * len(grams & one["grams"]) / (len(grams) + len(one["grams"])) + score += 1 if one["tidy"] == me else 0 + score += 0.15 if fits(unit, one["단위"]) else 0 + score += 0.05 if one.get("산림") else 0 + score += 0.5 if one.get("spec") and one["spec"] in text else 0 + scored.append((score, one)) + scored.sort(key=lambda x: -x[0]) + out = [] + for score, one in scored: + label = (one["이름"], one.get("규격"), one["단위"]) + if label in seen: + continue + seen.add(label) + out.append({**_public(one), "점수": round(score, 3)}) + if len(out) == limit: + break + return out + + +# ── 층 기억 ─────────────────────────────────────────────────────────── +def chain(folder: Path) -> list[Path]: + """기억을 볼 층 폴더 — 같은 층 → 위 층(개인 → 회사 → 마스터).""" + layer, owner = refs.where(folder) + out = [Path(folder)] + if layer == "personal": + out.append(refs.folder_of("company", str(owner).split("/")[0])) + if layer in ("personal", "company"): + out.append(Path(store.FOLDER)) + return out + + +def memories(folder: Path) -> list[dict[str, dict[str, Any]]]: + return [hopyo_store.memory(one) for one in chain(folder)] + + +def recall(line: dict[str, Any], memo: list[dict[str, dict[str, Any]]]) -> dict[str, Any] | None: + key = line_key(line) + return next((one[key] for one in memo if key in one), None) + + +# ── 줄 하나 맞춤 ─────────────────────────────────────────────────────── +def _logic_specs(code: str) -> list[dict[str, Any]]: + got = labor_inputs.logic_inputs([code])["로직"] + return got[0]["입력"] if got else [] + + +def _target_of(remembered: dict[str, Any], got: dict[str, Any]) -> dict[str, Any] | None: + if remembered.get("로직"): + return next((x for x in got["로직"] if x["로직"] == remembered["로직"]), None) + if remembered.get("자재"): + return next((x for x in got["자재"] if x["자재"] == remembered["자재"]), None) + return None + + +def match_line( + line: dict[str, Any], + memo: list[dict[str, dict[str, Any]]], + units: dict[int, str], + full: bool = True, + got: dict[str, Any] | None = None, +) -> dict[str, Any]: + """줄 → `{열쇠, 상태, 대상, 입력값, 못읽음, 환산, 들어감, 후보?}` — + `units` = {호표 번: 단위}(일위N 참조) · `full` = 입력값 · 후보까지(아니면 상태 · 환산만).""" + out: dict[str, Any] = {"열쇠": line_key(line), "대상": None, "입력값": {}, "못읽음": []} + kind = line.get("갈래") + pool = "자재" if kind in MATERIAL_KINDS else "로직" + remembered = recall(line, memo) + got = got or targets() + if kind == "%": + return {**out, "상태": "%", "환산": None, "들어감": False} + if remembered and remembered.get("빼기"): + return {**out, "상태": "빼기", "환산": None, "들어감": False} + if kind == "호표": + no = int((line.get("참조") or {}).get("번") or 0) + conv = factor(line.get("단위"), units.get(no), line.get("규격")) + target = {"호표": no, "단위": units.get(no)} + ready = no in units and conv["배"] is not None and conv["갈래"] != "추정" + return {**out, "상태": "호표", "대상": target, "환산": conv, "들어감": ready} + if remembered and (target := _target_of(remembered, got)): + conv = {"배": remembered.get("환산"), "갈래": "기억"} + state = {"상태": "기억", "대상": _public(target), "입력값": remembered.get("입력값") or {}} + return {**out, **state, "환산": conv, "들어감": conv["배"] is not None} + found = _exact(line, pool, got) + if len(found) == 1: + target = found[0] + conv = factor(line.get("단위"), target["단위"], line.get("규격")) + out.update(상태="같음", 대상=_public(target), 환산=conv) + out["들어감"] = conv["배"] is not None and conv["갈래"] != "추정" + if full and "로직" in target: + out["입력값"], out["못읽음"] = spec_inputs(line, _logic_specs(target["로직"])) + return out + out.update(상태="후보" if found else "없음", 환산=None, 들어감=False) + if not full: + return out + top = [{**_public(x), "점수": 9} for x in found] + same = [_public(x) for x in found] + rest = [ + c + for c in candidates(line, pool, got) + if {k: v for k, v in c.items() if k != "점수"} not in same + ] + out["후보"] = (top + rest)[:5] + return {**out, "상태": "후보" if out["후보"] else "없음"} + + +def guess(folder: Path, key: str, no: int, at: int, target: dict[str, Any]) -> dict[str, Any]: + """후보 · 고른 대상 하나 미리 채움 — `{대상, 입력값, 못읽음, 환산}`(확정 전 화면 보기).""" + line = _line(folder, key, no, at) + found = _target_of(target if isinstance(target, dict) else {}, targets()) + if found is None: + raise store.StoreError(400, "돈 로직 · 우리 자재가 아님") + target = _public(found) + conv = factor(line.get("단위"), target.get("단위"), line.get("규격")) + if not target.get("로직"): + return {"대상": target, "입력값": {}, "못읽음": [], "환산": conv} + values, unread = spec_inputs(line, _logic_specs(str(target["로직"]))) + return {"대상": target, "입력값": values, "못읽음": unread, "환산": conv} + + +def _line(folder: Path, key: str, no: int, at: int) -> dict[str, Any]: + doc = hopyo_store.read(folder, key) + hopyo = next((h for h in doc.get("호표") or [] if int(h["번"]) == int(no)), None) + lines = (hopyo or {}).get("줄") or [] + if not 0 <= int(at) < len(lines): + raise store.StoreError(404, f"없는 줄 「제{no}호표 {at}」") + return lines[int(at)] + + +def match_hopyo( + hopyo: dict[str, Any], + memo: list[dict[str, dict[str, Any]]], + units: dict[int, str], + full: bool = True, + got: dict[str, Any] | None = None, +) -> dict[str, Any]: + """호표 하나 — 줄마다 맞춤 · `맞춤` = [들어감 수, 줄 수].""" + got = got or targets() + lines = [ + {**one, "맞춤": match_line(one, memo, units, full, got)} for one in hopyo.get("줄") or [] + ] + ready = sum(1 for one in lines if one["맞춤"]["들어감"]) + return {**hopyo, "줄": lines, "맞춤": [ready, len(lines)]} + + +def units_of(doc: dict[str, Any]) -> dict[int, str]: + return {int(h["번"]): str(h.get("단위") or "") for h in doc.get("호표") or []} + + +def view(folder: Path, key: str, no: int | None = None) -> dict[str, Any]: + """실무자료 보기 — `no` 없으면 호표마다 맞춤 수만 · 있으면 그 호표 줄마다 맞춤 · 후보.""" + doc = hopyo_store.read(folder, key) + memo, units = memories(folder), units_of(doc) + if no is None: + got = targets() + hopyos = [match_hopyo(h, memo, units, False, got) for h in doc.get("호표") or []] + brief = [{k: h[k] for k in ("번", "이름", "규격", "단위", "금액", "맞춤")} for h in hopyos] + ready = [sum(h["맞춤"][0] for h in hopyos), sum(h["맞춤"][1] for h in hopyos)] + return {**hopyo_store.brief(doc), "검산": doc.get("검산"), "호표": brief, "맞춤": ready} + hopyo = next((h for h in doc.get("호표") or [] if int(h["번"]) == int(no)), None) + if hopyo is None: + raise store.StoreError(404, f"없는 호표 「제{no}호표」") + return {**hopyo_store.brief(doc), "호표": match_hopyo(hopyo, memo, units)} + + +# ── 확정 저장 ───────────────────────────────────────────────────────── +def confirm(folder: Path, entries: dict[str, Any]) -> dict[str, Any]: + """확정 · 빼기 · 지움 → 층 기억 — `{열쇠: {로직|자재|빼기, 입력값, 환산} | None}`. + 로직 = 돈 로직 · 자재 = M01 자재품목 · 환산 = 0 넘는 수(빼기는 안 봄).""" + if not isinstance(entries, dict) or not entries: + raise store.StoreError(400, "확정할 줄이 없음") + got = targets() + logics = {x["로직"] for x in got["로직"]} + mats = {x["자재"] for x in got["자재"]} + stamp = datetime.now().isoformat(timespec="seconds") + clean: dict[str, Any] = {} + for key, raw in entries.items(): + if raw is None: + clean[str(key)] = None + continue + if not isinstance(raw, dict) or str(key).count("|") != 2: + raise store.StoreError(400, f"확정 꼴이 틀림 「{key}」") + if raw.get("빼기") is True: + clean[str(key)] = {"빼기": True, "확정": stamp} + continue + code, mat = str(raw.get("로직") or ""), str(raw.get("자재") or "") + if (code and code not in logics) or (mat and mat not in mats) or bool(code) == bool(mat): + raise store.StoreError(400, f"돈 로직 · 우리 자재 하나만 「{key}」") + times = raw.get("환산") + number = None if isinstance(times, bool) else _number(times) + if number is None or number <= 0: + raise store.StoreError(400, f"환산은 0 넘는 수 「{key}」") + values = raw.get("입력값") or {} + if not isinstance(values, dict): + raise store.StoreError(400, f"입력값이 묶음 아님 「{key}」") + head = {"로직": code} if code else {"자재": mat} + clean[str(key)] = {**head, "입력값": values, "환산": _json(number), "확정": stamp} + return {"기억": hopyo_store.remember(folder, clean)} + + +def _number(raw: Any) -> Decimal | None: + try: + got = Decimal(str(raw).replace(",", "").strip()) + except ArithmeticError: + return None + return got if got.is_finite() else None diff --git a/M02_MasterTemplete/M02_Labor_Router.py b/M02_MasterTemplete/M02_Labor_Router.py index a6007ed72..8d89ade41 100644 --- a/M02_MasterTemplete/M02_Labor_Router.py +++ b/M02_MasterTemplete/M02_Labor_Router.py @@ -19,6 +19,7 @@ from pydantic import BaseModel from common_util.common_util_auth import verify_session from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel +from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store from M02_MasterTemplete import M02_Labor_Doc as labor from M02_MasterTemplete import M02_Labor_Ref as refs @@ -208,9 +209,61 @@ def hopyo_upload( def hopyo_read( layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session) ) -> dict: - """실무자료 하나(호표 · 줄 · 검산).""" + """실무자료 하나 — 검산 · 호표마다 `맞춤` [들어감, 줄 수] · 파일 `맞춤` 합.""" folder = _root(session, layer, owner) - return _call(folder, hopyo_store.read, folder, key) + return _call(folder, hopyo_match.view, folder, key) + + +@router.get("/layers/{layer}/hopyo/{key}/{no}") +def hopyo_one( + layer: Layer, + key: str, + no: int, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """호표 하나 — 줄마다 맞춤(상태 · 대상 · 입력값 · 환산 · 들어감 · 후보 5).""" + folder = _root(session, layer, owner) + return _call(folder, hopyo_match.view, folder, key, no) + + +class GuessBody(BaseModel): + """`차례` = 호표 안 줄(0부터) · `대상` = {로직} | {자재}.""" + + 차례: int + 대상: dict[str, Any] + + +@router.post("/layers/{layer}/hopyo/{key}/{no}/guess") +def hopyo_guess( + layer: Layer, + key: str, + no: int, + body: GuessBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """고른 대상 미리 채움(저장 안 함) — 규격 → 입력값 · 환산.""" + folder = _root(session, layer, owner) + return _call(folder, hopyo_match.guess, folder, key, no, body.차례, body.대상) + + +class MemoryBody(BaseModel): + """`줄` = {줄 열쇠: {로직|자재, 입력값, 환산} | {빼기: true} | null(지움)}.""" + + 줄: dict[str, Any] + + +@router.post("/layers/{layer}/hopyo-memory") +def hopyo_memory( + layer: Layer, + body: MemoryBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """맞춤 확정 · 빼기 → 층 기억(층 쓰기) — 다음부터 같은 줄은 자동.""" + folder = _root(session, layer, owner, write=True) + return _call(folder, hopyo_match.confirm, folder, body.줄) @router.delete("/layers/{layer}/hopyo/{key}") diff --git a/resources/tester/test_m02_hopyo_match.py b/resources/tester/test_m02_hopyo_match.py new file mode 100644 index 000000000..2f1179e0c --- /dev/null +++ b/resources/tester/test_m02_hopyo_match.py @@ -0,0 +1,167 @@ +"""일위대가 실무자료 맞춤(PLAN 324-2 · `M02_Hopyo_Match`) — 규격 → 입력값 · 단위 · 환산 · +맞춤 차례 · 기억 우선 · 길(보기 · 미리 채움 · 확정) · master_data 원본 그대로. 층은 임시 폴더. +""" + +import pytest + +from M02_MasterTemplete import M02_Hopyo_Match as hm +from M02_MasterTemplete import M02_Template_Layers as layers + +# 올리기 길 시험의 `client` 마당을 같이 씀 +# ruff: noqa: F811 +from resources.tester.test_m02_hopyo_excel import URL, _post, client, master_hash # noqa: F401 + +mm = hm.mm + + +# ── 규격 · 단위 · 환산 ───────────────────────────────────────────────── +def test_규격_이름_붙은_수(): + assert hm.numbers("D=1,000mm,일반형") == {"D": (1000, "mm")} + assert hm.numbers("H:1.2m") == {"H": (hm.Decimal("1.2"), "m")} + assert {k: float(v) for k, (v, _) in hm.numbers("B=2.3,H=0.4,야면석").items()} == { + "B": 2.3, + "H": 0.4, + } + assert hm.numbers("L3=35cm적용,장비사용") == {"L3": (35, "cm")} + assert hm.numbers("Φ40×200") == {} and mm.plain_spec("Φ40×200") == "ø40x200" + + +def test_단위_정규화(): + got = [mm.plain_unit(u) for u in ("㎥", "M", "Ton", "TON", "EA", "㎡")] + assert got == ["m3", "m", "톤", "톤", "개", "m2"] + + +@pytest.mark.parametrize( + ("have", "want", "spec", "times", "kind"), + [ + ("㎥", "㎥", "", 1, "같음"), + ("M", "m", "", 1, "같음"), + ("kg", "ton", "", 0.001, "단위표"), + ("㎡", "㎥", "T=150mm", 0.15, "추정"), + ("M", "㎡", "B=2.3,H=0.4", 2.3, "추정"), + ("M", "㎥", "B=2.3,T=0.2", 0.46, "추정"), + ("개소", "㎥", "", None, "입력"), + ("㎥", "㎡ · ㎥", "", 1, "두 단위"), + ("M", "㎡ · ㎥", "", None, "입력"), + ], +) +def test_환산(have, want, spec, times, kind): + assert hm.factor(have, want, spec) == {"배": times, "갈래": kind} + + +SPECS = [ + {"이름": "뒷길이", "단위": "㎝", "고르기": [25, 35, 45], "자동값": None}, + {"이름": "관경", "단위": "mm", "자동값": None}, + {"이름": "설치높이", "단위": "m", "자동값": None}, + { + "이름": "기계", + "고르기": ["0201-0020", "0201-0060"], + "고르기이름": { + "0201-0020": "굴착기(무한궤도) 0.2㎥", + "0201-0060": "굴착기(무한궤도) 0.6㎥", + }, + "자동값": None, + }, + {"이름": "횟수", "고르기": ["3회사용시", "4회사용시"], "자동값": None}, + {"이름": "단위당", "고르기": ["㎡당", "㎥당"], "자동값": None}, + {"이름": "지역", "고르기": ["전국평균", "서울"], "자동값": "전국평균"}, + {"이름": "보정작업", "고르기": ["해당 없음", "전석섞인토사"], "자동값": None}, +] + + +def test_규격에서_입력값(): + line = {"이름": "찰붙임(4회)", "규격": "L3=35cm적용,굴삭기0.6㎥,D=1,000mm,H:1.2m", "단위": "㎥"} + values, unread = hm.spec_inputs(line, SPECS) + assert values == { + "뒷길이": 35, + "관경": 1000, + "설치높이": 1.2, + "기계": "0201-0060", + "횟수": "4회사용시", + "단위당": "㎥당", + } + assert unread == ["보정작업"] # 지역 = 자동값 · 못 읽은 것만 표시 + other = {"이름": "찰붙임", "규격": "L3=40cm", "단위": "㎡"} + values, unread = hm.spec_inputs(other, SPECS[:1]) + assert values == {} and unread == ["뒷길이"] # 고르기 밖 수 = 빈 값 + 표시 + + +# ── 맞춤 차례 ──────────────────────────────────────────────────────── +def _hopyo(client, no, layer="personal"): + res = client.get(f"{URL.format(layer)}/HP-000001/{no}") + assert res.status_code == 200, res.text + return {one["이름"]: one["맞춤"] for one in res.json()["호표"]["줄"]} + + +def test_이름_같음_후보_호표_퍼센트(client): + before = master_hash() + assert _post(client).status_code == 200 + first = _hopyo(client, 1) + plywood = first["합판거푸집"] + assert plywood["상태"] == "같음" and plywood["대상"]["로직"] == "GF000160" + assert plywood["입력값"] == {"횟수": "4회사용시"} and plywood["들어감"] is True + remicon = first["레미콘(25-21-120)"] + assert remicon["상태"] == "후보" and remicon["들어감"] is False + assert all(c["규격"] == "25-21-120" for c in remicon["후보"][:3]) # 규격 같은 자재 먼저 + dig = _hopyo(client, 3)["구조물터파기(인력100%)"] + assert dig["상태"] == "후보" and len(dig["후보"]) == 5 + assert dig["후보"][0]["이름"].startswith("구조물터파기") + ref = _hopyo(client, 45)["고화매트재료비"] + assert ref["상태"] == "호표" and ref["대상"] == {"호표": 46, "단위": "㎡"} and ref["들어감"] + pct = _hopyo(client, 46)["잡재료비"] + assert pct["상태"] == "%" and pct["들어감"] is False + whole = client.get(f"{URL.format('personal')}/HP-000001").json() + assert whole["맞춤"][1] == 415 and 0 < whole["맞춤"][0] < 415 + assert next(h for h in whole["호표"] if h["번"] == 61)["맞춤"] == [0, 0] + assert master_hash() == before + + +def test_미리_채움_확정_기억_우선(client): + assert _post(client).status_code == 200 + url = f"{URL.format('personal')}/HP-000001/3/guess" + got = client.post(url, json={"차례": 1, "대상": {"로직": "GF000101"}}).json() + assert got["환산"] == {"배": 1, "갈래": "같음"} and got["대상"]["단위"] == "㎥" + assert client.post(url, json={"차례": 1, "대상": {"로직": "XX"}}).status_code == 400 + key = _hopyo(client, 3)["구조물터파기(인력100%)"]["열쇠"] + assert key == "구조물터파기(인력100%)|토사|m3" + body = {"줄": {key: {"로직": "GF000101", "입력값": {"지역": "서울"}, "환산": 1}}} + assert client.post("/api/m02/layers/personal/hopyo-memory", json=body).status_code == 200 + dig = _hopyo(client, 3)["구조물터파기(인력100%)"] + assert (dig["상태"], dig["대상"]["로직"], dig["입력값"], dig["들어감"]) == ( + "기억", + "GF000101", + {"지역": "서울"}, + True, + ) + # 이름 같음보다 기억이 앞섬 · 빼기 + plywood = _hopyo(client, 1)["합판거푸집"]["열쇠"] + out = {"줄": {plywood: {"빼기": True}}} + assert client.post("/api/m02/layers/personal/hopyo-memory", json=out).status_code == 200 + assert _hopyo(client, 1)["합판거푸집"]["상태"] == "빼기" + clear = {"줄": {plywood: None}} + assert client.post("/api/m02/layers/personal/hopyo-memory", json=clear).status_code == 200 + assert _hopyo(client, 1)["합판거푸집"]["상태"] == "같음" + + +def test_확정_검사와_위_층_기억(client): + bad = [ + {"로직": "GC000422", "환산": 1}, # 돈 아닌 로직 + {"로직": "GF000101", "환산": 0}, + {"로직": "GF000101", "자재": "MT004258", "환산": 1}, + {"환산": 1}, + ] + for one in bad: + res = client.post("/api/m02/layers/personal/hopyo-memory", json={"줄": {"a|b|c": one}}) + assert res.status_code == 400, one + assert client.post("/api/m02/layers/system/hopyo-memory", json={"줄": {}}).status_code == 403 + client.who.update(role="SYSTEM_ADMIN") + key = "구조물터파기(인력100%)|토사|m3" + body = {"줄": {key: {"로직": "GF000103", "환산": 1}}} + assert client.post("/api/m02/layers/system/hopyo-memory", json=body).status_code == 200 + client.who.update(role="USER") + assert _post(client).status_code == 200 + assert _hopyo(client, 3)["구조물터파기(인력100%)"]["대상"]["로직"] == "GF000103" # 위 층 + mine = {"줄": {key: {"로직": "GF000101", "환산": 1}}} + assert client.post("/api/m02/layers/personal/hopyo-memory", json=mine).status_code == 200 + assert _hopyo(client, 3)["구조물터파기(인력100%)"]["대상"]["로직"] == "GF000101" # 같은 층 먼저 + assert hm.chain(layers.personal_dir(7, 42))[1] == layers.company_dir(7) From 736aa405cf61459309fcc87127fd1a6a5211be73 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 14 Oct 2026 22:15:59 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat(=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80?= =?UTF-8?q?=EC=8B=A4=EB=AC=B4):=20324-3=20=EC=8B=A4=EB=AC=B4=20=ED=98=B8?= =?UTF-8?q?=ED=91=9C=20=E2=86=92=20=EC=9A=B0=EB=A6=AC=20=EC=9D=BC=EC=9C=84?= =?UTF-8?q?=EB=8C=80=EA=B0=80=20=EC=82=AC=EC=A0=84=EC=A1=B0=ED=95=A9=20?= =?UTF-8?q?=EB=A7=8C=EB=93=A4=EA=B8=B0=20=C2=B7=20=EC=82=AC=EC=A0=84?= =?UTF-8?q?=EC=A1=B0=ED=95=A9=20=EC=A4=84=20=EB=B3=84=EC=B9=AD=20=EC=82=B4?= =?UTF-8?q?=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M02_Hopyo_Build: 호표 = 사전조합 하나(이름 = 품명 + 규격 · 엑셀 단위 · 구분 실무자료 · 상세구분 파일 · 본뜬 실무자료 표시) · 로직 줄 {별칭 = 엑셀 품명 40 자 · 메모 「실무 0.14㎥ × 1 · 규격」 · 입력값 · 수량 손 = 엑셀 × 환산} · 자재 줄(값 없음 = 별도계상 · 아니면 직접계산 · 같은 자재 수량 더함) · 같은 로직 둘째 = 한 줄 사전조합으로 감쌈 · 일위N = 먼저 만들어 참조 · 순환 = 빠진 줄 · 같은 층 · 파일 · 호표 = 다시 씀 · 새로 = 그 키에 덮음 · 빠진 줄 + 엑셀 금액 · 만든 값 ↔ 엑셀 단가 - M02_Labor_Ref._combo_lines: 별칭 있으면 남김(한 줄) - 길 POST /layers/{층}/hopyo-build(이 층 쓰기 · 파일 층 읽기) - 시험 test_m02_hopyo_build 5 통과 · 곁 시험 84 통과 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- M02_MasterTemplete/M02_Hopyo_Build.py | 204 +++++++++++++++++++++++ M02_MasterTemplete/M02_Labor_Ref.py | 1 + M02_MasterTemplete/M02_Labor_Router.py | 25 +++ resources/tester/test_m02_hopyo_build.py | 152 +++++++++++++++++ 4 files changed, 382 insertions(+) create mode 100644 M02_MasterTemplete/M02_Hopyo_Build.py create mode 100644 resources/tester/test_m02_hopyo_build.py diff --git a/M02_MasterTemplete/M02_Hopyo_Build.py b/M02_MasterTemplete/M02_Hopyo_Build.py new file mode 100644 index 000000000..c6997996a --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_Build.py @@ -0,0 +1,204 @@ +"""M02 일위대가 실무자료 → 우리 일위대가 사전조합(PLAN 324-3). + +호표 하나 = 사전조합 하나 — 이름 = 품명 + 규격 · 단위 = 엑셀 단위 · 구분 「실무자료」 · +상세구분 = 파일 · 본뜬 = {층 「실무자료」, 키 「HP-###### 제N호표」, 파일} · +만드는 층 = 부르는 층(일위대가 문서 층). 들어가는 줄(`M02_Hopyo_Match` 의 `들어감`)만: + 로직 = {로직, 별칭 = 엑셀 품명 40 자, 메모 = 「실무 0.14㎥ × 1 · 규격」, 입력값, + 수량 {손 = 엑셀 수량 × 환산}} · 같은 로직 둘째부터 = 그 줄 하나짜리 사전조합으로 감싸 참조 + (같은 로직 두 번 금지) · 자재 = {코드, 수량 {손}, 별도계상 = 엑셀 값 없음 「별도계상」 · + 아니면 「직접계산」}(같은 자재 = 수량 더함) · 일위N = 그 호표 사전조합을 먼저 만들어 참조 + (순환 = 빠진 줄). +같은 층 · 같은 파일 · 같은 호표 = 있던 것을 다시 씀 · `새로` = 그 키에 다시 만들어 덮음(감싼 것도). +빠진 줄(% · 못 맞춤 · 빼기 · 환산 입력 · 추정) = 엑셀 금액과 함께 보고 · 만든 값 ↔ 엑셀 호표 단가. +엔진 · 일위대가 계산은 그대로(`M02_Labor_Ref.Expand`). +""" + +from __future__ import annotations + +import json +from decimal import Decimal +from pathlib import Path +from typing import Any + +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Hopyo_Match as match +from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_MasterTemplete_Store as store + +ORIGIN = "실무자료" +WHY = { + "%": "% 줄 — 우리 로직 덧줄이 셈", + "빼기": "뺌(확정)", + "후보": "못 맞춤 — 후보 확정 필요", + "없음": "못 맞춤", +} + + +def mark(key: str, no: int, at: int | None = None) -> str: + """본뜬 키 — 호표 「HP-000001 제3호표」 · 감싼 줄 「… 2째 줄」.""" + head = f"{key} 제{no}호표" + return head if at is None else f"{head} {at + 1}째 줄" + + +def _found(origin: str) -> tuple[str, str] | None: + """지금 층 사전조합 가운데 본뜬 = 이 실무 표시 → (키, 판).""" + for path in sorted((store.root() / "precombo").glob("SC-*.json")): + try: + doc = json.loads(path.read_bytes()) + except (OSError, ValueError): + continue + got = doc.get("본뜬") if isinstance(doc, dict) else None + if isinstance(got, dict) and got.get("층") == ORIGIN and got.get("키") == origin: + return path.stem, store.version_of(path.read_bytes()) + return None + + +def _save(doc: dict[str, Any], fresh: bool) -> tuple[dict[str, Any], bool]: + """(참조, 다시씀) — 있으면 그대로(`fresh` 면 그 키에 덮음) · 없으면 새 키.""" + at = refs.here() + have = _found(doc["본뜬"]["키"]) + if have and not fresh: + return {"층": at[0], "주인": at[1], "키": have[0]}, True + if have: + store.write("precombo", have[0], have[1], doc) + key = have[0] + else: + key = refs.create("precombo", doc)["이름"] + return {"층": at[0], "주인": at[1], "키": key}, bool(have) + + +def _num(value: Decimal) -> int | float: + value = value.normalize() + return int(value) if value == value.to_integral_value() else float(value) + + +def _memo(line: dict[str, Any], times: Any) -> str: + qty = line.get("수량") + spec = str(line.get("규격") or "").strip() + head = f"실무 {qty if qty is not None else ''}{line.get('단위') or ''} × {times}" + return f"{head} · {spec}" if spec else head + + +def _missing(line: dict[str, Any], why: str) -> dict[str, Any]: + keep = ("차례", "이름", "규격", "수량", "단위") + return { + **{k: line.get(k) for k in keep}, + "금액": (line.get("금액") or {}).get("계"), + "까닭": why, + } + + +class Builder: + """한 번 만들기 — 파일 · 층 기억 · 우리 쪽 목록을 한 번 읽고 호표마다 만든 참조를 기억.""" + + def __init__(self, source: Path, folder: Path, key: str, fresh: bool) -> None: + self.doc = hopyo_store.read(source, key) + self.key, self.fresh = key, fresh + self.memo, self.units = match.memories(folder), match.units_of(self.doc) + self.got = match.targets() + self.made: dict[int, dict[str, Any]] = {} + self.missing: dict[int, list[dict[str, Any]]] = {} + + def hopyo(self, no: int) -> dict[str, Any]: + found = next((h for h in self.doc.get("호표") or [] if int(h["번"]) == int(no)), None) + if found is None: + raise store.StoreError(404, f"없는 호표 「제{no}호표」") + return found + + def build(self, no: int, trail: tuple[int, ...] = ()) -> dict[str, Any]: + """호표 → 사전조합 참조 — 일위N 은 먼저(순환은 빠진 줄).""" + if no in self.made: + return self.made[no] + hopyo = match.match_hopyo(self.hopyo(no), self.memo, self.units, True, self.got) + lines, mats, missing = [], {}, [] + used: set[str] = set() + for line in hopyo["줄"]: + got = line["맞춤"] + if not got["들어감"]: + conv = got.get("환산") or {} + why = WHY.get(got["상태"]) or ( + "추정 환산 — 확정 필요" if conv.get("갈래") == "추정" else "환산 입력 필요" + ) + missing.append(_missing(line, why)) + continue + times = got["환산"]["배"] + qty = Decimal(str(line.get("수량") or 0)) * Decimal(str(times)) + target = got["대상"] + alias = str(line.get("이름") or "")[: labor.ALIAS_MAX].strip() or None + memo = _memo(line, times) + if "자재" in target: + code = target["자재"] + apart = "별도계상" if line.get("갈래") == "별도계상" else "직접계산" + have = mats.get(code) + hand = qty + Decimal(str(have["수량"]["손"])) if have else qty + mats[code] = {"코드": code, "수량": {"손": _num(hand)}, "별도계상": apart} + continue + if "호표" in target: + child = int(target["호표"]) + if child in (*trail, no): + missing.append(_missing(line, "순환 — 호표가 서로 담음")) + continue + ref = self.build(child, (*trail, no)) + lines.append({"참조": ref, "별칭": alias, "메모": memo, "수량": {"손": _num(qty)}}) + continue + one = {"로직": target["로직"], "입력값": got.get("입력값") or {}, "메모": memo} + if target["로직"] not in used: + used.add(target["로직"]) + lines.append({**one, "별칭": alias, "수량": {"손": _num(qty)}}) + continue + wrap = { # 같은 로직 둘째 — 한 줄 사전조합으로 감쌈 + "이름": f"{hopyo['이름']} — {line.get('이름')}", + "단위": target.get("단위"), + "구분": ORIGIN, + "상세구분": self.doc.get("파일") or "", + "담은로직": [{**one, "별칭": alias, "수량": {"손": 1}}], + "자재": [], + "본뜬": self._origin(mark(self.key, no, line["차례"])), + } + ref, _ = _save(wrap, self.fresh) + lines.append({"참조": ref, "별칭": alias, "메모": memo, "수량": {"손": _num(qty)}}) + name = " ".join(x for x in (hopyo["이름"], hopyo.get("규격")) if x) + doc = { + "이름": name, + "단위": hopyo.get("단위") or None, + "구분": ORIGIN, + "상세구분": self.doc.get("파일") or "", + "담은로직": lines, + "자재": list(mats.values()), + "본뜬": self._origin(mark(self.key, no)), + } + ref, again = _save(doc, self.fresh) + self.made[no] = {**ref, "다시씀": again} + self.missing[no] = missing + return self.made[no] + + def _origin(self, origin: str) -> dict[str, Any]: + return {"층": ORIGIN, "주인": None, "키": origin, "파일": self.doc.get("파일")} + + +def compare(ref: dict[str, Any], hopyo: dict[str, Any]) -> dict[str, Any]: + """만든 사전조합 1 단위 값 ↔ 엑셀 호표 단가.""" + got = refs.Expand(m01.loaded()[1])({k: ref[k] for k in ("층", "주인", "키")}) + base = got.get("기준") or {} + ours = float(base["계"]) if "계" in base else None + excel = (hopyo.get("금액") or {}).get("계") + gap = round(ours - excel, 1) if ours is not None and excel is not None else None + return {"엑셀": excel, "우리": ours, "차이": gap, "까닭": got.get("까닭")} + + +def build(folder: Path, source: Path, key: str, no: int, fresh: bool = False) -> dict[str, Any]: + """실무 호표(`source` 층 파일) → 지금 층(`folder`) 사전조합 — + `{참조, 이름, 다시씀, 빠진줄, 비교}`(부르는 쪽이 `store.at(folder)`).""" + maker = Builder(source, folder, key, fresh) + made = maker.build(int(no)) + hopyo = maker.hopyo(int(no)) + ref = {k: made[k] for k in ("층", "주인", "키")} + return { + "참조": ref, + "이름": hopyo.get("이름"), + "다시씀": made["다시씀"], + "빠진줄": maker.missing.get(int(no), []), + "비교": compare(ref, hopyo), + } diff --git a/M02_MasterTemplete/M02_Labor_Ref.py b/M02_MasterTemplete/M02_Labor_Ref.py index 6e0a14bfb..722b87ef3 100644 --- a/M02_MasterTemplete/M02_Labor_Ref.py +++ b/M02_MasterTemplete/M02_Labor_Ref.py @@ -210,6 +210,7 @@ def _combo_lines(doc: dict, key: str) -> list[dict[str, Any]]: if "로직" in one: head["입력값"] = one["입력값"] head.update({k: one[k] for k in ("켜쌓기율", "고름", "자재표시") if k in one}) + head.update({"별칭": one["별칭"]} if one["별칭"] else {}) # 실무 품명(324-3) lines.append({**head, "수량": one["수량"] or {"손": 1}, "메모": one["메모"]}) at_ = here() me = {"층": at_[0], "주인": at_[1], "키": key} diff --git a/M02_MasterTemplete/M02_Labor_Router.py b/M02_MasterTemplete/M02_Labor_Router.py index 8d89ade41..ab7530fa1 100644 --- a/M02_MasterTemplete/M02_Labor_Router.py +++ b/M02_MasterTemplete/M02_Labor_Router.py @@ -18,6 +18,7 @@ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from pydantic import BaseModel from common_util.common_util_auth import verify_session +from M02_MasterTemplete import M02_Hopyo_Build as hopyo_build from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store @@ -266,6 +267,30 @@ def hopyo_memory( return _call(folder, hopyo_match.confirm, folder, body.줄) +class BuildBody(BaseModel): + """실무 호표 → 이 층 사전조합 — 파일 층 `층` · `주인` · `키` + 호표 `번` · `새로` = 있던 것을 덮음.""" + + 층: Layer + 주인: str | None = None + 키: str + 번: int + 새로: bool = False + + +@router.post("/layers/{layer}/hopyo-build") +def hopyo_make( + layer: Layer, + body: BuildBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """실무 호표 → 우리 일위대가 사전조합(이 층 쓰기 · 파일 층 읽기) — + `{참조, 이름, 다시씀, 빠진줄, 비교}`.""" + folder = _root(session, layer, owner, write=True) + source = _root(session, body.층, body.주인) + return _call(folder, hopyo_build.build, folder, source, body.키, body.번, body.새로) + + @router.delete("/layers/{layer}/hopyo/{key}") def hopyo_delete( layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session) diff --git a/resources/tester/test_m02_hopyo_build.py b/resources/tester/test_m02_hopyo_build.py new file mode 100644 index 000000000..1284a544c --- /dev/null +++ b/resources/tester/test_m02_hopyo_build.py @@ -0,0 +1,152 @@ +"""일위대가 실무자료 → 사전조합 만들기(PLAN 324-3 · `M02_Hopyo_Build`) — 별칭 남음 · +수량 = 엑셀 × 환산 · 자재 · 같은 로직 둘 = 감쌈 · 일위N 참조 · 순환 · +같은 호표 다시 = 같은 사전조합 · 새로 = 덮음 · +빠진 줄 · 값 비교 · 길 권한 · master_data 원본 그대로. 층은 임시 폴더. +""" + +import json + +from M02_MasterTemplete import M02_Hopyo_Store as hs +from M02_MasterTemplete import M02_Template_Layers as layers + +# 올리기 길 시험의 `client` 마당을 같이 씀 +# ruff: noqa: F811 +from resources.tester.test_m02_hopyo_excel import URL, _post, client, master_hash # noqa: F401 + +DIG = "구조물터파기(인력100%)|토사|m3" +FILL = "구조물되메우기(인력100%)||m3" +SOIL = "잔토처리(인력100%)||m3" +STONE = "임도표지석(받침,글새김포함)|900x600x200x150|m3" +MEMORY = "/api/m02/layers/{}/hopyo-memory" +BUILD = "/api/m02/layers/{}/hopyo-build" + + +def _remember(client, layer="personal", **lines): + res = client.post(MEMORY.format(layer), json={"줄": lines}) + assert res.status_code == 200, res.text + + +def _build(client, no, layer="personal", want=200, **extra): + body = {"층": "personal", "키": "HP-000001", "번": no, **extra} + res = client.post(BUILD.format(layer), json=body) + assert res.status_code == want, res.text + return res.json() + + +def _doc(layer_dir, key): + return json.loads((layer_dir / "precombo" / f"{key}.json").read_bytes()) + + +def test_만들기_별칭_수량_메모_빠진_줄_비교(client): + before = master_hash() + assert _post(client).status_code == 200 + _remember( + client, + **{ + DIG: {"로직": "GF000101", "입력값": {"지역": "서울"}, "환산": 1}, + FILL: {"로직": "GF000113", "환산": 2}, + }, + ) + got = _build(client, 3) + mine = layers.personal_dir(7, 42) + doc = _doc(mine, got["참조"]["키"]) + assert (doc["이름"], doc["단위"], doc["구분"]) == ("임도표지석 910×600×150", "개소", "실무자료") + assert doc["상세구분"].endswith("(공통단가).xls") + assert doc["본뜬"]["층"] == "실무자료" and doc["본뜬"]["키"] == "HP-000001 제3호표" + assert doc["담은로직"] == [ + { + "로직": "GF000101", + "입력값": {"지역": "서울"}, + "별칭": "구조물터파기(인력100%)", + "수량": {"손": 0.14}, + "메모": "실무 0.14㎥ × 1 · 토사", + }, + { + "로직": "GF000113", + "입력값": {}, + "별칭": "구조물되메우기(인력100%)", + "수량": {"손": 0.1}, # 엑셀 0.05 × 환산 2 + "메모": "실무 0.05㎥ × 2", + }, + ] + assert [(x["이름"], x["금액"]) for x in got["빠진줄"]] == [ + ("임도표지석(받침,글새김포함)", 1400000), + ("잔토처리(인력100%)", 3097.1), + ] + assert got["비교"]["엑셀"] == 1415169 and got["비교"]["우리"] > 0 + assert got["비교"]["차이"] == round(got["비교"]["우리"] - 1415169, 1) + assert master_hash() == before + + +def test_자재_같은_로직_감쌈_다시씀_새로(client): + assert _post(client).status_code == 200 + _remember( + client, + **{ + DIG: {"로직": "GF000101", "환산": 1}, + SOIL: {"로직": "GF000101", "환산": 1}, + STONE: {"자재": "MT004258", "환산": 1}, + }, + ) + got = _build(client, 3) + mine = layers.personal_dir(7, 42) + doc = _doc(mine, got["참조"]["키"]) + first, wrapped = doc["담은로직"] + assert first["로직"] == "GF000101" and first["별칭"] == "구조물터파기(인력100%)" + assert wrapped["별칭"] == "잔토처리(인력100%)" and wrapped["수량"] == {"손": 0.09} + inner = _doc(mine, wrapped["참조"]["키"]) + assert inner["본뜬"]["키"] == "HP-000001 제3호표 4째 줄" + assert inner["담은로직"][0]["로직"] == "GF000101" and inner["담은로직"][0]["수량"] == {"손": 1} + assert [(m["코드"], m["수량"], m["별도계상"]) for m in doc["자재"]] == [ + ("MT004258", {"손": 1}, "직접계산") + ] + files = sorted(p.name for p in (mine / "precombo").glob("*.json")) + again = _build(client, 3) + assert again["다시씀"] is True and again["참조"] == got["참조"] + assert sorted(p.name for p in (mine / "precombo").glob("*.json")) == files + _remember(client, **{SOIL: {"빼기": True}}) + fresh = _build(client, 3, 새로=True) + assert fresh["참조"] == got["참조"] and fresh["다시씀"] is True + assert len(_doc(mine, got["참조"]["키"])["담은로직"]) == 1 # 덮음 + assert sorted(p.name for p in (mine / "precombo").glob("*.json")) == files + + +def test_일위N_참조_먼저_만듦(client): + assert _post(client).status_code == 200 + got = _build(client, 45) + mine = layers.personal_dir(7, 42) + lines = _doc(mine, got["참조"]["키"])["담은로직"] + ref = lines[0] + assert ref["별칭"] == "고화매트재료비" and ref["수량"] == {"손": 2.3} + child = _doc(mine, ref["참조"]["키"]) + assert child["이름"] == "고화매트재료비" and child["본뜬"]["키"] == "HP-000001 제46호표" + assert any(x["까닭"].startswith("% 줄") for x in _build(client, 46)["빠진줄"]) + + +def test_순환은_빠진_줄(client): + folder = layers.personal_dir(7, 42) + _post(client) # 층 뼈대 + line = {"차례": 0, "이름": "남", "규격": "", "수량": 1, "단위": "식", "갈래": "호표"} + money = {"계": 1, "노무비": 1, "재료비": None, "경비": None} + hopyos = [ + {"번": 1, "이름": "가", "규격": "", "단위": "식", "금액": money, "줄": [ + {**line, "참조": {"종류": "일위", "번": 2}, "금액": money}]}, + {"번": 2, "이름": "나", "규격": "", "단위": "식", "금액": money, "줄": [ + {**line, "참조": {"종류": "일위", "번": 1}, "금액": money}]}, + ] # fmt: skip + key = hs.save(folder, "loop.xls", {"시트": "일위대가", "호표": hopyos, "검산": {}}, None)["키"] + got = _build(client, 1, 키=key) + assert got["빠진줄"] == [] + inner = _doc(folder, _doc(folder, got["참조"]["키"])["담은로직"][0]["참조"]["키"]) + assert inner["담은로직"] == [] # 제2호표 안 제1호표 = 순환 → 빠짐 + + +def test_길_권한과_위_층_파일(client): + client.who.update(role="SYSTEM_ADMIN") + assert _post(client, "system").status_code == 200 + client.who.update(role="USER") + _build(client, 3, layer="system", 층="system", want=403) # 마스터 쓰기 = 시스템 관리자 + got = _build(client, 3, 층="system") # 마스터 파일 → 내 층 사전조합 + assert got["참조"]["층"] == "personal" and got["참조"]["주인"] == "7/42" + assert _build(client, 99, 층="system", want=404) + assert client.get(URL.format("personal")).json()["줄"] == [] # 파일은 마스터에만 From 56f606f331cd5baa9baced2a8f62cfd26c33e62c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 14 Oct 2026 22:27:57 +0900 Subject: [PATCH 5/6] =?UTF-8?q?feat(=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80?= =?UTF-8?q?=EC=8B=A4=EB=AC=B4):=20324-4=20=ED=99=94=EB=A9=B4=20=E2=80=94?= =?UTF-8?q?=20=EC=A2=8C=EC=B8=A1=20=E3=80=8C=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EC=8B=A4=EB=AC=B4=EC=9E=90=EB=A3=8C=E3=80=8D=20?= =?UTF-8?q?=ED=8C=90=20=C2=B7=20=EB=B3=B8=20=ED=99=94=EB=A9=B4(=ED=98=B8?= =?UTF-8?q?=ED=91=9C=20=ED=91=9C=20=C2=B7=20=EC=A4=84=20=EB=A7=9E=EC=B6=A4?= =?UTF-8?q?=20=C2=B7=20=ED=99=95=EC=A0=95=20=C2=B7=20=EB=B9=A0=EC=A7=84=20?= =?UTF-8?q?=EC=A4=84=20=C2=B7=20=EA=B0=92=20=EB=B9=84=EA=B5=90)=20=C2=B7?= =?UTF-8?q?=20[=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80]=20=E3=80=8C?= =?UTF-8?q?=EC=8B=A4=EB=AC=B4=20=ED=98=B8=ED=91=9C=E3=80=8D=20=EC=A4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 좌측 판(Hopyo_Side): 일위대가 사전조합 아래 · 구조물 조합 층 · 쓰기 권한 없으면 [엑셀 올리기] 꺼짐 · 숨은 file 입력 .xls,.xlsx · 파일 줄 → 본 화면 - 본 화면(Hopyo_View): 파일 · 시트 · 호표 수 · 검산 · 맞춤 n/m · 호표 표 + 찾기 · [지우기] / 호표 줄 표(갈래 · 품명 · 규격 · 수량 · 단위 · 엑셀 단가 · 맞춤 · 대상 · 입력값 · 환산) · [고르기](후보 5 + 다른 로직 · 자재 찾기 → 미리 채움) · [확정] · [빼기] · [되살림] · [같음 모두 확정] · 빠진 줄 · [사전조합 만들기] → 값 비교 · 다시 씀이면 [새로 만들기](확인 뒤 덮음) - [로직 추가](Hopyo_Pick · Labor_Pick 늦은 덧줄 `more` · 종류 고르기): 문서 층 · 위 층 호표 줄(맞춤 n/m) → 고르면 서버가 문서 층 사전조합을 만들고 참조 줄(별칭 = 호표 품명) · 빠진 줄 알림 · 프로젝트 층 없음 - Labor_Detail: 제자리 넷 줄(가져오기 · pick 별칭 받음 · withHopyo) · 693 줄 그대로 - 서버: GET /layers/{층}/hopyo-picks(이 층 · 위 층 · 이 층 기억) - 색 = 공용 토큰(밝음 · 어두움) · 시험 test_m02_hopyo_screen 4 + hopyo-picks 1 · typecheck 0 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- M02_MasterTemplete/M02_Hopyo_Match.py | 17 + M02_MasterTemplete/M02_Labor_Router.py | 13 +- .../M02_MasterTemplete_Hopyo.css | 118 +++++ .../M02_MasterTemplete_Hopyo_Api.ts | 190 +++++++ .../M02_MasterTemplete_Hopyo_Pick.ts | 58 +++ .../M02_MasterTemplete_Hopyo_Side.ts | 128 +++++ .../M02_MasterTemplete_Hopyo_View.ts | 478 ++++++++++++++++++ .../M02_MasterTemplete_Labor_Detail.ts | 8 +- .../M02_MasterTemplete_Labor_Pick.ts | 39 +- .../M02_MasterTemplete_UI_Side.ts | 13 +- resources/tester/helper_m02_hopyo_pick.cjs | 83 +++ resources/tester/test_m02_hopyo_match.py | 20 + resources/tester/test_m02_hopyo_screen.py | 132 +++++ 13 files changed, 1278 insertions(+), 19 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Hopyo.css create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts create mode 100644 resources/tester/helper_m02_hopyo_pick.cjs create mode 100644 resources/tester/test_m02_hopyo_screen.py diff --git a/M02_MasterTemplete/M02_Hopyo_Match.py b/M02_MasterTemplete/M02_Hopyo_Match.py index fc8136a1e..3d66494f5 100644 --- a/M02_MasterTemplete/M02_Hopyo_Match.py +++ b/M02_MasterTemplete/M02_Hopyo_Match.py @@ -445,6 +445,23 @@ def view(folder: Path, key: str, no: int | None = None) -> dict[str, Any]: return {**hopyo_store.brief(doc), "호표": match_hopyo(hopyo, memo, units)} +def picks(folder: Path) -> list[dict[str, Any]]: + """[로직 추가] 「실무 호표」 줄 — 이 층 · 위 층 실무자료 호표마다 맞춤 n/m + (이 층 기억 · 만드는 층).""" + memo, got, out = memories(folder), targets(), [] + for one in chain(folder): + layer, owner = refs.where(one) + for row in hopyo_store.rows(one): + doc = hopyo_store.read(one, row["키"]) + units = units_of(doc) + for hopyo in doc.get("호표") or []: + ready = match_hopyo(hopyo, memo, units, False, got)["맞춤"] + head = {"층": layer, "주인": owner, "키": row["키"], "파일": row["파일"]} + keep = ("번", "이름", "규격", "단위") + out.append({**head, **{k: hopyo.get(k) for k in keep}, "맞춤": ready}) + return out + + # ── 확정 저장 ───────────────────────────────────────────────────────── def confirm(folder: Path, entries: dict[str, Any]) -> dict[str, Any]: """확정 · 빼기 · 지움 → 층 기억 — `{열쇠: {로직|자재|빼기, 입력값, 환산} | None}`. diff --git a/M02_MasterTemplete/M02_Labor_Router.py b/M02_MasterTemplete/M02_Labor_Router.py index ab7530fa1..1f028a4b1 100644 --- a/M02_MasterTemplete/M02_Labor_Router.py +++ b/M02_MasterTemplete/M02_Labor_Router.py @@ -188,6 +188,16 @@ def hopyo_list( return {"줄": hopyo_store.rows(folder)} +@router.get("/layers/{layer}/hopyo-picks") +def hopyo_picks( + layer: Layer, owner: str | None = None, session: Session = Depends(verify_session) +) -> dict: + """[로직 추가] 「실무 호표」 줄 — 이 층 · 위 층 파일의 호표마다 + `{층, 주인, 키, 파일, 번, 이름, 규격, 단위, 맞춤}`(맞춤 n/m = 이 층 기억).""" + folder = _root(session, layer, owner) + return {"줄": _call(folder, hopyo_match.picks, folder)} + + @router.post("/layers/{layer}/hopyo") def hopyo_upload( layer: Layer, @@ -268,7 +278,8 @@ def hopyo_memory( class BuildBody(BaseModel): - """실무 호표 → 이 층 사전조합 — 파일 층 `층` · `주인` · `키` + 호표 `번` · `새로` = 있던 것을 덮음.""" + """실무 호표 → 이 층 사전조합 — 파일 층 `층` · `주인` · `키` + 호표 `번` · + `새로` = 있던 것을 덮음.""" 층: Layer 주인: str | None = None diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css new file mode 100644 index 000000000..236b453c9 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css @@ -0,0 +1,118 @@ +/* M02 일위대가 실무자료(PLAN 324-4) — 좌측 판 · 본 화면(호표 표 · 줄 맞춤) · 색은 공용 토큰(밝음 · 어두움 같이) */ +.m02-hopyo-side { + display: flex; + flex-direction: column; + gap: var(--spacing-4); +} + +.m02-hopyo { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-width: 0; +} + +.m02-hopyo__bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); +} + +.m02-hopyo__note { + margin: 0; + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} + +.m02-hopyo__warn { + margin: 0; + color: var(--color-warning); + font-size: var(--text-body-sm); + white-space: pre-wrap; +} + +.m02-hopyo__search { + max-width: 320px; +} + +.m02-hopyo__row { + cursor: pointer; +} + +.m02-hopyo__row:hover { + background: var(--color-mist-violet); +} + +.m02-hopyo__num { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.m02-hopyo__line.is-out { + color: var(--color-text-muted); +} + +.m02-hopyo__state { + display: inline-block; + padding: 0 var(--spacing-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + font-size: var(--text-caption); + white-space: nowrap; +} + +.m02-hopyo__state.is-기억, +.m02-hopyo__state.is-같음, +.m02-hopyo__state.is-호표 { + border-color: var(--color-success); + color: var(--color-success); +} + +.m02-hopyo__state.is-후보 { + border-color: var(--color-warning); + color: var(--color-warning); +} + +.m02-hopyo__state.is-없음 { + border-color: var(--color-danger); + color: var(--color-danger); +} + +.m02-hopyo__times { + width: 6em; +} + +.m02-hopyo__acts { + white-space: nowrap; +} + +.m02-hopyo__acts > button + button { + margin-left: var(--spacing-4); +} + +.m02-hopyo__block { + display: flex; + flex-direction: column; + gap: var(--spacing-4); +} + +.m02-hopyo__block h4 { + margin: 0; + font-size: var(--text-body-sm); +} + +.m02-hopyo__built { + padding: var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface-raised); +} + +.m02-hopyo__cands { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--spacing-4); + margin-bottom: var(--spacing-8); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts new file mode 100644 index 000000000..e11e7dcec --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts @@ -0,0 +1,190 @@ +/* ============================================================================= + * M02_MasterTemplete_Hopyo_Api.ts + * 일위대가 실무자료(PLAN 324) 서버 길 — `/api/m02/layers/{층}/hopyo…` · 층 = 화면 층(`Api_Fetch` 지금 층) · + * 올리기 = FormData(숨은 file 입력) · 그 밖 = 공용 `call`(서버 까닭 글 그대로). + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import { call, getActiveLayer, getActiveOwner, type Layer } from "./M02_MasterTemplete_Api_Fetch"; +import type { LaborRef } from "./M02_MasterTemplete_Labor_Calc"; + +/** 층 · 주인(null = 자기 층) */ +export interface HopyoScope { + layer: Layer; + owner: string | null; +} + +export interface HopyoFile { + 키: string; + 파일: string; + 올린때: string; + 올린이: { id: number; 이름: string | null } | null; + 시트: string; + 호표수: number; + 어긋남: number; +} + +export type Money = Record<"계" | "노무비" | "재료비" | "경비", number | null>; + +export interface HopyoBrief { + 번: number; + 이름: string; + 규격: string; + 단위: string; + 금액: Money; + /** [들어감 수, 줄 수] */ + 맞춤: [number, number]; +} + +export interface HopyoView extends HopyoFile { + 검산: { 어긋남: string[]; 총괄표: number }; + 호표: HopyoBrief[]; + 맞춤: [number, number]; +} + +/** 맞춤 대상 — 로직 · 자재 · 다른 호표 */ +export interface HopyoTarget { + 로직?: string; + 자재?: string; + 호표?: number; + 이름?: string; + 규격?: string; + 단위?: string | null; + 점수?: number; +} + +export interface HopyoMatch { + 열쇠: string; + 상태: "기억" | "같음" | "후보" | "없음" | "빼기" | "호표" | "%"; + 대상: HopyoTarget | null; + 입력값: Record; + 못읽음: string[]; + 환산: { 배: number | null; 갈래: string } | null; + 들어감: boolean; + 후보?: HopyoTarget[]; +} + +export interface HopyoLine { + 차례: number; + 이름: string; + 규격: string; + 수량: number | null; + 단위: string; + 갈래: string; + 단가: Money; + 금액: Money; + 비고: string; + 맞춤: HopyoMatch; +} + +export interface HopyoOne extends HopyoFile { + 호표: HopyoBrief & { 줄: HopyoLine[] }; +} + +export interface HopyoGuess { + 대상: HopyoTarget; + 입력값: Record; + 못읽음: string[]; + 환산: { 배: number | null; 갈래: string }; +} + +export interface HopyoMissing { + 차례: number; + 이름: string; + 규격: string; + 수량: number | null; + 단위: string; + 금액: number | null; + 까닭: string; +} + +export interface HopyoBuilt { + 참조: LaborRef; + 이름: string; + 다시씀: boolean; + 빠진줄: HopyoMissing[]; + 비교: { 엑셀: number | null; 우리: number | null; 차이: number | null; 까닭: string | null }; +} + +/** [로직 추가] 「실무 호표」 줄 하나 */ +export interface HopyoPickRow extends Omit { + 층: Layer; + 주인: string | null; + 키: string; + 파일: string; +} + +/** 확정 한 줄 — 로직 · 자재 · 빼기 · null(지움) */ +export type HopyoMemory = + | { 로직: string; 입력값: Record; 환산: number } + | { 자재: string; 입력값: Record; 환산: number } + | { 빼기: true } + | null; + +const enc = encodeURIComponent; +/** 지금 화면 층(구조물 조합 층) */ +export const pageScope = (): HopyoScope => ({ layer: getActiveLayer(), owner: getActiveOwner() }); +const at = (scope: HopyoScope, path: string): string => { + const who = scope.owner && scope.layer !== "system" ? `owner=${enc(scope.owner)}` : ""; + const join = path.includes("?") ? "&" : "?"; + return `/layers/${scope.layer}${path}${who ? join + who : ""}`; +}; + +export const listHopyo = async (scope: HopyoScope): Promise => + (await call<{ 줄: HopyoFile[] }>(at(scope, "/hopyo"))).줄; + +export const readHopyo = (scope: HopyoScope, key: string): Promise => + call(at(scope, `/hopyo/${enc(key)}`)); + +export const readHopyoOne = (scope: HopyoScope, key: string, no: number): Promise => + call(at(scope, `/hopyo/${enc(key)}/${no}`)); + +export const guessHopyo = ( + scope: HopyoScope, + key: string, + no: number, + 차례: number, + 대상: HopyoTarget, +): Promise => + call(at(scope, `/hopyo/${enc(key)}/${no}/guess`), { + method: "POST", + body: JSON.stringify({ 차례, 대상 }), + }); + +export const saveHopyoMemory = ( + scope: HopyoScope, + 줄: Record, +): Promise => + call(at(scope, "/hopyo-memory"), { method: "POST", body: JSON.stringify({ 줄 }) }); + +export const deleteHopyo = (scope: HopyoScope, key: string): Promise => + call(at(scope, `/hopyo/${enc(key)}`), { method: "DELETE" }); + +/** 실무 호표 → `target` 층 사전조합(파일 층 = `from`) */ +export const buildHopyo = ( + target: HopyoScope, + from: { 층: Layer; 주인: string | null; 키: string; 번: number }, + 새로 = false, +): Promise => + call(at(target, "/hopyo-build"), { + method: "POST", + body: JSON.stringify({ ...from, 새로 }), + }); + +export const listHopyoPicks = async (scope: HopyoScope): Promise => + (await call<{ 줄: HopyoPickRow[] }>(at(scope, "/hopyo-picks"))).줄; + +/** 엑셀 올리기 — 서버 까닭 글 그대로(시트 없음 · 20 MB · 엉터리) */ +export async function uploadHopyo(scope: HopyoScope, file: File): Promise { + const form = new FormData(); + form.append("file", file); + const response = await fetch(`${API_BASE_URL}/m02${at(scope, "/hopyo")}`, { + method: "POST", + credentials: "include", + body: form, + }); + const body = (await response.json().catch(() => ({}))) as { detail?: unknown } & HopyoFile; + if (!response.ok) + throw new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`); + return body; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts new file mode 100644 index 000000000..04cdd4c89 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts @@ -0,0 +1,58 @@ +/* ============================================================================= + * M02_MasterTemplete_Hopyo_Pick.ts + * [로직 추가] 창의 「실무 호표」 줄(PLAN 324-4) — 일위대가 문서 층 · 위 층 실무자료의 호표마다 한 줄(맞춤 n/m) · + * 고르면 서버가 그 호표를 문서 층 사전조합으로 만들고(`/hopyo-build` · 있던 것은 다시 씀) `lib.pick` 으로 + * 참조 줄(별칭 = 호표 품명) · 빠진 줄이 있으면 알림. 프로젝트 층엔 없음. + * ========================================================================== */ + +import { showToast } from "@ui/ui_template_elements"; +import { buildHopyo, listHopyoPicks, type HopyoPickRow } from "./M02_MasterTemplete_Hopyo_Api"; +import type { LibraryPick, PickExtra } from "./M02_MasterTemplete_Labor_Pick"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; + +export const HOPYO_KIND = "hopyo"; +const LAYER_TEXT: Record = { system: "마스터", company: "회사", personal: "개인" }; +const ALIAS_MAX = 40; // 별칭 상한(44-6 · 서버 `ALIAS_MAX`) + +/** 서고 고르기에 「실무 호표」 덧줄 · 종류 고르기(단가산출 · 사전조합 · 실무 호표)를 붙임 */ +export function withHopyo(lib: LibraryPick): LibraryPick { + if (lib.layer === "project") return lib; + const scope = { layer: lib.layer, owner: lib.owner }; + return { + ...lib, + kindFilter: lib.kindFilter ?? [ + { value: "logic", text: tc("Kind_Logic") }, + { value: "precombo", text: tc("Kind_Combo") }, + { value: HOPYO_KIND, text: "실무 호표" }, + ], + more: async () => (await listHopyoPicks(scope)).map((row) => hopyoExtra(row, lib)), + }; +} + +/** 실무 호표 한 줄 */ +export function hopyoExtra(row: HopyoPickRow, lib: LibraryPick): PickExtra { + return { + kind: HOPYO_KIND, + key: `${row.층}|${row.주인 ?? ""}|${row.키}|${row.번}`, + tag: "실무 호표", + name: [row.이름, row.규격].filter(Boolean).join(" "), + sub: `${row.파일} · 제${row.번}호표 · ${LAYER_TEXT[row.층] ?? row.층}`, + unit: `${row.단위} · 맞춤 ${row.맞춤[0]}/${row.맞춤[1]}`, + onPick: () => void pickHopyo(row, lib), + }; +} + +/** 고름 — 서버가 사전조합을 만들고(다시 씀) 참조 줄을 더함 */ +export async function pickHopyo(row: HopyoPickRow, lib: LibraryPick): Promise { + try { + const from = { 층: row.층, 주인: row.주인, 키: row.키, 번: row.번 }; + const got = await buildHopyo({ layer: lib.layer, owner: lib.owner }, from); + const miss = got.빠진줄.length; + const head = `「${row.이름}」 → 사전조합 ${got.참조.키}${got.다시씀 ? "(있던 것)" : ""}`; + if (miss) showToast(`${head} · 빠진 줄 ${miss} — 실무자료 화면에서 맞춤`, "warning", 6000); + else showToast(head, "success"); + lib.pick(got.참조, got.이름, row.이름.slice(0, ALIAS_MAX).trim() || null); + } catch (error) { + showToast(error instanceof Error ? error.message : String(error), "error"); + } +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts new file mode 100644 index 000000000..74ce15703 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts @@ -0,0 +1,128 @@ +/* ============================================================================= + * M02_MasterTemplete_Hopyo_Side.ts + * 좌측 「일위대가 실무자료」 컨테이너(PLAN 324-4 · 일위대가 사전조합 아래) — 층 = 구조물 조합 층(화면 층) · + * [엑셀 올리기](숨은 file 입력 .xls · .xlsx · 쓰기 권한 없으면 꺼짐) + 파일 줄 → 누르면 본 화면 + * (`M02_MasterTemplete_Hopyo_View`) · 고름 표시 = 본 화면이 메인 칸에 붙어 있는 동안만. + * ========================================================================== */ + +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { listHopyo, pageScope, uploadHopyo, type HopyoFile } from "./M02_MasterTemplete_Hopyo_Api"; +import { buildHopyoView } from "./M02_MasterTemplete_Hopyo_View"; + +export const HOPYO_TITLE = "일위대가 실무자료"; + +export interface HopyoSectionOptions { + /** 이 층 쓰기 권한 — 없으면 올리기 꺼짐 · 본 화면 확정 · 만들기 없음 */ + canWrite: boolean; + /** 저장 안 한 고침을 버릴지 확인 — 취소면 false */ + confirmLeave: () => Promise; + /** 메인 칸에 화면을 띄움 */ + show: (label: string, node: HTMLElement) => (dirty: boolean) => void; + /** 화면을 열었음 — 좌측 다른 고름 표시를 지움 */ + onOpen: () => void; +} + +export interface HopyoSection { + title: string; + body: HTMLElement; + /** 다른 화면이 열렸으면 고름 표시를 지움 */ + paint: () => void; + refresh: () => Promise; +} + +const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); + +export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection { + const scope = pageScope(); + const list = el("div", { className: "m02-side__items ui-sidebar-list" }); + const input = el("input", { + attrs: { type: "file", accept: ".xls,.xlsx", "data-hopyo-file": "" }, + }) as HTMLInputElement; + input.hidden = true; + const upload = createButton({ + label: "엑셀 올리기", + variant: "filled", + size: "sm", + disabled: !opt.canWrite, + onClick: () => input.click(), + }); + upload.title = opt.canWrite ? "설계내역서 엑셀(일위대가 시트)" : "이 층 쓰기 권한 없음"; + const body = el("div", { + className: "m02-hopyo-side", + children: [el("div", { className: "m02-side__row", children: [upload, input] }), list], + }); + let files: HopyoFile[] = []; + let open: { key: string; view: HTMLElement } | null = null; + let seq = 0; + + input.addEventListener("change", () => { + const file = input.files?.[0]; + input.value = ""; + if (!file) return; + upload.disabled = true; + uploadHopyo(scope, file) + .then(async (got) => { + const wrong = got.어긋남 ? ` · 검산 어긋남 ${got.어긋남}` : ""; + showToast(`올림 — 호표 ${got.호표수}${wrong}`, got.어긋남 ? "warning" : "success"); + await refresh(); + await show(got); + }) + .catch((error) => showToast(why(error), "error")) + .finally(() => (upload.disabled = !opt.canWrite)); + }); + + function paintList(): void { + list.replaceChildren( + ...(files.length + ? files.map((f) => { + const row = el("button", { + className: `m02-side__item ui-sidebar-row${open?.key === f.키 ? " is-active" : ""}`, + text: `${f.파일} · 호표 ${f.호표수}`, + attrs: { type: "button", title: `${f.키} · ${f.올린때}`, "data-hopyo-key": f.키 }, + }); + row.addEventListener("click", () => void show(f)); + return row; + }) + : [el("p", { className: "m02-side__empty", text: "올린 실무자료 없음" })]), + ); + } + + async function show(file: HopyoFile): Promise { + if (open?.key === file.키 && open.view.isConnected) return; + if (!(await opt.confirmLeave())) return; + const view = buildHopyoView(file.키, { + scope, + canWrite: opt.canWrite, + onDeleted: () => { + view.replaceChildren(el("p", { className: "m02-side__empty", text: "지운 실무자료" })); + void refresh(); + }, + }); + opt.show(`${HOPYO_TITLE} — ${file.파일}`, view); + opt.onOpen(); + open = { key: file.키, view }; + paintList(); + } + + async function refresh(): Promise { + const mine = ++seq; + try { + const got = await listHopyo(scope); + if (mine === seq) files = got; + } catch (error) { + if (mine === seq) files = []; + showToast(why(error), "error"); + } + if (mine === seq) paintList(); + } + + const paint = (): void => { + if (open && !open.view.isConnected) { + open = null; + paintList(); + } + }; + + void refresh(); + return { title: HOPYO_TITLE, body, paint, refresh }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts new file mode 100644 index 000000000..4318fa45a --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts @@ -0,0 +1,478 @@ +/* ============================================================================= + * M02_MasterTemplete_Hopyo_View.ts + * 일위대가 실무자료 본 화면(PLAN 324-4) — 파일 하나: 파일 · 시트 · 호표 수 · 검산 · 맞춤 n/m · 호표 표 + 찾기 · + * [지우기] / 호표 하나: 줄 표(갈래 · 품명 · 규격 · 수량 · 단위 · 엑셀 단가 · 맞춤 · 대상 · 입력값 · 환산 · + * [고르기] [확정] [빼기]) · [같음 모두 확정] · 빠진 줄 · [사전조합 만들기] → 값 비교 · 다시 씀이면 [새로 만들기]. + * 맞춤 · 만들기는 서버(`M02_Hopyo_Match` · `M02_Hopyo_Build`) · 쓰기 단추는 층 쓰기 권한만. + * ========================================================================== */ + +import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; +import { loadingError, showLoading } from "@ui/ui_template_loading"; +import { openModal } from "@ui/ui_template_modal"; +import { + buildHopyo, + deleteHopyo, + guessHopyo, + readHopyo, + readHopyoOne, + saveHopyoMemory, + type HopyoBuilt, + type HopyoGuess, + type HopyoLine, + type HopyoMemory, + type HopyoOne, + type HopyoScope, + type HopyoTarget, + type HopyoView, +} from "./M02_MasterTemplete_Hopyo_Api"; +import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; +import { pickMaterial } from "./M02_Material_Pick"; +import "./M02_MasterTemplete_Hopyo.css"; + +export interface HopyoViewOptions { + scope: HopyoScope; + /** 층 쓰기 권한 — 없으면 확정 · 만들기 · 지우기 단추 없음 */ + canWrite: boolean; + /** 파일을 지웠음 — 좌측 목록 다시 */ + onDeleted: () => void; +} + +const STATE: Record = { + 기억: "기억", + 같음: "같음", + 후보: "후보", + 없음: "없음", + 빼기: "뺌", + 호표: "호표", + "%": "% 줄", +}; +const MATERIAL = new Set(["자재", "별도계상"]); + +const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); +const won = (v: number | null | undefined): string => + v === null || v === undefined ? "" : v.toLocaleString("ko-KR"); +const fail = (error: unknown): void => showToast(`실패 — ${why(error)}`, "error"); + +const grid = (heads: string[], body: HTMLElement, cls = ""): HTMLElement => + el("div", { + className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head", + children: [ + el("table", { + className: `ui-general-block__table m02-hopyo__table ${cls}`.trim(), + children: [ + el("thead", { + children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })], + }), + body, + ], + }), + ], + }); +const table = (heads: string[], rows: HTMLElement[]): HTMLElement => + grid(heads, el("tbody", { children: rows })); +const td = (text: string, cls = ""): HTMLElement => + el("td", { text, className: cls, attrs: { title: text } }); +const note = (text: string): HTMLElement => el("span", { className: "m02-hopyo__note", text }); + +/** 대상 글 — 「이름 [단위]」 · 자재는 규격도 · 호표 = 「제N호표」 */ +export const targetText = (t: HopyoTarget | null | undefined): string => { + if (!t) return ""; + if (t.호표 !== undefined) return `제${t.호표}호표 ${t.단위 ? `[${t.단위}]` : ""}`.trim(); + const spec = t.규격 ? ` ${t.규격}` : ""; + return `${t.이름 ?? t.로직 ?? t.자재 ?? ""}${spec}${t.단위 ? ` [${t.단위}]` : ""}`; +}; + +/** 입력값 글 — 「이름=값」 · 못 읽은 입력 표시 */ +export const inputsText = (values: Record, unread: string[]): string => + [ + Object.entries(values) + .map(([k, v]) => `${k}=${String(v)}`) + .join(", "), + unread.length ? `⚠ 못 읽음: ${unread.join(", ")}` : "", + ] + .filter(Boolean) + .join(" · "); + +/** 확정 한 줄 몸 — 대상 · 입력값 · 환산(0 넘는 수) · 없으면 null */ +export function memoryOf( + target: HopyoTarget | null, + values: Record, + times: number | null, +): HopyoMemory { + if (!target || times === null || !(times > 0)) return null; + if (target.로직) return { 로직: target.로직, 입력값: values, 환산: times }; + if (target.자재) return { 자재: target.자재, 입력값: values, 환산: times }; + return null; +} + +export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement { + const view = el("div", { className: "m02-hopyo", attrs: { "data-hopyo-view": key } }); + let nav = 0; // 늦게 온 응답 버림 + let query = ""; + + async function load(get: () => Promise, retry: () => void): Promise { + const my = ++nav; + const stop = showLoading(view); + try { + const got = await get(); + return my === nav ? got : null; + } catch (error) { + if (my === nav) view.replaceChildren(loadingError(why(error), retry)); + return null; + } finally { + stop(); + } + } + + /* ── 파일 하나 — 호표 표 ─────────────────────────────────────── */ + async function drawFile(): Promise { + const file = await load( + () => readHopyo(opt.scope, key), + () => void drawFile(), + ); + if (!file) return; + const search = el("input", { + className: "ui-input ui-input--sm m02-hopyo__search", + attrs: { type: "search", placeholder: "호표 · 품명 · 규격 찾기", value: query }, + }) as HTMLInputElement; + const body = el("tbody"); + const paint = (): void => { + const q = search.value.trim().toLowerCase(); + query = search.value; + body.replaceChildren( + ...file.호표 + .filter((h) => !q || `제${h.번}호표 ${h.이름} ${h.규격}`.toLowerCase().includes(q)) + .map((h) => { + const tr = el("tr", { + className: "m02-hopyo__row", + attrs: { "data-hopyo-no": String(h.번) }, + children: [ + td(`제${h.번}호표`), + td(h.이름), + td(h.규격), + td(h.단위), + td(won(h.금액.계), "m02-hopyo__num"), + td(`${h.맞춤[0]} / ${h.맞춤[1]}`, "m02-hopyo__num"), + ], + }); + tr.addEventListener("click", () => void drawOne(h.번)); + return tr; + }), + ); + }; + search.addEventListener("input", paint); + paint(); + const wrong = file.검산.어긋남; + view.replaceChildren( + el("div", { + className: "m02-hopyo__bar", + children: [ + el("strong", { text: file.파일 }), + note(`시트 ${file.시트}`), + note(`호표 ${file.호표수}`), + note(wrong.length ? `검산 어긋남 ${wrong.length}` : "검산 맞음"), + note(`맞춤 ${file.맞춤[0]} / ${file.맞춤[1]}`), + ...(opt.canWrite ? [dropButton(file)] : []), + ], + }), + ...(wrong.length ? [el("p", { className: "m02-hopyo__warn", text: wrong.join("\n") })] : []), + search, + grid(["호표", "품명", "규격", "단위", "엑셀 단가", "맞춤"], body), + ); + } + + const dropButton = (file: HopyoView): HTMLButtonElement => + createButton({ + label: "지우기", + variant: "danger", + size: "sm", + onClick: async () => { + const ask = `실무자료 「${file.파일}」을 지움 — 만든 사전조합 · 맞춤 기억은 그대로`; + if (!(await showConfirmDialog(ask, "지우기"))) return; + try { + await deleteHopyo(opt.scope, key); + showToast("지움", "success"); + opt.onDeleted(); + } catch (error) { + fail(error); + } + }, + }); + + /* ── 호표 하나 — 줄 맞춤 ────────────────────────────────────── */ + async function drawOne(no: number, built?: HopyoBuilt): Promise { + const got = await load( + () => readHopyoOne(opt.scope, key, no), + () => void drawOne(no), + ); + if (!got) return; + const h = got.호표; + const picked = new Map(); // 고른 대상(확정 전) + const back = createButton({ label: "← 호표 목록", variant: "ghost", size: "sm" }); + back.addEventListener("click", () => void drawFile()); + const rows = el("tbody"); + const paintRows = (): void => { + rows.replaceChildren(...h.줄.map((line) => lineRow(got, line, picked, paintRows))); + }; + paintRows(); + const same = h.줄.filter((x) => x.맞춤.상태 === "같음" && x.맞춤.환산?.배 != null); + const tools: HTMLElement[] = []; + if (opt.canWrite) { + const all = createButton({ label: `같음 모두 확정 (${same.length})`, size: "sm" }); + all.disabled = !same.length; + all.addEventListener("click", () => void confirmAll(no, same)); + const make = createButton({ label: "사전조합 만들기", variant: "filled", size: "sm" }); + make.addEventListener("click", () => void build(no, false)); + tools.push(all, make); + } + const missing = h.줄.filter((x) => !x.맞춤.들어감); + view.replaceChildren( + el("div", { + className: "m02-hopyo__bar", + children: [ + back, + el("strong", { text: `제${h.번}호표 ${h.이름}` }), + note(h.규격), + note(h.단위), + note(`엑셀 단가 ${won(h.금액.계)}`), + note(`맞춤 ${h.맞춤[0]} / ${h.맞춤[1]}`), + ...tools, + ], + }), + h.줄.length + ? grid( + [ + "갈래", + "품명", + "규격", + "수량", + "단위", + "엑셀 단가", + "맞춤", + "대상", + "입력값", + "환산", + "", + ], + rows, + "m02-hopyo__lines", + ) + : el("p", { className: "m02-hopyo__note", text: "구성 줄 없음" }), + ...(missing.length ? [missingBlock(missing)] : []), + ...(built ? [builtBlock(no, built)] : []), + ); + } + + function lineRow( + got: HopyoOne, + line: HopyoLine, + picked: Map, + repaint: () => void, + ): HTMLElement { + const m = line.맞춤; + const mine = picked.get(line.차례); + const target = mine?.대상 ?? m.대상; + const values = mine?.입력값 ?? m.입력값; + const conv = mine?.환산 ?? m.환산; + const fixed = ["%", "호표", "빼기"].includes(m.상태); + const times = el("input", { + className: "ui-input ui-input--sm m02-hopyo__times", + attrs: { + type: "number", + step: "any", + min: "0", + value: conv?.배 != null ? String(conv.배) : "", + }, + }) as HTMLInputElement; + times.disabled = !opt.canWrite || fixed || !target; + const state = mine ? "고름" : (STATE[m.상태] ?? m.상태); + const actions: HTMLElement[] = []; + if (opt.canWrite && m.상태 === "빼기") { + actions.push( + createButton({ + label: "되살림", + variant: "ghost", + size: "sm", + onClick: () => void remember(got.호표.번, { [m.열쇠]: null }), + }), + ); + } else if (opt.canWrite && !["%", "호표"].includes(m.상태)) { + actions.push( + createButton({ + label: "고르기", + variant: "ghost", + size: "sm", + onClick: () => + choose(line, (t) => + guessHopyo(opt.scope, key, got.호표.번, line.차례, t).then( + (g) => (picked.set(line.차례, g), repaint()), + fail, + ), + ), + }), + createButton({ + label: "확정", + size: "sm", + disabled: !target, + onClick: () => { + const body = memoryOf(target, values, times.value === "" ? null : Number(times.value)); + if (!body) return showToast("대상과 0 넘는 환산을 넣어야 확정", "error"); + void remember(got.호표.번, { [m.열쇠]: body }); + }, + }), + createButton({ + label: "빼기", + variant: "ghost", + size: "sm", + onClick: () => void remember(got.호표.번, { [m.열쇠]: { 빼기: true } }), + }), + ); + } + return el("tr", { + className: `m02-hopyo__line is-${m.들어감 ? "in" : "out"}`, + attrs: { "data-line": String(line.차례), "data-state": m.상태 }, + children: [ + td(line.갈래), + td(line.이름), + td(line.규격), + td(line.수량 === null ? "" : String(line.수량), "m02-hopyo__num"), + td(line.단위), + td(won(line.단가.계), "m02-hopyo__num"), + el("td", { + children: [el("span", { className: `m02-hopyo__state is-${m.상태}`, text: state })], + }), + td(targetText(target)), + td(inputsText(values, mine?.못읽음 ?? m.못읽음)), + el("td", { + children: [times, note(conv?.갈래 ? ` ${conv.갈래}` : "")], + }), + el("td", { className: "m02-hopyo__acts", children: actions }), + ], + }); + } + + /** 대상 고르기 — 후보 5 + 다른 로직 · 자재 찾기 */ + function choose(line: HopyoLine, done: (t: HopyoTarget) => void): void { + const material = MATERIAL.has(line.갈래); + openModal({ + title: `고르기 — ${line.이름} ${line.규격}`.trim(), + closeLabel: "닫기", + dialogClass: "m02-modal m02-hopyo__choose", + mount: (body, close) => { + const pick = (t: HopyoTarget): void => { + close(); + done(t); + }; + const list = (line.맞춤.후보 ?? []).map((c) => + createButton({ + label: `${targetText(c)} · ${c.로직 ?? c.자재 ?? ""}`, + variant: "ghost", + size: "sm", + onClick: () => pick(c), + }), + ); + const more = createButton({ + label: material ? "다른 자재 찾기…" : "다른 로직 찾기…", + size: "sm", + onClick: () => { + close(); + if (material) pickMaterial((x) => done({ 자재: x.코드 })); + else openLogicPicker((x) => done({ 로직: x.키 })); + }, + }); + body.append( + el("p", { className: "m02-hopyo__note", text: list.length ? "후보" : "후보 없음" }), + el("div", { className: "m02-hopyo__cands", children: list }), + more, + ); + }, + }); + } + + async function remember(no: number, 줄: Record): Promise { + try { + await saveHopyoMemory(opt.scope, 줄); + await drawOne(no); + } catch (error) { + fail(error); + } + } + + async function confirmAll(no: number, lines: HopyoLine[]): Promise { + const body: Record = {}; + for (const x of lines) { + const one = memoryOf(x.맞춤.대상, x.맞춤.입력값, x.맞춤.환산?.배 ?? null); + if (one) body[x.맞춤.열쇠] = one; + } + await remember(no, body); + } + + async function build(no: number, fresh: boolean): Promise { + try { + const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: key, 번: no }; + const got = await buildHopyo(opt.scope, from, fresh); + showToast( + got.빠진줄.length ? `만듦 — 빠진 줄 ${got.빠진줄.length}` : "만듦", + got.빠진줄.length ? "warning" : "success", + ); + await drawOne(no, got); + } catch (error) { + fail(error); + } + } + + const missingBlock = (lines: HopyoLine[]): HTMLElement => + el("div", { + className: "m02-hopyo__block", + children: [ + el("h4", { text: `빠진 줄 ${lines.length} — 사전조합에 안 들어감` }), + table( + ["품명", "규격", "수량", "단위", "엑셀 금액", "까닭"], + lines.map((x) => + el("tr", { + children: [ + td(x.이름), + td(x.규격), + td(x.수량 === null ? "" : String(x.수량), "m02-hopyo__num"), + td(x.단위), + td(won(x.금액.계), "m02-hopyo__num"), + td(STATE[x.맞춤.상태] ?? x.맞춤.상태), + ], + }), + ), + ), + ], + }); + + function builtBlock(no: number, got: HopyoBuilt): HTMLElement { + const c = got.비교; + const again = got.다시씀 + ? [ + note(`있던 사전조합 ${got.참조.키} 를 다시 씀`), + createButton({ + label: "새로 만들기", + variant: "danger", + size: "sm", + onClick: async () => { + const ask = `사전조합 ${got.참조.키} 를 지금 맞춤으로 다시 만들어 덮음`; + if (await showConfirmDialog(ask, "새로 만들기")) await build(no, true); + }, + }), + ] + : [note(`새 사전조합 ${got.참조.키}`)]; + return el("div", { + className: "m02-hopyo__block m02-hopyo__built", + attrs: { "data-built": got.참조.키 }, + children: [ + el("div", { className: "m02-hopyo__bar", children: again }), + note( + `값 비교 — 엑셀 ${won(c.엑셀)} · 우리 ${won(c.우리 === null ? null : Math.round(c.우리))}` + + ` · 차이 ${won(c.차이)}${c.까닭 ? ` · ${c.까닭}` : ""}`, + ), + ], + }); + } + + void drawFile(); + return view; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index f0b65b1a6..7664d8972 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -59,7 +59,7 @@ import { openLaborInput } from "./M02_MasterTemplete_Labor_Input"; import { openQtyModal } from "./M02_MasterTemplete_Labor_QtyModal"; import { openRefEdit } from "./M02_MasterTemplete_Labor_RefEdit"; import { afterRowDrag } from "./M02_MasterTemplete_Labor_RowDrag"; -import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; +import { openLogicPicker, withHopyo } from "./M02_MasterTemplete_Labor_Pick"; import { logicMatRows } from "./M02_MasterTemplete_Labor_LogicMats"; import { setLogicMark, setMark } from "./M02_MasterTemplete_Labor_MatMarks"; import { logicTable, matTable, qtyShort } from "./M02_MasterTemplete_Labor_Rows"; @@ -484,12 +484,12 @@ export function mountLaborDetail( : opt.layer === getActiveLayer() ? getActiveOwner() : null, - pick: (ref: LaborRef, name: string) => { + pick: (ref: LaborRef, name: string, 별칭: string | null = null) => { const id = lineId({ 참조: ref }); if (cur.담은로직.some((r) => lineId(r) === id)) return showToast(tc("Labor_AlreadyRef").replace("{value}", `${ref.키} ${name}`), "error"); const 수량 = lib ? { 손: 1 } : null; // 사전조합 줄 기본 1(51-6) - cur.담은로직.push({ 로직: "", 참조: ref, 별칭: null, 메모: null, 입력값: {}, 수량 }); + cur.담은로직.push({ 로직: "", 참조: ref, 별칭, 메모: null, 입력값: {}, 수량 }); changed(); }, }; @@ -507,7 +507,7 @@ export function mountLaborDetail( void loadLogics().then(() => changed()); }, // 사전조합도 같은 창 · 같은 목록(51-11 · 자기 자신 · 순환은 [저장] 때 서버 422) - library, + withHopyo(library), ), }); const addMat = addMaterialButton( diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts index 275b0520a..1ac24376e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts @@ -4,7 +4,8 @@ * 단가승격 · 일위조합 줄은 층 단추(위 층만 · 짝 `M02_Labor_Ref.readable`) 층의 서고 · 고르면 참조 {층 · 주인 · 키} · * 프로젝트 · 카드엔 서고 줄 없음 · 거름 = 로직 품셈 분류 + 사전조합 새 분류(149-2 · 구조물군 › 공통 + 종류) * 단가연결(251 · B08 거리이정표 · B09 설계내역서) = 종류 고르기(단가산출 · 사전조합 · 덧붙인 줄 종류) + - * 덧붙일 줄(구조물 조합 · B09 호표) — `lib.extra` · `lib.kindFilter` + * 덧붙일 줄(구조물 조합 · B09 호표) — `lib.extra` · `lib.kindFilter` · 늦게 받는 덧줄 `lib.more` + * (324-4 실무 호표 · `withHopyo`) * ========================================================================== */ import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements"; @@ -28,28 +29,35 @@ import { fetchStructureDef } from "./StructOptions/M02_SO_Api"; import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css"; // 창 겉틀 import "./M02_MasterTemplete_Labor.css"; +export { withHopyo } from "./M02_MasterTemplete_Hopyo_Pick"; + const opts = (values: string[]): { value: string; text: string }[] => [L("M01_All"), ...values].map((v, i) => ({ value: i ? v : "", text: v })); type Scope = Omit; +/** 덧붙일 줄 하나 — kind = 종류 고르기 값 */ +export interface PickExtra { + kind: string; + key: string; + tag: string; + name: string; + sub: string; + unit: string; + onPick: () => void; +} /** 서고 탭 — 일위대가가 있는 층 · 주인(null = 자기 층) · 고르면 참조 */ export interface LibraryPick { layer: Layer; owner: string | null; - pick: (ref: LaborRef, name: string) => void; + /** `alias` = 참조 줄 별칭(324-4 실무 호표 품명 · 없으면 빈칸) */ + pick: (ref: LaborRef, name: string, alias?: string | null) => void; /** 서고 줄만(로직 목록 안 받음 · B09 설계내역서 연결 204-2) · 창 제목 */ libOnly?: boolean; title?: string; /** 목록 끝 덧붙일 줄(251 · 구조물 조합 · B09 호표) — kind = 종류 고르기 값 */ - extra?: Array<{ - kind: string; - key: string; - tag: string; - name: string; - sub: string; - unit: string; - onPick: () => void; - }>; + extra?: PickExtra[]; + /** 창을 연 뒤 받는 덧줄(324-4 실무 호표) — 받으면 `extra` 뒤에 붙음 */ + more?: () => Promise; /** 종류 고르기 칸(251 · 값 logic · precombo · 덧붙인 줄 kind) */ kindFilter?: Array<{ value: string; text: string }>; } @@ -97,6 +105,7 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra const kindOk = (k: string): boolean => !kindSel?.select.value || kindSel.select.value === k; let loading = false; let seq = 0; + let later: PickExtra[] = []; // lib.more 로 받은 덧줄 /* 공용 찾기 목록 — 구분 · 상세구분 2열 · 이름 왼쪽 맞춤 · ↑ ↓ Enter */ const finder = createFindList({ placeholder: tc("Pick_Search"), @@ -120,13 +129,13 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra (!detail.select.value || x.row.상세구분 === detail.select.value) && (!q || `${x.row.이름} ${x.row.제목 ?? ""}`.toLowerCase().includes(q)), ); - const extra = (lib?.extra ?? []).filter( + const extra = [...(lib?.extra ?? []), ...later].filter( (x) => kindOk(x.kind) && !sub.select.value && (!q || `${x.tag} ${x.name} ${x.sub}`.toLowerCase().includes(q)), ); - const total = all.length + libRows.length + (lib?.extra?.length ?? 0); + const total = all.length + libRows.length + (lib?.extra?.length ?? 0) + later.length; const shown = found.length + libFound.length + extra.length; finder.hint.textContent = loading ? tc("Loading") @@ -271,6 +280,10 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra document.body.append(backdrop); finder.search.focus(); + void lib?.more?.().then( + (got) => ((later = got), find()), + (error) => showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"), + ); if (!lib?.libOnly) void Promise.all([fetchLogicSubs(), fetchLogics()]) .then(([s, l]) => { diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts index f0e73ad40..c6d835e7a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts @@ -1,7 +1,8 @@ /* ============================================================================= * M02_MasterTemplete_UI_Side.ts * 마스터 템플릿 좌측 패널 — 「구조물 조합」(층 단추 · 나무) · 「일위대가 사전조합」(층 단추 따로 · 2단 폴더 나무 · - * `UI_Side_Precombo` · 50-3 · 134-3) · 「도면 양식」(새로 · 본떠 · 지우기). + * `UI_Side_Precombo` · 50-3 · 134-3) · 「일위대가 실무자료」(구조물 조합 층 · `Hopyo_Side` · 324-4) · + * 「도면 양식」(새로 · 본떠 · 지우기). * 서버 길은 계약 `6_계약.md`. * 구조물 도면 · 일위대가 · 면적산출 상세는 좌측 줄이 아니라 항목 카드 [수정]으로(PLAN 45-2). * 화면 이름 「구조물 조합」(PLAN 26-7a) — 파일 이름 · 양식 키 · API 길은 「구조물집계표」 그대로. @@ -31,6 +32,7 @@ import { import { STRUCTURE_TABLE } from "./M02_MasterTemplete_Structure"; import { buildOptionsSection } from "./StructOptions/M02_SO_Options"; import { buildPracticeSection } from "./M02_MasterTemplete_Practice"; +import { buildHopyoSection } from "./M02_MasterTemplete_Hopyo_Side"; import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main"; import { buildPrecomboSection, @@ -165,11 +167,19 @@ export function buildSide(opt: SideOptions): SideHandle { onOpen: () => (options.clear(), paint()), }) : null; + // 일위대가 실무자료(324-4) — 사전조합 아래 · 층 = 구조물 조합 층 · 쓰기 권한 없으면 올리기 꺼짐 + const hopyo = buildHopyoSection({ + canWrite: canEdit(layer, opt.isAdmin), + confirmLeave: opt.confirmLeave, + show: opt.showView, + onOpen: () => (options.clear(), practice?.clear(), paint()), + }); const listHost = el("div", { className: "m02-side__lists ui-sidebar-panel", children: [ summary, precomboSec, + section(hopyo.title, hopyo.body), ...(extras ? [extras.옵션, extras.도면].filter((s): s is HTMLElement => !!s) : []), ...(practice ? [section(practice.title, practice.body)] : []), ], @@ -225,6 +235,7 @@ export function buildSide(opt: SideOptions): SideHandle { function paint(): void { paintList(); precombo.paint(); + hopyo.paint(); paintButtons(); if (opt.getOpen()) options.clear(); if (opt.getOpen()) practice?.clear(); diff --git a/resources/tester/helper_m02_hopyo_pick.cjs b/resources/tester/helper_m02_hopyo_pick.cjs new file mode 100644 index 000000000..0ab06e6e8 --- /dev/null +++ b/resources/tester/helper_m02_hopyo_pick.cjs @@ -0,0 +1,83 @@ +/* M02 일위대가 실무자료(324-4) 헬퍼 — `M02_MasterTemplete_Hopyo_Pick.ts`(고르기 창 「실무 호표」 덧줄) · + * `M02_MasterTemplete_Hopyo_View.ts` 글 도우미를 트랜스파일해 Node 로 돌림 · fetch 대역이 부른 길 · 몸을 모음 · + * stdin = {rows, built} · stdout = {calls, kinds, rows, picked, project, view} JSON. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const STUBS = { "@config/config_frontend": { API_BASE_URL: "/api" } }; +// 본 화면이 부르는 고르기 창 · 자재 창은 DOM · 모듈 메타를 씀 — 대역(글 도우미만 시험) +const STUB_FILES = ["M02_MasterTemplete_Labor_Pick", "M02_Material_Pick"]; +const stub = new Proxy({}, { get: (_t, k) => (k === "currentLanguageIndex" ? () => 0 : () => {}) }); +const cache = new Map(); +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + if (STUB_FILES.some((name) => request.endsWith(name))) return stub; + if (request.startsWith(".") && !request.endsWith(".css")) + return loadTs(`${path.resolve(path.dirname(full), request)}.ts`); + return stub; + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +(async () => { + const input = JSON.parse(fs.readFileSync(0, "utf8")); + const calls = []; + global.fetch = async (url, init = {}) => { + calls.push({ + url, + method: init.method ?? "GET", + body: init.body ? JSON.parse(init.body) : null, + }); + const body = url.includes("/hopyo-picks") ? { 줄: input.rows } : input.built; + return { status: 200, ok: true, json: async () => body }; + }; + const M02 = path.join(ROOT, "M02_MasterTemplete"); + const pick = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Pick.ts")); + const picked = []; + const lib = { + layer: "company", + owner: "7", + pick: (ref, name, alias) => picked.push({ ref, name, alias }), + }; + const wrapped = pick.withHopyo(lib); + const rows = (await wrapped.more()).map(({ onPick: _, ...r }) => r); + await pick.pickHopyo(input.rows[0], lib); + const project = pick.withHopyo({ ...lib, layer: "project" }); + const view = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_View.ts")); + const out = { + calls, + kinds: wrapped.kindFilter.map((k) => k.value), + rows, + picked, + project: project.more === undefined && project.kindFilter === undefined, + view: { + target: [ + view.targetText({ 로직: "GF000101", 이름: "구조물터파기", 단위: "㎥" }), + view.targetText({ 자재: "MT1", 이름: "레미콘", 규격: "25-21-120", 단위: "㎥" }), + view.targetText({ 호표: 46, 단위: "㎡" }), + ], + inputs: view.inputsText({ 횟수: "4회사용시", 지역: "서울" }, ["보정작업"]), + memory: [ + view.memoryOf({ 로직: "GF000101" }, { 지역: "서울" }, 1), + view.memoryOf({ 자재: "MT1" }, {}, 0.5), + view.memoryOf({ 로직: "GF000101" }, {}, 0), + view.memoryOf(null, {}, 1), + ], + }, + }; + process.stdout.write(JSON.stringify(out)); +})().catch((error) => { + process.stderr.write(String(error?.stack ?? error)); + process.exit(1); +}); diff --git a/resources/tester/test_m02_hopyo_match.py b/resources/tester/test_m02_hopyo_match.py index 2f1179e0c..38f3039b7 100644 --- a/resources/tester/test_m02_hopyo_match.py +++ b/resources/tester/test_m02_hopyo_match.py @@ -165,3 +165,23 @@ def test_확정_검사와_위_층_기억(client): assert client.post("/api/m02/layers/personal/hopyo-memory", json=mine).status_code == 200 assert _hopyo(client, 3)["구조물터파기(인력100%)"]["대상"]["로직"] == "GF000101" # 같은 층 먼저 assert hm.chain(layers.personal_dir(7, 42))[1] == layers.company_dir(7) + + +def test_로직_추가_고르기_줄은_이_층과_위_층(client): + client.who.update(role="SYSTEM_ADMIN") + assert _post(client, "system").status_code == 200 + client.who.update(role="USER") + assert _post(client).status_code == 200 + rows = client.get("/api/m02/layers/personal/hopyo-picks").json()["줄"] + assert [r["층"] for r in rows].count("personal") == 68 + assert [r["층"] for r in rows].count("system") == 68 + three = next(r for r in rows if r["층"] == "system" and r["번"] == 3) + assert {k: three[k] for k in ("주인", "키", "이름", "단위")} == { + "주인": None, + "키": "HP-000001", + "이름": "임도표지석", + "단위": "개소", + } + assert three["맞춤"][1] == 4 + system = client.get("/api/m02/layers/system/hopyo-picks").json()["줄"] + assert {r["층"] for r in system} == {"system"} # 마스터 = 마스터만 diff --git a/resources/tester/test_m02_hopyo_screen.py b/resources/tester/test_m02_hopyo_screen.py new file mode 100644 index 000000000..92fb610d9 --- /dev/null +++ b/resources/tester/test_m02_hopyo_screen.py @@ -0,0 +1,132 @@ +"""일위대가 실무자료 화면(PLAN 324-4) — 좌측 판 · 본 화면 · [로직 추가] 「실무 호표」 줄. + +- 고르기 창 덧줄(Node · fetch 대역): 이 층 · 위 층 호표 줄(맞춤 n/m) · 종류 고르기 · + 고르면 문서 층에 만들기 길 → `lib.pick` 참조 줄(별칭 = 호표 품명) · 프로젝트 층엔 없음 · + 본 화면 글 도우미 +- 소스: 좌측 판 자리(사전조합 아래 · 구조물 조합 층 · 쓰기 권한 없으면 올리기 꺼짐 · + 숨은 file 입력) · Labor_Detail 줄 수 그대로 · 새 파일 이름 · 700 줄 · 색 = 공용 토큰 +""" + +import json +import os +import re +import subprocess +from pathlib import Path + +HERE = os.path.dirname(os.path.abspath(__file__)) +M02 = Path(HERE).parents[1] / "M02_MasterTemplete" +NEW = ("Hopyo_Api", "Hopyo_Pick", "Hopyo_Side", "Hopyo_View") + + +def _text(name: str) -> str: + return (M02 / name).read_text(encoding="utf-8") + + +def _run() -> dict: + row = { + "층": "system", + "주인": None, + "키": "HP-000001", + "파일": "북부청.xls", + "번": 3, + "이름": "임도표지석", + "규격": "910×600×150", + "단위": "개소", + "맞춤": [2, 4], + } + built = { + "참조": {"층": "company", "주인": "7", "키": "SC-000009"}, + "이름": "임도표지석", + "다시씀": False, + "빠진줄": [{"차례": 0}], + "비교": {}, + } + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_m02_hopyo_pick.cjs")], + input=json.dumps({"rows": [row], "built": built}, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + return json.loads(proc.stdout) + + +def test_고르기_창_실무_호표_줄과_고름(): + got = _run() + assert got["calls"][0] == { + "url": "/api/m02/layers/company/hopyo-picks?owner=7", + "method": "GET", + "body": None, + } + assert got["kinds"] == ["logic", "precombo", "hopyo"] + assert got["rows"] == [ + { + "kind": "hopyo", + "key": "system||HP-000001|3", + "tag": "실무 호표", + "name": "임도표지석 910×600×150", + "sub": "북부청.xls · 제3호표 · 마스터", + "unit": "개소 · 맞춤 2/4", + } + ] + build = got["calls"][1] + assert build["url"] == "/api/m02/layers/company/hopyo-build?owner=7" # 만드는 층 = 문서 층 + assert build["body"] == { + "층": "system", + "주인": None, + "키": "HP-000001", + "번": 3, + "새로": False, + } + assert got["picked"] == [ + { + "ref": {"층": "company", "주인": "7", "키": "SC-000009"}, + "name": "임도표지석", + "alias": "임도표지석", + } + ] + assert got["project"] is True # 프로젝트 층 = 덧줄 없음 + + +def test_본_화면_글_도우미(): + view = _run()["view"] + assert view["target"] == ["구조물터파기 [㎥]", "레미콘 25-21-120 [㎥]", "제46호표 [㎡]"] + assert view["inputs"] == "횟수=4회사용시, 지역=서울 · ⚠ 못 읽음: 보정작업" + assert view["memory"] == [ + {"로직": "GF000101", "입력값": {"지역": "서울"}, "환산": 1}, + {"자재": "MT1", "입력값": {}, "환산": 0.5}, + None, # 환산 0 = 확정 못 함 + None, # 대상 없음 + ] + + +def test_좌측_판_자리와_권한(): + side = _text("M02_MasterTemplete_UI_Side.ts") + assert 'import { buildHopyoSection } from "./M02_MasterTemplete_Hopyo_Side";' in side + host = side.split('className: "m02-side__lists ui-sidebar-panel"')[1] + assert host.index("precomboSec,") < host.index("section(hopyo.title, hopyo.body),") + assert host.index("section(hopyo.title, hopyo.body),") < host.index("extras ?") + assert "canWrite: canEdit(layer, opt.isAdmin)," in side # 구조물 조합 층 쓰기 권한 + assert "hopyo.paint();" in side.split("function paint(): void {")[1] + names = set(re.findall(r"\b\w*[Pp]ractice\w*\b", side)) + assert names <= {"buildPracticeSection", "practice", "M02_MasterTemplete_Practice"} # 옛 이름만 + assert "LaborSection" not in side and "labor?." not in side and "picked" not in side + hopyo_side = _text("M02_MasterTemplete_Hopyo_Side.ts") + assert 'accept: ".xls,.xlsx"' in hopyo_side and "input.hidden = true;" in hopyo_side + assert "disabled: !opt.canWrite," in hopyo_side and "pageScope()" in hopyo_side + + +def test_로직_추가_한_곳과_파일_규칙(): + detail = _text("M02_MasterTemplete_Labor_Detail.ts") + assert len(detail.splitlines()) == 693 # 줄 수 그대로(제자리 고침) + assert "withHopyo(library)," in detail + assert 'cur.담은로직.push({ 로직: "", 참조: ref, 별칭, 메모: null' in detail + pick = _text("M02_MasterTemplete_Labor_Pick.ts") + assert "more?: () => Promise;" in pick and "...later" in pick + for name in NEW: + text = _text(f"M02_MasterTemplete_{name}.ts") + assert len(text.splitlines()) <= 700, name + css = _text("M02_MasterTemplete_Hopyo.css") + assert "var(--color-" in css and not re.search(r"#[0-9a-fA-F]{3,6}\b", css) + assert '"./M02_MasterTemplete_Hopyo.css"' in _text("M02_MasterTemplete_Hopyo_View.ts") From 9edaec82e3eb1df613f126eb0d18f32f5057d5e9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 14 Oct 2026 22:46:24 +0900 Subject: [PATCH 6/6] =?UTF-8?q?fix(=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80?= =?UTF-8?q?=EC=8B=A4=EB=AC=B4):=20324=20=EB=8B=A4=EB=93=AC=EA=B8=B0=20?= =?UTF-8?q?=E2=80=94=20=EC=A4=84=20=ED=91=9C=20=EC=A4=84=EB=B0=94=EA=BF=88?= =?UTF-8?q?=20=EC=97=86=EC=9D=8C=20=C2=B7=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20?= =?UTF-8?q?=EC=B0=BD=20[=EB=8B=AB=EA=B8=B0]=20=EC=95=88=20=EA=B0=88?= =?UTF-8?q?=EB=9D=BC=EC=A7=90=20=C2=B7=20=EB=9C=BB=20=EB=82=B1=EB=A7=90=20?= =?UTF-8?q?=EC=95=88=20=EA=B2=B9=EC=B9=98=EB=8A=94=20=ED=9B=84=EB=B3=B4=20?= =?UTF-8?q?=EC=88=A8=EA=B9=80=20=C2=B7=20=EB=8B=A4=EC=8B=9C=20=EC=94=80=20?= =?UTF-8?q?=C2=B7=20=EC=83=88=EB=A1=9C=20=EB=8D=AE=EC=9D=8C=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EA=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 줄 표: 갈래 · 수량 · 단위 · 엑셀 단가 · 맞춤 · 입력값 · 환산 · 단추 열 줄바꿈 없음(글 폭) · 입력값 길면 줄임표 · 칸 여백 좁힘 · 품명 · 규격 · 대상이 남는 폭 - 고르기 창 머리: [닫기] 단추 줄바꿈 없음 · 안 줄어듦 - 후보: 정리 이름 뜻 낱말(괄호 밖 두 글자 이상 덩어리 · 낱글자 붙임)이 하나도 안 겹치면 뺌(잔토처리 · 임도표지석 → 후보 없음) · 다 걸러지면 「가까운 것 없음」 + [다른 로직 · 자재 찾기] - 알림: 있던 사전조합 = 「다시 씀 — 빠진 줄 N」 · [새로 만들기] 뒤 = 「새로 만들어 덮음」(토스트 · 판 글) - 시험 hopyo 넷 42 통과 · typecheck 0 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- M02_MasterTemplete/M02_Hopyo_Match.py | 31 +++++++++++++++-- .../M02_MasterTemplete_Hopyo.css | 33 +++++++++++++++++++ .../M02_MasterTemplete_Hopyo_View.ts | 25 +++++++++----- resources/tester/helper_m02_hopyo_pick.cjs | 5 +++ resources/tester/test_m02_hopyo_match.py | 15 +++++++++ resources/tester/test_m02_hopyo_screen.py | 15 +++++++++ 6 files changed, 112 insertions(+), 12 deletions(-) diff --git a/M02_MasterTemplete/M02_Hopyo_Match.py b/M02_MasterTemplete/M02_Hopyo_Match.py index 3d66494f5..dc894c8a4 100644 --- a/M02_MasterTemplete/M02_Hopyo_Match.py +++ b/M02_MasterTemplete/M02_Hopyo_Match.py @@ -2,7 +2,8 @@ 차례 = ① 층 기억(같은 층 → 위 층 · 확정분) ② 이름 같음(정규화) · 씨앗(일위대가조합.json 메모 · 읽기만) -③ 정리 이름 같음(괄호 · 기계 낱말) ④ 후보 5(글자 두 개 묶음 점수 + 단위 맞음 + 산림품셈 덤) — +③ 정리 이름 같음(괄호 · 기계 낱말) ④ 후보 5(글자 두 개 묶음 점수 + 단위 맞음 + 산림품셈 덤 · +뜻 낱말이 하나도 안 겹치면 뺌 — 「잔토처리」 ↔ 「소금처리」) — ③ ④ 는 사용자 확정(층 기억) 뒤에만 들어감. 줄 갈래: 단가산출 · 기계 · 기타 = 로직 / 자재 · 별도계상 = 자재(M01 자재품목) / 호표 = 그 호표 / % = 안 들어감(로직 덧줄이 셈). @@ -80,6 +81,27 @@ def tidy(name: Any) -> str: return mm.plain(_PAREN.sub("", text)) +def words(name: Any) -> frozenset[str]: + """뜻 낱말 — 괄호 밖 한글 덩어리 · 영문 두 글자 넘는 것 + (낱글자 덩어리는 붙임 「면 목」 = 면목).""" + text = unicodedata.normalize("NFKC", str(name or "")).lower() + for old, new in MACH_WORDS: + text = text.replace(old, new) + out, single = [], "" + for piece in re.findall(r"[가-힣]+|[a-z]{2,}", _PAREN.sub(" ", text)): + if len(piece) == 1: + single += piece + continue + out += [single, piece] + single = "" + return frozenset(w for w in [*out, single] if len(w) > 1) + + +def near(mine: frozenset[str], theirs: frozenset[str]) -> bool: + """뜻 낱말이 하나라도 겹침(한쪽이 다른 쪽에 듦) — 내 낱말이 없으면 거르지 않음.""" + return not mine or any(a in b or b in a for a in mine for b in theirs) + + def _spec_text(text: Any) -> str: out = str(text or "") for old, new in MACH_WORDS: @@ -221,13 +243,15 @@ def _targets(whole_id: int) -> dict[str, Any]: "단위": str(row.get("단위") or ""), "plain": mm.plain(name), "spec": mm.plain_spec(row.get("규격")), - "tidy": tidy(re.sub(r"-(사급|관급)$", "", name.strip())), + "base": (base := re.sub(r"-(사급|관급)$", "", name.strip())), + "tidy": tidy(base), } ) names: dict[str, dict[str, list]] = {"로직": {}, "자재": {}} for pool, group in (("로직", logics), ("자재", mats)): for one in group: one["grams"] = _grams(one["tidy"]) + one["words"] = words(one["base"] if "base" in one else one["이름"]) names[pool].setdefault(one["plain"], []).append(one) return {"로직": logics, "자재": mats, "이름": names, "씨앗": _seeds()} @@ -278,8 +302,9 @@ def candidates( grams, unit = _grams(me), line.get("단위") text = mm.plain_spec(f"{line.get('이름')}{line.get('규격')}") scored, seen = [], set() + mine = words(line.get("이름")) for one in (got or targets())[pool]: - if not grams & one["grams"]: + if not grams & one["grams"] or not near(mine, one["words"]): continue score = 2 * len(grams & one["grams"]) / (len(grams) + len(one["grams"])) score += 1 if one["tidy"] == me else 0 diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css index 236b453c9..7a00414cc 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css @@ -49,6 +49,33 @@ font-variant-numeric: tabular-nums; } +/* 줄 표 — 갈래 · 수량 · 단위 · 엑셀 단가 · 맞춤 · 입력값 · 환산 · 단추 = 줄바꿈 없음(글자가 세로로 안 + 갈라짐 · 폭 = 글 폭) · 품명 · 규격 · 대상이 남는 폭 · 칸 여백 좁힘(공용 표보다 앞서게 두 갈래 이름) */ +.m02-hopyo__table.m02-hopyo__lines :is(th, td) { + padding: var(--spacing-8); +} + +.m02-hopyo__table.m02-hopyo__lines + :is(th, td):is( + :nth-child(1), + :nth-child(4), + :nth-child(5), + :nth-child(6), + :nth-child(7), + :nth-child(9), + :nth-child(10), + :nth-child(11) + ) { + white-space: nowrap; +} + +/* 입력값 — 길면 줄임표(전체 글은 칸 풍선 도움말) */ +.m02-hopyo__table.m02-hopyo__lines td:nth-child(9) { + max-width: 24em; + overflow: hidden; + text-overflow: ellipsis; +} + .m02-hopyo__line.is-out { color: var(--color-text-muted); } @@ -109,6 +136,12 @@ background: var(--color-surface-raised); } +/* 고르기 창 머리 — 제목이 두 줄이어도 [닫기] 글이 안 갈라지고 안 줄어듦 */ +.m02-hopyo__choose .ui-modal__top > .ui-btn { + flex-shrink: 0; + white-space: nowrap; +} + .m02-hopyo__cands { display: flex; flex-direction: column; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts index 4318fa45a..5601885cf 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts @@ -93,6 +93,10 @@ export const inputsText = (values: Record, unread: string[]): s .filter(Boolean) .join(" · "); +/** 만들기 결과 낱말 — [새로 만들기] = 「새로 만들어 덮음」 · 있던 것 = 「다시 씀」 · 그 밖 「만듦」 */ +export const builtWord = (got: Pick, fresh: boolean): string => + fresh ? "새로 만들어 덮음" : got.다시씀 ? "다시 씀" : "만듦"; + /** 확정 한 줄 몸 — 대상 · 입력값 · 환산(0 넘는 수) · 없으면 null */ export function memoryOf( target: HopyoTarget | null, @@ -200,7 +204,7 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement }); /* ── 호표 하나 — 줄 맞춤 ────────────────────────────────────── */ - async function drawOne(no: number, built?: HopyoBuilt): Promise { + async function drawOne(no: number, built?: HopyoBuilt & { 새로: boolean }): Promise { const got = await load( () => readHopyoOne(opt.scope, key, no), () => void drawOne(no), @@ -381,7 +385,7 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement }, }); body.append( - el("p", { className: "m02-hopyo__note", text: list.length ? "후보" : "후보 없음" }), + el("p", { className: "m02-hopyo__note", text: list.length ? "후보" : "가까운 것 없음" }), el("div", { className: "m02-hopyo__cands", children: list }), more, ); @@ -411,11 +415,10 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement try { const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: key, 번: no }; const got = await buildHopyo(opt.scope, from, fresh); - showToast( - got.빠진줄.length ? `만듦 — 빠진 줄 ${got.빠진줄.length}` : "만듦", - got.빠진줄.length ? "warning" : "success", - ); - await drawOne(no, got); + const word = builtWord(got, fresh); + const miss = got.빠진줄.length; + showToast(miss ? `${word} — 빠진 줄 ${miss}` : word, miss ? "warning" : "success"); + await drawOne(no, { ...got, 새로: fresh }); } catch (error) { fail(error); } @@ -444,11 +447,15 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement ], }); - function builtBlock(no: number, got: HopyoBuilt): HTMLElement { + function builtBlock(no: number, got: HopyoBuilt & { 새로: boolean }): HTMLElement { const c = got.비교; const again = got.다시씀 ? [ - note(`있던 사전조합 ${got.참조.키} 를 다시 씀`), + note( + got.새로 + ? `사전조합 ${got.참조.키} 를 새로 만들어 덮음` + : `있던 사전조합 ${got.참조.키} 를 다시 씀`, + ), createButton({ label: "새로 만들기", variant: "danger", diff --git a/resources/tester/helper_m02_hopyo_pick.cjs b/resources/tester/helper_m02_hopyo_pick.cjs index 0ab06e6e8..a061c282c 100644 --- a/resources/tester/helper_m02_hopyo_pick.cjs +++ b/resources/tester/helper_m02_hopyo_pick.cjs @@ -74,6 +74,11 @@ function loadTs(file) { view.memoryOf({ 로직: "GF000101" }, {}, 0), view.memoryOf(null, {}, 1), ], + word: [ + view.builtWord({ 다시씀: false }, false), + view.builtWord({ 다시씀: true }, false), + view.builtWord({ 다시씀: true }, true), + ], }, }; process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_m02_hopyo_match.py b/resources/tester/test_m02_hopyo_match.py index 38f3039b7..0f1224240 100644 --- a/resources/tester/test_m02_hopyo_match.py +++ b/resources/tester/test_m02_hopyo_match.py @@ -185,3 +185,18 @@ def test_로직_추가_고르기_줄은_이_층과_위_층(client): assert three["맞춤"][1] == 4 system = client.get("/api/m02/layers/system/hopyo-picks").json()["줄"] assert {r["층"] for r in system} == {"system"} # 마스터 = 마스터만 + + +def test_뜻_낱말이_안_겹치면_후보_아님(): + assert hm.words("면 목") == {"면목"} and hm.words("구조물되메우기(인력100%)") == { + "구조물되메우기" + } + assert hm.words("되메우기 및 다짐(소형장비)") == {"되메우기", "다짐"} + soil = {"이름": "잔토처리(인력100%)", "규격": "", "단위": "㎥", "갈래": "단가산출"} + assert hm.candidates(soil, "로직") == [] # 「처리」 두 글자만 겹침(소금처리 · 바탕처리) + stone = {"이름": "임도표지석(받침,글새김포함)", "규격": "", "단위": "㎥", "갈래": "자재"} + assert hm.candidates(stone, "자재") == [] # 속도표지판 · 도로표지용페인트 + got = hm.match_line(soil, [], {}) + assert got["상태"] == "없음" and got["후보"] == [] + fill = {"이름": "구조물되메우기(인력100%)", "규격": "", "단위": "㎥", "갈래": "단가산출"} + assert hm.candidates(fill, "로직")[0]["이름"] == "되메우기" # 한쪽 낱말에 듦 = 겹침 diff --git a/resources/tester/test_m02_hopyo_screen.py b/resources/tester/test_m02_hopyo_screen.py index 92fb610d9..46345114b 100644 --- a/resources/tester/test_m02_hopyo_screen.py +++ b/resources/tester/test_m02_hopyo_screen.py @@ -99,6 +99,21 @@ def test_본_화면_글_도우미(): None, # 환산 0 = 확정 못 함 None, # 대상 없음 ] + assert view["word"] == ["만듦", "다시 씀", "새로 만들어 덮음"] # 알림 · 판 글 + + +def test_줄_표_줄바꿈_없음과_고르기_창_머리(): + css = _text("M02_MasterTemplete_Hopyo.css") + rule = css.split(".m02-hopyo__table.m02-hopyo__lines\n :is(th, td):is(")[1].split("}")[0] + cols = [int(n) for n in re.findall(r":nth-child\((\d+)\)", rule)] + assert cols == [1, 4, 5, 6, 7, 9, 10, 11] and "white-space: nowrap;" in rule + heads = ["갈래", "품명", "규격", "수량", "단위", "엑셀 단가", "맞춤", "대상", "입력값", "환산"] + view = _text("M02_MasterTemplete_Hopyo_View.ts") + flat = re.sub(r"\s+", "", view) + assert re.sub(r"\s+", "", ",".join(f'"{h}"' for h in heads)) in flat # 열 차례 그대로 + top = css.split(".m02-hopyo__choose .ui-modal__top > .ui-btn {")[1].split("}")[0] + assert "flex-shrink: 0;" in top and "white-space: nowrap;" in top + assert '"가까운 것 없음"' in view and "builtWord(got, fresh)" in view def test_좌측_판_자리와_권한():