feat(stmate-test): 총괄내역서 원가 줄·공사원가계산서 엑셀 수식(F5-31) · 화면 파일 캐시 확인(no-cache) · 장부 95.9%

- report_cost_fmla.js — 원가 줄 D·E 수식 · H 숨은 메타 · I~P 보정·요율 칸 · 이윤 끝전 사슬 · 공사원가계산서 참조·소계·구성비·바로가기(변경 양식 포함)
- 원본 5건(봉화·영월·대흥·울진 착공·영덕 변경) 1392칸 수식 글·값 일치 · 24·25판 괄호 구분(Ghidra 0x494b53)
- server.py — /api 밖 응답 Cache-Control: no-cache(고친 뒤 옛 js 가 섞여 「require is not defined」 나던 문제)
- 브라우저 확인(봉화2024): 전체 엑셀 내보내기에 원가 줄 수식 포함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-14 16:46:31 +09:00
co-authored by Claude Opus 5.5
parent 94c335a045
commit 01ed06f5f6
12 changed files with 1571 additions and 29 deletions
@@ -123,3 +123,4 @@ REA 6.7.0 / Ghidra 12.1.4 / 기존 JDK 21.0.7. Ghidra는 LOCALAPPDATA/STMATE-REA
- 2026-11-14 36단계: [계산 엔진 잔여 규칙](36_계산엔진_잔여_작업자료_20261114/36_계산엔진_잔여규칙.md) — A 코드 = COSTA · S_JDA 참 = 저장 단가 · ACTTYP·ACTOPT = PRJLST(0x45c938) · EXRNM 참 = S 줄 MROUNDUD(×율, ECD, 0) · 거짓 = 상위 B·천원 줄 수량 칸 = 율 → 두 경로 ECD 버림만큼 다름 · 수량 계산기 = MROUNDUD 선택 1 · 적용기준 Values 2·0·1(행자부 = 1) · 행자부 RATE_M 줄 b+11/b+15·23·24. Ghidra 읽기만, 원본 실행 없음.
- 2026-11-14 38단계: [보고서 잔여 서식 정적 분석](38_보고서_잔여_작업자료_20261114/38_보고서_잔여_정적분석.md) — 엑셀판 = ST_P_XLS · 설계내역서 쪽 채움(LNP_CNT · 0x520e34 · 원본 3건 행 번호 일치) · 인쇄 영역 $A:$열·탭 색 · 0605 금회 = BOQ QTY2·T_AMT2 · 0615 견적·기준 = xAMT1·xAMT2 두 행 · 0695 회사분 0·도급분 DTP 참조 · 0651 시기금·구기금 = 고른 1·2 차수 T_AMT · 토개공·수자원 글 없음. 바이트 읽기만, 원본 실행 없음.
- 2026-11-14 37단계: [파일 입출력 잔여 정적 분석](37_파일입출력_잔여_작업자료_20261114/37_파일입출력_잔여_정적분석.md) — JDL TEXT = 탭 구분(검사기 고정폭 표는 죽은 고리 · 34 §17.9 바로잡음) · 단가 목록 7종·Main·Global.Ini 줄 규칙 · 세부 3종 열 이름 · 구버전 판 가리기·필드 대응(B→X 조건 = 단위 X) · 엑셀 역등록 구성 줄 PRPT·PRIN 뜻 · wM_JajeXLS = 재료비 수량집계표 내보내기만 · 그림 = COSTN I 코드 IFORM · SIZE = 그림 클래스 이름
- 2026-11-14 38b F5-31: [원가 줄 엑셀 수식](38_보고서_잔여_작업자료_20261114/38b_F5-31_원가줄수식.md) — 총괄 원가 줄 D·E 식 · H 숨은 메타(ST_P_XLS 0x4b8c6c) · I~P 보정·요율 칸 · 이윤 끝전 J·K·L 사슬 · 공사원가계산서 참조·비고·바로가기 · 산재·기타경비 겉 괄호 = 25 판 틀(0x494b53) · 원본 5건 1392칸 글·값 일치. Ghidra 읽기만, 원본 실행 없음.
@@ -0,0 +1,92 @@
# 38b F5-31 원가 줄 엑셀 수식 — 총괄설계내역서 원가 줄 · 공사원가계산서
- 분석일: 2026-11-14
- 방법: 원본 STMATE 출력 엑셀 5건 수식 글(openpyxl 수식 읽기)·셈 값(data_only) 실측 · REA MCP(Ghidra 12.1.4) ST_P_XLS 의사코드
- 근거 파일: `evidence/ST_P_XLS_cost_meta_0x4b8c6c.c`(숨은 메타) · `evidence/ST_P_XLS_cost_tpl_0x494b53_excerpt.c`(식 틀)
- 원본 엑셀: 봉화 2024(24.04) · 영월 2024(24.02) · 대흥 2024(24.04) · 울진 착공(25.1) · 영덕 변경 2차수(25.05) — 괄호 = STC PRG_V
- 앞 단계: [38 §8 F5-31](38_보고서_잔여_정적분석.md) · [35 §3 식 틀 문자열](../35_보고서양식_수량산출서_작업자료_20261114/35_보고서양식_수량산출서_정적분석.md)
확신 표기: **높음** = 원본 엑셀 5건 글 그대로 + Ghidra 틀 확인 · **중간** = 원본 엑셀 일치, 코드 주소 미해독 · **미확인** = 표본·코드 없음.
## 1. 총괄설계내역서 원가 줄 칸 — 중간~높음
g = 가. 순공사비계 행(차수마다) · r = 그 줄 · J = `-I_KAN_JEOLSA`(0 이면 `0`)
| 줄 | D | E | 숨은 칸 |
|---|---|---|---|
| 간접노무비 | `=ROUNDDOWN(E{g}*M{r}/100,J)` | `="직접노무비 x "&ROUND(M{r},5)&"%"` | M = 율 |
| 산재·고용 | 24 판 `=ROUNDDOWN((E{g}+D{간노})*M/100,J)` · 25 판 `((E{g}+D{간노}))` | 같은 꼴 | M |
| 건강·연금 | `=ROUNDDOWN(E{g}*M/100,J)` | 같은 꼴 | J = `=E{g}`(고정 줄도) |
| 노인장기요양 | `=ROUNDDOWN(D{건강}*M/100,J)` | 같은 꼴 | J = `=D{건강}` |
| 안전관리비(A 관급재/1.1) | `=ROUNDDOWN(((E{g}+(F{g}))+(I{관급}-O{관급})/1.1)*M/100+기초액,0)` | 계산 글(수식 아님) | J = `=` 밑 식 · N = 기초액(C_ANJUN_G) |
| 안전관리비(변경 · 자동 2) | `=D{당초}+ROUNDDOWN(D{당초}*((J{r}-J{당초})/J{당초}),J)` | `당초금액 + 안전관리비 증감액` | J = 밑 식(관급 없으면 `((E{g}+(F{g})))`) |
| 기타경비 | 24 판 `=ROUNDDOWN((((E{g}+D{간노})+(F{g})))*M/100,J)` · 25 판 괄호 하나 더 | 같은 꼴 | M |
| 환경보전·건설기계 | `=ROUNDDOWN((D{g})*M/100,J)` | 같은 꼴 | M |
| 나. 소계 | `=(D{g}+D…)` 가.~나. 사이 줄 | — | 0 |
| 일반관리비 | `=ROUNDDOWN(((D{나})+0)*M/100,0)` | `="(나.소 계) x "&ROUND(M,5)&"%"` | M |
| 다. 소계 | `=(D{나}+D{일관})` | — | I = I_TOT_JEOL · J·L = `=D{r}` |
| 이윤 | `=ROUNDDOWN(((D{다})-(F{g}))*M/100,0)-I{r}-K{r}` | `="(다.소계 - 재료비) x "&ROUND(M,5)&"% = "&TEXT(J{r},"#,##0")` | 아래 §2 |
| 추가금액(이윤 뒤·부가세 뒤) | 값 | — | J·L = `=D{r}` |
| 라. 공급가액 | `=(D{다}+D{이윤}+D{추가2…})` | — | J·L 같은 꼴 |
| 부가세 I_BUGA 0 | `=ROUNDDOWN((D{라})*M/100,0)+I{r}` | `="공급가액 x "&ROUND(M,5)&"%"` | J·L = J·L 열 같은 꼴 · I = B_BUGA_UP · O = I_BUGA |
| 부가세 I_BUGA 4(면세품) | `=ROUNDDOWN((D{g}-E{g}-면세액)*M/100,0)+I{r}` | `="(재료비 - 면세품목:₩… + 산출경비) x "&ROUND(M,5)&"%"` | J·L = D 와 같은 글 · N = C_BUGA_M · O = 4 |
| 마. 도급공사비 | `=(D{라}+D{부가세}+D{추가3…})` | — | J·L · O = I_TOT_JEOL |
| 관급자재대 | `=ROUNDUP(I{r},-I_KWN_JEOLSA)` | `="원자재대: "&TEXT(I,"#,0")&" (조달수수료: "&TEXT(O,"#,0")&" 포함)"` | I = 원자재대 · M = I_KWN_JEOLSA · O = 조달수수료 |
| 바. 총공사비 | `=(D{마}+D{관급}+D{추가4…})` | — | J·L · O = I_TOT_JEOL |
- 원가 줄 숨은 칸 I·M·N·O·P = 위 값 · 그 밖 0(가. 줄은 없음)
- D_ 고정 항목 = D 값 · E 없음 · J 밑 식은 남김(울진 건강·연금·안전 · 영덕 당초)
- 변경 양식(영덕) = 줄마다 당초·변경 두 줄 · 각 줄은 자기 차수 g·행 참조 · 나. 소계는 두 줄 건너 합(`D42+D44+D46…`)
## 2. 이윤 끝전 사슬 — 높음(5건 값 일치)
- J 열 = 보정 없는 사슬 · L 열 = 보정액 I 만 뺀 사슬 · D 열 = I·K 둘 다 뺌 · 끝 = 바. (없으면 마.)
- I_TOT_JEOL > 0: I = `=ROUND((J{끝}-ROUNDDOWN(J{끝},-I{다}))/1.1,0)`(I_BUGA 0) · `=J{끝}-ROUNDDOWN(J{끝},-I{다})`(그 밖 — 영덕 4) · K = `=ABS(L{끝}-ROUNDDOWN(J{끝},-I{다}))*IF(L{끝}>ROUNDDOWN(J{끝},-I{다}),1,-1)`
- I_TOT_JEOL = 0(대흥): I = C_IYUN_M 값 · K 칸 없음(D 식의 `-K` 는 남음)
- kanjub_calc 의 `m0 = I_BUGA 0 ? 반올림(diff/1.1) : diff` 와 같은 갈래
## 3. 숨은 메타(H) — ST_P_XLS 0x4b8c6c · 높음(글 5건 일치)
- 글 = `0x07` + `` `키|값 `` + `0x05` … + `` ` `` (엑셀 칸 글 `_x0007_` · `_x0005_`)
- 첫 차수 줄: `DTP|D_TYP` · `QTY{n}|{n}` · `BDC|BDCODE1` · `SRE|S_REC` / 둘째 차수 줄: `DTP` · `QTY{n}|{n}` 만
- 덧붙임 차례: `DRT|T`(고정) → `CBO|값`(필드 1·2) → 안전(D_TYP 306) RAT5 `%10≠0` 이면 `ANB|1` · `÷10` 1~5 면 `ANK|÷10` → (312·313 `ANPUM|1`) → BDCODE1 `A1`~`A4` 면 `ADN|이름` `ADB|비고` (+ BUGA·ADDT 갈래)
- D_TYP: 간노 201 · 산재 301 · 고용 302 · 건강 303 · 연금 304 · 퇴직 305 · 안전 306 · 장기요양 310 · 환경 309 · 건설기계 311 · 기타 321 · 나 400 · 일관 401 · 이윤 402 · 다 410 · 라 500 · 부가세 502 · 마 600 · 관급 653 · 바 700 · 추가금액 461(이윤 뒤 1) · 561(부가세 뒤 1)
- S_REC 실측: 간노 LA · 간접비 SA · 일관·이윤·부가세 TA · 소계 TA-F · 관급 TA-3 · 추가금액 빈칸
- 재현: RAT5 = I_ANJUN_K×10 + I_ANJUN_B(봉화 ANK|1 · 울진 ANB|1) · CBO = 변경 차수 건강·연금·장기요양 I_BF_CH_BO · 안전 I_ANJUNAUTO(영덕 2)
## 4. 식 틀 출처 — ST_P_XLS 0x494b53 둘레 · 높음
- 산재·고용 = `ROUNDDOWN( ([L_AMT][+LAB]) * [RAT_1], ` + `-I_KAN_JEOLSA` + `)` · 기타경비 = `ROUNDDOWN( ((` + `([L_AMT]+[M_AMT])` + `)[+LAB]) * [RAT_1], ` …
- [L_AMT] = `(E{g}+D{간노})` · [M_AMT] = `(F{g})` 로 바꾸면 25 판 출력(울진·영덕)과 글 그대로 → 지금 설치본 = 25 판 꼴
- 24 판 출력(봉화·영월·대흥)은 겉 괄호 하나 적음 — 재현 앱은 STC PRG_V < 25 면 24 판 꼴(govLabel 25 판 갈래와 같은 기준)
- 안전 변경(PCLASS 7 · 계열 ≥ 2 · 자동 2 · 당초 밑 ≥ 1) = `[C_AMT_B]+ROUNDDOWN( [C_AMT_B] * (([C_TAMT_C]-[C_TAMT_B])/[C_TAMT_B]), ` — 영덕 D57 과 같음
## 5. 공사원가계산서 — 중간(5건 글·값 일치)
| 칸 | 꼴 |
|---|---|
| 직접재료비 D | `=총괄!{재료 열}{g}-D{간재}+D{작업설}` |
| 직접노무비·산출경비 D | `=총괄!{노무·경비 열}{g}` |
| 항목 줄 D | `=총괄!D{그 줄}` · 총원가 = 라. · 도급 = 마. · 총공사비 = 바.(없으면 `=D{도급}`) |
| 소계 | `=+D…+D…-D…`(재료 = 직재+간재−작업설 · 노무 = 직노+간노 · 경비 = 산출경비~앞 줄) |
| 구성비 E | `=D{r}/D{끝}*100` · 끝 줄 100 값 |
| 비고 F | 설명 있는 총괄 줄 = `=총괄!E{줄}` · 일관·이윤·부가세 = `="<원가계산서 설명> x "&ROUND(총괄!M{줄},5)&"%"` |
| 바로가기 H | `=HYPERLINK("#"&총괄!H2&"!{열}"&ROW(총괄!{열}{줄}),"총괄표 →")` · 소계·0 줄 없음 |
| 변경 양식 | D 당초 · E 변경 참조 · F `=E-D` · G `=E/E{끝}*100` · H 비고 = 변경 줄 · J 바로가기 = 당초 줄 |
## 6. 재현 앱 대조
- report_cost_fmla.js — 총괄 원가 줄 · 공사원가계산서 수식 · report_boq 줄 열쇠(_c) · 차수 재료(_kan)
- 원본 5건 수식·수 칸 1392칸 = 1392칸 글 그대로 · 수식 다시 셈 값 = 원본 셈 값 · 총괄 D = 계산 엔진 금액
- 시험: `resources/tester/test_stmate_report_cost_fmla.py`
## 7. 미확인 — 값 그대로 둠
| 갈래 | 까닭 |
|---|---|
| 안전관리비 B 상한 · 관급 없는 계산 · 기초액 0 | 원본 표본 없음(D 값) |
| 산재·고용·기타경비 [+LAB](C_EIS_HOLLAB ≠ 0) · 기타경비 틀 IIF 덧셈 | 표본 없음 |
| 퇴직공제·상시근로자 밑·수공(S_) 항목 | 표본 없음 |
| 부가세 I_BUGA 1·2·3 · 관급 I_KWN_UPDN 1·2(ROUND·ROUNDDOWN 으로 둠) | 표본 없음 |
| 추가금액 1·4 단계 DTP(361·661 으로 둠)·J·L 사슬 · BUGA·ADDT 메타 | 표본 없음 |
| 공사원가 엑셀판 본 함수(0x504e60) | Ghidra 응답 크기 초과로 디컴파일 실패 |
@@ -0,0 +1,575 @@
void FUN_004b8c6c(undefined4 param_1,int param_2,undefined4 param_3,undefined4 param_4)
{
undefined *puVar1;
undefined1 *puVar2;
undefined4 *puVar3;
undefined **ppuVar4;
char cVar5;
short sVar6;
undefined4 uVar7;
int iVar8;
int iVar9;
undefined4 *in_FS_OFFSET;
undefined4 local_1c8;
undefined4 local_1c4;
undefined4 *local_1c0;
undefined4 local_1bc;
undefined4 local_1b8;
undefined4 local_1b4;
undefined4 local_1b0;
undefined4 *local_1ac;
undefined4 local_1a8 [4];
undefined4 local_198;
undefined1 local_194 [16];
undefined4 local_184;
undefined4 local_180;
undefined4 local_17c;
undefined4 local_178;
undefined4 local_174;
undefined4 local_170;
undefined4 local_16c;
undefined4 local_168;
undefined4 local_164;
undefined *local_160;
undefined4 *local_15c;
undefined4 local_158;
undefined1 local_154 [16];
undefined4 local_144;
undefined4 local_140;
undefined4 local_13c;
undefined4 *local_138;
undefined4 local_134;
char *local_130;
char *local_12c;
undefined4 *local_128;
char *local_124;
undefined4 local_120 [4];
undefined4 local_110;
undefined4 local_10c;
undefined4 *local_108;
undefined4 local_104;
undefined4 local_100;
undefined1 *local_fc;
undefined4 local_f8;
undefined4 local_f4;
undefined4 uStack_f0;
undefined4 uStack_ec;
undefined4 uStack_e8;
undefined4 local_e4;
undefined *local_e0;
undefined4 *local_dc;
undefined4 local_d8;
undefined4 *local_d4;
uint local_d0;
undefined4 local_cc;
undefined4 local_c8;
undefined **local_c4;
undefined4 uStack_c0;
char *pcStack_bc;
char *pcStack_b8;
undefined4 *local_b4;
char *local_b0;
char *local_ac;
undefined4 *local_a8;
char *local_a4;
char *local_a0;
undefined4 *local_9c;
char *local_98;
char *local_94;
undefined *local_90;
char *pcStack_8c;
char *pcStack_88;
undefined *puStack_84;
undefined4 local_80;
char *local_7c;
char *local_78;
char *local_74;
undefined *local_70;
undefined4 local_6c;
undefined *puStack_68;
undefined *puStack_64;
char *pcStack_60;
char *local_5c;
undefined *local_58;
undefined *local_54;
undefined4 *local_50;
undefined4 local_4c;
undefined4 local_48;
undefined4 local_44;
undefined4 local_40;
undefined4 local_3c;
undefined4 local_38;
undefined4 local_34;
undefined4 local_30;
undefined4 local_2c;
undefined4 uStack_28;
undefined1 *puStack_24;
undefined1 *puStack_20;
char *local_10;
undefined *local_c;
int local_8;
iVar9 = 0x38;
do {
local_c = (undefined *)0x0;
iVar9 = iVar9 + -1;
} while (iVar9 != 0);
LOCK();
UNLOCK();
local_8 = 0;
puStack_20 = (undefined1 *)0x4b8c91;
_System__LStrAddRef_qqrpv(0);
puVar1 = PTR_DAT_006120a4;
puStack_24 = &LAB_004b9743;
uStack_28 = *in_FS_OFFSET;
*in_FS_OFFSET = &uStack_28;
iVar9 = 0;
if (((*(short *)(*(int *)puVar1 + 0x1750) == 7) && ('\x01' < *(char *)(*(int *)puVar1 + 0x1788)))
&& ((int)*(char *)(*(int *)puVar1 + 0x1788) != *(short *)(*(int *)puVar1 + 0x1754) + 1)) {
iVar9 = (*(short *)(*(int *)puVar1 + 0x1754) + 1) - (int)*(char *)(*(int *)puVar1 + 0x1788);
}
local_2c = 0x4b8cfa;
puStack_20 = &stack0xfffffffc;
_System__VarFromLStr_qqrr14System_Variantx17System_AnsiString(&local_3c,0);
local_2c = local_30;
local_30 = local_34;
local_34 = local_38;
local_38 = local_3c;
local_50 = (undefined4 *)CONCAT22(local_50._2_2_,0x2200);
local_50 = (undefined4 *)CONCAT31(local_50._1_3_,1);
local_3c = 0x4b8d1a;
uVar7 = _System__VarFromPStr_qqrr14System_Variantrx28System__SmallString_iuc_255_
(&local_4c,&local_50);
local_3c = local_40;
local_40 = local_44;
local_44 = local_48;
local_48 = local_4c;
local_4c = CONCAT31((int3)((uint)uVar7 >> 8),local_8 != 0);
local_50 = &local_2c;
local_54 = (undefined *)0x4b8d37;
IIF();
local_54 = (undefined *)0x4b8d42;
_System__VarToLStr_qqrr17System_AnsiStringrx14System_Variant(&local_c,&local_2c);
local_54 = (undefined *)0x4b8d51;
_Apodset_TApolloTable_GetString_qqr17System_AnsiString(param_1,"BDCODE1",&local_10);
local_54 = (undefined *)0x4b8d5d;
iVar8 = _Apodset_TApolloTable_GetInteger_qqr17System_AnsiString(param_1,"D_TYP");
if (iVar8 == 0x28d) {
local_54 = (undefined *)0x4b8d89;
_System__LStrCat3_qqrv
(&local_58,&DAT_004b9780,
*(undefined4 *)(PTR_PTR_006120e4 + *(char *)(*(int *)puVar1 + 0x1788 + param_2) * 4))
;
local_54 = (undefined *)0x4b8d96;
_Apodset_TApolloTable_GetString_qqr17System_AnsiString(param_1,local_58,&local_54);
local_58 = &DAT_004b9790;
local_5c = (char *)0x4b8da4;
cVar5 = MPOS();
if (cVar5 != '\0') {
iVar8 = 0x28b;
}
}
if (param_2 == 1) {
local_54 = local_c;
local_58 = &DAT_004b97a4;
local_5c = "`DTP|";
pcStack_60 = (char *)0x4b8e3b;
_Sysutils_IntToStr_qqri(iVar8,&local_74);
pcStack_60 = local_74;
puStack_64 = &DAT_004b97c0;
puStack_68 = &DAT_004b97cc;
local_6c = *(undefined4 *)(PTR_PTR_006120e4 + 4);
local_70 = &DAT_004b97dc;
local_74 = (char *)(*(char *)(*(int *)puVar1 + 0x1789) - iVar9);
local_78 = (char *)0x1;
local_7c = (char *)CONCAT31((int3)((uint)*(int *)puVar1 >> 8),
*(char *)(*(int *)puVar1 + 0x1788) == '\x01');
local_80 = 0x4b8e77;
iVar8 = IFI();
local_80 = *(undefined4 *)(PTR_PTR_006120e4 + iVar8 * 4);
puStack_84 = &DAT_004b97c0;
pcStack_88 = "`BDC|";
pcStack_8c = (char *)0x4b8e95;
_Sysutils_TrimRight_qqrx17System_AnsiString(local_10,&local_78);
pcStack_8c = local_78;
local_90 = &DAT_004b97c0;
local_94 = "`SRE|";
local_98 = (char *)0x4b8eb1;
_Apodset_TApolloTable_GetString_qqr17System_AnsiString(param_1,"S_REC",&local_80);
local_98 = (char *)0x4b8ebc;
_Sysutils_TrimRight_qqrx17System_AnsiString(local_80,&local_7c);
local_98 = local_7c;
local_9c = (undefined4 *)0x4b8ecc;
_System__LStrCatN_qqrv(&local_70,0xf);
local_9c = (undefined4 *)0x4b8ed7;
_System__VarFromLStr_qqrr14System_Variantx17System_AnsiString(&local_6c,local_70);
local_9c = &local_6c;
local_a0 = (char *)0x4b8ee8;
_System__VarFromLStr_qqrr14System_Variantx17System_AnsiString(&local_a0,0);
local_a0 = local_94;
local_a4 = local_98;
local_a8 = local_9c;
local_ac = local_94;
local_b0 = (char *)0x4b8f10;
_System__VarFromLStr_qqrr14System_Variantx17System_AnsiString(&local_b0,&DAT_004b9818);
local_b0 = local_a4;
local_b4 = local_a8;
pcStack_b8 = local_ac;
pcStack_bc = local_a4;
uStack_c0 = 0x4b8f4b;
_System__LStrCat3_qqrv
(&local_b4,"ITM_TYP_",
*(undefined4 *)(PTR_PTR_006120e4 + *(char *)(*(int *)puVar1 + 0x1789) * 4));
uStack_c0 = 0x4b8f58;
iVar8 = _Apodset_TApolloTable_GetInteger_qqr17System_AnsiString(param_1,local_b4);
uStack_c0 = CONCAT31((int3)((uint)iVar8 >> 8),0 < iVar8);
local_c4 = &local_90;
local_c8 = 0x4b8f6a;
IIF();
ppuVar4 = local_c4;
local_c4 = (undefined **)0x4b8f76;
_System__VarAdd_qqrr14System_Variantrx14System_Variant(ppuVar4,&local_90);
local_c4 = (undefined **)&local_6c;
local_c8 = 0x4b8f87;
_System__VarFromLStr_qqrr14System_Variantx17System_AnsiString(&local_c4,0);
ppuVar4 = local_c4;
local_c4 = (undefined **)0x4b8f93;
_System__VarAdd_qqrr14System_Variantrx14System_Variant(ppuVar4,&local_c4);
local_c4 = (undefined **)0x4b8f9e;
_System__VarToLStr_qqrr17System_AnsiStringrx14System_Variant(&local_c,&local_6c);
}
else {
local_54 = local_c;
local_58 = &DAT_004b97a4;
local_5c = "`DTP|";
pcStack_60 = (char *)0x4b8dcc;
_Sysutils_IntToStr_qqri(iVar8,&local_5c);
pcStack_60 = local_5c;
puStack_64 = &DAT_004b97c0;
puStack_68 = &DAT_004b97cc;
local_6c = *(undefined4 *)(PTR_PTR_006120e4 + param_2 * 4);
local_70 = &DAT_004b97dc;
local_74 = (char *)(*(char *)(*(int *)puVar1 + 0x1788 + param_2) - iVar9);
local_78 = (char *)0x1;
local_7c = (char *)CONCAT31((int3)((uint)*(int *)puVar1 >> 8),
*(char *)(*(int *)puVar1 + 0x1788) == '\x01');
local_80 = 0x4b8e08;
iVar8 = IFI();
local_80 = *(undefined4 *)(PTR_PTR_006120e4 + iVar8 * 4);
puStack_84 = (undefined *)0x4b8e1e;
_System__LStrCatN_qqrv(&local_c,9);
}
if (*(short *)(*(int *)puVar1 + 0x1750) == 7) {
puStack_84 = (undefined *)0x4b8fba;
iVar8 = _Apodset_TApolloTable_GetInteger_qqr17System_AnsiString(param_1,"D_TYP");
if (iVar8 != 0x12f) {
puStack_84 = (undefined *)0x4b8fcd;
iVar8 = _Apodset_TApolloTable_GetInteger_qqr17System_AnsiString(param_1,"D_TYP");
if (iVar8 != 0x130) {
puStack_84 = (undefined *)0x4b8fe0;
iVar8 = _Apodset_TApolloTable_GetInteger_qqr17System_AnsiString(param_1,"D_TYP");
if (iVar8 != 0x131) {
puStack_84 = (undefined *)0x4b8ff3;
iVar8 = _Apodset_TApolloTable_GetInteger_qqr17System_AnsiString(param_1,"D_TYP");
if (iVar8 != 0x136) {
puStack_84 = (undefined *)0x4b9006;
iVar8 = _Apodset_TApolloTable_GetInteger_qqr17System_AnsiString(param_1,"D_TYP");
if (iVar8 != 0x132) goto LAB_004b904f;
}
}
}
}
if ((*(char *)(*(int *)puVar1 + 0x1788) != '\x01') &&
((iVar9 < 1 ||
((short)*(char *)(*(int *)puVar1 + 0x1788 + param_2) != *(short *)(*(int *)puVar1 + 0x1754))
))) goto LAB_004b904f;
puStack_84 = local_c;
@@ -0,0 +1,196 @@
/* ST_P_XLS 0x494b53 둘레 함수 발췌(Ghidra 12.1.4 의사코드 글 그대로) — 산재·고용 · 안전 변경 · 기타경비 식 틀 */
uStack_b2 = 0;
local_b8 = (char **)0x494b0d;
FUN_004947fc(param_5 + 9,param_5 + 0x24,0);
}
local_ac = 0;
local_b0 = -*(int *)(param_4 + 0x1e4);
fVar22 = (((float10)param_5[7] + (float10)param_5[0x5c]) * (float10)*(double *)(param_4 + 0x48)) /
(float10)100.0;
uStack_bc = SUB104(fVar22,0);
local_b8 = (char **)((unkuint10)fVar22 >> 0x20);
local_b4 = (undefined2)((unkuint10)fVar22 >> 0x40);
uStack_c4 = (double)CONCAT44(0x494b36,(undefined4)uStack_c4);
MROUNDUD();
uStack_c4 = (double)in_ST3;
local_c8 = (undefined *)0x0;
uStack_cc = 0x494b53;
FUN_004947fc(param_5 + 9,param_5 + 0x25,*(undefined1 *)((int)param_5 + 0x103ad));
uStack_c4._4_4_ = "ROUNDDOWN( ([L_AMT][+LAB]) * [RAT_1], ";
uStack_c4._0_4_ = 0x494b6b;
_Sysutils_IntToStr_qqri(-*(int *)(param_4 + 0x1e4),&local_b0);
uStack_c4 = (double)CONCAT44(uStack_c4._4_4_,local_b0);
local_c8 = &DAT_00499ba4;
uStack_cc = 0x494b86;
_System__LStrCatN_qqrv(param_5 + 0x205d,3);
uStack_cc = 0;
iStack_d0 = -*(int *)(param_4 + 0x1e4);
local_dc._0_10_ =
(((float10)param_5[7] + (float10)param_5[0x5c]) * (float10)*(double *)(param_4 + 0x50)) /
(float10)100.0;
local_e4 = (double)CONCAT44(0x494baf,(undefined4)local_e4);
MROUNDUD();
local_e4 = (double)in_ST4;
local_e8 = (undefined *)0x0;
local_ec._0_2_ = 0x4bcc;
local_ec._2_2_ = 0x49;
FUN_004947fc(param_5 + 9,param_5 + 0x26,*(undefined1 *)((int)param_5 + 0x103ae));
local_e4._4_4_ = "ROUNDDOWN( ([L_AMT][+LAB]) * [RAT_1], ";
local_e4._0_4_ = 0x494be4;
_Sysutils_IntToStr_qqri(-*(int *)(param_4 + 0x1e4),&local_b4);
local_e4._0_2_ = local_b4;
local_e4._2_2_ = uStack_b2;
local_e8 = &DAT_00499ba4;
local_ec._0_2_ = 0x4bff;
local_ec._2_2_ = 0x49;
_System__LStrCatN_qqrv((int)param_5 + 0x102ec,3);
if (*(int *)(param_4 + 0x1dc) - 1U < 2) {
local_ec._0_2_ = (undefined2)*(undefined4 *)((int)param_5 + 0x154);
local_ec._2_2_ = (undefined2)((uint)*(undefined4 *)((int)param_5 + 0x154) >> 0x10);
local_f0 = *(char ***)(param_5 + 0x2a);
local_f4 = 0;
uStack_f2 = 0;
/* ... */
*(undefined4 *)(param_4 + 400) = 1;
}
}
if ((((DAT_00613928 != 7) || (param_1 < 2)) || (*(int *)(param_4 + 0x1d4) != 2)) ||
(param_5[0x21] < 1.0)) {
fVar29 = fVar25;
if (*(int *)(param_4 + 400) == 1) {
local_328 = (char *)0x0;
iStack_32c = -*(int *)(param_4 + 0x1e4);
fVar27 = _DAT_00499f1c * (float10)local_14;
local_338 = SUB104(fVar27,0);
uStack_334 = (undefined4)((unkuint10)fVar27 >> 0x20);
uStack_330 = (undefined2)((unkuint10)fVar27 >> 0x40);
local_33c = 0x766f;
uStack_33a = 0x49;
MROUNDUD();
dVar1 = (double)fVar28;
local_340 = SUB84(dVar1,0);
local_33c = (undefined2)((ulonglong)dVar1 >> 0x20);
uStack_33a = (undefined2)((ulonglong)dVar1 >> 0x30);
local_344 = (char *)0x0;
local_348._0_2_ = 0x768c;
local_348._2_2_ = 0x49;
FUN_004947fc(param_5 + 9,param_5 + 0x35,*(undefined1 *)((int)param_5 + 0x103b3));
local_33c = 0x9dec;
uStack_33a = 0x49;
local_340 = *(undefined4 *)(param_5 + 0x2062);
local_344 = " * 1.2,";
local_348._0_2_ = 0x76af;
local_348._2_2_ = 0x49;
_Sysutils_IntToStr_qqri(-*(int *)(param_4 + 0x1e4),local_adc);
local_348._0_2_ = local_adc._0_2_;
local_348._2_2_ = local_adc._2_2_;
local_34c = &DAT_00499ba4;
local_350 = 0x76ca;
uStack_34e = 0x49;
_System__LStrCatN_qqrv(param_5 + 0x2062,5);
*(undefined4 *)(param_4 + 0x78) = *(undefined4 *)(param_4 + 0x80);
*(undefined4 *)(param_4 + 0x7c) = *(undefined4 *)(param_4 + 0x84);
*(undefined4 *)(param_5 + 0x2e) = *(undefined4 *)(param_5 + 0x2f);
*(undefined4 *)((int)param_5 + 0x174) = *(undefined4 *)((int)param_5 + 0x17c);
}
else {
local_328 = (char *)0x0;
iStack_32c = -*(int *)(param_4 + 0x1e4);
fVar27 = (float10)(double)fVar27;
local_338 = SUB104(fVar27,0);
uStack_334 = (undefined4)((unkuint10)fVar27 >> 0x20);
uStack_330 = (undefined2)((unkuint10)fVar27 >> 0x40);
local_33c = 0x7710;
uStack_33a = 0x49;
MROUNDUD();
dVar1 = (double)fVar28;
local_34c = SUB84(dVar1,0);
local_348._0_2_ = (undefined2)((ulonglong)dVar1 >> 0x20);
local_348._2_2_ = (undefined2)((ulonglong)dVar1 >> 0x30);
local_350 = 0;
uStack_34e = 0;
local_354 = 0x49772d;
FUN_004947fc(param_5 + 9,param_5 + 0x35,*(undefined1 *)((int)param_5 + 0x103b3));
if (*(int *)(param_4 + 0x18c) == 1) {
*(undefined4 *)(param_4 + 0x78) = *(undefined4 *)(param_4 + 0x88);
*(undefined4 *)(param_4 + 0x7c) = *(undefined4 *)(param_4 + 0x8c);
*(undefined4 *)(param_5 + 0x2e) = *(undefined4 *)(param_5 + 0x30);
*(undefined4 *)((int)param_5 + 0x174) = *(undefined4 *)((int)param_5 + 0x184);
}
}
}
else {
local_328 = "[C_AMT_B]+ROUNDDOWN( [C_AMT_B] * (([C_TAMT_C]-[C_TAMT_B])/[C_TAMT_B]), ";
iStack_32c = 0x49747e;
_Sysutils_IntToStr_qqri(-*(int *)(param_4 + 0x1e4),&local_a88);
iStack_32c = local_a88;
uStack_330 = 0x9ba4;
uStack_32e = 0x49;
uStack_334 = 0x497499;
_System__LStrCatN_qqrv(param_5 + 0x2062,3);
uStack_334 = 0;
local_338 = (char *)-*(int *)(param_4 + 0x1e4);
fVar27 = (((float10)param_5[0x3a] - (float10)param_5[0x21]) * (float10)param_5[0x20]) /
(float10)param_5[0x21];
/* ... */
local_3b8 = 0x497b18;
FUN_004947fc(param_5 + 9,param_5 + 0x3c,*(undefined1 *)((int)param_5 + 0x103b4));
pcStack_3ac = (char *)0x497b32;
_System__LStrCat3_qqrv(&local_b5c,"ROUNDDOWN( ((",*(undefined4 *)(*(int *)PTR_DAT_006120c0 + 4))
;
pcStack_3ac = (char *)0x497b43;
_System__VarFromLStr_qqrr14System_Variantx17System_AnsiString(local_b58,local_b5c);
pcStack_3ac = local_b58;
uStack_3b0 = 0x7b57;
uStack_3ae = 0x49;
_System__VarFromLStr_qqrr14System_Variantx17System_AnsiString(&local_b7c,0);
uStack_3b0 = (undefined2)local_b70;
uStack_3ae = (undefined2)((uint)local_b70 >> 0x10);
local_3b4 = local_b74;
local_3b8 = local_b78;
local_3bc = local_b7c;
fVar22 = (float10)_local_24;
local_3c8._0_2_ = SUB102(fVar22,0);
local_3c8._2_2_ = (undefined2)((unkuint10)fVar22 >> 0x10);
local_3c4 = (int)((unkuint10)fVar22 >> 0x20);
local_3c0 = (undefined2)((unkuint10)fVar22 >> 0x40);
local_3cc = (char **)0x497b84;
_Sysutils_FloatToStr_qqrg(&local_b94);
local_3cc = (char **)0x497b9a;
_System__LStrCat3_qqrv(&local_b90,&DAT_00499ec0,local_b94);
local_3cc = (char **)0x497bab;
_System__VarFromLStr_qqrr14System_Variantx17System_AnsiString(local_b8c,local_b90);
local_3cc = local_b80;
local_3d0 = local_b84;
local_3d4 = local_b88;
local_3d8 = local_b8c._0_2_;
uStack_3d6 = local_b8c._2_2_;
local_3dc = (char *)CONCAT31((int3)(CONCAT22(extraout_var_11,
(ushort)(_local_24 < 0.0) << 8 |
(ushort)NAN(_local_24) << 10 |
(ushort)(_local_24 == 0.0) << 0xe) >> 8),
(_local_24 == 0.0) == 0);
local_3e0 = local_b6c;
local_3e4 = (char *)0x497bdf;
IIF();
pcVar13 = local_3e0;
local_3e0 = (char *)0x497beb;
_System__VarAdd_qqrr14System_Variantrx14System_Variant(pcVar13,local_b6c);
local_3e0 = local_b58;
local_3e4 = (char *)0x497c02;
_System__VarFromLStr_qqrr14System_Variantx17System_AnsiString(local_ba4,")[+LAB]) * [RAT_1], ");
pcVar13 = local_3e0;
local_3e0 = (char *)0x497c0e;
_System__VarAdd_qqrr14System_Variantrx14System_Variant(pcVar13,local_ba4);
local_3e0 = local_b58;
local_3e4 = (char *)0x497c28;
_Sysutils_IntToStr_qqri(-*(int *)(param_4 + 0x1e4),&local_bb8);
local_3e4 = (char *)0x497c39;
_System__VarFromLStr_qqrr14System_Variantx17System_AnsiString(local_bb4,local_bb8);
pcVar13 = local_3e0;
local_3e0 = (char *)0x497c45;
_System__VarAdd_qqrr14System_Variantrx14System_Variant(pcVar13,local_bb4);
local_3e0 = local_b58;
local_2c8 = CONCAT22(local_2c8._2_2_,0x2900);
local_2c8 = CONCAT31(local_2c8._1_3_,1);
local_3e4 = (char *)0x497c6c;
@@ -0,0 +1,264 @@
"""STMATE 재현 앱 보고서 F5-31 — 총괄내역서 원가 줄 · 공사원가계산서 엑셀 수식을 원본 STMATE 엑셀과 대조.
정답 = 실무 STC 옆 원본 출력 엑셀(.xlsx) — 수식 글(openpyxl 수식 읽기) · 셈한 값(data_only) 그대로.
앱 쪽 = report_cost_fmla.js(원가 줄 D·E 식 · H 숨은 메타 · I~P 보정·요율 칸 · 원가계산서 참조·비고·바로가기).
값 = 앱 수식을 작은 엑셀 셈틀로 다시 셈(다른 시트 참조 칸 = 앱 모형 값) → 원본 셈 값과 맞댐.
대조 범위: 총괄 가. 줄 아래 C(글)·D 값 칸·E 글 칸 뺀 수식·수 칸 / 원가계산서 5행부터 수식 칸.
"""
import re
import sys
import warnings
from decimal import ROUND_DOWN, ROUND_HALF_UP, ROUND_UP, Decimal
from functools import lru_cache
from io import BytesIO
from pathlib import Path
import openpyxl
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent))
from test_stmate_report_link import formulas, mine_formulas # noqa: E402
from test_stmate_report_sheets import DOCS, PAIRS, book, server # noqa: E402
SIDS = ("총괄내역서", "공사원가계산서")
def _first(sh, sid, name):
"""대조 시작 행 — 총괄 = 가. 줄(변경 양식은 두 줄) 다음 · 원가계산서 = 5행."""
if sid == "공사원가계산서":
return 5
ga = next(i for i, r in enumerate(sh["rows"], 1) if r.get("_c") == "GA")
return ga + (2 if PAIRS[name][2] > 1 else 1)
@lru_cache(maxsize=None)
def pairs(name, sid):
"""(원본 수식·수 칸, 앱 수식·수 칸) — 같은 범위."""
sh = book(name)[sid]
lo = _first(sh, sid, name)
orig = formulas(name, sh["name"])
cols = "DEFGHIJKLMNOP" if sid == "총괄내역서" else "DEFGHIJ"
def keep(k, v):
if k[1] < lo or k[0] not in cols:
return False
if sid == "공사원가계산서" or k[0] in "DE":
return isinstance(v, str) and v.startswith("=")
return True
want = {k: v for k, v in orig.items() if keep(k, v)}
got = {k: v for k, v in mine_formulas(sh).items() if k[1] >= lo and k[0] in cols}
return want, got
@pytest.mark.parametrize("name", list(PAIRS))
@pytest.mark.parametrize("sid", SIDS)
def test_cost_formulas_match_original(name, sid):
"""원가 줄 수식 글 · 숨은 메타 · 보정·요율 칸 · 원가계산서 참조·비고·바로가기 — 원본 글 그대로."""
want, got = pairs(name, sid)
bad = [(f"{c}{r}", want.get((c, r)), got.get((c, r))) for (c, r) in set(want) | set(got)]
bad = sorted(b for b in bad if b[1] != b[2])
assert len(want) > 60, f"{name} {sid} 원본 수식 없음"
assert not bad, f"{name} {sid} 수식 불일치 {len(bad)}건 {bad[:4]}"
# ── 작은 엑셀 셈틀 — 원가 줄 수식에 나오는 꼴만(ROUNDDOWN·ROUNDUP·ROUND·ABS·IF·TEXT·& · 비교) ──
TOK = re.compile(
r'\s*(?:(?P<s>"(?:[^"]|"")*")|(?P<ref>(?:(?:\'[^\']+\'|[^\W\d][\w]*)!)?\$?[A-Z]{1,2}\$?\d+)'
r"|(?P<n>\d+(?:\.\d+)?)|(?P<fn>[A-Z]+)\(|(?P<op><>|>=|<=|[-+*/&(),<>=]))"
)
def _num(v):
return 0 if v in (None, "") else v
def _text(v):
if isinstance(v, str):
return v
v = _num(v)
return str(int(v)) if float(v).is_integer() else ("%.15g" % v)
def _rnd(x, d, mode):
q = Decimal(1).scaleb(-int(d))
return float(Decimal(format(_num(x), ".15g")).quantize(q, rounding=mode))
FUNCS = {
"ROUNDDOWN": lambda x, d: _rnd(x, d, ROUND_DOWN),
"ROUNDUP": lambda x, d: _rnd(x, d, ROUND_UP),
"ROUND": lambda x, d: _rnd(x, d, ROUND_HALF_UP),
"ABS": lambda x: abs(_num(x)),
"IF": lambda c, a, b: a if c else b,
"TEXT": lambda v, f: f"{round(_num(v)):,}",
}
class Book:
"""시트 모형 묶음 셈 — live {시트: 첫 행} 아래 _f 수식만 셈 · 그 밖(공종 줄 · 다른 시트)은 모형 값."""
def __init__(self, sheets, live):
self.by = {s["name"]: s for s in sheets.values()}
self.live = dict(live)
self.memo = {}
def cell(self, sheet, col, row):
key = (sheet, col, row)
if key in self.memo:
return self.memo[key]
rows = self.by[sheet]["rows"]
r = rows[row - 1] if row <= len(rows) else {}
f = (r.get("_f") or {}).get(col) if row >= self.live.get(sheet, 10**9) else None
if isinstance(f, str) and f.startswith("="):
v = self.eval(f[1:], sheet)
elif f is not None and not isinstance(f, str):
v = f
else:
v = r.get(col)
self.memo[key] = v
return v
def eval(self, text, sheet):
toks = [m for m in TOK.finditer(text)]
self.t, self.i, self.sheet = toks, 0, sheet
return self.cmp()
def peek(self, kind=None, val=None):
if self.i >= len(self.t):
return None
m = self.t[self.i]
if kind and m.group(kind) is None:
return None
if val is not None and m.group("op") != val:
return None
return m
def take(self):
self.i += 1
return self.t[self.i - 1]
def cmp(self):
a = self.cat()
while self.peek("op") and self.peek().group("op") in (">", "<", "=", "<>"):
op = self.take().group("op")
b = self.cat()
a = {">": a > b, "<": a < b, "=": a == b, "<>": a != b}[op]
return a
def cat(self):
a = self.add()
while self.peek(val="&"):
self.take()
a = _text(a) + _text(self.add())
return a
def add(self):
a = self.mul()
while self.peek("op") and self.peek().group("op") in "+-":
op = self.take().group("op")
b = self.mul()
a = _num(a) + _num(b) if op == "+" else _num(a) - _num(b)
return a
def mul(self):
a = self.unary()
while self.peek("op") and self.peek().group("op") in "*/":
op = self.take().group("op")
b = self.unary()
a = _num(a) * _num(b) if op == "*" else _num(a) / _num(b)
return a
def unary(self):
if self.peek("op") and self.peek().group("op") in "+-":
op = self.take().group("op")
v = _num(self.unary())
return -v if op == "-" else v
return self.prim()
def prim(self):
m = self.take()
if m.group("s"):
return m.group("s")[1:-1].replace('""', '"')
if m.group("n"):
return float(m.group("n"))
if m.group("ref"):
ref = m.group("ref").replace("$", "")
sh, _, at = ref.rpartition("!")
sh = sh.strip("'") if sh else self.sheet
c, r = re.match(r"([A-Z]+)(\d+)", at).groups()
state = (self.t, self.i, self.sheet)
v = self.cell(sh, c, int(r))
self.t, self.i, self.sheet = state
return v
if m.group("fn"):
args = []
while not self.peek(val=")"):
args.append(self.cmp())
if self.peek(val=","):
self.take()
self.take()
return FUNCS[m.group("fn")](*args)
if m.group("op") == "(":
v = self.cmp()
self.take()
return v
raise ValueError(m.group(0))
@lru_cache(maxsize=None)
def original_values(name, sheet):
warnings.filterwarnings("ignore")
wb = openpyxl.load_workbook(DOCS / PAIRS[name][1], data_only=True, read_only=True)
return {
(c.column_letter, c.row): c.value
for row in wb[sheet].iter_rows()
for c in row
if c.value is not None
}
@pytest.mark.parametrize("name", list(PAIRS))
def test_cost_formula_values_match_original(name):
"""앱 수식을 다시 셈한 값 = 원본 엑셀 셈 값(절사·끝전 보정 포함) · 총괄 D = 앱 계산 엔진 금액."""
bk = book(name)
live = {bk[s]["name"]: _first(bk[s], s, name) for s in SIDS}
ev = Book(bk, live)
bad, n = [], 0
for sid in SIDS:
sh = bk[sid]
want, got = pairs(name, sid)
ov = original_values(name, sh["name"])
for (c, r), f in got.items():
if not (isinstance(f, str) and f.startswith("=")) or "HYPERLINK" in f:
continue
v, o = ev.cell(sh["name"], c, r), ov.get((c, r))
n += 1
same = abs(v - o) <= 1e-9 * max(1, abs(o)) if isinstance(o, (int, float)) else v == o
if not same:
bad.append((sid, f"{c}{r}", o, v))
if c == "D" and sid == "총괄내역서" and v != sh["rows"][r - 1].get("D"):
bad.append((sid, f"{c}{r} 엔진", sh["rows"][r - 1].get("D"), v))
assert n > 80 and not bad, f"{name} 값 불일치 {len(bad)}/{n} {bad[:4]}"
def test_xlsx_writes_cost_formulas_meta_and_numbers():
"""엑셀 쓰기(수식 모드) — 총괄 원가 줄 D 식 · H 숨은 메타 글 · I~P 수 칸 · 원가계산서 참조 식이 원본 칸 그대로."""
from fastapi.testclient import TestClient
name = "봉화2024"
bk = book(name)
body = {"sheets": list(bk.values()), "filename": "c.xlsx", "formulas": True}
res = TestClient(server.app).post("/api/report/xlsx", json=body)
warnings.filterwarnings("ignore")
wb = openpyxl.load_workbook(BytesIO(res.content))
checked = 0
for sid in SIDS:
sh = bk[sid]
want, _ = pairs(name, sid)
ws = wb[sh["name"]]
for (c, r), v in want.items():
assert ws[f"{c}{r}"].value == v, f"{sid} {c}{r}"
checked += 1
assert checked > 200
+3 -3
View File
@@ -29,7 +29,7 @@
## 진행률
- 합계: 전체 246 · 구현 226 · 부분 19 · 없음 1 → 진행률 95.7%((226 + 19×0.5) / 246) · 구현만 세면 91.9%
- 합계: 전체 246 · 구현 227 · 부분 18 · 없음 1 → 진행률 95.9%((227 + 18×0.5) / 246) · 구현만 세면 92.3%
### G1 화면·메뉴 (구현 51 · 부분 2 · 없음 1) — 96.3%
@@ -217,7 +217,7 @@
- [x] F4-28 구버전 → Win8 자료 변환 — ST_U_Convert ST_ConvToWin8 · 현재: 구현(REA 37 — 판 가리기(Chk_Data 0x459280) · 7.x/DOS COSTN·BDQTY·PRJLST·KONGJONG·BOQ·Start8x.Ini 대응 · B→X 조건 = 단위 X · 합성 자료 시험 · 간접비 이관·RATE·SYSINFO·IFORM 미확인(틀 유지))
- [x] F4-29 자동백업·자료 복원 — wM_D_Restore · wM_ST_Restore · 현재: 구현(api_file 자동 백업·복원)
### G5 보고서·내보내기 (구현 25 · 부분 11 · 없음 0) — 84.7%
### G5 보고서·내보내기 (구현 26 · 부분 10 · 없음 0) — 86.1%
- [x] F5-01 재료비·노무비·경비 목록표(0101~0103) — Prn_SanMok · 현재: 구현(report_* · 원본 STMATE 엑셀 셀 대조 3건(test_stmate_report_sheets))
- [x] F5-02 일식·견적(W코드) 목록표(0104) — Prn_SanMok · 현재: 구현(일식견적 목록 0104 · 울진 변경1차 당초 차수 셀 일치)
@@ -249,7 +249,7 @@
- [x] F5-28 안내 시트(※※안내※※) — 19번 §3 · 현재: 구현(REA 38 — 설계내역서 쪽 채움 LNP_CNT(0x520e34) · 원본 3건 행 수·빈 줄·숨은 총합계 줄 일치 · 안내 줄 번호 일치(test_stmate_report_link))
- [x] F5-29 〓 INITIAL 〓 설정 스냅샷 시트 — 18번 §2.3 · 현재: 구현(report_init INITIAL 시트 · 3건 일치(파일 자리·작성자·날짜 제외))
- [ ] F5-30 제잡비 요율표 인쇄 — PM_KAN_RATE · 현재: 부분(REA 38 — 엑셀판·인쇄 분기 없음(ST_KanJub 전용) · 견본 없음)
- [ ] F5-31 엑셀 다중 시트 내보내기·수식 보존 — ST_P_XLS Prn_FMLA_INFO · 현재: 부분(REA 38 — 원가 줄·원가계산서 원본 수식 꼴 기록(§8) · 미구현)
- [x] F5-31 엑셀 다중 시트 내보내기·수식 보존 — ST_P_XLS Prn_FMLA_INFO · 현재: 구현(REA 38b — 총괄 원가 줄 D·E 수식·H 숨은 메타·I~P 보정·요율 칸·이윤 끝전 사슬 + 공사원가계산서 참조·소계·구성비·비고·바로가기(변경 양식 포함) — 원본 5건 1392칸 수식 글·값 일치(test_stmate_report_cost_fmla) · 24·25판 괄호 구분 · 표본 없는 갈래(안전 B상한·퇴직공제·부가세 1~3 등) 값만)
- [ ] F5-32 보고서 인쇄·미리보기 — PM_* · 현재: 부분(REA 38 — 인쇄 영역 $A:$열 · 탭 색 · 안내·INITIAL 세로 · 숨은 열 — 원본 3건 일치 · 배율·여백·바닥글 미확인)
- [ ] F5-33 가로형·토개공·수자원·산근양식 변형 — PM_*_L · PM_Prt · 현재: 부분(REA 38 — 토개공·수자원 글 설치본 전체에 없음 · 가로형 방향만)
- [x] F5-34 출력별 반올림(내역 절사·집계 사사오입) — 06번 §3 · 18번 §2.4 · 현재: 구현(출력별 반올림 · 집계표 사사오입 원본 일치)
+50 -25
View File
@@ -290,13 +290,16 @@ const StmateReportBoq = (() => {
C.push(sh, h3);
const now = summaryRows(dbf, stage, design._tree, opts);
const old = base ? summaryRows(dbf, base.stage, base.tree, opts) : null;
const F = globalThis.STMATE_REPORT_COST_FMLA || require("./report_cost_fmla.js");
if (!old || old.length !== now.length) {
for (const r of now) C.push(sh, r);
sh._kan = [F.kanInfo(stage, dbf)]; // 원가 줄 수식 재료(report_cost_fmla)
return sh;
}
sh._kan = [F.kanInfo(base.stage, dbf), F.kanInfo(stage, dbf)];
now.forEach((r, i) => {
C.push(sh, old[i]);
const second = Object.assign({}, r);
const second = Object.assign({}, r, { _s: 1 });
for (const c of ["A", "B", "C"]) delete second[c];
C.push(sh, second);
});
@@ -323,22 +326,28 @@ const StmateReportBoq = (() => {
const k = stage.kanjub;
const L = costLines(k, stage.cfg);
const direct = tree.finalRow ? tree.finalRow.node.sum : { M: k.dM, L: k.dL, S: k.dS };
const ga = { A: " 가.", B: "순공사비계", _k: "sub" };
const ga = { A: " 가.", B: "순공사비계", _k: "sub", _c: "GA" };
put4(ga, direct);
out.push(ga);
let no = 0;
const line = (label, amount, note, extra) => {
// _c = 원가 줄 열쇠(report_cost_fmla 수식) · _rt = 요율(숨은 M 칸)
const line = (label, amount, note, extra, c, rt) => {
no += 1;
const cells = { B: `${String(no).padStart(2)}. ${label}`, D: amount, _k: "cost" };
const cells = { B: `${String(no).padStart(2)}. ${label}`, D: amount, _k: "cost", _c: c };
if (rt != null) cells._rt = num(rt);
if (note) cells.E = note;
out.push(Object.assign(cells, extra || {}));
};
const sub = (a, label, amount) =>
out.push({ A: ` ${a}`, B: ` ${label}`, D: amount, _k: "sub" });
const sub = (a, label, amount, c) =>
out.push({ A: ` ${a}`, B: ` ${label}`, D: amount, _k: "sub", _c: c });
const addLine = (a, g, i) => line(a.name, num(a.amount), "", { _add: [g, i + 1] }, "ADD");
line(
"간접노무비",
k.kanno,
L.fixedKeys.has("KANNO") ? "" : `직접노무비 x ${rateText(k.itemMap.KANNO.rate)}%`,
null,
"KANNO",
k.itemMap.KANNO.rate,
);
for (const e of L.exp.filter((x) => x.code !== "KANNO")) {
const note =
@@ -352,33 +361,43 @@ const StmateReportBoq = (() => {
e.code === "ANJUN" && L.govMat > 0 && !L.fixedKeys.has("ANJUN")
? { C: "A)관급재/1.1포함 적용" }
: null,
e.code,
e.rate,
);
}
for (const a of L.adds(1)) line(a.name, num(a.amount));
L.adds(1).forEach((a, i) => addLine(a, 1, i));
const na = k.pureConstructionCost;
sub("나.", "소 계", na);
line(
"일반관리비",
k.ilbanAmt,
L.fixedKeys.has("ILBAN") ? "" : `(나.소 계) x ${rateText(num(stage.cfg?.rates?.ILBAN))}%`,
);
sub("다.", "소 계", na + k.ilbanAmt);
const iyunR = rateText(num(stage.cfg?.rates?.IYUN));
line("이 윤", k.iyunFinal, `(다.소계 - 재료비) x ${iyunR}% = ${commas(k.iyunRaw)}`);
for (const a of L.adds(2)) line(a.name, num(a.amount));
sub("라.", "공급가액", k.supplyFinal);
sub("나.", "소 계", na, "NA");
const ilR = num(stage.cfg?.rates?.ILBAN);
const ilN = L.fixedKeys.has("ILBAN") ? "" : `(나.소 계) x ${rateText(ilR)}%`;
line("일반관리비", k.ilbanAmt, ilN, null, "ILBAN", ilR);
sub("다.", "소 계", na + k.ilbanAmt, "DA");
const iyunR = num(stage.cfg?.rates?.IYUN);
const iyN = `(다.소계 - 재료비) x ${rateText(iyunR)}% = ${commas(k.iyunRaw)}`;
line("이 윤", k.iyunFinal, iyN, null, "IYUN", iyunR);
L.adds(2).forEach((a, i) => addLine(a, 2, i));
sub("라.", "공급가액", k.supplyFinal, "RA");
const vn = vatNote(stage.cfg);
line("부가가치세", k.vatFinal, vn === "총 원 가 x 10%" ? "공급가액 x 10%" : vn);
for (const a of L.adds(3)) line(a.name, num(a.amount));
sub("마.", "도급공사비", k.contractFinal);
line(
"부가가치세",
k.vatFinal,
vn === "총 원 가 x 10%" ? "공급가액 x 10%" : vn,
null,
"VAT",
10,
);
L.adds(3).forEach((a, i) => addLine(a, 3, i));
sub("마.", "도급공사비", k.contractFinal, "MA");
if (k.govFinal)
line(
govLabel(dbf, stage, false),
k.govFinal,
`원자재대: ${commas(L.govMat)} (조달수수료: ${commas(k.govFeeTotal)} 포함)`,
null,
"GOV",
);
for (const a of L.adds(4)) line(a.name, num(a.amount));
if (k.govFinal || L.adds(4).length) sub("바.", "총공사비", k.grandTotal);
L.adds(4).forEach((a, i) => addLine(a, 4, i));
if (k.govFinal || L.adds(4).length) sub("바.", "총공사비", k.grandTotal, "BA");
return out;
}
@@ -469,7 +488,10 @@ const StmateReportBoq = (() => {
C.put(sh, 3, { A: "구 분 ", D: "금 액", E: "구 성 비", F: "비 고", _k: "head" });
C.put(sh, 4, { A: " 비 목", _k: "head" });
now.forEach((r, i) => {
const c = Object.assign({ D: r.amount, E: (r.amount / grand) * 100, _k: "cost" }, r.cells);
const c = Object.assign(
{ D: r.amount, E: (r.amount / grand) * 100, _k: "cost", _c: r.key },
r.cells,
);
if (r.note) c.F = r.note;
C.put(sh, 5 + i, c);
});
@@ -479,7 +501,10 @@ const StmateReportBoq = (() => {
const old = Object.fromEntries(wonRows(dbf, base).map((r) => [r.key, r]));
now.forEach((r, i) => {
const o = old[r.key] ? old[r.key].amount : 0;
const c = Object.assign({ D: o, E: r.amount, F: r.amount - o, _k: "cost" }, r.cells);
const c = Object.assign(
{ D: o, E: r.amount, F: r.amount - o, _k: "cost", _c: r.key },
r.cells,
);
c.G = (r.amount / grand) * 100;
if (r.note) c.H = r.note;
C.put(sh, 5 + i, c);
+372
View File
@@ -0,0 +1,372 @@
/* -*- coding: utf-8 -*- */
/**
* 원가 줄 엑셀 수식 (report_cost_fmla.js) — 총괄내역서(0601) 원가 줄 · 공사원가계산서(0608) 칸 수식
* 근거: 지식DB STmate/REA 분석/38_보고서_잔여_작업자료_20261114/38b_F5-31_원가줄수식.md
* (ST_P_XLS 0x494b53 식 틀 · 0x4b8c6c 숨은 메타 · 원본 출력 엑셀 5건 수식 실측)
* 정답 대조: resources/tester/test_stmate_report_cost_fmla.py(원본 수식 글 · 값 그대로)
* 총괄 원가 줄(가. 아래) — D 금액 식 · E 설명 식 · H 숨은 메타 · I~P 보정·요율·추가액 칸(이윤 끝전 J·K·L 사슬)
* 공사원가계산서 — D(변경 양식 D·E) = 총괄 행 참조 · 구성비 = D/총공사비 · 비고 = 총괄 E 참조 · 바로가기
* 원본에 없는 갈래(수식 꼴 미확인)는 값 그대로 둠 — 값은 계산 엔진(kanjub_calc) 값
*/
const StmateReportCostFmla = (() => {
const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
// 세부공종코드(D_TYP) — 조달청 공사제경비 코드(report_pps 와 같은 표) · 원본 메타 `DTP|` 실측
const DTP = { KANNO: 201, SANBO: 301, KOBO: 302, KUNBO: 303, SLVBO: 310, YUNBO: 304 };
Object.assign(DTP, { TBUGUM: 305, ANJUN: 306, KITA: 321, HWAN: 309, EQIP: 311 });
Object.assign(DTP, { NA: 400, ILBAN: 401, DA: 410, IYUN: 402, RA: 500, VAT: 502 });
Object.assign(DTP, { MA: 600, GOV: 653, BA: 700 });
const SUBS = new Set(["NA", "DA", "RA", "MA", "BA"]);
const sre = (c) =>
c === "KANNO"
? "LA"
: SUBS.has(c)
? "TA-F"
: c === "ILBAN" || c === "IYUN" || c === "VAT"
? "TA"
: c === "GOV"
? "TA-3"
: c === "ADD"
? ""
: "SA";
const CHG = new Set(["KUNBO", "SLVBO", "YUNBO", "TBUGUM"]);
const ITEM_CODES = ["KANNO", "SANBO", "KOBO", "KUNBO", "SLVBO", "YUNBO", "TBUGUM", "ANJUN"];
ITEM_CODES.push("KITA", "HWAN", "EQIP", "ILBAN", "IYUN");
/** 차수 하나의 수식 재료 — 설정 키(차수 접두 뗌) · 계산 결과 · 판 번호(PRG_V) */
function kanInfo(stage, dbf) {
const g = globalThis.STMATE_KANJUB_PARTS || require("./kanjub_parts.js");
const P = g({ num });
const cfg = (stage && stage.cfg) || {};
const o = P.reader(cfg, null);
const k = stage.kanjub || {};
const ini = (dbf && dbf.start_ini) || {};
const fixed = ITEM_CODES.filter((c) => o.fix(c) != null);
const keysOut = k.keysOut || {};
return {
slot: num(stage.slot) || 1,
v25: num(ini.PRG_V) >= 25,
totJeol: o.k("I_TOT_JEOL"),
buga: o.k("I_BUGA"),
bugaUp: num(keysOut.B_BUGA_UP ?? o.k("B_BUGA_UP")),
exempt: o.k("C_BUGA_M"),
iyunM: num(k.pretaxProfitAdj),
kanJeol: o.k("I_KAN_JEOLSA"),
kwnJeol: o.k("I_KWN_JEOLSA"),
kwnUpdn: o.k("I_KWN_UPDN"),
rat5: o.k("I_ANJUN_K") * 10 + o.k("I_ANJUN_B"),
anjunAuto: o.k("I_ANJUNAUTO"),
bfch: o.k("I_BF_CH_BO"),
hol: o.k("C_EIS_HOLLAB"),
anjunAdd: num(cfg.anjunBaseAdd ?? o.k("C_ANJUN_G")),
govMat: num(k.govRawTotal) + num(k.govFeeTotal),
govFee: num(k.govFeeTotal),
fixed,
};
}
const dig = (n) => (num(n) ? String(-num(n)) : "0");
const setF = (row, col, v) => ((row._f = row._f || {}), (row._f[col] = v));
// 설명 식 — 「밑 x 율%」 글의 율 자리를 M 칸 ROUND 로(원본 꼴)
const pctNote = (text, mref) => {
const m = /^(.*) x ([\d.]+)%$/s.exec(String(text || ""));
return m ? `="${m[1]} x "&ROUND(${mref},5)&"%"` : null;
};
/**
* 숨은 메타(H) — ST_P_XLS 0x4b8c6c: 0x07 + `키|값 0x05 … + ` · 첫 차수 줄 = DTP·QTY·BDC·SRE
* 둘째 차수 줄 = DTP·QTY · 덧붙임 DRT(고정) · CBO(변경 당초액 잇기) · ANB·ANK(안전 RAT5) · ADN·ADB(추가금액)
*/
function meta(row, info, first) {
const c = row._c;
const add = c === "ADD" ? row._add : null;
const dtp = add ? (add[0] + 2) * 100 + 60 + add[1] : DTP[c];
if (!dtp) return null;
const n = info.slot;
const p = [`DTP|${dtp}`, `QTY${n}|${n}`];
if (first)
p.push(`BDC|${add ? `A${add[0]}${String(add[1]).padStart(2, "0")}` : ""}`, `SRE|${sre(c)}`);
if (info.fixed.includes(c)) p.push("DRT|T");
if (n > 1 && CHG.has(c) && (info.bfch === 1 || info.bfch === 2)) p.push(`CBO|${info.bfch}`);
if (n > 1 && c === "ANJUN" && (info.anjunAuto === 1 || info.anjunAuto === 2))
p.push(`CBO|${info.anjunAuto}`);
if (c === "ANJUN" && info.rat5) {
if (info.rat5 % 10) p.push("ANB|1");
const kk = Math.floor(info.rat5 / 10);
if (kk >= 1 && kk <= 5) p.push(`ANK|${kk}`);
}
if (add) p.push(`ADN|${String(row.B || "").replace(/^\s*\d+\.\s*/, "")}`, "ADB|");
return "_x0007_" + p.map((x) => "`" + x + "_x0005_").join("") + "`";
}
/** 총괄 원가 줄 — 차수(_s)마다 가. 줄부터 차례로 */
function linkSummaryCost(sm) {
const kan = sm._kan || [];
const at = kan.map(() => ({ seq: [] }));
sm.rows.forEach((r, i) => {
if (!r._c || !at[r._s || 0]) return;
const p = at[r._s || 0];
p.seq.push([r, i + 1]);
if (r._c !== "ADD" && p[r._c] == null) p[r._c] = i + 1;
});
at.forEach((p, s) => {
if (p.GA == null) return;
p.last = p.BA || p.MA;
p.seq.forEach(([r, n]) => costRow(r, n, kan[s], p, at[0], s));
});
}
// 사이 합 — from 열쇠 줄부터 to 열쇠 줄 앞까지 · 열(D·J·L)
const span = (p, from, to, col) => {
const i = p.seq.findIndex(([r]) => r._c === from);
const k = p.seq.findIndex(([r]) => r._c === to);
return (
"=(" +
p.seq
.slice(i, k)
.map(([, n]) => `${col}${n}`)
.join("+") +
")"
);
};
function costRow(r, n, info, p, p0, s) {
const c = r._c;
if (c === "GA") return;
const g = p.GA,
J = dig(info.kanJeol);
const fx = info.fixed.includes(c);
for (const col of "IMNOP") setF(r, col, 0);
if (r._rt != null) setF(r, "M", r._rt);
const h = meta(r, info, s === 0);
if (h) setF(r, "H", h);
const note = String(r.E || "");
const pctD = (base) => setF(r, "D", `=ROUNDDOWN(${base}*M${n}/100,${J})`);
const pctE = () => {
const e = pctNote(note, `M${n}`);
if (e) setF(r, "E", e);
};
const v25 = info.v25; // 25 판 = ST_P_XLS 0x494b53 「([L_AMT][+LAB])」 겉 괄호 · 24 판 출력은 없음(원본 실측)
const lab = `(E${g}+D${p.KANNO})`;
const chain = () => (setF(r, "J", `=D${n}`), setF(r, "L", `=D${n}`));
const sum3 = (from, to) => {
for (const col of "DJL") setF(r, col, span(p, from, to, col));
};
switch (c) {
case "KANNO":
if (!fx && note.startsWith("직접노무비 x ")) (pctD(`E${g}`), pctE());
break;
case "SANBO":
case "KOBO":
if (!fx && !info.hol && note.startsWith("(직접노무비+간접노무비) x ") && p.KANNO)
(pctD(v25 ? `(${lab})` : lab), pctE());
break;
case "KUNBO":
case "YUNBO":
setF(r, "J", `=E${g}`);
if (!fx && note.startsWith("직접노무비 x ")) (pctD(`E${g}`), pctE());
break;
case "SLVBO":
if (p.KUNBO) setF(r, "J", `=D${p.KUNBO}`);
if (!fx && p.KUNBO && note.startsWith("건강보험료 x ")) (pctD(`D${p.KUNBO}`), pctE());
break;
case "ANJUN": {
const gov = info.govMat > 0 && p.GOV ? `+(I${p.GOV}-O${p.GOV})/1.1` : "";
const base = `((E${g}+(F${g}))${gov})`;
setF(r, "J", `=${base}`);
setF(r, "N", info.anjunAdd);
const b0 = p0.ANJUN;
if (fx) break;
if (s > 0 && b0 && note.startsWith("당초금액"))
setF(r, "D", `=D${b0}+ROUNDDOWN(D${b0}*((J${n}-J${b0})/J${b0}),${J})`);
else if (gov && String(r.C || "").startsWith("A)"))
setF(r, "D", `=ROUNDDOWN(${base}*M${n}/100+${info.anjunAdd},0)`);
break;
}
case "KITA":
if (!fx && !info.hol && note.startsWith("(직접노무비+간접노무비+재료비) x ") && p.KANNO) {
const b = `(${lab}+(F${g}))`;
(pctD(v25 ? `((${b}))` : `(${b})`), pctE());
}
break;
case "HWAN":
case "EQIP":
if (!fx && note.startsWith("(재료비 + 직접노무비 + 산출경비) x "))
(pctD(`(D${g})`), pctE());
break;
case "NA":
setF(r, "D", span(p, "GA", "NA", "D"));
break;
case "ILBAN":
if (!fx && note.startsWith("(나.소 계) x "))
(setF(r, "D", `=ROUNDDOWN(((D${p.NA})+0)*M${n}/100,0)`), pctE());
break;
case "DA":
setF(r, "D", `=(D${p.NA}+D${p.ILBAN})`);
setF(r, "I", info.totJeol);
chain();
break;
case "IYUN": {
const da = p.DA,
ba = p.last,
ga = `-(F${g}))*M${n}/100,0)`;
setF(r, "D", `=ROUNDDOWN(((D${da})${ga}-I${n}-K${n}`);
setF(r, "J", `=ROUNDDOWN(((J${da})${ga}`);
setF(r, "L", `=ROUNDDOWN(((L${da})${ga}-I${n}`);
const m = /^(.*) x ([\d.]+)% = [\d,]+$/s.exec(note);
if (m) setF(r, "E", `="${m[1]} x "&ROUND(M${n},5)&"% = "&TEXT(J${n},"#,##0")`);
if (info.totJeol > 0 && ba) {
const t = `ROUNDDOWN(J${ba},-I${da})`;
setF(r, "I", info.buga === 0 ? `=ROUND((J${ba}-${t})/1.1,0)` : `=J${ba}-${t}`);
setF(r, "K", `=ABS(L${ba}-${t})*IF(L${ba}>${t},1,-1)`);
} else setF(r, "I", info.iyunM);
break;
}
case "RA":
sum3("DA", "RA");
break;
case "VAT": {
setF(r, "I", info.bugaUp);
setF(r, "N", info.buga === 4 ? info.exempt : 0);
setF(r, "O", info.buga);
const ex = `(D${g}-E${g}-${info.exempt})`;
const f =
info.buga === 0
? (col) => `=ROUNDDOWN((${col}${p.RA})*M${n}/100,0)+I${n}`
: info.buga === 4
? () => `=ROUNDDOWN(${ex}*M${n}/100,0)+I${n}`
: null;
if (f && p.RA) {
for (const col of "DJL") setF(r, col, f(col));
pctE();
}
break;
}
case "MA":
sum3("RA", "MA");
setF(r, "O", info.totJeol);
break;
case "GOV": {
setF(r, "I", info.govMat);
setF(r, "M", info.kwnJeol);
setF(r, "O", info.govFee);
const fn = ["ROUNDUP", "ROUND", "ROUNDDOWN"][info.kwnUpdn];
if (fn && note.startsWith("원자재대:")) {
for (const col of "DJL") setF(r, col, `=${fn}(I${n},-${info.kwnJeol})`);
const t = `TEXT(I${n},"#,0")&" (조달수수료: "&TEXT(O${n},"#,0")&" 포함)"`;
setF(r, "E", `="원자재대: "&${t}`);
}
break;
}
case "BA":
sum3("MA", "BA");
setF(r, "O", info.totJeol);
break;
case "ADD":
if (r._add[0] > 1) chain(); // 이윤 뒤 추가금액부터 끝전 사슬(J·L)에 듦
break;
default:
break;
}
}
/**
* 공사원가계산서 — 총괄 행 참조 · 소계 식 · 구성비 · 비고(총괄 E 참조 / 율 ROUND) · 「총괄표 →」 바로가기
* 변경 양식(당초·변경) = D 당초 · E 변경 · F 증감 · G 구성비 · H 비고(변경 줄) · 바로가기 = 당초 줄
*/
function linkWon(won, sm, ini, L) {
const kan = sm._kan || [];
const two = won.rows.some((r) => r._k === "head" && r.D === "당 초");
if (!kan.length || (two && kan.length < 2)) return;
const pos = kan.map(() => ({ adds: [] }));
sm.rows.forEach((r, i) => {
const p = pos[r._s || 0];
if (!r._c || !p) return;
if (r._c === "ADD") p.adds.push([r, i + 1]);
else if (p[r._c] == null) p[r._c] = i + 1;
});
const C = globalThis.STMATE_REPORT_CORE || require("./report_core.js");
const scol = {};
C.bimokOrder(ini).forEach((k, i) => (scol[k] = "DEFG"[i]));
const S = L.q(sm.name);
const rows = won.rows;
const idx = {};
rows.forEach((r, i) => r._c && (idx[r._c] = i + 1));
const last = rows.length;
const lc = L.linkCol(won);
const addRow = (p, key) => {
const m = /^A(\d)(.*)$/s.exec(key);
const hit = m && p.adds.find(([r]) => r._add[0] === +m[1] && r.B.endsWith(`. ${m[2]}`));
return hit ? hit[1] : 0;
};
// 열쇠 → [총괄 열, 총괄 행]
const ref = (key, p) => {
const t = { dM: ["M"], dL: ["L"], dS: ["S"] }[key];
if (t) return [scol[t[0]], p.GA];
const m = { SUP: "RA", CON: "MA", GRAND: "BA" }[key] || key;
if (/^A\d/.test(key)) return ["D", addRow(p, key)];
return p[m] ? ["D", p[m]] : null;
};
const vc = two ? ["D", "E"] : ["D"];
const ps = two ? [pos[0], pos[1]] : [pos[0]];
const sumKeys = { sM: ["dM", "iM", "by"], sL: ["dL", "KANNO"] };
rows.forEach((r, i) => {
const n = i + 1;
if (!r._c || n < 5) return;
const key = r._c;
vc.forEach((col, s) => {
const p = ps[s];
if (key === "sM") setF(r, col, `=+${col}${idx.dM}+${col}${idx.iM}-${col}${idx.by}`);
else if (sumKeys[key]) setF(r, col, "=+" + sumKeys[key].map((k) => col + idx[k]).join("+"));
else if (key === "sS") {
const parts = [];
for (let q = idx.dS; q < n; q++) parts.push(col + q);
setF(r, col, "=+" + parts.join("+"));
} else if (key === "GRAND" && !p.BA) setF(r, col, `=${col}${idx.CON}`);
else {
const rf = ref(key, p);
if (!rf || !rf[1]) return;
setF(
r,
col,
`=${S}!${rf[0]}${rf[1]}` + (key === "dM" ? `-${col}${idx.iM}+${col}${idx.by}` : ""),
);
}
});
// 증감 · 구성비
if (two) {
setF(r, "F", `=E${n}-D${n}`);
if (n < last) setF(r, "G", `=E${n}/E${last}*100`);
} else if (n < last) setF(r, "E", `=D${n}/D${last}*100`);
// 비고 — 총괄 E 참조(설명 있는 줄) · 일반관리비·이윤·부가세 = 원가계산서 설명 + 총괄 M 율
const nc = two ? "H" : "F";
const p = ps[ps.length - 1];
const rf = ref(key, p);
if (rf && rf[0] === "D" && rf[1]) {
const srow = sm.rows[rf[1] - 1];
if (key === "ILBAN" || key === "IYUN" || key === "VAT") {
const e = pctNote(r[nc], `${S}!M${rf[1]}`);
if (e) setF(r, nc, e);
} else if (srow && srow.E != null && srow.E !== "") setF(r, nc, `=${S}!E${rf[1]}`);
}
// 바로가기 — 당초(첫) 줄
const r0 = ref(key, ps[0]);
if (lc && r0 && r0[1]) {
r[lc] = "총괄표 →";
r._links = Object.assign(r._links || {}, { [lc]: `${S}!${r0[0]}${r0[1]}` });
setF(r, lc, L.jump(sm, r0[0], r0[1], "총괄표 →"));
}
});
}
function linkCost(by, ini, L) {
const sm = by.총괄내역서;
if (!sm || !sm._kan) return;
linkSummaryCost(sm);
if (by.공사원가계산서) linkWon(by.공사원가계산서, sm, ini, L);
}
return { kanInfo, linkCost, DTP };
})();
if (typeof window !== "undefined") window.STMATE_REPORT_COST_FMLA = StmateReportCostFmla;
if (typeof module !== "undefined" && module.exports) module.exports = StmateReportCostFmla;
+3
View File
@@ -515,6 +515,9 @@ const StmateReportLink = (() => {
linkComposition(by, by.중기사용료, ini);
linkDesign(by, ini);
linkSummary(by, ini);
// 총괄 원가 줄 · 공사원가계산서 수식(F5-31)
const CF = globalThis.STMATE_REPORT_COST_FMLA || require("./report_cost_fmla.js");
CF.linkCost(by, ini, { q, jump, linkCol });
return sheets;
}
+5 -1
View File
@@ -105,7 +105,11 @@ def write_sheet(ws, sheet: dict, formulas: bool = False, prn: dict | None = None
c_idx = column_index_from_string(col)
max_col = max(max_col, c_idx)
fml = fmls.get(col)
out = fml.replace("{r}", str(r_idx)) if isinstance(fml, str) else val
out = (
fml.replace("{r}", str(r_idx))
if isinstance(fml, str)
else (fml if isinstance(fml, (int, float)) and not isinstance(fml, bool) else val)
) # 수 _f = 숨은 보정·요율 칸(총괄 원가 줄 I~P)
cell = ws.cell(row=r_idx, column=c_idx, value=out)
bold = bolds.get(col, kind in BOLD_KINDS or kind == "title")
size = sizes.get(col) or (16 if kind == "title" else font_size)
+1
View File
@@ -16,6 +16,7 @@
[
"report_core.js",
"report_unit.js",
"report_cost_fmla.js",
"report_boq.js",
"report_coll.js",
"report_san.js",
+9
View File
@@ -39,6 +39,15 @@ app = FastAPI(title="STMATE w 8.0 Full DBF/SMT Engine")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
@app.middleware("http")
async def _no_stale_cache(request, call_next):
"""화면 파일(js·html·css)은 매번 새 판인지 확인 — 고친 뒤 옛 파일이 섞여 도는 것을 막음(ETag 로 바뀐 것만 받음)."""
res = await call_next(request)
if not request.url.path.startswith("/api/"):
res.headers["Cache-Control"] = "no-cache"
return res
def fnum(val: object, default: float = 0.0) -> float:
try:
s = str(val or "").strip().replace(",", "")