Merge remote-tracking branch 'origin/dev' into sub_laptop_5

This commit is contained in:
2026-10-31 16:48:51 +09:00
25 changed files with 1046 additions and 38 deletions
+1
View File
@@ -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);
+31 -2
View File
@@ -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)
]
+32 -5
View File
@@ -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,
@@ -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
}
@@ -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
+2 -2
View File
@@ -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)
+85 -2
View File
@@ -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}
+9 -1
View File
@@ -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
+184 -2
View File
@@ -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<Record<TabId, Kind>> = {
total: "rates",
cost: "rates",
labor: "prices",
machine: "prices",
compare: "prices",
};
const LABEL: Record<Kind, string> = {
rates: "엑셀에서 요율 불러오기",
prices: "엑셀에서 단가표 불러오기",
};
const PART: Record<string, string> = { labor: "노임", material: "자재", machine: "기계" };
/** 마지막에 올린 파일(서버 기억 열쇠) — 같은 파일 다시 씀 */
let last: { key: string; name: string } | null = null;
async function postSheet(kind: Kind, ctx: TabContext, file: File | null): Promise<Row> {
const form = new FormData();
if (file) form.append("file", file);
else if (last) form.append("file_key", last.key);
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];
}
+2 -1
View File
@@ -42,7 +42,7 @@ import { createCheckPanel } from "./B09_Estimation_UI_Check";
import { createLiveCalc, markWaiting, waitingKeys, withoutWaiting } from "./B09_Estimation_UI_Live";
import { tableOptionsOf, tabTableOf } from "./B09_Estimation_UI_Builders";
import { createDock, linkMode } from "./B09_Estimation_UI_Dock";
import { 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;
+2 -2
View File
@@ -132,10 +132,10 @@ export interface InputsValueHandle {
const VALUE_TEXT = {
title: "넣을 값",
missing: "빠진 로직 {value}",
edit: "넣을 값 수정",
edit: "옵션 고치기",
none: "넣을 값이 없음 — 단가산출 로직에 입력이 없음",
auto: "자동값",
unconfirmed: "확인 안 함",
unconfirmed: "값 확인 전",
confirmed: "✓ 확인함",
confirmBox: "확인",
summary: "넣을 값 {value}",
@@ -58,6 +58,7 @@ export const PART_WORD: Record<CardKind, string> = {
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<void>,
) => {
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<CardKind, string> = {
structure: TEXT.edit,
area: TEXT.edit,
labor: TEXT.edit,
labor: opts.picker ? TEXT.editOptions : TEXT.edit,
};
const root = el("div", {
@@ -56,7 +56,7 @@ export function openLaborInput(opt: LaborInputOptions): void {
const draft: LaborValues = { ...kept };
const bad = new Set<string>();
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) => {
@@ -40,8 +40,10 @@ import { createText } from "@ui/ui_template_text";
export { withHopyo } from "./M02_MasterTemplete_Hopyo_Pick";
/** 구분 보이는 글(707 낱말) — 값(저장 글)은 그대로 */
const CLASS_TEXT: Record<string, string> = { 실무자료: "실무 엑셀 일위대가" };
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<LaborRef, "키">;
/** 덧붙일 줄 하나 — 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.단위 ?? "",
@@ -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 (%)"],
@@ -1025,3 +1025,7 @@ CPU는 더늘리기는 힘들어보이고 GPU 사용을 위해 부품사용에
## 요구 514 (10-31 · 사용자)
해상도가 높은건 나중에 공공기관과 협업을 하면서 정식으로 요청을 해야할것같아. 일단은 되는 범위에서 진행하는게 좋을것같은데 지금 우리프로그램에서 상당히 중요하게 보고 있는 요소가 속도야. 그래서 등고선이나 미리 계산해야하는것들을 미리 받아 계산하고 있는데 현재 위 데이터를 받으면 용량이 어느정도 되는거야? / 지형 · 0차곡 이웃 목록 밖이라는게 무슨 말이야? / 현재 위데이터를 순차적으로 받으면 랩탑에 무리가 갈까? / 리소스 폴더는 시놀로지 문제 없음. PC 스토리지 문제 없음.. 공공데이터포털의 API키를 env파일에 넣어둔걸로 기억함. 확인
## 요구 515 (10-31 · 사용자)
1. api키는 env파일로 옮겨둬. 2. 공공데이터 포털에서 데이터를 다운받는데 위치는 리소스 폴더가 맞을것같아. 적당히 폴더를 만들어서 넣어줘. 3. 지금 랩탑의 CPU 사용율이 89%로 웹앱에는 표기되는데 작업관리자로 확인해보니까 이용률이 19%밖에 안됨.... 왜 그런지 이유를 찾아봐.
+24 -1
View File
@@ -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)
+4 -4
View File
@@ -76,8 +76,8 @@ def _run_ts(inputs, user):
)
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", source + body],
capture_output=True, text=True, encoding="utf-8", timeout=60,
"--input-type=module-typescript"], # 글은 stdin — 명령 줄 32 K 넘김(Windows)
input=source + body, capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
@@ -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)
+2 -2
View File
@@ -75,8 +75,8 @@ def _rows(sheets):
def _node(source: str) -> str:
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", source],
capture_output=True, text=True, encoding="utf-8", timeout=60,
"--input-type=module-typescript"], # 글은 stdin — 명령 줄 32 K 넘김(Windows)
input=source, capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
return result.stdout
@@ -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",
@@ -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)
+1 -1
View File
@@ -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: [" 추가", " +"],
@@ -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);
}
+1 -1
View File
@@ -39,7 +39,7 @@ const TEXT = {
loadFailed: "복사본을 못 읽음 — {value}",
discard: "저장 안 한 고침이 있음 — 버리고 돌아갈까요?",
outOfRange: "범위 밖 값이 있어 저장하지 못함",
noAreaPill: "면적산출 알약 없음 — 이 복사본 일위대가에 단가산출 로직 · 자재가 없음",
noAreaPill: "면적산출 연결 칸 없음 — 이 복사본 일위대가에 단가산출 로직 · 자재가 없음",
drawing: "도면",
area: "면적산출",
inputs: "넣을 값",
+2 -2
View File
@@ -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"],