diff --git a/docs/raw/요구_1104/_과녁_773_자재표기.tsv b/docs/raw/요구_1104/_과녁_773_자재표기.tsv new file mode 100644 index 000000000..bf88925c1 --- /dev/null +++ b/docs/raw/요구_1104/_과녁_773_자재표기.tsv @@ -0,0 +1,101 @@ +행 가져온 이름 가져온 규격 쓰임 기대 우리 키 우리 이름 우리 규격 확신 +1 접착제 11 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음 +2 패널(유로폼) 600*1200mm 10 이음 MT000467 유로폼패널 600×1200㎜ 표기만 다름 +3 각재 외송 8 이음 MT000393 각 재(외송) 30×30×3600mm 눈으로 추정 +4 요소 8 이음 MT006763 요 소 N:46% 눈으로 추정 +5 내부코너패널(유로폼) (100x200)x1200mm 7 이음 MT000470 내부코너패널 100×200×1200㎜ 표기만 다름 +6 결속선(철선) #20 , 0.9M/M 7 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음 +7 각재 (외송) 6 이음 MT000393 각 재(외송) 30×30×3600mm 눈으로 추정 +8 파형강관 별도계상 6 잇지 않음 별도계상 +9 금강사 5 잇지 않음 우리 표에 없음 +10 고무테이프 0.5mm×19mm×10m 5 이음 MT009202 절연 면고무테이프 0.5mm×19mm×10m 눈으로 추정 +11 낙석방지책 H2500×W2000(표준) 5 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음 +12 낙석방지책 H2500×W2000(단부) 5 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음 +13 종자 초본류 5 후보 1 순위 MT006646 종 자 초본위주형 좁힘(자동 이음 아님) +14 종자 목본류 5 후보 1 순위 MT006648 종 자 목본군락형 좁힘(자동 이음 아님) +15 어닐링철선(결속선) #20 0.9mm 200.4m/kg 5 이음 MT000209 어닐링철선(결속선),서울 #20, 0.9mm 표기만 다름 +16 내수합판1급 12mmx1220x2440 4 이음 MT000018 합판(내수), 서울 12t×1220×2440mm 표기만 다름 +17 어닐링철선(결속선) #20 0.9M/M 4 이음 MT000209 어닐링철선(결속선),서울 #20, 0.9mm 표기만 다름 +18 유로폼(패널) 600*1200 4 이음 MT000467 유로폼패널 600×1200㎜ 표기만 다름 +19 내부코너패널 (200+200)*1200 4 이음 MT000472 내부코너패널 (200+200)×1200㎜ 표기만 다름 +20 합판(내수) 12×1220×2440 4 이음 MT000018 합판(내수), 서울 12t×1220×2440mm 표기만 다름 +21 조임철물 (직교자재) 4 이음 MT000499 조임철물,서울 직교,자재 표기만 다름 +22 에폭시수지 YD-128 4 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음 +23 종자 생태복원형,초본류 3 후보 1 순위 MT006646 종 자 초본위주형 좁힘(자동 이음 아님) +24 종자 생태복원형,목본류 3 후보 1 순위 MT006648 종 자 목본군락형 좁힘(자동 이음 아님) +25 내부코너패널(유로폼) (100+200)*1200mm 3 이음 MT000470 내부코너패널 100×200×1200㎜ 표기만 다름 +26 유로폼(패널) 600×1200 3 이음 MT000467 유로폼패널 600×1200㎜ 표기만 다름 +27 콘테이너하우스 창고용(3.0m×6.0m×2.6m) 3 이음 MT007111 콘테이너(창고) 6.0*3.0*2.6m 표기만 다름 +28 전기뇌관(MS) 3.5m×500개(하이데토) 2 이음 MT005677 전기뇌관 3.5m 눈으로 추정 +29 BUTTON BIT Ø38×76㎜ 2 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음 +30 유압드릴샹크 T38×CM470 2 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음 +31 유압식드릴스리브 T38×190mm 2 이음 MT005687 유압드릴스리브 T38×190mm 표기만 다름 +32 미송(판재) 2 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음 +33 산림종자 참싸리(박피) 2 이음 MT006664 산림종자 참싸리(풍화암,강마사토) 눈으로 추정 +34 유기질퇴비 2 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음 +35 PVC지수판 200x5T KSM3805 2 이음 MT006544 PVC지수판 200×5T 표기만 다름 +36 PVC용접봉 Ø3.2 (국산) 2 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음 +37 PVC파이프(일반용VG2) 50㎜ × 4m 2 이음 MT002926 PVC파이프(일반관),서울 D=50mm(VG2) 표기만 다름 +38 혼합골재 영월 2 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음 +39 이형철근 별도계상 2 잇지 않음 별도계상 +40 안전표지판 삼각,D=900 2 이음 MT003202 교통안전표지판 900,삼각(고휘도) 눈으로 추정 +41 표지판지주 D=76.3mm, 3.5M 2 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음 +42 블레이드(커터) "d=320 400mm 14""xt3.2" 2 이음 MT005749 블레이드 "14""×3.2mm" 표기만 다름 +43 떼 2 잇지 않음 우리 표에 없음 +44 강관비계 φ48.6m/m×2.3mm 2 이음 MT000481 강관비계,서울 Φ48.6*2.3*4.0m 표기만 다름 +45 철물 2 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음 +46 각재(말뚝) Φ60 2 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음 +47 강관써포트 V4 2600-4000mm 14.3kg 2 이음 MT000487 강관동바리,서울 Φ48.6*2.4,L=2.6~4.0 눈으로 추정 +48 안고초 2 이음 MT006667 산림종자 안고초 표기만 다름 +49 비수리 2 이음 MT006665 산림종자 비수리 표기만 다름 +50 내수합판1급 12m/m 1220×2440 2 이음 MT000018 합판(내수), 서울 12t×1220×2440mm 표기만 다름 +51 각재(외송) 3.6×3.0×3.0 2 이음 MT000393 각 재(외송) 30×30×3600mm 눈으로 추정 +52 내수합판1급 12mm 1220×2440 2 이음 MT000018 합판(내수), 서울 12t×1220×2440mm 표기만 다름 +53 각재(거푸집용,외송) 3.6×3.0×3.0 2 이음 MT000393 각 재(외송) 30×30×3600mm 눈으로 추정 +54 다이아몬드톱날 "Ø14""×3.2t(콘크리트용)" 2 이음 MT005749 블레이드 "14""×3.2mm" 눈으로 추정 +55 아세틸렌 용접용 2 이음 MT003163 아세틸렌 98% (용접용) 표기만 다름 +56 산소 고압가스 2 이음 MT003166 산 소 고압상태 눈으로 추정 +57 신나 KSM6060 2종(조합페인트류) 2 이음 MT003398 신너(조합페인트용) KSM-6060(2종) 표기만 다름 +58 초류종자(국내산) 참싸리,비수리,낭아초등 1 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음 +59 종 자 초본형 1 후보 1 순위 MT006646 종 자 초본위주형 좁힘(자동 이음 아님) +60 소나무우드칩(멀칭재) 1 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음 +61 3단코어비트 "5""(125mm)" 1 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음 +62 P V C PIPE Φ50M/M,VG1 1 이음 MT002861 PVC파이프(일반관),서울 D=50mm(VG1) 표기만 다름 +63 콘테이너(창 고) 3×6×2.6 1 이음 MT007111 콘테이너(창고) 6.0*3.0*2.6m 표기만 다름 +64 강관비계 48.6mm×2.3mm×6m 1 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음 +65 철물 직교자재 1 이음 MT000499 조임철물,서울 직교,자재 눈으로 추정 +66 문양거푸집(ABS코팅품) 600×1200 1 이음 MT000453 문양거푸집(플라스틱 ABS코팅폼) 600×1200 표기만 다름 +67 와이어매쉬 #6 100×100 1 이음 MT007130 와이어메쉬(용접철망)관급 #6, 100×100 표기만 다름 +68 지수판 T5×200 1 이음 MT006544 PVC지수판 200×5T 표기만 다름 +69 용접봉(연강용) 3.2m/m 1 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음 +70 어닐링철선 (결속선) #6 4.0mm 10.1m/kg 1 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음 +71 구역화물자동차(4.5Ton) 80km이내 1 이음 MT023493 구역화물 4.5톤, 80km 표기만 다름 +72 구역화물자동차(4.5Ton) 60km이내 1 이음 MT023491 구역화물 4.5톤, 60km 표기만 다름 +73 산소(6000ℓ) 99%,120kg/㎠ 1 이음 MT003165 산 소 6000ℓ/병 표기만 다름 +74 아세칠렌 AC 98%,용접용 1 이음 MT003163 아세틸렌 98% (용접용) 표기만 다름 +75 서비스볼트(고장력볼트) M20×60 1 이음 MT002536 고장력볼트 M20*60mm 표기만 다름 +76 보조강재(열연강판) T=9-12 1 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음 +77 구역화물자동차(8Ton) 10km이내 1 이음 MT023654 구역화물 8.0톤, 10km 표기만 다름 +78 구역화물자동차(8Ton) 20km이내 1 이음 MT023655 구역화물 8.0톤, 20km 표기만 다름 +79 참싸리(박피) 1 이음 MT006664 산림종자 참싸리(풍화암,강마사토) 눈으로 추정 +80 부산물비료 20kg/포 1 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음 +81 참싸리 박피 1 이음 MT006664 산림종자 참싸리(풍화암,강마사토) 눈으로 추정 +82 복합비료 21-17-17 20kg/포 1 이음 MT006490 복합비료 21-17-17 표기만 다름 +83 퇴비(천연부엽토) 20㎏/포 1 이음 MT006788 천연부엽토 20kg/포 눈으로 추정 +84 문양거푸집(ABS코팅폼) 600×1200×20~30mm 1 이음 MT000453 문양거푸집(플라스틱 ABS코팅폼) 600×1200 표기만 다름 +85 P.V.C PIPE VG1(50mm) 1 이음 MT002861 PVC파이프(일반관),서울 D=50mm(VG1) 표기만 다름 +86 다웰바(DOWEL BAR) Ø32×1,000 1 이음 MT006540 다 웰 바 Φ32*1000 표기만 다름 +87 P.V.C PIPE VG1(35mm) 1 이음 MT002859 PVC파이프(일반관),서울 D=35mm(VG1) 표기만 다름 +88 휠라 T=15mm,1000×1000mm 1 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음 +89 측구수로관 300×300×2000×120,앵글포함 1 이음 MT015883 측구수로관(앵글부착) 300×300×120 표기만 다름 +90 중하중그레이팅 395×995×52 1 이음 MT007025 스틸그레이팅(중하중용) 395×995×52 표기만 다름 +91 측구수로관 500×500×2000×150,앵글포함 1 이음 MT015887 측구수로관(앵글부착) 500×500×150 표기만 다름 +92 중하중그레이팅 595×995×67 1 이음 MT007032 스틸그레이팅(중하중용) 595×995×67 표기만 다름 +93 잔디 0.18×0.18×0.01 1 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음 +94 강관비계 4000×48.6mm×2.3mm 1 이음 MT000481 강관비계,서울 Φ48.6*2.3*4.0m 표기만 다름 +95 삼각표지판 삼각,900mm 1 이음 MT003202 교통안전표지판 900,삼각(고휘도) 눈으로 추정 +96 표지판지주 D=76.3mm×3.0m 1 이음 MT003241 표지판지주 Φ76.3×3000 표기만 다름 +97 공사안내표지판 300×1500 1 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음 +98 공사안전PE드럼 500×800 1 이음 MT003311 PE드럼(일체형) Φ500×800 표기만 다름 +99 윙카호스 10cm 1 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음 +100 조합페인트 KSM6020 2급,백색 1 이음 MT003372 조합페인트 KSM-6020 2급(백색) 표기만 다름 diff --git a/docs/raw/요구_1104/_방향_불러오기.md b/docs/raw/요구_1104/_방향_불러오기.md index 2ca3a689c..c0ffd5d3c 100644 --- a/docs/raw/요구_1104/_방향_불러오기.md +++ b/docs/raw/요구_1104/_방향_불러오기.md @@ -160,3 +160,36 @@ - M2 고침(안): 가름의 잣대 = 금액(노 · 재 · 경 · 계)보다 **구성 줄(손료계수 · 주연료량 · 조종원 — 품셈 값)** — 원파일은 구성 표 · 엑셀은 「기계경비」 시트(산출근거 줄)에 있음 · 가격 · 유가는 값이라 잣대에서 뺌. - M1 = PLAN 816-1 로 진행(요구 770-1). + +## O. 구글 시트 「수동연결(자재)」 · 「수동연결(기계)」 검토(요구 772 · 시트 = AISLO_이슈관리대장 · 읽기 = 드라이브 커넥터로 xlsx 받아 `tmp/q772_sheet.xlsx`) + +기계 30 줄(사용자 메모 대조): + +| 갈래 | 줄 | 내용 | +|---|---|---| +| 이미 이어짐(목록이 옛 것 — 815-2 별칭 다섯 합치기 전에 뜬 목록) | 4 | 바이브레이트 → EQ000248 · 버킷준설기 → EQ000425 · 아스팔트페이버(피니셔) 3.0m → EQ000193 · 1.7m → EQ000192 | +| 816 에서 처리 중 | 8 | 덤프트럭 1.0Ton · 굴착기 1.0 · 0.6㎥ · 굴삭기(1.0m3) 둘 · 크레인(트럭) 10 · 15Ton · 엔진톱 18" | +| 메모 글 검색으로 바로 나옴(별칭만 넣으면 됨) | 3 | 덤프자동덮개 15톤 → EQ000078 덤프트럭 자동덮개시설 15톤용 · 24톤 → EQ000080 · 트럭트랙터및트레일러 20톤 → EQ000183 트럭 트랙터 및 평판트레일러 20ton | +| 확인 필요 | 2 | 커터(콘크리트 및 아스팔트용) 320-440mm ↔ EQ000234 320~400(규격 범위 다름) · 크로울러드릴 탑승유압식 ↔ EQ000337 110㎾ / EQ000338 150㎾(가져온 쪽 규격 없음) | +| 우리 표 · 현행 품셈에 없음 | 13 | 발전기 5.5 · 7 · 10kW · 브레이카 0.18㎥ · 모노펌프 · 에어 호스 · 파워트로웰 · 콘크리트펌프용 파이프 여섯(EST 는 기계 손료로 둠) | + +- 크레인(트럭) 10 · 15Ton: 사용자 메모 = 트럭탑재형 크레인 · EST 구성 줄 = 크레인(타이어)와 같은 값(2,298 · 136,400 천원 · 3.8ℓ — 트럭탑재형 10ton 은 2,598 · 91,593 천원 · 10.3ℓ) → 사용자 판단 대기. + +자재 100 줄(브레인이 한 줄씩 읽음 · 사용자 메모 칸은 비어 있음): + +- 우리 줄 하나로 또렷(표기만 다름 — 별칭만 넣으면 됨) 65 줄: 2 · 18 · 26 → MT000467 / 5 · 25 → MT000470 / 19 → MT000472 / 15 · 17 → MT000209 / 16 · 50 · 52 · 20 → MT000018 / 21 · 65 → MT000499 / 3 · 7 · 51 · 53 → MT000393 / 10 → MT009202 / 27 · 63 → MT007111 / 28 → MT005677 / 31 → MT005687 / 35 · 68 → MT006544 / 37 → MT002926 / 62 · 85 → MT002861 / 87 → MT002859 / 40 · 95 → MT003202 / 96 → MT003241 / 42 · 54 → MT005749 / 44 · 94 → MT000481 / 47 → MT000487 / 55 · 74 → MT003163 / 73 → MT003165 / 56 → MT003166 / 66 · 84 → MT000453 / 67 → MT007130 / 71 → MT023493 / 72 → MT023491 / 77 → MT023654 / 78 → MT023655 / 75 → MT002536 / 82 → MT006490 / 86 → MT006540 / 89 → MT015883 / 91 → MT015887 / 90 → MT007025 / 92 → MT007032 / 98 → MT003311 / 100 → MT003372 / 57 → MT003398 / 4 → MT006763 / 49 → MT006665 / 48 → MT006667 / 33 · 79 · 81 → MT006664 / 83 → MT006788 (번호 = 시트 행 차례 · 머리 뺀 1 ~ 100) +- 사람이 골라야 함(우리 줄 여럿 · 규격이 조금 다름) 31 줄: 1 접착제 · 6 결속선(보통철선 대 어닐링철선) · 13 · 14 · 23 · 24 · 58 · 59 종자 · 32 미송(판재) · 45 철물 · 70 어닐링철선 #6 · 36 PVC용접봉 · 69 용접봉(연강용) · 29 BUTTON BIT · 41 표지판지주 3.5M · 64 강관비계 6m · 93 잔디 · 22 에폭시수지 · 46 각재(말뚝) · 76 보조강재 · 97 공사안내표지판 · 99 윙카호스 · 11 · 12 낙석방지책 · 38 혼합골재 · 61 코어비트 · 30 샹크 · 34 유기질퇴비 · 60 우드칩 · 80 부산물비료 · 88 휠라 +- 잇지 않음 · 없음 4 줄: 8 파형강관 · 39 이형철근(「별도계상」 = 자리만) · 9 금강사 · 43 떼 +- 못 이은 까닭 = 정확 일치만 잇는 규칙(이름 낱말 차례 「유로폼(패널)」 ↔ 「유로폼패널」 · 「내수합판1급」 ↔ 「합판(내수)」 · 규격 표기 12mm ↔ 12t · 차례 뒤집힘 T5×200 ↔ 200×5T · 덧글 KSM · 국산 · 지역 꼬리) · 가까운 줄 제안이 틀린 것도 있음(신나 → 신나무 · 중하중그레이팅 → 다른 치수). +- 구글 시트 직접 쓰기 토큰 만료(invalid_grant) — 사용자가 데스크탑에 있어 랩탑 로그인 창을 못 누름 · 읽기는 드라이브 커넥터로 됨. + +## P. 요구 773 ~ 776 결정 + +| # | 결정 | 상태 | +|---|---|---| +| P1 | 기계 가름은 값 비교가 아니라 로직 — 우리 기계표 값과 맞대지 않고 **같은 파일 안 단독 기계와 대조**해 그 이름으로 찾음(요구 773-3) | 816-2 진행 | +| P2 | 기계 별칭 = 브레인이 제안 → 승인 길로 넣음(요구 776): 덤프자동덮개 15톤 → EQ000078 · 24톤 → EQ000080 · 트럭트랙터및트레일러 20톤 → EQ000183 · 커터(콘크리트 및 아스팔트용) 320-440mm → EQ000234 · 크로울러드릴 탑승유압식 → EQ000337(110㎾) · 크레인(트럭) 10Ton → EQ000128 · 15Ton → EQ000129 | 넣음 · 찾기 확인 | +| P3 | 엔진톱 18" → 체인톱 EQ000620 별칭 = 인치 따옴표 풀이(816-3) 합친 뒤 넣음 | 대기(816 합친 뒤) | +| P4 | 재료 찾기 엔진 개선 — 표기만 다름 = 자동 이음 · 종자류 = 후보 좁힘 1 순위 · 별도계상 = 단가산출 뒤 프로젝트에서 연결(요구 773-5) | 817-1 진행 | +| P5 | 값이 빈 우리 자재 줄(치즐 0.7 · 0.4 등)에 이어진 줄 = 이음 완료(로직 문제 아님) · 0 원 줄 흠에서 뺌 · 자재표 값 보강은 27 년 초 프로그램 막바지(요구 776) — J 장 닫음 | 완료 처리 | +| P6 | 콘크리트펌프용 파이프 = EST 「레미콘타설(펌프차)(배관타설)」 의 압송관 손료(직관 1 · 2 · 3m · 곡관 45 · 60 · 90° · 손료계수 4,500) · 현행 품셈 기계 목록에 없음 | 조사 끝 | diff --git a/docs/raw/요구_1104/사용자요구_1104.md b/docs/raw/요구_1104/사용자요구_1104.md index 85b767497..77515bbe8 100644 --- a/docs/raw/요구_1104/사용자요구_1104.md +++ b/docs/raw/요구_1104/사용자요구_1104.md @@ -892,3 +892,37 @@ b3 품셈 원문에 있는데 누락된 것만 추가하면 된다는거지? 2번 엑셀에는 당연히 자료가 없는게 맞고 엑셀을 만든 출처 프로그램의 분석을 확인하면 어떻게 사용되었는지 알수 있잔항. 확인해봐. 3. 대기 4. 엔진톱 내용 원문에 있는지 확인 + +## 요구 771 (11-11 · 사용자 · 엔진톱 = 체인톱이 맞아 보임 · 2 번도 반영) + +엔진톱은 체인톱이 맞아보임. +일단 2번도 반영 + +## 요구 772 (11-11 · 사용자 · 공유 엑셀을 구글 시트 「수동연결(자재)」 · 「수동연결(기계)」 탭으로 넣음 · 기계는 행 메모에 의견 · 이미 별칭으로 연결된 것 · 간단한 글 검색으로 분류되는 것 있음 — 확인 · 재료 메모 의견은 직접 읽고 대화) + +1. 다음으로 너가 공유해준 엑셀파일을 구글 스프레드 시트로 가져와 수동연결(자재)탭과 수동연결(기계)탭으로 넣었고 기계와 관련해서는 해당 행의 메모에 의견을 작성해 뒀음. 이때 벌써 별칭으로 연결된 정보도 있었고 간단한 텍스트 검색으로도 분류가 되는 경우가 있어. 확인해봐. +2. 재료에 대한 메모 의견은 다시 한번 너도 직접 일어봐봐. 읽어보고 대화하자. 대부분 연결되는 것글이 많은 것같은데? + +## 요구 773 (11-11 · 사용자 · 1 크롤러드릴 기본 110kW · 2 커터 320~440 인정 → 우리 320~400 · 3 크레인(트럭) = 크레인(타이어) · 값 비교로 잇지 말고 로직으로 판단 · 4 콘크리트펌프용 파이프 = 손료 — 어떤 장비 손료인지 확인 · 5 자재: 표기 다름은 분석 엔진 개선 · 종자는 로직 개선으로 찾을 수 있을 듯(종자 & 초본류 조회) · 별도계상은 단가산출 뒤 연결) + +1. 크롤러는 기본 110kw로 지정 +2. 커터는 320~440 인정, 우리 자료의 320~400으로 지정 +3. 그럼 크레인 타이어가 맞지. (대신 값을 비교해서 연결하면 문제가 됨. 값은 업데이트 되기때문 과거 자료일수록 더 값이 안맞음. 그러니 로직으로 판단하게 되어야함. 굴삭기가 그렇게 되어 있을것으로 판단됨. ) +4. 콘크리트펌프용 파이프는 손료가 맞는것같음. 어떤 장비의 손료인지. 확인 +5. 자재에서 + - 표기 다름은 잘 찾도록 분석엔진 개선 + - 사람이 골라야하는 정보들 중 종자 같은건 로직 개선치 찾을 수 있을것 같음. 종자 & 초본류로 조회해봐. + - 별도계상은 단가산출에서는 원가를 합산하지는 않지만 나중에 프로젝트 내부에서는 연결이 되어야함. 그러니 별도계상은 항목도 연결이 불가능에 가까움. 사용자가 별도계상하더라도 옵션은 단가산출을 만든 후 연결하게 해야함. 벌써 반영되어 있을것으로 예상 + +## 요구 774 (11-11 · 사용자 · 별칭은 사용자가 지정 · 브레인은 분석 엔진 업데이트에 집중) + +그리고 별칭은 내가 지정할거야. 너는 분석 엔진 업데이트에 집중해줘. + +## 요구 775 (11-11 · 사용자 · 이렇게 반영되면 엑셀 · 경쟁사 두 확장자 파일을 각각 넣었을 때 우리 단가산출 형태로 만드는 진행율이 어느 정도인지) + +이렇게 반영되면 엑셀, 경쟁사 2개 확장자 파일을 각각 넣었을때 우리 프로그램의 단가산출 형태로 만드는 진행율이 어느정도 됨? + +## 요구 776 (11-11 · 사용자 · 기계 별칭 입력은 브레인이 · 승인까지 · 자재표 값 빈 줄은 로직 문제 아님 = 완료 처리 · 자재표 보강은 27 년 초 프로그램 막바지) + +기계 별칭 입력은 너가 해줘. 승인까지 진행 +자재표의 값이 없어 빈값을 출력하는 정보는 연결은 되었지만 못찾아서가 아니라 값이 없어서인지라 로직에는 문제가 없음. 그러니 완료 처리 자재표 보강은 27년 초에 진행예정. 프로그램 막바지에 진행예정. 너는 완료처리 diff --git a/resources/master_data/기계.json b/resources/master_data/기계.json index 651076b64..e563bec45 100644 --- a/resources/master_data/기계.json +++ b/resources/master_data/기계.json @@ -5613,7 +5613,8 @@ "조종원": "건설품셈 공통 8-4", "조종원직종": "건설품셈 공통 8-1-3 5" }, - "비고": "규격 단위: (ton)" + "비고": "규격 단위: (ton)", + "별칭": "덤프자동덮개 15톤" }, { "키": "EQ000079", @@ -5735,7 +5736,8 @@ "조종원": "건설품셈 공통 8-4", "조종원직종": "건설품셈 공통 8-1-3 5" }, - "비고": "규격 단위: (ton)" + "비고": "규격 단위: (ton)", + "별칭": "덤프자동덮개 24톤" }, { "키": "EQ000081", @@ -8672,7 +8674,8 @@ "조종원": "건설품셈 공통 8-4-3", "조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048" }, - "비고": "조종원직종 원문 이름: 건설기계운전사 · 해석: 크레인(타이어) = 원문 「기중기(차륜)」 · 주연료종류: 원문 글을 가름" + "비고": "조종원직종 원문 이름: 건설기계운전사 · 해석: 크레인(타이어) = 원문 「기중기(차륜)」 · 주연료종류: 원문 글을 가름", + "별칭": "크레인(트럭) 10Ton" }, { "키": "EQ000129", @@ -8733,7 +8736,8 @@ "조종원": "건설품셈 공통 8-4-3", "조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048" }, - "비고": "조종원직종 원문 이름: 건설기계운전사 · 해석: 크레인(타이어) = 원문 「기중기(차륜)」 · 주연료종류: 원문 글을 가름" + "비고": "조종원직종 원문 이름: 건설기계운전사 · 해석: 크레인(타이어) = 원문 「기중기(차륜)」 · 주연료종류: 원문 글을 가름", + "별칭": "크레인(트럭) 15Ton" }, { "키": "EQ000130", @@ -12018,7 +12022,8 @@ "조종원": "건설품셈 공통 8-4-3", "조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1049" }, - "비고": "조종원직종 원문 이름: 화물차운전사 · 주연료종류: 원문 글을 가름" + "비고": "조종원직종 원문 이름: 화물차운전사 · 주연료종류: 원문 글을 가름", + "별칭": "트럭트랙터및트레일러 20톤" }, { "키": "EQ000184", @@ -15176,9 +15181,8 @@ "조종원직종": "건설품셈 공통 8-1-3 5" }, "비고": "규격 단위: (㎜) · 주연료종류: 원문 글을 가름", - "값원문": { - "8-4-5 주연료": "휘발유5.6" - } + "값원문": { "8-4-5 주연료": "휘발유5.6" }, + "별칭": "커터(콘크리트 및 아스팔트용) 320-440mm" }, { "키": "EQ000235", @@ -21400,7 +21404,8 @@ "조종원": "건설품셈 공통 8-4-6", "조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048" }, - "비고": "조종원직종 원문 이름: 건설기계운전사 · 주연료종류: 원문 글을 가름" + "비고": "조종원직종 원문 이름: 건설기계운전사 · 주연료종류: 원문 글을 가름", + "별칭": "크로울러드릴 탑승유압식" }, { "키": "EQ000338", diff --git a/resources/tester/test_est_cleanup.py b/resources/tester/test_est_cleanup.py new file mode 100644 index 000000000..11b77cfc0 --- /dev/null +++ b/resources/tester/test_est_cleanup.py @@ -0,0 +1,102 @@ +"""EST 테스트 앱 무관자료 삭제(원본 mnuNotLinkDelete·DelSort·MulDelete).""" + +import json +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +FILES = [ + "native-math.js", + "native-formula.js", + "native-machine.js", + "native-rows.js", + "native-text.js", + "native-quantity.cjs", + "native-wonga.js", + "native-engine.js", + "native-row-match.js", + "native-escalation.js", + "native-progress.js", + "native-business.js", + "native-review.js", + "native-resources.js", + "native-schedule.js", + "native-model.js", + "native-verify.js", + "native-cleanup.js", +] + + +def run_js(code: str): + runner = f""" + const fs = require('fs'), vm = require('vm'); + const ctx = vm.createContext({{ structuredClone, console }}); + for (const f of {json.dumps(FILES)}) + vm.runInContext(fs.readFileSync('tools/est-test/' + f, 'utf8'), ctx, {{ filename: f }}); + ctx.source = JSON.parse(fs.readFileSync('tools/est-test/sample.json', 'utf8')); + process.stdout.write(JSON.stringify(vm.runInContext({json.dumps(code)}, ctx))); + """ + return json.loads(subprocess.check_output(["node", "-e", runner], cwd=ROOT).decode("utf-8")) + + +def test_unused_rows_removed_and_totals_unchanged(): + result = run_js( + """(() => { + const calc = (p) => EstNative.calculate(EstModel.validate(p)); + const p = calc(structuredClone(source)); + p.tables.StuffcDat.push({ Code: 'MZZZ', Name: '안 쓰는 자재', Index: 999, Cost1: 1, ValueIndex: 0 }); + p.tables.HeadMainDat.push({ Index: 900, Code: 'UZZZ', Name: '안 쓰는 일위', TitleCode: 'UZZZ' }, + { Code: 'MZZZ', LinkIndex: 5, GN: 1 }); + const before = calc(p), total = before.calculation.total, direct = before.calculation.direct.TC; + const counts = Object.fromEntries(['StuffcDat','LaborcDat','HeadMainDat','CostMainDat','MachMainDat'].map((t) => [t, p.tables[t].length])); + const removed = EstCleanup.clean(p, false); + const after = calc(p); + return { total: after.calculation.total === total, direct: after.calculation.direct.TC === direct, + errors: after.calculation.errors.length, + gone: !p.tables.StuffcDat.some((r) => r.Code === 'MZZZ') && !EstNative.groups(p.tables.HeadMainDat).has('UZZZ'), + removedStuff: removed.StuffcDat >= 1, removedHead: removed.HeadMainDat >= 1, + estimate: p.tables.EstMainDat.length === before.tables.EstMainDat.length }; + })()""" + ) + assert result == { + "total": True, + "direct": True, + "errors": 0, + "gone": True, + "removedStuff": True, + "removedHead": True, + "estimate": True, + } + + +def test_sorted_mode_follows_first_use_and_keeps_codes(): + result = run_js( + """(() => { + const calc = (p) => EstNative.calculate(EstModel.validate(p)); + const p = calc(structuredClone(source)); + const order = EstCleanup.used(p); + const total = p.calculation.total; + const codes = new Set(p.tables.HeadTitleDat.map((r) => r.Code)); + EstCleanup.clean(p, true); + const after = calc(p); + return { heads: [...EstNative.groups(p.tables.HeadMainDat).keys()].join() === order.get('HeadMainDat').join(), + stuff: p.tables.StuffcDat.map((r) => r.Code).join() === order.get('StuffcDat').join(), + codes: p.tables.HeadTitleDat.every((r) => codes.has(r.Code)), + total: after.calculation.total === total, errors: after.calculation.errors.length }; + })()""" + ) + assert result == {"heads": True, "stuff": True, "codes": True, "total": True, "errors": 0} + + +def test_blank_rows_before_heads_and_quantity_sheets(): + result = run_js( + """(() => { + const rows = [{ Index: 1, Code: 'A' }, { Name: 'x', GN: 1 }, {}, {}, { Index: 2, Code: 'B' }, {}]; + const trimmed = EstCleanup.trimBeforeHeads(rows, 'HeadMainDat').map((r) => r.Code || r.Name || '-'); + const p = { tables: { EstMainDat: [{ PkID: 7, Code: 'D1' }] }, quantitySheets: [ + { Code: 'Q1', items: [{ TargetKey: 'id:7' }] }, { Code: 'Q2', items: [{ TargetCode: 'NONE' }] }, { Code: 'Q3', items: [] }] }; + const gone = EstCleanup.cleanQuantity(p); + return { trimmed, gone, left: p.quantitySheets.map((s) => s.Code) }; + })()""" + ) + assert result == {"trimmed": ["A", "x", "B", "-"], "gone": 2, "left": ["Q1"]} diff --git a/resources/tester/test_est_forms_library.py b/resources/tester/test_est_forms_library.py new file mode 100644 index 000000000..1a0cb73a4 --- /dev/null +++ b/resources/tester/test_est_forms_library.py @@ -0,0 +1,56 @@ +"""EST 테스트 앱 사용자 양식 보관함(원본 양식목록 등록·삭제·열기).""" + +import json +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def node(script: str, *args: str): + out = subprocess.check_output(["node", "-e", script, *args], cwd=ROOT) + return json.loads(out.decode("utf-8")) + + +def test_store_saves_replaces_lists_and_deletes(tmp_path): + result = node( + """ + const f = require('./tools/est-test/forms-store.cjs'), dir = process.argv[1]; + const errors = []; + const t = (fn) => { try { fn(); errors.push('ok'); } catch (e) { errors.push(e.message.slice(0, 6)); } }; + f.save(dir, { kind: 'wonga', name: '회사 기본', data: [{ Name: 'a' }] }); + f.save(dir, { kind: 'wonga', name: '회사 기본', data: [{ Name: 'b' }] }); + f.save(dir, { kind: 'document', name: '표지', form: 1, data: 'x' }); + const listed = f.read(dir).map((x) => [x.kind, x.name, x.data]); + t(() => f.save(dir, { kind: 'bad', name: 'x', data: 1 })); + t(() => f.save(dir, { kind: 'wonga', name: ' ', data: 1 })); + t(() => f.save(dir, { kind: 'wonga', name: 'x' })); + t(() => f.remove(dir, 'wonga', '없는 양식')); + f.remove(dir, 'wonga', '회사 기본'); + process.stdout.write(JSON.stringify({ listed, errors, left: f.read(dir).map((x) => x.name) })); + """, + str(tmp_path), + ) + assert result["listed"] == [["wonga", "회사 기본", [{"Name": "b"}]], ["document", "표지", "x"]] + assert [e[:5] for e in result["errors"]] == ["양식 종류", "양식 이름", "양식 내용", "지울 양식"] + assert result["left"] == ["표지"] + + +def test_template_drops_amounts_and_rejects_bad_rows(): + result = node( + """ + const fs = require('fs'), vm = require('vm'); + const ctx = vm.createContext({ structuredClone }); + vm.runInContext(fs.readFileSync('tools/est-test/native-forms-library.js', 'utf8'), ctx); + const out = vm.runInContext(`(() => { + const rows = EstFormsLibrary.strip([{ Gubun: 'N', Name: '직접 노무비', Cost: 100, VSusic: 'N' }]); + const errors = []; + for (const bad of [[], null, [1]]) { try { EstFormsLibrary.validRows(bad); } catch (e) { errors.push(e.message.slice(0, 9)); } } + return { rows, errors, other: EstFormsLibrary.handle('other', {}, null) }; + })()`, ctx); + process.stdout.write(JSON.stringify(out)); + """ + ) + assert result["rows"] == [{"Gubun": "N", "Name": "직접 노무비", "VSusic": "N"}] + assert len(result["errors"]) == 3 + assert result["other"] is False diff --git a/resources/tester/test_est_jungsang.py b/resources/tester/test_est_jungsang.py new file mode 100644 index 000000000..71650c642 --- /dev/null +++ b/resources/tester/test_est_jungsang.py @@ -0,0 +1,87 @@ +"""EST 테스트 앱 정산원가계산서(원본 당초금액 초기화·정산원가계산서 작성/해제).""" + +import json +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +FILES = [ + "native-math.js", + "native-formula.js", + "native-machine.js", + "native-rows.js", + "native-text.js", + "native-quantity.cjs", + "native-wonga.js", + "native-jungsang.js", + "native-engine.js", + "native-row-match.js", + "native-escalation.js", + "native-progress.js", + "native-business.js", + "native-review.js", + "native-resources.js", + "native-schedule.js", + "native-model.js", + "native-verify.js", +] + + +def run_js(code: str): + runner = f""" + const fs = require('fs'), vm = require('vm'); + const ctx = vm.createContext({{ structuredClone, console }}); + for (const f of {json.dumps(FILES)}) + vm.runInContext(fs.readFileSync('tools/est-test/' + f, 'utf8'), ctx, {{ filename: f }}); + ctx.source = JSON.parse(fs.readFileSync('tools/est-test/sample.json', 'utf8')); + process.stdout.write(JSON.stringify(vm.runInContext({json.dumps(code)}, ctx))); + """ + return json.loads(subprocess.check_output(["node", "-e", runner], cwd=ROOT).decode("utf-8")) + + +def test_difference_text_and_row_reuse(): + result = run_js( + """(() => { + const rows = [{ Name: '순공사비', Cost: 100, SC: 120 }, { Name: '', Cost: 0 }, { Name: '', Cost: 0 }]; + const less = EstJungSang.difference(rows); + rows[0].Cost = 150; + const more = EstJungSang.difference(rows); + return { less, more, at: rows.findIndex((r) => r.LinkIndex === 66), size: rows.length, row: rows[1] }; + })()""" + ) + assert result["less"] == "(감)20" and result["more"] == "(증)30" + assert result["at"] == 1 and result["size"] == 3 + assert result["row"]["Name"] == "." and result["row"]["EndLineIndex"] == 1 + + +def test_toggle_copies_original_amounts_and_recalc_rewrites_difference(): + result = run_js( + """(() => { + const calc = (p) => EstNative.calculate(EstModel.validate(p)); + let p = calc(structuredClone(source)); + const before = p.tables.Wonga.map((r) => Number(r.Cost) || 0); + const fixed = p.tables.Wonga.findIndex((r) => Number(r.LinkIndex) === 55); + EstJungSang.toggle(p); + const copied = p.tables.Wonga.every((r, i) => r.LinkIndex === 66 || Number(r.SC) === (before[i] ?? 0)); + p = calc(p); + const zero = p.tables.Wonga.find((r) => r.LinkIndex === 66).VSusic; + const row = p.tables.EstMainDat.find((r) => Number(r.GN) > 0 && Number(r.TU) > 0 && !Number(r.Level)); + row.GN = Number(row.GN) * 2; + p = calc(p); + const diff = p.tables.Wonga.find((r) => r.LinkIndex === 66).VSusic; + const errors = p.calculation.errors.length; + EstJungSang.toggle(p); + const off = { show: p.options.JungSangShow, has66: p.tables.Wonga.some((r) => r.LinkIndex === 66), + keptSC: p.tables.Wonga.some((r) => Number(r.SC) > 0) }; + return { copied, released: Number(p.tables.Wonga[fixed].LinkIndex), zero, diff, errors, off, + columns: EstJungSang.columns({ options: { JungSangShow: true } }, ['Name', 'VSusic', 'Cost']) }; + })()""" + ) + assert result["copied"] is True + assert result["released"] == 0 + assert re.match(r"^(0|\((증|감)\)\d+)$", result["zero"]) + assert result["diff"].startswith("(증)") + assert result["errors"] == 0 + assert result["off"] == {"show": False, "has66": False, "keptSC": True} + assert result["columns"] == ["Name", "VSusic", "SC", "Cost"] diff --git a/resources/tester/test_est_multi_search.py b/resources/tester/test_est_multi_search.py new file mode 100644 index 000000000..69928c982 --- /dev/null +++ b/resources/tester/test_est_multi_search.py @@ -0,0 +1,36 @@ +"""EST 테스트 앱 통합 검색(원본 TFRMMULTISERCH, 검색위치 = 저장된 작업).""" + +import json +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def test_search_finds_words_in_selected_kinds(tmp_path): + sample = json.loads((ROOT / "tools/est-test/sample.json").read_text(encoding="utf-8")) + sample["name"] = "검색 시험 공사" + (tmp_path / ("a" * 24 + ".json")).write_text(json.dumps(sample), encoding="utf-8") + (tmp_path / ("b" * 24 + ".json")).write_text("{bad json", encoding="utf-8") + (tmp_path / "forms.json").write_text("[]", encoding="utf-8") + script = """ + const s = require('./tools/est-test/search-store.cjs'), dir = process.argv[1]; + const out = {}; + out.labor = s.search(dir, '건설기계 운전사', ['6']); + out.none = s.search(dir, '건설기계', ['5']); + out.excluded = s.search(dir, '건설기계', ['6'], 'a'.repeat(24)); + const err = []; + for (const args of [['', ['6']], ['x', ['999']]]) { try { s.search(dir, ...args); } catch (e) { err.push(e.message.slice(0, 4)); } } + out.err = err; + process.stdout.write(JSON.stringify(out)); + """ + result = json.loads( + subprocess.check_output(["node", "-e", script, str(tmp_path)], cwd=ROOT).decode("utf-8") + ) + hit = result["labor"]["hits"][0] + assert result["labor"]["total"] >= 1 + assert hit["project"] == "검색 시험 공사" and hit["kind"] == 6 and hit["label"] == "노임단가" + assert "건설기계운전사" in hit["Name"].replace(" ", "") + assert result["none"]["total"] == 0 + assert result["excluded"]["total"] == 0 + assert result["err"] == ["검색어를", "검색할 "] diff --git a/resources/tester/test_est_name_reg.py b/resources/tester/test_est_name_reg.py new file mode 100644 index 000000000..ba807b62b --- /dev/null +++ b/resources/tester/test_est_name_reg.py @@ -0,0 +1,102 @@ +"""EST 테스트 앱 품명정리와 자재·견적단가 등록(원본 TfrmNameDevide·popMainStuffcReg·popMainEstimateReg).""" + +import json +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +FILES = [ + "native-math.js", + "native-formula.js", + "native-machine.js", + "native-rows.js", + "native-text.js", + "native-quantity.cjs", + "native-wonga.js", + "native-engine.js", + "native-row-match.js", + "native-escalation.js", + "native-progress.js", + "native-business.js", + "native-review.js", + "native-resources.js", + "native-schedule.js", + "native-model.js", + "native-verify.js", + "native-name-reg.js", +] + + +def run_js(code: str): + runner = f""" + const fs = require('fs'), vm = require('vm'); + const ctx = vm.createContext({{ structuredClone, console }}); + for (const f of {json.dumps(FILES)}) + vm.runInContext(fs.readFileSync('tools/est-test/' + f, 'utf8'), ctx, {{ filename: f }}); + ctx.source = JSON.parse(fs.readFileSync('tools/est-test/sample.json', 'utf8')); + process.stdout.write(JSON.stringify(vm.runInContext({json.dumps(code)}, ctx))); + """ + return json.loads(subprocess.check_output(["node", "-e", runner], cwd=ROOT).decode("utf-8")) + + +def test_divide_splits_at_position_and_syncs_title(): + result = run_js( + """(() => { + const p = { tables: { + HeadMainDat: [{ Index: 1, Code: 'U1', Name: '거푸집 설치 합판', Size: '3회', Unite: '' }, { Code: 'L1', LinkIndex: 6 }, + { Index: 2, Code: 'U2', Name: '거푸집 해체 강재', Size: '', Unite: '㎡' }], + HeadTitleDat: [{ Code: 'U1', Name: '거푸집 설치 합판' }, { Code: 'U2', Name: '거푸집 해체 강재' }] } }; + const n = EstNameReg.divide(p, [0, 1, 2], 4, 0, '㎡'); + const c = { tables: { HeadMainDat: [{ Index: 1, Code: 'X', Name: 'AB-CD', Size: '' }] } }; + EstNameReg.divide(c, [0], 2, 1, ''); + let bad = ''; try { EstNameReg.divide(p, [1], 4, 0, ''); } catch (e) { bad = e.message.slice(0, 6); } + const r = p.tables.HeadMainDat; + return { n, a: [r[0].Name, r[0].Size, r[0].Unite], b: [r[2].Name, r[2].Size, r[2].Unite], + title: p.tables.HeadTitleDat[0].Size, bad, cut: c.tables.HeadMainDat[0].Size }; + })()""" + ) + assert result["n"] == 2 + assert result["a"] == ["거푸집 ", "설치 합판3회", "㎡"] + assert result["b"] == ["거푸집 ", "해체 강재", "㎡"] + assert result["title"] == "설치 합판3회" + assert result["bad"] == "품명을 정리" + assert result["cut"] == "CD" + + +def test_register_stuff_and_estimate_link_row_and_keep_price(): + result = run_js( + r"""(() => { + const calc = (x) => EstNative.calculate(EstModel.validate(x)); + const p = calc(structuredClone(source)); + const i = p.tables.EstMainDat.findIndex((r) => Number(r.GU) > 0 && !Number(r.Level) && Number(r.LinkIndex) === 5); + p.tables.EstMainDat[i].Name = '시험 자재 등록품'; + const before = p.tables.EstMainDat[i].TC; + const code = EstNameReg.register(p, 'EstMainDat', i, 'stuff'); + const rec = p.tables.StuffcDat.find((r) => r.Code === code); + const j = p.tables.EstMainDat.findIndex((r, k) => k !== i && Number(r.JU) + Number(r.NU) > 0 && !Number(r.Level) && r.Name); + p.tables.EstMainDat[j].Name = '시험 견적 등록품'; + const w = EstNameReg.register(p, 'EstMainDat', j, 'estimate'); + const est = p.tables.EstiMateDat.find((r) => r.Code === w); + const after = calc(p); + const errors = []; + for (const fn of [() => EstNameReg.register(p, 'EstMainDat', i, 'stuff'), + () => EstNameReg.register(p, 'HeadMainDat', p.tables.HeadMainDat.findIndex((r) => Number(r.Index) > 0), 'stuff')]) + try { fn(); } catch (e) { errors.push(e.message.slice(0, 6)); } + return { code: /^M\d{10}$/.test(code), price: rec.Cost1 === Number(p.tables.EstMainDat[i].GU) && rec.TC === rec.Cost1, + link: [p.tables.EstMainDat[i].LinkIndex, p.tables.EstMainDat[i].Code === code], + same: after.tables.EstMainDat[i].TC === before, w: /^W\d{10}$/.test(w), + est: est.TC === est.NC + est.GC + est.JC && est.ONC === est.NC, estLink: p.tables.EstMainDat[j].LinkIndex, + errors, calcErrors: after.calculation.errors.length }; + })()""" + ) + assert result == { + "code": True, + "price": True, + "link": [5, True], + "same": True, + "w": True, + "est": True, + "estLink": 101, + "errors": ["같은 명칭의", "호표는 자재"], + "calcErrors": 0, + } diff --git a/resources/tester/test_est_schedule_scale.py b/resources/tester/test_est_schedule_scale.py new file mode 100644 index 000000000..8a7fde018 --- /dev/null +++ b/resources/tester/test_est_schedule_scale.py @@ -0,0 +1,39 @@ +"""EST 테스트 앱 예정공정표 눈금 단위(원본 1·5·10·15·20일, 1주, 1개월).""" + +import json +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def run_js(code: str): + runner = f""" + const fs = require('fs'), vm = require('vm'); + const ctx = vm.createContext({{ structuredClone, console }}); + vm.runInContext(fs.readFileSync('tools/est-test/native-schedule.js', 'utf8'), ctx); + process.stdout.write(JSON.stringify(vm.runInContext({json.dumps(code)}, ctx))); + """ + return json.loads(subprocess.check_output(["node", "-e", runner], cwd=ROOT).decode("utf-8")) + + +def test_day_week_month_ticks(): + result = run_js( + """(() => { + const t = (s, e, k, n) => EstSchedule.ticks(s, e, k, n); + const week = t('2026-01-01', '2026-01-28', '1w'); + const month = t('2026-01-15', '2026-04-10', '1m'); + const tenth = t('2026-01-01', '2026-01-30', '10d'); + const many = t('2026-01-01', '2026-12-31', '1d', 60); + let bad = ''; try { t('2026-01-01', '2026-01-02', 'x'); } catch (e) { bad = e.message; } + return { week: week.ticks.map((x) => [x.label, Math.round(x.left)]), + month: month.ticks.map((x) => x.label), tenth: tenth.ticks.map((x) => x.label), + cut: many.cut, count: many.ticks.length, empty: t('', '', '1d').ticks.length, bad }; + })()""" + ) + assert result["week"] == [["01-01", 0], ["01-08", 25], ["01-15", 50], ["01-22", 75]] + assert result["month"] == ["2026-01", "2026-02", "2026-03", "2026-04"] + assert result["tenth"] == ["01-01", "01-11", "01-21"] + assert result["cut"] is True and result["count"] == 60 + assert result["empty"] == 0 + assert result["bad"] == "눈금 단위를 고르세요." diff --git a/resources/tester/test_est_tidy.py b/resources/tester/test_est_tidy.py new file mode 100644 index 000000000..38cdd15ca --- /dev/null +++ b/resources/tester/test_est_tidy.py @@ -0,0 +1,90 @@ +"""EST 테스트 앱 정렬·데이타 위치정리·호표 정리(원본 BarSort·TFRMTABLESORT·popTitleSort).""" + +import json +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +FILES = [ + "native-math.js", + "native-formula.js", + "native-machine.js", + "native-rows.js", + "native-text.js", + "native-quantity.cjs", + "native-wonga.js", + "native-engine.js", + "native-row-match.js", + "native-escalation.js", + "native-progress.js", + "native-business.js", + "native-review.js", + "native-resources.js", + "native-schedule.js", + "native-model.js", + "native-verify.js", + "native-tidy.js", +] + + +def run_js(code: str): + runner = f""" + const fs = require('fs'), vm = require('vm'); + const ctx = vm.createContext({{ structuredClone, console }}); + for (const f of {json.dumps(FILES)}) + vm.runInContext(fs.readFileSync('tools/est-test/' + f, 'utf8'), ctx, {{ filename: f }}); + ctx.source = JSON.parse(fs.readFileSync('tools/est-test/sample.json', 'utf8')); + process.stdout.write(JSON.stringify(vm.runInContext({json.dumps(code)}, ctx))); + """ + return json.loads(subprocess.check_output(["node", "-e", runner], cwd=ROOT).decode("utf-8")) + + +def test_sort_base_groups_by_gangub_and_drops_blank(): + result = run_js( + """(() => { + const p = { tables: { StuffcDat: [ + { Index: 1, Code: 'M3', Name: '철근', Gangub: 0 }, { Index: 2, Code: 'M1', Name: '레미콘', Gangub: 1 }, + { Index: 3, Code: 'M2', Name: '거푸집', Gangub: 0 }, { Index: 0, Code: 'M9', Name: '번호없음' }, { Index: 4 } ] } }; + const dropped = EstTidy.sortBase(p, 'StuffcDat'); + const s = { tables: { SilJukDat: [{ Index: 1, Code: 'S2', Name: '가' }, { Index: 2, Code: 'S1', Name: '나' }] } }; + EstTidy.sortBase(s, 'SilJukDat', true); + let bad = ''; try { EstTidy.sortBase(p, 'HeadMainDat'); } catch (e) { bad = e.message.slice(0, 4); } + return { order: p.tables.StuffcDat.map((r) => [r.Code, r.Index]), dropped, code: s.tables.SilJukDat.map((r) => r.Code), bad }; + })()""" + ) + assert result == { + "order": [["M2", 1], ["M3", 2], ["M1", 3]], + "dropped": 2, + "code": ["S1", "S2"], + "bad": "자재·노", + } + + +def test_arrange_groups_and_number_heads_keep_totals(): + result = run_js( + """(() => { + const calc = (x) => EstNative.calculate(EstModel.validate(x)); + const p = calc(structuredClone(source)); + const total = p.calculation.total; + const n = EstTidy.arrange(p, 'HeadMainDat', false, 10); + const heads = [...EstNative.groups(p.tables.HeadMainDat).values()].map((g) => g.rows[0]); + const sorted = heads.every((h, i) => i === 0 || String(heads[i - 1].Name) <= String(h.Name)); + const numbers = heads.map((h) => h.Index); + const titles = p.tables.HeadTitleDat.filter((t) => heads.some((h) => h.Code === t.Code)).every((t) => t.Index === heads.find((h) => h.Code === t.Code).Index); + EstTidy.numberHeads(p, 'HeadMainDat', 1); + const after = calc(p); + return { n: n === heads.length, sorted, first: numbers[0], step: numbers[1] - numbers[0], titles, + renum: [...EstNative.groups(p.tables.HeadMainDat).values()].map((g) => g.rows[0].Index).join() === heads.map((_, i) => i + 1).join(), + total: after.calculation.total === total, errors: after.calculation.errors.length }; + })()""" + ) + assert result == { + "n": True, + "sorted": True, + "first": 10, + "step": 1, + "titles": True, + "renum": True, + "total": True, + "errors": 0, + } diff --git a/resources/tester/test_est_xls_read.py b/resources/tester/test_est_xls_read.py new file mode 100644 index 000000000..18f58600c --- /dev/null +++ b/resources/tester/test_est_xls_read.py @@ -0,0 +1,53 @@ +"""EST 테스트 앱 엑셀형 수량산출서(.xls OLE2/BIFF8) 읽기 — 원본 TFRMMULEXCEL 본문.""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +MAIN = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx") + + +def test_reads_embedded_excel_sheets_from_installed_main(): + if not MAIN.exists(): + pytest.skip("installed EST main sample unavailable") + script = """ + const fs = require('fs'), imp = require(process.cwd() + '/tools/est-test/import-estx.cjs'); + const p = imp.importEstx(fs.readFileSync(process.argv[1])); + const sheets = p.quantitySheets.filter((s) => s.excel); + const first = sheets.find((s) => s.Code === 'N0000000136').excel[0]; + const formulas = Object.values(first.cells).filter((c) => c.formula); + process.stdout.write(JSON.stringify({ count: sheets.length, name: first.name, + title: first.cells['1,1'].value, station: first.cells['6,1'].value, number: first.cells['6,2'].value, + formulas: formulas.length, numeric: formulas.every((c) => typeof c.value === 'number' || typeof c.value === 'string') })); + """ + result = json.loads( + subprocess.check_output(["node", "-e", script, str(MAIN)], cwd=ROOT).decode("utf-8") + ) + assert result == { + "count": 6, + "name": "Sheet1", + "title": "토적계산표", + "station": "No.1", + "number": 20, + "formulas": 16, + "numeric": True, + } + + +def test_rejects_non_ole_and_renders_grid(): + script = """ + const fs = require('fs'), vm = require('vm'); + const { readXls } = require(process.cwd() + '/tools/est-test/xls-read.cjs'); + let message = ''; try { readXls(Buffer.from('not an xls file at all')); } catch (e) { message = e.message; } + const ctx = vm.createContext({ structuredClone, EstApp: { esc: (v) => String(v), number: (v) => String(v) } }); + vm.runInContext(fs.readFileSync('tools/est-test/native-quantity-sheets.js', 'utf8'), ctx); + ctx.sheet = { excel: [{ name: 'S', cells: { '0,0': { value: '측점' }, '1,1': { value: 3, formula: true } } }] }; + const html = vm.runInContext('EstQuantitySheets.excelView(sheet)', ctx); + process.stdout.write(JSON.stringify({ message, html: [html.includes('측점'), html.includes('
현재 원가계산서의 항목·수식·요율을 양식으로 등록하고, 다른 공사에서 열어 씁니다. 금액은 열 때 다시 계산합니다.
| 양식 이름 | 행 | 등록 | 작업 |
|---|---|---|---|
| ${esc(f.name)} | ${f.data.length} | ${esc(f.saved.slice(0, 10))} | |
| 등록된 양식이 없습니다. | |||
검색위치: 이 앱에 저장된 다른 작업
| 작업 | 구분 | 코드 | 품명 | 규격 | 단위 | 단가 |
|---|
찾은 자료 ${saved.total}건${saved.total > saved.hits.length ? ` 중 앞 ${saved.hits.length}건` : ""}
`, + ); + } + + async function run(name, state) { + if (name === "multi-search") form(state); + else if (name === "multi-search-run") { + const saved = state.multiSearch; + saved.query = document.querySelector('#dialog-content [name="ms-query"]').value; + saved.kinds = [...document.querySelectorAll('#dialog-content [name="ms-kind"]:checked')].map( + (n) => Number(n.value), + ); + const params = new URLSearchParams({ q: saved.query, kinds: saved.kinds.join(",") }); + if (state.project.id) params.set("exclude", state.project.id); + Object.assign(saved, await EstApp.api(`/api/search?${params}`)); + form(state); + } else if (name === "multi-search-take") { + const picked = [...document.querySelectorAll('#dialog-content [name="ms-hit"]:checked')].map( + (n) => state.multiSearch.hits[Number(n.value)], + ); + if (!picked.length) throw Error("가져올 자료를 고르세요."); + const projects = new Map(); + for (const hit of picked) + if (!projects.has(hit.id)) + projects.set(hit.id, await EstApp.api(`/api/projects/${hit.id}`)); + const deps = picked.map((hit) => + EstTransfer.collect(projects.get(hit.id), [{ LinkIndex: hit.kind, Code: hit.Code }]), + ); + let added = 0, + kept = 0; + EstApp.change((project) => { + for (const dep of deps) { + const result = EstTransfer.merge(project, dep); + added += result.added.length; + kept += result.kept.length; + } + }); + EstApp.byId("dialog").close(); + state.notice = `자료 ${added}건을 가져왔습니다${kept ? `, 같은 코드 ${kept}건은 이 공사 자료를 그대로 씀` : ""}. 단가 고르기로 행에 넣으세요.`; + EstApp.render(); + } + } + const names = new Set(["multi-search", "multi-search-run", "multi-search-take"]); + function handle(name, state) { + if (!names.has(name)) return false; + run(name, state).catch((error) => { + state.notice = error.message; + EstApp.render(); + }); + return true; + } + + return { handle }; +})(); diff --git a/tools/est-test/native-name-reg.js b/tools/est-test/native-name-reg.js new file mode 100644 index 000000000..8e0cb7faa --- /dev/null +++ b/tools/est-test/native-name-reg.js @@ -0,0 +1,131 @@ +// 원본 품명정리(TfrmHead ActionNameDeivde 0xf5a2b4·TfrmNameDevide 적용 0xf595c0·규격으로지정 0xf59640)와 +// 자재단가·견적단가 등록(popMainStuffcReg·popMainEstimateReg → c_f00264·c_f00670). +// 품명정리: 기존명칭에서 고른 자리 앞 = 품명(다듬지 않음), 뒤에서 「삭제할 칸」만큼 떼고 기존 규격을 붙여 다듬은 값 = 규격, +// 단위는 비어 있을 때만 채움. 고른 일위대가 호표마다 같은 자리로 나누고 HeadTitleDat 의 같은 코드도 맞춘다. +// 등록: 같은 품명·규격(빈칸·| 무시) 있으면 거부, 코드 = 머리글자(M·W) + PkID 10자리(쓰인 코드면 하나씩 올림), +// 자재 Cost1 = TC = 행 재료단가 · 견적 NC/GC/JC = 행 노무/재료/경비단가, 원래 행은 새 코드·연결(5·101)로 바꾼다. +const EstNameReg = (() => { + const num = (value) => Number(value) || 0; + const strip = (value) => String(value ?? "").replace(/[\s|]/g, ""); + + function divide(project, indices, position, cut, unit) { + if (!Number.isInteger(position) || position < 0) throw Error("나눌 자리를 고르세요."); + if (!Number.isInteger(cut) || cut < 0) throw Error("삭제할 칸은 0 이상의 정수입니다."); + const rows = project.tables.HeadMainDat, + titles = project.tables.HeadTitleDat || []; + const picked = indices.filter((i) => num(rows[i]?.Index) > 0); + if (!picked.length) throw Error("품명을 정리할 일위대가 호표(제목 줄)를 고르세요."); + for (const i of picked) { + const row = rows[i], + name = String(row.Name ?? ""); + const rest = name.slice(position).slice(cut); + row.Name = name.slice(0, position); + row.Size = `${rest}${row.Size ?? ""}`.trim(); + if (!strip(row.Unite) && unit) row.Unite = unit; + const title = titles.find((t) => t.Code === row.Code); + if (title) Object.assign(title, { Name: row.Name, Size: row.Size, Unite: row.Unite }); + } + return picked.length; + } + + const targets = { + stuff: { table: "StuffcDat", kind: 5, letter: "M", label: "자재단가" }, + estimate: { table: "EstiMateDat", kind: 101, letter: "W", label: "견적단가" }, + }; + + function register(project, view, index, which) { + const target = targets[which], + row = project.tables[view]?.[index]; + if (!target || !row) throw Error("등록할 행을 고르세요."); + if (view === "HeadMainDat" && num(row.Index) > 0) + throw Error(`호표는 ${target.label}에 등록할수 없습니다.`); + if (!String(row.Name ?? "").trim()) throw Error("명칭을 입력하세요."); + const rows = (project.tables[target.table] ||= []); + const same = rows.find( + (r) => strip(r.Name) === strip(row.Name) && strip(r.Size) === strip(row.Size), + ); + if (same) + throw Error(`같은 명칭의 데이타가 있습니다 . ${target.label} (${num(same.Index)} 번)`); + let pk = Math.max(0, ...rows.map((r) => num(r.PkID))) + 1; + const used = new Set(rows.map((r) => r.Code)); + let code = `${target.letter}${String(pk).padStart(10, "0").slice(-10)}`; + while (used.has(code)) code = `${target.letter}${String(++pk).padStart(10, "0").slice(-10)}`; + const schema = rows[0] ? Object.keys(rows[0]) : ["Name", "Size", "Unite"]; + const record = {}; + for (const key of schema) + if (!["PkID", "Index", "Code"].includes(key) && Object.hasOwn(row, key)) + record[key] = row[key]; + Object.assign(record, { PkID: pk, Code: code, Index: rows.length + 1 }); + if (which === "stuff") + Object.assign(record, { Cost1: num(row.GU), TC: num(row.GU), OTC: num(row.GU) }); + else { + const [nc, gc, jc] = [num(row.NU), num(row.GU), num(row.JU)]; + Object.assign(record, { + NC: nc, + GC: gc, + JC: jc, + TC: nc + gc + jc, + ONC: nc, + OGC: gc, + OJC: jc, + OTC: nc + gc + jc, + }); + } + rows.push(record); + row.Code = code; + row.LinkIndex = target.kind; + return code; + } + + function divideDialog(state) { + if (state.view !== "HeadMainDat") throw Error("일위대가 화면에서 품명을 정리하세요."); + const rows = state.project.tables.HeadMainDat, + picked = [...state.selection].sort((a, b) => a - b).filter((i) => num(rows[i]?.Index) > 0); + if (!picked.length) throw Error("품명을 정리할 일위대가 호표(제목 줄)를 고르세요."); + const { esc } = EstApp; + EstApp.showDialog( + "품명정리", + `기존명칭에서 품명과 규격을 나눌 자리에 커서를 두고 「규격으로 지정」을 누르세요. 고른 ${picked.length}개 호표에 같은 자리로 적용합니다.
`, + ); + state.nameDivide = { picked, position: null }; + } + function field(name) { + return document.querySelector(`#dialog-content [name="${name}"]`); + } + + function handle(name, state) { + if (name === "name-divide") divideDialog(state); + else if (name === "name-divide-set") { + const all = field("nd-all"), + position = all.selectionStart ?? 0; + state.nameDivide.position = position; + field("nd-name").value = all.value.slice(0, position); + field("nd-size").value = all.value.slice(position); + } else if (name === "name-divide-apply") { + const info = state.nameDivide; + if (info?.position == null) throw Error("먼저 「규격으로 지정」으로 나눌 자리를 정하세요."); + const cut = Number(field("nd-cut").value), + unit = field("nd-unit").value.trim(); + EstApp.byId("dialog").close(); + let count; + EstApp.change((project) => (count = divide(project, info.picked, info.position, cut, unit))); + state.notice = `${count}개 호표의 품명을 정리했습니다.`; + } else if (name === "register-stuff" || name === "register-estimate") { + const which = name === "register-stuff" ? "stuff" : "estimate", + index = [...state.selection][0], + row = state.project.tables[state.view]?.[index]; + if (!["EstMainDat", "HeadMainDat"].includes(state.view) || state.selection.size !== 1 || !row) + throw Error("내역서·일위대가에서 등록할 행 하나를 고르세요."); + const label = targets[which].label; + register(structuredClone(state.project), state.view, index, which); + if (!confirm(`"${row.Name ?? ""}" ${row.Size ?? ""} 를 ${label}에 등록하시겠습니까?`)) + return true; + let code; + EstApp.change((project) => (code = register(project, state.view, index, which))); + state.notice = `${label}에 ${code}로 등록하고 이 행을 연결했습니다.`; + } else return false; + return true; + } + + return { divide, register, handle }; +})(); diff --git a/tools/est-test/native-quantity-sheets.js b/tools/est-test/native-quantity-sheets.js index 9ea2a0831..9d91345b7 100644 --- a/tools/est-test/native-quantity-sheets.js +++ b/tools/est-test/native-quantity-sheets.js @@ -137,5 +137,35 @@ const EstQuantitySheets = (() => { return true; } - return { copy, move, mark, free, handle }; + // 엑셀형 수량산출서(원본 TFRMMULEXCEL) — 가져온 셀 값을 읽기 전용 격자로 보인다. 수식 셀은 저장된 계산값(굵게). + function excelView(sheet) { + if (!sheet?.excel?.length) return ""; + const { esc, number } = EstApp; + const letter = (n) => { + let s = ""; + for (n += 1; n > 0; n = Math.floor((n - 1) / 26)) + s = String.fromCharCode(65 + ((n - 1) % 26)) + s; + return s; + }; + return sheet.excel + .map(({ name, cells }) => { + const keys = Object.keys(cells).map((k) => k.split(",").map(Number)); + if (!keys.length) return ""; + const rows = Math.max(...keys.map(([r]) => r)) + 1, + cols = Math.min(40, Math.max(...keys.map(([, c]) => c)) + 1); + const body = Array.from({ length: rows }, (_, r) => { + const cellsHtml = Array.from({ length: cols }, (_, c) => { + const cell = cells[`${r},${c}`]; + if (!cell) return "| ${Array.from({ length: cols }, (_, c) => ` | ${letter(c)} | `).join("")}
|---|
| 산출코드 | 품명 | 규격 | 단위 | 저장 수량 | 재계산 수량 | 연결 내역코드 | 끝수 처리 | 작업 |
|---|---|---|---|---|---|---|---|---|
| ${esc(item.Code)} | ${number(sheet.items[index]?.GN ?? 0)} | ${number(item.GN)} | ${targetSelect(state.project, item, "sheet", index)} | ${formatSelect(sheet.items[index], index)} |
${esc(output.errors.join(" / "))} 산출 행의 결과 표지로 수량 항목을 연결합니다. 신규 산출서와 지원되지 않는 원본 구조 변경은 JSON으로 저장합니다.
| 줄 | 산출근거 편집 | 작업 |
|---|---|---|
| ${index + 1} |
| 산출코드 | 품명 | 규격 | 단위 | 저장 수량 | 재계산 수량 | 연결 내역코드 | 끝수 처리 | 작업 |
|---|---|---|---|---|---|---|---|---|
| ${esc(item.Code)} | ${number(sheet.items[index]?.GN ?? 0)} | ${number(item.GN)} | ${targetSelect(state.project, item, "sheet", index)} | ${formatSelect(sheet.items[index], index)} |
${esc(output.errors.join(" / "))} 산출 행의 결과 표지로 수량 항목을 연결합니다. 신규 산출서와 지원되지 않는 원본 구조 변경은 JSON으로 저장합니다.
| 줄 | 산출근거 편집 | 작업 |
|---|---|---|
| ${index + 1} |
기간과 진도는 직접 입력합니다. 가중치는 공종 금액 또는 사용자가 정한 값을 사용합니다.
| ${h} | `).join("")}||
|---|---|---|
| `).join("")} |
기간과 진도는 직접 입력합니다. 가중치는 공종 금액 또는 사용자가 정한 값을 사용합니다.
| ${h === "기간" ? `기간${header}` : h} | `).join("")}||
|---|---|---|
| `).join("")} |
${EstApp.esc(body)}${typeof row.DifineMemo === "string" ? "" : "원본 서식 메모라 웹에서는 읽기만 합니다.
"}` + : "이 행에는 근거(메모)가 없습니다.
", + ); + } else if (name === "memo-delete") { + const { index } = memoTarget(state); + EstApp.change((project) => (project.tables[state.view][index].DifineMemo = null), false); + state.notice = "근거(메모)를 지웠습니다."; + } + } + const names = new Set(["tidy-sort", "tidy-arrange", "tidy-number", "memo-view", "memo-delete"]); + function handle(name, state) { + if (!names.has(name)) return false; + run(name, state).catch((error) => { + state.notice = error.message; + EstApp.render(); + }); + return true; + } + + return { sortBase, arrange, numberHeads, memoText, handle }; +})(); diff --git a/tools/est-test/native-views.js b/tools/est-test/native-views.js index 5efd9e593..51de43517 100644 --- a/tools/est-test/native-views.js +++ b/tools/est-test/native-views.js @@ -76,7 +76,7 @@ const EstViews = (() => { } function grid(table, list, columns) { const { esc, number, state } = EstApp; - return `| 선택 | ${columns.map((key) => `${esc(EstModel.labels[key] || key)} | `).join("")}
|---|---|
| ${columns.map((key) => ` | ${cell(table, index, key, row[key])} | `).join("")}
| 선택 | ${columns.map((key) => `${esc((table === "Wonga" && typeof EstJungSang !== "undefined" && EstJungSang.label(state.project, key)) || EstModel.labels[key] || key)} | `).join("")}
|---|---|
| ${columns.map((key) => ` | ${cell(table, index, key, row[key])} | `).join("")}