merge(707): sub_laptop_8(sub5 707-1 내역 줄 불러오기) 합침 — 불러오기 길 · 화면 한 파일 · file_key 하나 (PLAN 707-1 · 707-2 · 707-3)

- Router_Import = import/bill · bill/apply(sub5) + import/rates · import/prices · 올린 파일 = 프로젝트 import/{표지} 한 자리
- UI_Import = [엑셀 내역 불러오기] + [엑셀에서 요율 · 단가표 불러오기] · 마지막 표지 함께 씀
- test_145_3 · test_166_2 TS 거울 = 글 stdin(Calc_Base · Calc_Bill 길어져 node -e 32 K 넘김)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 16:30:43 +09:00
co-authored by Claude Opus 5.5
16 changed files with 1208 additions and 96 deletions
+238
View File
@@ -0,0 +1,238 @@
"""B09 경쟁사 내역 엑셀 읽기(PLAN 707-1) — 파일 → 내역 줄(묶음 · 항목) · 엑셀 합계. 저장 안 함.
꼴 둘(`_분석_705-4_불러오기_범위.md` ②):
STmate 「설계내역서」(없으면 「착공내역서」 · 「설계변경내역서」) —
숨은 열 = 줄 코드 · 부모 코드(`B00194_1` · `E2_1` — 비고 열 뒤 첫 둘) ·
묶음 = 코드 E · C(C = 제외금액 맨 위) ·
변경 차수(`_2` 뒤) 줄 = 안 읽음 · 부모 = 열린 묶음 가운데 부모 코드가 같은 가장 안쪽 ·
호표 = 코드 B 대가 · D 산근 · M 자재(번 = 비고 「대가 5호표」 · 「산근 13호표」 숫자)
EST 「내역서」 — 숨은 열 없음 · 묶음 = 공종 칸 있음 + 수량 없음 · 깊이 = 공종 번호
(Ⅰ · Ⅱ = 1 · 「4.1」 = 점 둘레 수 + 1) · 공종 · 수량 없이 금액만 = 합계 줄(안 읽음) ·
호표 = 비고 「단산46참조」 · 「일위8참조」 · 「관급」
열 = 머리 글(앞 10 줄 · 빈칸 뺌) 공종 · 명칭|품명 · 규격 · 수량 · 단위 · 합계 ·
노무비 · 재료비 · 경비 · 비고 — 돈 머리 = 단가 열 · 다음 열 = 금액
(`M02_Hopyo_Excel` 의 칸 읽기 재사용) · 빈 단가 = 0.
제외 = 맨 위 묶음 가운데 STmate 코드 C · 비고 「제외」 · 이름 「관급」 — 직접비 합에서 뺌.
조달수수료 = 제외 묶음 안 단위 % · 이름 「수수료」 줄 → `조달수수료율`
(줄로 안 실음 · 우리 조달수수료 줄이 셈).
그 밖 % 줄 = 1 식 · 단가 = 엑셀 금액(금액 그대로).
감싼 묶음 = 직접 맨 위 묶음이 하나뿐(공사 이름)이면 벗김(깊이 5 한도 · 레벨 1 = 공종).
DRM 파일 = 「읽을 수 없음」(`M02_Hopyo_Excel.DRM`).
"""
from __future__ import annotations
import io
import re
import warnings
from decimal import Decimal
from typing import Any
from M02_MasterTemplete import M02_Hopyo_Excel as hx
SHEETS = ("설계내역서", "내역서", "착공내역서", "설계변경내역서")
HEADS = {
"공종": "공종",
"명칭": "품명",
"품명": "품명",
"규격": "규격",
"수량": "수량",
"단위": "단위",
"합계": "합계",
"노무비": "노무비",
"재료비": "재료비",
"경비": "경비",
"비고": "비고",
}
PARTS = (("노무비", "labor"), ("재료비", "material"), ("경비", "expense"))
_CODE = re.compile(r"^([A-Z]+\d*)_(\d+)$")
_ROMAN = re.compile(r"^[ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩ]+$|^[IVX]+\.?$")
_ST_NOTE = re.compile(r"^(대가|산근|자재)(\d+)")
_EST_NOTE = re.compile(r"^(단산|일위)(\d+)")
NO_SHEET = "내역서 시트(설계내역서 · 내역서) 없음"
class BillExcelError(ValueError):
"""못 읽는 파일 — 길이 400 으로 답함."""
def _sheet(name: str, data: bytes) -> tuple[str, list[list[Any]]]:
if not data:
raise BillExcelError("빈 파일")
if b"DRMONE" in data[:256] and data[:4] not in (b"PK\x03\x04", b"\xd0\xcf\x11\xe0"):
raise BillExcelError(f"읽을 수 없음 — {hx.DRM}")
try:
if data[:4] == b"PK\x03\x04" or name.lower().endswith(".xlsx"):
import openpyxl
with warnings.catch_warnings(): # 인쇄 영역 이름 경고(STmate) — 읽기와 무관
warnings.simplefilter("ignore")
book = openpyxl.load_workbook(io.BytesIO(data), read_only=True, data_only=True)
titles = {ws.title.strip(): ws for ws in book.worksheets}
pick = next((t for t in SHEETS if t in titles), None)
rows = [list(r) for r in titles[pick].iter_rows(values_only=True)] if pick else []
book.close()
else:
import xlrd
book = xlrd.open_workbook(file_contents=data, logfile=io.StringIO())
titles = {sh.name.strip(): sh for sh in book.sheets()}
pick = next((t for t in SHEETS if t in titles), None)
sh = titles.get(pick or "")
rows = [sh.row_values(r) for r in range(sh.nrows)] if sh else []
except Exception as error: # 라이브러리마다 다른 예외 — 깨진 파일 · 엑셀 아님
raise BillExcelError("엑셀 파일을 읽지 못함 — xls · xlsx 가 아니거나 깨진 파일") from error
if pick is None:
raise BillExcelError(NO_SHEET)
return pick, rows
def _columns(rows: list[list[Any]]) -> tuple[int, dict[str, int]]:
for at, row in enumerate(rows[:10]):
flat = [hx._flat(v) for v in row]
if "공종" not in flat or not ({"명칭", "품명"} & set(flat)):
continue
cols: dict[str, int] = {}
for col, head in enumerate(flat):
if head in HEADS and HEADS[head] not in cols:
cols[HEADS[head]] = col
below = [hx._flat(v) for v in rows[at + 1]] if at + 1 < len(rows) else []
return (at + 2 if "단가" in below or "금액" in below else at + 1), cols
raise BillExcelError("내역서 머리 글(공종 · 명칭)을 못 찾음")
def _codes(row: list[Any], after: int) -> tuple[str, str] | None:
"""STmate 숨은 열 (줄 코드, 부모 코드) — 비고 뒤 「X_n」 꼴 첫 둘 ·
변경 차수(_2 뒤)면 ("", "")."""
got = [m for v in row[after + 1 :] if (m := _CODE.match(hx._text(v)))]
if not got:
return None
if got[0].group(2) != "1":
return "", ""
return got[0].group(1), (got[1].group(1) if len(got) > 1 else "")
def _depth(head: str) -> int:
if _ROMAN.match(head):
return 1
parts = [p for p in head.split(".") if p.strip()]
return len(parts) + 1 if parts and all(p.strip().isdigit() for p in parts) else 1
def _money(row: list[Any], cols: dict[str, int], offset: int) -> dict[str, Decimal]:
got = hx._money(row, cols, offset)
return {key: got.get(head) or Decimal(0) for head, key in PARTS}
def _hopyo(note: str, code: str) -> dict[str, Any] | None:
flat = hx._flat(note)
if hit := _ST_NOTE.match(flat):
return {"종류": hit.group(1), "번": int(hit.group(2)), "코드": code}
if hit := _EST_NOTE.match(flat):
return {"종류": "산근" if hit.group(1) == "단산" else "대가", "번": int(hit.group(2))}
if code[:1] in ("B", "D", "M"):
return {"종류": {"B": "대가", "D": "산근", "M": "자재"}[code[0]], "코드": code}
return None
def read_bill(name: str, data: bytes) -> dict[str, Any]:
"""엑셀 → `{꼴, 시트, 줄 [{행, 묶음, 위, 공종, 이름, 규격, 단위, 수량, 단가 {labor, material,
expense}, 금액 {…}, 비고, 호표, 제외}], 합계 {직접, 제외}, 조달수수료율, 합계줄, 율줄}` —
`위` = 부모 줄 행(None = 맨 위) · 수 = JSON 수."""
title, rows = _sheet(name, data)
start, cols = _columns(rows)
lines: list[dict[str, Any]] = []
stack: list[tuple[Any, dict[str, Any]]] = [] # (코드 | 깊이, 묶음 줄)
stmate, skipped, fee, pct_lines = False, 0, None, 0
for at, row in enumerate(rows[start:], start + 1):
cell = lambda h: hx._cell(row, cols.get(h)) # noqa: E731
head, item_name = hx._text(cell("공종")), hx._text(cell("품명"))
if not item_name:
continue
qty = hx._num(cell("수량"))
codes = _codes(row, cols.get("비고", 13))
stmate = stmate or codes is not None
if codes == ("", ""):
continue
if codes:
own, parent = codes
group = own[:1] in ("E", "C")
at_up = next((n for n in range(len(stack) - 1, -1, -1) if stack[n][0] == parent), None)
stack = stack[: at_up + 1] if at_up is not None else []
else:
own = ""
group = bool(head) and qty is None
if not group and qty is None:
skipped += 1 # 「순 공 사 비」 같은 합계 줄
continue
if group:
depth = _depth(head)
while stack and stack[-1][0] >= depth:
stack.pop()
up = stack[-1][1] if stack else None
note = hx._text(cell("비고"))
line = {
"행": at,
"묶음": group,
"위": up["행"] if up else None,
"공종": head,
"이름": item_name,
"규격": hx._text(cell("규격")),
"단위": hx._text(cell("단위")),
"수량": qty,
"단가": _money(row, cols, 0),
"금액": _money(row, cols, 1),
"비고": note,
"호표": None if group else _hopyo(note, own),
"제외": (up or {}).get("제외", False)
or (up is None and (own[:1] == "C" or "제외" in note or "관급" in item_name)),
}
if group:
stack.append((own if codes else _depth(head), line))
elif hx._flat(line["단위"]) == "%":
if line["제외"] and "수수료" in item_name:
fee = fee if fee is not None else qty
continue
pct_lines += 1
line.update({"단가": line["금액"], "수량": Decimal(1), "단위": "식"})
line["규격"] = " ".join(t for t in (line["규격"], f"({hx._out(qty)} %)") if t)
lines.append(line)
_unwrap(lines)
total = {"직접": Decimal(0), "제외": Decimal(0)}
for line in lines:
if not line["묶음"]:
total["제외" if line["제외"] else "직접"] += sum(line["금액"].values())
return _plain(
{
"꼴": "STmate" if stmate else "EST",
"시트": title,
"줄": lines,
"합계": total,
"조달수수료율": fee,
"합계줄": skipped,
"율줄": pct_lines,
}
)
def _unwrap(lines: list[dict[str, Any]]) -> None:
"""직접 맨 위 묶음이 하나뿐이면(공사 이름) 벗김 — 자식이 맨 위로(되풀이)."""
while True:
tops = [x for x in lines if x["위"] is None and not x["제외"]]
if len(tops) != 1 or not tops[0]["묶음"]:
return
wrap = tops[0]["행"]
lines.remove(tops[0])
for line in lines:
if line["위"] == wrap:
line["위"] = None
def _plain(value: Any) -> Any:
if isinstance(value, Decimal):
return hx._out(value)
if isinstance(value, dict):
return {k: _plain(v) for k, v in value.items()}
if isinstance(value, list):
return [_plain(v) for v in value]
return value
@@ -0,0 +1,228 @@
"""B09 경쟁사 내역 줄 불러오기 짜기(PLAN 707-1) — 읽은 내역(`Bill_Excel.read_bill`) → 설계내역서
덮개 v2(`user.bill` · 계약 = `B09_Estimation_Calc_Bill` 머리). 파일 · 저장 안 만짐(부르는 쪽 몫).
먼저 싣고 나중 잇기 — 빠짐 0 · 실은 줄 = 표 차례에서 관급자재대(`sec:G`) 앞:
묶음 줄 → 새 묶음(`hand:x{표지}-{행}` · 제외 맨 위 = 관급자재대 `sec:G` 아래)
항목 줄 → 새 줄 + 고정 단가 칸 `price`(엑셀 단가) + `src_qty`(엑셀 수량) + `note`(엑셀 비고) ·
연결 = 같은 책 일위대가 사본(`links` {대가 번: ref}) — 없으면 고정 단가로 금액
같은 파일을 다시 부르면(같은 표지) 앞서 실은 줄을 갈아 끼움.
수량 갈래 — `take`(가 넘겨받기) 붙인 자동 줄 수량 = 엑셀(B08 = `auto_qty` 로 나란히) ·
`price`(나 단가만) 붙인 자동 줄 수량 = B08(B08 수량 없으면 엑셀) · 엑셀 수량 = 비교 칸.
새 줄 수량 = 늘 엑셀.
두 번 셈 막음 — `attach` = 이름(빈칸 뺌)이 같은 자동 항목 줄에 붙임(그 줄 = 불러온 자리로 옮기고
단가 = 연결 · 고정 단가 칸 · 자재 줄 · 운반 줄(이름이 자재와 같음 「모래」) · 조달수수료 뺌) ·
`drop` = 안 붙은 자동 항목 줄 지움(`gone`).
조달수수료율 = 사용자 값이 비었을 때만 엑셀 율.
"""
from __future__ import annotations
import copy
import hashlib
import re
from typing import Any
MODES = ("take", "price")
FIELDS = ("name", "spec", "unit", "qty", "ref")
PARTS = ("labor", "material", "expense")
#: 붙이지도 지우지도 않는 자동 줄 머리 — 자재(대비표 주인) · 조달수수료(운반 = 지움만)
SKIP = ("mat", "fee")
def tag_of(data: bytes) -> str:
"""파일 표지 — 같은 내용 = 같은 표지(새 줄 키 머리)."""
return hashlib.sha1(data).hexdigest()[:8]
def _flat(text: Any) -> str:
return re.sub(r"\s+", "", str(text or ""))
def freeze(user: dict[str, Any], rows: list[dict[str, Any]]) -> dict[str, Any]:
"""화면 굳힘(`UI_Tabs_Bill_Edit.freeze`)과 같은 일 — v2 = 그대로 + 덮개에 없는 보이는 줄을
보이는 앞 줄 뒤에 · 옛 꼴 = 보이는 줄로(자동 줄 수량 덮개 · 새 줄 칸 옮김)."""
bill = user.get("bill") or {}
v2 = bill.get("v") == 2
entries = copy.deepcopy(bill.get("rows") or []) if v2 else []
gone = list(bill.get("gone") or []) if v2 else []
prev = -1
for row in rows:
key = row["key"]
if key == "fee":
continue
at = next((n for n, e in enumerate(entries) if e["key"] == key), -1)
if at < 0 and key not in gone:
entry = {"key": key, **({"parent": row["parent"]} if row.get("parent") else {})}
if not v2 and row.get("auto"):
if key in (bill.get("qty") or {}):
entry["qty"] = bill["qty"][key]
elif not v2:
hand = next((h for h in bill.get("rows") or [] if h.get("key") == key), {})
entry.update({f: hand[f] for f in FIELDS if f in hand})
if row["kind"] != "item":
entry["kind"] = "group"
entries.insert(prev + 1, entry)
at = prev + 1
if at >= 0:
prev = at
return {"v": 2, "rows": entries, "gone": gone}
def _amount(line: dict[str, Any]) -> float:
return sum(float(line["금액"].get(p) or 0) for p in PARTS)
def _price(line: dict[str, Any]) -> dict[str, float]:
return {p: float(line["단가"].get(p) or 0) for p in PARTS}
def _autos(rows: list[dict[str, Any]], attach: bool = False) -> list[dict[str, Any]]:
"""지울 수 있는 자동 항목 줄(자재 · 조달수수료 뺌) ·
`attach` = 붙일 수 있는 줄(운반도 뺌 — 「모래」 = 자재대 줄 이름)."""
skip = (*SKIP, "haul") if attach else SKIP
return [
r
for r in rows
if r["kind"] == "item" and r.get("auto") and r["key"].split(":")[0] not in skip
]
def matches(parsed: dict[str, Any], rows: list[dict[str, Any]]) -> dict[str, str]:
"""{불러온 줄 행(글): 자동 줄 키} — 이름(빈칸 뺌)이 같은 첫 남은 자동 줄 · 제외 줄 뺌."""
free: dict[str, list[str]] = {}
for row in _autos(rows, attach=True):
free.setdefault(_flat(row["name"]), []).append(row["key"])
out = {}
for line in parsed["줄"]:
if line["묶음"] or line["제외"]:
continue
keys = free.get(_flat(line["이름"])) or []
if keys:
out[str(line["행"])] = keys.pop(0)
return out
def preview(parsed: dict[str, Any], rows: list[dict[str, Any]]) -> dict[str, Any]:
"""불러오기 창 수 — 묶음 · 항목 · 이름 같은 자동 줄 · 그 밖 자동 줄 · 엑셀 합계."""
lines = parsed["줄"]
pairs = matches(parsed, rows)
return {
"꼴": parsed["꼴"],
"시트": parsed["시트"],
"묶음": sum(1 for x in lines if x["묶음"]),
"항목": sum(1 for x in lines if not x["묶음"]),
"합계": parsed["합계"],
"조달수수료율": parsed["조달수수료율"],
"합계줄": parsed["합계줄"],
"율줄": parsed["율줄"],
"같은이름": len(pairs),
"남는자동": len(_autos(rows)) - len(pairs),
}
def apply(
parsed: dict[str, Any],
user: dict[str, Any],
rows: list[dict[str, Any]],
tag: str,
mode: str = "take",
attach: bool = True,
drop: bool = False,
links: dict[int, dict[str, Any]] | None = None,
) -> tuple[dict[str, Any], dict[str, Any]]:
"""(새 사용자 값, 알림 {실음, 붙임, 지움, 이음, 줄 {행: 줄 키}}) — `rows` = 지금 셈 줄."""
if mode not in MODES:
raise ValueError(f"수량 갈래는 {', '.join(MODES)} 중 하나")
links = links or {}
head = f"hand:x{tag}-"
bill = freeze(user, rows)
kept = []
for e in bill[
"rows"
]: # 앞서 실은 줄 갈아 끼움 — 그 묶음에 있던 자동 줄 = 제자리 · 새 줄 = 맨 위
if e["key"].startswith(head):
continue
if str(e.get("parent") or "").startswith(head):
if not e["key"].startswith("hand:"):
continue
e.pop("parent")
kept.append(e)
bill["rows"] = kept
pairs = matches(parsed, rows) if attach else {}
by_key = {r["key"]: r for r in rows}
keys = {line["행"]: f"{head}{line['행']:05d}" for line in parsed["줄"]}
put, report = [], {"실음": 0, "붙임": 0, "지움": 0, "이음": 0, "줄": {}}
for line in parsed["줄"]:
parent = keys.get(line["위"]) if line["위"] is not None else None
if parent is None and line["제외"]:
parent = "sec:G"
hopyo = line.get("호표") or {}
link = links.get(hopyo["번"]) if hopyo.get("종류") == "대가" and "번" in hopyo else None
report["이음"] += bool(link)
cells = {"price": _price(line), "src_qty": line["수량"]}
auto = pairs.get(str(line["행"]))
if auto:
entry = next((e for e in bill["rows"] if e["key"] == auto), None)
if entry is None:
entry = {"key": auto}
else:
bill["rows"].remove(entry)
entry.update({**cells, "ref": link})
if mode == "take" or by_key[auto].get("auto_qty", by_key[auto]["qty"]) is None:
entry["qty"] = line["수량"] # 단가만 · B08 수량 없음 = 엑셀
entry.pop("parent", None)
put.append({**entry, **({"parent": parent} if parent else {})})
report["붙임"] += 1
report["줄"][str(line["행"])] = auto
continue
entry = {"key": keys[line["행"]], **({"parent": parent} if parent else {})}
entry.update({"name": line["이름"], "spec": line["규격"], "unit": line["단위"]})
if line["묶음"]:
entry.update({"kind": "group", "qty": None, "ref": None})
else:
entry.update({"qty": line["수량"], "ref": link, **cells, "note": line["비고"]})
report["줄"][str(line["행"])] = entry["key"]
put.append(entry)
report["실음"] += 1
if drop:
used = set(pairs.values())
for row in _autos(rows):
if row["key"] not in used and row["key"] not in bill["gone"]:
bill["gone"].append(row["key"])
report["지움"] += 1
bill["rows"] = [e for e in bill["rows"] if e["key"] not in bill["gone"]]
at = next((n for n, e in enumerate(bill["rows"]) if e["key"] == "sec:G"), len(bill["rows"]))
bill["rows"][at:at] = put # 불러온 공종 = 관급자재대 앞(엑셀 차례)
out = {**user, "bill": bill}
estimate = dict(user.get("estimate") or {})
if estimate.get("procurement_fee_pct") is None and parsed.get("조달수수료율") is not None:
estimate["procurement_fee_pct"] = parsed["조달수수료율"]
out["estimate"] = estimate
return out, report
def differences(
parsed: dict[str, Any], report: dict[str, Any], rows: list[dict[str, Any]]
) -> list[dict[str, Any]]:
"""불러온 직후 엑셀 금액 ≠ 우리 금액인 줄 [{행, 이름, 엑셀, 우리, 까닭}]."""
by_key = {r["key"]: r for r in rows}
out = []
for line in parsed["줄"]:
row = by_key.get(report["줄"].get(str(line["행"]), ""))
if row is None:
continue
excel, ours = _amount(line), row.get("sum")
if ours is not None and abs(ours - excel) < 1:
continue
if ours is None:
why = "빈칸 — 단가 · 수량 없음"
elif row.get("qty") != line["수량"]:
why = "수량 = B08(단가만)"
elif not row.get("fixed"):
why = "이음 단가 = 우리 셈"
else:
why = "끝수"
out.append(
{"행": line["행"], "이름": line["이름"], "엑셀": excel, "우리": ours, "까닭": why}
)
return out
+23 -3
View File
@@ -27,7 +27,10 @@
(제목 `sec:*` = name 만 · `mat:` = ref 없음 ·
`fee` = 덮개 없음 · 차례에 안 넣음 — `sec:G` 끝 고정)
새 줄(`hand:*`) = 모든 칸 + "kind": "group"(묶음 · 수량 · 연결 없음) | 없음 = 항목 ·
"cost": "direct"(맨 위 새 묶음 · 없음도 direct)}],
"cost": "direct"(맨 위 새 묶음 · 없음도 direct) ·
고정 단가 칸(707-1 · 새 줄 · 자동 줄 둘 다) = "price" {labor, material, expense}(제 단가 ·
연결이 단가를 못 내면 이것으로 금액 · 줄 `fixed`) · "src_qty"(불러온 수량 = 비교 칸) ·
"note"(새 줄 비고 · 연결 비고가 이김)}],
"gone": [지운 자동 줄 키 — `sec:G` · `fee` · `mat:` 는 안 받음(자재 줄 주인 = 대비표 표시)]}
덮개 없음 = 기본 표 그대로 · [초기화] = `bill` 지움 ·
`rows` 에 없고 `gone` 에도 없는 자동 줄(B08 새 열 · 대비표 새 표시) =
@@ -113,6 +116,8 @@ def _item_row(item: dict[str, Any], prices: dict[str, Any] | None) -> dict[str,
"ref": item.get("ref"),
**{k: v for k, v in item.items() if k.startswith("auto_")},
**({"hand": True} if item.get("hand") else {"auto": True}),
**({"fixed": True} if item.get("fixed") else {}),
**({"src_qty": item["src_qty"]} if num(item.get("src_qty")) is not None else {}),
**_amounts(qty, prices),
}
row["_gaps"] = []
@@ -170,16 +175,27 @@ def ref_price(
return hopyo.get(key) if key else None
def fixed_price(price: Any) -> dict[str, Any] | None:
"""고정 단가 칸(707-1) → 1 단위 단가 — 빈 비목 0 · 칸 없음 = None."""
if not isinstance(price, dict):
return None
out = {p: num(price.get(p)) or 0.0 for p in PARTS}
return {"no": "", **out, "sum": out["labor"] + out["material"] + out["expense"]}
def hand_items(user: dict[str, Any], books: dict[str, Any], hopyo: dict) -> list[tuple]:
"""새 줄(`hand:`) → (항목, 단가) — 연결 = 고른 호표(로직 = 새 산근 키로 풀어 둠)."""
"""새 줄(`hand:`) → (항목, 단가) — 연결 = 고른 호표(로직 = 새 산근 키로 풀어 둠) ·
연결이 단가를 못 내면 고정 단가 칸(707-1)."""
out = []
for hand in (user.get("bill") or {}).get("rows") or []:
if not str(hand.get("key") or "").startswith("hand:"):
continue
price = ref_price(hand.get("ref"), books, hopyo, hand["key"])
ref = {"tab": price["tab"], "key": price["key"]} if price and price.get("tab") else None
fixed = price is None and (price := fixed_price(hand.get("price"))) is not None
why = WHY_LOGIC if (hand.get("ref") or {}).get("tab") == "logic" else WHY_HAND
item = {**hand, "hand": True, "note": (price or {}).get("no") or "", "ref": ref}
note = (price or {}).get("no") or hand.get("note") or ""
item = {**hand, "hand": True, "note": note, "ref": ref, "fixed": fixed}
out.append((item, price, why))
return out
@@ -209,6 +225,10 @@ def _covered(
out["note"] = (prices or {}).get("no") or ""
if (cover["ref"] or {}).get("tab") == "logic":
out["why"] = WHY_LOGIC
if prices is None and (prices := fixed_price(cover.get("price"))) is not None:
out["fixed"] = True # 707-1 연결이 단가를 못 냄 = 고정 단가 칸
if num(cover.get("src_qty")) is not None:
out["src_qty"] = num(cover["src_qty"])
return out, prices
+28 -3
View File
@@ -13,6 +13,8 @@
* 단가 = `refPrice(ref)` 하나(dg 대가 목록 · sg 산근 목록 · logic = 서버 sg_hand 산근(702-3 연결 즉시 뒤에서 셈) · 그 밖 hopyo)
* 자유 편집(184) = `user.bill` v2 덮개 {v: 2, rows: [표 차례 · 고친 칸 · 새 줄], gone: [지운 자동 줄]} ·
* 옛 꼴(qty · 손 줄 after · parent) 그대로 읽음 · 저장 꼴 · 출력 줄 칸(계약) = 짝 .py 머리
* 고정 단가 칸(707-1) = 줄 price {labor, material, expense} — 연결이 단가를 못 내면 그것으로 금액(줄 fixed) ·
* src_qty = 불러온 수량(비교 칸)
* 출력 줄 칸 = kind · key · name · parent · level · no · cost · auto | hand · auto_{칸} · section
* ========================================================================== */
@@ -79,6 +81,8 @@ function itemRow(item: Row, prices: Row | null): Row {
ref: item.ref ?? null,
...Object.fromEntries(Object.entries(item).filter(([k]) => k.startsWith("auto_"))),
...(item.hand ? { hand: true } : { auto: true }),
...(item.fixed ? { fixed: true } : {}),
...(num(item.src_qty) !== null ? { src_qty: item.src_qty } : {}),
...amounts(qty, prices),
};
row._gaps = [];
@@ -134,15 +138,31 @@ export function refPrice(ref: Row | null, books: Row, hopyo: Row, rowKey = ""):
return key ? (hopyo[key] ?? null) : null;
}
/** 새 줄(hand:) → [항목, 단가, 까닭] — 연결 = 고른 호표(로직 = 새 산근 키로 풀어 둠) */
/** 고정 단가 칸(707-1) → 1 단위 단가 — 빈 비목 0 · 칸 없음 = null */
export function fixedPrice(price: unknown): Row | null {
if (!price || typeof price !== "object" || Array.isArray(price)) return null;
const out: Row = { no: "" };
for (const p of PARTS) out[p] = num((price as Row)[p]) || 0;
out.sum = out.labor + out.material + out.expense;
return out;
}
/** 새 줄(hand:) → [항목, 단가, 까닭] — 연결 = 고른 호표(로직 = 새 산근 키로 풀어 둠) ·
* 연결이 단가를 못 내면 고정 단가 칸(707-1) */
export function handItems(user: Row, books: Row, hopyo: Row): Array<[Row, Row | null, string]> {
const out: Array<[Row, Row | null, string]> = [];
for (const hand of (user.bill || {}).rows || []) {
if (!String(hand.key || "").startsWith("hand:")) continue;
const price = refPrice(hand.ref ?? null, books, hopyo, hand.key);
let price = refPrice(hand.ref ?? null, books, hopyo, hand.key);
const ref = price?.tab ? { tab: price.tab, key: price.key } : null;
let fixed = false;
if (price === null) {
price = fixedPrice(hand.price);
fixed = price !== null;
}
const why = hand.ref?.tab === "logic" ? WHY_LOGIC : WHY_HAND;
out.push([{ ...hand, hand: true, note: price?.no || "", ref }, price, why]);
const note = price?.no || hand.note || "";
out.push([{ ...hand, hand: true, note, ref, fixed }, price, why]);
}
return out;
}
@@ -171,6 +191,11 @@ function covered(
out.note = prices?.no || "";
if (cover.ref?.tab === "logic") out.why = WHY_LOGIC;
}
if (prices === null) {
prices = fixedPrice(cover.price); // 707-1 연결이 단가를 못 냄 = 고정 단가 칸
if (prices !== null) out.fixed = true;
}
if (num(cover.src_qty) !== null) out.src_qty = num(cover.src_qty);
return [out, prices];
}
+2 -2
View File
@@ -41,7 +41,7 @@
지난 공사 일위대가 → 이 공사 서고 사본(덮개째) → {폴더, 만듦, key, cover, 덮개, payload} ·
덮개 = 그 공사 저장 칸(화면 캐시 cover 가 비었을 때만 얹음 · 같은 꼴 블록이 먼저 있으면 빈 것) ·
같은 회사만(남의 회사 403)
불러오기(707-2 요율 · 707-3 단가표) = `B09_Estimation_Router_Import`.
경쟁사 결과물 불러오기(707-1 내역 줄 · 707-2 요율 · 707-3 단가표) = `B09_Estimation_Router_Import`.
운반 사본 연결 = 셈 때마다 거리로 사본 다시 고름(`Bill_Library.resolve_hauls` · `fix_keys`).
저장 자리 = 프로젝트 안 `B09_Estimation/v1/`(`B09_Estimation_Store`) ·
@@ -556,7 +556,7 @@ async def past_import(
return {**made, "key": key, "cover": cover, "payload": out}
# 166-4 견적서 파일 길 · 707-2 · 3 불러오기 길 — 같은 머리 · 같은 권한
# 166-4 견적서 파일 길 · 707-1~3 경쟁사 결과물 불러오기 길 — 같은 머리 · 같은 권한
router.include_router(quotes_router)
router.include_router(import_router)
+192 -61
View File
@@ -1,99 +1,230 @@
"""B09 경쟁사 결과물 불러오기 길 — 요율(707-2) · 기초 단가표(707-3) · 저장 안 함(제안 → 화면 캐시).
"""B09 경쟁사 결과물 불러오기 길 — 내역 줄(707-1) · 요율(707-2) · 기초 단가표(707-3) · 저장 안 함
(화면 캐시로 → [저장] 때 서버 정본).
POST /api/projects/{id}/estimation/import/rates form: file | file_key · user?(화면 캐시 JSON)
원가계산서 요율 → {file_key, name, kind, rows, rates, notes, check, patch{estimate}}
POST /api/projects/{id}/estimation/import/prices form: file | file_key · user?
노임 · 자재 · 기계 단가표 → 이 공사 단가 책에 이음 →
{file_key, name, counts, linked, missing, patch{labor_cover, machine_cover, materials}}
올린 파일 = 서버 임시 폴더에 내용 지문(`file_key`)으로 한 시간 둠 — 같은 파일을 요율 · 단가표 ·
내역 줄(707-1 · sub5)이 다시 올리지 않고 `file_key` 로 씀(`load` · `remember`).
화면은 `patch` 를 캐시에 얹고 [저장] 때 서버가 정본으로 다시 셈(5장).
POST /api/projects/{id}/estimation/import/bill (multipart: file | file_key · user?)
읽기 + 미리 보기 — 파일 = 프로젝트 `B09_Estimation/v1/import/{표지}/`
(file_key = 표지 · 같은 파일 다시 씀) → {file_key, 파일, 꼴, 시트, 묶음, 항목, 합계,
조달수수료율, 합계줄, 율줄, 같은이름, 남는자동, 이음} ·
user = 화면 캐시(JSON 글 · 없으면 저장 값) · DRM = 400 「읽을 수 없음」
POST /api/projects/{id}/estimation/import/bill/apply {file_key, mode, attach, drop, user?}
짜기(`B09_Estimation_Bill_Import.apply`) →
{user(새 캐시), 알림, 어긋남, 엑셀합계, payload(그 캐시로 셈)} ·
이음 = 읽을 수 있는 층(개인 → 회사 → 시스템)에서 같은 파일 이름으로 들여온 실무자료 사전조합
(본뜬 「HP-… 제N호표」) → 서고 사본(층 쓰기 0 · 프로젝트 사본만) · 대가 N 줄 연결.
POST /api/projects/{id}/estimation/import/rates (multipart: file | file_key · user?)
원가계산서 요율(707-2) → {file_key, name, kind, rows, rates, notes, check, patch{estimate}}
POST /api/projects/{id}/estimation/import/prices (multipart: file | file_key · user?)
노임 · 자재 · 기계 단가표(707-3) → 이 공사 단가 책에 이음 →
{file_key, name, sheets, counts, linked, missing,
patch{labor_cover, machine_cover, materials}}
file_key = 세 길 같은 표지(`_keep` · `_saved`) — 같은 파일을 다시 올리지 않음 ·
화면은 `patch` 를 캐시에 얹고 [저장] 때 서버가 정본으로 다시 셈.
`B09_Estimation_Router` 가 끼워 넣음(같은 머리 · 같은 권한).
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import re
import tempfile
import time
from pathlib import Path
from typing import Any
from uuid import UUID
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from pydantic import BaseModel, Field
from B03_FileInput.B03_FileInput_Engine import save_upload_stream
from B08_Quantity.B08_Quantity_Router_Hydro import _project_root
from B09_Estimation import B09_Estimation_Bill_Import as importer
from B09_Estimation import B09_Estimation_Bill_Library as library
from B09_Estimation import B09_Estimation_Import_Prices as import_prices
from B09_Estimation import B09_Estimation_Import_Rates as import_rates
from B09_Estimation import B09_Estimation_Prices as prices
from B09_Estimation import B09_Estimation_Store as store
from B09_Estimation.B09_Estimation_Bill_Excel import BillExcelError, read_bill
from B09_Estimation.B09_Estimation_Calc_Base import compute_base
from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs
from B09_Estimation.B09_Estimation_Rates import rate_table
from common_util.common_util_auth import verify_session
from M02_MasterTemplete import M02_Hopyo_Build as hopyo_build
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_Layer_Access as access
from M02_MasterTemplete.M02_Hopyo_Excel import ExcelError
from M02_MasterTemplete.M02_Item_Snapshot import importer as who_of
router = APIRouter()
PATH = "/{project_id}/estimation/import"
KEEP_S = 3600
_KEY = re.compile(r"^[0-9a-f]{24}$")
ROOT = "/{project_id}/estimation/import"
PATH = ROOT + "/bill"
IMPORT = "import"
MAX_BYTES = 50 * 1024 * 1024
_TAG = re.compile(r"^[0-9a-f]{8}$")
_MARK = re.compile(r"^HP-\d{6} 제(\d+)호표$")
def _dir() -> Path:
return Path(tempfile.gettempdir()) / "aislo_b09_import"
def _saved(root: Path, key: str) -> Path:
"""file_key → 둔 파일 — 없으면 404."""
folder = store.folder(root) / IMPORT / key
files = (
[f for f in folder.iterdir() if f.is_file()] if _TAG.match(key) and folder.is_dir() else []
)
if not files:
raise HTTPException(status_code=404, detail="불러온 파일이 없음 — 다시 고름")
return files[0]
def load(file_key: str) -> tuple[str, bytes]:
"""지문 → (파일 이름, 내용) — 없거나 지난 것 = 404."""
if not _KEY.match(file_key or ""):
raise HTTPException(400, "file_key 꼴이 아님")
data, name = _dir() / f"{file_key}.bin", _dir() / f"{file_key}.name"
if not data.is_file() or time.time() - data.stat().st_mtime > KEEP_S:
raise HTTPException(404, "올린 파일이 지났음 — 다시 올림")
return name.read_text(encoding="utf-8"), data.read_bytes()
async def remember(upload: UploadFile) -> tuple[str, str, bytes]:
"""올린 파일 → (지문, 이름, 내용) · 한 시간 지난 파일 지움."""
folder = _dir()
def _keep(root: Path, name: str, data: bytes) -> str:
key = importer.tag_of(data)
folder = store.folder(root) / IMPORT / key
folder.mkdir(parents=True, exist_ok=True)
for old in folder.glob("*.bin"):
if time.time() - old.stat().st_mtime > KEEP_S:
old.unlink(missing_ok=True)
old.with_suffix(".name").unlink(missing_ok=True)
temp = folder / f"up-{time.time_ns()}.part"
for old in folder.iterdir():
old.unlink()
(folder / (store.safe_name(name) or "내역.xlsx")).write_bytes(data)
return key
def _parse(name: str, data: bytes) -> dict[str, Any]:
try:
await save_upload_stream(upload, temp)
return read_bill(name, data)
except BillExcelError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
def _draft(raw: Any) -> dict[str, Any] | None:
if raw in (None, ""):
return None
try:
user = json.loads(raw) if isinstance(raw, str) else raw
except ValueError as error:
raise HTTPException(400, str(error)) from error
data = temp.read_bytes()
key = hashlib.sha256(data).hexdigest()[:24]
temp.replace(folder / f"{key}.bin")
name = store.safe_name(upload.filename or "") or "불러온 파일.xlsx"
(folder / f"{key}.name").write_text(name, encoding="utf-8")
return key, name, data
raise HTTPException(status_code=400, detail="user 가 JSON 이 아님") from error
return store.clean_user(user) if isinstance(user, dict) else None
async def _file(file: UploadFile | None, file_key: str | None) -> tuple[str, str, bytes]:
def _found(session: dict[str, Any], name: str, numbers: set[int]) -> dict[int, tuple]:
"""대가 번 → (층, 주인, 사전조합 키) — 같은 파일 이름 실무자료 본뜬 · 앞 층이 이김."""
out: dict[int, tuple] = {}
for layer in ("personal", "company", "system"):
try:
got = access.grant(session, layer)
except HTTPException: # 회사 없음 등 — 그 층 건너뜀
continue
for key, origin, _raw in hopyo_build._origins(refs.folder_of(got.layer, got.owner)):
hit = _MARK.match(str(origin.get("키") or ""))
no = int(hit.group(1)) if hit else None
if origin.get("파일") == name and no in numbers and no not in out:
out[no] = (got.layer, got.owner, key)
return out
def _links(root: Path, found: dict[int, tuple], who: Any) -> dict[int, dict[str, Any]]:
folders = {}
for no, (layer, owner, key) in found.items():
try:
folders[no] = library.import_copy(root, "precombo", layer, owner, key, who)["폴더"]
except (LookupError, ValueError): # 원본 없음 · 모양 틀림 = 고정 단가로
continue
libs = (daega_inputs(root).get("libs") or {}) if folders else {}
return {
no: {"tab": "dg", "key": libs[name], "lib": name}
for no, name in folders.items()
if libs.get(name)
}
def _numbers(parsed: dict[str, Any]) -> set[int]:
return {
int(h["번"])
for line in parsed["줄"]
if (h := line.get("호표") or {}).get("종류") == "대가" and "번" in h
}
@router.post(PATH)
async def import_bill(
project_id: UUID,
file: UploadFile | None = File(None),
file_key: str | None = Form(None),
user: str | None = Form(None),
session: dict[str, Any] = Depends(verify_session),
) -> dict[str, Any]:
from B09_Estimation.B09_Estimation_Router import payload # 서로 부름 — 부를 때만
root = await _project_root(project_id)
if file is not None:
return await remember(file)
if file_key:
return (file_key, *load(file_key))
raise HTTPException(400, "file 또는 file_key 가 필요함")
data = await file.read(MAX_BYTES + 1)
if len(data) > MAX_BYTES:
raise HTTPException(status_code=400, detail="파일이 너무 큼(50MB 넘음)")
parsed = await asyncio.to_thread(_parse, file.filename or "", data) # 읽힌 파일만 둠
file_key = await asyncio.to_thread(_keep, root, file.filename or "", data)
if not file_key:
raise HTTPException(status_code=400, detail="파일을 고름")
path = _saved(root, file_key)
if file is None:
parsed = await asyncio.to_thread(_parse, path.name, path.read_bytes())
rows = (await payload(project_id, root, _draft(user), True))["sheets"]["bill"]["rows"]
found = await asyncio.to_thread(_found, session, path.name, _numbers(parsed))
return {
"file_key": file_key,
"파일": path.name,
**importer.preview(parsed, rows),
"이음": len(found),
}
def _draft(user: str | None) -> dict[str, Any] | None:
try:
raw = json.loads(user) if user else None
except ValueError as error:
raise HTTPException(400, "user 가 JSON 이 아님") from error
return store.clean_user(raw) if isinstance(raw, dict) else None
class ApplyBody(BaseModel):
file_key: str = Field(min_length=8, max_length=8)
mode: str = "take"
attach: bool = True
drop: bool = False
user: dict[str, Any] | None = None
@router.post(PATH + "/rates")
@router.post(PATH + "/apply")
async def apply_bill(
project_id: UUID, body: ApplyBody, session: dict[str, Any] = Depends(verify_session)
) -> dict[str, Any]:
from B09_Estimation.B09_Estimation_Router import payload # 서로 부름 — 부를 때만
if body.mode not in importer.MODES:
raise HTTPException(status_code=400, detail="수량 갈래는 take · price 중 하나")
root = await _project_root(project_id)
path = _saved(root, body.file_key)
parsed = await asyncio.to_thread(_parse, path.name, path.read_bytes())
draft = _draft(body.user) or await asyncio.to_thread(store.read_user, root)
rows = (await payload(project_id, root, draft, True))["sheets"]["bill"]["rows"]
found = await asyncio.to_thread(_found, session, path.name, _numbers(parsed))
links = await asyncio.to_thread(_links, root, found, who_of(session))
made, report = importer.apply(
parsed, draft, rows, body.file_key, body.mode, body.attach, body.drop, links
)
made = store.clean_user(made)
out = await payload(project_id, root, made, True)
lines = out["sheets"]["bill"]["rows"]
return {
"user": made,
"알림": {k: v for k, v in report.items() if k != "줄"},
"어긋남": importer.differences(parsed, report, lines),
"엑셀합계": parsed["합계"],
"payload": out,
}
async def _file(
file: UploadFile | None, file_key: str | None, root: Path
) -> tuple[str, str, bytes]:
"""올린 파일 · 표지 → (표지, 이름, 내용) — 올린 파일은 내역 줄 길과 같은 자리에 둠."""
if file is not None:
data = await file.read(MAX_BYTES + 1)
if len(data) > MAX_BYTES:
raise HTTPException(status_code=400, detail="파일이 너무 큼(50MB 넘음)")
key = await asyncio.to_thread(_keep, root, file.filename or "", data)
return key, store.safe_name(file.filename or "") or "내역.xlsx", data
if not file_key:
raise HTTPException(status_code=400, detail="file 또는 file_key 가 필요함")
path = _saved(root, file_key)
return file_key, path.name, path.read_bytes()
@router.post(ROOT + "/rates")
async def import_rate_sheet(
project_id: UUID,
file: UploadFile | None = File(None),
@@ -101,7 +232,7 @@ async def import_rate_sheet(
user: str | None = Form(None),
) -> dict[str, Any]:
root = await _project_root(project_id)
key, name, data = await _file(file, file_key)
key, name, data = await _file(file, file_key, root)
draft = _draft(user)
mine = (draft if draft is not None else await asyncio.to_thread(store.read_user, root))[
"estimate"
@@ -117,7 +248,7 @@ async def import_rate_sheet(
"patch": {"estimate": estimate}} # fmt: skip
@router.post(PATH + "/prices")
@router.post(ROOT + "/prices")
async def import_price_sheets(
project_id: UUID,
file: UploadFile | None = File(None),
@@ -127,7 +258,7 @@ async def import_price_sheets(
from B09_Estimation import B09_Estimation_Router as main # 서로 부름 — 부를 때만
root = await _project_root(project_id)
key, name, data = await _file(file, file_key)
key, name, data = await _file(file, file_key, root)
draft = _draft(user)
mine = draft if draft is not None else await asyncio.to_thread(store.read_user, root)
try:
+19 -1
View File
@@ -155,9 +155,22 @@ def _clean_ref(ref: Any) -> dict[str, Any] | None:
return None
def _fixed_cells(row: dict[str, Any]) -> dict[str, Any]:
"""707-1 고정 단가 칸 `price` {노 · 재 · 경 수(빈 = 0)} · 불러온 수량 `src_qty` — 있는 것만."""
out: dict[str, Any] = {}
if isinstance(row.get("price"), dict):
out["price"] = {
p: _num(row["price"].get(p)) or 0.0 for p in ("labor", "material", "expense")
}
if _num(row.get("src_qty")) is not None:
out["src_qty"] = _num(row["src_qty"])
return out
def _clean_v2(raw: dict[str, Any]) -> dict[str, Any]:
"""184 덮개 v2 — 표 차례(겹친 키 · `fee` 버림) · 새 줄(`hand:`) = 모든 칸 ·
자동 줄 = 고친 칸만(값 · null = 일부러 빈칸 · 못 쓰는 값 = 칸 버림) · `gone` = 자동 줄 키."""
자동 줄 = 고친 칸만(값 · null = 일부러 빈칸 · 못 쓰는 값 = 칸 버림) · `gone` = 자동 줄 키 ·
고정 단가 칸 · 불러온 수량(707-1) = 항목 줄만 · 새 줄 비고 `note` 글."""
rows, seen = [], set()
for row in raw.get("rows") if isinstance(raw.get("rows"), list) else []:
key = row.get("key") if isinstance(row, dict) else None
@@ -177,6 +190,10 @@ def _clean_v2(raw: dict[str, Any]) -> dict[str, Any]:
out.update({k: _text(row.get(k)) for k in ("name", "spec", "unit")})
out["qty"] = None if group else _num(row.get("qty"))
out["ref"] = None if group else _clean_ref(row.get("ref"))
if not group:
out.update(_fixed_cells(row))
if _text(row.get("note")):
out["note"] = _text(row["note"])
else:
for k in ("name", "spec", "unit"):
if k in row:
@@ -185,6 +202,7 @@ def _clean_v2(raw: dict[str, Any]) -> dict[str, Any]:
out["qty"] = _num(row["qty"])
if "ref" in row and (row["ref"] is None or _clean_ref(row["ref"]) is not None):
out["ref"] = _clean_ref(row["ref"])
out.update(_fixed_cells(row))
rows.append(out)
given = raw.get("gone") if isinstance(raw.get("gone"), list) else []
gone = list(
+183 -12
View File
@@ -1,23 +1,194 @@
/* =============================================================================
* B09_Estimation_UI_Import.ts
* 경쟁사 결과물 불러오기 단추 · 확인 창(PLAN 707-2 요율 · 707-3 기초 단가표) — 저장 안 함.
* B09 설계내역서 [엑셀 내역 불러오기](PLAN 707-1) — 경쟁사 내역 엑셀(STmate · EST) 줄을 화면 캐시로.
*
* 누름 = 파일 고름 → 서버 읽기 · 미리 보기(`POST …/estimation/import/bill`) → 창 하나에서 한 번 고름
* 수량 갈래(가 넘겨받기 · 나 단가만) · 이름 같은 자동 줄에 붙임(두 번 셈 막음) · 남는 자동 줄 지움
* [불러오기] = `POST …/import/bill/apply` → 받은 원가 자료 · 새 캐시(내역 덮개 · 조달수수료율) → [저장]해야 남음
* 어긋난 줄(엑셀 금액 ≠ 우리 금액) = 같은 창 목록(줄 · 엑셀 · 우리 · 까닭) · 없으면 창 닫고 알림
* DRM 파일 = 서버 400 「읽을 수 없음」 알림
*
* 요율 · 기초 단가표 불러오기(PLAN 707-2 · 707-3) — 저장 안 함.
* 단추 = 보조 탭 줄 도구 자리(`…_UI_TabBar` setTools) — 총괄 · 원가계산서 탭 [엑셀에서 요율 불러오기] ·
* 노무비목록표 · 중기목록표 · 자재단가대비표 탭 [엑셀에서 단가표 불러오기]
* 길 = `B09_Estimation_Router_Import.py`(file | file_key · user = 화면 캐시) → 확인 창(짝 표 · 식 다름 ·
* 짝 없음 n) → [적용] = 답 `patch` 를 캐시에 얹음(바로 다시 셈) · [저장] 때 서버가 정본으로 셈
* 올린 파일 = 서버가 한 시간 기억(file_key) — 같은 파일로 요율 · 단가표를 다시 올리지 않음
* 길 = `B09_Estimation_Router_Import.py` import/rates · import/prices(file | file_key · user = 화면 캐시) →
* 확인 창(짝 표 · 식 다름 · 짝 없음 n) → [적용] = 답 `patch` 를 캐시에 얹음 · [저장] 때 서버 정본
* 올린 파일 = 내역 줄 · 요율 · 단가표 세 길이 같은 표지(file_key · `last`) — 같은 파일 다시 안 올림
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { createCheckbox } from "@ui/ui_template_checkbox";
import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table";
import { pickFiles } from "@ui/ui_template_drop";
import { createButton, el, showToast } from "@ui/ui_template_elements";
import {
createButton,
el,
hideLoadingOverlay,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import { createText } from "@ui/ui_template_text";
import { currentProjectId } from "../A00_Common/b_project_switch";
import type { Row } from "./B09_Estimation_Calc_Base";
import { money, qty, text, type TabContext, type TabId } from "./B09_Estimation_UI_Tabs";
const won = (v: unknown): string => Number(v || 0).toLocaleString("ko-KR");
async function post(projectId: string, path: string, body: BodyInit, json = false): Promise<Row> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/estimation/import/${path}`, {
method: "POST",
credentials: "include",
...(json ? { headers: { "Content-Type": "application/json" } } : {}),
body,
});
const got = (await response.json().catch(() => null)) as Row | null;
if (!response.ok || !got) {
const detail = got?.detail;
throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`);
}
return got;
}
/** 받은 캐시 = 지금 캐시 + 내역 덮개 · 조달수수료율(서버가 빈 때만 채움) */
function merged(user: Row, made: Row): Row {
const pct = made.estimate?.procurement_fee_pct;
const estimate = { ...(user.estimate || {}), ...(pct != null && { procurement_fee_pct: pct }) };
return { ...user, bill: made.bill, estimate };
}
function differences(rows: Row[]): HTMLElement {
return createDataTable({
columns: [
{ key: "행", label: "엑셀 행", kind: "number", width: 48 },
{ key: "이름", label: "명칭", width: 150 },
{ key: "엑셀", label: "엑셀 금액", kind: "number", width: 96 },
{ key: "우리", label: "우리 금액", kind: "number", width: 96 },
{ key: "까닭", label: "까닭", width: 130 },
],
rows,
}).root;
}
/** [엑셀 내역 불러오기] 단추 — user = 지금 화면 캐시 · done = 받은 원가 자료 + 새 캐시 */
export function createBillImport(
projectId: string,
user: () => Row,
done: (payload: Row, user: Row) => void,
): HTMLButtonElement {
const open = (seen: Row, mode: { value: string }): void => {
const total = seen.합계 || {};
const lines = [
`${seen.파일} · ${seen.꼴} 「${seen.시트}」 · 공종 ${seen.묶음} · 항목 ${seen.항목}`,
`엑셀 직접비 합 ${won(total.직접)} 원 · 제외(관급 등) ${won(total.제외)} 원` +
(seen.조달수수료율 != null ? ` · 조달수수료율 ${seen.조달수수료율} %` : ""),
seen.이음
? `같은 파일로 들여온 일위대가 ${seen.이음} 호표 — 그 줄은 일위대가에 이음`
: "같은 파일로 들여온 일위대가 없음 — 모든 줄 = 엑셀 단가(고정)",
];
const take = createCheckbox("넘겨받기 — 불러온 수량을 씀(수량 산출 값은 곁에)", {
radio: "b09-import-mode",
value: "take",
checked: mode.value === "take",
});
const price = createCheckbox(
"단가만 — 수량 산출 수량을 씀(불러온 수량은 비교 · 차이 큰 줄 붉게)",
{
radio: "b09-import-mode",
value: "price",
checked: mode.value === "price",
},
);
const attach = createCheckbox(`이름이 같은 자동 줄 ${seen.같은이름} 개에 붙임(두 번 셈 막음)`, {
checked: seen.같은이름 > 0,
disabled: !seen.같은이름,
});
const drop = createCheckbox(`그 밖 자동 줄 ${seen.남는자동} 개 지움`, {
disabled: !seen.남는자동,
});
const run = createButton({ label: "불러오기", variant: "filled" });
run.dataset.b09ImportRun = "";
openModal({
title: "엑셀 내역 불러오기",
closeLabel: "닫기",
width: "40rem",
mount: (body, close, actions) => {
body.append(
...lines.map((t) => createText(t, "muted", 14)),
el("div", { className: "b09-import__choices", children: [take.root, price.root] }),
el("div", { className: "b09-import__choices", children: [attach.root, drop.root] }),
);
actions.append(run);
run.addEventListener("click", async () => {
mode.value = price.input.checked ? "price" : "take";
const draft = user();
const ask = {
file_key: seen.file_key,
mode: mode.value,
attach: attach.input.checked,
drop: drop.input.checked,
user: draft,
};
showLoadingOverlay();
try {
const got = await post(projectId, "bill/apply", JSON.stringify(ask), true);
// 창 닫고 다시 그림 — 표 높이(fillViewport)가 열린 창 가림막을 아래 자리로 셈
close();
done(got.payload, merged(draft, got.user));
const n = got.알림 || {};
const head = `새 줄 ${n.실음} · 붙임 ${n.붙임} · 지움 ${n.지움} · 이음 ${n.이음} — [저장]해야 남음`;
const off: Row[] = got.어긋남 || [];
if (!off.length) return void showToast(`${head} · 엑셀 금액과 모두 같음`, "success");
// 어긋난 줄 = 표 높이를 잰 뒤(다음 화면) 보기 창
const result = (): void =>
void openModal({
title: "엑셀 내역 불러오기 — 결과",
closeLabel: "닫기",
width: "40rem",
mount: (box) =>
box.append(
createText(head, "success", 14),
createText(`엑셀 금액과 다른 줄 ${off.length}`, "warning", 14),
differences(off),
),
});
requestAnimationFrame(() => requestAnimationFrame(result));
} catch (error) {
showToast(`불러오지 못함 — ${error instanceof Error ? error.message : error}`, "error");
} finally {
hideLoadingOverlay();
}
});
},
});
};
const mode = { value: "take" };
const button = createButton({
label: "엑셀 내역 불러오기",
variant: "ghost",
onClick: () =>
pickFiles({ accept: ".xls,.xlsx" }, ([file]) => {
const form = new FormData();
form.append("file", file);
form.append("user", JSON.stringify(user()));
showLoadingOverlay();
post(projectId, "bill", form)
.then((seen) => {
last = { key: String(seen.file_key), name: String(seen.파일) }; // 요율 · 단가표가 다시 씀
open(seen, mode);
})
.catch((error: unknown) =>
showToast(`읽지 못함 — ${error instanceof Error ? error.message : error}`, "error"),
)
.finally(hideLoadingOverlay);
}),
});
button.dataset.b09Import = "";
return button;
}
/* ── 707-2 · 3 요율 · 기초 단가표 ── */
type Kind = "rates" | "prices";
const TABS: Partial<Record<TabId, Kind>> = {
total: "rates",
@@ -35,7 +206,7 @@ const PART: Record<string, string> = { labor: "노임", material: "자재", mach
/** 마지막에 올린 파일(서버 기억 열쇠) — 같은 파일 다시 씀 */
let last: { key: string; name: string } | null = null;
async function post(kind: Kind, ctx: TabContext, file: File | null): Promise<Row> {
async function postSheet(kind: Kind, ctx: TabContext, file: File | null): Promise<Row> {
const form = new FormData();
if (file) form.append("file", file);
else if (last) form.append("file_key", last.key);
@@ -51,7 +222,7 @@ async function post(kind: Kind, ctx: TabContext, file: File | null): Promise<Row
return body!;
}
const won = (v: unknown): string =>
const wonOrBlank = (v: unknown): string =>
typeof v === "number" ? Math.round(v).toLocaleString("en-US") : "";
function ratesView(got: Row): { table: DataRow[]; lines: string[]; columns: DataColumn[] } {
@@ -76,7 +247,7 @@ function ratesView(got: Row): { table: DataRow[]; lines: string[]; columns: Data
}));
const grand = got.check?.grand;
const lines = [
`율 ${Object.keys(got.rates || {}).length} 줄 실음 · 총공사비 우리 ${won(grand)} / 엑셀 ${won(
`율 ${Object.keys(got.rates || {}).length} 줄 실음 · 총공사비 우리 ${wonOrBlank(grand)} / 엑셀 ${wonOrBlank(
got.check?.excel_grand,
)}`,
...(got.notes as string[]),
@@ -94,8 +265,8 @@ function ratesView(got: Row): { table: DataRow[]; lines: string[]; columns: Data
function pricesView(got: Row): { table: DataRow[]; lines: string[]; columns: DataColumn[] } {
const show = (v: unknown): string =>
v && typeof v === "object"
? ["labor", "material", "expense"].map((p) => won((v as Row)[p])).join(" / ")
: won(v);
? ["labor", "material", "expense"].map((p) => wonOrBlank((v as Row)[p])).join(" / ")
: wonOrBlank(v);
const table = (got.linked as Row[]).map((x, i) => ({
key: String(i),
part: PART[x.kind] || x.kind,
@@ -162,7 +333,7 @@ const fail = (error: unknown): void => showToast(`불러오지 못함 — ${Stri
function pick(kind: Kind, ctx: TabContext): void {
pickFiles(
{ accept: ".xlsx,.xls" },
([file]) => void post(kind, ctx, file).then((got) => openResult(kind, ctx, got), fail),
([file]) => void postSheet(kind, ctx, file).then((got) => openResult(kind, ctx, got), fail),
);
}
@@ -176,7 +347,7 @@ export function importTools(tab: TabId, ctx: TabContext): HTMLElement[] {
size: "sm",
onClick: () =>
last
? void post(kind, ctx, null).then(
? void postSheet(kind, ctx, null).then(
(got) => openResult(kind, ctx, got),
() => pick(kind, ctx),
)
+13
View File
@@ -301,3 +301,16 @@ html.is-bm-route .b09-estimation > .b09-estimation__tabs:first-child {
.ui-side-panels-row > .ui-side-panels.b09-store__side {
flex: none;
}
/* 707-1 엑셀 내역 불러오기 창 — 고르기 줄 묶음 · 단가만 갈래 수량 차이 큰 칸(불러온 수량과 10 % 넘게 다름) */
.b09-import__choices {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
margin-top: var(--spacing-8);
}
.b09-estimation .ui-data-table__table tbody td.is-qty-gap {
color: var(--color-danger);
font-weight: var(--font-weight-bold);
}
+14 -2
View File
@@ -42,7 +42,7 @@ import { createCheckPanel } from "./B09_Estimation_UI_Check";
import { createLiveCalc, markWaiting, waitingKeys, withoutWaiting } from "./B09_Estimation_UI_Live";
import { tableOptionsOf, tabTableOf } from "./B09_Estimation_UI_Builders";
import { createDock, linkMode } from "./B09_Estimation_UI_Dock";
import { importTools } from "./B09_Estimation_UI_Import";
import { createBillImport, importTools } from "./B09_Estimation_UI_Import";
import { closePopups, openRowPopup } from "./B09_Estimation_UI_Popup";
import {
DRAFT,
@@ -287,7 +287,7 @@ export function renderB09Estimation(root: HTMLElement): void {
);
if (range) unpickOnRelease(table.root);
const level = bill ? levelBar(table.root, bill, (i) => table.edit(i)) : null;
if (level) bars.setTools(level.tools);
if (level) bars.setTools(importer ? [...level.tools, importer] : level.tools);
// 203 안내 글 영역 하나 — 설계내역서 레벨 안내 + 탭 안내 이어 붙임
if (level && hint) hint.textContent = `${level.guide.textContent} · ${hint.textContent}`;
const foot = hint ?? level?.guide;
@@ -303,6 +303,18 @@ export function renderB09Estimation(root: HTMLElement): void {
if (at >= 0) requestAnimationFrame(() => table.focusRow(at, false)); // 스크롤만(표시 = 빨강)
};
// 707-1 [엑셀 내역 불러오기] = 설계내역서 도구 줄 끝 · 받은 캐시 → 원가 자료와 함께 다시 셈(저장 안 함)
const importer = projectId
? createBillImport(
projectId,
() => user,
(next, made) => {
writeState("b09-estimation", made, projectId);
apply(next, false);
},
)
: null;
const apply = (next: Row, fromServer: boolean): void => {
payload = next;
const draft = projectId && !fromServer ? readState<Row>("b09-estimation", projectId) : null;
+13 -1
View File
@@ -12,6 +12,7 @@
* 접히지 않는 나무(235-1) = 폴더(묶음 · 자식 있는 줄) = 이름만 · 수량 · 단가 빈칸 · 금액 = 자손 합 수식(읽기 전용) ·
* 파일(항목) = 이름 · 규격 · 단위 · 수량 + 연결 · 안내선 · 아이콘 = 공용 표 level.tree · 고칠 칸 = 더블클릭 입력
* 오른쪽 재료 창고(703-1) = `…_UI_Store`(두 번 누름 · 끌어 놓기 = 단가연결)
* 불러온 수량(707-1 src_qty) = 수량 칸 풍선 · 지금 수량과 10 % 넘게 다르면 붉은 칸(단가만 갈래)
* ========================================================================== */
import type { DataRow } from "@ui/ui_template_data_table";
@@ -31,6 +32,14 @@ import { attachBillStore } from "./B09_Estimation_UI_Store";
const TEXT_KEYS = ["name", "spec", "unit"];
/** 폴더 줄 수식 칸 — 자손 합(셈) */
const SUM_KEYS = ["sum", "labor", "material", "expense"];
/** 707-1 불러온 수량과 지금 수량 차이 — 이 비율 넘으면 붉은 칸 */
const QTY_GAP = 0.1;
const qtyGap = (r: Row | undefined): boolean =>
!!r &&
r.kind === "item" &&
typeof r.src_qty === "number" &&
typeof r.qty === "number" &&
Math.abs(r.qty - r.src_qty) > QTY_GAP * Math.abs(r.src_qty);
export const buildBill: TabBuild = (ctx): BillTable => {
const { sheets, user, setUser } = ctx;
@@ -99,7 +108,8 @@ export const buildBill: TabBuild = (ctx): BillTable => {
key === "connect" && r && linkable(raw[i])
? `ui-data-table__lamp ui-data-table__lamp--only b09-bill__lamp${linkText(raw[i]) === "단가연결" ? "" : " is-linked"}`
: "";
return [lamp, edited(r, key) ? "is-edited" : ""].filter(Boolean).join(" ") || null;
const gap = key === "qty" && qtyGap(r) ? "is-qty-gap" : "";
return [lamp, edited(r, key) ? "is-edited" : "", gap].filter(Boolean).join(" ") || null;
},
tip: (i, key) => {
const r = rows[i];
@@ -109,6 +119,8 @@ export const buildBill: TabBuild = (ctx): BillTable => {
return linkText(raw[i]) + back;
}
if (r && r.kind !== "item" && SUM_KEYS.includes(key)) return "수식 = 아래 항목 합(읽기 전용)";
if (key === "qty" && typeof r?.src_qty === "number" && !edited(r, key))
return `불러온 수량 ${r.src_qty}`;
if (!r || !edited(r, key)) return null;
const auto = key === "connect" ? (r.auto_ref as Row | null)?.key : r[`auto_${key}`];
return `자동 값 ${auto ?? "빈칸"} — 우클릭 [자동값으로 되돌리기]`;
+2 -2
View File
@@ -76,8 +76,8 @@ def _run_ts(inputs, user):
)
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", source + body],
capture_output=True, text=True, encoding="utf-8", timeout=60,
"--input-type=module-typescript"], # 글은 stdin — 명령 줄 32 K 넘김(Windows)
input=source + body, capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
+2 -2
View File
@@ -75,8 +75,8 @@ def _rows(sheets):
def _node(source: str) -> str:
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", source],
capture_output=True, text=True, encoding="utf-8", timeout=60,
"--input-type=module-typescript"], # 글은 stdin — 명령 줄 32 K 넘김(Windows)
input=source, capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
return result.stdout
@@ -200,7 +200,8 @@ def test_셸_배선():
assert (
"bill ? (i) => pickedClass(bill.rows[i]) ?? pickClass(jumpRange(), i)" in src
) # 239-1 · 242-1 이동 줄 빨강 곁(고름과 한때 하나)
assert "if (level) bars.setTools(level.tools);" in src # 229-1 단추 = 보조 탭 줄
# 229-1 단추 = 보조 탭 줄 · 707-1 [엑셀 내역 불러오기] = 그 끝
assert "if (level) bars.setTools(importer ? [...level.tools, importer] : level.tools);" in src
assert (
"const body = [table.root, ...(fold ? [fold] : []), ...(foot ? [foot] : [])];" in src
and "panel.replaceChildren(...banner, ...(row ? [row] : body));" in src
@@ -112,19 +112,18 @@ def test_B09_표_만드는_길_한_곳_더블클릭_기본():
calls = [
p.name for p in _sources("B09_Estimation") if "createDataTable({" in p.read_text("utf-8")
]
# 303-2 팝업 창 = 보기 전용(고칠 칸 없음) · 옵션 = 같은 표 짜기(Builders) · 702-1 펼침 표 = 제 표(두 번 눌러 고침)
# 707-2 · 3 불러오기 확인 창 = 보기 전용(고칠 칸 없음)
# 303-2 팝업 창 · 707-1 불러오기 어긋난 줄 · 707-2 · 3 확인 창 = 보기 전용(고칠 칸 없음) ·
# 옵션 = 같은 표 짜기(Builders) · 702-1 펼침 표 = 제 표(두 번 눌러 고침)
assert calls == [
"B09_Estimation_UI_Import.ts",
"B09_Estimation_UI_Popup.ts",
"B09_Estimation_UI_Shell.ts",
"B09_Estimation_UI_Tabs_Bill_Fold.ts",
]
fold = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Bill_Fold.ts").read_text("utf-8")
assert "editable" not in (ROOT / "B09_Estimation" / calls[0]).read_text("utf-8")
fold = (ROOT / "B09_Estimation" / calls[3]).read_text("utf-8")
assert "editOnDouble: true," in fold
view = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Import.ts").read_text("utf-8")
assert "editable" not in view
shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
shell = (ROOT / "B09_Estimation" / calls[2]).read_text("utf-8")
assert "...tableOptionsOf(spec, tab)," in shell # 303-2 옵션 짜기 = Builders 한 곳
builders = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Builders.ts").read_text("utf-8")
assert "editOnDouble: spec.editOnDouble ?? true," in builders
+244
View File
@@ -0,0 +1,244 @@
"""707-1 경쟁사 내역 줄 불러오기 — 읽기(`Bill_Excel`) · 짜기(`Bill_Import`) · 고정 단가 칸 셈 거울.
- 실무 xlsx(STmate 기번3 · 영월 · 봉화 · EST 거창) = 빠짐 0 · 불러온 뒤 직접비 합 = 엑셀 합 ·
어긋난 줄 0 · 조달수수료 = 율(줄로 안 실음) · DRM = 「읽을 수 없음」
- 수량 갈래 (가) take = 붙인 자동 줄 수량 엑셀 · auto_qty = B08 / (나) price = B08 · src_qty = 엑셀
- 두 번 셈 막음 = 이름 같은 자동 줄에 붙임(한 번만 셈) · drop = 남은 자동 줄 지움 ·
같은 파일 다시 = 갈아 끼움 · 조달수수료율 = 빈 때만
- 고정 단가 칸 = 연결이 단가를 못 낼 때만 · 다듬기 · TS 짝 같은 값
"""
import json
import re
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B09_Estimation import B09_Estimation_Bill_Import as imp # noqa: E402
from B09_Estimation import B09_Estimation_Store as store # noqa: E402
from B09_Estimation.B09_Estimation_Bill_Excel import BillExcelError, read_bill # noqa: E402
from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation # noqa: E402
K = ROOT / "resources" / "knowledge" / "original" / "실무문서"
FILES = {
"기번3": K / "2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/01 내역자료/2024년 간선임도사업(기번3).xlsx", # noqa: E501
"영월": K / "05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.xlsx",
"봉화": K / "04.산불 봉화/01. 내역서/2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km.xlsx",
"거창": K / "01.산불거창(거창북상병곡)/01.내역서/00.25년_산불_거창(거창,북상,병곡,산8외)내역+수량.xlsx", # noqa: E501
} # fmt: skip
#: (꼴, 항목 수, 엑셀 직접비 합)
WANT = {
"기번3": ("STmate", 136, 710359136),
"영월": ("STmate", 108, 767148700),
"봉화": ("STmate", 77, 781283415),
"거창": ("EST", 60, 384916227),
}
EMPTY = {"materials": [], "bill": {"items": []}, "hopyo": {}}
ITEMS = [
{"key": "earth:4", "section": "1.2", "name": "절토(토사)", "unit": "㎥", "qty": 10,
"ref": {"tab": "sg_list", "key": "D01341"}},
{"key": "earth:9", "section": "1.6", "name": "노면 정리X", "unit": "㎡", "qty": 3,
"ref": {"tab": "sg_list", "key": "D01341"}},
{"key": "earth:6", "section": "1.2", "name": "암발파", "unit": "㎥", "qty": None,
"ref": {"tab": "sg_list", "key": "D01341"}},
{"key": "haul:3", "section": "3", "name": "모래", "unit": "㎥", "qty": 2,
"ref": {"tab": "sg_list", "key": "D01341"}},
] # fmt: skip
INPUTS = {"materials": [], "bill": {"items": ITEMS}, "hopyo": {"D01341": {
"labor": 1, "material": 0, "expense": 0}}} # fmt: skip
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
@pytest.fixture(scope="module")
def parsed():
return {k: read_bill(p.name, p.read_bytes()) for k, p in FILES.items()}
def _rows(inputs, user):
return compute_estimation(inputs, user, None, {})["bill"]
def _load(got, inputs=EMPTY, user=None, **kw):
user = user or {}
made, report = imp.apply(got, user, _rows(inputs, user)["rows"], "abcd1234", **kw)
made = store.clean_user(made)
return made, report, _rows(inputs, made)
@pytest.mark.parametrize("name", list(FILES))
def test_실무_내역_빠짐_0_합계_엑셀과_같음(parsed, name):
got = parsed[name]
kind, items, total = WANT[name]
assert (got["꼴"], sum(1 for x in got["줄"] if not x["묶음"])) == (kind, items)
assert got["합계"]["직접"] == total and got["조달수수료율"] == 0.54
made, report, bill = _load(got)
assert report["실음"] == len(got["줄"]) and report["붙임"] == 0
assert bill["totals"]["direct"]["sum"] == total and bill["totals"]["direct"]["blank"] == 0
assert imp.differences(got, report, bill["rows"]) == []
assert made["estimate"]["procurement_fee_pct"] == 0.54
assert max(r["level"] for r in bill["rows"]) <= 5
keys = {r["key"] for r in bill["rows"]}
assert all(k in keys for k in report["줄"].values()) # 실은 줄 모두 보임
# 관급 = 제외(관급자재대 아래 · 직접비 밖)
assert bill["totals"]["owner"]["net_material"] > 0
def test_EST_비고_참조_STmate_코드(parsed):
est = [x["호표"] for x in parsed["거창"]["줄"] if not x["묶음"]]
assert {"종류": "산근", "번": 46} in est and {"종류": "대가", "번": 8} in est
st = next(x for x in parsed["기번3"]["줄"] if x["이름"] == "규준틀설치")
assert st["호표"] == {"종류": "대가", "번": 5, "코드": "B00194"}
def test_DRM_읽을_수_없음():
with pytest.raises(BillExcelError, match="읽을 수 없음"):
read_bill("a.xlsx", b"\x9b DRMONE" + b"\x00" * 300)
with pytest.raises(BillExcelError, match="엑셀"):
read_bill("a.xlsx", b"PK\x03\x04 broken")
def test_가_넘겨받기_붙인_자동_줄_수량_엑셀(parsed):
made, report, bill = _load(parsed["기번3"], INPUTS, mode="take")
rows = {r["key"]: r for r in bill["rows"]}
assert report["붙임"] == 2 # 절토(토사) 첫 줄만(1공구) · 암발파 · 운반 「모래」 = 안 붙음
cut = rows["earth:4"]
assert (cut["qty"], cut["auto_qty"], cut["src_qty"]) == (5675, 10, 5675)
assert cut["fixed"] and cut["sum"] == 11003825 # 엑셀 단가 · 연결 덮개 null
assert cut["parent"].startswith("hand:xabcd1234-") # 불러온 자리로 옮김
assert sum(1 for r in bill["rows"] if r["name"] == "절토(토사)") == 3 # 두 번 셈 없음
# 붙인 줄 금액 = 엑셀 · 안 붙은 자동 줄(노면 정리X 3 · 운반 모래 2) 그대로 = 직접비 = 엑셀 + 5
assert bill["totals"]["direct"]["sum"] == 710359136 + 5
assert "haul:3" in rows and rows["haul:3"]["parent"] == "sec:3"
tops = [r["key"] for r in bill["rows"] if r["parent"] is None]
assert tops.index("hand:xabcd1234-00006") < tops.index("sec:G") # 1공구 = 관급자재대 앞
def test_나_단가만_B08_이김_비교_칸(parsed):
made, report, bill = _load(parsed["기번3"], INPUTS, mode="price")
cut = {r["key"]: r for r in bill["rows"]}["earth:4"]
assert (cut["qty"], cut["src_qty"], "auto_qty" in cut) == (10, 5675, False)
assert cut["sum"] == 1939 * 10 # 엑셀 단가 × B08 수량
blast = {r["key"]: r for r in bill["rows"]}["earth:6"]
assert (blast["qty"], blast["sum"]) == (3889, 43700693) # B08 수량 없음 = 엑셀 수량
diff = imp.differences(parsed["기번3"], report, bill["rows"])
assert [d["까닭"] for d in diff] == ["수량 = B08(단가만)"]
def test_붙이지_않음_지우기_다시_부르기(parsed):
_, report, bill = _load(parsed["기번3"], INPUTS, attach=False, drop=True)
assert (report["붙임"], report["지움"]) == (0, 4) # 운반 줄도 지움
assert bill["totals"]["direct"]["sum"] == 710359136
made, _, _ = _load(parsed["기번3"], INPUTS)
made["bill"]["rows"].append({"key": "hand:mine", "parent": "hand:xabcd1234-00007",
"name": "내 줄", "qty": 1, "ref": None}) # fmt: skip
again, report, bill = _load(parsed["기번3"], INPUTS, user=made)
keys = [e["key"] for e in again["bill"]["rows"]]
assert len(keys) == len(set(keys)) and report["붙임"] == 2
assert bill["totals"]["direct"]["sum"] == 710359136 + 5 # 갈아 끼움 · 두 번 안 셈
mine = next(e for e in again["bill"]["rows"] if e["key"] == "hand:mine")
assert "parent" not in mine # 내 줄 = 남음(맨 위)
def test_조달수수료율_빈_때만(parsed):
made, _, _ = _load(parsed["거창"], user={"estimate": {"procurement_fee_pct": 1.0}})
assert made["estimate"]["procurement_fee_pct"] == 1.0
def test_고정_단가_칸_다듬기_연결이_이김():
raw = {"bill": {"v": 2, "rows": [
{"key": "hand:a", "name": "a", "qty": 2, "ref": {"tab": "sg", "key": "SG:1"},
"price": {"labor": 5, "material": "x"}, "src_qty": 3, "note": "대가 5호표"},
{"key": "earth:4", "price": {"expense": 7}, "src_qty": "y"},
]}} # fmt: skip
user = store.clean_user(raw)
hand, auto = user["bill"]["rows"]
assert hand["price"] == {"labor": 5, "material": 0, "expense": 0} and hand["src_qty"] == 3
assert hand["note"] == "대가 5호표" and auto == {"key": "earth:4", "price": {
"labor": 0, "material": 0, "expense": 7}} # fmt: skip
books = {"sg_list": [{"no": 1, "key": "SG:1", "labor": 100, "material": 0, "expense": 0,
"sum": 100}]} # fmt: skip
rows = {r["key"]: r for r in compute_estimation(INPUTS, user, None, books)["bill"]["rows"]}
assert (rows["hand:a"]["sum"], rows["hand:a"].get("fixed")) == (200, None) # 연결 이김
assert rows["earth:4"]["sum"] == 10 and "fixed" not in rows["earth:4"] # 자동 연결 이김
rows = {r["key"]: r for r in compute_estimation(INPUTS, user, None, {})["bill"]["rows"]}
assert (rows["hand:a"]["sum"], rows["hand:a"]["fixed"]) == (10, True)
assert rows["hand:a"]["note"] == "대가 5호표"
def test_거울_TS_가_같은_값(parsed):
calc = "".join(
IMPORTS.sub("", (ROOT / "B09_Estimation" / f"B09_Estimation_{n}.ts").read_text("utf-8"))
for n in ("Calc_Base", "Calc_Bill")
)
users = [
_load(parsed["거창"], INPUTS, mode="take")[0],
_load(parsed["영월"], INPUTS, mode="price")[0],
{"bill": {"v": 2, "rows": [{"key": "earth:4", "ref": None, "price": {"labor": 3}},
{"key": "hand:a", "qty": 2, "price": {"material": 4}}]}},
] # fmt: skip
body = (
f"\nconst i = {json.dumps(INPUTS, ensure_ascii=False)};"
f"\nconst us = {json.dumps(users, ensure_ascii=False)};"
"\nconsole.log(JSON.stringify(us.map((u) => computeEstimation(i, u, null, {}))));"
)
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript"],
input=calc + body, capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == [compute_estimation(INPUTS, u, None, {}) for u in users]
def test_이음_같은_책_일위대가_이음_단가_어긋남_알림(parsed):
got = parsed["기번3"]
link = {"tab": "dg", "key": "DG:5", "lib": "precombo@personal@1-3@SC-000001"}
made, report, _ = _load(got, links={5: link})
lines = [e for e in made["bill"]["rows"] if e.get("ref")]
assert report["이음"] == 3 and len(lines) == 3 # 대가 5호표(종단규준틀) 공구마다
assert lines[0]["ref"] == link and lines[0]["price"]["labor"] == 21936 # 고정 단가 곁에 남김
same = {"daega": [{"no": 5, "key": "DG:5", "labor": 21936, "material": 3885, "expense": 0,
"sum": 25821}]} # fmt: skip
rows = compute_estimation(EMPTY, made, None, same)["bill"]["rows"]
assert imp.differences(got, report, rows) == [] # 실무자료 사본 = 엑셀 단가 그대로
other = {"daega": [{**same["daega"][0], "labor": 30000, "sum": 33885}]}
rows = compute_estimation(EMPTY, made, None, other)["bill"]["rows"]
diff = imp.differences(got, report, rows)
assert len(diff) == 3 and {d["까닭"] for d in diff} == {"이음 단가 = 우리 셈"}
# 사본이 없어짐(연결이 단가를 못 냄) = 고정 단가
rows = compute_estimation(EMPTY, made, None, {})["bill"]["rows"]
assert imp.differences(got, report, rows) == []
def test_이음_찾기_같은_파일_이름_본뜬만(tmp_path, monkeypatch):
from B09_Estimation import B09_Estimation_Router_Import as route
folder = tmp_path / "precombo"
folder.mkdir()
docs = {
"SC-000001": {"층": "실무자료", "키": "HP-000001 제5호표", "파일": "a.xlsx"},
"SC-000002": {"층": "실무자료", "키": "HP-000001 제5호표 2째 줄", "파일": "a.xlsx"},
"SC-000003": {"층": "실무자료", "키": "HP-000002 제8호표", "파일": "b.xlsx"},
"SC-000004": {"층": "실무자료", "키": "HP-000001 제9호표", "파일": "a.xlsx"},
}
for key, origin in docs.items():
(folder / f"{key}.json").write_text(json.dumps({"본뜬": origin}), encoding="utf-8")
class Got:
layer, owner = "personal", "1/3"
def grant(session, layer):
if layer != "personal":
raise route.HTTPException(status_code=409)
return Got
monkeypatch.setattr(route.access, "grant", grant)
monkeypatch.setattr(route.refs, "folder_of", lambda layer, owner: tmp_path)
found = route._found({}, "a.xlsx", {5, 8})
assert found == {5: ("personal", "1/3", "SC-000001")} # 감싼 줄 · 다른 파일 · 안 쓰는 번 뺌