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:
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ?? "빈칸"} — 우클릭 [자동값으로 되돌리기]`;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")} # 감싼 줄 · 다른 파일 · 안 쓰는 번 뺌
|
||||
Reference in New Issue
Block a user