Merge remote-tracking branches 'origin/CODEX' and 'origin/main_laptop_1' into sub_laptop_3

This commit is contained in:
2026-11-11 20:12:19 +09:00
33 changed files with 2015 additions and 22 deletions
@@ -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급(백색) 표기만 다름
1 행 가져온 이름 가져온 규격 쓰임 기대 우리 키 우리 이름 우리 규격 확신
2 1 접착제 11 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음
3 2 패널(유로폼) 600*1200mm 10 이음 MT000467 유로폼패널 600×1200㎜ 표기만 다름
4 3 각재 외송 8 이음 MT000393 각 재(외송) 30×30×3600mm 눈으로 추정
5 4 요소 8 이음 MT006763 요 소 N:46% 눈으로 추정
6 5 내부코너패널(유로폼) (100x200)x1200mm 7 이음 MT000470 내부코너패널 100×200×1200㎜ 표기만 다름
7 6 결속선(철선) #20 , 0.9M/M 7 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음
8 7 각재 (외송) 6 이음 MT000393 각 재(외송) 30×30×3600mm 눈으로 추정
9 8 파형강관 별도계상 6 잇지 않음 별도계상
10 9 금강사 5 잇지 않음 우리 표에 없음
11 10 고무테이프 0.5mm×19mm×10m 5 이음 MT009202 절연 면고무테이프 0.5mm×19mm×10m 눈으로 추정
12 11 낙석방지책 H2500×W2000(표준) 5 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음
13 12 낙석방지책 H2500×W2000(단부) 5 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음
14 13 종자 초본류 5 후보 1 순위 MT006646 종 자 초본위주형 좁힘(자동 이음 아님)
15 14 종자 목본류 5 후보 1 순위 MT006648 종 자 목본군락형 좁힘(자동 이음 아님)
16 15 어닐링철선(결속선) #20 0.9mm 200.4m/kg 5 이음 MT000209 어닐링철선(결속선),서울 #20, 0.9mm 표기만 다름
17 16 내수합판1급 12mmx1220x2440 4 이음 MT000018 합판(내수), 서울 12t×1220×2440mm 표기만 다름
18 17 어닐링철선(결속선) #20 0.9M/M 4 이음 MT000209 어닐링철선(결속선),서울 #20, 0.9mm 표기만 다름
19 18 유로폼(패널) 600*1200 4 이음 MT000467 유로폼패널 600×1200㎜ 표기만 다름
20 19 내부코너패널 (200+200)*1200 4 이음 MT000472 내부코너패널 (200+200)×1200㎜ 표기만 다름
21 20 합판(내수) 12×1220×2440 4 이음 MT000018 합판(내수), 서울 12t×1220×2440mm 표기만 다름
22 21 조임철물 (직교자재) 4 이음 MT000499 조임철물,서울 직교,자재 표기만 다름
23 22 에폭시수지 YD-128 4 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음
24 23 종자 생태복원형,초본류 3 후보 1 순위 MT006646 종 자 초본위주형 좁힘(자동 이음 아님)
25 24 종자 생태복원형,목본류 3 후보 1 순위 MT006648 종 자 목본군락형 좁힘(자동 이음 아님)
26 25 내부코너패널(유로폼) (100+200)*1200mm 3 이음 MT000470 내부코너패널 100×200×1200㎜ 표기만 다름
27 26 유로폼(패널) 600×1200 3 이음 MT000467 유로폼패널 600×1200㎜ 표기만 다름
28 27 콘테이너하우스 창고용(3.0m×6.0m×2.6m) 3 이음 MT007111 콘테이너(창고) 6.0*3.0*2.6m 표기만 다름
29 28 전기뇌관(MS) 3.5m×500개(하이데토) 2 이음 MT005677 전기뇌관 3.5m 눈으로 추정
30 29 BUTTON BIT Ø38×76㎜ 2 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음
31 30 유압드릴샹크 T38×CM470 2 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음
32 31 유압식드릴스리브 T38×190mm 2 이음 MT005687 유압드릴스리브 T38×190mm 표기만 다름
33 32 미송(판재) 2 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음
34 33 산림종자 참싸리(박피) 2 이음 MT006664 산림종자 참싸리(풍화암,강마사토) 눈으로 추정
35 34 유기질퇴비 2 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음
36 35 PVC지수판 200x5T KSM3805 2 이음 MT006544 PVC지수판 200×5T 표기만 다름
37 36 PVC용접봉 Ø3.2 (국산) 2 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음
38 37 PVC파이프(일반용VG2) 50㎜ × 4m 2 이음 MT002926 PVC파이프(일반관),서울 D=50mm(VG2) 표기만 다름
39 38 혼합골재 영월 2 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음
40 39 이형철근 별도계상 2 잇지 않음 별도계상
41 40 안전표지판 삼각,D=900 2 이음 MT003202 교통안전표지판 900,삼각(고휘도) 눈으로 추정
42 41 표지판지주 D=76.3mm, 3.5M 2 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음
43 42 블레이드(커터) d=320 400mm 14"xt3.2 2 이음 MT005749 블레이드 14"×3.2mm 표기만 다름
44 43 떼 2 잇지 않음 우리 표에 없음
45 44 강관비계 φ48.6m/m×2.3mm 2 이음 MT000481 강관비계,서울 Φ48.6*2.3*4.0m 표기만 다름
46 45 철물 2 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음
47 46 각재(말뚝) Φ60 2 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음
48 47 강관써포트 V4 2600-4000mm 14.3kg 2 이음 MT000487 강관동바리,서울 Φ48.6*2.4,L=2.6~4.0 눈으로 추정
49 48 안고초 2 이음 MT006667 산림종자 안고초 표기만 다름
50 49 비수리 2 이음 MT006665 산림종자 비수리 표기만 다름
51 50 내수합판1급 12m/m 1220×2440 2 이음 MT000018 합판(내수), 서울 12t×1220×2440mm 표기만 다름
52 51 각재(외송) 3.6×3.0×3.0 2 이음 MT000393 각 재(외송) 30×30×3600mm 눈으로 추정
53 52 내수합판1급 12mm 1220×2440 2 이음 MT000018 합판(내수), 서울 12t×1220×2440mm 표기만 다름
54 53 각재(거푸집용,외송) 3.6×3.0×3.0 2 이음 MT000393 각 재(외송) 30×30×3600mm 눈으로 추정
55 54 다이아몬드톱날 Ø14"×3.2t(콘크리트용) 2 이음 MT005749 블레이드 14"×3.2mm 눈으로 추정
56 55 아세틸렌 용접용 2 이음 MT003163 아세틸렌 98% (용접용) 표기만 다름
57 56 산소 고압가스 2 이음 MT003166 산 소 고압상태 눈으로 추정
58 57 신나 KSM6060 2종(조합페인트류) 2 이음 MT003398 신너(조합페인트용) KSM-6060(2종) 표기만 다름
59 58 초류종자(국내산) 참싸리,비수리,낭아초등 1 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음
60 59 종 자 초본형 1 후보 1 순위 MT006646 종 자 초본위주형 좁힘(자동 이음 아님)
61 60 소나무우드칩(멀칭재) 1 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음
62 61 3단코어비트 5"(125mm) 1 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음
63 62 P V C PIPE Φ50M/M,VG1 1 이음 MT002861 PVC파이프(일반관),서울 D=50mm(VG1) 표기만 다름
64 63 콘테이너(창 고) 3×6×2.6 1 이음 MT007111 콘테이너(창고) 6.0*3.0*2.6m 표기만 다름
65 64 강관비계 48.6mm×2.3mm×6m 1 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음
66 65 철물 직교자재 1 이음 MT000499 조임철물,서울 직교,자재 눈으로 추정
67 66 문양거푸집(ABS코팅품) 600×1200 1 이음 MT000453 문양거푸집(플라스틱 ABS코팅폼) 600×1200 표기만 다름
68 67 와이어매쉬 #6 100×100 1 이음 MT007130 와이어메쉬(용접철망)관급 #6, 100×100 표기만 다름
69 68 지수판 T5×200 1 이음 MT006544 PVC지수판 200×5T 표기만 다름
70 69 용접봉(연강용) 3.2m/m 1 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음
71 70 어닐링철선 (결속선) #6 4.0mm 10.1m/kg 1 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음
72 71 구역화물자동차(4.5Ton) 80km이내 1 이음 MT023493 구역화물 4.5톤, 80km 표기만 다름
73 72 구역화물자동차(4.5Ton) 60km이내 1 이음 MT023491 구역화물 4.5톤, 60km 표기만 다름
74 73 산소(6000ℓ) 99%,120kg/㎠ 1 이음 MT003165 산 소 6000ℓ/병 표기만 다름
75 74 아세칠렌 AC 98%,용접용 1 이음 MT003163 아세틸렌 98% (용접용) 표기만 다름
76 75 서비스볼트(고장력볼트) M20×60 1 이음 MT002536 고장력볼트 M20*60mm 표기만 다름
77 76 보조강재(열연강판) T=9-12 1 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음
78 77 구역화물자동차(8Ton) 10km이내 1 이음 MT023654 구역화물 8.0톤, 10km 표기만 다름
79 78 구역화물자동차(8Ton) 20km이내 1 이음 MT023655 구역화물 8.0톤, 20km 표기만 다름
80 79 참싸리(박피) 1 이음 MT006664 산림종자 참싸리(풍화암,강마사토) 눈으로 추정
81 80 부산물비료 20kg/포 1 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음
82 81 참싸리 박피 1 이음 MT006664 산림종자 참싸리(풍화암,강마사토) 눈으로 추정
83 82 복합비료 21-17-17 20kg/포 1 이음 MT006490 복합비료 21-17-17 표기만 다름
84 83 퇴비(천연부엽토) 20㎏/포 1 이음 MT006788 천연부엽토 20kg/포 눈으로 추정
85 84 문양거푸집(ABS코팅폼) 600×1200×20~30mm 1 이음 MT000453 문양거푸집(플라스틱 ABS코팅폼) 600×1200 표기만 다름
86 85 P.V.C PIPE VG1(50mm) 1 이음 MT002861 PVC파이프(일반관),서울 D=50mm(VG1) 표기만 다름
87 86 다웰바(DOWEL BAR) Ø32×1,000 1 이음 MT006540 다 웰 바 Φ32*1000 표기만 다름
88 87 P.V.C PIPE VG1(35mm) 1 이음 MT002859 PVC파이프(일반관),서울 D=35mm(VG1) 표기만 다름
89 88 휠라 T=15mm,1000×1000mm 1 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음
90 89 측구수로관 300×300×2000×120,앵글포함 1 이음 MT015883 측구수로관(앵글부착) 300×300×120 표기만 다름
91 90 중하중그레이팅 395×995×52 1 이음 MT007025 스틸그레이팅(중하중용) 395×995×52 표기만 다름
92 91 측구수로관 500×500×2000×150,앵글포함 1 이음 MT015887 측구수로관(앵글부착) 500×500×150 표기만 다름
93 92 중하중그레이팅 595×995×67 1 이음 MT007032 스틸그레이팅(중하중용) 595×995×67 표기만 다름
94 93 잔디 0.18×0.18×0.01 1 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음
95 94 강관비계 4000×48.6mm×2.3mm 1 이음 MT000481 강관비계,서울 Φ48.6*2.3*4.0m 표기만 다름
96 95 삼각표지판 삼각,900mm 1 이음 MT003202 교통안전표지판 900,삼각(고휘도) 눈으로 추정
97 96 표지판지주 D=76.3mm×3.0m 1 이음 MT003241 표지판지주 Φ76.3×3000 표기만 다름
98 97 공사안내표지판 300×1500 1 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음
99 98 공사안전PE드럼 500×800 1 이음 MT003311 PE드럼(일체형) Φ500×800 표기만 다름
100 99 윙카호스 10cm 1 후보 사람이 고름 — 틀린 줄에 자동으로 잇지 않음
101 100 조합페인트 KSM6020 2급,백색 1 이음 MT003372 조합페인트 KSM-6020 2급(백색) 표기만 다름
@@ -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) · 현행 품셈 기계 목록에 없음 | 조사 끝 |
@@ -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년 초에 진행예정. 프로그램 막바지에 진행예정. 너는 완료처리
+14 -9
View File
@@ -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",
+102
View File
@@ -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"]}
@@ -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
+87
View File
@@ -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"]
+36
View File
@@ -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"] == ["검색어를", "검색할 "]
+102
View File
@@ -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,
}
@@ -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"] == "눈금 단위를 고르세요."
+90
View File
@@ -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,
}
+53
View File
@@ -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('<th>B</th>'), html.includes('font-weight:bold'), vm.runInContext('EstQuantitySheets.excelView({})', ctx)] }));
"""
result = json.loads(subprocess.check_output(["node", "-e", script], cwd=ROOT).decode("utf-8"))
assert result["message"].startswith("엑셀형 수량산출서 형식")
assert result["html"] == [True, True, True, ""]
+21 -1
View File
@@ -452,9 +452,29 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD
- [x] 낙찰률 적용 범위(원본 TFRMBIMOGBIUL 내역서낙찰·공종별) — 전체낙찰율/비목별, 전체·선택한 줄·선택한 공종 하위, 낙찰 적용된 줄 제외. 기존 계산(`EstBusiness.bid`)에 행 묶음 지정만 더함. 편집 메뉴에 낙찰률 적용·당초단가 복원.
- 검증완료: `test_est_bid.py` 2개(공종 하위만 변경·밖 불변, 적용된 줄 제외·빈 선택·제목 없음·음수 거부), 낙찰 관련 업무·설계변경 시험 포함 41개 통과. ORCA 브라우저에서 「구조물공」 선택 → 전체 80% 공종별 → 하위 4줄만 변경·순공사비 45,293,066 → 42,598,790, 되돌리기 원상.
- 남음: 근거자료낙찰 탭(공종별 낙찰율 목록), 「연금·건강보험료·안전관리비 고정」.
- [x] 예정공정표 눈금 단위(원본 TFRMSCHEDULE mnu1Day·5Day·10Day·15Day·20Day·1Week·1Month) — 기간 칸 머리에 눈금 날짜, 막대 뒤 눈금선, 60개 초과 시 앞 60개.
- 검증완료: `test_est_schedule_scale.py`(1주·1개월·10일 눈금 위치·날짜, 60개 자르기, 빈 기간, 잘못된 단위 거부) 통과. ORCA 브라우저에서 1개월 → 1주 전환 시 머리 날짜 03-01·03-08… 11개, 글자 간격 47px 이상 확인 후 샘플 복구.
- [x] 원가계산서 사용자 양식(원본 TFRMWONGABASE popupSaveAs·popupFormats·popupLoadFrom, TFRMFORMATFILELIST 등록·삭제·열기·파일) — 서버 보관함 `data/forms.json`(`forms-store.cjs`, 종류 wonga·document), 등록·목록·열기·삭제·파일 내려받기/불러오기. 금액 제외, 열 때 도급액 고정 해제(저장 금액을 쓰는 옵션이라 금액 없는 양식에서 0이 됨을 확인).
- 검증완료: `test_est_forms_library.py` 2개(등록·같은 이름 교체·목록·삭제·잘못된 종류/이름/내용 거부, 금액 제외·잘못된 행 거부·다른 동작 무시) 통과. ORCA 브라우저에서 등록 → 목록 31행 → 원가 표를 3행으로 줄인 뒤 열기 → 31행 복구·도급액 79,603,324(고정 해제) → 삭제·보관함 비움, 샘플 복구.
- 남음: 기본양식으로 저장(새 공사 기본값), 갑지·표지 문서 양식.
- [x] 정산원가계산서(원본 popJunSangCalc 0xd0eb28→0xd0f6fc, popJungSangShow 0xd0ea24, 재계산 0xca31c8 — 디스어셈블 근거) — 당초금액 초기화(SC := Cost, 55 → 0, JungSangInit), 작성/해제 토글(JungSangShow), 차액 행(LinkIndex 66, (감)/(증)) 재계산마다 갱신, 원가 화면 당초금액·정산금액 열. 엔진은 66 행 수식을 계산하지 않음.
- 검증완료: `test_est_jungsang.py` 2개(차액 글자·빈 행 재사용, 당초 복사·55 해제·재계산 차액·해제 시 66 비움·SC 유지·열 순서), 대조·업무 시험 포함 통과. ORCA 브라우저에서 작성 → 열 「당초금액, 정산금액」·차액 (증)324(도급액 고정 해제분)·오류 0, 해제 → 「원가금액」 복귀, 샘플 복구.
- 남음: 당초분·정산분 색상 옵션.
- [x] 무관자료 삭제(원본 mnuNotLinkDelete·DelSort → f039f0, MulDelete → 1116db0 — 디스어셈블 근거) — 내역서에서 LinkIndex 5·6·21·101·103·104·105·Code(설계변경 Code_ 포함)로 하위까지 따라가 쓰는 자료만 남김, 묶음 머리 앞 빈 줄 정리, 내역서순 재배열(코드 유지), 내역 미연결 수량산출서 삭제. 원본의 한 번 훑기 누락(기계경비 안에서만 쓰인 일위대가 하위)은 웹에서 끝까지 따라가 막음. LinkIndex 211–218 코드 비우기는 뜻 미확인으로 하지 않음.
- 검증완료: `test_est_cleanup.py` 3개(안 쓰는 자재·일위 삭제·합계 불변·오류 0, 내역서순 순서·코드 유지, 빈 줄·수량산출서) 통과. 설치 main·sample·산림토목 ESTX에서 정리 전후 도급액·오류 수 같음. ORCA 브라우저에서 내역서순 정리 → 자재 30 → 17·도급액 79,603,000 유지·되살리기 복원.
- [x] 품명정리·자재/견적단가 등록(원본 ActionNameDeivde 0xf5a2b4·TfrmNameDevide 0xf595c0/0xf59640, popMainStuffcReg/EstimateReg → c_f00264/c_f00670 — 디스어셈블 근거) — 커서 자리 나누기·삭제할 칸·빈 단위만 채움·호표 목록 동기화, 등록 시 같은 품명·규격 거부·호표 거부·M/W+PkID 10자리 코드·자재 Cost1=TC=재료단가·견적 NC/GC/JC·원래 행 연결. 원본은 품명정리를 2개 이상 선택 때만 돌리나 웹은 1개도 허용.
- 검증완료: `test_est_name_reg.py` 2개(나누기·삭제할 칸·제목 동기화·호표 아님 거부, 자재/견적 등록 코드·가격·연결·금액 불변·중복/호표 거부·계산 오류 0) 통과. ORCA 브라우저에서 내역 행 자재 등록 → M0000000038·연결 5, 일위대가 「레미콘타설(…)」 3자리 나누기 → 품명 「레미콘」·규격 「타설(인력운반타설)철근구조물」, 샘플 복구.
- [x] 정렬·데이타 위치정리·호표 정리·근거(메모)(원본 BarSort 0x110ADEC·TfrmSiljukSort, mnuTableSort 0x10CCAEC·TFRMTABLESORT, popTitleSort 0x10D88EC, mnuGunguEdit/Del·DifineMemo — 디스어셈블 근거) — 관급구분별 가나다·Index 재번호·빈 행 제외(30줄 채우기는 안 함), 품명 오름/내림·시작번호·묶음 통째 이동, 번호만 재매김·제목표 동기화, 메모 WPTools 글자 읽기 전용 보기·삭제. 원본 위치정리 콤보 글자 뒤바뀜은 웹에서 실제 순서대로 표기.
- 검증완료: `test_est_tidy.py` 2개(관급구분·빈 행·실적 코드 정렬·화면 거부, 위치정리·번호정렬·제목 동기화·도급액 불변·오류 0) 통과. ORCA 브라우저에서 설치 main.estx 메모를 넣어 보기 → 「2015년 건설표준품셈 … 잔디블록」 글자 표시, 삭제 → null, 자재 정렬 → 빈 행 12개 제외, 샘플 복구.
- 남음: 메모 편집(원본 서식 저장), 위치정리 드래그 이동.
- [x] 통합 검색(원본 TFRMMULTISERCH 자료 7종·검색어·검색위치) — 검색위치는 이 앱 저장 작업(`search-store.cjs`, `/api/search`), 고른 자료를 하위 연결째 현재 공사로 가져옴(EstTransfer). 원본 「Main Data에서 검색」·폴더 고르기는 웹 서버에 원본 자료 폴더가 없어 저장 작업으로 대신함.
- 검증완료: `test_est_multi_search.py`(낱말 모두 포함·자료 종류·제외 작업·깨진 파일 무시·빈 검색어/종류 거부) 통과. ORCA 브라우저에서 저장 작업 59개 대상 「콘크리트」 단가산출 85건 → 없는 D0000000140 가져오기 → 연결 자료 3건·단가산출 21행 추가·단가 28,878 원본과 같음·오류 0, 샘플 복구.
- [ ] 엑셀형 수량산출서(원본 TFRMMULEXCEL, MulMainDat.Editor = OLE2 .xls BIFF8)
- [x] 1단계 읽기 — `xls-read.cjs`(복합문서·SST/CONTINUE·LABELSST·NUMBER·RK·MULRK·LABEL·BOOLERR·FORMULA 저장값), 가져올 때 `sheet.excel`, 수량산출 화면 읽기 전용 격자(수식 셀 굵게).
- 검증완료: `test_est_xls_read.py` 2개(설치 main.estx 엑셀형 6개·토적계산표 B2·No.1·20·수식 16개, OLE 아님 거부·격자 그리기), 수량 시험 포함 22개 통과. 엑셀형 붙인 뒤에도 ESTX 원본 그대로 저장 동일. ORCA 브라우저에서 토적계산표 격자 표시·수식 칸 16개 확인 후 샘플 복구.
- 남음: 셀 편집·수식 계산·4방향 합계·품명/규격/수량 지정(항목과 셀 대응 근거 미확인), .xls 저장.
- 대조 합계(CAD 제외 882개): 구현 283 · 부분 193 · 없음 273 · 제외 133.
- [ ] 남은 핵심(실무 빈도순):
1. 통합검색 — 저장된 다른 작업에서 단가·호표 찾기.
3. 수량산출서 — 누계·4방향 합계, 제출용 전체 인쇄.
7. 무관자료 삭제, 데이타 정리(정렬·번호), 품명정리.
8. 원가계산서 — 정산원가계산서, 기타경비·요율 대화, 서식 저장·불러오기, 기성 원가 편집.
+16
View File
@@ -22,6 +22,20 @@
내역서·일위대가 화면의 `편집 → 단가 고르기`는 자재·노임·기계경비·일위대가·단가산출·견적·실적 자료를 검색어로 찾아 선택 행 아래에 넣거나, 선택 행 하나의 연결을 바꿉니다. 여러 개를 골라 한 번에 넣을 수 있고, 바꿀 때는 변경 전·후를 확인한 뒤 같은 코드를 쓰는 이 표의 다른 행도 함께 바꿀지 고릅니다. 넣거나 바꾼 행만 원천 단가를 다시 읽습니다. 단가산출 산출근거 행은 대상이 아닙니다.
`편집 → 선택 행 자재단가 등록`·`견적단가 등록`은 원본처럼 내역·일위대가 행을 새 자재(M)·견적(W) 단가로 등록하고 그 행을 새 코드에 연결합니다. 같은 품명·규격이 있으면 거부하고, 일위대가 호표 제목 줄은 등록하지 않습니다. `품명정리`는 고른 일위대가 호표의 품명을 커서 자리에서 나눠 뒤쪽을 규격 앞에 붙이고, 단위가 비어 있으면 채웁니다.
`편집 → 통합 검색`은 원본 통합 검색처럼 일위대가·단가산출·기계경비·견적·실적·자재·노임 중 고른 자료를 검색어로 찾습니다. 검색 위치는 이 앱에 저장된 다른 작업입니다. 찾은 자료를 고르면 하위 연결 자료와 함께 현재 공사로 가져오고(같은 코드는 현재 자료 유지), 단가 고르기로 행에 넣습니다.
`편집 → 정렬(가나다)`은 기초단가 표를 관급구분별로 묶어 품명·규격 순(실적은 코드 순 선택)으로 정렬하고 번호를 다시 매기며 빈 행은 뺍니다. `데이타 위치정리`는 기초단가 또는 일위대가·단가산출·기계경비 호표를 품명 오름/내림으로 다시 놓고 시작번호부터 번호를 매깁니다. `호표 정리`는 차례는 그대로 두고 호표 번호만 시작번호부터 다시 매깁니다. `근거(메모) 보기`는 원본 서식 메모의 글자를 읽기 전용으로 보이고, `근거(메모) 삭제`는 고른 행의 메모를 지웁니다.
`편집 → 무관자료 삭제`는 원본처럼 내역서가 쓰는 자재·노임·실적·견적과 일위대가·단가산출·기계경비 호표를 하위까지 따라가 표시하고 나머지를 지웁니다. 내역서·원가계산서·기계경비 적용기준은 지우지 않고, 묶음 머리 앞의 빈 줄을 정리합니다. `(내역서순)`은 표를 내역서 첫 등장 순서로 다시 늘어놓으며 코드는 바꾸지 않습니다. `무관 수량산출서 삭제`는 내역에 연결되지 않은 수량산출서를 지웁니다. 모두 되살리기로 되돌릴 수 있습니다.
`원가계산서 → 정산원가계산서 작성/해제`는 원본처럼 원가 표에 당초금액 열을 금액 앞에 보이고 금액 열을 정산금액으로 바꿉니다. 처음 켤 때(또는 `당초금액 초기화`) 원가 행마다 현재 금액을 당초금액으로 옮기고 도급액 고정 연결(55)을 풀며, 맨 아래 금액 행의 차이를 `(감)`·`(증)` 금액 행으로 적습니다. 켜져 있는 동안 재계산마다 차액 행을 다시 씁니다. 끄면 차액 행만 비우고 당초금액은 남깁니다.
`원가계산서 → 원가계산서 양식 목록·등록`은 현재 원가계산서의 항목·수식·요율을 이름 붙여 이 앱 `data/forms.json`에 등록하고, 목록에서 열기·삭제하며, 파일로 내려받거나 불러옵니다. 금액은 남기지 않고 열 때 다시 계산하므로 도급액 고정은 풀립니다.
예정공정표의 `눈금`은 원본처럼 1일·5일·10일·15일·20일·1주·1개월 단위로 기간 칸 위에 날짜를 적고 막대 뒤에 세로 눈금선을 그립니다. 눈금이 60개를 넘으면 앞 60개만 그립니다.
`편집 → 낙찰률 적용`은 원본 낙찰율 창처럼 전체낙찰율(한 비율) 또는 비목별낙찰율을 고르고, 전체 줄·선택한 줄·선택한 공종(하위 줄 모두)에 적용합니다. 이미 낙찰이 적용된 줄은 제외할 수 있고, `당초단가(100%) 복원`으로 되돌립니다.
`파일 → 프로젝트 추가`는 다른 공사 파일(ESTX·웹 JSON)의 순공사비 위 내역 행 전체를 하위 자료와 함께 이 공사의 순공사비 위에 합칩니다. 같은 코드 자료는 이 공사의 것을 그대로 씁니다.
@@ -34,6 +48,8 @@
`편집 → 찾기·다음찾기`(Ctrl+F)는 원본 찾기 창처럼 검색어를 처음부터 또는 현재 위치부터 찾아 해당 행으로 이동합니다. 일위대가·단가산출·기계경비는 찾은 행의 호표로 넘어갑니다. 단가산출에서는 찾은 행의 문구를 한 건씩 바꾸고 다음 위치로 이동하며, 모두 바꾸기는 `산출근거 문구 바꾸기`를 씁니다. 표 위 검색창은 걸러 보기로 그대로 남습니다.
원본의 엑셀형 수량산출서(본문이 옛 Excel .xls)는 가져올 때 셀 값을 읽어 수량산출 화면 아래에 읽기 전용 격자로 보입니다. 수식 셀은 Excel이 저장해 둔 계산값을 굵게 보이며, 셀 편집·다시 계산은 아직 하지 않습니다.
산출 줄 옆 `수량 지정`은 줄 글에서 드래그해 고른 숫자를 결과 표지로 감싸고 수량 항목을 새로 만듭니다. 품명·규격·단위 표지가 있으면 항목 이름에 옮깁니다. `지정 해제`는 커서가 놓인 결과 표지를 풀고, 다른 줄에서 쓰지 않는 항목을 지웁니다. 내역에 연결된 항목은 먼저 연결을 비워야 합니다. 이 변경이 있는 작업은 ESTX 수정 저장을 거부합니다.
수량산출 화면의 `산출서 복사`는 현재 산출서를 바로 아래에 복사하고, 내역에 두 번 반영되지 않도록 복사본의 내역 연결을 비웁니다. `산출서 위로`·`아래로`로 순서를 바꿉니다. 산출서 추가·순서 변경이 있는 작업은 ESTX 수정 저장을 거부하고 JSON 저장을 안내합니다.
+54
View File
@@ -0,0 +1,54 @@
// 원본 양식목록(TFRMFORMATFILELIST: 등록·삭제·열기)의 사용자 양식 보관함 — 이 앱 data/forms.json 한 파일.
// 종류(kind)별 이름 목록. 같은 종류·이름이면 바꿔 쓴다.
const fs = require("node:fs");
const path = require("node:path");
const kinds = new Set(["wonga", "document"]);
const LIMIT = 2 * 1024 * 1024;
function file(directory) {
return path.join(directory, "forms.json");
}
function read(directory) {
const target = file(directory);
if (!fs.existsSync(target)) return [];
const list = JSON.parse(fs.readFileSync(target, "utf8"));
return Array.isArray(list) ? list : [];
}
function write(directory, list) {
fs.mkdirSync(directory, { recursive: true });
const target = file(directory),
temporary = `${target}.tmp`;
fs.writeFileSync(temporary, JSON.stringify(list), "utf8");
fs.renameSync(temporary, target);
}
function check(kind, name) {
if (!kinds.has(kind)) throw Error("양식 종류가 올바르지 않습니다.");
if (typeof name !== "string" || !name.trim() || name.length > 100)
throw Error("양식 이름은 1~100자입니다.");
}
function save(directory, entry) {
check(entry?.kind, entry?.name);
if (entry.data == null) throw Error("양식 내용이 없습니다.");
if (JSON.stringify(entry.data).length > LIMIT) throw Error("양식은 2MB 이하만 등록합니다.");
const list = read(directory).filter((f) => !(f.kind === entry.kind && f.name === entry.name));
list.push({
kind: entry.kind,
name: entry.name.trim(),
form: entry.form ?? null,
data: entry.data,
saved: new Date().toISOString(),
});
write(directory, list);
return list;
}
function remove(directory, kind, name) {
check(kind, name);
const list = read(directory),
next = list.filter((f) => !(f.kind === kind && f.name === name));
if (next.length === list.length) throw Error("지울 양식이 없습니다.");
write(directory, next);
return next;
}
module.exports = { read, save, remove };
+9 -6
View File
@@ -1,4 +1,5 @@
// Read-only ESTX import. Layout: knowledge/다산소프트/ESTX_파일구조.md.
const { readXls } = require("./xls-read.cjs");
const zlib = require("node:zlib");
const quantity = require("./native-quantity.cjs");
const { readPrintOptions } = require("./import-print.cjs");
@@ -366,12 +367,14 @@ function importEstx(buffer, name = "가져온 내역서") {
const text = new TextDecoder("euc-kr").decode(bytes);
const items = memoryRows(Buffer.from(row.MulList.binary, "base64"));
const name = tables.MulTitleDat?.find((r) => r.Code === row.Code)?.Name || row.Code;
quantitySheets.push(
quantity.fromWpt(row.Code, name, text, items, {
digits: props.TMulLangOption?.Diget ?? 2,
roundMode: (props.TMulLangOption?.CalcMethod ?? 1) + 1,
}),
);
const sheet = quantity.fromWpt(row.Code, name, text, items, {
digits: props.TMulLangOption?.Diget ?? 2,
roundMode: (props.TMulLangOption?.CalcMethod ?? 1) + 1,
});
// 엑셀형 수량산출서(OLE2 .xls)는 읽기 전용 셀 값으로 붙인다.
if (bytes.length > 8 && Buffer.from(bytes).readUInt32LE(0) === 0xe011cfd0)
sheet.excel = readXls(Buffer.from(bytes));
quantitySheets.push(sheet);
} catch (error) {
warnings.add(`수량산출 ${row.Code}: ${error.message}`);
}
+22
View File
@@ -12,6 +12,7 @@
<script defer src="native-text.js"></script>
<script defer src="native-quantity.cjs"></script>
<script defer src="native-wonga.js"></script>
<script defer src="native-jungsang.js"></script>
<script defer src="native-engine.js"></script>
<script defer src="native-row-match.js"></script>
<script defer src="native-escalation.js"></script>
@@ -44,6 +45,11 @@
<script defer src="native-excel-map.js"></script>
<script defer src="native-transfer.js"></script>
<script defer src="native-bid.js"></script>
<script defer src="native-forms-library.js"></script>
<script defer src="native-cleanup.js"></script>
<script defer src="native-name-reg.js"></script>
<script defer src="native-tidy.js"></script>
<script defer src="native-multi-search.js"></script>
<script defer src="native-actions.js"></script>
<script defer src="native-business-actions.js"></script>
<script defer src="native-app.js"></script>
@@ -100,6 +106,10 @@
><button data-action="block-del">블럭 삭제</button>
<hr />
<button data-action="find">찾기·다음찾기(Ctrl+F)</button>
<button data-action="multi-search">통합 검색(저장된 다른 작업)</button>
<button data-action="register-stuff">선택 행 자재단가 등록</button
><button data-action="register-estimate">선택 행 견적단가 등록</button
><button data-action="name-divide">품명정리(일위대가 호표)</button>
<button data-action="pick-open">
단가 고르기(자재·노임·기계·일위·단가산출·견적·실적)
</button>
@@ -114,6 +124,15 @@
<button data-action="clear-estimate-quantities">내역 수량 초기화</button>
<button data-action="bid-rates">낙찰률 적용</button
><button data-action="restore-bid">당초단가(100%) 복원</button>
<hr />
<button data-action="tidy-sort">정렬(가나다, 기초단가)</button
><button data-action="tidy-arrange">데이타 위치정리</button
><button data-action="tidy-number">호표 정리(번호정렬)</button
><button data-action="memo-view">근거(메모) 보기</button
><button data-action="memo-delete">근거(메모) 삭제</button>
<button data-action="notlink-delete">무관자료 삭제</button
><button data-action="notlink-sort">무관자료 삭제(내역서순)</button
><button data-action="notlink-quantity">무관 수량산출서 삭제</button>
</div>
</details>
<details>
@@ -150,6 +169,9 @@
><button data-action="template">원가 서식 선택</button
><button data-action="settings">자릿수·도급액 고정</button
><button data-action="reset-wonga">원가계산서 초기화</button>
<button data-action="wonga-forms">원가계산서 양식 목록·등록</button>
<button data-action="jungsang-toggle">정산원가계산서 작성/해제</button
><button data-action="jungsang-init">당초금액 초기화</button>
</div>
</details>
<details>
+7 -1
View File
@@ -605,7 +605,13 @@ const EstBusinessActions = (() => {
(typeof EstQuantitySheets !== "undefined" && EstQuantitySheets.handle(name, state, node)) ||
(typeof EstExcelMap !== "undefined" && EstExcelMap.handle(name, state)) ||
(typeof EstTransfer !== "undefined" && EstTransfer.handle(name, state)) ||
(typeof EstBid !== "undefined" && EstBid.handle(name, state))
(typeof EstBid !== "undefined" && EstBid.handle(name, state)) ||
(typeof EstFormsLibrary !== "undefined" && EstFormsLibrary.handle(name, state, node)) ||
(typeof EstJungSang !== "undefined" && EstJungSang.handle(name, state)) ||
(typeof EstCleanup !== "undefined" && EstCleanup.handle(name, state)) ||
(typeof EstNameReg !== "undefined" && EstNameReg.handle(name, state)) ||
(typeof EstTidy !== "undefined" && EstTidy.handle(name, state)) ||
(typeof EstMultiSearch !== "undefined" && EstMultiSearch.handle(name, state))
) {
EstApp.render();
} else if (name === "load-contract" || name === "load-bid") {
+147
View File
@@ -0,0 +1,147 @@
// 원본 무관자료 삭제(TfrmEstMain mnuNotLinkDelete 0x10cd2a8·mnuNotLinkDelSort 0x10cd2b8 → f039f0,
// mnuNotLinkMulDelete 0x10cd2c8 → 1116db0). 내역서가 쓰는 자료(LinkIndex 5·6·21·101·103·104·105, Code 일치)를
// 일위대가·단가산출·기계경비 묶음 안까지 따라가 표시하고 나머지 기초·묶음 자료를 지운다. 내역서·원가·적용기준은 지우지 않는다.
// 내역서순: 내역서 첫 등장 순서(중첩은 깊이 우선)로 표를 다시 늘어놓고, 코드는 바꾸지 않는다.
// 설계변경 당초 코드(Code_)도 함께 따라간다. 원본은 내역서→일위→단가→기계 차례 한 번 훑기라 기계경비 안에서만
// 쓰인 일위대가의 하위를 놓칠 수 있으나, 웹은 끝까지 따라가 쓰는 자료를 지우지 않는다.
const EstCleanup = (() => {
const flat = { 5: "StuffcDat", 6: "LaborcDat", 21: "SilJukDat", 101: "EstiMateDat" };
const grouped = {
103: ["HeadTitleDat", "HeadMainDat"],
104: ["CostTitleDat", "CostMainDat"],
105: ["MachTitleDat", "MachMainDat"],
};
const num = (value) => Number(value) || 0;
const text = (value) => String(value ?? "").trim();
function used(project) {
const order = new Map(Object.values(flat).map((t) => [t, []])),
seen = new Set(),
groups = {};
for (const [, [, main]] of Object.entries(grouped)) {
order.set(main, []);
groups[main] = EstNative.groups(project.tables[main] || []);
}
function visit(rows) {
for (const row of rows)
for (const code of new Set([row.Code, row.Code_].filter(Boolean))) {
const kind = num(row.LinkIndex),
id = `${kind}:${code}`;
if (seen.has(id)) continue;
if (flat[kind]) {
if ((project.tables[flat[kind]] || []).some((r) => r.Code === code)) {
seen.add(id);
order.get(flat[kind]).push(code);
}
} else if (grouped[kind]) {
const [, main] = grouped[kind],
group = groups[main].get(code);
if (!group) continue;
seen.add(id);
order.get(main).push(code);
visit(group.rows.slice(1));
}
}
}
visit(project.tables.EstMainDat || []);
return order;
}
function blankRow(table, row) {
if (table === "CostMainDat") return !text(row.LineData);
return (
!text(row.Name) &&
!text(row.Size) &&
!text(row.Unite) &&
!(typeof row.Bigo === "string" && text(row.Bigo)) &&
!num(row.GN) &&
!num(row.TC)
);
}
// 빈 줄 정리(f038ac): 묶음 머리 바로 앞의 빈 행을 지운다.
function trimBeforeHeads(rows, table) {
return rows.filter((row, i) => {
if (!blankRow(table, row) || num(row.Index) > 0) return true;
let next = i + 1;
while (next < rows.length && blankRow(table, rows[next]) && !(num(rows[next].Index) > 0))
next++;
return !(next < rows.length && num(rows[next].Index) > 0);
});
}
function clean(project, sorted) {
const order = used(project),
removed = {};
for (const table of Object.values(flat)) {
const rows = project.tables[table] || [],
keep = order.get(table),
byCode = new Map(rows.map((r) => [r.Code, r]));
const next = sorted
? keep.map((code) => byCode.get(code))
: rows.filter((r) => keep.includes(r.Code));
removed[table] = rows.length - next.length;
project.tables[table] = next;
}
for (const [title, main] of Object.values(grouped)) {
const keep = order.get(main),
groups = EstNative.groups(project.tables[main] || []),
before = groups.size;
const codes = sorted ? keep : [...groups.keys()].filter((code) => keep.includes(code));
project.tables[main] = trimBeforeHeads(
codes.flatMap((code) => groups.get(code)?.rows || []),
main,
);
const titles = project.tables[title] || [],
byCode = new Map(titles.map((r) => [r.Code, r]));
project.tables[title] = sorted
? codes.map((code) => byCode.get(code)).filter(Boolean)
: titles.filter((r) => keep.includes(r.Code));
removed[main] = before - codes.length;
}
return removed;
}
// 수량산출서: 내역 행이 쓰는(MullangIndex 9999·MulCode 앞 조각) 산출서와, 항목이 내역에 연결된 산출서만 남긴다.
function cleanQuantity(project) {
const sheets = project.quantitySheets || [],
estimate = project.tables.EstMainDat || [],
codes = new Set(estimate.map((r) => r.Code).filter(Boolean)),
keys = new Set(estimate.map((r) => r.PkID != null && `id:${r.PkID}`).filter(Boolean)),
named = new Set(
estimate
.filter((r) => num(r.MullangIndex) === 9999 && typeof r.MulCode === "string")
.map((r) => r.MulCode.split("\t")[0]),
);
const keep = sheets.filter(
(sheet) =>
named.has(sheet.Code) ||
(sheet.items || []).some(
(item) =>
(item.TargetKey && keys.has(item.TargetKey)) ||
(item.TargetCode && codes.has(item.TargetCode)),
),
);
project.quantitySheets = keep;
return sheets.length - keep.length;
}
const WARN =
"내역서상에 사용한 데이타(수량산출)만 남기고 나머지는 삭제합니다.\n삭제된 자료는 되살리기로만 복구할 수 있습니다.";
function handle(name, state) {
if (name === "notlink-delete" || name === "notlink-sort") {
if (!confirm(WARN)) return true;
let removed;
EstApp.change((project) => (removed = clean(project, name === "notlink-sort")));
const total = Object.values(removed).reduce((a, b) => a + b, 0);
state.notice = `안 쓰는 자료 ${total}건을 지웠습니다${name === "notlink-sort" ? "(내역서 순서로 정리)" : ""}.`;
} else if (name === "notlink-quantity") {
if (!confirm(WARN)) return true;
let removed;
EstApp.change((project) => (removed = cleanQuantity(project)), false);
state.notice = `내역에 연결되지 않은 수량산출서 ${removed}개를 지웠습니다.`;
} else return false;
return true;
}
return { used, clean, cleanQuantity, trimBeforeHeads, handle };
})();
+2
View File
@@ -339,6 +339,8 @@ const EstNative = (() => {
직접공사비: num(direct.TC),
});
tables.Wonga = wonga.rows;
if (options.JungSangShow && typeof EstJungSang !== "undefined")
EstJungSang.difference(tables.Wonga);
errors.push(...wonga.errors);
const wongaNames = new Map(
wonga.rows
+111
View File
@@ -0,0 +1,111 @@
// 원본 원가계산서 양식(TFRMWONGABASE popupSaveAs·popupFormats·popupLoadFrom)과 양식목록(TFRMFORMATFILELIST)
// — 현재 원가 표를 이름 붙여 서버 보관함(data/forms.json)에 등록하고, 목록에서 열기·삭제, 파일로 내려받기·불러오기.
const EstFormsLibrary = (() => {
// 원가 표 양식으로 남길 필드 — 금액(Cost)은 계산 결과라 남기지 않는다.
function strip(rows) {
return rows.map((row) => {
const { Cost, ...rest } = row;
return rest;
});
}
function validRows(rows) {
if (!Array.isArray(rows) || !rows.length) throw Error("원가계산서 양식에 행이 없습니다.");
if (rows.some((row) => !row || typeof row !== "object" || Array.isArray(row)))
throw Error("원가계산서 양식 행이 올바르지 않습니다.");
return rows;
}
async function list() {
return (await EstApp.api("/api/forms")).filter((f) => f.kind === "wonga");
}
async function show(state) {
const { esc } = EstApp,
forms = await list();
EstApp.showDialog(
"원가계산서 양식 목록",
`<p>현재 원가계산서의 항목·수식·요율을 양식으로 등록하고, 다른 공사에서 열어 씁니다. 금액은 열 때 다시 계산합니다.</p><button data-action="wonga-form-register">현재 원가계산서 등록</button> <button data-action="wonga-form-export">파일로 내려받기</button> <button data-action="wonga-form-import">파일에서 불러오기</button><table class="sheet" style="margin-top:8px"><thead><tr><th>양식 이름</th><th>행</th><th>등록</th><th>작업</th></tr></thead><tbody>${forms.map((f) => `<tr><td>${esc(f.name)}</td><td>${f.data.length}</td><td>${esc(f.saved.slice(0, 10))}</td><td><button data-action="wonga-form-open" data-name="${esc(f.name)}">열기</button><button data-action="wonga-form-delete" data-name="${esc(f.name)}">삭제</button></td></tr>`).join("") || '<tr><td colspan="4">등록된 양식이 없습니다.</td></tr>'}</tbody></table>`,
);
state.formsLibrary = forms;
}
function apply(state, rows, name) {
validRows(rows);
if (!confirm(`현재 원가계산서를 「${name}」 양식으로 바꾸시겠습니까?`)) return;
EstApp.byId("dialog").close();
// 도급액 고정은 저장된 금액을 그대로 쓰므로 금액 없는 양식에서는 풀어야 다시 계산된다(기초 뼈대와 같음).
const fixed = state.project.options.TotalFix;
EstApp.change((project) => {
project.options.TotalFix = false;
project.tables.Wonga = structuredClone(rows);
});
state.notice = `원가계산서를 「${name}」 양식으로 바꿨습니다.${fixed ? " 도급액 고정은 풀었으니 필요하면 다시 맞추세요." : ""}`;
}
async function run(name, state, node) {
if (name === "wonga-forms") await show(state);
else if (name === "wonga-form-register") {
const title = prompt("양식 이름", state.project.name || "원가계산서");
if (!title) return;
const data = strip(validRows(state.project.tables.Wonga));
await EstApp.api("/api/forms", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind: "wonga", name: title, data }),
});
await show(state);
} else if (name === "wonga-form-open") {
const form = state.formsLibrary?.find((f) => f.name === node.dataset.name);
if (!form) throw Error("양식을 찾을 수 없습니다. 목록을 다시 여세요.");
apply(state, form.data, form.name);
} else if (name === "wonga-form-delete") {
if (!confirm(`「${node.dataset.name}」 양식을 지우시겠습니까?`)) return;
await EstApp.api("/api/forms/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind: "wonga", name: node.dataset.name }),
});
await show(state);
} else if (name === "wonga-form-export") {
const data = strip(validRows(state.project.tables.Wonga));
EstApp.download(
`${state.project.name || "원가계산서"}.wonga.json`,
JSON.stringify({ kind: "est-web-wonga-1", rows: data }, null, 2),
);
} else if (name === "wonga-form-import") {
const input = document.createElement("input");
input.type = "file";
input.accept = ".json";
input.addEventListener("change", async () => {
try {
const file = input.files[0];
if (!file) return;
const parsed = JSON.parse(await file.text());
if (parsed?.kind !== "est-web-wonga-1") throw Error("원가계산서 양식 파일이 아닙니다.");
apply(state, parsed.rows, file.name);
} catch (error) {
state.notice = error.message;
}
EstApp.render();
});
input.click();
}
}
const names = new Set([
"wonga-forms",
"wonga-form-register",
"wonga-form-open",
"wonga-form-delete",
"wonga-form-export",
"wonga-form-import",
]);
// 동작 사슬은 동기 — 이름만 먼저 가리고 서버 요청은 뒤에서 돌린다.
function handle(name, state, node) {
if (!names.has(name)) return false;
run(name, state, node).catch((error) => {
state.notice = error.message;
EstApp.render();
});
return true;
}
return { strip, validRows, handle };
})();
+82
View File
@@ -0,0 +1,82 @@
// 원본 정산원가계산서(TfrmWongaBase popJunSangCalc 0xd0eb28 → 0xd0f6fc, popJungSangShow 0xd0ea24, 재계산 0xca31c8).
// 당초금액 초기화: 원가 행마다 SC(당초금액) := Cost, LinkIndex 55 → 0, 초기화 표지(JungSangInit), 차액 행 기록.
// 정산원가계산서 작성/해제: JungSangShow 토글 — 켤 때 초기화 전이면 확인 없이 초기화, 끌 때 차액 행(66)만 비움(SC 유지).
// 켜져 있는 동안 재계산마다 차액 행을 다시 쓴다. 차액 d = 마지막 금액 행의 SC − Cost, d>0 「(감)」·d<0 「(증)」.
const EstJungSang = (() => {
const num = (value) => Number(value) || 0;
const blank = (row) => !String(row.Name ?? "").trim() && !num(row.Cost) && !row.VSusic;
function difference(rows) {
let last = -1;
rows.forEach((row, i) => {
if (num(row.LinkIndex) !== 66 && String(row.Name ?? "").trim() && num(row.Cost)) last = i;
});
const d = last < 0 ? 0 : num(rows[last].SC) - num(rows[last].Cost);
const text = d > 0 ? `(감)${Math.abs(d)}` : d < 0 ? `(증)${Math.abs(d)}` : "0";
let at = rows.findIndex((row) => num(row.LinkIndex) === 66);
if (at < 0) {
for (let i = rows.length - 1; i >= 0 && blank(rows[i]); i--) at = i;
}
if (at < 0) {
rows.push({});
at = rows.length - 1;
}
Object.assign(rows[at], {
EndLineIndex: 1,
Name: ".",
VGungu: text,
VSusic: text,
LinkIndex: 66,
});
return text;
}
function init(project) {
const rows = project.tables.Wonga || [];
for (const row of rows) {
row.SC = num(row.Cost);
if (num(row.LinkIndex) === 55) row.LinkIndex = 0;
}
project.options.JungSangInit = 1;
return difference(rows);
}
function clear(project) {
const row = (project.tables.Wonga || []).find((r) => num(r.LinkIndex) === 66);
if (row) Object.assign(row, { Name: "", VGungu: "", VSusic: "", LinkIndex: 0 });
}
function toggle(project) {
project.options.JungSangShow = !project.options.JungSangShow;
if (project.options.JungSangShow) {
if (!num(project.options.JungSangInit)) init(project);
} else clear(project);
return project.options.JungSangShow;
}
// 화면: 정산 모드에서는 원가 표에 당초금액 열을 금액 앞에 보이고 금액 열 이름을 정산금액으로 바꾼다.
function columns(project, base) {
if (!project.options?.JungSangShow) return base;
const out = base.filter((key) => key !== "SC");
out.splice(out.indexOf("Cost"), 0, "SC");
return out;
}
function label(project, key) {
if (!project.options?.JungSangShow) return null;
return { SC: "당초금액", Cost: "정산금액" }[key] || null;
}
function handle(name, state) {
if (name === "jungsang-init") {
if (!confirm("당초금액이 정산금액으로 대체됩니다.")) return true;
EstApp.change((project) => init(project));
} else if (name === "jungsang-toggle") {
let on;
EstApp.change((project) => (on = toggle(project)));
state.notice = on ? "정산원가계산서를 작성합니다." : "정산원가계산서를 해제했습니다.";
} else return false;
return true;
}
return { difference, init, clear, toggle, columns, label, handle };
})();
+79
View File
@@ -0,0 +1,79 @@
// 원본 통합 검색(TFRMMULTISERCH) 화면 — 저장된 다른 작업에서 단가·호표를 찾아, 고른 자료를 하위 연결 자료와 함께
// 현재 공사로 가져온다(EstTransfer). 같은 코드는 현재 공사 자료를 그대로 둔다. 가져온 뒤 단가 고르기로 행에 넣는다.
const EstMultiSearch = (() => {
const kinds = [
[103, "일위대가"],
[104, "단가산출"],
[105, "기계경비"],
[101, "견적단가"],
[21, "실적단가"],
[5, "자재단가"],
[6, "노임단가"],
];
function form(state) {
const { esc } = EstApp,
saved = state.multiSearch || { query: "", kinds: kinds.map(([k]) => k), hits: [], total: 0 };
state.multiSearch = saved;
const rows = saved.hits
.map(
(h, i) =>
`<tr><td><input type="checkbox" name="ms-hit" value="${i}"></td><td>${esc(h.project)}</td><td>${esc(h.label)}</td><td>${esc(h.Code)}</td><td style="text-align:left">${esc(h.Name)}</td><td>${esc(h.Size)}</td><td>${esc(h.Unite)}</td><td style="text-align:right">${EstApp.number(h.price)}</td></tr>`,
)
.join("");
EstApp.showDialog(
"통합 검색",
`<p>검색위치: 이 앱에 저장된 다른 작업</p><div class="form-fields">${kinds.map(([k, label]) => `<label><input type="checkbox" name="ms-kind" value="${k}" ${saved.kinds.includes(k) ? "checked" : ""}>${label}</label>`).join("")}<label>검색어<input name="ms-query" value="${esc(saved.query)}"></label></div><button data-action="multi-search-run">검색</button> <button data-action="multi-search-take">고른 자료를 이 공사로 가져오기</button><div style="max-height:360px;overflow:auto;border:1px solid #ccc;margin-top:6px"><table class="sheet" style="font-size:12px"><thead><tr><th></th><th>작업</th><th>구분</th><th>코드</th><th>품명</th><th>규격</th><th>단위</th><th>단가</th></tr></thead><tbody>${rows}</tbody></table></div><p>찾은 자료 ${saved.total}건${saved.total > saved.hits.length ? ` 중 앞 ${saved.hits.length}건` : ""}</p>`,
);
}
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 };
})();
+131
View File
@@ -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(
"품명정리",
`<p>기존명칭에서 품명과 규격을 나눌 자리에 커서를 두고 「규격으로 지정」을 누르세요. 고른 ${picked.length}개 호표에 같은 자리로 적용합니다.</p><div class="form-fields"><label>기존명칭<input name="nd-all" value="${esc(rows[picked[0]].Name ?? "")}"></label><label>명칭<input name="nd-name" readonly></label><label>규격<input name="nd-size" readonly></label><label>규격에서 삭제할 칸<input name="nd-cut" type="number" value="0"></label><label>단위(비어 있을 때만)<input name="nd-unit"></label></div><button data-action="name-divide-set">규격으로 지정</button> <button data-action="name-divide-apply">적용</button>`,
);
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 };
})();
+31 -1
View File
@@ -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 "<td></td>";
const value = typeof cell.value === "number" ? number(cell.value) : esc(cell.value);
return `<td${cell.formula ? ' style="font-weight:bold" title="수식(저장된 계산값)"' : ""}>${value}</td>`;
}).join("");
return `<tr><th>${r + 1}</th>${cellsHtml}</tr>`;
}).join("");
return `<h4>엑셀형 산출서 · ${esc(name)} (읽기 전용)</h4><div style="overflow:auto;max-height:420px"><table class="sheet" style="font-size:11px"><thead><tr><th></th>${Array.from({ length: cols }, (_, c) => `<th>${letter(c)}</th>`).join("")}</tr></thead><tbody>${body}</tbody></table></div>`;
})
.join("");
}
return { copy, move, mark, free, excelView, handle };
})();
+1 -1
View File
@@ -315,7 +315,7 @@ const EstQuantity = (() => {
{ esc, number, byId } = EstApp;
byId("work-content").innerHTML =
selector(state) +
`<div class="business-tools"><label>산출서명 <input data-quantity-name value="${esc(sheet.Name)}"></label><button data-quantity-add-item>수량 항목 추가</button><button data-quantity-add>산출 행 추가</button><button data-quantity-apply>내역 수량 반영</button><button data-action="quantity-sheet-copy">산출서 복사</button><button data-action="quantity-sheet-up">산출서 위로</button><button data-action="quantity-sheet-down">산출서 아래로</button><button data-quantity-delete-sheet>산출서 삭제</button></div><table class="sheet"><thead><tr><th>산출코드</th><th>품명</th><th>규격</th><th>단위</th><th>저장 수량</th><th>재계산 수량</th><th>연결 내역코드</th><th>끝수 처리</th><th>작업</th></tr></thead><tbody>${output.items.map((item, index) => `<tr><td>${esc(item.Code)}</td><td><input data-quantity-item="Name" data-index="${index}" value="${esc(item.Name || "")}" aria-label="품명"></td><td><input data-quantity-item="Size" data-index="${index}" value="${esc(item.Size || "")}" aria-label="규격"></td><td><input data-quantity-item="Unite" data-index="${index}" value="${esc(item.Unite || "")}" aria-label="단위"></td><td>${number(sheet.items[index]?.GN ?? 0)}</td><td>${number(item.GN)}</td><td style="white-space:normal"><input data-quantity-target="${index}" value="${esc(item.TargetCode ?? item.LinkCode ?? "")}" aria-label="연결 내역코드">${targetSelect(state.project, item, "sheet", index)}</td><td>${formatSelect(sheet.items[index], index)}</td><td><button data-quantity-delete-item="${index}">삭제</button></td></tr>`).join("")}</tbody></table><p class="business-note">${esc(output.errors.join(" / "))} 산출 행의 결과 표지로 수량 항목을 연결합니다. 신규 산출서와 지원되지 않는 원본 구조 변경은 JSON으로 저장합니다.</p><table class="sheet"><thead><tr><th>줄</th><th>산출근거 편집</th><th>작업</th></tr></thead><tbody>${sheet.rows.map((row, index) => `<tr><td>${index + 1}</td><td><textarea data-quantity-line="${index}" rows="1" style="min-width:750px" aria-label="산출근거 ${index + 1}">${esc(EstText.display(output.rows[index].LineData))}</textarea></td><td><button data-quantity-add data-index="${index}">아래 추가</button><button data-quantity-move="-1" data-index="${index}" ${index === 0 ? "disabled" : ""}>↑</button><button data-quantity-move="1" data-index="${index}" ${index === sheet.rows.length - 1 ? "disabled" : ""}>↓</button><button data-action="quantity-mark" data-index="${index}">수량 지정</button><button data-action="quantity-free" data-index="${index}">지정 해제</button><button data-quantity-delete-row="${index}">삭제</button></td></tr>`).join("")}</tbody></table>`;
`<div class="business-tools"><label>산출서명 <input data-quantity-name value="${esc(sheet.Name)}"></label><button data-quantity-add-item>수량 항목 추가</button><button data-quantity-add>산출 행 추가</button><button data-quantity-apply>내역 수량 반영</button><button data-action="quantity-sheet-copy">산출서 복사</button><button data-action="quantity-sheet-up">산출서 위로</button><button data-action="quantity-sheet-down">산출서 아래로</button><button data-quantity-delete-sheet>산출서 삭제</button></div><table class="sheet"><thead><tr><th>산출코드</th><th>품명</th><th>규격</th><th>단위</th><th>저장 수량</th><th>재계산 수량</th><th>연결 내역코드</th><th>끝수 처리</th><th>작업</th></tr></thead><tbody>${output.items.map((item, index) => `<tr><td>${esc(item.Code)}</td><td><input data-quantity-item="Name" data-index="${index}" value="${esc(item.Name || "")}" aria-label="품명"></td><td><input data-quantity-item="Size" data-index="${index}" value="${esc(item.Size || "")}" aria-label="규격"></td><td><input data-quantity-item="Unite" data-index="${index}" value="${esc(item.Unite || "")}" aria-label="단위"></td><td>${number(sheet.items[index]?.GN ?? 0)}</td><td>${number(item.GN)}</td><td style="white-space:normal"><input data-quantity-target="${index}" value="${esc(item.TargetCode ?? item.LinkCode ?? "")}" aria-label="연결 내역코드">${targetSelect(state.project, item, "sheet", index)}</td><td>${formatSelect(sheet.items[index], index)}</td><td><button data-quantity-delete-item="${index}">삭제</button></td></tr>`).join("")}</tbody></table>${typeof EstQuantitySheets !== "undefined" ? EstQuantitySheets.excelView(sheet) : ""}<p class="business-note">${esc(output.errors.join(" / "))} 산출 행의 결과 표지로 수량 항목을 연결합니다. 신규 산출서와 지원되지 않는 원본 구조 변경은 JSON으로 저장합니다.</p><table class="sheet"><thead><tr><th>줄</th><th>산출근거 편집</th><th>작업</th></tr></thead><tbody>${sheet.rows.map((row, index) => `<tr><td>${index + 1}</td><td><textarea data-quantity-line="${index}" rows="1" style="min-width:750px" aria-label="산출근거 ${index + 1}">${esc(EstText.display(output.rows[index].LineData))}</textarea></td><td><button data-quantity-add data-index="${index}">아래 추가</button><button data-quantity-move="-1" data-index="${index}" ${index === 0 ? "disabled" : ""}>↑</button><button data-quantity-move="1" data-index="${index}" ${index === sheet.rows.length - 1 ? "disabled" : ""}>↓</button><button data-action="quantity-mark" data-index="${index}">수량 지정</button><button data-action="quantity-free" data-index="${index}">지정 해제</button><button data-quantity-delete-row="${index}">삭제</button></td></tr>`).join("")}</tbody></table>`;
}
// 원본 수량목록 자리수 메뉴(mnuAuto·mnuTrunc·mnuUp·mnuRound): 결과 코드 뒤 방식(1 반올림·2 절사·3 올림)+자리수.
function resultFormat(code) {
+50 -1
View File
@@ -152,12 +152,55 @@ const EstSchedule = (() => {
function clearSchedule(project) {
project.schedule = [];
}
// 원본 예정공정표 눈금(mnu1Day·mnu5Day·mnu10Day·mnu15Day·mnu20Day·mnu1Week·mnu1Month).
const scales = [
["1d", "1일", 1],
["5d", "5일", 5],
["10d", "10일", 10],
["15d", "15일", 15],
["20d", "20일", 20],
["1w", "1주", 7],
["1m", "1개월", 0],
];
function ticks(start, end, scale, limit = 60) {
if (!start || !end) return { ticks: [], cut: false };
const first = day(start),
span = day(end) - first + 1,
step = scales.find(([key]) => key === scale)?.[2];
if (step == null) throw Error("눈금 단위를 고르세요.");
const out = [];
if (step) for (let d = first; d < first + span; d += step) out.push(d);
else {
const date = new Date(first * 86400000);
for (let y = date.getUTCFullYear(), m = date.getUTCMonth(); ; m++) {
const t = Date.UTC(y, m, 1) / 86400000;
if (t >= first + span) break;
out.push(Math.max(t, first));
}
}
const cut = out.length > limit;
return {
cut,
ticks: out.slice(0, limit).map((d) => ({
left: ((d - first) / span) * 100,
label: new Date(d * 86400000).toISOString().slice(step ? 5 : 0, step ? 10 : 7),
})),
};
}
function render() {
const { esc, number, state } = EstApp,
output = calculate(state.project);
state.scheduleScale ||= "1m";
const grid = ticks(output.start, output.end, state.scheduleScale),
lines = grid.ticks
.map((t) => `linear-gradient(#999,#999) ${t.left}% 0/1px 100% no-repeat`)
.join(","),
width = Math.max(180, grid.ticks.length * 44),
header = `<div style="position:relative;height:14px;font-size:10px;min-width:${width}px">${grid.ticks.map((t) => `<span style="position:absolute;left:${t.left}%">${esc(t.label)}</span>`).join("")}</div>`,
scaleSelect = `<label>눈금 <select data-schedule-scale>${scales.map(([key, label]) => `<option value="${key}" ${key === state.scheduleScale ? "selected" : ""}>${label}</option>`).join("")}</select></label>${grid.cut ? "<span>눈금이 많아 앞 60개만 표시</span>" : ""}`;
const rowsCount = output.rows.length;
EstApp.byId("work-content").innerHTML =
`<div class="business-tools"><button data-action="schedule-add">공정 추가</button><button data-action="schedule-import">내역 일괄 추가</button><button data-action="schedule-weights">내역 금액으로 가중치 갱신</button><button data-action="schedule-progress">최근 확정 기성으로 진도 갱신</button><button data-action="schedule-clear" ${rowsCount === 0 ? "disabled" : ""}>공정 전체 비우기</button><span>시작 ${esc(output.start)} · 종료 ${esc(output.end)} · 가중치 합계 ${number(output.weight)} · 입력 진도 가중평균 ${number(output.progress)}%</span></div><p>기간과 진도는 직접 입력합니다. 가중치는 공종 금액 또는 사용자가 정한 값을 사용합니다.</p><table class="sheet"><thead><tr>${["코드", "공정명", "시작일", "종료일", "가중치", "진도(%)", "기간", "작업"].map((h) => `<th>${h}</th>`).join("")}</tr></thead><tbody>${output.rows.map((r, i) => `<tr>${["Code", "Name", "start", "end", "weight", "progress"].map((k) => `<td><input data-schedule="${k}" data-index="${i}" type="${["start", "end"].includes(k) ? "date" : ["weight", "progress"].includes(k) ? "number" : "text"}" value="${esc(r[k])}" step="any"></td>`).join("")}<td style="min-width:180px"><div style="height:12px;background:#ddd"><div style="margin-left:${r.left}%;width:${r.width}%;height:12px;background:#316ea5"></div></div></td><td><button data-action="schedule-move" data-index="${i}" data-direction="-1" ${i === 0 ? "disabled" : ""}>↑</button><button data-action="schedule-move" data-index="${i}" data-direction="1" ${i === rowsCount - 1 ? "disabled" : ""}>↓</button><button data-action="schedule-delete" data-index="${i}">삭제</button></td></tr>`).join("")}</tbody></table>`;
`<div class="business-tools"><button data-action="schedule-add">공정 추가</button><button data-action="schedule-import">내역 일괄 추가</button><button data-action="schedule-weights">내역 금액으로 가중치 갱신</button><button data-action="schedule-progress">최근 확정 기성으로 진도 갱신</button><button data-action="schedule-clear" ${rowsCount === 0 ? "disabled" : ""}>공정 전체 비우기</button>${scaleSelect}<span>시작 ${esc(output.start)} · 종료 ${esc(output.end)} · 가중치 합계 ${number(output.weight)} · 입력 진도 가중평균 ${number(output.progress)}%</span></div><p>기간과 진도는 직접 입력합니다. 가중치는 공종 금액 또는 사용자가 정한 값을 사용합니다.</p><table class="sheet"><thead><tr>${["코드", "공정명", "시작일", "종료일", "가중치", "진도(%)", "기간", "작업"].map((h) => `<th>${h === "기간" ? `기간${header}` : h}</th>`).join("")}</tr></thead><tbody>${output.rows.map((r, i) => `<tr>${["Code", "Name", "start", "end", "weight", "progress"].map((k) => `<td><input data-schedule="${k}" data-index="${i}" type="${["start", "end"].includes(k) ? "date" : ["weight", "progress"].includes(k) ? "number" : "text"}" value="${esc(r[k])}" step="any"></td>`).join("")}<td style="min-width:${width}px"><div style="height:12px;background:${lines ? `${lines},` : ""}#ddd"><div style="margin-left:${r.left}%;width:${r.width}%;height:12px;background:#316ea5"></div></div></td><td><button data-action="schedule-move" data-index="${i}" data-direction="-1" ${i === 0 ? "disabled" : ""}>↑</button><button data-action="schedule-move" data-index="${i}" data-direction="1" ${i === rowsCount - 1 ? "disabled" : ""}>↓</button><button data-action="schedule-delete" data-index="${i}">삭제</button></td></tr>`).join("")}</tbody></table>`;
}
function install() {
document.addEventListener("click", (event) => {
@@ -213,6 +256,11 @@ const EstSchedule = (() => {
});
document.addEventListener("change", (event) => {
const node = event.target;
if (node.dataset.scheduleScale !== undefined) {
EstApp.state.scheduleScale = node.value;
EstApp.render();
return;
}
if (!node.dataset.schedule) return;
const p = structuredClone(EstApp.state.project),
row = p.schedule[Number(node.dataset.index)],
@@ -231,6 +279,7 @@ const EstSchedule = (() => {
return {
validate,
calculate,
ticks,
fromEstimate,
refreshWeights,
refreshProgress,
+163
View File
@@ -0,0 +1,163 @@
// 원본 표 정리 — 정렬(BarSort 0x110ADEC·TfrmSiljukSort), 데이타 위치정리(mnuTableSort 0x10CCAEC·TFRMTABLESORT),
// 호표 정리(popTitleSort 0x10D88EC), 근거(메모) 보기·삭제(mnuGunguEdit·mnuGunguDel, 필드 DifineMemo).
// 정렬: 기초단가 표를 관급구분(Gangub) 0·1·2 차례로 묶고 각 묶음을 품명+규격(실적은 코드 선택 가능) 순으로, Index 1..n.
// Index ≤ 0 이거나 품명·규격·수량·금액이 모두 빈 행은 버린다. 원본의 빈 행 30줄 채우기는 웹에서 하지 않는다.
// 위치정리: 기초단가·일위대가·단가산출·기계경비를 품명 오름/내림으로 다시 놓고 시작번호부터 Index — 묶음은 통째로 옮긴다.
// 원본 콤보 글자는 오름/내림이 뒤바뀌어 보이나(분석 추정) 웹은 실제 순서대로 적는다.
// 호표 정리: 현재 차례 그대로 묶음 머리 Index 를 시작번호부터 다시 매기고 제목표 같은 코드도 맞춘다(행·코드 불변).
const EstTidy = (() => {
const num = (value) => Number(value) || 0;
const base = ["StuffcDat", "LaborcDat", "SilJukDat", "EstiMateDat"];
const grouped = {
HeadMainDat: "HeadTitleDat",
CostMainDat: "CostTitleDat",
MachMainDat: "MachTitleDat",
};
const compare = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
const key = (row) => `${row.Name ?? ""}${row.Size ?? ""}`;
function sortBase(project, table, byCode = false) {
if (!base.includes(table)) throw Error("자재·노임·실적·견적 단가 화면에서 정렬하세요.");
const rows = project.tables[table] || [];
const kept = rows.filter(
(row) => num(row.Index) > 0 && (key(row).trim() || num(row.GN) || num(row.TC)),
);
const out = [];
for (const gangub of [0, 1, 2])
out.push(
...kept
.filter((row) => num(row.Gangub) === gangub)
.sort((a, b) =>
byCode ? compare(String(a.Code ?? ""), String(b.Code ?? "")) : compare(key(a), key(b)),
),
);
out.forEach((row, i) => (row.Index = i + 1));
project.tables[table] = out;
return rows.length - out.length;
}
function arrange(project, table, descending, start) {
if (!Number.isInteger(start)) throw Error("시작번호는 정수입니다.");
const order = (a, b) => compare(String(a ?? ""), String(b ?? "")) * (descending ? -1 : 1);
if (base.includes(table)) {
const rows = (project.tables[table] || []).filter((row) => row.Code);
rows.sort((a, b) => order(a.Name, b.Name)).forEach((row, i) => (row.Index = start + i));
project.tables[table] = rows;
return rows.length;
}
if (!grouped[table]) throw Error("이 화면은 위치정리를 할 수 없습니다.");
const groups = [...EstNative.groups(project.tables[table] || []).values()].sort((a, b) =>
order(a.rows[0].Name, b.rows[0].Name),
);
project.tables[table] = groups.flatMap((g) => g.rows);
numberHeads(project, table, start);
return groups.length;
}
function numberHeads(project, table, start) {
if (!grouped[table]) throw Error("일위대가·단가산출·기계경비 화면에서 호표를 정리하세요.");
if (!Number.isInteger(start)) throw Error("시작번호는 정수입니다.");
const titles = new Map((project.tables[grouped[table]] || []).map((t) => [t.Code, t]));
let n = start;
for (const row of project.tables[table] || [])
if (num(row.Index) > 0) {
row.Index = n;
const title = titles.get(row.Code);
if (title) title.Index = n;
n++;
}
return n - start;
}
// 근거(메모): 원본 DifineMemo 는 WPTools 서식(zlib) — 웹은 글자만 꺼내 읽기 전용으로 보인다.
async function memoText(memo) {
if (memo == null) return "";
if (typeof memo === "string") return memo;
if (!memo.binary) return "";
let bytes = Uint8Array.from(atob(memo.binary), (c) => c.charCodeAt(0));
if (bytes[0] === 0x78)
bytes = new Uint8Array(
await new Response(
new Blob([bytes]).stream().pipeThrough(new DecompressionStream("deflate")),
).arrayBuffer(),
);
const source = new TextDecoder("euc-kr").decode(bytes);
return EstQuantity.entities(
source
.replace(/<(numberstyles|stylesheet)[\s\S]*?<\/\1>/gi, "")
.replace(/<\/(div|p)>|<br\s*\/?>/gi, "\n")
.replace(/<[^>]*>/g, ""),
)
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function memoTarget(state) {
const index = [...state.selection][0],
row = state.project.tables[state.view]?.[index];
if (state.selection.size !== 1 || !row) throw Error("근거(메모)를 볼 행 하나를 고르세요.");
return { index, row };
}
async function run(name, state) {
if (name === "tidy-sort") {
const table = state.view;
let byCode = false;
if (table === "SilJukDat") byCode = !confirm("품명,규격으로 정렬할까요? [취소] 코드로 정렬");
if (!confirm("가,나,다 순으로 정렬합니다. 빈 행과 번호 없는 행은 빠집니다.")) return;
let dropped;
EstApp.change((project) => (dropped = sortBase(project, table, byCode)));
state.notice = `정렬했습니다${dropped ? ` (빈 행 ${dropped}개 제외)` : ""}.`;
} else if (name === "tidy-arrange" || name === "tidy-number") {
EstActions.form(
name === "tidy-arrange" ? "데이타 위치정리" : "호표 정리(번호정렬)",
[
...(name === "tidy-arrange"
? [
{
name: "order",
label: "품명 정렬",
value: "asc",
choices: [
["asc", "오름차순(가나다)"],
["desc", "내림차순"],
],
},
]
: []),
{ name: "start", label: "시작번호", type: "number", value: 1 },
],
(values) =>
EstApp.change((project) =>
name === "tidy-arrange"
? arrange(project, state.view, values.order === "desc", values.start)
: numberHeads(project, state.view, values.start),
),
);
} else if (name === "memo-view") {
const { row } = memoTarget(state),
body = await memoText(row.DifineMemo);
EstApp.showDialog(
`근거(메모) — ${row.Name ?? row.Code ?? ""}`,
body
? `<pre style="white-space:pre-wrap;max-height:400px;overflow:auto">${EstApp.esc(body)}</pre>${typeof row.DifineMemo === "string" ? "" : "<p>원본 서식 메모라 웹에서는 읽기만 합니다.</p>"}`
: "<p>이 행에는 근거(메모)가 없습니다.</p>",
);
} 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 };
})();
+5 -2
View File
@@ -76,7 +76,7 @@ const EstViews = (() => {
}
function grid(table, list, columns) {
const { esc, number, state } = EstApp;
return `<table class="sheet" data-grid="${table}"><thead><tr><th class="row-number">선택</th>${columns.map((key) => `<th class="col-${key}">${esc(EstModel.labels[key] || key)}</th>`).join("")}</tr></thead><tbody>${list.map(({ row, index }) => `<tr data-index="${index}" class="${state.selection.has(index) ? "selected " : ""}${Number(row.Index) > 0 ? "heading " : ""}${Number(row.Level) > 0 ? "level " : ""}${row.Error ? "error-row" : ""}"><th class="row-number"><label><input type="checkbox" data-select="${index}" ${state.selection.has(index) ? "checked" : ""}>${index + 1}</label></th>${columns.map((key) => `<td class="col-${key}">${cell(table, index, key, row[key])}</td>`).join("")}</tr>`).join("")}</tbody></table>`;
return `<table class="sheet" data-grid="${table}"><thead><tr><th class="row-number">선택</th>${columns.map((key) => `<th class="col-${key}">${esc((table === "Wonga" && typeof EstJungSang !== "undefined" && EstJungSang.label(state.project, key)) || EstModel.labels[key] || key)}</th>`).join("")}</tr></thead><tbody>${list.map(({ row, index }) => `<tr data-index="${index}" class="${state.selection.has(index) ? "selected " : ""}${Number(row.Index) > 0 ? "heading " : ""}${Number(row.Level) > 0 ? "level " : ""}${row.Error ? "error-row" : ""}"><th class="row-number"><label><input type="checkbox" data-select="${index}" ${state.selection.has(index) ? "checked" : ""}>${index + 1}</label></th>${columns.map((key) => `<td class="col-${key}">${cell(table, index, key, row[key])}</td>`).join("")}</tr>`).join("")}</tbody></table>`;
}
function render(state) {
const { project, view } = state,
@@ -121,7 +121,10 @@ const EstViews = (() => {
const all = EstApp.visibleRows(),
start = state.page * 100,
list = all.slice(start, start + 100),
columns = EstModel.columns(view);
columns =
view === "Wonga" && typeof EstJungSang !== "undefined"
? EstJungSang.columns(project, EstModel.columns(view))
: EstModel.columns(view);
byId("work-content").innerHTML = grid(view, list, columns);
byId("paging").hidden = false;
byId("page-info").textContent =
+1
View File
@@ -75,6 +75,7 @@ const EstWonga = (() => {
row.Cost = defaultBases[row.BaseField];
} else if (
row.VSusic &&
num(row.LinkIndex) !== 66 &&
!/^원금액\s*:/.test(row.VSusic) &&
![1, 3].includes(num(row.CalcIndex))
) {
+69
View File
@@ -0,0 +1,69 @@
// 원본 통합 검색(TFRMMULTISERCH: 일위대가·단가산출·기계경비·견적·실적·자재·노임 고르기, 검색어, 검색위치) —
// 웹은 이 앱에 저장된 작업(data/*.json)을 검색 위치로 쓴다. 검색어 낱말이 모두 코드·품명·규격에 들어 있으면 찾음.
const fs = require("node:fs");
const path = require("node:path");
const sources = {
103: ["HeadTitleDat", "일위대가"],
104: ["CostTitleDat", "단가산출"],
105: ["MachTitleDat", "기계경비"],
101: ["EstiMateDat", "견적단가"],
21: ["SilJukDat", "실적단가"],
5: ["StuffcDat", "자재단가"],
6: ["LaborcDat", "노임단가"],
};
const LIMIT = 300;
const fold = (value) =>
String(value ?? "")
.normalize("NFKC")
.toLowerCase()
.replace(/\s+/g, "");
function search(directory, query, kinds, exclude = "") {
const words = String(query ?? "")
.split(/\s+/)
.map(fold)
.filter(Boolean);
if (!words.length) throw Error("검색어를 입력하세요.");
const wanted = (kinds?.length ? kinds : Object.keys(sources))
.map(Number)
.filter((k) => sources[k]);
if (!wanted.length) throw Error("검색할 자료를 하나 이상 고르세요.");
if (!fs.existsSync(directory)) return { hits: [], total: 0 };
const hits = [];
let total = 0;
for (const name of fs.readdirSync(directory).filter((n) => /^[a-f0-9]{24}\.json$/.test(n))) {
const id = name.slice(0, -5);
if (id === exclude) continue;
let project;
try {
project = JSON.parse(fs.readFileSync(path.join(directory, name), "utf8"));
} catch {
continue;
}
for (const kind of wanted) {
const [table, label] = sources[kind];
for (const row of project.tables?.[table] || []) {
if (!row?.Code) continue;
const text = fold(`${row.Code} ${row.Name} ${row.Size}`);
if (!words.every((w) => text.includes(w))) continue;
total++;
if (hits.length < LIMIT)
hits.push({
id,
project: project.name || id,
kind,
label,
Code: row.Code,
Name: row.Name ?? "",
Size: row.Size ?? "",
Unite: row.Unite ?? "",
price: Number(row.TC) || 0,
});
}
}
}
return { hits, total };
}
module.exports = { search, sources };
+32
View File
@@ -9,6 +9,8 @@ const exchange = require("./exchange.cjs");
const { exportEstx } = require("./export-estx.cjs");
const jdl = require("./jdl.cjs");
const { inspectAccom } = require("./inspect-accom.cjs");
const forms = require("./forms-store.cjs");
const searchStore = require("./search-store.cjs");
const base = __dirname,
port = Number(process.env.EST_TEST_PORT || 5175),
host = process.env.EST_TEST_HOST || "100.65.141.15";
@@ -21,6 +23,7 @@ const scripts = [
"native-text.js",
"native-quantity.cjs",
"native-wonga.js",
"native-jungsang.js",
"native-engine.js",
"native-row-match.js",
"native-escalation.js",
@@ -64,6 +67,11 @@ const allowed = new Set([
"native-excel-map.js",
"native-transfer.js",
"native-bid.js",
"native-forms-library.js",
"native-cleanup.js",
"native-name-reg.js",
"native-tidy.js",
"native-multi-search.js",
"native-issues.js",
"native-attachments.js",
"native-standard.js",
@@ -152,6 +160,30 @@ const server = http.createServer(async (req, res) => {
}
return;
}
if (url.pathname === "/api/forms" && ["GET", "POST"].includes(req.method)) {
const input = req.method === "POST" ? JSON.parse((await body(req)).toString("utf8")) : null;
reply(res, 200, input ? forms.save(dataDirectory, input) : forms.read(dataDirectory));
return;
}
if (req.method === "GET" && url.pathname === "/api/search") {
const kinds = (url.searchParams.get("kinds") || "").split(",").filter(Boolean);
reply(
res,
200,
searchStore.search(
dataDirectory,
url.searchParams.get("q"),
kinds,
url.searchParams.get("exclude") || "",
),
);
return;
}
if (req.method === "POST" && url.pathname === "/api/forms/delete") {
const input = JSON.parse((await body(req)).toString("utf8"));
reply(res, 200, forms.remove(dataDirectory, input.kind, input.name));
return;
}
if (req.method === "GET" && url.pathname === "/api/projects") {
reply(res, 200, list());
return;
+235
View File
@@ -0,0 +1,235 @@
// 원본 엑셀형 수량산출서(TFRMMULEXCEL) 본문 — MulMainDat.Editor 에 든 옛 Excel(.xls, OLE2 복합문서 + BIFF8) 읽기.
// 읽기 전용: 시트 이름과 셀 값(문자·숫자·불·오류), 수식 셀은 저장된 계산값과 수식 여부만 꺼낸다. 서식·그림은 읽지 않는다.
const zlib = require("node:zlib");
function cfb(buffer) {
if (buffer.readUInt32LE(0) !== 0xe011cfd0 || buffer.readUInt32LE(4) !== 0xe11ab1a1)
throw Error("엑셀형 수량산출서 형식(OLE2)이 아닙니다.");
const sectorSize = 1 << buffer.readUInt16LE(30),
miniSize = 1 << buffer.readUInt16LE(32),
fatCount = buffer.readUInt32LE(44),
dirStart = buffer.readInt32LE(48),
miniCutoff = buffer.readUInt32LE(56),
miniFatStart = buffer.readInt32LE(60),
difatStart = buffer.readInt32LE(68);
const sector = (n) => buffer.subarray(512 + n * sectorSize, 512 + (n + 1) * sectorSize);
const difat = [];
for (let i = 0; i < 109; i++) difat.push(buffer.readInt32LE(76 + i * 4));
for (let next = difatStart, guard = 0; next >= 0 && guard < 10000; guard++) {
const s = sector(next);
for (let i = 0; i < sectorSize / 4 - 1; i++) difat.push(s.readInt32LE(i * 4));
next = s.readInt32LE(sectorSize - 4);
}
const fat = [];
for (const n of difat.slice(0, fatCount)) {
if (n < 0) continue;
const s = sector(n);
for (let i = 0; i < sectorSize / 4; i++) fat.push(s.readInt32LE(i * 4));
}
const chain = (start, table) => {
const out = [];
for (let n = start, guard = 0; n >= 0 && guard < table.length + 1; guard++) {
out.push(n);
n = table[n];
}
return out;
};
const read = (start) => Buffer.concat(chain(start, fat).map(sector));
const dir = read(dirStart),
entries = [];
for (let at = 0; at + 128 <= dir.length; at += 128) {
const nameLength = dir.readUInt16LE(at + 64);
entries.push({
name: dir.toString("utf16le", at, at + Math.max(0, nameLength - 2)),
type: dir[at + 66],
start: dir.readInt32LE(at + 116),
size: dir.readUInt32LE(at + 120),
});
}
const root = entries[0],
miniStream = root ? read(root.start) : Buffer.alloc(0),
miniFat = [];
if (miniFatStart >= 0) {
const m = read(miniFatStart);
for (let i = 0; i < m.length / 4; i++) miniFat.push(m.readInt32LE(i * 4));
}
return (name) => {
const entry = entries.find((e) => e.type === 2 && e.name.toLowerCase() === name.toLowerCase());
if (!entry) return null;
if (entry.size < miniCutoff)
return Buffer.concat(
chain(entry.start, miniFat).map((n) =>
miniStream.subarray(n * miniSize, (n + 1) * miniSize),
),
).subarray(0, entry.size);
return read(entry.start).subarray(0, entry.size);
};
}
function rk(value) {
const raw = value & 1 ? 100 : 1;
const number =
value & 2
? value >> 2
: (() => {
const b = Buffer.alloc(8);
b.writeInt32LE(value & 0xfffffffc, 4);
return b.readDoubleLE(0);
})();
return number / raw;
}
// BIFF8 유니코드 문자열(길이·옵션·리치/확장 정보) — CONTINUE 경계는 SST 에서만 따로 처리한다.
function unicode(data, at, lengthBytes = 2) {
const length = lengthBytes === 2 ? data.readUInt16LE(at) : data[at];
let pos = at + lengthBytes;
const flags = data[pos++],
wide = flags & 1;
let runs = 0,
ext = 0;
if (flags & 8) ((runs = data.readUInt16LE(pos)), (pos += 2));
if (flags & 4) ((ext = data.readUInt32LE(pos)), (pos += 4));
const bytes = wide ? length * 2 : length;
const text = wide
? data.toString("utf16le", pos, pos + bytes)
: data.toString("latin1", pos, pos + bytes);
return { text, end: pos + bytes + runs * 4 + ext };
}
function records(stream) {
const out = [];
for (let at = 0; at + 4 <= stream.length;) {
const type = stream.readUInt16LE(at),
size = stream.readUInt16LE(at + 2);
out.push({ type, data: stream.subarray(at + 4, at + 4 + size) });
at += 4 + size;
}
return out;
}
function sst(list, index) {
// SST 와 뒤따르는 CONTINUE 를 문자열 단위로 이어 읽는다.
const parts = [list[index].data];
for (let i = index + 1; i < list.length && list[i].type === 0x3c; i++) parts.push(list[i].data);
const strings = [],
count = parts[0].readUInt32LE(4);
let part = 0,
data = parts[0],
pos = 8;
const next = () => {
part++;
data = parts[part];
pos = 0;
};
while (strings.length < count && data) {
if (pos >= data.length) {
next();
continue;
}
const length = data.readUInt16LE(pos);
let flags = data[pos + 2],
p = pos + 3,
runs = 0,
ext = 0;
if (flags & 8) ((runs = data.readUInt16LE(p)), (p += 2));
if (flags & 4) ((ext = data.readUInt32LE(p)), (p += 4));
let text = "",
left = length;
pos = p;
while (left > 0) {
if (pos >= data.length) {
next();
if (!data) break;
flags = data[pos++];
}
const wide = flags & 1,
room = Math.floor((data.length - pos) / (wide ? 2 : 1)),
take = Math.min(left, room);
text += wide
? data.toString("utf16le", pos, pos + take * 2)
: data.toString("latin1", pos, pos + take);
pos += take * (wide ? 2 : 1);
left -= take;
}
let skip = runs * 4 + ext;
while (skip > 0 && data) {
const step = Math.min(skip, data.length - pos);
pos += step;
skip -= step;
if (skip > 0) next();
}
strings.push(text);
}
return strings;
}
function readXls(input) {
let buffer = Buffer.from(input);
if (buffer[0] === 0x78) buffer = zlib.inflateSync(buffer);
const open = cfb(buffer),
stream = open("Workbook") || open("Book");
if (!stream) throw Error("엑셀형 수량산출서에 Workbook 이 없습니다.");
const list = records(stream);
let strings = [];
const sheets = [];
list.forEach((record, i) => {
if (record.type === 0xfc) strings = sst(list, i);
if (record.type === 0x85)
sheets.push({
offset: record.data.readUInt32LE(0),
name: unicode(record.data, 6, 1).text,
cells: {},
});
});
const byOffset = new Map();
let at = 0;
for (const record of list) {
byOffset.set(at, record);
at += 4 + record.data.length;
}
const offsets = [...byOffset.keys()];
for (const sheet of sheets) {
let index = offsets.indexOf(sheet.offset);
if (index < 0) continue;
let pendingFormula = null;
for (index++; index < offsets.length; index++) {
const { type, data } = byOffset.get(offsets[index]);
if (type === 0x0a) break;
const put = (row, col, value, formula = false) =>
(sheet.cells[`${row},${col}`] = formula ? { value, formula: true } : { value });
if (type === 0xfd)
put(data.readUInt16LE(0), data.readUInt16LE(2), strings[data.readUInt32LE(6)] ?? "");
else if (type === 0x203)
put(data.readUInt16LE(0), data.readUInt16LE(2), data.readDoubleLE(6));
else if (type === 0x27e)
put(data.readUInt16LE(0), data.readUInt16LE(2), rk(data.readInt32LE(6)));
else if (type === 0xbd) {
const row = data.readUInt16LE(0),
first = data.readUInt16LE(2),
last = data.readUInt16LE(data.length - 2);
for (let c = first; c <= last; c++)
put(row, c, rk(data.readInt32LE(4 + (c - first) * 6 + 2)));
} else if (type === 0x204)
put(data.readUInt16LE(0), data.readUInt16LE(2), unicode(data, 6).text);
else if (type === 0x205) {
const v = data[6];
put(data.readUInt16LE(0), data.readUInt16LE(2), data[7] ? `#ERR${v}` : Boolean(v));
} else if (type === 0x06) {
const row = data.readUInt16LE(0),
col = data.readUInt16LE(2);
if (data.readUInt16LE(12) === 0xffff) {
const kind = data[6];
if (kind === 0) pendingFormula = { row, col };
else
put(row, col, kind === 1 ? Boolean(data[8]) : kind === 2 ? `#ERR${data[8]}` : "", true);
} else put(row, col, data.readDoubleLE(6), true);
} else if (type === 0x207 && pendingFormula) {
put(pendingFormula.row, pendingFormula.col, unicode(data, 0).text, true);
pendingFormula = null;
}
}
}
return sheets.map(({ name, cells }) => ({ name, cells }));
}
module.exports = { readXls, cfb };