feat(814-1 1·2): 올리기 파일 하나 · 확장자 파일 = 단가산출표 판 + 산출근거

- 같은 공사 엑셀 · 원파일 붙이기 걷음(keep · upload_native · same 지움) — 엑셀이든 .estx · .STC 든 하나만
- 원파일 = 새 HP · 산출근거 머리(코드 · 이름 · 규격 · 단위 · 저장 금액) 단가산출표 격자 · 일위대가 안 만듦(HeadMainDat 안 씀)
- 화면 = 일위대가 없으면 단가산출 목록 먼저 · 올림 안내 글 하나로
- 시험 810-2: 붙음 · EST 호표 → SC 기대 지움(요구 742-1 · 743-2 로 걷은 길) → 파일 하나 · 단가산출표 판 기대

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-10 14:14:21 +09:00
co-authored by Claude Opus 5.5
parent 4bd9e1484c
commit 56a3e0538f
5 changed files with 69 additions and 155 deletions
+38 -76
View File
@@ -7,9 +7,8 @@
STmate = 원문 그대로 `M02_San_Emit.stmate`(원파일 원문 = 엑셀 숨은 G 열 · 단가 = COSTN ·
끝전 = 옵션 SAN_TYP 1 비목버림 / 0 합버림_큰나머지 · 810-1 저장값 대조로 확인).
블록 번 = 엑셀 길과 같음(EST = 산근 표 차례 1부터 = 「단산N」 · STmate = 코드 숫자).
같은 공사 = 엑셀 산근 블록이 모두 원파일 블록과 금액 · 이름이 같음(`same`) — 둘 다 있으면 원파일.
일위대가(EST HeadMainDat) → 실무자료 호표 꼴(`hopyo`) · STmate 일위대가 구성은 못 읽음(BDQTY 난독화) →
호표 없음(엑셀을 같이 올리면 엑셀 호표 + 원파일 산근).
올리기 = 파일 하나(814-1 · 요구 742-1 — 같은 공사 엑셀 붙이기 걷음) · 원파일 = 단가산출표 판(`grid` ·
산출근거 머리) + 산출근거 · 일위대가 안 만듦(요구 743-2 · `hopyo` = 단산만).
"""
from __future__ import annotations
@@ -154,89 +153,52 @@ def inner(saved: dict, logic: dict) -> list[tuple[dict, int | None, str | None]]
return out
# ── 같은 공사 · 일위대가 ───────────────────────────────────────────────
def same(saved: dict, sheets: dict) -> bool:
"""엑셀 산근 블록이 모두 원파일 같은 번 블록과 계 금액(1 원 안)이 같음 = 같은 공사."""
from M02_MasterTemplete import M02_San_Book as book
have = numbers(saved)
kind = book.kind_of(sheets)
if not kind or (kind == "STmate") != (saved["꼴"] == "STmate"):
return False
if kind == "STmate":
excel = {int(b["코드"][1:]): b["머리"].get("C") for b in book.stmate_blocks(sheets)}
else:
excel = {int(n): t.get("T") for n, t in book.est_prices(sheets)[1].items()}
if not excel:
return False
for n, total in excel.items():
blk = have.get(int(n))
if blk is None or abs(float(total or 0) - float((blk.get("저장") or {}).get("T") or 0)) > 1:
return False
return True
GALLAE = {6: "노임", 5: "자재", 105: "기계", 104: "단가산출", 103: "호표", 21: "기타", 101: "기타"}
# ── 단가산출표 판(814-1 · 요구 742-2 · 743-2) ───────────────────────────
LIST = "단가산출표"
_LIST_HEAD = ("코드", "이름", "규격", "단위", "재료비", "노무비", "경비", "합계")
_LIST_COST = ("M", "L", "S", "T")
def hopyo(saved: dict) -> dict[str, Any]:
"""EST 일위대가(HeadMainDat) → 실무자료 읽기 꼴(`M02_Hopyo_Excel.read_book` 짝 · 호표 · 단산) ·
STmate = 호표 없음(구성 못 읽음)."""
blocks = numbers(saved)
names = {str(b.get("코드")): n for n, b in blocks.items()}
heads = {str(h.get("코드")): n for n, h in enumerate(saved.get("일위") or [], 1)}
"""원파일 → 실무자료 읽기 꼴 — 단산(산출근거 머리)만 · 일위대가 안 만듦(요구 743-2 · HeadMainDat 안 씀)."""
out: dict[str, Any] = {"꼴": "원파일", "시트": saved["꼴"], "호표": [], "검산": {"어긋남": []}}
out["단산"] = [
{"번": n, "이름": b.get("이름"), "단위": b.get("단위"), "금액": _cost(b.get("저장"))}
for n, b in blocks.items()
for n, b in numbers(saved).items()
]
if saved["꼴"] != "EST":
return out
for n, h in enumerate(saved.get("일위") or [], 1):
lines = []
for k, ln in enumerate(h.get("줄") or []):
link = ln.get("연결")
code = str((ln.get("자원") or {}).get("코드") or "")
ref = None
if link == 104 and code in names:
ref = {"종류": "단산", "번": names[code]}
elif link == 103 and code in heads:
ref = {"종류": "일위", "번": heads[code]}
kind = "%" if ln.get("연결") in (52, 58, 62) else GALLAE.get(link or 0, "기타")
if kind == "자재" and str(ln.get("단위") or "") == "인":
kind = "노임"
if not any((ln.get("단가") or {}).values()) and kind not in ("%",):
kind = "별도계상" if not ref else kind
lines.append(
{
"차례": k,
"이름": ln.get("품명") or "",
"규격": ln.get("규격") or "",
"수량": _dec(ln.get("수량")),
"단위": ln.get("단위") or "",
"단가": _cost(ln.get("단가")),
"금액": _cost(ln.get("저장")),
"비고": "",
"참조": ref,
"갈래": kind,
"행": ln.get("행"),
"코드": code or None,
}
)
out["호표"].append(
{
"번": n,
"이름": h.get("이름"),
"규격": h.get("규격") or "",
"단위": h.get("단위") or "",
"금액": _cost(h.get("저장")),
"행": [n, n],
"줄": lines,
}
)
return out
def grid(saved: dict) -> dict[str, Any]:
"""원파일 → 단가산출표 한 시트 격자(`M02_Hopyo_Grid.read` 꼴) — 산출근거 머리(코드 · 이름 · 규격 ·
단위 · 저장 금액) 한 줄씩 · 행 = 1 머리 + 블록 차례."""
cells: dict[str, dict] = {}
cols = "ABCDEFGH"
for c, head in zip(cols, _LIST_HEAD):
cells[f"{c}1"] = {"값": head}
rows = []
for at, (n, b) in enumerate(numbers(saved).items(), 2):
cost = b.get("저장") or {}
vals = [b.get("코드"), b.get("이름"), b.get("규격"), b.get("단위")]
vals += [_dec(cost.get(k)) for k in _LIST_COST]
for c, v in zip(cols, vals):
if v not in (None, ""):
cells[f"{c}{at}"] = {"값": v if isinstance(v, (int, float)) else str(v)}
rows.append({"번": n, "이름": b.get("이름"), "행": [at, at]})
sheet = {"id": "s1", "이름": LIST, "칸": cells, "틀고정": {"행": 1}}
sheet["열"] = {c: {"폭": w} for c, w in zip(cols, (10, 40, 18, 6, 12, 12, 12, 12))}
book = {"종류": "통합문서", "판": 1, "열": "", "서식": [{}], "시트": [sheet], "활성": "s1"}
return {
"책": book,
"호표": [],
"단산": rows,
"보기": {LIST: "s1"},
"원본": {LIST: LIST},
"목록": {"호표": [], "단산": [{"번": r["번"], "행": r["행"][0]} for r in rows]},
"줄": [],
}
def _dec(v: Any) -> int | float | None:
"""수 → 엑셀 읽기와 같은 꼴(정수 = int · 아니면 float · 없음 = None)."""
if v is None:
@@ -275,7 +275,7 @@ export function buildHopyoSheet(opt: HopyoSheetOptions): HopyoSheetBody {
const lastOf = (sid: string): string =>
lastColumn(book.시트.find((x) => x.id === sid) ?? first);
const more = { ...opt, target, grid, last: lastOf, sheet: () => sheet, host: left };
nav = ids.일위대가표 ? Nav.createSheetNav(more) : null;
nav = ids.일위대가표 || ids.단가산출표 ? Nav.createSheetNav(more) : null; // 원파일 = 단가산출표 판만(814-1)
if (nav) warn.after(nav.root);
sk = nav ? createSheetSk({ ...opt, target, nav, side: right, ...(opt.onSkChange ? { onChange: opt.onSkChange } : {}) }) : null; // prettier-ignore
sheet = createSpreadsheet(left, book, {
@@ -290,6 +290,7 @@ export function buildHopyoSheet(opt: HopyoSheetOptions): HopyoSheetBody {
if (no !== null && no !== current) mark(no);
},
});
if (!ids.일위대가표 && ids.단가산출표) sk?.at({ 시트: first.id, 칸: "A1" }); // 일위대가 없음 = 단가산출 목록 먼저
nav?.marks(briefs);
// 머리 행 칸을 눌러 뗌 → 그 호표 영역 고름(끌기 · 방향키 도중엔 안 바꿈 · 줄 행은 그 줄 그대로)
left.querySelector(".ss-grid-host")?.addEventListener("mouseup", (e) => {
@@ -1,7 +1,7 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_Side.ts
* 좌측 「일위대가 불러오기」 컨테이너(PLAN 324-4 · 718-1 한시 · 785-4) —
* · [불러오기](구조물 조합 층 단추 꼴 `m02-layers__btn`) = 바로 엑셀 고르기 창 → 올림 → 본 칸 불러오기 보기
* · [불러오기](구조물 조합 층 단추 꼴 `m02-layers__btn`) = 바로 파일 하나 고르기 창(엑셀 · 경쟁사 .estx · .STC · 814-1) → 올림 → 본 칸 불러오기 보기
* (`M02_MasterTemplete_Hopyo_Import` · 엑셀 + 오른쪽 호표 목록 · 가운데 상세 페이지 없음 · 누름 하나)
* · 결과 층 고름 = 작은 고름(쓸 수 있는 층 둘 이상일 때만 · 일반 = 개인 하나라 안 보임)
* · 「작업 중」 줄 = 층마다 서버 목록(같은 엑셀 작업 중 이어서) · 누르면 본 칸 불러오기 보기
@@ -67,7 +67,7 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
});
start.classList.add("m02-layers__btn");
start.title =
"다른 프로그램 일위대가 엑셀 → 우리 일위대가(한시 · 끝내거나 떠나면 올린 파일 지움)";
"파일 하나 — 경쟁사 엑셀(.xls · .xlsx) 또는 경쟁사 파일(.estx · .STC) → 우리 정보로 다시 만듦(한시 · 끝내거나 떠나면 올린 파일 지움)";
start.dataset.hopyoWizardOpen = "";
const seg = el("div", { className: "m02-side__row" });
seg.hidden = true;
@@ -145,14 +145,10 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
if (!file) return;
start.disabled = true;
uploadHopyo(scopeOf(pick), file)
.then((got: HopyoFile & { 이어서?: boolean; 원파일?: boolean; 붙음?: string[] | null }) => {
.then((got: HopyoFile & { 이어서?: boolean; 원파일?: boolean }) => {
const tool = /\.estx$/i.test(file.name) ? "EST" : "STmate"; // 810-3 원파일 올림 안내
if (got.원파일)
showToast(
`원파일 · ${tool} — 호표 ${got.호표수}${got.붙음?.length ? ` · 같은 공사 엑셀 ${got.붙음.join(", ")} 에 붙음` : ""}`,
"success",
);
else if (got.이어서) showToast(`같은 엑셀 작업 중 — ${got.키} 이어서`, "info");
if (got.이어서) showToast(`같은 파일 작업 중 — ${got.키} 이어서`, "info");
else if (got.원파일) showToast(`${tool} 파일 — 단가산출표 · 산출근거`, "success");
else showToast(`올림 — 호표 ${got.호표수}`, got.어긋남 ? "warning" : "success");
if (got.읽기불가) showToast(`가져온 식으로 열 수 없음 — ${got.읽기불가}`, "warning");
return show(pick, got, true);
+6 -31
View File
@@ -8,9 +8,9 @@
같은 내용 SK 가 있으면 그것(다시씀).
블록 번 = 판정 블록 번(STmate = 코드 「D00102」 숫자 · EST = 단가산출 블록 번).
STmate 블록 끝전 = 파일 투표(`M02_San_Xlsx_Stmate.vote`).
원파일(.estx · .stc · 810-2 `M02_Doc_FromNative`) = 같은 자리 칸 `원파일`(둘 꼴) — 있으면 원파일 우선
(그 블록 번이 원파일에 있을 때) · 엑셀 올림 때 같은 공사 원파일이 다른 HP 에 있으면 붙임 · 원파일 올림 =
같은 공사 엑셀 HP 에 붙임(없으면 원파일로 새 HP · `upload_native`).
원파일(.estx · .stc · 810-2 `M02_Doc_FromNative`) = 같은 자리 칸 `원파일`(둘 꼴) · 올리기 = 파일 하나
(814-1 · 요구 742-1 — 같은 공사 엑셀 · 원파일 붙이기 걷음) · 원파일 = 새 HP(단가산출표 판 격자 · 일위대가 없음 ·
`upload_native`).
"""
from __future__ import annotations
@@ -63,9 +63,7 @@ def keep(folder: str | Path, key: str, name: str, data: bytes) -> str | None:
return f"산근 시트 읽기 오류 — {type(e).__name__}: {str(e)[:80]}"
path = _path(folder, key)
path.parent.mkdir(parents=True, exist_ok=True)
got = {"파일": name, "시트": sheets, "엑셀": excel}
native = next((n for n in _natives(folder) if fn.same(n, sheets)), None)
atomic_write_json(path, got | ({"원파일": native} if native else {}))
atomic_write_json(path, {"파일": name, "시트": sheets, "엑셀": excel})
return None
@@ -85,23 +83,8 @@ def _excel_of(path: Path, saved: dict) -> dict:
# ── 원파일(810-2) ──────────────────────────────────────────────────────
def _saved(folder: str | Path) -> list[tuple[Path, dict]]:
out = []
for path in sorted((hopyo_store._dir(folder) / DIRNAME).glob("HP-*.json")):
try:
out.append((path, json.loads(path.read_bytes())))
except (OSError, ValueError):
continue
return out
def _natives(folder: str | Path) -> list[dict]:
return [d["원파일"] for _, d in _saved(folder) if isinstance(d.get("원파일"), dict)]
def upload_native(folder: str | Path, name: str, data: bytes, who: Any) -> dict[str, Any]:
"""원파일 올림 — 같은 공사 엑셀 HP 에 원파일을 붙임(`붙음` 키들 · 첫 HP 답) · 없으면 원파일로
새 HP(EST = 일위대가 호표 · STmate = 호표 없음 · 산근만). 같은 파일 다시 = 그 HP(`이어서`)."""
"""원파일 올림 — 새 HP(단가산출표 판 격자 · 산근만 · 일위대가 없음 · 814-1) · 같은 파일 다시 = 그 HP(`이어서`)."""
mark = hopyo_store.fingerprint(data)
if same := next((r for r in hopyo_store.rows(folder) if r.get("지문") == mark), None):
return {**same, "이어서": True, "원파일": True}
@@ -111,15 +94,7 @@ def upload_native(folder: str | Path, name: str, data: bytes, who: Any) -> dict[
native = fn.read(name, data)
except fn.NativeError as e:
raise store.StoreError(400, str(e)) from e
joined = []
for path, got in _saved(folder):
if got.get("시트") and fn.same(native, got["시트"]):
atomic_write_json(path, {**got, "원파일": native})
joined.append(path.stem)
rows = {r["키"]: r for r in hopyo_store.rows(folder)}
if joined and joined[0] in rows:
return {**rows[joined[0]], "원파일": True, "붙음": joined}
row = hopyo_store.save(folder, name, fn.hopyo(native), who, None, mark)
row = hopyo_store.save(folder, name, fn.hopyo(native), who, fn.grid(native), mark)
path = _path(folder, row["키"])
path.parent.mkdir(parents=True, exist_ok=True)
atomic_write_json(path, {"파일": name, "원파일": native})
+18 -38
View File
@@ -6,8 +6,8 @@
(b) 꼴 — 소계 = 합 밖 · 비목 칸 비움(문서 셈 금액만) · 기계 세 줄 = 묶음 한 줄 · 변수 = 이름(상수 아님)
· 분수 그림 공식 = 변수 식 · 수 하나 변수 = 숫자 연결 + 출처 「원파일」 · 품셈 조건 글 = 「추정」 표지
(c) 문서 셈 거울 — 비목 없는 합 밖 줄 파이썬 = TS
(d) 올림 길 — 원파일 = 같은 공사 엑셀 HP 에 붙음 · 가져온 식 = 원파일 · 원파일만 = 새 HP(EST 호표 ·
STmate 호표 없음) · 원파일 HP 호표 → 일위대가(SC) 금액 = 원파일 · 엑셀만 = 엑셀 길 그대로
(d) 올림 길(814-1 파일 하나) — 원파일 = 새 HP(단가산출표 판 · 일위대가 없음 · 엑셀 HP 에 안 붙음) ·
가져온 식 = 원파일 · 엑셀 HP = 엑셀 길 그대로
원파일 · 실무 엑셀 없으면 그 시험만 skip(다른 PC).
"""
@@ -33,7 +33,6 @@ from test_810_1_stc import FILES as STC # noqa: E402
from M02_MasterTemplete import M02_Doc_FromNative as fn # noqa: E402
from M02_MasterTemplete import M02_Doc_Import as dim # noqa: E402
from M02_MasterTemplete import M02_Hopyo_Build as hb # noqa: E402
from M02_MasterTemplete import M02_Hopyo_Excel as hx # noqa: E402
from M02_MasterTemplete import M02_Hopyo_Store as hs # noqa: E402
from M02_MasterTemplete import M02_MasterTemplete_Store as store # noqa: E402
@@ -200,53 +199,34 @@ def _excel_hp(folder: Path, tag: str) -> str:
return row["키"]
def test_upload_native_joins_excel_and_wins(world) -> None:
def test_upload_native_one_file_list_only(world) -> None:
"""814-1 — 파일 하나: 원파일은 같은 공사 엑셀 HP 에 안 붙고 새 HP(단가산출표 판 · 일위대가 없음)."""
folder = world["personal"]
est = _file(EST["거창"])
est, stc = _file(EST["거창"]), _file(STC["봉화"])
with store.at(folder):
hp = _excel_hp(folder, "거창")
before = fh.emit(folder, hp, 15)
assert not before["원본"].get("원파일") # 엑셀만 = 엑셀 길
got = fh.upload_native(folder, est.name, est.read_bytes(), WHO)
assert got["붙음"] == [hp] and got["키"] == hp and got["원파일"]
assert got["원파일"] and got["키"] != hp and "붙음" not in got and got["호표수"] == 0
assert not fh.emit(folder, hp, 15)["원본"].get("원파일") # 엑셀 HP = 엑셀 길 그대로
again = fh.upload_native(folder, est.name, est.read_bytes(), WHO)
assert again["붙음"] == [hp] # 다시 올려도 새 HP 없음
assert len(hs.rows(folder)) == 1
made = fh.make(folder, hp, 15) # 전석 메쌓기 — 안쪽 산근 둘 + 굴삭기
assert again["이어서"] and again["키"] == got["키"] and len(hs.rows(folder)) == 2
grid = hs.grid(folder, got["키"])
assert grid["보기"] == {"단가산출표": "s1"} and not grid["호표"]
assert len(grid["목록"]["단산"]) == len(fn.numbers(native("EST", "거창")))
made = fh.make(folder, got["키"], 15) # 전석 메쌓기 — 안쪽 산근 둘 + 굴삭기
sk = store.read("sangeun", made["이름"])["문서"]
assert sk["가져온"]["원본"]["원파일"] and made["검산"]["같음"]
refs = [x for x in sk["가져온"]["로직"]["호표"] if x.get("참조")]
assert len(refs) == 2 # 산근 속 산근 = 형제 SK 참조(797-1)
doc = dim.of(sk)
assert doc["가져온"]["원파일"] is True
ans = dim.run(doc, sk)
assert len([x for x in sk["가져온"]["로직"]["호표"] if x.get("참조")]) == 2
ans = dim.run(dim.of(sk), sk)
assert (ans["노무비"], ans["재료비"], ans["경비"]) == (74880, 7029, 12306)
# 엑셀 먼저 · 원파일 나중의 반대 — 원파일 HP 가 있으면 엑셀 올림에 붙음
other = world["company"]
with store.at(other):
fh.upload_native(other, est.name, est.read_bytes(), WHO)
hp2 = _excel_hp(other, "거창")
assert fh.emit(other, hp2, 1)["원본"].get("원파일")
def test_upload_native_alone_hopyo_to_sc(world) -> None:
folder = world["personal"]
est, stc = _file(EST["장수"]), _file(STC["봉화"])
with store.at(folder):
got = fh.upload_native(folder, est.name, est.read_bytes(), WHO)
assert got["원파일"] and not got.get("붙음") and got["호표수"] == 3
out = hb.build_all(folder, folder, got["키"], None, True, 1) # 제1호표 = SC + 부르는 SK
assert [x["번"] for x in out["만듦"]] == [1] and out["같음"] == 1 and not out["실패"]
assert out["단가산출"] and all(
store.read("sangeun", k)["문서"]["가져온"]["원본"].get("원파일")
for k in out["단가산출"]
)
st = fh.upload_native(folder, stc.name, stc.read_bytes(), WHO)
assert st["원파일"] and st["호표수"] == 0 # STmate 일위대가 구성 못 읽음 — 산근만
assert st["원파일"] and st["호표수"] == 0
head = hs.grid(folder, st["키"])["책"]["시트"][0]["칸"]
assert head["A1"]["값"] == "코드" and str(head["A2"]["값"]).startswith("D")
made = fh.make(folder, st["키"], next(iter(fn.numbers(native("STC", "봉화")))))
assert made["검산"]["같음"]
with pytest.raises(store.StoreError):
fh.upload_native(folder, "깨진.estx", b"PK\x03\x04garbage", WHO)
fh.upload_native(folder, "깨진.estx", b"PKgarbage", WHO)
def test_router_branch_by_extension() -> None: