diff --git a/B08_Quantity/B08_Quantity_UI_Drawing.ts b/B08_Quantity/B08_Quantity_UI_Drawing.ts index 725ff48fb..b05c33f84 100644 --- a/B08_Quantity/B08_Quantity_UI_Drawing.ts +++ b/B08_Quantity/B08_Quantity_UI_Drawing.ts @@ -143,6 +143,7 @@ function comboSection( onEdit: (kind) => openEdit(copy, name, kind, load), warnOnFail: () => false, // 못 읽으면 카드에 「없음」 labor: () => openEdit(copy, name, "inputs", load), // 일위대가 카드(161-1) [수정] = 넣을 값만 + picker: true, }); handles.push(cards); section.append(cards.root); diff --git a/B09_Estimation/B09_Estimation_Calc_Base.py b/B09_Estimation/B09_Estimation_Calc_Base.py index 127960c1b..14748c60d 100644 --- a/B09_Estimation/B09_Estimation_Calc_Base.py +++ b/B09_Estimation/B09_Estimation_Calc_Base.py @@ -21,6 +21,10 @@ 중기사용료 = 손료(경비 · 기준가 천원 × 손료계수 ÷ 10,000) + 운전사(노무 · 인 × 시간 노임) + 연료(재료 · ℓ/hr × 적용 단가) + 잡품(재료 · 연료 금액 × % ÷ 100) 줄 = ROUNDDOWN(단가 × 수량, 1) · 비목 합 = ROUNDDOWN(Σ, 0) → 중기목록표 +노임 · 기계 단가 덮개(707-3) = 사용자 값 `labor_cover` {노무 키: 일당} · `machine_cover` + {기계 키: {labor, material, expense}}(셋 다 수일 때만) — 굳힌 값 대신 · + 줄 `covered` · `book`(굳힌 값) · + 덮은 기계 = 중기사용료 줄은 그대로 · 비목 합 · 합 = 덮개 · 그 기계 「연결 없음」 뺌 빈칸 규칙 = 값을 낸 원천이 없으면 None(0 을 채우지 않음) · 그 칸을 쓰는 합도 None · 「연결 없음」 목록(`gaps`)에 탭 · 줄 키 · 까닭을 남김(목차 탭이 이동 링크로 씀). """ @@ -39,6 +43,7 @@ SOURCES = ("관급", "물가정보", "물가자료", "유통물가", "거래가 #: 자재 줄 사용자 표시 SUPPLY = ("관급", "별도") +_PARTS = ("labor", "material", "expense") def _scaled(value: float, places: int, fn) -> float: @@ -95,10 +100,15 @@ def driver_hour(day: float | None, factor: dict[str, Any] | None) -> float | Non return rounddown(day * factor["num"] / factor["den"], 0) -def labor_sheet(labor: list[dict[str, Any]], gaps: list) -> list[dict[str, Any]]: +def labor_sheet( + labor: list[dict[str, Any]], gaps: list, cover: dict[str, Any] | None = None +) -> list[dict[str, Any]]: rows = [] for no, row in enumerate(labor, 1): price = num(row.get("price")) + mine = num((cover or {}).get(row["key"])) + extra = {} if mine is None else {"covered": True, "book": price} + price = price if mine is None else mine if price is None: gaps.append(gap("labor", row["key"], row["name"], "노임 단가 없음")) rows.append( @@ -107,11 +117,22 @@ def labor_sheet(labor: list[dict[str, Any]], gaps: list) -> list[dict[str, Any]] "code": row.get("code"), "unit": row.get("unit") or "인", "price": price, + **extra, } ) return rows +def machine_cover(cover: Any) -> dict[str, dict[str, float]]: + """기계 덮개 — 노 · 재 · 경 셋 다 수인 줄만.""" + out = {} + for key, cell in (cover if isinstance(cover, dict) else {}).items(): + got = {p: num((cell or {}).get(p)) if isinstance(cell, dict) else None for p in _PARTS} + if None not in got.values(): + out[key] = got + return out + + def _head(row: dict[str, Any], no: int) -> dict[str, Any]: return { "no": no, @@ -306,7 +327,7 @@ def basis_sheet( def compute_base(inputs: dict[str, Any], user: dict[str, Any]) -> dict[str, Any]: """144 탭 일곱 + 「연결 없음」 목록.""" gaps: list[dict[str, str]] = [] - labor_rows = labor_sheet(inputs.get("labor") or [], gaps) + labor_rows = labor_sheet(inputs.get("labor") or [], gaps, user.get("labor_cover")) expense = expense_sheet(inputs.get("machines") or [], gaps) compare = compare_sheet(inputs.get("materials") or [], user.get("materials") or {}, gaps) basis = basis_sheet(inputs, labor_rows) @@ -316,6 +337,13 @@ def compute_base(inputs: dict[str, Any], user: dict[str, Any]) -> dict[str, Any] kind: by_key[key] for kind, key in (inputs.get("fuel_keys") or {}).items() if key in by_key } blocks = [machine_block(m, drivers, fuels, gaps) for m in inputs.get("machines") or []] + covers = machine_cover(user.get("machine_cover")) + for b in blocks: + if b["key"] in covers: # 707-3 덮개 = 비목 합 · 합(줄은 그대로) + b["book"] = {p: b[p] for p in (*_PARTS, "sum")} + b.update(covers[b["key"]], covered=True) + b["sum"] = total([b[p] for p in _PARTS]) + gaps = [g for g in gaps if not (g["tab"] == "machine_blocks" and g["key"] in covers)] machine = [ { "no": no, @@ -327,6 +355,7 @@ def compute_base(inputs: dict[str, Any], user: dict[str, Any]) -> dict[str, Any] "labor": b["labor"], "material": b["material"], "expense": b["expense"], + **({"covered": True} if b.get("covered") else {}), } for no, b in enumerate(blocks, 1) ] diff --git a/B09_Estimation/B09_Estimation_Calc_Base.ts b/B09_Estimation/B09_Estimation_Calc_Base.ts index 7c133ea91..011b21db0 100644 --- a/B09_Estimation/B09_Estimation_Calc_Base.ts +++ b/B09_Estimation/B09_Estimation_Calc_Base.ts @@ -12,6 +12,9 @@ * 재료비목록표 = 수량 × 적용 단가 = 총액 ROUND(·, 0) · 하나라도 빈칸이면 빈칸 · 수량 = 입력 * material_qty(183-1 · 서버 역전개 총수량 · 없는 키 빈칸) ?? 입력 줄 수량(B08 총괄 합계 열) * 중기사용료 = 손료 + 운전사 + 연료 + 잡품 · 줄 ROUNDDOWN 1 · 비목 합 ROUNDDOWN 0 → 중기목록표 + * 노임 · 기계 단가 덮개(707-3) = 사용자 값 labor_cover {노무 키: 일당} · machine_cover + * {기계 키: {labor, material, expense}}(셋 다 수일 때만) — 굳힌 값 대신 · covered · book(굳힌 값) · + * 덮은 기계 = 중기사용료 줄 그대로 · 비목 합 · 합 = 덮개 · 그 기계 「연결 없음」 뺌 * 빈칸 = 원천이 없으면 null · 그 칸을 쓰는 합도 null · 「연결 없음」 목록(gaps)에 남김 * ========================================================================== */ @@ -41,6 +44,7 @@ export const SOURCES = [ /** 자재 줄 사용자 표시 */ export const SUPPLY = ["관급", "별도"] as const; +const COVER_PARTS = ["labor", "material", "expense"] as const; function scaled(value: number, places: number, fn: (x: number) => number): number { const a = Math.abs(value); @@ -101,14 +105,28 @@ const head = (row: Row, no: number): Row => ({ source: row.source || "", }); -function laborSheet(labor: Row[], gaps: Gap[]): Row[] { +function laborSheet(labor: Row[], gaps: Gap[], cover?: Row): Row[] { return labor.map((row, i) => { - const price = num(row.price); + const book = num(row.price); + const mine = num((cover || {})[row.key]); + const price = mine === null ? book : mine; if (price === null) gaps.push(gap("labor", row.key, row.name, "노임 단가 없음")); - return { ...head(row, i + 1), code: row.code ?? null, unit: row.unit || "인", price }; + const extra = mine === null ? {} : { covered: true, book }; + return { ...head(row, i + 1), code: row.code ?? null, unit: row.unit || "인", price, ...extra }; }); } +/** 기계 덮개 — 노 · 재 · 경 셋 다 수인 줄만 */ +export function machineCover(cover: unknown): Row { + const out: Row = {}; + for (const [key, cell] of Object.entries(cover && typeof cover === "object" ? cover : {})) { + const got: Row = {}; + for (const p of COVER_PARTS) got[p] = cell && typeof cell === "object" ? num(cell[p]) : null; + if (COVER_PARTS.every((p) => got[p] !== null)) out[key] = got; + } + return out; +} + function expenseSheet(machines: Row[], gaps: Gap[]): Row[] { return machines.map((m, i) => { const price = num(m.price); @@ -276,8 +294,8 @@ function basisSheet(inputs: Row, laborRows: Row[]): Row { /** 144 탭 일곱 + 「연결 없음」 목록 */ export function computeBase(inputs: Row, user: Row): Row { - const gaps: Gap[] = []; - const laborRows = laborSheet(inputs.labor || [], gaps); + let gaps: Gap[] = []; + const laborRows = laborSheet(inputs.labor || [], gaps, user.labor_cover); const expense = expenseSheet(inputs.machines || [], gaps); const compare = compareSheet(inputs.materials || [], user.materials || {}, gaps); const basis = basisSheet(inputs, laborRows); @@ -287,6 +305,14 @@ export function computeBase(inputs: Row, user: Row): Row { for (const [kind, key] of Object.entries(inputs.fuel_keys || {})) if ((key as string) in byKey) fuels[kind] = byKey[key as string]; const blocks = (inputs.machines || []).map((m: Row) => machineBlock(m, drivers, fuels, gaps)); + const covers = machineCover(user.machine_cover); + for (const b of blocks) { + if (!(b.key in covers)) continue; // 707-3 덮개 = 비목 합 · 합(줄은 그대로) + b.book = { labor: b.labor, material: b.material, expense: b.expense, sum: b.sum }; + Object.assign(b, covers[b.key], { covered: true }); + b.sum = total(COVER_PARTS.map((p) => b[p])); + } + gaps = gaps.filter((g) => !(g.tab === "machine_blocks" && g.key in covers)); const machine = blocks.map((b: Row, i: number) => ({ no: i + 1, key: b.key, @@ -297,6 +323,7 @@ export function computeBase(inputs: Row, user: Row): Row { labor: b.labor, material: b.material, expense: b.expense, + ...(b.covered ? { covered: true } : {}), })); return { labor: laborRows, diff --git a/B09_Estimation/B09_Estimation_Import_Prices.py b/B09_Estimation/B09_Estimation_Import_Prices.py new file mode 100644 index 000000000..ae2a337e0 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Import_Prices.py @@ -0,0 +1,186 @@ +"""B09 경쟁사 기초 단가표 불러오기(PLAN 707-3) — 서버 단독 · 저장 안 함 +(덮개 제안 → 화면 캐시 → [저장]). + +읽는 시트(꼴마다 첫 것) — 머리 = 「명칭」 · 「품명」 칸이 든 줄: + 노임 = STmate 「노무비목록표」 · EST 「노임단가」 → 단가(「적용단가」 › 「단가」) + 자재 = STmate 「재료비목록표」 › 「자재단가대비표」 · EST 「자재단가」 → 적용 단가(단위 % 줄 뺌) + 기계 = STmate 「중기목록표」(= 「중기사용료」 블록 합계) · + EST 「기계경비총괄표」(= 「기계경비」 합) → 시간 단가 노무비 · 재료비 · 경비 +이음 = 이 공사 단가 책 줄(바탕 셈 `compute_base` 노무비목록표 · 중기목록표 · 자재단가대비표)에 + 이름 · 규격 · 단위로 — 705-3 이음 낱말 그대로(노임 = `M02_Hopyo_Shares.trade` 직종 이름 · + 기계 = 이름 + 규격 같음 › `eq_key` 갈래 · 크기 · 자재 = 「별도계상」 글 뗀 이름 · 규격 · 단위 › + 이름 · 단위) · 한 줄에만 맞을 때만 · 기계 갈래 · 크기는 표지 글(타이어 · 브레이커 · 할증 …) + 까지 · 한 책 줄에 엑셀 줄 여럿 = 「짝 여럿」(못 고름). +덮개 = 노임 `labor_cover` · 기계 `machine_cover`(새 · `B09_Estimation_Calc_Base`) · + 자재 = 지금 덮개 `materials[키].applied`(고른 출처 `pick` 은 뺌 — 적용 차례가 pick 먼저) · + 책 값과 같으면 덮지 않음(옛 덮개 걷음). +못 이은 줄 = 「짝 없음 n」(이름 목록). +""" + +from __future__ import annotations + +import re +import unicodedata +from collections import Counter +from typing import Any + +from B09_Estimation.B09_Estimation_Import_Rates import flat, money, read_sheets +from M02_MasterTemplete import M02_Hopyo_Material as hmat +from M02_MasterTemplete import M02_Hopyo_Shares as shares +from M02_MasterTemplete.M02_Hopyo_Excel import ExcelError + +KINDS = { + "labor": ("노무비목록표", "노임단가"), + "material": ("재료비목록표", "자재단가대비표", "자재단가"), + "machine": ("중기목록표", "기계경비총괄표"), +} +PARTS = ("labor", "material", "expense") +_MACHINE = {"labor": "노무비", "material": "재료비", "expense": "경비"} +LIST_MAX = 40 +#: 같은 갈래 · 크기라도 다른 기계 — 표지 글이 같아야 이음 +_MARK = re.compile(r"타이어|브레|할증|암석|\(암\)|집게|진동|탑재|크레인|살수") + + +def _n(text: Any) -> str: + return flat(unicodedata.normalize("NFKC", str(text or ""))).lower() + + +def table(rows: list[list[Any]], machine: bool) -> list[dict[str, Any]]: + """목록 시트 → [{name, spec, unit, price | labor · material · expense}].""" + for at, row in enumerate(rows[:12]): + cols = [flat(v) for v in row] + name = next((cols.index(h) for h in ("명칭", "품명") if h in cols), None) + if name is None: + continue + spec, unit = (cols.index(h) if h in cols else None for h in ("규격", "단위")) + if machine: + price = {p: cols.index(h) for p, h in _MACHINE.items() if h in cols} + if len(price) < 3: + continue + else: + price = next((cols.index(h) for h in ("적용단가", "단가") if h in cols), None) + if price is None: + continue + out = [] + for line in rows[at + 1 :]: + + def cell(col: int | None, line: list[Any] = line) -> Any: + return line[col] if col is not None and col < len(line) else None + + item = {"name": str(cell(name) or "").strip(), "spec": str(cell(spec) or "").strip(), + "unit": str(cell(unit) or "").strip()} # fmt: skip + if machine: + item.update({p: money(cell(c)) for p, c in price.items()}) + ok = None not in (item[p] for p in PARTS) + else: + item["price"] = money(cell(price)) + ok = item["price"] is not None and item["unit"] != "%" + if item["name"] and ok: + out.append(item) + return out + return [] + + +def read_prices(name: str, data: bytes) -> dict[str, list[dict[str, Any]]]: + """파일 → {labor, material, machine: 줄 목록} · 시트 없음 = 빈 목록 · 셋 다 없으면 막힘.""" + sheets = read_sheets(name, data, tuple(s for names in KINDS.values() for s in names)) + out = {} + for kind, names in KINDS.items(): + sheet = next((s for s in names if s in sheets), None) + out[kind] = table(sheets[sheet], kind == "machine") if sheet else [] + out[f"{kind}_sheet"] = sheet + if not any(out[k] for k in KINDS): + raise ExcelError("노무비목록표 · 재료비목록표 · 중기목록표(STmate) · 노임단가 · 자재단가 · " + "기계경비총괄표(EST) 시트가 없음") # fmt: skip + return out + + +def _labor_keys(row: dict[str, Any]) -> list[str]: + return [_n(shares.trade(row.get("name")) or row.get("name"))] + + +def _machine_keys(row: dict[str, Any]) -> list[str]: + """[이름 + 규격 같음, 갈래 · 크기 + 표지 글(타이어 · 브레이커 · 할증 · 암 …)].""" + text = f"{row.get('name') or ''} {row.get('spec') or ''}" + kind = shares.eq_key(text) + marks = "+".join(sorted(set(_MARK.findall(_n(text))))) + return [_n(text), f"{kind}#{marks}" if kind else ""] + + +def _material_keys(row: dict[str, Any]) -> list[str]: + me, _ = hmat.clean({"이름": row.get("name"), "규격": row.get("spec")}) + unit = _n(row.get("unit")) + return [f"{_n(me['이름'])}|{_n(me['규격'])}|{unit}", f"{_n(me['이름'])}||{unit}"] + + +def _match(book: list[dict[str, Any]], keys_of, item: dict[str, Any]) -> dict[str, Any] | None: + """책 줄 하나 — 단계마다 한 줄에만 맞으면 그 줄 · 여럿 · 없음이면 다음 단계.""" + for step, want in enumerate(keys_of(item)): + hits = [r for r in book if keys_of(r)[step] == want] if want else [] + if len(hits) == 1: + return hits[0] + return None + + +def _same(a: Any, b: Any) -> bool: + if isinstance(a, dict): + return all(_same(a[p], (b or {}).get(p)) for p in PARTS) + return a is not None and b is not None and abs(a - b) < 0.5 + + +def propose(read: dict[str, Any], sheets: dict[str, Any], user: dict[str, Any]) -> dict[str, Any]: + """읽은 단가표 + 이 공사 단가 책(덮개 뺀 바탕 셈) → 덮개 patch · 이은 줄 · 짝 없음.""" + base = {"labor": sheets.get("labor") or [], "machine": sheets.get("machine") or [], + "material": sheets.get("compare") or []} # fmt: skip + keys = {"labor": _labor_keys, "machine": _machine_keys, "material": _material_keys} + labor_cover = dict(user.get("labor_cover") or {}) + machine_cover = dict(user.get("machine_cover") or {}) + materials = {k: dict(v) for k, v in (user.get("materials") or {}).items()} + linked: list[dict[str, Any]] = [] + missing: dict[str, list[str]] = {k: [] for k in KINDS} + for kind in KINDS: + items = read.get(kind) or [] + rows = [_match(base[kind], keys[kind], item) for item in items] + hits = Counter(r["key"] for r in rows if r) + for item, row in zip(items, rows, strict=True): + label = " ".join(x for x in (item["name"], item["spec"]) if x) + if row is None or hits[row["key"]] > 1: # 한 책 줄에 엑셀 줄 여럿 = 못 고름 + missing[kind].append(label if row is None else f"{label}(짝 여럿)") + continue + if kind == "machine": + got = {p: item[p] for p in PARTS} + book = {p: row.get(p) for p in PARTS} + else: + got, book = item["price"], row.get("price" if kind == "labor" else "applied") + linked.append({"kind": kind, "key": row["key"], "name": row.get("name") or "", + "from": label, "excel": got, "book": book, + "same": _same(got, book)}) # fmt: skip + same = linked[-1]["same"] # 책 값 = 엑셀 → 옛 덮개 걷음(값 = 책) + if kind in ("labor", "machine"): + cover = labor_cover if kind == "labor" else machine_cover + cover.pop(row["key"], None) + if not same: + cover[row["key"]] = got + else: + drop = ("pick", "applied") if same else ("pick",) + cell = {k: v for k, v in materials.get(row["key"], {}).items() if k not in drop} + materials[row["key"]] = cell if same else {**cell, "applied": got} + return { + "patch": { + "labor_cover": labor_cover, + "machine_cover": machine_cover, + "materials": materials, + }, # fmt: skip + "linked": linked, + "missing": {k: v[:LIST_MAX] for k, v in missing.items()}, + "counts": { + k: { + "read": len(read.get(k) or []), + "linked": sum(x["kind"] == k for x in linked), + "covered": sum(x["kind"] == k and not x["same"] for x in linked), + "book": len(base[k]), + "missing": len(missing[k]), + } + for k in KINDS + }, # fmt: skip + } diff --git a/B09_Estimation/B09_Estimation_Import_Rates.py b/B09_Estimation/B09_Estimation_Import_Rates.py new file mode 100644 index 000000000..014687a04 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Import_Rates.py @@ -0,0 +1,278 @@ +"""B09 경쟁사 원가계산서 요율 불러오기(PLAN 707-2) — 서버 단독 · 저장 안 함 +(제안만 → 화면 캐시 → [저장]). + +읽는 시트 = STmate 「공사원가계산서」(율 = 비고 글 「직접노무비 x 14.5%」) · + EST 「원가계산서」(율 = 「요율」 칸 · 비면 산출근거 글). +머리 = 「금액」 칸이 든 줄 · 행 이름 = 금액 칸 앞 마지막 한글 두 글자 넘는 글 + (EST 세로 머리 · 구분 칸 뺌). +행 이름 → 원가계산서 줄 키(`ROWS` · RT 코드 = `B09_Estimation_Calc_Total` 줄 표) → + 율 덮개 `estimate.rates{줄 키: %}` · + 손입력 칸 = 지금 칸(`B09_Estimation_Store.ESTIMATE_FIELDS`): + safety_base = 산업안전보건관리비 글 「% + 5,349,000」(글 있고 + 없으면 0 · 글 없으면 안 실음) · + indirect_material · scrap = 그 줄 금액 · + profit_adjust = ROUNDDOWN((순공사원가 − 재료비 + 일반관리비) × 율) − 엑셀 이윤 · + vat_adjust = 엑셀 부가세 − ROUNDDOWN(총원가 × 율) · + procurement_fee_pct = 관급 조달수수료 ÷ 순자재대(소수 2~4 자리 중 수수료를 그대로 내는 첫 값). +대조(`check`) = 엑셀 비목 합 + 실은 값으로 우리 식(`compute_total`)을 돌려 줄마다 엑셀 금액과 견줌 — + 1 원 넘게 다른 줄 = 「식 다름」(노인장기요양 · 산업안전보건관리비 A/B 등 식 꼴이 다른 비목) · + 우리 표에 없는 행(폐기물처리 · 국가지점번호판 …) · 셈 없는 현행 비목(퇴직공제부금비 …) + = 「짝 없음」 · 「셈 없음」. +""" + +from __future__ import annotations + +import io +import re +from typing import Any + +from B09_Estimation.B09_Estimation_Calc_Base import num, rounddown +from B09_Estimation.B09_Estimation_Calc_Total import ( + CURRENT_ONLY, + RATE_LINES, + TAIL_LINES, + compute_total, +) +from M02_MasterTemplete.M02_Hopyo_Excel import DRM, ExcelError + +SHEETS = ("공사원가계산서", "원가계산서") +#: 행 이름 앞머리(빈칸 뺀 글) → 키 — 율 줄 = 원가계산서 줄 키 · 그 밖 = 비목 합 · 대조용 +ROWS: tuple[tuple[str, str], ...] = ( + ("직접재료비", "material_direct"), + ("간접재료비", "indirect_material"), + ("작업설", "scrap"), + ("직접노무비", "labor"), + ("간접노무비", "indirect_labor"), + ("산출경비", "expense"), + ("산재보험", "accident"), + ("고용보험", "employment"), + ("건강보험", "health"), + ("노인장기요양", "care"), + ("연금보험", "pension"), + ("산업안전보건관리비", "safety"), + ("기타경비", "other"), + ("환경보전비", "environment"), + ("건설기계대여금", "machine"), + ("퇴직공제", "retirement"), + ("임금채권", "wage_claim"), + ("석면", "asbestos"), + ("하도급대금", "subcontract"), + ("공사이행보증", "performance"), + ("일반관리비", "overhead"), + ("이윤", "profit"), + ("총원가", "supply_price"), + ("부가가치세", "vat"), + ("도급", "contract"), + ("관급자재대", "supply"), + ("총공사비", "grand"), + ("순공사원가", "net_cost"), + ("소계", "subtotal"), +) +RATE_KEYS = {key: (name, rt) for key, name, rt, _ in (*RATE_LINES, *TAIL_LINES)} +NO_CALC = {key: (name, rt) for key, name, rt in CURRENT_ONLY} +_PCT = re.compile(r"[x×*]\s*([\d.]+)\s*%") +_BASE = re.compile(r"%\s*\+\s*([\d,]+)") +_HANGUL = re.compile(r"[가-힣]") +_FEE = re.compile(r"원자재대\s*:\s*([\d,]+).*?조달수수료\s*:\s*([\d,]+)") + + +def flat(value: Any) -> str: + return re.sub(r"\s+", "", str(value or "")) + + +def read_sheets(name: str, data: bytes, wanted: tuple[str, ...]) -> dict[str, list[list[Any]]]: + """시트 이름 → 줄 목록(`wanted` 만 · 이름 앞뒤 빈칸 뺌) — xlsx · xls · DRM 막힘.""" + if not data: + raise ExcelError("빈 파일") + if b"DRMONE" in data[:256] and data[:4] not in (b"PK\x03\x04", b"\xd0\xcf\x11\xe0"): + raise ExcelError(DRM) + try: + if data[:4] == b"PK\x03\x04" or name.lower().endswith(".xlsx"): + import openpyxl + + book = openpyxl.load_workbook(io.BytesIO(data), read_only=True, data_only=True) + out = { + ws.title.strip(): [list(row) for row in ws.iter_rows(values_only=True)] + for ws in book.worksheets + if ws.title.strip() in wanted + } + book.close() + return out + import xlrd + + book = xlrd.open_workbook(file_contents=data, logfile=io.StringIO()) + return { + sh.name.strip(): [sh.row_values(r) for r in range(sh.nrows)] + for sh in book.sheets() + if sh.name.strip() in wanted + } + except Exception as error: # 라이브러리마다 다른 예외 — 깨진 파일 · 엑셀 아님 + raise ExcelError("엑셀 파일을 읽지 못함 — xls · xlsx 가 아니거나 깨진 파일") from error + + +def money(value: Any) -> float | None: + """칸 → 수(「80,165,010」 글도) · 아니면 None.""" + if isinstance(value, str): + value = value.replace(",", "").strip() + try: + return float(value) if value else None + except ValueError: + return None + return num(value) + + +def _pct(value: Any) -> float | None: + """요율 칸 — 「16.5%」 글 · 엑셀 % 수(0.165) → %.""" + if isinstance(value, str): + return money(value.replace("%", "")) if "%" in value else None + got = num(value) + return None if got is None else round(got * 100, 6) + + +def _key(name: str) -> str | None: + return next((key for head, key in ROWS if name.startswith(head)), None) + + +def parse(rows: list[list[Any]]) -> dict[str, Any]: + """원가계산서 줄 → [{name, key, amount, rate, text, more}] — 머리 = 「금액」 칸 든 줄.""" + head = next((i for i, r in enumerate(rows[:12]) if "금액" in [flat(v) for v in r]), None) + if head is None: + raise ExcelError("원가계산서 머리(금액 칸)를 못 찾음") + cols = [flat(v) for v in rows[head]] + at = cols.index("금액") + rate_col = cols.index("요율") if "요율" in cols else None + text_col = next((cols.index(h) for h in ("산출근거", "비고") if h in cols), None) + out = [] + for row in rows[head + 1 :]: + names = [flat(v) for v in row[:at] if len(_HANGUL.findall(str(v or ""))) > 1] + amount = money(row[at]) if at < len(row) else None + if not names: + continue + text = str(row[text_col] or "") if text_col is not None and text_col < len(row) else "" + rate = _pct(row[rate_col]) if rate_col is not None and rate_col < len(row) else None + if rate is None and (m := _PCT.search(text)): + rate = float(m.group(1)) + tail = row[max(at, text_col or 0) + 1 :] + more = [v for v in (money(x) for x in tail) if v is not None] + out.append({"name": names[-1], "key": _key(names[-1]), "amount": amount, + "rate": rate, "text": text.strip(), "more": more}) # fmt: skip + return {"rows": out} + + +def _fee(row: dict[str, Any]) -> tuple[float, float] | None: + """관급 줄 → (순자재대, 조달수수료) — STmate 글 「원자재대: X (조달수수료: Y 포함)」 · + EST 뒤 칸 둘.""" + if m := _FEE.search(row["text"].replace("\n", " ")): + raw, fee = (float(g.replace(",", "")) for g in m.groups()) + return raw - fee, fee + if len(row["more"]) >= 2 and row["more"][0] > row["more"][1] > 0: + return row["more"][0] - row["more"][1], row["more"][1] + return None + + +def _fee_pct(net: float, fee: float) -> float | None: + for places in (2, 3, 4): + pct = round(fee / net * 100, places) + if rounddown(net * pct / 100) == fee: + return pct + return None + + +def read_rates(name: str, data: bytes) -> dict[str, Any]: + """파일 → {kind, rows, estimate(실을 값), excel(키: 금액), notes}.""" + sheets = read_sheets(name, data, SHEETS) + sheet = next((s for s in SHEETS if s in sheets), None) + if sheet is None: + raise ExcelError("「공사원가계산서」(STmate) · 「원가계산서」(EST) 시트가 없음") + rows = parse(sheets[sheet])["rows"] + excel: dict[str, float] = {} + subtotals: list[float] = [] + rates: dict[str, float] = {} + estimate: dict[str, Any] = {} + notes: list[str] = [] + for row in rows: + key, amount = row["key"], row["amount"] + if key == "subtotal": + subtotals.append(amount or 0.0) + continue + if key is None: + if amount: + notes.append( + f"짝 없음 — 「{row['name']}」 {amount:,.0f} 원(우리 원가계산서에 줄 없음)" + ) + continue + if key == "safety" and key in excel: # EST A · B 두 줄 = 작은 쪽(우리 식과 같음) + excel[key] = min(excel[key], amount or excel[key]) + continue + excel[key] = amount if amount is not None else excel.get(key, 0.0) + if key in RATE_KEYS or key in NO_CALC: + if row["rate"] is not None: + rates[key] = row["rate"] + elif amount: + notes.append(f"율 없음 — 「{row['name']}」 산출근거 빈칸 · 지금 율 그대로") + if key in NO_CALC and amount: + notes.append( + f"셈 없음 — 「{row['name']}」 {amount:,.0f} 원 · 율 {row['rate']}% 만 실음" + "(우리 원가계산서 줄만 · 총액에 안 듦)" + ) + if key == "safety" and row["text"]: + got = _BASE.search(row["text"]) + estimate["safety_base"] = float(got.group(1).replace(",", "")) if got else 0.0 + if key in ("indirect_material", "scrap") and amount: + estimate[key] = amount + if key == "supply" and (fee := _fee(row)): + excel["supply_net"], excel["supply_fee"] = fee + if (pct := _fee_pct(*fee)) is not None: + estimate["procurement_fee_pct"] = pct + else: + notes.append( + f"조달수수료 {fee[1]:,.0f} 원 — 한 율로 안 맞음(품목마다 셈) · 수수료율 손입력" + ) + excel["material"] = subtotals[0] if subtotals else excel.get("material_direct") + net = excel.get("net_cost") or (sum(subtotals[:3]) if len(subtotals) >= 3 else None) + if net is not None and excel.get("overhead") is not None and "profit" in rates: + base = net - (excel["material"] or 0.0) + excel["overhead"] + adjust = rounddown(base * rates["profit"] / 100) - (excel.get("profit") or 0.0) + if adjust: + estimate["profit_adjust"] = adjust + if excel.get("supply_price") is not None and "vat" in rates and "vat" in excel: + adjust = excel["vat"] - rounddown(excel["supply_price"] * rates["vat"] / 100) + if adjust: + estimate["vat_adjust"] = adjust + kind = "STmate" if sheet == "공사원가계산서" else "EST" + return {"kind": kind, "sheet": sheet, "rows": rows, "rates": rates, + "estimate": estimate, "excel": excel, "notes": notes} # fmt: skip + + +NAMES = {"supply": "관급자재대", "supply_price": "총원가(공급가액)", "contract": "도급공사비", + "grand": "총공사비"} # fmt: skip +#: 대조 줄 — 엑셀 키 → 우리 셈 자리 +_OURS = { + **{key: ("lines", key) for key in RATE_KEYS}, + "supply": ("lines", "supply"), + "supply_price": ("subtotals", "supply_price"), + "contract": ("subtotals", "contract"), + "grand": ("subtotals", "grand"), +} + + +def check(read: dict[str, Any], estimate: dict[str, Any] | None, table: Any) -> dict[str, Any]: + """엑셀 비목 합 + (지금 사용자 값 위에 실은 값)으로 우리 식 → 줄마다 엑셀과 견줌.""" + excel = read["excel"] + mine = dict(estimate or {}) + est = {**mine, **read["estimate"], "rates": {**(mine.get("rates") or {}), **read["rates"]}} + net, fee = excel.get("supply_net", 0.0), excel.get("supply_fee", 0.0) + if "procurement_fee_pct" not in read["estimate"] and net: # 한 율로 못 맞춘 수수료 = 그 비율째 + est["procurement_fee_pct"] = fee / net * 100 + sums = {"material": excel.get("material"), "labor": excel.get("labor"), + "expense": excel.get("expense"), "supply_net": net} # fmt: skip + got = compute_total(sums, est, table) + lines = [] + for key, (part, at) in _OURS.items(): + if key not in excel: + continue + ours = got[part].get(at) + same = ours is not None and abs(ours - excel[key]) < 1 + name = RATE_KEYS[key][0] if key in RATE_KEYS else NAMES[key] + lines.append({"key": key, "name": name, "excel": excel[key], "ours": ours, "same": same}) + return {"lines": lines, "grand": got["subtotals"]["grand"], "excel_grand": excel.get("grand"), + "differ": [ln["name"] for ln in lines if not ln["same"]]} # fmt: skip diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index ad0f6cd53..058f9fffb 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -41,7 +41,7 @@ 지난 공사 일위대가 → 이 공사 서고 사본(덮개째) → {폴더, 만듦, key, cover, 덮개, payload} · 덮개 = 그 공사 저장 칸(화면 캐시 cover 가 비었을 때만 얹음 · 같은 꼴 블록이 먼저 있으면 빈 것) · 같은 회사만(남의 회사 403) -경쟁사 내역 줄 불러오기(707-1) = `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`) · @@ -586,7 +586,7 @@ async def past_import( return {**made, "key": key, "cover": cover, "payload": out} -# 166-4 견적서 파일 길 · 707-1 경쟁사 내역 줄 불러오기 길 — 같은 머리 · 같은 권한 +# 166-4 견적서 파일 길 · 707-1~3 경쟁사 결과물 불러오기 길 — 같은 머리 · 같은 권한 router.include_router(quotes_router) router.include_router(import_router) diff --git a/B09_Estimation/B09_Estimation_Router_Import.py b/B09_Estimation/B09_Estimation_Router_Import.py index 259bb39cf..b51db8407 100644 --- a/B09_Estimation/B09_Estimation_Router_Import.py +++ b/B09_Estimation/B09_Estimation_Router_Import.py @@ -1,4 +1,5 @@ -"""B09 경쟁사 내역 줄 불러오기 길(PLAN 707-1) — 저장 안 함(화면 캐시로 → [저장] 때 서버 정본). +"""B09 경쟁사 결과물 불러오기 길 — 내역 줄(707-1) · 요율(707-2) · 기초 단가표(707-3) · 저장 안 함 +(화면 캐시로 → [저장] 때 서버 정본). POST /api/projects/{id}/estimation/import/bill (multipart: file | file_key · user?) 읽기 + 미리 보기 — 파일 = 프로젝트 `B09_Estimation/v1/import/{표지}/` @@ -10,6 +11,14 @@ {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` 가 끼워 넣음(같은 머리 · 같은 권한). """ @@ -28,17 +37,24 @@ from pydantic import BaseModel, Field 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/bill" +ROOT = "/{project_id}/estimation/import" +PATH = ROOT + "/bill" IMPORT = "import" MAX_BYTES = 50 * 1024 * 1024 _TAG = re.compile(r"^[0-9a-f]{8}$") @@ -190,3 +206,70 @@ async def apply_bill( "엑셀합계": 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), + file_key: str | None = Form(None), + user: str | None = Form(None), +) -> dict[str, Any]: + root = await _project_root(project_id) + 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" + ] + try: + read = await asyncio.to_thread(import_rates.read_rates, name, data) + except ExcelError as error: + raise HTTPException(400, str(error)) from error + check = await asyncio.to_thread(import_rates.check, read, mine, rate_table()) + estimate = {**mine, **read["estimate"], "rates": {**(mine.get("rates") or {}), **read["rates"]}} + keep = ("kind", "sheet", "rows", "rates", "notes") + return {"file_key": key, "name": name, **{k: read[k] for k in keep}, "check": check, + "patch": {"estimate": estimate}} # fmt: skip + + +@router.post(ROOT + "/prices") +async def import_price_sheets( + project_id: UUID, + file: UploadFile | None = File(None), + file_key: str | None = Form(None), + user: str | None = Form(None), +) -> dict[str, Any]: + from B09_Estimation import B09_Estimation_Router as main # 서로 부름 — 부를 때만 + + root = await _project_root(project_id) + 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: + read = await asyncio.to_thread(import_prices.read_prices, name, data) + except ExcelError as error: + raise HTTPException(400, str(error)) from error + keys = await main._demand(root) # 화면 단가 목록(GET)과 같은 수요 — 사본 전부 + b08 = await main._b08_for(project_id, [], True) + book = await asyncio.to_thread(store.read_book, root) + inputs = await asyncio.to_thread(prices.build_inputs, book, keys, b08["materials"]) + base = compute_base(inputs, {}) # 덮개 뺀 이 공사 단가 책 + out = import_prices.propose(read, base, mine) + sheets = {k: read[f"{k}_sheet"] for k in import_prices.KINDS} + return {"file_key": key, "name": name, "sheets": sheets, **out} diff --git a/B09_Estimation/B09_Estimation_Store.py b/B09_Estimation/B09_Estimation_Store.py index 0b04d5235..3ba1c68b3 100644 --- a/B09_Estimation/B09_Estimation_Store.py +++ b/B09_Estimation/B09_Estimation_Store.py @@ -14,6 +14,9 @@ (702-2 일위대가 고친 칸 덮개 — 수만 · 셈 = `B09_Estimation_Calc_Labor`) + 복사본 `K:…` 의 `@copy` {from · name · spec}(703-2 · 글만) + 붙인 줄 `@add` [블록 줄 fixed · pct](705-2 · `clean_added`) + "labor_cover": {노무 키: 일당} · "machine_cover": {기계 키: {labor, + material, expense}}(707-3 노임 · 기계 단가 덮개 · 공사 몫 · [업데이트] 가 + 책만 새로 굳혀 덮개는 남음 · 셈 = `B09_Estimation_Calc_Base`) quotes/<자재 키>/ 견적서 파일(166-4 · 여러 개) — 폴더가 목록(키의 못 쓰는 글자는 %XX) · 읽을 때 materials[키].files = 이름들 · 사용자 값으로는 저장 안 함 @@ -31,7 +34,7 @@ from pathlib import Path from typing import Any from urllib.parse import unquote -from B09_Estimation.B09_Estimation_Calc_Base import SOURCES, SUPPLY +from B09_Estimation.B09_Estimation_Calc_Base import SOURCES, SUPPLY, machine_cover from B09_Estimation.B09_Estimation_Calc_Bill import SECTIONS from B09_Estimation.B09_Estimation_Calc_Labor import ADD, COVER_KEYS, PARTS @@ -331,6 +334,11 @@ def clean_user(raw: Any) -> dict[str, Any]: out = {"materials": materials, "estimate": estimate, **({"bill": bill} if bill else {})} if cover := clean_cover(raw.get("daega_cover")): out["daega_cover"] = cover + given = raw.get("labor_cover") if isinstance(raw.get("labor_cover"), dict) else {} + if labor := {str(k)[:200]: _num(v) for k, v in given.items() if _num(v) is not None}: + out["labor_cover"] = labor + if machine := machine_cover(raw.get("machine_cover")): + out["machine_cover"] = {str(k)[:200]: v for k, v in machine.items()} if clean_schedule and (schedule := clean_schedule(raw.get("schedule"))): out["schedule"] = schedule return out diff --git a/B09_Estimation/B09_Estimation_UI_Import.ts b/B09_Estimation/B09_Estimation_UI_Import.ts index 93151d2ae..cfe1070fe 100644 --- a/B09_Estimation/B09_Estimation_UI_Import.ts +++ b/B09_Estimation/B09_Estimation_UI_Import.ts @@ -7,11 +7,18 @@ * [불러오기] = `POST …/import/bill/apply` → 받은 원가 자료 · 새 캐시(내역 덮개 · 조달수수료율) → [저장]해야 남음 * 어긋난 줄(엑셀 금액 ≠ 우리 금액) = 같은 창 목록(줄 · 엑셀 · 우리 · 까닭) · 없으면 창 닫고 알림 * DRM 파일 = 서버 400 「읽을 수 없음」 알림 + * + * 요율 · 기초 단가표 불러오기(PLAN 707-2 · 707-3) — 저장 안 함. + * 단추 = 보조 탭 줄 도구 자리(`…_UI_TabBar` setTools) — 총괄 · 원가계산서 탭 [엑셀에서 요율 불러오기] · + * 노무비목록표 · 중기목록표 · 자재단가대비표 탭 [엑셀에서 단가표 불러오기] + * 길 = `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 } from "@ui/ui_template_data_table"; +import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table"; import { pickFiles } from "@ui/ui_template_drop"; import { createButton, @@ -22,7 +29,9 @@ import { } 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"); @@ -164,7 +173,10 @@ export function createBillImport( form.append("user", JSON.stringify(user())); showLoadingOverlay(); post(projectId, "bill", form) - .then((seen) => open(seen, mode)) + .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"), ) @@ -174,3 +186,173 @@ export function createBillImport( button.dataset.b09Import = ""; return button; } + +/* ── 707-2 · 3 요율 · 기초 단가표 ── */ + +type Kind = "rates" | "prices"; +const TABS: Partial> = { + total: "rates", + cost: "rates", + labor: "prices", + machine: "prices", + compare: "prices", +}; +const LABEL: Record = { + rates: "엑셀에서 요율 불러오기", + prices: "엑셀에서 단가표 불러오기", +}; +const PART: Record = { labor: "노임", material: "자재", machine: "기계" }; + +/** 마지막에 올린 파일(서버 기억 열쇠) — 같은 파일 다시 씀 */ +let last: { key: string; name: string } | null = null; + +async function postSheet(kind: Kind, ctx: TabContext, file: File | null): Promise { + const form = new FormData(); + if (file) form.append("file", file); + else if (last) form.append("file_key", last.key); + form.append("user", JSON.stringify(ctx.user)); + const url = `${API_BASE_URL}/projects/${currentProjectId()}/estimation/import/${kind}`; + const response = await fetch(url, { method: "POST", body: form, credentials: "include" }); + const body = (await response.json().catch(() => null)) as Row | null; + if (!response.ok) { + if (response.status === 404 && !file) last = null; // 지난 파일 = 다시 고름 + throw new Error(typeof body?.detail === "string" ? body.detail : `HTTP ${response.status}`); + } + last = { key: String(body!.file_key), name: String(body!.name) }; + return body!; +} + +const wonOrBlank = (v: unknown): string => + typeof v === "number" ? Math.round(v).toLocaleString("en-US") : ""; + +function ratesView(got: Row): { table: DataRow[]; lines: string[]; columns: DataColumn[] } { + const check: Row[] = got.check?.lines || []; + const ours = Object.fromEntries(check.map((ln) => [ln.key, ln])); + const table = (got.rows as Row[]) + .filter((r) => r.key && r.key !== "subtotal") + .map((r, i) => ({ + key: String(i), + name: r.name, + rate: r.rate, + excel: r.amount, + ours: ours[r.key]?.ours ?? null, + // 산업안전보건관리비 A · B 두 줄(EST) = 작은 쪽만 우리 식과 견줌 + same: !ours[r.key] + ? "" + : Math.abs((r.amount ?? NaN) - ours[r.key].ours) < 1 + ? "같음" + : ours[r.key].same + ? "안 고른 쪽" + : "식 다름", + })); + const grand = got.check?.grand; + const lines = [ + `율 ${Object.keys(got.rates || {}).length} 줄 실음 · 총공사비 우리 ${wonOrBlank(grand)} / 엑셀 ${wonOrBlank( + got.check?.excel_grand, + )}`, + ...(got.notes as string[]), + ]; + const columns = [ + text("name", "행 이름", 180), + qty("rate", "율(%)", 4), + money("excel", "엑셀 금액"), + money("ours", "우리 식"), + text("same", "대조", 72), + ]; + return { table, lines, columns }; +} + +function pricesView(got: Row): { table: DataRow[]; lines: string[]; columns: DataColumn[] } { + const show = (v: unknown): string => + v && typeof v === "object" + ? ["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, + from: x.from, + name: x.name, + excel: show(x.excel), + book: show(x.book), + same: x.same ? "같음" : "덮음", + })); + const counts: Row = got.counts || {}; + const lines = Object.entries(PART).map(([k, name]) => { + const c: Row = counts[k] || {}; + const miss: string[] = got.missing?.[k] || []; + return `${name} — 읽음 ${c.read ?? 0} · 이음 ${c.linked ?? 0}(덮음 ${c.covered ?? 0}) · 이 공사 줄 ${ + c.book ?? 0 + } · 짝 없음 ${c.missing ?? 0}${miss.length ? `(${miss.slice(0, 8).join(", ")}${miss.length > 8 ? " …" : ""})` : ""}`; + }); + const columns = [ + text("part", "갈래", 56), + text("from", "엑셀 이름", 200), + text("name", "이 공사 줄", 160), + text("excel", "엑셀 단가", 150), + text("book", "이 공사 단가", 150), + text("same", "", 56), + ]; + return { table, lines, columns }; +} + +function openResult(kind: Kind, ctx: TabContext, got: Row): void { + const view = kind === "rates" ? ratesView(got) : pricesView(got); + openModal({ + title: `${LABEL[kind]} — ${got.name}`, + closeLabel: "닫기", + width: "960px", + mount: (body, close, actions) => { + const apply = createButton({ + label: "적용", + variant: "filled", + size: "sm", + onClick: () => { + ctx.setUser({ ...ctx.user, ...got.patch }); + showToast("적용 — [저장] 해야 남음", "success"); + close(); + }, + }); + const other = createButton({ + label: "다른 파일", + variant: "ghost", + size: "sm", + onClick: () => { + close(); + pick(kind, ctx); + }, + }); + actions.append(other, apply); + const table = createDataTable({ columns: view.columns, rows: view.table, empty: "" }); + body.append(...view.lines.map((t) => el("p", { text: t })), table.root); + }, + }); +} + +const fail = (error: unknown): void => showToast(`불러오지 못함 — ${String(error)}`, "error"); + +function pick(kind: Kind, ctx: TabContext): void { + pickFiles( + { accept: ".xlsx,.xls" }, + ([file]) => void postSheet(kind, ctx, file).then((got) => openResult(kind, ctx, got), fail), + ); +} + +/** 탭 도구 자리 단추 — 불러오기 없는 탭 = 빈 목록 */ +export function importTools(tab: TabId, ctx: TabContext): HTMLElement[] { + const kind = TABS[tab]; + if (!kind) return []; + const button = createButton({ + label: LABEL[kind], + variant: "ghost", + size: "sm", + onClick: () => + last + ? void postSheet(kind, ctx, null).then( + (got) => openResult(kind, ctx, got), + () => pick(kind, ctx), + ) + : pick(kind, ctx), + }); + if (last) button.title = `마지막 파일 ${last.name} 로 · 다른 파일은 창에서`; + return [button]; +} diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index 5f236405b..6786947ae 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -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 { createBillImport } from "./B09_Estimation_UI_Import"; +import { createBillImport, importTools } from "./B09_Estimation_UI_Import"; import { closePopups, openRowPopup } from "./B09_Estimation_UI_Popup"; import { DRAFT, @@ -192,6 +192,7 @@ export function renderB09Estimation(root: HTMLElement): void { const spec = tabTableOf(tab, ctx); if (!spec) return void panel.replaceChildren(note(LATER[tab] ?? "다음 단계에서 채움")); if (typeof spec === "string") return void panel.replaceChildren(note(spec)); + bars.setTools(importTools(tab, ctx)); // 707-2 · 3 엑셀에서 요율 · 단가표 불러오기 // 303 행 누름 = 팝업(기본 · 누른 줄 곁 표 조각) · 하이퍼링크(그 탭 그 줄로) const goto = (link: Link | null, anchor?: Element | null): void => { if (!link) return; diff --git a/M02_MasterTemplete/M02_Labor_Values.ts b/M02_MasterTemplete/M02_Labor_Values.ts index 5df45859a..ac2eb93f9 100644 --- a/M02_MasterTemplete/M02_Labor_Values.ts +++ b/M02_MasterTemplete/M02_Labor_Values.ts @@ -132,10 +132,10 @@ export interface InputsValueHandle { const VALUE_TEXT = { title: "넣을 값", missing: "빠진 로직 {value}", - edit: "넣을 값 수정", + edit: "옵션 고치기", none: "넣을 값이 없음 — 단가산출 로직에 입력이 없음", auto: "자동값", - unconfirmed: "확인 안 함", + unconfirmed: "값 확인 전", confirmed: "✓ 확인함", confirmBox: "확인", summary: "넣을 값 {value}", diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts index b8d8f49f9..478a2b0f7 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts @@ -58,6 +58,7 @@ export const PART_WORD: Record = { const TEXT = { drawingInfo: "도면 정보", edit: "수정", // 카드 셋 같은 글(40-2) — 무엇을 고치는지는 툴팁 + editOptions: "옵션 고치기", noNumber: "도번 없음", noDrawing: "도면 없음", editTip: "{value} 수정 — 전체 편집 화면", @@ -108,6 +109,9 @@ export interface ItemCardsOptions { /** 일위대가 카드(39-5 · 몸 = 일위대가 상세 카드 갈래 `Labor_Detail` · 43-4) — 도면 다음 · 지금 층에서 읽음 · * [일위대가 수정] = 이것(일위대가 상세) */ labor?: () => void; + /** 구조물 조합 고르기 창 · B08 도면 창(707) — 일위대가 카드 [수정] = 넣을 값만 → 글 「옵션 고치기」 · + * 카드 번호(SD · SU · SA) 숨김 */ + picker?: boolean; } /** 카드 스프레드시트를 다루는 손잡이 — 알약이 씀 */ @@ -191,6 +195,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { save: () => Promise, ) => { const no = createBadge("", "accent"); // 번호 = 배지 부품 + no.hidden = !!opts.picker; no.classList.add("m02-struct__no"); const name = el("span", { className: "m02-struct__title" }); const shell = cardShell({ title, body, sub: [no, name] }); @@ -204,7 +209,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { ); const help = helpButton(shell, title, CARD_HELP[kind]); // 제목 바로 옆(47-1) const edit = createButton({ - label: TEXT.edit, + label: kind === "labor" && opts.picker ? TEXT.editOptions : TEXT.edit, variant: "ghost", size: "sm", }); @@ -340,7 +345,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { const defaults: Record = { structure: TEXT.edit, area: TEXT.edit, - labor: TEXT.edit, + labor: opts.picker ? TEXT.editOptions : TEXT.edit, }; const root = el("div", { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts index 4b50c22d6..30cb66a3e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts @@ -56,7 +56,7 @@ export function openLaborInput(opt: LaborInputOptions): void { const draft: LaborValues = { ...kept }; const bad = new Set(); openModal({ - title: fill(tc("In_Title"), opt.title), + title: fill(tc("In_Title"), opt.title.replace(/^[A-Z]{2}\d{6}\s+/, "")), // 707 번호(GF …) 숨김 closeLabel: tc("In_Close"), dialogClass: "m02-modal m02-labin", mount: (body, close, head) => { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts index a92505ab3..2b675b661 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts @@ -40,8 +40,10 @@ import { createText } from "@ui/ui_template_text"; export { withHopyo } from "./M02_MasterTemplete_Hopyo_Pick"; +/** 구분 보이는 글(707 낱말) — 값(저장 글)은 그대로 */ +const CLASS_TEXT: Record = { 실무자료: "실무 엑셀 일위대가" }; const opts = (values: string[]): { value: string; text: string }[] => - [L("M01_All"), ...values].map((v, i) => ({ value: i ? v : "", text: v })); + [L("M01_All"), ...values].map((v, i) => ({ value: i ? v : "", text: CLASS_TEXT[v] ?? v })); type Scope = Omit; /** 덧붙일 줄 하나 — kind = 종류 고르기 값 */ @@ -215,7 +217,11 @@ export function openLogicPicker( key: x.row.이름, tag: tc(x.kind), name: x.row.제목 || x.row.이름, - sub: [x.row.이름, `${x.row.구분 || tc("Lib_Unsorted")} ${x.row.상세구분 ?? ""}`.trim()] + // 707 번호(SC · SP 키) 숨김 — 제목이 없을 때만 이름 자리에 씀 + sub: [ + x.row.제목 ? "" : x.row.이름, + `${CLASS_TEXT[x.row.구분 ?? ""] ?? (x.row.구분 || tc("Lib_Unsorted"))} ${x.row.상세구분 ?? ""}`.trim(), + ] .filter(Boolean) .join(" · "), unit: x.row.단위 ?? "", diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index c453025c4..0d8541919 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -9,7 +9,7 @@ const TEXT = { Title: ["일위대가 조합", "Unit-price combination"], ReadOnly: ["읽기만 — 이 층은 고칠 수 없음", "Read only — this layer cannot be edited"], Sub: ["구분", "Category"], - Detail: ["상세구분", "Detail"], + Detail: ["세부 구분", "Detail"], All: ["전체", "All"], Empty: ["조합이 없음 — [새 조합]으로 만듦", "No combinations — press [New combination]"], New: ["새 조합", "New combination"], @@ -138,7 +138,7 @@ const TEXT = { Mark_Direct: ["직접계산", "Direct"], Mark_In: ["포함", "Included"], Mark_Out: ["미포함", "Excluded"], - In_Title: ["넣을 값 — {value}", "Inputs — {value}"], + In_Title: ["옵션 — {value}", "Inputs — {value}"], In_None: ["이 로직은 넣을 값이 없음", "This logic has no inputs"], In_Memo: ["메모", "Memo"], In_Layer: ["켜쌓기 적용율 (%)", "Layered-course rate (%)"], diff --git a/resources/tester/test_144_b09_base_calc.py b/resources/tester/test_144_b09_base_calc.py index 83df24dc8..293706474 100644 --- a/resources/tester/test_144_b09_base_calc.py +++ b/resources/tester/test_144_b09_base_calc.py @@ -165,6 +165,29 @@ def test_144_4_중기사용료_엑셀_검산(): assert why == {"연료(중유) 단가 없음", "기계 기준가 없음", "손료 기준가 · 계수 없음"} +#: 707-3 노임 · 기계 단가 덮개 — 건설기계운전사 일당 · 불도우저 시간 단가(셋 다) · 빠진 칸 = 버림 +COVER = { + "labor_cover": {"LB000048": 283297, "LB000002": 172068, "없는 키": 1}, + "machine_cover": {"X09999": {"labor": 1, "material": 2, "expense": 3}, + "X00003": {"labor": 1, "material": None, "expense": 3}}, +} # fmt: skip + + +def test_707_3_노임_기계_덮개(): + s = compute_base(INPUTS, COVER) + labor = {r["key"]: r for r in s["labor"]} + assert (labor["LB000048"]["price"], labor["LB000048"]["book"]) == (283297, 267360) + assert labor["LB000002"]["covered"] and "covered" not in labor["LB000049"] + assert "LB000002" not in {g["key"] for g in s["gaps"] if g["tab"] == "labor"} # 덮개 = 빈칸 아님 + hours = {d["key"]: d["hour"] for d in s["basis"]["drivers"]} + assert hours["1048"] == 59020 # 거창 기계경비 운전사 59,020.2 → 59,020 + got = {r["key"]: (r["sum"], r["labor"], r["material"], r["expense"]) for r in s["machine"]} + assert got["X09999"] == (6, 1, 2, 3) and got["X00003"][1] == 59020 # 반쪽 덮개 = 버림 + block = next(b for b in s["machine_blocks"] if b["key"] == "X09999") + assert block["covered"] and block["book"]["sum"] is None + assert "X09999" not in {g["key"] for g in s["gaps"] if g["tab"] == "machine_blocks"} + + def test_거울_TS_가_같은_값(): - for user in ({}, USER): + for user in ({}, USER, COVER): assert _run_ts(INPUTS, user) == compute_base(INPUTS, user) diff --git a/resources/tester/test_145_3_b09_bill.py b/resources/tester/test_145_3_b09_bill.py index f18b74948..86961d72d 100644 --- a/resources/tester/test_145_3_b09_bill.py +++ b/resources/tester/test_145_3_b09_bill.py @@ -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) @@ -248,8 +248,8 @@ def test_거울_블록_나누기(): ) result = subprocess.run( ["node", "--experimental-strip-types", "--no-warnings", - "--input-type=module-typescript", "-e", calc + body], - capture_output=True, text=True, encoding="utf-8", timeout=60, + "--input-type=module-typescript"], # 글은 stdin — 명령 줄 32 K 넘김(Windows) + 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(SPLIT_INPUTS, USER, DAEGA.get) diff --git a/resources/tester/test_166_2_b09_bill_hand.py b/resources/tester/test_166_2_b09_bill_hand.py index 436f12882..ae0be8dbe 100644 --- a/resources/tester/test_166_2_b09_bill_hand.py +++ b/resources/tester/test_166_2_b09_bill_hand.py @@ -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 diff --git a/resources/tester/test_243_1_double_click_tables.py b/resources/tester/test_243_1_double_click_tables.py index 7ea0db851..5a4dc083e 100644 --- a/resources/tester/test_243_1_double_click_tables.py +++ b/resources/tester/test_243_1_double_click_tables.py @@ -112,8 +112,8 @@ def test_B09_표_만드는_길_한_곳_더블클릭_기본(): calls = [ p.name for p in _sources("B09_Estimation") if "createDataTable({" in p.read_text("utf-8") ] - # 303-2 팝업 창 · 707-1 불러오기 어긋난 줄 = 보기 전용(고칠 칸 없음) · 옵션 = 같은 표 짜기(Builders) · - # 702-1 펼침 표 = 제 표(두 번 눌러 고침) + # 303-2 팝업 창 · 707-1 불러오기 어긋난 줄 · 707-2 · 3 확인 창 = 보기 전용(고칠 칸 없음) · + # 옵션 = 같은 표 짜기(Builders) · 702-1 펼침 표 = 제 표(두 번 눌러 고침) assert calls == [ "B09_Estimation_UI_Import.ts", "B09_Estimation_UI_Popup.ts", diff --git a/resources/tester/test_707_b09_import_rates_prices.py b/resources/tester/test_707_b09_import_rates_prices.py new file mode 100644 index 000000000..5b328bc48 --- /dev/null +++ b/resources/tester/test_707_b09_import_rates_prices.py @@ -0,0 +1,174 @@ +"""707-2 원가계산서 요율 · 707-3 기초 단가표 불러오기 — 실무 xlsx 넷(EST 거창 · STmate 영월 · 울진 대흥 · +울진 소광) · 이음 규칙 · 덮개 지킴(새 판 [업데이트] 뒤). + +총액 대조 = 엑셀 비목 합 + 실은 요율 · 손입력으로 우리 식(`compute_total`) → 엑셀 총공사비: + 대흥 = 같음 · 영월 = 「국가지점번호판검증수수료」 762,000 원만 다름(우리 표에 줄 없음) · + 거창 = 퇴직공제부금비(셈 없는 현행 비목)만큼 · 소광 = 산출근거 빈칸 율 넷 · 폐기물처리 줄. +""" + +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_Import_Prices as ip # noqa: E402 +from B09_Estimation import B09_Estimation_Import_Rates as ir # noqa: E402 +from B09_Estimation import B09_Estimation_Store as store # noqa: E402 +from B09_Estimation.B09_Estimation_Calc_Base import compute_base # noqa: E402 +from B09_Estimation.B09_Estimation_Rates import rate_table # noqa: E402 + +K = ROOT / "resources" / "knowledge" / "original" / "실무문서" +FILES = { + "거창": "01.산불거창(거창북상병곡)/01.내역서/00.25년_산불_거창(거창,북상,병곡,산8외)내역+수량.xlsx", + "영월": "05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.xlsx", + "대흥": "2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/01 내역자료/2024년 간선임도사업(기번3).xlsx", + "소광": "25년 산불진화임도(기번8)(울진소광)/" + "2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외).xlsx", +} + + +def _file(tag): + path = K / FILES[tag] + if not path.is_file(): + pytest.skip(f"실무 xlsx 없음 — {tag}") + return path.name, path.read_bytes() + + +@pytest.fixture(scope="module") +def rates(): + return {tag: ir.read_rates(*_file(tag)) for tag in FILES} + + +def test_707_2_요율_짝(rates): + dh = rates["대흥"] + assert dh["kind"] == "STmate" and rates["거창"]["kind"] == "EST" + assert len(dh["rates"]) == 13 # 율 줄 10 + 일반관리비 · 이윤 · 부가세 + assert (dh["rates"]["indirect_labor"], dh["rates"]["safety"], dh["rates"]["profit"]) == ( + 14.5, + 1.86, + 15.0, + ) + assert dh["estimate"]["safety_base"] == 5349000 # 「x1.86% + 5,349,000」 + assert dh["estimate"]["procurement_fee_pct"] == 0.54 # 375,160 ÷ 69,474,220 + gc = rates["거창"] # EST 요율 칸 · 건강보험 글 「13.595%」 대신 칸 3.595 + assert (gc["rates"]["health"], gc["rates"]["care"], gc["rates"]["retirement"]) == ( + 3.595, + 13.14, + 2.3, + ) + assert gc["estimate"]["safety_base"] == 0 # 글에 + 기초액 없음 + assert any("퇴직공제부금비" in n and "셈 없음" in n for n in gc["notes"]) + sk = rates["소광"] + assert sum("율 없음" in n for n in sk["notes"]) == 4 # 산출근거 빈칸 — 지금 율 그대로 + assert any("페기물처리" in n for n in sk["notes"]) + + +def test_707_2_총액_대조(rates): + got = {tag: ir.check(r, {}, rate_table()) for tag, r in rates.items()} + assert got["대흥"]["grand"] == got["대흥"]["excel_grand"] == 1201879000 + assert got["대흥"]["differ"] == [] + assert got["영월"]["excel_grand"] - got["영월"]["grand"] == 762000 # 국가지점번호판 줄 몫 + assert set(got["영월"]["differ"]) == {"도급공사비", "총공사비"} + lines = {ln["key"]: ln for ln in got["거창"]["lines"]} + rate_keys = ("indirect_labor", "accident", "employment", "health", "care", "pension") + assert all(lines[k]["same"] for k in (*rate_keys, "safety", "other", "environment")) + assert not lines["overhead"]["same"] # 퇴직공제부금비 5,603,907 이 순공사원가에 없음 + assert lines["overhead"]["excel"] - lines["overhead"]["ours"] == 448312 # = 5,603,907 × 8 % + + +def test_707_3_단가표_읽기(): + counts = {} + for tag in FILES: + got = ip.read_prices(*_file(tag)) + counts[tag] = tuple(len(got[k]) for k in ip.KINDS) + assert counts == {"거창": (17, 52, 22), "영월": (16, 55, 29), "대흥": (12, 39, 25), + "소광": (14, 35, 24)} # fmt: skip + + +BASE = { + "labor": [ + {"key": "LB1", "name": "석공", "spec": "", "unit": "인", "price": 268908}, + {"key": "LB2", "name": "보통인부", "spec": "", "unit": "인", "price": 172068}, + ], + "machine": [ + { + "key": "EQ1", + "name": "굴착기(무한궤도)", + "spec": "0.7㎥", + "labor": 1, + "material": 2, + "expense": 3, + }, + { + "key": "EQ2", + "name": "굴착기(무한궤도)", + "spec": "0.2㎥", + "labor": 1, + "material": 2, + "expense": 3, + }, + ], # fmt: skip + "compare": [ + {"key": "MO1", "name": "휘발유", "spec": "전국평균", "unit": "L", "applied": 1863.25}, + {"key": "MT1", "name": "레미콘", "spec": "25-18-80", "unit": "㎥", "applied": 92330}, + ], +} + + +def _m(name, spec, n): + return {"name": name, "spec": spec, "unit": "hr", "labor": n, "material": n, "expense": n} + + +READ = { + "labor": [ + {"name": "석 공", "spec": "", "unit": "인", "price": 258935}, + {"name": "보통인부", "spec": "", "unit": "인", "price": 172068}, # 책과 같음 + {"name": "벌 목 부", "spec": "", "unit": "인", "price": 253243}, + ], + "machine": [ + _m("굴삭기(무한궤도)", "0.7㎥", 10), + _m("굴삭기(타이어형)", "0.7㎥", 11), # 타이어 = 다른 기계 + _m("굴삭기+브레이카", "0.7㎥", 12), + _m("굴삭기(무한궤도)", "0.2㎥", 13), + _m("굴삭기(무한궤도)", "0.2㎥", 14), # 같은 책 줄에 둘 = 못 고름 + ], + "material": [ + {"name": "휘 발 유", "spec": "", "unit": "ℓ", "price": 1473}, # 이름 · 단위(ℓ = L) + {"name": "레미콘(관급)", "spec": "25-18-80", "unit": "m3", "price": 92330}, + ], +} + + +def test_707_3_이음_규칙(): + user = {"labor_cover": {"LB2": 1}, "materials": {"MO1": {"pick": "유가", "supply": "관급"}}} + out = ip.propose(READ, BASE, user) + patch = out["patch"] + assert patch["labor_cover"] == {"LB1": 258935} # 같은 값 = 옛 덮개 걷음 + assert patch["machine_cover"] == {"EQ1": {"labor": 10, "material": 10, "expense": 10}} + assert patch["materials"]["MO1"] == {"supply": "관급", "applied": 1473} # pick 뺌 + assert "MT1" not in patch["materials"] # 「(관급)」 이름 다름 = 짝 없음 + assert "굴삭기(무한궤도) 0.2㎥(짝 여럿)" in out["missing"]["machine"] + assert out["counts"]["machine"] == {"read": 5, "linked": 1, "covered": 1, "missing": 4, + "book": 2} # fmt: skip + assert out["counts"]["labor"]["linked"] == 2 and out["missing"]["labor"] == ["벌 목 부"] + + +def test_707_3_덮개_새_판_뒤_남음(tmp_path): + """[업데이트] = 단가 책만 새로 굳힘 — 사용자 값의 노임 · 기계 덮개는 그대로 · 셈은 덮개.""" + cover = {"labor_cover": {"LB1": 258935, "": None}, + "machine_cover": {"EQ1": {"labor": 10, "material": 10, "expense": 10}, + "EQ2": {"labor": 1}}} # fmt: skip + saved = store.write_user(tmp_path, cover) + assert saved["labor_cover"] == {"LB1": 258935} # 빈 값 버림 + assert saved["machine_cover"] == {"EQ1": {"labor": 10, "material": 10, "expense": 10}} + for price in (268908, 280000): # 옛 판 → 새 판 책 + store.write_book(tmp_path, {"rows": {"LB1": {"key": "LB1", "price": price}}}) + user = store.read_user(tmp_path) + assert user["labor_cover"] == saved["labor_cover"] + inputs = {"labor": [{"key": "LB1", "name": "석공", "price": price}]} + row = compute_base(inputs, user)["labor"][0] + assert (row["price"], row["book"], row["covered"]) == (258935, price, True) diff --git a/ui_template/sheet/ui_template_sheet_text.ts b/ui_template/sheet/ui_template_sheet_text.ts index 1c6966dea..3532bc060 100644 --- a/ui_template/sheet/ui_template_sheet_text.ts +++ b/ui_template/sheet/ui_template_sheet_text.ts @@ -11,7 +11,7 @@ const TEXT = { Col_New: ["새 열", "New column"], Row_Unit: ["단위", "Unit"], Row_Unit_Price: ["일위대가", "Unit price"], - Row_LaborSum: ["일위대가 합계", "Unit price total"], + Row_LaborSum: ["일위대가 금액", "Unit price total"], Row_Drawing: ["도면", "Drawing"], Row_Area: ["면적산출", "Area"], Add_Suffix: [" 추가", " +"], diff --git a/ui_template/ui_template_item_picker_detail.ts b/ui_template/ui_template_item_picker_detail.ts index d673624fb..ada74e3d8 100644 --- a/ui_template/ui_template_item_picker_detail.ts +++ b/ui_template/ui_template_item_picker_detail.ts @@ -75,7 +75,7 @@ export interface ItemDetail { } const TEXT = { - importEdit: "이 구조물로 가져와 고침", + importEdit: "이 구조물에서 고치기", noStructure: "구조물을 먼저 추가하면 이 항목을 그 구조물로 가져와 고칠 수 있음 — 지금은 원본을 보고 있음", notImported: "이 구조물에는 아직 안 가져옴 — 원본을 보고 있음", @@ -327,6 +327,7 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { onEdit: (kind) => void edit(kind), warnOnFail: () => false, labor: () => void edit("inputs"), + picker: true, }); cardsHost.replaceChildren(cards.root); } diff --git a/ui_template/ui_template_item_picker_edit.ts b/ui_template/ui_template_item_picker_edit.ts index c39f4385b..1d9fc1da5 100644 --- a/ui_template/ui_template_item_picker_edit.ts +++ b/ui_template/ui_template_item_picker_edit.ts @@ -39,7 +39,7 @@ const TEXT = { loadFailed: "복사본을 못 읽음 — {value}", discard: "저장 안 한 고침이 있음 — 버리고 돌아갈까요?", outOfRange: "범위 밖 값이 있어 저장하지 못함", - noAreaPill: "면적산출 알약 없음 — 이 복사본 일위대가에 단가산출 로직 · 자재가 없음", + noAreaPill: "면적산출 연결 칸 없음 — 이 복사본 일위대가에 단가산출 로직 · 자재가 없음", drawing: "도면", area: "면적산출", inputs: "넣을 값", diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index aa695e38e..3a5811a1d 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -88,9 +88,9 @@ export const ui_locales_m2 = { M02_PickerNone: ["없음", "None"], M02_PickerApply: ["적용", "Apply"], M02_PickerClear: ["해제", "Clear"], - M02_PickerCopy: ["개인용 복사", "Copy to personal"], + M02_PickerCopy: ["내 자료집에 복사", "Copy to personal"], M02_PickerManage: ["관리 화면 편집", "Edit in manager"], - M02_PickerCopied: ["개인용으로 복사했음", "Copied to personal"], + M02_PickerCopied: ["내 자료집에 복사했음", "Copied to personal"], M02_PickerCopyFailed: ["복사 못 함 — {value}", "Copy failed — {value}"], M02_TreeTitle: ["구조물 라이브러리", "Structure library"], M02_RootMine: ["내 것", "Mine"],