From 193ce9280662f9e953a74ecd2f77f28907b02c67 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 15 Nov 2026 16:02:11 +0900 Subject: [PATCH] =?UTF-8?q?refactor(841-1):=20B09=20=EB=8B=A8=EA=B0=80=20?= =?UTF-8?q?=EC=B1=85=20=C2=B7=20=EB=B0=94=ED=83=95=20=EC=85=88=20=EA=B1=B7?= =?UTF-8?q?=EC=9D=8C=20=E2=80=94=20B=20=EA=B1=B8=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 단가 책 = 자재만(자재단가대비표 · 4 자재대 · 관급자재대) · 노무 · 기계 · 운전사 계수(CC000336 세 줄) · 연료 · 환율 줄 걷음 - 바탕 셈(py · ts 짝) 운전사 시간 노임 · 중기사용료(손료 · 운전사 · 연료 · 잡품 재현) · 경비목록표 · 노임 · 기계 덮개 걷음 - 노무비목록표 · 중기목록표 = 대가 · 산근 줄 엔진 단가(프로젝트 사본 M02 값) 나열 · 다시 안 셈(price_lists) - 경비목록표 · 중기사용료 · 환율및기초자료 탭 = 「—」 한 줄 + 까닭 한 줄(탭은 둠) - [단가 갱신] = 프로젝트 사본 자원 단가 줄(단가.json LB · MT · EQ · MO · FX)을 지금 M01 로 + 자재 단가 책(새 파일 B09_Estimation_Copy_Prices.py · 사용자가 누를 때만) · 미리 보기 = 덮개로 같은 셈(파일 안 씀) - 산근 · 대가 단가 고친 칸 걷음(품만) · 단가표 불러오기(707-3) 노임 · 기계 덮개 걷음(자재만) - 낡은 시험은 C 걸음에서 정리 Co-Authored-By: Claude Opus 5.5 --- B09_Estimation/B09_Estimation_Calc_Base.py | 203 ++---------------- B09_Estimation/B09_Estimation_Calc_Base.ts | 189 ++-------------- B09_Estimation/B09_Estimation_Copy_Prices.py | 92 ++++++++ .../B09_Estimation_Import_Prices.py | 102 ++------- B09_Estimation/B09_Estimation_Labor_Source.py | 7 +- B09_Estimation/B09_Estimation_Linked.py | 9 +- B09_Estimation/B09_Estimation_Prices.py | 151 ++----------- B09_Estimation/B09_Estimation_Refresh.py | 26 +-- B09_Estimation/B09_Estimation_Router.py | 25 ++- .../B09_Estimation_Router_Import.py | 5 +- B09_Estimation/B09_Estimation_Sangeun.py | 7 +- .../B09_Estimation_Sangeun_Cover.py | 22 +- .../B09_Estimation_Sangeun_Cover.ts | 12 +- B09_Estimation/B09_Estimation_Store.py | 16 +- B09_Estimation/B09_Estimation_Supply.py | 26 ++- B09_Estimation/B09_Estimation_UI_Check.ts | 9 +- B09_Estimation/B09_Estimation_UI_Import.ts | 10 +- B09_Estimation/B09_Estimation_UI_Tabs_Base.ts | 171 ++++----------- .../B09_Estimation_UI_Tabs_Bill_Fold.ts | 13 +- 19 files changed, 300 insertions(+), 795 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_Copy_Prices.py diff --git a/B09_Estimation/B09_Estimation_Calc_Base.py b/B09_Estimation/B09_Estimation_Calc_Base.py index 7f2158576..664cb590a 100644 --- a/B09_Estimation/B09_Estimation_Calc_Base.py +++ b/B09_Estimation/B09_Estimation_Calc_Base.py @@ -5,26 +5,20 @@ 조작 중은 화면 짝이 바로 셈 · [저장] 때 서버가 이 파일로 정본을 다시 셈. 실무 엑셀(기번3 공통 xlsx) 탭 꼴 · 끝수 = 엑셀 자리 그대로: - 노무비목록표 · 경비목록표 = 굳힌 단가 그대로(사용자 고침 없음 · [단가 갱신] 때만 새로) - 환율및기초자료 = 환율 · 운전사 시간 노임 = ROUNDDOWN(일당 × 분자 ÷ 분모, 0) - (분자 · 분모 = M01 CC000336 1/8 × 16/12 × 25/20 — 서버가 넣어 줌) + 노무비목록표 · 중기목록표 = 대가 · 산근 줄 엔진 단가(사본 값 · 서버가 `inputs.labor` · + `inputs.machines` 로 넣어 줌 · `B09_Estimation_Supply.price_lists`) 나열 — 다시 안 셈(841-1) + 경비목록표 · 중기사용료 · 환율및기초자료 = 걷음(841-1 · B09 가 기계 시간당 · 운전사 노임을 + 다시 셈하던 곳 · 값 = M02 단가산출 그대로) — 빈 표 자재단가대비표 = 출처 단가 중 최저(0 · 빈칸 뺌 · 견적 포함 = 엑셀 MIN) 자동 · 적용 차례 = 고른 출처(pick · 견적이 높아도) › 사용자 적용 단가 › 최저 · 견적 = 사용자 손입력(quote · quote_page · 견적서 파일은 `B09_Estimation_Router_Quotes`) - · 관급 · 별도계상 = 사용자 표시(없으면 입력 줄 기본 · B08 파형강관 = 관급) + · 관급 · 별도계상 = 사용자 표시(없으면 입력 줄 기본 · 로직 자재 = M02 자재표시 · B08 파형강관 = 관급) → 재료비목록표 단가 = 적용 단가 재료비목록표 = 수량 × 적용 단가 = 총액 ROUND(·, 0) (엑셀 재료비수량금액집계표 G 열 자리 · 수량 · 단가 하나라도 빈칸이면 빈칸) · 수량 = 입력 `material_qty`(183-1 · 서버 1차 셈 역전개 총수량 = 재료비수량금액집계표 수량 · 없는 키 빈칸) · `material_qty` 가 없으면 입력 줄 수량(B08 총괄 합계 열) - 중기사용료 = 손료(경비 · 기준가 천원 × 손료계수 ÷ 10,000) + 운전사(노무 · 인 × 시간 노임) - + 연료(재료 · ℓ/hr × 적용 단가) + 잡품(재료 · 연료 금액 × % ÷ 100) - 줄 = ROUNDDOWN(단가 × 수량, 1) · 비목 합 = ROUNDDOWN(Σ, 0) → 중기목록표 -노임 · 기계 단가 덮개(707-3) = 사용자 값 `labor_cover` {노무 키: 일당} · `machine_cover` - {기계 키: {labor, material, expense}}(셋 다 수일 때만) — 굳힌 값 대신 · - 줄 `covered` · `book`(굳힌 값) · - 덮은 기계 = 중기사용료 줄은 그대로 · 비목 합 · 합 = 덮개 · 그 기계 「연결 없음」 뺌 빈칸 규칙 = 값을 낸 원천이 없으면 None(0 을 채우지 않음) · 그 칸을 쓰는 합도 None · 「연결 없음」 목록(`gaps`)에 탭 · 줄 키 · 까닭을 남김(목차 탭이 이동 링크로 씀). """ @@ -93,22 +87,10 @@ def gap(tab: str, key: str, name: str, why: str) -> dict[str, str]: return {"tab": tab, "key": key, "name": name, "why": why} -def driver_hour(day: float | None, factor: dict[str, Any] | None) -> float | None: - """운전사 시간 노임 = ROUNDDOWN(일당 × 분자 ÷ 분모, 0).""" - if day is None or not factor or not factor.get("den"): - return None - return rounddown(day * factor["num"] / factor["den"], 0) - - -def labor_sheet( - labor: list[dict[str, Any]], gaps: list, cover: dict[str, Any] | None = None -) -> list[dict[str, Any]]: +def labor_sheet(labor: list[dict[str, Any]], gaps: list) -> 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( @@ -117,22 +99,11 @@ def labor_sheet( "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, @@ -143,17 +114,6 @@ def _head(row: dict[str, Any], no: int) -> dict[str, Any]: } -def expense_sheet(machines: list[dict[str, Any]], gaps: list) -> list[dict[str, Any]]: - """경비목록표 — 기계 손료 기준가(천원).""" - rows = [] - for no, m in enumerate(machines, 1): - price = num(m.get("price")) - if price is None: - gaps.append(gap("expense", m["key"], m["name"], "기계 기준가 없음")) - rows.append({**_head(m, no), "unit": "천원", "price": price}) - return rows - - def lowest(prices: dict[str, Any]) -> tuple[float | None, str | None]: """출처 단가 중 최저 — 0 · 빈칸 뺌 · 같으면 앞 칸.""" best: tuple[float | None, str | None] = (None, None) @@ -219,155 +179,22 @@ def material_sheet( return rows -def _line(name, spec, qty, unit, kind, price, ref, pct=False) -> dict[str, Any]: - if price is None or qty is None: - amount = None - elif pct: - amount = rounddown(price * qty / 100, 1) - else: - amount = rounddown(price * qty, 1) - return { - "name": name, - "spec": spec, - "qty": qty, - "unit": unit, - "kind": kind, - "price": price, - "amount": amount, - "ref": ref, - } - - -def machine_block( - m: dict[str, Any], - drivers: dict[str, dict[str, Any]], - fuels: dict[str, dict[str, Any]], - gaps: list, -) -> dict[str, Any]: - """중기사용료 한 블록 — 손료 · 운전사 · 연료 · 잡품 줄 + 비목 합.""" - lines = [] - loss = num(m.get("loss")) - coef = loss / 10000 if loss is not None else None - price = num(m.get("price")) - lines.append(_line(m["name"], m.get("spec") or "", coef, "천원", "expense", price, - {"tab": "expense", "key": m["key"]})) # fmt: skip - crew = num(m.get("crew")) - if crew: - code = str(m.get("crew_code") or "") - driver = drivers.get(code) or {} - hour = driver.get("hour") - if hour is None: - gaps.append(gap("machine_blocks", m["key"], m["name"], "운전사 노임 없음")) - lines.append(_line(driver.get("name") or "운전사", "", crew, "인", "labor", hour, - {"tab": "basis", "key": code})) # fmt: skip - lph = num(m.get("fuel_lph")) - if lph: - kind = str(m.get("fuel") or "") - fuel = fuels.get(kind) or {} - fprice = fuel.get("applied") - if fprice is None: - gaps.append(gap("machine_blocks", m["key"], m["name"], f"연료({kind}) 단가 없음")) - fuel_line = _line(fuel.get("name") or kind, fuel.get("spec") or "", lph, "ℓ", "material", - fprice, {"tab": "compare", "key": fuel.get("key") or ""}) # fmt: skip - lines.append(fuel_line) - misc = num(m.get("misc_pct")) - if misc: - lines.append(_line("잡품", "주연료의 %", misc, "%", "material", fuel_line["amount"], - None, pct=True)) # fmt: skip - if price is None or coef is None: - gaps.append(gap("machine_blocks", m["key"], m["name"], "손료 기준가 · 계수 없음")) - sums: dict[str, float | None] = {} - for part in ("labor", "material", "expense"): - s = total([ln["amount"] for ln in lines if ln["kind"] == part]) - sums[part] = None if s is None else rounddown(s, 0) - return { - "key": m["key"], - "name": m["name"], - "spec": m.get("spec") or "", - "unit": "시간", - "lines": lines, - "labor": sums["labor"], - "material": sums["material"], - "expense": sums["expense"], - "sum": total([sums["labor"], sums["material"], sums["expense"]]), - } - - -def basis_sheet( - inputs: dict[str, Any], labor_rows: list[dict[str, Any]] -) -> dict[str, list[dict[str, Any]]]: - """환율및기초자료 — 환율 · 운전사 시간 노임(기계가 부르는 직종만).""" - factor = inputs.get("driver_factor") - by_code = {str(row.get("code")): row for row in labor_rows if row.get("code") is not None} - codes: list[str] = [] - for m in inputs.get("machines") or []: - code = str(m.get("crew_code")) if m.get("crew_code") else None - if code and num(m.get("crew")) and code not in codes: - codes.append(code) - drivers = [] - for code in codes: - row = by_code.get(code) or {} - day = row.get("price") - drivers.append( - { - "key": code, - "labor_key": row.get("key"), - "name": row.get("name") or f"직종 {code}", - "day": day, - "factor": (factor or {}).get("text") or "", - "hour": driver_hour(day, factor), - } - ) - fx = [ - {"key": f["key"], "name": f.get("name") or "", "unit": f.get("unit") or "", - "value": num(f.get("value")), "date": f.get("date") or ""} - for f in inputs.get("fx") or [] - ] # fmt: skip - return {"fx": fx, "drivers": drivers} - - def compute_base(inputs: dict[str, Any], user: dict[str, Any]) -> dict[str, Any]: - """144 탭 일곱 + 「연결 없음」 목록.""" + """144 탭 + 「연결 없음」 목록 — 841-1 경비목록표 · 중기사용료 · 환율및기초자료 = 빈 표.""" gaps: list[dict[str, str]] = [] - 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) - drivers = {d["key"]: d for d in basis["drivers"]} - by_key = {row["key"]: row for row in compare} - fuels = { - 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, - "key": b["key"], - "name": b["name"], - "spec": b["spec"], - "unit": b["unit"], - "sum": b["sum"], - "labor": b["labor"], - "material": b["material"], - "expense": b["expense"], - **({"covered": True} if b.get("covered") else {}), - } - for no, b in enumerate(blocks, 1) - ] + {**_head(m, no), "unit": "시간", **{p: num(m.get(p)) for p in _PARTS}, + "sum": total([num(m.get(p)) for p in _PARTS])} + for no, m in enumerate(inputs.get("machines") or [], 1) + ] # fmt: skip return { - "labor": labor_rows, - "expense": expense, - "basis": basis, + "labor": labor_sheet(inputs.get("labor") or [], gaps), + "expense": [], + "basis": {"fx": [], "drivers": []}, "compare": compare, "material": material_sheet(compare, inputs.get("material_qty")), - "machine_blocks": blocks, + "machine_blocks": [], "machine": machine, "gaps": gaps, } diff --git a/B09_Estimation/B09_Estimation_Calc_Base.ts b/B09_Estimation/B09_Estimation_Calc_Base.ts index ba951e0f0..2340b1d64 100644 --- a/B09_Estimation/B09_Estimation_Calc_Base.ts +++ b/B09_Estimation/B09_Estimation_Calc_Base.ts @@ -6,15 +6,12 @@ * 거울 시험 `resources/tester/test_144_b09_base_calc.py`. * 조작 중은 이 파일이 바로 셈 · [저장] 때 서버가 파이썬 짝으로 정본을 다시 셈. * - * 노무비 · 경비목록표 = 굳힌 단가 · 환율및기초자료 = 운전사 시간 노임 ROUNDDOWN(일당 × 분자 ÷ 분모, 0) + * 노무비목록표 · 중기목록표 = 대가 · 산근 줄 엔진 단가(사본 값 · 서버가 inputs.labor · machines 로 줌) 나열 · + * 경비목록표 · 중기사용료 · 환율및기초자료 = 걷음(841-1 · 값 = M02 단가산출 그대로) — 빈 표 * 자재단가대비표 = 출처 중 최저 자동(견적 포함) · 적용 = 고른 출처 › 사용자 단가 › 최저 · * 관급 · 별도 표시 → 재료비목록표 * 재료비목록표 = 수량 × 적용 단가 = 총액 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)에 남김 * ========================================================================== */ @@ -44,7 +41,7 @@ export const SOURCES = [ /** 자재 줄 사용자 표시 */ export const SUPPLY = ["관급", "별도"] as const; -const COVER_PARTS = ["labor", "material", "expense"] as const; +const PARTS = ["labor", "material", "expense"] as const; function scaled(value: number, places: number, fn: (x: number) => number): number { const a = Math.abs(value); @@ -92,11 +89,6 @@ export const gap = (tab: string, key: string, name: string, why: string): Gap => why, }); -export function driverHour(day: Num, factor: Row | null | undefined): Num { - if (day === null || !factor || !factor.den) return null; - return rounddown((day * factor.num) / factor.den, 0); -} - const head = (row: Row, no: number): Row => ({ no, key: row.key, @@ -105,33 +97,11 @@ const head = (row: Row, no: number): Row => ({ source: row.source || "", }); -function laborSheet(labor: Row[], gaps: Gap[], cover?: Row): Row[] { +function laborSheet(labor: Row[], gaps: Gap[]): Row[] { return labor.map((row, i) => { - const book = num(row.price); - const mine = num((cover || {})[row.key]); - const price = mine === null ? book : mine; + const price = num(row.price); if (price === null) gaps.push(gap("labor", row.key, row.name, "노임 단가 없음")); - 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); - if (price === null) gaps.push(gap("expense", m.key, m.name, "기계 기준가 없음")); - return { ...head(m, i + 1), unit: "천원", price }; + return { ...head(row, i + 1), code: row.code ?? null, unit: row.unit || "인", price }; }); } @@ -192,148 +162,21 @@ function materialSheet(compare: Row[], qtyOf?: Row | null): Row[] { }); } -function line( - name: string, - spec: string, - qty: Num, - unit: string, - kind: string, - price: Num, - ref: Row | null, - pct = false, -): Row { - let amount: Num; - if (price === null || qty === null) amount = null; - else if (pct) amount = rounddown((price * qty) / 100, 1); - else amount = rounddown(price * qty, 1); - return { name, spec, qty, unit, kind, price, amount, ref }; -} - -function machineBlock(m: Row, drivers: Row, fuels: Row, gaps: Gap[]): Row { - const lines: Row[] = []; - const loss = num(m.loss); - const coef = loss !== null ? loss / 10000 : null; - const price = num(m.price); - lines.push( - line(m.name, m.spec || "", coef, "천원", "expense", price, { tab: "expense", key: m.key }), - ); - const crew = num(m.crew); - if (crew) { - const code = String(m.crew_code || ""); - const driver: Row = drivers[code] || {}; - const hour: Num = driver.hour ?? null; - if (hour === null) gaps.push(gap("machine_blocks", m.key, m.name, "운전사 노임 없음")); - lines.push( - line(driver.name || "운전사", "", crew, "인", "labor", hour, { tab: "basis", key: code }), - ); - } - const lph = num(m.fuel_lph); - if (lph) { - const kind = String(m.fuel || ""); - const fuel: Row = fuels[kind] || {}; - const fprice: Num = fuel.applied ?? null; - if (fprice === null) gaps.push(gap("machine_blocks", m.key, m.name, `연료(${kind}) 단가 없음`)); - const fuelLine = line(fuel.name || kind, fuel.spec || "", lph, "ℓ", "material", fprice, { - tab: "compare", - key: fuel.key || "", - }); - lines.push(fuelLine); - const misc = num(m.misc_pct); - if (misc) - lines.push(line("잡품", "주연료의 %", misc, "%", "material", fuelLine.amount, null, true)); - } - if (price === null || coef === null) - gaps.push(gap("machine_blocks", m.key, m.name, "손료 기준가 · 계수 없음")); - const sums: Record = {}; - for (const part of ["labor", "material", "expense"]) { - const s = total(lines.filter((ln) => ln.kind === part).map((ln) => ln.amount)); - sums[part] = s === null ? null : rounddown(s, 0); - } - return { - key: m.key, - name: m.name, - spec: m.spec || "", - unit: "시간", - lines, - labor: sums.labor, - material: sums.material, - expense: sums.expense, - sum: total([sums.labor, sums.material, sums.expense]), - }; -} - -function basisSheet(inputs: Row, laborRows: Row[]): Row { - const factor = inputs.driver_factor; - const byCode: Row = {}; - for (const row of laborRows) - if (row.code !== null && row.code !== undefined) byCode[String(row.code)] = row; - const codes: string[] = []; - for (const m of inputs.machines || []) { - const code = m.crew_code ? String(m.crew_code) : null; - if (code && num(m.crew) && !codes.includes(code)) codes.push(code); - } - const drivers = codes.map((code) => { - const row: Row = byCode[code] || {}; - const day: Num = row.price ?? null; - return { - key: code, - labor_key: row.key ?? null, - name: row.name || `직종 ${code}`, - day, - factor: (factor || {}).text || "", - hour: driverHour(day, factor), - }; - }); - const fx = (inputs.fx || []).map((f: Row) => ({ - key: f.key, - name: f.name || "", - unit: f.unit || "", - value: num(f.value), - date: f.date || "", - })); - return { fx, drivers }; -} - -/** 144 탭 일곱 + 「연결 없음」 목록 */ +/** 144 탭 + 「연결 없음」 목록 — 841-1 경비목록표 · 중기사용료 · 환율및기초자료 = 빈 표(짝 `compute_base`) */ export function computeBase(inputs: Row, user: Row): Row { - let gaps: Gap[] = []; - const laborRows = laborSheet(inputs.labor || [], gaps, user.labor_cover); - const expense = expenseSheet(inputs.machines || [], gaps); + const gaps: Gap[] = []; const compare = compareSheet(inputs.materials || [], user.materials || {}, gaps); - const basis = basisSheet(inputs, laborRows); - const drivers: Row = Object.fromEntries(basis.drivers.map((d: Row) => [d.key, d])); - const byKey: Row = Object.fromEntries(compare.map((row) => [row.key, row])); - const fuels: 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, - name: b.name, - spec: b.spec, - unit: b.unit, - sum: b.sum, - labor: b.labor, - material: b.material, - expense: b.expense, - ...(b.covered ? { covered: true } : {}), - })); + const machine = (inputs.machines || []).map((m: Row, i: number) => { + const parts: Row = Object.fromEntries(PARTS.map((p) => [p, num(m[p])])); + return { ...head(m, i + 1), unit: "시간", ...parts, sum: total(PARTS.map((p) => parts[p])) }; + }); return { - labor: laborRows, - expense, - basis, + labor: laborSheet(inputs.labor || [], gaps), + expense: [], + basis: { fx: [], drivers: [] }, compare, material: materialSheet(compare, inputs.material_qty), - machine_blocks: blocks, + machine_blocks: [], machine, gaps, }; diff --git a/B09_Estimation/B09_Estimation_Copy_Prices.py b/B09_Estimation/B09_Estimation_Copy_Prices.py new file mode 100644 index 000000000..725490cb4 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Copy_Prices.py @@ -0,0 +1,92 @@ +"""B09 [단가 갱신] = 프로젝트 사본 자원 단가 줄 갱신(PLAN 841-1 · 요구 854 · 866) — 서버 단독. + +B09 금액 = 프로젝트 사본(구조물 사본 · 서고 사본)을 M02 엔진으로 셈한 값(B09 단가 책으로 다시 안 셈). +그래서 사본 값을 지금 M01 로 올리는 일 = 사본 `단가.json` 자원 줄(LB · MT · EQ · MO · FX)만 지금 M01 줄로 +(로직 줄 · 일위대가 · 입력값 · 고른 자재 · 덮개 그대로 · M01 에서 지워진 키 = 마지막 값 그대로) — +M02 [단가 갱신](`M02_Sangeun_Copy.refresh`)과 같은 뜻 · 사용자가 누를 때만(프로젝트 안은 자동 아님). +미리 보기 = `OVER` 덮개(파일 안 씀 · 사본 읽는 곳이 `bundle` 로 읽음)로 같은 셈. +""" + +from __future__ import annotations + +from contextvars import ContextVar +from pathlib import Path +from typing import Any + +from B09_Estimation import B09_Estimation_Bill_Library as library +from common_util.common_util_json import atomic_write_json +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Item_Prices as item_prices +from M02_MasterTemplete import M02_Sangeun_Copy as skc +from M02_MasterTemplete.M02_Item_Snapshot import linked_snapshots, structures_dir +from M02_MasterTemplete.M02_Item_Source import PRICE_FILE, read_json + +#: 미리 보기 덮개 {사본 폴더 글: 새 `단가.json`} — 없으면 파일 +OVER: ContextVar[dict[str, dict] | None] = ContextVar("b09_copy_prices", default=None) + + +def bundle(folder: Path) -> dict[str, Any]: + """사본 `단가.json` — 미리 보기 덮개가 있으면 그것.""" + over = OVER.get() + if over and str(folder) in over: + return over[str(folder)] + return read_json(folder / PRICE_FILE) or {} + + +def folders(root: str | Path) -> list[Path]: + """연결된 구조물 사본 + 서고 사본 폴더.""" + base = structures_dir(root) + out = [base / one["폴더"] for one in linked_snapshots(root).values()] + out += [folder for folder, _manifest in library.copies(root)] + return list(dict.fromkeys(out)) + + +def _shown(row: dict[str, Any]) -> Any: + """미리 보기 값 — 노임 · 유가 = 값 · 기계 = 가격(천원) · 자재 = 출처 값 묶음.""" + value = row.get("값") + if isinstance(value, dict): + return value.get("가격") + if value is not None: + return value + return {k: row.get(k) for k in ("조달청", "물가자료", "유통물가", "물가정보", "거래가격")} + + +def refreshed(root: str | Path) -> tuple[dict[str, dict], list[dict[str, Any]]]: + """({바뀐 사본 폴더 글: 새 `단가.json`}, 바뀐 자원 [{키, 이름, 전, 후}]) — 파일 안 씀.""" + whole = skc.whole_() + news: dict[str, dict] = {} + changed: dict[str, dict[str, Any]] = {} + for folder in folders(root): + data = read_json(folder / PRICE_FILE) + if not isinstance(data, dict): + continue + out, hit = {}, False + for name, part in data.items(): + slot = m01.items_key(part) if isinstance(part, dict) else "" + rows = part.get(slot) if slot else None + if not isinstance(rows, list): + out[name] = part + continue + fresh = [] + for row in rows: + key = str((row or {}).get("키") or "") + live = whole.index.get(key[:2], {}).get(key) if key[:2] in skc.RES else None + new = item_prices.to_json(live) if live is not None else row + if new != row: + hit = True + changed.setdefault( + key, + {"키": key, "이름": new.get("이름"), "전": _shown(row), "후": _shown(new)}, + ) + fresh.append(new) + out[name] = {**part, slot: fresh} + if hit: + news[str(folder)] = out + return news, list(changed.values()) + + +def write(news: dict[str, dict]) -> int: + """새 `단가.json` 을 사본 폴더에 씀 — 쓴 사본 수.""" + for folder, data in news.items(): + atomic_write_json(Path(folder) / PRICE_FILE, data) + return len(news) diff --git a/B09_Estimation/B09_Estimation_Import_Prices.py b/B09_Estimation/B09_Estimation_Import_Prices.py index ae2a337e0..93af80deb 100644 --- a/B09_Estimation/B09_Estimation_Import_Prices.py +++ b/B09_Estimation/B09_Estimation_Import_Prices.py @@ -2,65 +2,45 @@ (덮개 제안 → 화면 캐시 → [저장]). 읽는 시트(꼴마다 첫 것) — 머리 = 「명칭」 · 「품명」 칸이 든 줄: - 노임 = 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 먼저) · + (841-1 노임 · 기계 단가표 걷음 — B09 노무 · 기계 단가 = M02 사본 값 · 덮지 않음) +이음 = 이 공사 자재단가대비표 줄에 이름 · 규격 · 단위로 — 705-3 이음 낱말 그대로(「별도계상」 글 뗀 + 이름 · 규격 · 단위 › 이름 · 단위) · 한 줄에만 맞을 때만 · 한 책 줄에 엑셀 줄 여럿 = 「짝 여럿」. +덮개 = 자재 = 지금 덮개 `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": "경비"} +#: 841-1 노임 · 기계 단가 덮개 걷음(B09 노무 · 기계 단가 = M02 사본 값) — 자재만 +KINDS = {"material": ("재료비목록표", "자재단가대비표", "자재단가")} 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}].""" +def table(rows: list[list[Any]]) -> list[dict[str, Any]]: + """목록 시트 → [{name, spec, unit, price}].""" 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 + 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 :]: @@ -69,12 +49,8 @@ def table(rows: list[list[Any]], machine: bool) -> list[dict[str, Any]]: 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"] != "%" + item["price"] = money(cell(price)) + ok = item["price"] is not None and item["unit"] != "%" if item["name"] and ok: out.append(item) return out @@ -82,31 +58,18 @@ def table(rows: list[list[Any]], machine: bool) -> list[dict[str, Any]]: def read_prices(name: str, data: bytes) -> dict[str, list[dict[str, Any]]]: - """파일 → {labor, material, machine: 줄 목록} · 시트 없음 = 빈 목록 · 셋 다 없으면 막힘.""" + """파일 → {material: 줄 목록} · 시트 없음 = 막힘.""" 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[kind] = table(sheets[sheet]) if sheet else [] out[f"{kind}_sheet"] = sheet if not any(out[k] for k in KINDS): - raise ExcelError("노무비목록표 · 재료비목록표 · 중기목록표(STmate) · 노임단가 · 자재단가 · " - "기계경비총괄표(EST) 시트가 없음") # fmt: skip + raise ExcelError("재료비목록표 · 자재단가대비표(STmate) · 자재단가(EST) 시트가 없음") 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")) @@ -123,18 +86,13 @@ def _match(book: list[dict[str, Any]], keys_of, item: dict[str, Any]) -> dict[st 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 {}) + base = {"material": sheets.get("compare") or []} + keys = {"material": _material_keys} 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} @@ -147,30 +105,16 @@ def propose(read: dict[str, Any], sheets: dict[str, Any], user: dict[str, Any]) 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") + got, book = item["price"], row.get("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} + 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 + "patch": {"materials": materials}, "linked": linked, "missing": {k: v[:LIST_MAX] for k, v in missing.items()}, "counts": { diff --git a/B09_Estimation/B09_Estimation_Labor_Source.py b/B09_Estimation/B09_Estimation_Labor_Source.py index 6e0b7c70f..b2f095cfe 100644 --- a/B09_Estimation/B09_Estimation_Labor_Source.py +++ b/B09_Estimation/B09_Estimation_Labor_Source.py @@ -38,6 +38,7 @@ from B09_Estimation import B09_Estimation_Labor_Rule as rule from B09_Estimation import B09_Estimation_Sangeun as sg from B09_Estimation import B09_Estimation_Sangeun_Text as st from B09_Estimation.B09_Estimation_Bill_Items import LAYER_WORD +from B09_Estimation.B09_Estimation_Copy_Prices import bundle from B09_Estimation.B09_Estimation_Supply import supply_mark from M02_MasterTemplete import M02_Item_Prices as item_prices from M02_MasterTemplete import M02_Labor_Doc as labor_doc @@ -50,7 +51,7 @@ from M02_MasterTemplete.M02_Item_Snapshot import ( linked_snapshots, structures_dir, ) -from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, PRICE_FILE, read_json +from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, read_json mf = item_prices.mf @@ -414,7 +415,7 @@ class Builder: labor = read_json(folder / LABOR_FILE) if not isinstance(labor, dict): return None - prices = read_json(folder / PRICE_FILE) or {} + prices = bundle(folder) # 841-1 미리 보기 덮개 values = read_json(folder / VALUES_FILE) or {} picks = read_json(folder / PICKS_FILE) or {} whole = item_prices.load_prices(prices) @@ -472,7 +473,7 @@ class Builder: doc = copies.get(refs.ref_id(ref)) if isinstance(ref, dict) else None if not isinstance(doc, dict): return None - whole = item_prices.load_prices(read_json(folder / PRICE_FILE) or {}) + whole = item_prices.load_prices(bundle(folder)) if manifest["종류"] == "sangeun": # 내 자료집 단가산출(719-2) return self.sangeun(whole, ref, doc, copies, 1) return self.precombo(whole, ref, doc, copies, 1) diff --git a/B09_Estimation/B09_Estimation_Linked.py b/B09_Estimation/B09_Estimation_Linked.py index dff2cacb4..13549a6e7 100644 --- a/B09_Estimation/B09_Estimation_Linked.py +++ b/B09_Estimation/B09_Estimation_Linked.py @@ -15,6 +15,7 @@ from __future__ import annotations from pathlib import Path from typing import Any +from B09_Estimation.B09_Estimation_Copy_Prices import bundle from M02_MasterTemplete import M02_Item_Prices as item_prices from M02_MasterTemplete import M02_Labor_Doc as labor_doc from M02_MasterTemplete import M02_Labor_Fixed as fixed @@ -24,7 +25,7 @@ from M02_MasterTemplete.M02_Item_Snapshot import ( linked_snapshots, structures_dir, ) -from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, PRICE_FILE, read_json +from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, read_json #: 원가 단가 목록에 오르는 자원 표 RESOURCE_TABLES = ("LB", "EQ", "MT", "MO") @@ -37,7 +38,7 @@ def used_keys(folder: Path) -> list[str]: labor = read_json(folder / LABOR_FILE) if not isinstance(labor, dict): return [] - snapshot = item_prices.load_prices(read_json(folder / PRICE_FILE) or {}) + snapshot = item_prices.load_prices(bundle(folder)) values = read_json(folder / VALUES_FILE) or {} picks = read_json(folder / PICKS_FILE) or {} seen: list[str] = [] @@ -110,8 +111,8 @@ def frozen_rows(project_root: str | Path) -> dict[str, dict[str, Any]]: out: dict[str, dict[str, Any]] = {} for folder in _folders(project_root): - bundle = read_json(folder / PRICE_FILE) or {} - for name, data in bundle.items(): + got = bundle(folder) # 841-1 미리 보기 덮개 + for name, data in got.items(): if name.startswith("_") or not isinstance(data, dict): continue for row in data.get("줄") or data.get("표") or []: diff --git a/B09_Estimation/B09_Estimation_Prices.py b/B09_Estimation/B09_Estimation_Prices.py index 0daf99ff0..ada70ecd6 100644 --- a/B09_Estimation/B09_Estimation_Prices.py +++ b/B09_Estimation/B09_Estimation_Prices.py @@ -1,18 +1,17 @@ -"""B09 원가 단가 한 벌 · 수요 모음(PLAN 144) — 서버 단독(M01 을 읽는 곳은 여기 하나). +"""B09 자재 단가 책 · 수요 모음(PLAN 144 · 841-1) — 서버 단독(M01 을 읽는 곳은 여기 하나). -원가 단가 = 프로젝트 한 벌 — 원가산출에서 한 번 굳힘(`price_book.json`) · +841-1 — 노무 · 기계 · 운전사 · 연료 · 환율은 책에서 걷음(B09 금액 = 사본을 M02 엔진으로 셈한 값 · +`B09_Estimation_Copy_Prices`). +책에 남는 것 = 자재단가대비표(4 자재대 · 관급자재대 단가 고르기) 자재 줄. + +자재 단가 = 프로젝트 한 벌 — 원가산출에서 한 번 굳힘(`price_book.json`) · 값 = 사본 단가 기준 시점(795-4 · 구조물 지정 때 `단가.json` 자원 줄 · 없는 자원만 지금 M01 + - `now_keys`) · -[단가 갱신] 때만 새로 · 대가 · 산근도 이 단가로 셈(한 직종 · 한 자재 = 한 단가). - + `now_keys`) · [단가 갱신] 때만 새로. 줄 = 수요 따라(사용자 승인 ① · 10-9) — 엑셀 목록을 기본으로 깔지 않음: - 연결된 사본이 부른 자원 키(`B09_Estimation_Linked` — 143-1 「연결된 사본」 원천 · 없으면 빈 표) - + 기계가 부르는 운전사(조종원직종 → 인력 원문번호) · 연료(주연료종류 → 유가 「종류:전국평균」) + 연결된 사본이 부른 자재 키(`B09_Estimation_Linked` — 143-1 「연결된 사본」 원천 · 없으면 빈 표) + B08 총괄자재수량집계표 자재(M01 짝 없음 → 출처 빈칸 · 적용 단가는 사용자 입력) · 수량 = 합계 열 · 채집석 공제 줄 거름 · 키가 MT 코드(맞춤 없는 사본 자재)면 사본 자원 줄에 수량만 얹음 · 파형강관 = 관급 기본 -운전사 시간 계수 = M01 CC000336(시간환산 · 상여계수 · 휴지계수 분자 · 분모 곱). -연료 지역 = 전국평균(M01 기계경비 로직 지역 첫 값) — 다른 지역 값은 자재 적용 단가를 고쳐 씀. """ from __future__ import annotations @@ -25,11 +24,6 @@ from B08_Quantity.B08_Quantity_Material_Calc import DEDUCT_KEY from common_util.common_util_time import utc_now_text from M01_MasterData import M01_MasterData_Store as m01 -#: 운전사 시간 노임 계수 표(건설품셈 공통 8-1-3 · 건협기술-2230) -DRIVER_TABLE = "CC000336" -DRIVER_PARTS = ("시간환산", "상여계수", "휴지계수") -#: 연료 지역 — M01 기계경비 로직 지역 고르기 첫 값 -FUEL_REGION = "전국평균" #: 자재품목 값 열 → 대비표 출처 칸 · 대비표 「관급」 = M01 「조달청」 칸(830-2) MT_SOURCES = ("관급", "물가자료", "유통물가", "물가정보", "거래가격") MT_COLUMN = {"관급": "조달청"} @@ -65,36 +59,6 @@ def _row(master, key: str) -> dict[str, Any] | None: return master.index.get(key[:2], {}).get(key) -def labor_input(row: dict[str, Any]) -> dict[str, Any]: - return { - "key": row["키"], - "code": str(row.get("원문번호") or ""), - "name": row.get("이름") or "", - "spec": row.get("규격") or "", - "unit": "인", - "price": _f(row.get("값")), - "source": row.get("출처") or "", - } - - -def machine_input(row: dict[str, Any]) -> dict[str, Any]: - v = row.get("값") or {} - return { - "key": row["키"], - "code": str(row.get("원문번호") or ""), - "name": row.get("이름") or "", - "spec": row.get("규격") or "", - "price": _f(v.get("가격")), - "loss": _f(v.get("손료계수")), - "fuel": v.get("주연료종류") or None, - "fuel_lph": _f(v.get("주연료")), - "misc_pct": _f(v.get("잡재료율")), - "crew": _f(v.get("조종원")), - "crew_code": str(v["조종원직종"]) if v.get("조종원직종") else None, - "source": (row.get("출처") or {}).get("가격") or row.get("원문") or "", - } - - def material_input(row: dict[str, Any]) -> dict[str, Any]: """자재품목(MT) · 유가(MO) 줄 → 대비표 입력 줄.""" if row["키"].startswith("MO"): @@ -121,82 +85,22 @@ def material_input(row: dict[str, Any]) -> dict[str, Any]: } -def driver_factor(master) -> dict[str, Any] | None: - table = _row(master, DRIVER_TABLE) - if not table: - return None - parts = {r.get("구분"): r for r in table.get("줄") or []} - if any(p not in parts for p in DRIVER_PARTS): - return None - top = bottom = 1 - text = [] - for p in DRIVER_PARTS: - top *= int(parts[p]["분자"]) - bottom *= int(parts[p]["분모"]) - text.append(f"{parts[p]['분자']}/{parts[p]['분모']}") - return {"num": top, "den": bottom, "text": " × ".join(text)} - - -def fx_inputs(master) -> list[dict[str, Any]]: - return [ - {"key": key, "name": row.get("이름") or "", "unit": row.get("단위") or "", - "value": _f(row.get("값")), "date": row.get("규격") or ""} - for key, row in sorted(master.index.get("FX", {}).items()) - ] # fmt: skip - - -def _fuel_row(master, kind: str) -> dict[str, Any] | None: - return master.numbers.get("MO", {}).get(f"{kind}:{FUEL_REGION}") - - def demand(master, keys: list[str]) -> dict[str, Any]: - """수요 키 → 줄 차례(노무 · 기계 · 자재) + 연료 짝 · 찾지 못한 키.""" - labor: list[str] = [] - machines: list[str] = [] + """수요 키 → 자재 줄 차례 · 찾지 못한 키(841-1 노무 · 기계 = 사본 엔진 값 · 책 밖).""" materials: list[str] = [] missing: list[str] = [] - fuel_keys: dict[str, str] = {} - - def add(bucket: list[str], key: str) -> None: - if key not in bucket: - bucket.append(key) - for key in keys: + if not key.startswith(("MT", "MO")): + continue if _row(master, key) is None: missing.append(key) - elif key.startswith("LB"): - add(labor, key) - elif key.startswith("EQ"): - add(machines, key) - elif key.startswith(("MT", "MO")): - add(materials, key) - for key in machines: - v = _row(master, key).get("값") or {} - code = v.get("조종원직종") - driver = master.numbers.get("LB", {}).get(str(code)) if code and v.get("조종원") else None - if driver: - add(labor, driver["키"]) - kind = v.get("주연료종류") - fuel = _fuel_row(master, kind) if kind and v.get("주연료") else None - if fuel: - add(materials, fuel["키"]) - fuel_keys[kind] = fuel["키"] - return { - "labor": labor, - "machines": machines, - "materials": materials, - "fuel_keys": fuel_keys, - "missing": missing, - } + elif key not in materials: + materials.append(key) + return {"materials": materials, "missing": missing} def _convert(master, key: str) -> dict[str, Any]: - row = _row(master, key) - if key.startswith("LB"): - return labor_input(row) - if key.startswith("EQ"): - return machine_input(row) - return material_input(row) + return material_input(_row(master, key)) def refresh_book( @@ -207,17 +111,12 @@ def refresh_book( `frozen` = 첫 굳힘(795-4) — 사본 단가 기준 시점 값 · 없는 자원 = 지금 값(`now_keys`).""" master = basis(frozen) want = demand(master, keys) - rows = { - key: _convert(master, key) - for key in [*want["labor"], *want["machines"], *want["materials"]] - } + rows = {key: _convert(master, key) for key in want["materials"]} old = (book or {}).get("rows") or {} rows.update({key: old[key] for key in want["missing"] if key in old}) return { "made": utc_now_text(), "rows": rows, - "driver_factor": driver_factor(master), - "fx": fx_inputs(master), "labor_edition": labor_editions(keys), # 703-3 미리 보기 「노임 판 전 → 후」 "now_keys": now_keys(frozen, list(rows)), } @@ -228,7 +127,7 @@ def labor_editions(keys: list[str]) -> dict[str, str]: files, master = m01.loaded() groups = (files.get("인력.json") or {}).get("구분") or {} out: dict[str, str] = {} - for key in demand(master, keys)["labor"]: + for key in [k for k in keys if k.startswith("LB") and _row(master, k)]: kind = str((_row(master, key) or {}).get("구분") or "") if (groups.get(kind) or {}).get("판"): out[kind] = str(groups[kind]["판"]) @@ -236,14 +135,12 @@ def labor_editions(keys: list[str]) -> dict[str, str]: def book_diff(old: dict[str, Any] | None, new: dict[str, Any]) -> dict[str, int | bool]: - """두 책 견줌(저장 꼴 JSON) — 새 줄 · 바뀐 줄 수 · 운전사 계수 · 환율 바뀜(203-1 · 703-3).""" + """두 책 견줌(저장 꼴 JSON) — 새 줄 · 바뀐 줄 수(203-1 · 703-3 · 자재 줄만).""" new = json.loads(json.dumps(new, ensure_ascii=False)) rows = (old or {}).get("rows") or {} return { "new": sum(key not in rows for key in new["rows"]), "changed": sum(key in rows and rows[key] != row for key, row in new["rows"].items()), - "factor": (old or {}).get("driver_factor") != new["driver_factor"], - "fx": (old or {}).get("fx") != new["fx"], } @@ -256,8 +153,6 @@ def book_stale(book: dict[str, Any] | None, keys: list[str]) -> str | None: parts = [ f"새 단가 줄 {diff['new']}" if diff["new"] else "", f"M01 에서 바뀐 단가 {diff['changed']}" if diff["changed"] else "", - "운전사 계수 바뀜" if diff["factor"] else "", - "환율 바뀜" if diff["fx"] else "", ] return " · ".join(p for p in parts if p) or None @@ -272,9 +167,7 @@ def extend_book( master = basis(frozen) want = demand(master, keys) rows = dict(book["rows"]) - added = [ - key for key in [*want["labor"], *want["machines"], *want["materials"]] if key not in rows - ] + added = [key for key in want["materials"] if key not in rows] rows.update({key: _convert(master, key) for key in added}) late = [*(book.get("now_keys") or []), *now_keys(frozen or {}, added)] return {**book, "rows": rows, "now_keys": list(dict.fromkeys(late))} @@ -326,7 +219,6 @@ def build_inputs( late.append(key) return {**_convert(master, key), "fresh": True} - factor = (book or {}).get("driver_factor") if book else driver_factor(master) materials = [pick(k) for k in want["materials"]] at = {row["key"]: i for i, row in enumerate(materials)} for row in b08_material_rows(b08_sheets): @@ -336,12 +228,7 @@ def build_inputs( else: # 같은 MT 코드 = 같은 자재 — 두 줄 안 만들고 수량만 materials[same] = {**materials[same], "qty": row["qty"]} return { - "labor": [pick(k) for k in want["labor"]], - "machines": [pick(k) for k in want["machines"]], "materials": materials, - "fuel_keys": want["fuel_keys"], - "driver_factor": factor, - "fx": (book or {}).get("fx") if book else fx_inputs(master), "missing": want["missing"], "now": list(dict.fromkeys(late)), # 위 줄들 pick 뒤 — 사전 차례대로 셈 } diff --git a/B09_Estimation/B09_Estimation_Refresh.py b/B09_Estimation/B09_Estimation_Refresh.py index b9effe7ee..dae1f2b4d 100644 --- a/B09_Estimation/B09_Estimation_Refresh.py +++ b/B09_Estimation/B09_Estimation_Refresh.py @@ -1,5 +1,6 @@ -"""B09 [단가 갱신] · [재생성](PLAN 795-4 · 옛 [업데이트] 703-3) — 사본은 만든 때 그대로 · -[단가 갱신](기본) = 단가 책만 지금 M01 값 · 미리 보기 `price_preview` = 바뀐 자원 · 총괄 전 → 후 · +"""B09 [단가 갱신] · [재생성](PLAN 795-4 · 옛 [업데이트] 703-3) — 사본 로직은 만든 때 그대로 · +[단가 갱신](기본 · 841-1) = 프로젝트 사본 자원 단가 줄(`B09_Estimation_Copy_Prices`) + 자재 단가 책만 + 지금 M01 값 · 미리 보기 `price_preview` = 바뀐 자원 · 총괄 전 → 후 · [재생성](사용자가 따로) = 아래 옛 [업데이트] — 단가 책 다시 굳힘 + 원본이 바뀐 구조물 사본 [재적용]. 미리 보기(`preview`) = 바뀌는 단가 줄(새 줄 + 바뀐 줄 · 지금 수요) · 다시 가져올 구조물 사본 @@ -34,12 +35,16 @@ def _value(row: dict[str, Any] | None) -> Any: def price_preview( - old: dict[str, Any] | None, new: dict[str, Any], before: dict, after: dict, cells: int + old: dict[str, Any] | None, + new: dict[str, Any], + before: dict, + after: dict, + cells: int, + copies: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: - """[단가 갱신] 미리 보기(795-4) — {할일, 바뀐자원 [{키, 이름, 전, 후}], 계수, 환율, 전, 후 - (총괄 grand · 빈 줄 있으면 None), 대가 {전, 후}(일위대가 블록 합), 고친칸, 노임판} · - 사본(로직)은 안 바뀜.""" - diff = prices.book_diff(old, new) + """[단가 갱신] 미리 보기(795-4 · 841-1) — {할일, 바뀐자원 [{키, 이름, 전, 후}](사본 자원 줄 + + 자재 단가 책), 전, 후(총괄 grand · 빈 줄 있으면 None), 대가 {전, 후}(일위대가 블록 합), 고친칸, + 노임판} · 사본(로직)은 안 바뀜.""" was = json.loads(json.dumps((old or {}).get("rows") or {}, ensure_ascii=False)) now = json.loads(json.dumps(new["rows"], ensure_ascii=False)) rows = [ @@ -54,11 +59,10 @@ def price_preview( def daega(sheets: dict) -> float: return sum(float(b.get("sum") or 0) for b in sheets.get("daega_blocks") or []) + seen = {r["키"] for r in copies or []} return { "할일": "단가갱신", - "바뀐자원": rows, - "계수": diff["factor"], - "환율": diff["fx"], + "바뀐자원": [*(copies or []), *(r for r in rows if r["키"] not in seen)], "전": grand(before), "후": grand(after), "대가": {"전": daega(before), "후": daega(after)}, @@ -138,8 +142,6 @@ def preview( "단가줄": diff["new"] + diff["changed"], "새줄": diff["new"], "바뀐줄": diff["changed"], - "계수": diff["factor"], - "환율": diff["fx"], "사본": copies + lib_names, "단가산출": sk_changes(root, copies, libs), "고친칸": cover_cells(cover), diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 4acc03ef6..eeb7b3c45 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -73,6 +73,7 @@ from B08_Quantity.B08_Quantity_Router_Material import once as material_once from B08_Quantity.B08_Quantity_Router_Milestone import read_data as read_milestone from B08_Quantity.B08_Quantity_Router_Milestone import site_info from B09_Estimation import B09_Estimation_Bill_Library as library +from B09_Estimation import B09_Estimation_Copy_Prices as copy_prices from B09_Estimation import B09_Estimation_Daega_Paste as daega_paste from B09_Estimation import B09_Estimation_Daega_Personal as personal from B09_Estimation import B09_Estimation_Linked as linked @@ -99,7 +100,7 @@ from B09_Estimation.B09_Estimation_Summary import ( totals, used_materials, ) -from B09_Estimation.B09_Estimation_Supply import supply_defaults +from B09_Estimation.B09_Estimation_Supply import price_lists, supply_defaults from common_util.common_util_auth import verify_session from common_util.common_util_project_settings import reset_section from common_util.common_util_storage import resolve_stored_project_path @@ -166,8 +167,10 @@ def compute_sheets( 화면 짝 = `B09_Estimation_UI_Shell.computeSheets`(산근만 서버 답 그대로) · memo = 두 번 셈의 앞 산근 풀이(`Sangeun.collect`).""" supply = supply_defaults(inputs, user) - base = compute_base(inputs, user) sg = sangeun.build(root, user, memo, supply) # 145-1 M02 엔진 값으로 풀이(841-1 · 166-2 손 줄) + blocks = (inputs.get("daega") or {}).get("blocks") or [] + inputs["labor"], inputs["machines"] = price_lists(blocks, sg["sg"]) # 841-1 엔진 단가 나열 + base = compute_base(inputs, user) sg = sg_cover.cover_sangeun(sg, user.get("sg_cover")) # 719-3 이 공사 산근 고친 칸 by_key = {row["key"]: row for row in sg["sg_list"]} labor = compute_labor( @@ -368,8 +371,8 @@ async def refresh_prices( body: dict[str, Any] | None = Body(None), session: dict[str, Any] = Depends(verify_session), ) -> dict[str, Any]: - """[단가 갱신](795-4) — 단가 책 다시 굳힘 · 사본 · 덮개 그대로 · - job 「재생성」 = 원본이 바뀐 구조물 사본 재적용(703-3) 먼저.""" + """[단가 갱신](795-4 · 841-1) — 프로젝트 사본 자원 단가 줄 + 자재 단가 책을 지금 M01 값으로 · + 사본 로직 · 덮개 그대로 · job 「재생성」 = 원본이 바뀐 구조물 사본 재적용(703-3) 먼저.""" root = await _project_root(project_id) raw = (body or {}).get("user") draft = store.clean_user(raw) if isinstance(raw, dict) else None # 296 화면 캐시(저장 안 함) @@ -377,6 +380,8 @@ async def refresh_prices( again = {"다시": [], "막힘": []} if regen: again = await asyncio.to_thread(refresh.reapply_changed, root, importer(session)) + news, changed = await asyncio.to_thread(copy_prices.refreshed, root) + await asyncio.to_thread(copy_prices.write, news) keys = await _demand(root, draft) old = await asyncio.to_thread(store.read_book, root) book = await asyncio.to_thread(prices.refresh_book, keys, old) @@ -387,7 +392,8 @@ async def refresh_prices( cells = refresh.cover_cells(kept.get("daega_cover")) + refresh.cover_cells(kept.get("sg_cover")) head = f"재생성 — 구조물 사본 {len(again['다시'])} 다시 가져옴 · " if regen else "단가 갱신 — " out["notes"] = [ - f"{head}단가 줄 {diff['new'] + diff['changed']} 바뀜 · 고친 칸 {cells} 그대로", + f"{head}사본 {len(news)} · 자원 {len(changed)} · 자재 단가 줄 " + f"{diff['new'] + diff['changed']} 바뀜 · 고친 칸 {cells} 그대로", *(f"재적용 못 함 {why}" for why in again["막힘"]), *(out.get("notes") or []), ] @@ -409,10 +415,15 @@ async def refresh_preview( if (body or {}).get("job") == refresh.REGEN: return await asyncio.to_thread(refresh.preview, root, book, keys, user.get("daega_cover")) new = await asyncio.to_thread(prices.refresh_book, keys, book) + news, changed = await asyncio.to_thread(copy_prices.refreshed, root) before = await payload(project_id, root, draft, True, keys) - after = await payload(project_id, root, draft, True, keys, new) + token = copy_prices.OVER.set(news) # 841-1 사본 새 단가 = 덮개로만(파일 안 씀) + try: + after = await payload(project_id, root, draft, True, keys, new) + finally: + copy_prices.OVER.reset(token) cells = refresh.cover_cells(user.get("daega_cover")) + refresh.cover_cells(user.get("sg_cover")) - return refresh.price_preview(book, new, before["sheets"], after["sheets"], cells) + return refresh.price_preview(book, new, before["sheets"], after["sheets"], cells, changed) @router.post("/{project_id}/estimation/calc") diff --git a/B09_Estimation/B09_Estimation_Router_Import.py b/B09_Estimation/B09_Estimation_Router_Import.py index 943bf58f9..3a2ce5094 100644 --- a/B09_Estimation/B09_Estimation_Router_Import.py +++ b/B09_Estimation/B09_Estimation_Router_Import.py @@ -19,9 +19,8 @@ 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}} + 자재 단가표(707-3 · 841-1 노임 · 기계 걷음) → 이 공사 자재단가대비표에 이음 → + {file_key, name, sheets, counts, linked, missing, patch{materials}} file_key = 세 길 같은 표지(`_keep` · `_saved`) — 같은 파일을 다시 올리지 않음 · .estx(EST 원본 · 707-5 `B09_Estimation_Estx`) · .STC(STmate 원본 · 707-6 `B09_Estimation_Stc`) = 이름 끝으로 갈림 — 내역 줄 · 단가표 같은 그릇 · 요율 = 400 「같은 공사 엑셀에서」 · diff --git a/B09_Estimation/B09_Estimation_Sangeun.py b/B09_Estimation/B09_Estimation_Sangeun.py index 6296f730a..e7ccbe6df 100644 --- a/B09_Estimation/B09_Estimation_Sangeun.py +++ b/B09_Estimation/B09_Estimation_Sangeun.py @@ -43,6 +43,7 @@ from typing import Any from B08_Quantity.B08_Quantity_Haul_Cycle import fill_given from B09_Estimation import B09_Estimation_Sangeun_Sum as ss from B09_Estimation.B09_Estimation_Calc_Base import SUPPLY, gap, num, rounddown, total +from B09_Estimation.B09_Estimation_Copy_Prices import bundle from B09_Estimation.B09_Estimation_Store import ref_rows from M01_MasterData import M01_MasterData_Store as m01 from M01_MasterData import M01_MasterData_Store_Combo as combo_store @@ -55,7 +56,7 @@ from M02_MasterTemplete.M02_Item_Snapshot import ( linked_snapshots, structures_dir, ) -from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, PRICE_FILE, read_json +from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, read_json mf = item_prices.mf import master_sheet_line as msl # noqa: E402 — 계산서 줄 덧칸(714-1) · scripts 길은 `m01` 이 둠 @@ -109,7 +110,7 @@ def rows_of(folder: Path, whole: Any = None) -> list[dict[str, Any]]: if not isinstance(labor, dict): return [] if whole is None: - whole = item_prices.load_prices(read_json(folder / PRICE_FILE) or {}) + whole = item_prices.load_prices(bundle(folder)) values = read_json(folder / VALUES_FILE) or {} picks = read_json(folder / PICKS_FILE) or {} copies = labor.get("참조사본") or {} @@ -630,7 +631,7 @@ def collect( return copy.deepcopy(memo["sg"]) solver = Solver(supply) for owner, folder in entries: - whole = item_prices.load_prices(read_json(folder / PRICE_FILE) or {}) + whole = item_prices.load_prices(bundle(folder)) for src in rows_of(folder, whole): solver.add(whole, src, owner) hand_keys: dict[str, str] = {} diff --git a/B09_Estimation/B09_Estimation_Sangeun_Cover.py b/B09_Estimation/B09_Estimation_Sangeun_Cover.py index 9d19988c9..b376861ee 100644 --- a/B09_Estimation/B09_Estimation_Sangeun_Cover.py +++ b/B09_Estimation/B09_Estimation_Sangeun_Cover.py @@ -4,10 +4,10 @@ 거울 시험 `resources/tester/test_719_3_sangeun_cover.py`. 조작 중 = 화면이 TS 짝으로 바로 셈 · [저장] = 서버가 다시 풀이(`Sangeun.build`) 뒤 이 파일로 정본. -덮개 = 사용자 값 `sg_cover` {산근 키: {줄 열쇠 「n:이름」: {qty?, labor?, material?, expense?}}} — - qty = 1 단위 품 · 비목 = 단가 · 있는 칸만 셈 값 대신 · 줄이 없거나 이름이 다르면 짝 잃음 +덮개 = 사용자 값 `sg_cover` {산근 키: {줄 열쇠 「n:이름」: {qty?}}} — + qty = 1 단위 품(841-1 단가 칸 걷음 · 옛 단가 칸은 안 읽음) · 있는 칸만 셈 값 대신 · 줄이 없거나 이름이 다르면 짝 잃음 (`sg_orphans` · 알림만 · 덮개는 남음) · 산근 키 = 로직 + 입력값 → [업데이트] 뒤에도 같은 블록. -고칠 칸(`fields`) = 노무 · 자재 · 중기 · 빈 줄 = 품 + 쓰는 비목 단가 · 하위 산근 = 품 · +고칠 칸(`fields`) = 노무 · 자재 · 중기 · 빈 줄 · 하위 산근 = 품 · % 덧줄(`of` 있는 것) = 율. 다시 셈 = 덮개가 닿은 블록과 그 블록을 부르는 블록만(안 닿은 블록 = 서버 값 그대로) — 줄 금액 ROUNDDOWN(단가 × 품, 1) · 하위 산근 줄 단가 = 다시 센 그 블록 비목 합 · @@ -29,7 +29,7 @@ from typing import Any from B09_Estimation.B09_Estimation_Calc_Base import gap, num, rounddown, total PARTS = ("labor", "material", "expense") -COVER_KEYS = ("qty", *PARTS) +COVER_KEYS = ("qty",) # 841-1 품만 DEPTH = 8 # 하위 산근 깊이 상한(서버 풀이 4 보다 넉넉히) @@ -39,21 +39,11 @@ def line_id(n: int, line: dict[str, Any]) -> str: def fields(line: dict[str, Any]) -> list[str]: - """고칠 수 있는 칸 — 품(qty) + 단가 비목.""" + """고칠 수 있는 칸 — 품(qty)만(841-1 단가 칸 걷음 · 단가 = M02 사본 값) · % 덧줄 = 율.""" kind = line.get("kind") - if kind == "sangeun": - return ["qty"] if kind == "extra": return ["qty"] if line.get("of") else [] - if kind == "machine": - return ["qty", *PARTS] - if kind == "labor": - return ["qty", "labor"] - if kind == "material": - return ["qty", *[p for p in PARTS if p in (line.get("price") or {})]] - if kind == "blank": - return ["qty", *[p for p in PARTS if line.get(p) is None]] - return [] + return ["qty"] if kind in ("sangeun", "machine", "labor", "material", "blank") else [] def _money(price: float | None, qty: float | None) -> float | None: diff --git a/B09_Estimation/B09_Estimation_Sangeun_Cover.ts b/B09_Estimation/B09_Estimation_Sangeun_Cover.ts index 91792efaa..8e6962431 100644 --- a/B09_Estimation/B09_Estimation_Sangeun_Cover.ts +++ b/B09_Estimation/B09_Estimation_Sangeun_Cover.ts @@ -8,7 +8,7 @@ * * 덮개 = 사용자 값 `sg_cover` {산근 키: {줄 열쇠 「n:이름」: {qty?, labor?, material?, expense?}}} — * 있는 칸만 셈 값 대신 · 줄이 없거나 이름이 다르면 짝 잃음(`sg_orphans` · 알림만) - * 고칠 칸(`fields`) = 노무 · 자재 · 중기 · 빈 줄 = 품 + 쓰는 비목 단가 · 하위 산근 = 품 · % 덧줄(`of`) = 율 + * 고칠 칸(`fields`) = 품만(841-1 단가 칸 걷음) · % 덧줄(`of`) = 율 * 다시 셈 = 덮개가 닿은 블록과 그 블록을 부르는 블록만 — 줄 금액 ROUNDDOWN(단가 × 품, 1) · * 하위 산근 줄 단가 = 다시 센 그 블록 비목 합 · % 덧줄 = 서버 값 × 밑수 비 × 율 비(처음이 0 · 빈칸이면 * 밑수 × 율 ÷ 100) · 총계 = 비목마다 ROUNDDOWN(Σ, 0) · 목록표 · 빈칸 목록 같이 @@ -20,7 +20,7 @@ import { gap, num, rounddown, total, type Num, type Row } from "./B09_Estimation_Calc_Base"; const PARTS = ["labor", "material", "expense"] as const; -export const SG_COVER_KEYS = ["qty", ...PARTS] as const; +export const SG_COVER_KEYS = ["qty"] as const; // 841-1 품만 const DEPTH = 8; /** 덮개 줄 열쇠 — 블록 안 차례 + 이름 */ @@ -29,13 +29,9 @@ export const lineId = (n: number, line: Row): string => `${n}:${line.name || ""} /** 고칠 수 있는 칸 — 품(qty) + 단가 비목 */ export function fields(line: Row): string[] { const kind = line.kind; - if (kind === "sangeun") return ["qty"]; + // 841-1 단가 칸 걷음 — 품(qty)만 · % 덧줄 = 율(짝 `fields`) if (kind === "extra") return line.of ? ["qty"] : []; - if (kind === "machine") return ["qty", ...PARTS]; - if (kind === "labor") return ["qty", "labor"]; - if (kind === "material") return ["qty", ...PARTS.filter((p) => p in (line.price || {}))]; - if (kind === "blank") return ["qty", ...PARTS.filter((p) => (line[p] ?? null) === null)]; - return []; + return ["sangeun", "machine", "labor", "material", "blank"].includes(kind) ? ["qty"] : []; } const money = (price: Num | undefined, qty: Num | undefined): Num => diff --git a/B09_Estimation/B09_Estimation_Store.py b/B09_Estimation/B09_Estimation_Store.py index 37d76abdc..0247f15c3 100644 --- a/B09_Estimation/B09_Estimation_Store.py +++ b/B09_Estimation/B09_Estimation_Store.py @@ -1,7 +1,7 @@ """B09 원가 저장 자리(PLAN 144-1) — 프로젝트 안 `B09_Estimation/v1/`. - price_book.json 굳힌 단가 한 벌 {"made": 시각, "rows": {키: 입력 줄}, - "driver_factor": …, "fx": […]} + price_book.json 굳힌 자재 단가 한 벌 {"made": 시각, "rows": {키: 입력 줄}}(841-1 자재만 · + 옛 책의 노무 · 기계 줄 · 계수 · 환율은 안 읽음) — [단가 갱신] 때만 M01 로 새로 · [저장] 때 책에 없는 새 줄만 더함(있는 줄 안 바꿈) user_values.json 사용자 값 {"materials": {자재 키: {applied?, supply?, quote?, quote_page?, @@ -12,13 +12,10 @@ — 고친 칸만(키가 있으면 그 값 · null = 빈칸 · 없으면 기본) "daega_cover": {블록 cover: {줄 cid: {qty?, labor?, material?, expense?}}} (702-2 일위대가 고친 칸 덮개 — 수만 · 셈 = `B09_Estimation_Calc_Labor`) - "sg_cover": {산근 키: {줄 「n:이름」: {qty?, labor?, material?, expense?}}} + "sg_cover": {산근 키: {줄 「n:이름」: {qty?}}}(841-1 단가 칸 걷음 · 품만) (719-3 산근 고친 칸 — 셈 = `B09_Estimation_Sangeun_Cover`) + 복사본 `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 = 이름들 · 사용자 값으로는 저장 안 함 @@ -36,7 +33,7 @@ from pathlib import Path from typing import Any from urllib.parse import unquote -from B09_Estimation.B09_Estimation_Calc_Base import SOURCES, SUPPLY, machine_cover +from B09_Estimation.B09_Estimation_Calc_Base import SOURCES, SUPPLY from B09_Estimation.B09_Estimation_Calc_Bill import SECTIONS from B09_Estimation.B09_Estimation_Calc_Labor import ADD, COVER_KEYS, PARTS @@ -357,11 +354,6 @@ def clean_user(raw: Any) -> dict[str, Any]: out["daega_cover"] = cover if cover := clean_cover(raw.get("sg_cover")): # 719-3 산근 고친 칸(같은 꼴 · 줄 「n:이름」) out["sg_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_Supply.py b/B09_Estimation/B09_Estimation_Supply.py index c2e8461be..42b60883f 100644 --- a/B09_Estimation/B09_Estimation_Supply.py +++ b/B09_Estimation/B09_Estimation_Supply.py @@ -1,4 +1,6 @@ -"""B09 자재 관급 · 별도 표시(PLAN 841-1) — M02 자재표시를 대비표 기본 표시로 · 서버 단독 · 순수 함수. +"""B09 자재 관급 · 별도 표시 · 사본 단가 목록(PLAN 841-1) — 서버 단독 · 순수 함수. + +자재 표시 = M02 자재표시를 대비표 기본 표시로 · 노무비 · 중기목록 = 엔진 단가 나열(`price_lists`). M02 일위대가 · 단가산출 셈은 재료 줄을 자재표시(자재구분 관급 · 별도계상 — 빈 칸 = 별도계상)로 합에서 뺌(`M02_Labor_Doc.apart`) → B09 도 같은 표시로 대가 · 산근 안 0 원 · 4 자재대 · 관급자재대로 @@ -12,6 +14,8 @@ from typing import Any from M02_MasterTemplete import M02_Labor_Doc as labor_doc +PARTS = ("labor", "material", "expense") + def supply_mark(one: Any) -> str | None: """M02 자재표시(841-1) → 대비표 기본 표시 — 관급 · 별도(별도계상 · M02 기본) · None(직접계산).""" @@ -48,3 +52,23 @@ def supply_defaults(inputs: dict[str, Any], user: dict[str, Any]) -> Any: return None if got == "사급" else got or default.get(key, marks.get(key, "별도")) return supply + + +def price_lists(blocks: list[dict[str, Any]], sg_blocks: list[dict[str, Any]]) -> tuple[list, list]: + """노무비목록표 · 중기목록표 입력(841-1) — 대가 입력 줄 `own` · 산근 줄 `price` = M02 엔진 단가 + (사본 값) 나열 · 다시 안 셈 · 수량 있는 줄만. ponytail: 키 하나 = 처음 본 단가(사본마다 시점이 달라도).""" + labor: dict[str, dict] = {} + machine: dict[str, dict] = {} + for block in [*blocks, *sg_blocks]: + for ln in block.get("lines") or []: + kind = ln.get("kind") + key = ln.get("key") or (ln.get("ref") or {}).get("key") + if kind not in ("labor", "machine") or not key or not ln.get("qty"): + continue + price = ln.get("own") or ln.get("price") or {} + head = {"key": key, "name": ln.get("name") or "", "spec": ln.get("spec") or ""} + if kind == "labor" and price.get("labor") is not None: + labor.setdefault(key, {**head, "unit": "인", "price": price["labor"]}) + elif kind == "machine" and None not in [price.get(p) for p in PARTS]: + machine.setdefault(key, {**head, **{p: price[p] for p in PARTS}}) + return list(labor.values()), list(machine.values()) diff --git a/B09_Estimation/B09_Estimation_UI_Check.ts b/B09_Estimation/B09_Estimation_UI_Check.ts index 03d936f98..eecf9d837 100644 --- a/B09_Estimation/B09_Estimation_UI_Check.ts +++ b/B09_Estimation/B09_Estimation_UI_Check.ts @@ -2,7 +2,7 @@ * B09_Estimation_UI_Check.ts * B09 좌측 「데이터 점검」 묶음(PLAN 203-1) — [단가 갱신] · 연결 점검(옛 「연결 점검」 탭 내용). * - * [단가 갱신](795-4 · 옛 [업데이트]) = 자원 단가만 지금 M01 값 · 서버가 낡았다고 보면(`payload.stale` 까닭 한 줄) + * [단가 갱신](795-4 · 옛 [업데이트] · 841-1) = 프로젝트 사본 자원 단가 · 자재 단가만 지금 M01 값 · 서버가 낡았다고 보면(`payload.stale` 까닭 한 줄) * 단추에 점 배지(`is-pending`) · 굳힌 시각 · 까닭 = 공용 호버 * 미리 보기 고리 = 누름을 먼저 잡아 POST …/estimation/prices/preview(화면 캐시) → * 「총괄 금액 전 → 후 · 바뀐 자원」 창 → [적용] = 원래 단추 일(단가만 · 사본 · 덮개 그대로) · @@ -68,7 +68,7 @@ export function refreshTip(payload: Row | null): string { const made = payload?.book?.made; const when = made ? `단가 굳힘 ${dateTimeText(String(made))}` : "단가 아직 안 굳힘"; const stale = payload?.stale; - return `${when} · 누르면 미리 보기 → [적용] = 자원 단가만 지금 M01 값 · 사본(로직) · 고친 칸 그대로 · 로직까지 새로 = 미리 보기의 [재생성]${stale ? ` · 단가 갱신 가능 — ${stale}` : ""}`; + return `${when} · 누르면 미리 보기 → [적용] = 프로젝트 사본 자원 단가 · 자재 단가만 지금 M01 값 · 사본(로직) · 고친 칸 그대로 · 로직까지 새로 = 미리 보기의 [재생성]${stale ? ` · 단가 갱신 가능 — ${stale}` : ""}`; } /** [재생성] 미리 보기 글 줄(703-3 옛 [업데이트]) */ @@ -77,8 +77,7 @@ export function previewLines(got: Row): string[] { const sks: Row[] = got.단가산출 || []; const edition = got.노임판 || {}; return [ - `바뀌는 단가 줄 ${got.단가줄 ?? 0}(새 줄 ${got.새줄 ?? 0} · 값 바뀜 ${got.바뀐줄 ?? 0})` + - `${got.계수 ? " · 운전사 계수 바뀜" : ""}${got.환율 ? " · 환율 바뀜" : ""}`, + `바뀌는 자재 단가 줄 ${got.단가줄 ?? 0}(새 줄 ${got.새줄 ?? 0} · 값 바뀜 ${got.바뀐줄 ?? 0})`, `다시 가져올 사본 ${copies.length}${copies.length ? ` — ${copies.map((c) => c.이름).join(" · ")}` : ""}`, ...(sks.length ? [`M02 단가산출 바뀜 ${sks.length} — ${sks.map((s) => `${s.이름}(${s.판})`).join(" · ")}`] @@ -105,7 +104,7 @@ export function priceLines(got: Row): string[] { const shown = 30; return [ `총괄 금액 ${won(got.전)} → ${won(got.후)} 원 · 대가 합 ${won(got.대가?.전)} → ${won(got.대가?.후)} 원`, - `바뀐 자원 ${rows.length}${got.계수 ? " · 운전사 계수 바뀜" : ""}${got.환율 ? " · 환율 바뀜" : ""}`, + `바뀐 자원 ${rows.length}(사본 자원 단가 · 자재 단가)`, ...rows .slice(0, shown) .map((r) => `· ${r.이름 || r.키} ${priceText(r.전)} → ${priceText(r.후)}`), diff --git a/B09_Estimation/B09_Estimation_UI_Import.ts b/B09_Estimation/B09_Estimation_UI_Import.ts index fd9d2f7ec..ae56036f7 100644 --- a/B09_Estimation/B09_Estimation_UI_Import.ts +++ b/B09_Estimation/B09_Estimation_UI_Import.ts @@ -16,7 +16,7 @@ * * 요율 · 기초 단가표 불러오기(PLAN 707-2 · 707-3) — 저장 안 함. * 단추 = 보조 탭 줄 도구 자리(`…_UI_TabBar` setTools) — 총괄 · 원가계산서 탭 [엑셀에서 요율 불러오기] · - * 노무비목록표 · 중기목록표 · 자재단가대비표 탭 [엑셀에서 단가표 불러오기] + * 자재단가대비표 탭 [엑셀에서 단가표 불러오기](841-1 노임 · 기계 걷음) * 길 = `B09_Estimation_Router_Import.py` import/rates · import/prices(file | file_key · user = 화면 캐시) → * 확인 창(짝 표 · 식 다름 · 짝 없음 n) → [적용] = 답 `patch` 를 캐시에 얹음 · [저장] 때 서버 정본 * 올린 파일 = 내역 줄 · 요율 · 단가표 세 길이 같은 표지(file_key · `last`) — 같은 파일 다시 안 올림 @@ -65,7 +65,7 @@ async function post(projectId: string, path: string, body: BodyInit, json = fals /** 받은 캐시 = 지금 캐시 + 내역 덮개 · 원가계산서 값(조달수수료율 · 요율) · 단가표 덮개(707-7) */ function merged(user: Row, made: Row): Row { - const keep = ["labor_cover", "machine_cover", "materials"].filter((k) => k in made); + const keep = ["materials"].filter((k) => k in made); // 841-1 노임 · 기계 덮개 걷음 return { ...user, bill: made.bill, @@ -227,7 +227,7 @@ export function createBillImport( later ? `원가계산서 요율 — ${later}` : "원가계산서 요율 — 율 덮개", { checked: !later, disabled: !!later }, ); - const prices = createCheckbox("단가표 — 노임 · 자재 · 기계 단가를 이 공사 단가 책에 덮음", { + const prices = createCheckbox("단가표 — 자재 단가를 이 공사 자재단가대비표에 덮음", { checked: true, }); const take = createCheckbox("넘겨받기 — 불러온 수량을 씀(수량 산출 값은 곁에)", { @@ -330,9 +330,7 @@ type Kind = "rates" | "prices"; const TABS: Partial> = { total: "rates", cost: "rates", - labor: "prices", - machine: "prices", - compare: "prices", + compare: "prices", // 841-1 노임 · 기계 단가 덮개 걷음 — 자재단가대비표만 }; const LABEL: Record = { rates: "엑셀에서 요율 불러오기", diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts index 8178fc13a..d10a67f4f 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts @@ -12,23 +12,22 @@ * · 「견적서」 칸(견적 묶음 아래 · 183-2) 누름 = 파일 모달(`B09_Estimation_UI_Tabs_Base_Quote`)(166-4) * 견적서 글자 = 견적 단가 수기 줄만(185-5 · 나머지 빈칸 · 빈칸 누름 = 안 엶) * · 출처 묶음 = 단가 칸 하나 · 출처 뜻(M01 면수 · 유가 기준일)은 단가 칸 호버(185-4 `sourceInfo`) - * 이동 = 재료비목록표 줄 → 대비표 · 중기목록표 줄 → 중기사용료 블록 · 블록 줄 → 경비 · 기초 · 대비표 + * 이동 = 재료비목록표 줄 → 대비표 + * 841-1 노무비목록표 · 중기목록표 = 프로젝트 사본(M02) 엔진 단가 나열 · 경비목록표 · 중기사용료 · + * 환율및기초자료 = 「—」 한 줄 + 까닭 한 줄(B09 가 기계 시간당 · 운전사 노임을 따로 셈하던 탭) * ========================================================================== */ import type { MapContextMenuItem } from "@ui/ui_template_context_menu"; import type { DataRow } from "@ui/ui_template_data_table"; import { SOURCES, type Row } from "./B09_Estimation_Calc_Base"; import { formatPrice } from "../common_util/common_util_money"; -import { dateText } from "../common_util/common_util_time"; import { openQuotes } from "./B09_Estimation_UI_Tabs_Base_Quote"; import { - blockLevel, money, parseNumber, qty, text, type TabBuild, - type TabId, type TabTable, } from "./B09_Estimation_UI_Tabs"; @@ -51,69 +50,34 @@ function priceList(rows: Row[], unitPrice: string, hint: string): TabTable { }; } -export const buildLabor: TabBuild = ({ sheets, payload }) => +/** 841-1 B09 가 따로 셈하던 탭 — 「—」 한 줄 + 까닭 한 줄(값 = M02 단가산출 그대로) */ +const GONE = + "M02 단가산출 값을 그대로 씀 — B09 가 따로 셈하지 않음(기계 시간당 · 운전사 노임 포함)"; +const gone = (columns: TabTable["columns"]): TabTable => ({ + columns, + rows: [{ key: "-", name: "—" }], + hint: GONE, +}); + +export const buildLabor: TabBuild = ({ sheets }) => priceList( sheets.labor || [], "단가(원/인)", - `이 공사 단가 책(${madeText(payload)}) 기준 · 새 노임은 [단가 갱신]`, + "대가 · 산근이 쓰는 노임 = 프로젝트 사본(M02) 값 나열 · 새 값은 [단가 갱신]", ); -export const buildExpense: TabBuild = ({ sheets, payload }) => - priceList( - sheets.expense || [], - "기준가(천원)", - `이 공사 단가 책(${madeText(payload)}) 기준 · 새 노임은 [단가 갱신]`, - ); +export const buildExpense: TabBuild = () => gone(priceList([], "기준가(천원)", "").columns); -function madeText(payload: Row): string { - return payload.book?.made ? `${dateText(String(payload.book.made))} 정함` : "아직 안 정함"; -} - -export const buildBasis: TabBuild = ({ sheets }): TabTable => { - const basis: Row = sheets.basis || { fx: [], drivers: [] }; - const rows: DataRow[] = [ - ...basis.fx.map((f: Row) => ({ - part: "1. 환율", - key: f.key, - name: f.name, - spec: f.date, - unit: f.unit, - day: null, - factor: "", - value: f.value, - })), - ...basis.drivers.map((d: Row) => ({ - part: "2. 인건비", - key: d.key, - name: d.name, - spec: `직종 ${d.key}`, - unit: "원/시간", - day: d.day, - factor: d.factor, - value: d.hour, - labor_key: d.labor_key, - })), - ]; - return { - columns: [ - text("part", "구분", 90), - text("name", "명칭", 160), - text("spec", "규격 · 기준일", 120), - text("unit", "단위", 80), - money("day", "일당(원)"), - text("factor", "시간 환산", 160), - money("value", "값", undefined, 2), - ], - rows, - frozen: 2, - mergeSame: ["part"], - hint: "인건비 줄을 누르면 노무비목록표로 이동", - link: (i) => { - const row = rows[i]; - return row?.labor_key ? { tab: "labor", key: String(row.labor_key) } : null; - }, - }; -}; +export const buildBasis: TabBuild = (): TabTable => + gone([ + text("part", "구분", 90), + text("name", "명칭", 160), + text("spec", "규격 · 기준일", 120), + text("unit", "단위", 80), + money("day", "일당(원)"), + text("factor", "시간 환산", 160), + money("value", "값", undefined, 2), + ]); // 841-1 기본 표시(M02 자재표시)가 있어도 끌 수 있게 — 별도 다음 = 사급(표시 끔) const SUPPLY_NEXT: Record = { "": "관급", 관급: "별도", 별도: "사급" }; @@ -326,79 +290,18 @@ export const buildMachine: TabBuild = ({ sheets }): TabTable => { ], rows, frozen: 2, - hint: LINK_HINT, - link: (i) => (rows[i] ? { tab: "machine_blocks", key: String(rows[i].key) } : null), + hint: "대가 · 산근이 쓰는 기계 시간당 = 프로젝트 사본(M02 단가산출) 값 나열 · 새 값은 [단가 갱신]", }; }; -const KIND_TAB: Record = { - expense: "expense", - labor: "basis", - material: "compare", -}; - -export const buildMachineBlocks: TabBuild = ({ sheets }): TabTable => { - const rows: DataRow[] = []; - const links: Array<{ tab: TabId; key: string } | null> = []; - (sheets.machine_blocks || []).forEach((b: Row, n: number) => { - rows.push({ - key: b.key, - name: `제 ${n + 1} 호표 ${b.name}`, - no: n + 1, - title: b.name, // 171-3 묶음 카드 머리 - spec: b.spec, - unit: b.unit, - }); - links.push(null); - for (const ln of b.lines) { - const at = (part: string): number | null => (ln.kind === part ? ln.price : null); - const amt = (part: string): number | null => (ln.kind === part ? ln.amount : null); - rows.push({ - key: `${b.key}|${ln.name}`, - name: ln.name, - spec: ln.spec, - qty: ln.qty, - unit: ln.unit, - sum_price: ln.price, - sum: ln.amount, - labor_price: at("labor"), - labor: amt("labor"), - material_price: at("material"), - material: amt("material"), - expense_price: at("expense"), - expense: amt("expense"), - }); - links.push(ln.ref?.key ? { tab: KIND_TAB[ln.kind] ?? "compare", key: ln.ref.key } : null); - } - rows.push({ - key: `${b.key}|합계`, - name: "합 계", - sum: b.sum, - labor: b.labor, - material: b.material, - expense: b.expense, - }); - links.push(null); - }); - const pair = (key: string, label: string) => [ - money(`${key}_price`, "단가", [label], 3), - money(key, "금액", [label], 1), - ]; - return { - columns: [ - text("name", "명칭", 220), - text("spec", "규격", 120), - qty("qty", "수량"), - text("unit", "단위", 56), - ...pair("sum", "합계"), - ...pair("labor", "노무비"), - ...pair("material", "재료비"), - ...pair("expense", "경비"), - ], - rows, - frozen: 1, - hint: LINK_HINT, - link: (i) => links[i] ?? null, - level: blockLevel("name", (i) => !String(rows[i]?.key).includes("|")), - }; -}; +export const buildMachineBlocks: TabBuild = (): TabTable => + gone([ + text("name", "명칭", 220), + text("spec", "규격", 120), + qty("qty", "수량"), + text("unit", "단위", 56), + money("sum", "금액", ["합계"], 1), + money("labor", "금액", ["노무비"], 1), + money("material", "금액", ["재료비"], 1), + money("expense", "금액", ["경비"], 1), + ]); diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold.ts index 483889634..82ac039dd 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold.ts @@ -4,8 +4,8 @@ * * 값 = 일위대가표와 같은 셈(`sheets.daega_blocks` · `B09_Estimation_Calc_Labor`) — 펼친 값 = 일위대가표 값 * 줄 = 호표 줄(품명 · 규격 · 품 · 수량 · 단위 · 노 · 재 · 경 단가 · 금액 · 품셈 값) · 부른 대가 = 그 아래 「ㄴ」(3단까지) - * 고친 칸 덮개(702-2) = 품 · 수량 · 단가 칸 두 번 눌러 고침 → 사용자 값 `daega_cover` - * {블록 cover: {줄 cid: {qty · labor · material · expense}}} · 캐시에 바로 · 셈 짝이 바로 다시 셈 · + * 고친 칸 덮개(702-2) = 품 칸 두 번 눌러 고침 → 사용자 값 `daega_cover`(841-1 단가 칸 걷음) + * {블록 cover: {줄 cid: {qty}}} · 캐시에 바로 · 셈 짝이 바로 다시 셈 · * [저장] 때 서버가 덮개 입력값으로 정본을 다시 셈 · 노임 새 판 · [재적용] 뒤에도 남음(702-4) * 고친 칸 = 칸 색(`is-edited`) · 「품셈 값」 칸 = 셈 값 · 우클릭 [품셈 값으로] · 빈칸 입력 = 되돌림 * M02 단가산출 블록 줄(`sk` · 719-2) = 되돌림 [단가산출 값으로](셈 값 = 단가산출 값) · 곁 글 = 품셈 값(`poom`) @@ -37,13 +37,8 @@ import { } from "../M02_MasterTemplete/M02_MasterTemplete_Hopyo_Relink"; import { relinkRows, type PasteCols } from "../M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api"; -/** 고칠 칸 → 덮개 칸 */ -const FIELD: Record = { - qty: "qty", - labor_price: "labor", - material_price: "material", - expense_price: "expense", -}; +/** 고칠 칸 → 덮개 칸 — 841-1 품만(단가 = M02 사본 값 · 고치지 않음) */ +const FIELD: Record = { qty: "qty" }; const LABEL: Record = { qty: "품", labor: "노", material: "재", expense: "경" }; /** 부른 대가 펼침 깊이 */ const DEPTH = 3;