From bf0f01d7607d6c5e5e4b5914a50e860fc52a3582 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 16:54:39 +0900 Subject: [PATCH 1/5] =?UTF-8?q?chore(secrets):=20=EA=B3=B5=EA=B3=B5?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=ED=8F=AC=ED=84=B8=20=ED=82=A4=20.en?= =?UTF-8?q?v=20DATA=5FGO=5FKR=5FAPI=5FKEY=20=EB=A1=9C=20=EC=98=AE=EA=B9=80?= =?UTF-8?q?=20=C2=B7=20=EC=82=B0=EB=A6=BC=20=EC=A3=BC=EC=A0=9C=EB=8F=84=20?= =?UTF-8?q?=ED=8F=B4=EB=8D=94=20=EC=B6=94=EC=A0=81=20=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - read_g2b_key 가 .env 의 DATA_GO_KR_API_KEY 읽음(없으면 멈춤) - .secrets.local.md 키 값 줄 → 옮김 안내 한 줄 - .gitignore 에 resources/data_forest/ (산림청 공개 파일 원본) Co-Authored-By: Claude Opus 5.5 --- .env | 1 + .gitignore | 2 ++ resources/knowledge/.secrets.local.md | 2 +- .../knowledge/original/_pipeline/collect_cost_sources.py | 8 +++++++- 4 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.env b/.env index 509a5e6e5..4d9fecafd 100644 --- a/.env +++ b/.env @@ -90,3 +90,4 @@ AUTH_OTP_DISABLED=True NVIDIA_API_KEY=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 # 노선분석 산사태위험지도(행안부 생활안전지도 WMS IF_0046 · D03_PlanAnalysis_Landslide) 키 — 비우면 겹 꺼짐 SAFEMAP_API_KEY= +DATA_GO_KR_API_KEY=0j5WZHeNxSkdPVSLXUsx9kVikXEN%2BDc%2Byv68jz8IpKFffjbZkRiQrJO78y6rxPCTqhiSOW4lDxpid0twkSVQ9g%3D%3D diff --git a/.gitignore b/.gitignore index 41ea91f75..2a17e5483 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,8 @@ storage/ #resources/ # 전국 등고선 gpkg 22GB — 영구 추적 제외 (배포서버 구성 시 직접 배치) resources/data_global_contours/national_contours.gpkg +# 산림 주제도 원본(산림청 공개 파일) — 추적 제외 +resources/data_forest/ # 로그 파일 *.log diff --git a/resources/knowledge/.secrets.local.md b/resources/knowledge/.secrets.local.md index 0252971a9..34f892dac 100644 --- a/resources/knowledge/.secrets.local.md +++ b/resources/knowledge/.secrets.local.md @@ -21,7 +21,7 @@ ## 공공데이터포털 (data.go.kr) — 일반 인증키 - 용도: 조달청 나라장터 가격정보현황서비스 등 data.go.kr OpenAPI 전반 (API별 활용신청 필요) -- 인증키(Encoding형, URL에 그대로 사용): `0j5WZHeNxSkdPVSLXUsx9kVikXEN%2BDc%2Byv68jz8IpKFffjbZkRiQrJO78y6rxPCTqhiSOW4lDxpid0twkSVQ9g%3D%3D` +- 인증키(Encoding형) → .env DATA_GO_KR_API_KEY 로 옮김 - 발급: 2026-08-14, 계정 = data.go.kr 회원(사용자 개인). 만료 시 마이페이지 → 오픈API → 개발계정에서 갱신 - 검증(2026-08-14): 가격정보현황서비스 실호출 정상 (resultCode 00) - 엔드포인트: `http://apis.data.go.kr/1230000/ao/PriceInfoService/<오퍼레이션>?serviceKey=<키>&pageNo=1&numOfRows=999&type=json` diff --git a/resources/knowledge/original/_pipeline/collect_cost_sources.py b/resources/knowledge/original/_pipeline/collect_cost_sources.py index 866a52972..5ee8ae1dd 100644 --- a/resources/knowledge/original/_pipeline/collect_cost_sources.py +++ b/resources/knowledge/original/_pipeline/collect_cost_sources.py @@ -92,7 +92,13 @@ def read_key(pattern, name): def read_g2b_key(): - return read_key(r"인증키\(Encoding형[^`]*`([^`]+)`", "공공데이터포털") + from dotenv import load_dotenv + + load_dotenv(Path(__file__).resolve().parents[4] / ".env") + key = os.environ.get("DATA_GO_KR_API_KEY", "").strip() + if not key: + sys.exit("공공데이터포털 키 없음 — .env DATA_GO_KR_API_KEY") + return key def collect_docs(): From 3eda7f1d264aa39e9c4c0bdc915abb75c4971e1b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 16:58:24 +0900 Subject: [PATCH 2/5] =?UTF-8?q?chore(=EA=B3=84=ED=9A=8D):=20PLAN=20619=20?= =?UTF-8?q?=EC=9D=B4=EA=B4=80=20=E2=80=94=20=EA=B3=B5=EA=B3=B5=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=ED=8F=AC=ED=84=B8=20=ED=82=A4=20.env=20?= =?UTF-8?q?=C2=B7=20=EC=82=B0=EB=A6=BC=20=EC=A3=BC=EC=A0=9C=EB=8F=84=20?= =?UTF-8?q?=EA=B3=B5=EA=B0=9C=20=ED=8C=8C=EC=9D=BC=20=EB=B0=9B=EA=B8=B0=20?= =?UTF-8?q?(=EC=9A=94=EA=B5=AC=20514=20=C2=B7=20515)?= 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_015uTVHHDv583hbLQAFeBmd9 --- docs/raw/plans/2026-10-31_plan_619.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 docs/raw/plans/2026-10-31_plan_619.md diff --git a/docs/raw/plans/2026-10-31_plan_619.md b/docs/raw/plans/2026-10-31_plan_619.md new file mode 100644 index 000000000..b82c8fb1f --- /dev/null +++ b/docs/raw/plans/2026-10-31_plan_619.md @@ -0,0 +1,9 @@ +# PLAN 619 이관 — 공공데이터포털 키 .env · 산림 주제도 공개 파일 받기(요구 514 · 515) + +## 619 공공데이터포털 키 · 산림 주제도 파일 받기 (요구 514 · 515 · 끝) + +- 근거: `docs/raw/요구_1025/_조사_513_산림청_주제도_API.md` · 정함(사용자): 1:5,000 은 나중 공공기관 협업 때 정식 요청 · 지금은 공공데이터포털 「제한 없음」 1:25,000 급만. +- [x] 619-1 공공데이터포털 인증키 `.secrets.local.md` → `.env` `DATA_GO_KR_API_KEY` · `read_g2b_key` 가 `.env` 읽음 — 확인 = 조달청 API 한 번 정상 + - 검증완료 — `.env` 한 줄 덧 · `.secrets.local.md` 키 값 0 개(git grep) · `read_g2b_key` 가 `.env` 읽음 · 조달청 가격정보 API = 진짜 키는 관문 통과(가짜 키 403 · 빈 키 401) · 응답 = 조달청 Open API 시스템 점검 화면 → 결과 수 확인은 점검 끝난 뒤 한 번 더 (bf0f01d76) +- [x] 619-2 `resources/data_forest/` 에 임상도 1:25,000 · 산림입지토양도 1:25,000 · 임도망도 · 산사태위험지도 공개 받기(로그인 필요 = 적고 넘어감) — 확인 = 크기 · 좌표계 · 칸 · 도형 수 표 + - 검증완료 — 산림청 누리집 공개 내려받기(로그인 없음) 20 zip · 2.7 GB · 125 파일 · 산림입지토양도 1:25,000 전국 17 시도 zip 1,015 MB · Polygon 347,613 · 86 칸 · ITRF2000 TM 중부(5186 꼴) · 20200114 판 / 임상도 1:25,000 = 제주시 예시만 Polygon 4,695 · 5 칸(FIFTH_FRTP DMCLS_CD AGCLS_CD DNST_CD SMBL_CD) / 임도망도 「전체」 = 제주 두 시만 LineString 131 · 142 km · 6 칸 / 산사태위험지도 = 제주시 예시만 GeoTIFF 10 m 등급 1~5 · Korea 2000 Central Belt / 로그인 필요 = 임상도 · 임도망도 · 산사태 전국(산림공간정보서비스 신청) · 빅데이터플랫폼 FRT002601 = 접속 시간 초과 · 표 = `resources/data_forest/_목록.md` From f1e59f0e5bfb4aabeb52f602a4c27441c5ac1d38 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 17:01:51 +0900 Subject: [PATCH 3/5] =?UTF-8?q?knowledge(=EC=9A=94=EA=B5=AC):=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20516=20=EC=9B=90=EB=AC=B8=20=EA=B8=B0=EB=A1=9D=20?= =?UTF-8?q?=E2=80=94=20=EC=82=B0=EB=A6=BC=20=EC=9E=90=EB=A3=8C=20git=20?= =?UTF-8?q?=EC=97=90=20=EC=98=AC=EB=A6=BC=20=C2=B7=20=EA=B2=AC=EB=B3=B8=20?= =?UTF-8?q?=EB=AC=BC=EC=9D=8C=20=C2=B7=20=EA=B3=B5=EA=B3=B5=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=ED=8F=AC=ED=84=B8=20=EB=B0=9B=EA=B8=B0=20?= =?UTF-8?q?=EB=AC=BC=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015uTVHHDv583hbLQAFeBmd9 --- docs/raw/요구_1025/사용자요구_1025.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/raw/요구_1025/사용자요구_1025.md b/docs/raw/요구_1025/사용자요구_1025.md index 63996aa66..fa1ca6dff 100644 --- a/docs/raw/요구_1025/사용자요구_1025.md +++ b/docs/raw/요구_1025/사용자요구_1025.md @@ -1029,3 +1029,7 @@ CPU는 더늘리기는 힘들어보이고 GPU 사용을 위해 부품사용에 ## 요구 515 (10-31 · 사용자) 1. api키는 env파일로 옮겨둬. 2. 공공데이터 포털에서 데이터를 다운받는데 위치는 리소스 폴더가 맞을것같아. 적당히 폴더를 만들어서 넣어줘. 3. 지금 랩탑의 CPU 사용율이 89%로 웹앱에는 표기되는데 작업관리자로 확인해보니까 이용률이 19%밖에 안됨.... 왜 그런지 이유를 찾아봐. + +## 요구 516 (10-31 · 사용자) + +2.7gb정도는 깃티아에 올라감. 무리없음. 나만 단독으로 사용하는 레포임. 견본만받은건 뭐지? 그리고 공공데이터포털로 받기로 한거 아니야? From 4cc39a1e5642ccda7150d22547ef891c2a9ba85f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 17:07:54 +0900 Subject: [PATCH 4/5] auto: 2026-10-31 17:07 (ESD_LAPTOP) --- docs/raw/요구_1025/사용자요구_1025.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/raw/요구_1025/사용자요구_1025.md b/docs/raw/요구_1025/사용자요구_1025.md index fa1ca6dff..92e9ef3fb 100644 --- a/docs/raw/요구_1025/사용자요구_1025.md +++ b/docs/raw/요구_1025/사용자요구_1025.md @@ -1033,3 +1033,7 @@ CPU는 더늘리기는 힘들어보이고 GPU 사용을 위해 부품사용에 ## 요구 516 (10-31 · 사용자) 2.7gb정도는 깃티아에 올라감. 무리없음. 나만 단독으로 사용하는 레포임. 견본만받은건 뭐지? 그리고 공공데이터포털로 받기로 한거 아니야? + +## 요구 517 (10-31 · 사용자) + +그럼 제주만 받은 데이터는 삭제하고 다른곳을 찾아보자. From f37a521136f5781e44614183f03a312c856233d3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 17:29:24 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat(B09=20=EB=B6=88=EB=9F=AC=EC=98=A4?= =?UTF-8?q?=EA=B8=B0):=20.STC(STmate=20=EC=9B=90=EB=B3=B8)=20=EB=B0=94?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=BD=EA=B8=B0=20=E2=80=94=20=EB=82=B4=EC=97=AD?= =?UTF-8?q?=20BOQ11=20=C2=B7=20=EB=8B=A8=EA=B0=80=20COSTN=20=C2=B7=20?= =?UTF-8?q?=ED=98=B8=ED=91=9C=20=EB=AA=A9=EB=A1=9D=20=C2=B7=20=EB=B6=88?= =?UTF-8?q?=EB=9F=AC=EC=98=A4=EA=B8=B0=20=EA=B8=B8=20=C2=B7=20=EC=B0=BD=20?= =?UTF-8?q?*.STC=20(PLAN=20707-6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - B09_Estimation_Stc — DBF 필드 이름으로 읽음 · 묶음 T · 소계 E · 제외 C · 호표 번 = 같은 글자 코드 정렬 차례 - 다공구(BOQ12) · 변경 차수 = 당초만 읽고 알림 · BDQTY · COSTN.SMT · 요율 = UNKNOWN(같은 공사 엑셀로) - Router_Import 이름 끝 갈림(.estx · .stc) · 미리 보기 알림 · UI_Import 파일 고르기 *.stc · 창 알림 줄 - 시험 test_707_6_stc 16 — 실무 STC 일곱 직접비 = PRJLST · 기번3 · 영월 · 봉화 엑셀과 줄마다 같음 Co-Authored-By: Claude Opus 5.5 --- .../B09_Estimation_Router_Import.py | 33 ++- B09_Estimation/B09_Estimation_Stc.py | 274 ++++++++++++++++++ B09_Estimation/B09_Estimation_UI_Import.ts | 13 +- resources/tester/test_707_6_stc.py | 174 +++++++++++ 4 files changed, 477 insertions(+), 17 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_Stc.py create mode 100644 resources/tester/test_707_6_stc.py diff --git a/B09_Estimation/B09_Estimation_Router_Import.py b/B09_Estimation/B09_Estimation_Router_Import.py index e2868fde1..fafbea2d6 100644 --- a/B09_Estimation/B09_Estimation_Router_Import.py +++ b/B09_Estimation/B09_Estimation_Router_Import.py @@ -18,9 +18,10 @@ {file_key, name, sheets, counts, linked, missing, patch{labor_cover, machine_cover, materials}} file_key = 세 길 같은 표지(`_keep` · `_saved`) — 같은 파일을 다시 올리지 않음 · - .estx(EST 원본 · 707-5 `B09_Estimation_Estx`) = 이름 끝으로 갈림 — 내역 줄 · 단가표 같은 그릇 · - 요율 = 400 「같은 공사 엑셀에서」 · - 미리 보기 `호표` = 대가 · 산근 목록 수 + 코드로 가리킨 줄(이음 후보만) + .estx(EST 원본 · 707-5 `B09_Estimation_Estx`) · .STC(STmate 원본 · 707-6 `B09_Estimation_Stc`) = + 이름 끝으로 갈림 — 내역 줄 · 단가표 같은 그릇 · 요율 = 400 「같은 공사 엑셀에서」 · + 미리 보기 `호표` = 대가 · 산근 목록 수 + 코드로 가리킨 줄(이음 후보만) · + `알림` = 안 읽은 몫(다공구 · 변경 차수) 화면은 `patch` 를 캐시에 얹고 [저장] 때 서버가 정본으로 다시 셈. `B09_Estimation_Router` 가 끼워 넣음(같은 머리 · 같은 권한). """ @@ -44,6 +45,7 @@ from B09_Estimation import B09_Estimation_Estx as estx from B09_Estimation import B09_Estimation_Import_Prices as import_prices from B09_Estimation import B09_Estimation_Import_Rates as import_rates from B09_Estimation import B09_Estimation_Prices as prices +from B09_Estimation import B09_Estimation_Stc as stc from B09_Estimation import B09_Estimation_Store as store from B09_Estimation.B09_Estimation_Bill_Excel import BillExcelError, read_bill from B09_Estimation.B09_Estimation_Calc_Base import compute_base @@ -86,22 +88,26 @@ def _keep(root: Path, name: str, data: bytes) -> str: return key -def _is_estx(name: str) -> bool: - return name.lower().endswith(".estx") +_RAW = {".estx": estx, ".stc": stc} + + +def _raw(name: str) -> Any: + """원본 파일 읽기 모듈(.estx · .STC) — 엑셀이면 None.""" + return _RAW.get(Path(name).suffix.lower()) def _parse(name: str, data: bytes) -> dict[str, Any]: try: - return (estx.read_bill if _is_estx(name) else read_bill)(name, data) + return (raw.read_bill if (raw := _raw(name)) else read_bill)(name, data) except BillExcelError as error: raise HTTPException(status_code=400, detail=str(error)) from error def _hopyo(name: str, data: bytes, parsed: dict[str, Any]) -> dict[str, int] | None: - """.estx 호표 목록 수 · 내역 줄이 코드로 가리킨 수(이음 후보 — 구성 줄은 `estx.UNKNOWN`).""" - if not _is_estx(name): + """원본 호표 목록 수 · 내역 줄이 코드로 가리킨 수(이음 후보 — 구성 줄은 `UNKNOWN`).""" + if not (raw := _raw(name)): return None - got = estx.read_hopyo(name, data) + got = raw.read_hopyo(name, data) codes = {(k, x["코드"]) for k, v in got.items() for x in v} hits = [h for line in parsed["줄"] if (h := line.get("호표"))] return {**{k: len(v) for k, v in got.items()}, @@ -189,6 +195,7 @@ async def import_bill( **importer.preview(parsed, rows), "이음": len(found), "호표": hopyo, + "알림": parsed.get("알림") or [], } @@ -256,8 +263,10 @@ async def import_rate_sheet( root = await _project_root(project_id) key, name, data = await _file(file, file_key, root) draft = _draft(user) - if _is_estx(name): - raise HTTPException(400, "원가계산서 요율은 .estx 에서 못 읽음 — 같은 공사 엑셀에서 불러옴") + if _raw(name): + raise HTTPException( + 400, f"원가계산서 요율은 {Path(name).suffix} 에서 못 읽음 — 같은 공사 엑셀에서 불러옴" + ) mine = (draft if draft is not None else await asyncio.to_thread(store.read_user, root))[ "estimate" ] @@ -286,7 +295,7 @@ async def import_price_sheets( draft = _draft(user) mine = draft if draft is not None else await asyncio.to_thread(store.read_user, root) try: - reader = estx.read_prices if _is_estx(name) else import_prices.read_prices + reader = raw.read_prices if (raw := _raw(name)) else import_prices.read_prices read = await asyncio.to_thread(reader, name, data) except (ExcelError, BillExcelError) as error: raise HTTPException(400, str(error)) from error diff --git a/B09_Estimation/B09_Estimation_Stc.py b/B09_Estimation/B09_Estimation_Stc.py new file mode 100644 index 000000000..7f19a83b4 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Stc.py @@ -0,0 +1,274 @@ +"""B09 STmate 원본(.STC) 바로 읽기(PLAN 707-6) — 메모리에서 풀어 평문 표만 · 저장 안 함. + +.STC = 보통 ZIP · 표마다 dBase/FoxPro DBF(cp949 · 이름 대소문자 섞임) +(지식DB `original/원가계산/STmate/STC_컨테이너_구조.md` · `STC_필드사전_핵심.md`): + 머리 4 레코드 수 · 8 머리 길이 · 10 레코드 길이 · 32 부터 필드 설명자 32 바이트씩(0x0D 끝) · + 필드 = 이름으로만 찾음(위치는 판마다 다름) · 레코드 첫 바이트 `*` = 지운 줄. + 접미 1 = 당초 · 2~4 = 변경 차수 — 당초만 읽음. + +돌려주는 꼴 = 엑셀 · .estx 읽기와 같은 dict: + `read_bill` = `B09_Estimation_Bill_Excel.read_bill`(707-1) — BOQ11 + BDCODE1 T1~T5 = 묶음(클수록 바깥) · E1~E5 = 소계 · 합계(그 깊이 묶음 닫음 · 안 읽음) · + C1~C5 = 제외금액 합계(닫는 묶음 = 제외 · 관급 등) · `:` = 페이지 넘김 · 이름 없음 = 빈 줄 · + 단위 끝 `%` = 율 줄(제외 안 「수수료」 = 조달수수료율 · 그 밖 = 1 식 · 단가 = 금액) · + 호표 = 코드 B 대가 · D 산근 · M 자재 · 번 = COSTN 같은 글자 코드 정렬 차례 + (STmate 엑셀 비고 「대가 5호표」 · 「자재 28」 과 같음 — 봉화 · 기번3 실측) · + `알림` = 다공구(BOQ12 ~) · 변경 차수 줄 수(안 읽음) + `read_prices` = `B09_Estimation_Import_Prices.read_prices`(707-3) — COSTN + L 노임 · M 자재(+ 출처 다섯 칸 AMT_11~51 · 쪽 PG_11~51 · 이름 = Start8x.Ini JMOK1~5) · + X 기계(시간당 노 · 재 · 경 · 단위 「X」 → 시간) + `read_hopyo` = 호표 목록(COSTN B 대가 · D 산근 — 코드 · 번 · 이름 · 단가 셋) +안 읽음(같은 공사 STmate 엑셀 「일위대가표」 로 메움) = `UNKNOWN`. +""" + +from __future__ import annotations + +import io +import struct +import zipfile +from decimal import Decimal, InvalidOperation +from typing import Any + +from B09_Estimation.B09_Estimation_Bill_Excel import BillExcelError, _plain, _unwrap + +UNKNOWN = ( + "일위대가 구성 수량(BDQTY — 난독)", + "단가산출 전개 글(COSTN.SMT — 메모 자리 규칙 미확정)", + "원가계산서 요율(PRJLST 는 금액만)", +) +#: 가격자료 다섯 칸 이름 — Start8x.Ini JMOK1~5 가 없을 때 +SOURCES = ("조달가격", "물가자료", "물가정보", "거래가격", "견적단가") +HOPYO = {"B": "대가", "D": "산근", "M": "자재"} +PART_KEYS = ("labor", "material", "expense") + + +class StcError(BillExcelError): + """못 읽는 .STC — 길이 400 으로 답함.""" + + +def _zip(data: bytes) -> tuple[zipfile.ZipFile, dict[str, str]]: + if data[:4] != b"PK\x03\x04": + raise StcError(".STC 가 아님 — ZIP 머리 없음") + try: + book = zipfile.ZipFile(io.BytesIO(data)) + except zipfile.BadZipFile as error: + raise StcError("깨진 .STC") from error + if any(i.flag_bits & 1 for i in book.infolist()): + raise StcError("암호 걸린 .STC — 읽을 수 없음") + return book, {n.upper(): n for n in book.namelist()} + + +def table(book: tuple[zipfile.ZipFile, dict[str, str]], name: str) -> list[dict[str, Any]]: + """`{name}.DBF` → 레코드 목록(파일 차례 · 지운 줄 뺌) — C 글 · N 수(Decimal) · L 참 거짓.""" + zf, names = book + if f"{name}.DBF" not in names: + raise StcError(f".STC 안에 {name} 표가 없음") + d = zf.read(names[f"{name}.DBF"]) + count, start, size = struct.unpack_from(" len(d): + raise StcError(f"{name} 표 길이가 머리와 다름") + out = [] + for r in range(count): + base = start + r * size + if d[base : base + 1] == b"*": + continue + row: dict[str, Any] = {} + for field, kind, off, width in cols: + raw = d[base + off : base + off + width] + if kind == "C": + row[field] = raw.decode("cp949", "replace").strip() + elif kind in "NF": + try: + row[field] = Decimal(raw.decode("ascii").strip() or 0) + except (InvalidOperation, UnicodeDecodeError): + row[field] = Decimal(0) + elif kind == "L": + row[field] = raw in (b"T", b"t", b"Y", b"y") + out.append(row) + return out + + +def _parts(row: dict[str, Any], money: str) -> dict[str, Decimal]: + """money = 'AMT1'(단가) · '_AMT1'(금액) → {labor, material, expense}.""" + return {k: row.get(f"{p}{money}") or Decimal(0) for p, k in zip("LMS", PART_KEYS)} + + +def _numbers(costn: list[dict[str, Any]]) -> dict[str, int]: + """대가 · 산근 · 자재 코드 → 번(같은 글자 코드 정렬 차례).""" + out = {} + for head in HOPYO: + for n, code in enumerate(sorted({r["CODE"] for r in costn if r["CODE"][:1] == head}), 1): + out[code] = n + return out + + +def _changes(rows: list[dict[str, Any]]) -> int: + """변경 차수(2~4) 금액이 찬 줄 수 — 수량 칸만 베낀 당초 판은 0.""" + return sum(any(r.get(f"T_AMT{k}") for k in "234") for r in rows) + + +def read_bill(name: str, data: bytes) -> dict[str, Any]: + """.STC → 엑셀 `read_bill` 과 같은 꼴(`행` = BOQ11 차례 1~).""" + book = _zip(data) + rows = table(book, "BOQ11") + numbers = _numbers(table(book, "COSTN")) + lines: list[dict[str, Any]] = [] + stack: list[tuple[int, dict[str, Any]]] = [] # (깊이, 묶음 줄) + skipped = 0 + for at, row in enumerate(rows, 1): + code, item_name = row.get("BDCODE1") or "", row.get("NAME1") or "" + kind = code[:1] if len(code) == 2 and code[1:].isdigit() else "" + if kind in ("E", "C"): # 소계 · 합계 — 그 깊이 묶음 닫음 · C = 제외 + skipped += 1 + hit = next( + (n for n in range(len(stack) - 1, -1, -1) if stack[n][0] == int(code[1])), None + ) + if hit is not None: + stack[hit][1]["제외"] = stack[hit][1]["제외"] or kind == "C" + del stack[hit:] + continue + if not item_name or code == ":": + continue + group = kind == "T" + if group: + while stack and stack[-1][0] <= int(code[1]): + stack.pop() + up = stack[-1][1] if stack else None + hopyo = None + if not group and code[:1] in HOPYO: + hopyo = {"종류": HOPYO[code[0]], "코드": code} + if code in numbers: + hopyo["번"] = numbers[code] + line = { + "행": at, + "묶음": group, + "위": up["행"] if up else None, + "공종": (row.get("ITNUM") or "") if group else "", + "이름": item_name, + "규격": row.get("SIZE1") or "", + "단위": row.get("UNIT1") or "", + "수량": None if group else row.get("QTY1") or Decimal(0), + "단가": _parts(row, "AMT1"), + "금액": _parts(row, "_AMT1"), + "비고": "", + "호표": hopyo, + "제외": False, + } + if group: + stack.append((int(code[1]), line)) + lines.append(line) + by_row = {x["행"]: x for x in lines} + out, fee, pct_lines = [], None, 0 + for line in lines: # 부모가 앞 — 제외 물려받음 · 맨 위 「관급」 + up = by_row.get(line["위"]) + line["제외"] = line["제외"] or (up["제외"] if up else "관급" in line["이름"]) + if not line["묶음"] and line["단위"].endswith("%"): + if line["제외"] and "수수료" in line["이름"]: + fee = fee if fee is not None else line["수량"] + continue + pct_lines += 1 + qty = _plain(line["수량"]) + line.update({"단가": line["금액"], "수량": Decimal(1), "단위": "식"}) + line["규격"] = " ".join(t for t in (line["규격"], f"({qty} %)") if t) + out.append(line) + _unwrap(out) + total = {"직접": Decimal(0), "제외": Decimal(0)} + for line in out: + if not line["묶음"]: + total["제외" if line["제외"] else "직접"] += sum(line["금액"].values()) + notes = [ + f"다공구 — {t[:-4]} {len(table(book, t[:-4]))} 줄 있음(당초 첫 공구 BOQ11 만 읽음)" + for t in sorted(book[1]) + if t.startswith("BOQ1") and t.endswith(".DBF") and t != "BOQ11.DBF" + ] + if changed := _changes(rows): + notes.append(f"변경 차수 — {changed} 줄 있음(당초만 읽음)") + return _plain( + { + "꼴": "stc", + "시트": "BOQ11", + "줄": out, + "합계": total, + "조달수수료율": fee, + "합계줄": skipped, + "율줄": pct_lines, + "알림": notes, + } + ) + + +def _item(row: dict[str, Any]) -> dict[str, Any]: + return {"name": row["NAME"], "spec": row.get("SIZE") or "", "unit": row.get("UNIT") or ""} + + +def _titles(book: tuple[zipfile.ZipFile, dict[str, str]]) -> tuple[str, ...]: + zf, names = book + if "START8X.INI" not in names: + return SOURCES + text = zf.read(names["START8X.INI"]).decode("cp949", "replace") + got = dict(line.split("=", 1) for line in text.splitlines() if "=" in line) + return tuple(got.get(f"JMOK{n}", "").strip() or SOURCES[n - 1] for n in range(1, 6)) + + +def read_prices(name: str, data: bytes) -> dict[str, Any]: + """.STC → 707-3 `read_prices` 꼴 {labor, material, machine, *_sheet} · + 자재 줄 + `sources` [{출처, 단가, 쪽}](값 있는 칸만).""" + book = _zip(data) + titles = _titles(book) + costn = [ + r for r in table(book, "COSTN") if r.get("NAME") and not (r.get("UNIT") or "").endswith("%") + ] + labor = [{**_item(r), "price": r["TAMT1"]} for r in costn if r["CODE"][:1] == "L"] + material = [ + { + **_item(r), + "price": r["TAMT1"], + "sources": [ + {"출처": titles[n - 1], "단가": r.get(f"AMT_{n}1"), "쪽": r.get(f"PG_{n}1") or ""} + for n in range(1, 6) + if r.get(f"AMT_{n}1") + ], + } + for r in costn + if r["CODE"][:1] == "M" + ] + machine = [ + {**_item(r), "unit": "시간", **_parts(r, "AMT1")} for r in costn if r["CODE"][:1] == "X" + ] + return _plain( + { + "labor": labor, + "labor_sheet": "COSTN L", + "material": material, + "material_sheet": "COSTN M", + "machine": machine, + "machine_sheet": "COSTN X", + } + ) + + +def read_hopyo(name: str, data: bytes) -> dict[str, list[dict[str, Any]]]: + """.STC → {대가: COSTN B, 산근: COSTN D} 목록 + [{코드, 번, 이름, 규격, 단위, 단가 {labor, material, expense}, 합계}] — 구성 줄은 `UNKNOWN`.""" + costn = table(_zip(data), "COSTN") + numbers = _numbers(costn) + out: dict[str, list[dict[str, Any]]] = {"대가": [], "산근": []} + for r in sorted(costn, key=lambda r: r["CODE"]): + if r["CODE"][:1] in "BD" and r.get("NAME"): + out[HOPYO[r["CODE"][0]]].append( + { + "코드": r["CODE"], + "번": numbers[r["CODE"]], + "이름": r["NAME"], + "규격": r.get("SIZE") or "", + "단위": r.get("UNIT") or "", + "단가": _parts(r, "AMT1"), + "합계": r.get("TAMT1") or Decimal(0), + } + ) + return _plain(out) diff --git a/B09_Estimation/B09_Estimation_UI_Import.ts b/B09_Estimation/B09_Estimation_UI_Import.ts index 24b46fcaa..fcdd3445b 100644 --- a/B09_Estimation/B09_Estimation_UI_Import.ts +++ b/B09_Estimation/B09_Estimation_UI_Import.ts @@ -17,6 +17,7 @@ * * EST 원본(.estx · PLAN 707-5) = 같은 단추 · 같은 창 — 내역 줄 + [단가표도 같이](받은 patch 를 캐시에 함께) · * 요율 = 서버 400 「같은 공사 엑셀에서」 안내 · 호표 목록 = 수만(이음 후보 · 구성 줄 못 읽음) + * STmate 원본(.STC · PLAN 707-6) = .estx 와 같은 자리 · 안 읽은 몫(다공구 · 변경 차수) = 창 알림 줄 * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; @@ -81,9 +82,10 @@ export function createBillImport( ): HTMLButtonElement { const open = (seen: Row, mode: { value: string }): void => { const total = seen.합계 || {}; + const raw = ({ estx: ".estx", stc: ".STC" } as Record)[seen.꼴]; const lines = [ `${seen.파일} · ${seen.꼴} 「${seen.시트}」 · 공종 ${seen.묶음} · 항목 ${seen.항목}`, - `${seen.꼴 === "estx" ? ".estx" : "엑셀"} 직접비 합 ${won(total.직접)} 원 · 제외(관급 등) ${won(total.제외)} 원` + + `${raw ?? "엑셀"} 직접비 합 ${won(total.직접)} 원 · 제외(관급 등) ${won(total.제외)} 원` + (seen.조달수수료율 != null ? ` · 조달수수료율 ${seen.조달수수료율} %` : ""), seen.이음 ? `같은 파일로 들여온 일위대가 ${seen.이음} 호표 — 그 줄은 일위대가에 이음` @@ -91,9 +93,10 @@ export function createBillImport( ...(seen.호표 ? [ `호표 목록 대가 ${seen.호표.대가} · 산근 ${seen.호표.산근} — 내역 줄 ${seen.호표.가리킴} 개가 코드로 가리킴(이음 후보 · 구성 줄은 못 읽음)`, - "원가계산서 요율은 .estx 에서 못 읽음 — 같은 공사 엑셀에서(총괄 · 원가계산서 탭)", + `원가계산서 요율은 ${raw} 에서 못 읽음 — 같은 공사 엑셀에서(총괄 · 원가계산서 탭)`, ] : []), + ...((seen.알림 as string[] | undefined) ?? []), ]; const take = createCheckbox("넘겨받기 — 불러온 수량을 씀(수량 산출 값은 곁에)", { radio: "b09-import-mode", @@ -192,7 +195,7 @@ export function createBillImport( label: "엑셀 내역 불러오기", variant: "ghost", onClick: () => - pickFiles({ accept: ".xls,.xlsx,.estx" }, ([file]) => { + pickFiles({ accept: ".xls,.xlsx,.estx,.stc" }, ([file]) => { const form = new FormData(); form.append("file", file); form.append("user", JSON.stringify(user())); @@ -357,7 +360,7 @@ const fail = (error: unknown): void => showToast(`불러오지 못함 — ${Stri function pick(kind: Kind, ctx: TabContext): void { pickFiles( - { accept: ".xlsx,.xls,.estx" }, + { accept: ".xlsx,.xls,.estx,.stc" }, ([file]) => void postSheet(kind, ctx.user, file).then((got) => openResult(kind, ctx, got), fail), ); @@ -376,7 +379,7 @@ export function importTools(tab: TabId, ctx: TabContext): HTMLElement[] { ? void postSheet(kind, ctx.user, null).then( (got) => openResult(kind, ctx, got), (error: unknown) => { - fail(error); // 지난 파일이 .estx(요율) · 없음 = 까닭 알리고 다시 고름 + fail(error); // 지난 파일이 .estx · .STC(요율) · 없음 = 까닭 알리고 다시 고름 pick(kind, ctx); }, ) diff --git a/resources/tester/test_707_6_stc.py b/resources/tester/test_707_6_stc.py new file mode 100644 index 000000000..a27ed41d7 --- /dev/null +++ b/resources/tester/test_707_6_stc.py @@ -0,0 +1,174 @@ +"""707-6 STmate 원본(.STC) 바로 읽기 — `B09_Estimation_Stc` (읽기만 · 저장 안 함). + +- 실무 .STC 일곱(지식DB 실무문서) = 항목 수 · 직접비 · 제외 · 노임 · 자재 · 기계 · 호표 목록 줄 수 · + 직접비 = 파일 안 공사 금액(PRJLST TCOST1) +- 기번3 · 영월 · 봉화 = 같은 공사 STmate 엑셀(707-1 · 707-3)과 줄마다 같음 + (이름 · 규격 · 수량 · 단가 · 금액 · 호표 코드 · 번 · 제외 · 묶음 나무 · 단가표) +- 다공구(BOQ12) · 변경 차수 = 당초만 읽고 알림 · 707-1 짜기 그릇에 그대로 부음 +- 깨진 파일 · 표 없음 = 막힘 · 불러오기 길 = 이름 끝 .STC 갈림 +""" + +import io +import sys +import time +import zipfile +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B09_Estimation import B09_Estimation_Bill_Import as imp # noqa: E402 +from B09_Estimation import B09_Estimation_Import_Prices as ip # noqa: E402 +from B09_Estimation import B09_Estimation_Stc as st # noqa: E402 +from B09_Estimation import B09_Estimation_Store as store # noqa: E402 +from B09_Estimation.B09_Estimation_Bill_Excel import read_bill as read_excel # noqa: E402 +from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation # noqa: E402 + +K = ROOT / "resources" / "knowledge" / "original" / "실무문서" +G3 = "2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/01 내역자료/2024년 간선임도사업(기번3)" +YW = "05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월" +BH = "04.산불 봉화/01. 내역서/{}2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km" +SG = "25년 산불진화임도(기번8)(울진소광)/" +NEW = "2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외)" +FILES = { + "기번3": K / f"{G3}.STC", + "영월": K / f"{YW}.STC", + "봉화": K / f"{BH.format('13-')}.STC", + "계류보전": K / "20250527 2025년 계류보전사업(기번1-영덕.병곡.영리.산214-1)(변경)/01.내역자료/2025년 계류보전사업(기번1 영덕.병곡.영.산214)(변경).STC", # noqa: E501 + "소광": K / f"{SG}1. 설계원본/03-내역/22-2025년 산불진화임도사업(울진 금강송 소광).STC", + "소광다공구": K / f"{SG}{NEW}.STC", + "소광변경": K / f"{SG}변경 1차분-{NEW}.STC", +} # fmt: skip +EXCEL = {"기번3": K / f"{G3}.xlsx", "영월": K / f"{YW}.xlsx", "봉화": K / f"{BH.format('')}.xlsx"} +#: (항목 수, 직접비, 제외, 노임, 자재, 기계, 대가, 산근) +WANT = { + "기번3": (136, 710359136, 69474220, 12, 39, 25, 34, 46), + "영월": (108, 767148700, 75263810, 16, 55, 29, 23, 69), + "봉화": (77, 781283415, 142783280, 19, 67, 25, 45, 65), + "계류보전": (98, 93027484, 0, 7, 25, 18, 13, 35), + "소광": (46, 197135038, 53316160, 14, 35, 24, 25, 40), + "소광다공구": (46, 197135038, 53316160, 14, 41, 24, 37, 62), + "소광변경": (70, 173311386, 53316160, 14, 40, 31, 39, 54), +} +NOTES = { + "계류보전": ["변경 차수 — 96 줄 있음(당초만 읽음)"], + "소광다공구": ["다공구 — BOQ12 89 줄 있음(당초 첫 공구 BOQ11 만 읽음)"], + "소광변경": ["변경 차수 — 93 줄 있음(당초만 읽음)"], +} +EMPTY = {"materials": [], "bill": {"items": []}, "hopyo": {}} + + +@pytest.fixture(scope="module") +def data(): + return {k: p.read_bytes() for k, p in FILES.items()} + + +@pytest.mark.parametrize("name", list(FILES)) +def test_실무_stc_줄_수_합계_공사금액(data, name): + t = time.perf_counter() + bill = st.read_bill(FILES[name].name, data[name]) + prices = st.read_prices(FILES[name].name, data[name]) + hopyo = st.read_hopyo(FILES[name].name, data[name]) + print(f"{name} 읽기 {time.perf_counter() - t:.3f} s") + got = ( + sum(1 for x in bill["줄"] if not x["묶음"]), + bill["합계"]["직접"], + bill["합계"]["제외"], + *(len(prices[k]) for k in ("labor", "material", "machine")), + len(hopyo["대가"]), + len(hopyo["산근"]), + ) + assert got == WANT[name] + assert bill["알림"] == NOTES.get(name, []) + # 직접비 = 파일 안 공사 금액(첫 공구) + prj = st.table(st._zip(data[name]), "PRJLST") + assert bill["합계"]["직접"] == prj[0]["TCOST1"] + assert all(r["price"] > 0 for r in prices["labor"]) + assert all(h["합계"] == sum(h["단가"].values()) for h in hopyo["대가"] + hopyo["산근"]) + + +@pytest.mark.parametrize("name", list(EXCEL)) +def test_엑셀과_줄마다_같음(data, name): + mine = st.read_bill("a.STC", data[name]) + excel = read_excel(EXCEL[name].name, EXCEL[name].read_bytes()) + + def key(line, numbered): + h = line["호표"] or {} + number = h.get("번") if numbered.get(line["행"]) else None + return (line["이름"], line["규격"], line["단위"], line["수량"], line["단가"], + line["금액"], h.get("코드"), number, line["제외"]) # fmt: skip + + # 번 = 엑셀 비고에 있을 때만 견줌(영월 · 봉화 자재 = 비고에 번 없음) + has = [bool((x["호표"] or {}).get("번")) for x in excel["줄"]] + assert len(mine["줄"]) == len(has) + numbered = [{x["행"]: n for x, n in zip(got["줄"], has)} for got in (mine, excel)] + items = [[key(x, n) for x in got["줄"]] for got, n in zip((mine, excel), numbered)] + assert items[0] == items[1] + + def tree(got): + names = {x["행"]: x["이름"] for x in got["줄"]} + return [(x["이름"], names.get(x["위"])) for x in got["줄"]] + + assert tree(mine) == tree(excel) + assert mine["합계"] == excel["합계"] + assert mine["조달수수료율"] == excel["조달수수료율"] == 0.54 + + +@pytest.mark.parametrize("name", ["기번3", "봉화"]) +def test_단가표_엑셀과_같음(data, name): + mine = st.read_prices("a.STC", data[name]) + excel = ip.read_prices(EXCEL[name].name, EXCEL[name].read_bytes()) + for kind in ("labor", "material", "machine"): + keys = ("name", "spec", "unit", "price", "labor", "material", "expense") + rows = [sorted(tuple(r.get(k) for k in keys) for r in got[kind]) for got in (mine, excel)] + assert rows[0] == rows[1], kind + + +def test_자재_출처_칸(data): + stuff = st.read_prices("a.STC", data["봉화"])["material"] + pipe = next(x for x in stuff if x["name"] == "PVC파이프") + assert pipe["price"] == 3475 + assert {"출처": "거래가격", "단가": 3475, "쪽": "793"} in pipe["sources"] # 엑셀 자재단가대비표 + assert [s["출처"] for s in pipe["sources"]] == ["물가자료", "물가정보", "거래가격"] + + +def test_707_1_그릇에_부음(data): + got = st.read_bill("a.STC", data["봉화"]) + rows = compute_estimation(EMPTY, {}, None, {})["bill"]["rows"] + made, report = imp.apply(got, {}, rows, "abcd1234") + made = store.clean_user(made) + bill = compute_estimation(EMPTY, made, None, {})["bill"] + assert report["실음"] == len(got["줄"]) + assert bill["totals"]["direct"]["sum"] == 781283415 + assert bill["totals"]["direct"]["blank"] == 0 + assert imp.differences(got, report, bill["rows"]) == [] + assert made["estimate"]["procurement_fee_pct"] == 0.54 + + +def test_깨진_파일_표_없음_막힘(): + with pytest.raises(st.StcError): + st.read_bill("a.STC", b"not a zip") + with pytest.raises(st.StcError): + st.read_bill("a.STC", b"PK\x03\x04 broken") + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as z: + z.writestr("COSTN.DBF", b"") + with pytest.raises(st.StcError, match="BOQ11"): + st.read_bill("a.STC", buf.getvalue()) + + +def test_불러오기_길_stc_갈림(data): + """이름 끝 .STC = 원본 읽기 · 호표 = 목록 수 + 코드로 가리킨 줄(이음 후보).""" + from B09_Estimation import B09_Estimation_Router_Import as route + + name = "봉화.stc" + parsed = route._parse(name, data["봉화"]) + assert parsed["꼴"] == "stc" and parsed["합계"]["직접"] == 781283415 + assert route._hopyo(name, data["봉화"], parsed) == {"대가": 45, "산근": 65, "가리킴": 61} + assert route._hopyo("a.xlsx", data["봉화"], parsed) is None + with pytest.raises(route.HTTPException) as got: + route._parse("a.STC", b"not a zip") + assert got.value.status_code == 400