Merge branch 'sub_laptop_1' of C:/Aislo_wt/sub_laptop_1 into main_laptop_1
This commit is contained in:
@@ -20,6 +20,7 @@ from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Doc_Copy as dc
|
||||
from M02_MasterTemplete import M02_Doc_Import_Shape as shape
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_Sangeun_Doc as sd
|
||||
from M02_MasterTemplete import M02_Sangeun_Imported as imp
|
||||
@@ -297,6 +298,7 @@ def derive(sk: dict, ctx: tuple | None = None, whole: Any = None) -> dict:
|
||||
_empty(doc) # 빈 산근 = 엑셀 0 원(줄 없음 합) — 「—」 아님
|
||||
marks.append(EMPTY)
|
||||
doc["가져온"] = head | {"표지": marks} | ({"알림": notes} if notes else {})
|
||||
shape.shape(doc, meta) # 809-1 · 요구 704 — 지은 이름 → 엑셀 글 머리 · EST 기계 세 줄 = 한 줄
|
||||
return {**doc, "키": str(sk.get("키") or key), "이름": str(sk.get("이름") or doc.get("이름"))}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
"""M02 가져온 식 수식 문서 다듬기(PLAN 809-1 · 요구 704) — `M02_Doc_Import.derive` 끝에 부름 · 금액 그대로.
|
||||
|
||||
① 소계 · 합계 성격 줄(덧줄 · 단위 「식」 · 수량 없음 · 단가 = 다른 줄 기호 합) 이름 = 엑셀 원 글 머리
|
||||
(`가져온.원본.글줄` 자리 = 그 줄 이름 · 「:」 앞 글 · 글이 수식뿐이면 바로 위 설명 글 ·
|
||||
「합 계」 절 아래 비목 = 「노무비 합계」 꼴) — 「줄_2 · 노무비_2 · 계_2」 같은 지은 이름 걷음
|
||||
② EST 기계 세 줄(같은 자원 · 같은 기계 이음 · 같은 수량 · 노무비 · 재료비 · 경비 하나씩) = 한 기계 줄
|
||||
(이름 = 자원 이름 · 비목 묶음 · 단가 = 기계 이음 칸 없음 · 엑셀 = 값묶음) · 세 줄 기호를 부르던 식 =
|
||||
그 줄 비목 칸(`C_크레인.노무비`) — 셈 값 그대로(줄 끝수 = 비목마다 같은 규칙)
|
||||
엑셀 원 글 · 엔진 줄은 안 고침(문서 겉꼴만) · 못 맞추면 그대로 둠.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
COSTS = ("노무비", "재료비", "경비")
|
||||
_MADE = re.compile(r"^(?:줄|노무비|재료비|경비|계|소계|합계)?_\d+$")
|
||||
_SECTION = re.compile(r"^\s*\d+\s*\.\s*(.+?)\s*$")
|
||||
|
||||
|
||||
def _flat(text: Any) -> str:
|
||||
return re.sub(r"\s+", "", str(text or ""))
|
||||
|
||||
|
||||
def _head(text: str) -> str:
|
||||
"""「노무비 : 13,944.5 + …」 → 「노무비」 · 머리 없음 = 빈 글."""
|
||||
head = text.split(":", 1)[0] if ":" in text else ""
|
||||
return _flat(head)
|
||||
|
||||
|
||||
def names(doc: dict, lines: list[dict]) -> None:
|
||||
"""① 지은 이름(「줄_2 · 노무비_2 · 계_2」) → 엑셀 원 글 머리."""
|
||||
section, note, by = "", "", {}
|
||||
for one in lines:
|
||||
text = str(one.get("글") or "")
|
||||
if hit := _SECTION.match(text):
|
||||
section, note = _flat(hit.group(1)), ""
|
||||
continue
|
||||
spot = one.get("자리")
|
||||
if not spot:
|
||||
note = text.strip()
|
||||
continue
|
||||
head = _head(text) or note
|
||||
if head and section in ("합계", "계") and head in COSTS:
|
||||
head = f"{head} 합계"
|
||||
by[str(spot)] = head
|
||||
note = ""
|
||||
taken = {str(x.get("이름")) for x in doc.get("금액") or []}
|
||||
for line in doc.get("금액") or []:
|
||||
name = str(line.get("이름") or "")
|
||||
new = by.get(name)
|
||||
if _MADE.match(name) and new and new not in taken: # 단계 · 다른 줄 이름과 안 겹칠 때만
|
||||
line["이름"] = new
|
||||
taken.add(new)
|
||||
_rename_steps(doc, {name: new})
|
||||
|
||||
|
||||
def _rename_steps(doc: dict, swap: dict[str, str]) -> None:
|
||||
"""단계 줄 목록(이름으로 묶음) — 바뀐 이름 따라감 · 합친 줄 = 한 번."""
|
||||
for st in doc.get("단계") or []:
|
||||
got = []
|
||||
for n in st.get("줄") or []:
|
||||
n = swap.get(n, n)
|
||||
if n not in got:
|
||||
got.append(n)
|
||||
st["줄"] = got
|
||||
|
||||
|
||||
def _bundle_of(doc: dict, at: int) -> list[int] | None:
|
||||
"""at 에서 시작하는 기계 세 줄(노무비 · 재료비 · 경비) — 같은 기계 이음 · 같은 수량."""
|
||||
money = doc.get("금액") or []
|
||||
three = money[at : at + 3]
|
||||
if len(three) < 3 or [x.get("비목") for x in three] != list(COSTS):
|
||||
return None
|
||||
vars_ = {v.get("열쇠"): v for v in doc.get("변수") or []}
|
||||
links = [(vars_.get(str(x.get("단가"))) or {}).get("연결") or {} for x in three]
|
||||
if any(x.get("갈래") != "단가산출" or "기계" not in (x.get("입력") or {}) for x in links):
|
||||
return None
|
||||
same = {(x.get("키"), str(x.get("입력"))) for x in links}
|
||||
if len(same) != 1 or [x.get("칸") for x in links] != list(COSTS):
|
||||
return None
|
||||
for k in ("수량", "규격", "자리", "합밖"):
|
||||
if len({str(x.get(k) or "") for x in three}) != 1:
|
||||
return None
|
||||
names = {str(x.get("이름")) for x in three}
|
||||
for st in doc.get("단계") or []: # 단계가 셋 중 일부만 담으면 못 합침
|
||||
if 0 < len(names & set(st.get("줄") or [])) < 3:
|
||||
return None
|
||||
return [at, at + 1, at + 2]
|
||||
|
||||
|
||||
def _place(doc: dict, name: str, used: set[str]) -> str:
|
||||
"""기계 줄 번지 — 자원 이름 첫 낱말(괄호 · 규격 · 기호 뺌) · 겹치면 숫자."""
|
||||
word = re.sub(r"[^\w]|_", "", re.split(r"[\s(]", name.strip())[0]) or "기계"
|
||||
if word[0].isdigit():
|
||||
word = f"기계{word}"
|
||||
have = used | {str(x.get("번지")) for x in doc.get("금액") or [] if x.get("번지")}
|
||||
got, n = word, 1
|
||||
while got in have:
|
||||
n += 1
|
||||
got = f"{word}{n}"
|
||||
used.add(got)
|
||||
return got
|
||||
|
||||
|
||||
def _rewrite(text: Any, swap: dict[str, str]) -> Any:
|
||||
if not isinstance(text, str) or not swap:
|
||||
return text
|
||||
keys = "|".join(re.escape(k) for k in sorted(swap, key=len, reverse=True))
|
||||
pat = re.compile(rf"(?<![^\W_])(?:{keys})(?![^\W_]|[′'.])")
|
||||
return pat.sub(lambda m: swap[m.group(0)], text)
|
||||
|
||||
|
||||
def machines(doc: dict, title: dict[str, str]) -> None:
|
||||
"""② EST 기계 세 줄 → 한 기계 줄(비목 묶음) · 부르던 식 = 비목 칸. `title` = {줄 이름: 자원 이름}."""
|
||||
money = doc.get("금액") or []
|
||||
vars_ = {v.get("열쇠"): v for v in doc.get("변수") or []}
|
||||
out, swap, at, used = [], {}, 0, set()
|
||||
while at < len(money):
|
||||
idx = _bundle_of(doc, at)
|
||||
if idx is None:
|
||||
out.append(money[at])
|
||||
at += 1
|
||||
continue
|
||||
three = [money[i] for i in idx]
|
||||
first = vars_[str(three[0]["단가"])]
|
||||
link = {k: v for k, v in first["연결"].items() if k not in ("칸", "엑셀")}
|
||||
excel = {
|
||||
c: Decimal(str((vars_[str(x["단가"])]["연결"]).get("엑셀") or 0))
|
||||
for c, x in zip(COSTS, three)
|
||||
}
|
||||
link["엑셀"] = excel
|
||||
name = title.get(str(three[0].get("이름"))) or str(three[0].get("이름"))
|
||||
first["연결"], first["이름"] = link, f"{name} 단가"
|
||||
if name in {str(x.get("이름")) for x in money} - {str(x.get("이름")) for x in three}:
|
||||
name = f"{name} ({at + 1})" # 같은 이름 줄이 있으면 겹치지 않게
|
||||
line = {k: v for k, v in three[0].items() if k != "기호"} | {"이름": name, "비목": "묶음"}
|
||||
_rename_steps(doc, {str(x.get("이름")): name for x in three})
|
||||
if any(x.get("기호") for x in three): # 부르던 식 = 번지 칸(엔진 `번지.X.노무비`)
|
||||
place = _place(doc, name, used)
|
||||
line["번지"] = place
|
||||
for c, x in zip(COSTS, three):
|
||||
if x.get("기호"):
|
||||
swap[str(x["기호"])] = f"번지.{place}.{c}"
|
||||
doc["변수"] = [
|
||||
v
|
||||
for v in doc["변수"]
|
||||
if v is first or v.get("열쇠") not in {str(x["단가"]) for x in three[1:]}
|
||||
]
|
||||
out.append(line)
|
||||
at += 3
|
||||
doc["금액"] = out
|
||||
for line in out:
|
||||
for k in ("단가", "수량"):
|
||||
line[k] = _rewrite(line.get(k), swap)
|
||||
for one in doc.get("식") or []:
|
||||
one["식"] = _rewrite(one.get("식"), swap)
|
||||
|
||||
|
||||
def shape(doc: dict, meta: dict) -> dict:
|
||||
"""derive 문서 다듬기 — 원본 글줄 · 엔진 줄 자원 이름."""
|
||||
src = meta.get("원본") or {}
|
||||
names(doc, list(src.get("글줄") or []))
|
||||
title = {}
|
||||
for item in (meta.get("로직") or {}).get("호표") or []:
|
||||
res = item.get("자원") or {}
|
||||
if res.get("갈래") == "기계" and res.get("이름"):
|
||||
title[str(item.get("이름"))] = " ".join(x for x in (res["이름"], res.get("규격")) if x)
|
||||
machines(doc, title)
|
||||
return doc
|
||||
@@ -1,474 +0,0 @@
|
||||
"""M02 일위대가 실무자료 옵션값 읽기(PLAN 785-2 · 요구 641 · 642 · 설계 642-A 2-5 ④-3) —
|
||||
우리 기초 하나 + 엑셀 글감 → 입력값. 찾지 않고 읽음 · 지어 넣는 1 · 0 없음.
|
||||
|
||||
길(앞이 먼저 · 두 길이 다른 값 = 엇갈림 · 앞 값 둠):
|
||||
B1 부르는 호표 줄 규격 · 품명 괄호(`Match.spec_inputs`)
|
||||
B6 같은 호표 다른 줄 — 레미콘 규격 「a-b-c」 → 굵은골재 · 강도 · 슬럼프 · 채움량 = 그 줄 수량 ÷ 이 줄 수량
|
||||
B2 선택값 줄(785-1 Formula) · 글에 없는 고르기 = 「아님 · 해당 없음」(글 · 줄에 칸 이름 · 다른 고름이 없을 때)
|
||||
B3 상수 줄(기호표 `SYMBOLS` · 기호 = 입력 이름 · 「(90˚)」 = 선회각도 · 표 칸 이름 = 기호면 그 표 줄 조건값)
|
||||
B4 대입식 — 우리 줄 수량 식을 중간까지 펴 분자 · 분모 자리 + 단위로 짝(하나뿐일 때만)
|
||||
B5 표 거꾸로 — 못 읽은 찾기 조건 칸 = 엑셀 품과 같은 표 줄(`fit` 검산) · 「감률」 칸 = 품 배수
|
||||
못 읽은 칸 = 비움(지역 · 자재지역 · 조종원직종 = 프로젝트) · 입력 필요(빈 줄을 낳음 · 요소 글에 듦) ·
|
||||
안 씀(그 밖) · 기본 = `master_auto.defaults` 제안(저장 아님). 엔진 몫 `quantities` = 금액 없이 품만.
|
||||
서버 단독 · 읽기만 · 짝 = `M02_Hopyo_Solve`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from decimal import Decimal
|
||||
from typing import Any, Callable
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M02_MasterTemplete import M02_Hopyo_Match as hm
|
||||
|
||||
mf = m01.cm.mf
|
||||
mp = mf.mp
|
||||
mm = mp.mm
|
||||
import master_auto as ma # noqa: E402 — mf 를 불러 scripts 경로가 깔린 뒤
|
||||
import master_unit as mu # noqa: E402
|
||||
|
||||
SKIP = ("지역", "자재지역", "조종원직종")
|
||||
NEG = ("아님", "해당 없음", "없음", "미적용", "사용 안 함")
|
||||
# 상수 기호 → 입력 이름(앞이 먼저 · 기호 = 입력 이름이면 바로)
|
||||
SYMBOLS = {
|
||||
"q": ("버킷용량",),
|
||||
"E": ("E입력",),
|
||||
"Cm": ("싸이클입력",),
|
||||
"L": ("운반거리", "거리"),
|
||||
"n": ("운반회수",),
|
||||
"T": ("실작업시간",),
|
||||
"경사도": ("경사각도",),
|
||||
}
|
||||
_WORDS = re.compile(r"[\s::,/()※*·\[\]{}]+")
|
||||
_SAME_UNIT = {"sec": "초", "s": "초", "min": "분", "hr": "시간", "h": "시간"}
|
||||
_ANGLE = re.compile(r"\(\s*(\d+)\s*[˚°]\s*\)")
|
||||
_MIX = re.compile(r"(?<!\d)(\d{2})-(\d{2,3})-(\d{2,3})(?!\d)")
|
||||
MIX_NAMES = ("굵은골재최대치수", "강도", "슬럼프")
|
||||
CONC = ("레미콘", "콘크리트")
|
||||
|
||||
|
||||
def _num(value: Any) -> Decimal | None:
|
||||
try:
|
||||
return None if value in (None, "") else Decimal(str(value))
|
||||
except ArithmeticError:
|
||||
return None
|
||||
|
||||
|
||||
def plain(value: Any) -> Any:
|
||||
"""JSON 수 — Decimal → int · float(글은 그대로)."""
|
||||
if isinstance(value, Decimal):
|
||||
value = value.normalize()
|
||||
return int(value) if value == value.to_integral_value() else float(value)
|
||||
return value
|
||||
|
||||
|
||||
def specs_of(row: dict) -> list[dict]:
|
||||
return mf.inputs_of(row) if isinstance(row.get("흐름"), dict) else row.get("입력", [])
|
||||
|
||||
|
||||
def _pick(spec: dict, value: Any) -> Any:
|
||||
"""고르기 칸이면 같은 수 · 같은 글의 고름(「080」 = 80) · 없으면 None · 고르기 아니면 수."""
|
||||
picks = spec.get("고르기")
|
||||
if not picks:
|
||||
return _num(value)
|
||||
hit = next((o for o in picks if str(o) == str(value)), None)
|
||||
n = _num(value)
|
||||
if hit is None and n is not None:
|
||||
hit = next((o for o in picks if _num(o) == n), None)
|
||||
return hit
|
||||
|
||||
|
||||
# ── 엔진 몫(품만) ─────────────────────────────────────────────────────
|
||||
def _env(master: mf.Master, row: dict, given: dict) -> dict:
|
||||
"""입력 검사(고르기 · 범위 · 기계 키) — 빈 칸은 자리값으로 검사만 하고 None 으로 되돌림."""
|
||||
holes = {}
|
||||
for spec in specs_of(row):
|
||||
if given.get(spec["이름"]) is None:
|
||||
span = spec.get("범위")
|
||||
holes[spec["이름"]] = (spec.get("고르기") or [None])[0] or (span[0] if span else 0)
|
||||
env = mf._inputs(row, {**{s["이름"]: None for s in specs_of(row)}, **given, **holes}, master)
|
||||
return {**env, **dict.fromkeys(holes)}
|
||||
|
||||
|
||||
def _rate(extra: dict, env: dict, master: mf.Master) -> Decimal | None:
|
||||
"""덧줄 율 — 식 꼴 「(기준) × 율 / 100」 의 율 ÷ 100 · 다른 꼴 = None."""
|
||||
node = mf.parse(extra["식"])
|
||||
if node[0] == "bin" and node[1] == "/" and node[3] == ("num", Decimal(100)):
|
||||
left = node[2]
|
||||
if left[0] == "bin" and left[1] == "*":
|
||||
return mf.evaluate(left[3], env, master) / 100
|
||||
return None
|
||||
|
||||
|
||||
def quantities(master: mf.Master, key: str, given: dict) -> list[dict]:
|
||||
"""로직 줄마다 품 `{이름, 종류, 단위, 요소, 품, 까닭?}` + 덧줄 율(종류 「율」) — 금액 안 셈 ·
|
||||
빈 칸을 쓰는 줄만 품 None + 까닭(`mf.run` 과 같은 길)."""
|
||||
row = master.logic(key)
|
||||
if isinstance(row.get("흐름"), dict): # 흐름 로직 — 엔진 한 번(빈 칸 = 자리값 없음 → 못 셈)
|
||||
try:
|
||||
got = mf.run(master, key, given)
|
||||
except mf.FormulaError as e:
|
||||
return [{"이름": "", "종류": "", "단위": "", "품": None, "까닭": str(e)}]
|
||||
return [{**x, "종류": "", "품": x.get("수량")} for x in got.get("줄", [])]
|
||||
env = _env(master, row, given)
|
||||
for step in row.get("중간", []):
|
||||
try:
|
||||
env[step["이름"]] = mf.evaluate(mf.parse(step["식"]), env, master)
|
||||
except mf.EmptyInput as e:
|
||||
env[step["이름"]] = mf.Blank(str(e))
|
||||
out = []
|
||||
for item in row.get("호표", []):
|
||||
one = {k: item.get(k) for k in ("이름", "종류", "단위", "요소", "비목")}
|
||||
try:
|
||||
one["품"] = mf.evaluate(mf.parse(item["수량"]), env, master)
|
||||
except mf.EmptyInput as e:
|
||||
one |= {"품": None, "까닭": str(e)}
|
||||
out.append(one)
|
||||
for extra in row.get("덧줄", []):
|
||||
one = {"이름": extra["이름"], "종류": "율", "단위": "%", "비목": extra.get("비목")}
|
||||
try:
|
||||
one["품"] = _rate(extra, env, master)
|
||||
except mf.EmptyInput as e:
|
||||
one |= {"품": None, "까닭": str(e)}
|
||||
out.append(one)
|
||||
return out
|
||||
|
||||
|
||||
# ── 식 풀기 ───────────────────────────────────────────────────────────
|
||||
def _names(node) -> set:
|
||||
return {p[1] for p in mf.walk(node) if p[0] == "name"}
|
||||
|
||||
|
||||
def _mids(row: dict) -> dict:
|
||||
return {s["이름"]: mf.parse(s["식"]) for s in row.get("중간", [])}
|
||||
|
||||
|
||||
def deps(row: dict, text: str) -> set:
|
||||
"""식이 기대는 입력 이름 — 중간을 거쳐 폄(만약 갈래 모두 · 넉넉히)."""
|
||||
mids, seen, todo = _mids(row), set(), _names(mf.parse(text))
|
||||
while todo:
|
||||
name = todo.pop()
|
||||
if name in seen:
|
||||
continue
|
||||
seen.add(name)
|
||||
if name in mids:
|
||||
todo |= _names(mids[name])
|
||||
return seen
|
||||
|
||||
|
||||
def _place(node, side: str, mids: dict, out: dict, depth: int = 0) -> None:
|
||||
"""이름마다 분자 · 분모 자리 — 「/」 오른쪽이 뒤집힘 · 만약은 두 갈래만 · 찾기 · 함수 안은 안 봄."""
|
||||
kind = node[0]
|
||||
if kind == "name":
|
||||
if node[1] in mids and depth < 20:
|
||||
_place(mids[node[1]], side, mids, out, depth + 1)
|
||||
else:
|
||||
out.setdefault(node[1], set()).add(side)
|
||||
elif kind == "bin" and node[1] in ("*", "/", "+", "-"):
|
||||
_place(node[2], side, mids, out, depth)
|
||||
flip = {"분자": "분모", "분모": "분자"}[side] if node[1] == "/" else side
|
||||
_place(node[3], flip, mids, out, depth)
|
||||
elif kind == "call" and node[1] == "만약":
|
||||
for arm in node[2][1:]:
|
||||
_place(arm, side, mids, out, depth)
|
||||
|
||||
|
||||
def _element_names(row: dict, element: Any) -> set:
|
||||
"""요소가 기대는 입력 — 글 = 식 · 묶음 = 「{이름}」 구멍."""
|
||||
if isinstance(element, str):
|
||||
return deps(row, element)
|
||||
if isinstance(element, dict):
|
||||
return {n for v in element.values() for n in re.findall(r"\{([^{}]+)\}", str(v))}
|
||||
return set()
|
||||
|
||||
|
||||
def places(row: dict) -> dict:
|
||||
"""입력 이름 → {분자 | 분모} — 호표 줄 수량 식에서."""
|
||||
out: dict = {}
|
||||
mids = _mids(row)
|
||||
for item in row.get("호표", []):
|
||||
_place(mf.parse(item["수량"]), "분자", mids, out)
|
||||
return out
|
||||
|
||||
|
||||
def finds(master: mf.Master, row: dict, depth: int = 0) -> dict:
|
||||
"""찾기 조건 칸으로 쓰인 입력 → [(표, 조건)] — 「로직(키, 칸=이름)」 한 겹 안도 이름을 옮겨 봄."""
|
||||
out = {k: list(v) for k, v in ma._uses(row)[0].items()}
|
||||
if depth > 1:
|
||||
return out
|
||||
for text in ma._texts(row):
|
||||
for part in mf.walk(mf.parse(text)):
|
||||
if part[0] != "logic" or not isinstance(part[1], str) or "{" in part[1]:
|
||||
continue
|
||||
try:
|
||||
inner = finds(master, master.logic(part[1]), depth + 1)
|
||||
except mf.FormulaError:
|
||||
continue
|
||||
passed = {k: v[1] for k, v in part[2] if v[0] == "name"}
|
||||
for name, uses in inner.items():
|
||||
if name in passed:
|
||||
out.setdefault(passed[name], []).extend(uses)
|
||||
return out
|
||||
|
||||
|
||||
# ── 읽기 ─────────────────────────────────────────────────────────────
|
||||
class _Got:
|
||||
"""읽은 값 · 출처 · 엇갈림."""
|
||||
|
||||
def __init__(self, specs: list[dict]) -> None:
|
||||
self.specs = {s["이름"]: s for s in specs}
|
||||
self.value: dict = {}
|
||||
self.where: dict = {}
|
||||
self.clash: dict = {}
|
||||
|
||||
def put(self, name: str, value: Any, how: str, at: Any = None, text: Any = "") -> None:
|
||||
spec = self.specs.get(name)
|
||||
value = _pick(spec, value) if spec else None
|
||||
if value is None or name in SKIP:
|
||||
return
|
||||
mine = {"값": plain(value), "길": how, "행": at}
|
||||
if name not in self.value:
|
||||
self.value[name], self.where[name] = value, {"길": how, "행": at, "글": str(text)[:120]}
|
||||
elif self.value[name] != value:
|
||||
first = {
|
||||
"값": plain(self.value[name]),
|
||||
**{k: self.where[name][k] for k in ("길", "행")},
|
||||
}
|
||||
self.clash.setdefault(name, [first])
|
||||
if mine not in self.clash[name]:
|
||||
self.clash[name].append(mine)
|
||||
|
||||
|
||||
def _b1(got: _Got, line: dict | None, how: str = "B1") -> None:
|
||||
if not line:
|
||||
return
|
||||
values, _ = hm.spec_inputs(line, list(got.specs.values()))
|
||||
text = f"{line.get('이름') or ''} {line.get('규격') or ''}".strip()
|
||||
for name, value in values.items():
|
||||
got.put(name, value, how, line.get("행"), text)
|
||||
|
||||
|
||||
def _b6(got: _Got, line: dict | None, same: list[dict]) -> None:
|
||||
for other in same or []:
|
||||
text = f"{other.get('이름') or ''} {other.get('규격') or ''}"
|
||||
mix = _MIX.search(text)
|
||||
if not mix or not any(w in text for w in CONC):
|
||||
continue
|
||||
for name, value in zip(MIX_NAMES, mix.groups(), strict=True):
|
||||
got.put(name, value, "B6", other.get("행"), text)
|
||||
have, mine = _num(other.get("수량")), _num((line or {}).get("수량"))
|
||||
for name, spec in got.specs.items():
|
||||
unit = mm.plain_unit(spec.get("단위"))
|
||||
fill = "콘크리트" in name and unit.count("/") == 1 and have and mine
|
||||
if fill and unit.split("/") == [
|
||||
mm.plain_unit(other.get("단위")),
|
||||
mm.plain_unit(line.get("단위")),
|
||||
]:
|
||||
got.put(
|
||||
name, (have / mine).quantize(Decimal("0.0001")), "B6", other.get("행"), text
|
||||
)
|
||||
|
||||
|
||||
def _b2(got: _Got, picks: list[dict], text: str, keys: dict) -> None:
|
||||
"""선택값 줄 · 글에 없는 켜기 칸 = 아님(표 줄을 고르는 찾기 조건 칸은 B5 몫 · 뺌)."""
|
||||
for one in picks or []: # 고름 글 = 줄의 낱말 하나와 같음(「사용횟수」 ≠ 「사용」)
|
||||
words = {mm.plain_spec(w) for w in _WORDS.split(str(one.get("글") or "")) if w}
|
||||
for name, spec in got.specs.items():
|
||||
hits = [o for o in spec.get("고르기") or [] if mm.plain_spec(str(o)) in words]
|
||||
if len(hits) == 1:
|
||||
got.put(name, hits[0], "B2", one.get("행"), one.get("글"))
|
||||
flat = mm.plain_spec(text)
|
||||
for name, spec in got.specs.items():
|
||||
opts = spec.get("고르기") or []
|
||||
neg = next((o for o in opts if str(o) in NEG), None)
|
||||
if neg is None or name in got.value or name in keys:
|
||||
continue
|
||||
words = [name, *(hm._PAREN.sub("", str(o)) for o in opts if o != neg)]
|
||||
if not any(len(w) > 2 and mm.plain_spec(w) in flat for w in words):
|
||||
got.put(name, neg, "B2", None, f"글에 「{name}」 없음")
|
||||
|
||||
|
||||
def _unit_ok(have: Any, want: Any) -> bool:
|
||||
"""상수 단위 ↔ 입력 단위 — 한쪽이 없음 · 같음(「회/일」 = 회) · 같은 성격(길이 등)."""
|
||||
a, b = (mm.plain_unit(str(u or "").split("/")[0]) for u in (have, want))
|
||||
a, b = _SAME_UNIT.get(a, a), _SAME_UNIT.get(b, b)
|
||||
return not a or not b or a == b or mu.same_kind(a, b)
|
||||
|
||||
|
||||
def _b3(got: _Got, consts: list[dict], master: mf.Master, uses: dict) -> None:
|
||||
for c in consts or []:
|
||||
sym, value, at, text = str(c.get("기호") or ""), c.get("값"), c.get("행"), c.get("글") or ""
|
||||
angle = _ANGLE.search(f"({c.get('덧') or ''}) {c.get('단위') or ''} {text}")
|
||||
if angle:
|
||||
got.put("선회각도", angle.group(1), "B3", at, text)
|
||||
names = [
|
||||
n
|
||||
for n in (c.get("이름"), sym, *SYMBOLS.get(sym, ()))
|
||||
if n in got.specs and _unit_ok(c.get("단위"), got.specs[n].get("단위"))
|
||||
]
|
||||
if names:
|
||||
unit = got.specs[names[0]].get("단위")
|
||||
rate = mu.factor(c.get("단위"), unit) if c.get("단위") and unit else None
|
||||
value = _num(value) * rate if rate and _num(value) is not None else value
|
||||
got.put(
|
||||
names[0],
|
||||
value,
|
||||
"B3",
|
||||
at,
|
||||
f"{sym} = {plain(_num(c.get('값')))}{c.get('단위') or ''}",
|
||||
)
|
||||
continue
|
||||
for name, refs in uses.items(): # 표 칸 이름 = 기호 → 그 줄 조건값(K = 0.55 → 버킷조건)
|
||||
if name in got.value or name not in got.specs:
|
||||
continue
|
||||
for ref, cond in refs:
|
||||
rows = [r for r in master.get(ref).get("줄", []) if _num(r.get(sym)) == _num(value)]
|
||||
conds = {r.get(cond) for r in rows}
|
||||
if len(conds) == 1 and None not in conds:
|
||||
got.put(name, conds.pop(), "B5", at, f"{sym} = {plain(_num(value))} → {ref}")
|
||||
|
||||
|
||||
def _b4(got: _Got, forms: list[dict], row: dict) -> None:
|
||||
where = places(row)
|
||||
for form in forms or []:
|
||||
for side in ("분자", "분모"):
|
||||
for term in form.get(side) or []:
|
||||
unit = mm.plain_unit(str(term.get("단위") or "").split("/")[0])
|
||||
hits = [
|
||||
n
|
||||
for n, s in got.specs.items()
|
||||
if side in where.get(n, ()) and unit and mm.plain_unit(s.get("단위")) == unit
|
||||
]
|
||||
value = term.get("수", term.get("값"))
|
||||
if term.get("기호") is not None:
|
||||
value = (form.get("대입") or {}).get(term["기호"])
|
||||
if len(hits) == 1 and _num(value) is not None:
|
||||
got.put(hits[0], value, "B4", form.get("행"), form.get("글"))
|
||||
|
||||
|
||||
def _ok(fit: Callable, given: dict) -> dict:
|
||||
try:
|
||||
return fit(given) or {}
|
||||
except mf.FormulaError:
|
||||
return {}
|
||||
|
||||
|
||||
def _b5(got: _Got, master: mf.Master, row: dict, uses: dict, fit: Callable, need: set) -> None:
|
||||
"""못 읽은 찾기 조건 칸 — 표 줄마다 품 검산 · 줄이 갈리면 그 값(같은 값 여럿 = 엇갈림)."""
|
||||
rate = [n for n in need if n.endswith("감률") and n not in got.value]
|
||||
zero = dict.fromkeys(rate, Decimal(0))
|
||||
tables: dict = {}
|
||||
for name in sorted(need):
|
||||
for ref, cond in uses.get(name, []):
|
||||
if name not in got.value and got.specs.get(name, {}).get("고르기"):
|
||||
tables.setdefault(ref, []).append((name, cond))
|
||||
for ref, conds in tables.items():
|
||||
rows, combos = master.get(ref).get("줄", []), []
|
||||
bound = {
|
||||
c: got.value[n]
|
||||
for n, refs in uses.items()
|
||||
if n in got.value
|
||||
for r, c in refs
|
||||
if r == ref
|
||||
}
|
||||
for r in rows:
|
||||
if all(r.get(c) is None or str(r.get(c)) == str(v) for c, v in bound.items()):
|
||||
combo = {n: r.get(c) for n, c in conds}
|
||||
combos += [combo] if combo not in combos else []
|
||||
exact, scaled = [], []
|
||||
for combo in combos:
|
||||
fill = {n: v if v is not None else got.specs[n]["고르기"][0] for n, v in combo.items()}
|
||||
res = _ok(fit, {**got.value, **zero, **fill})
|
||||
if res.get("맞음"):
|
||||
exact.append(combo)
|
||||
elif rate and (res.get("배") or 0) > 1:
|
||||
scaled.append(combo)
|
||||
live = exact or scaled # 그대로 같은 줄이 감률 배 줄보다 먼저
|
||||
if not live or (len(live) == len(combos) > 1):
|
||||
continue
|
||||
for name, _ in conds:
|
||||
seen = {str(c[name]) for c in live if c[name] is not None}
|
||||
for v in [str(o) for o in got.specs[name]["고르기"] if str(o) in seen]:
|
||||
got.put(name, v, "B5", None, f"{ref} 줄 = 엑셀 품")
|
||||
if rate: # 감률 = 엑셀 품 ÷ 우리 품(감률 0)이 모든 노무 · 기계 줄에서 같은 배
|
||||
res = _ok(fit, {**got.value, **zero})
|
||||
k = res.get("배")
|
||||
if k and k > 1:
|
||||
cut = (Decimal(1) - Decimal(1) / Decimal(str(k))) * 100
|
||||
cut = (
|
||||
cut.quantize(Decimal("1"))
|
||||
if abs(cut - cut.quantize(Decimal("1"))) < Decimal("0.5")
|
||||
else cut
|
||||
)
|
||||
if _ok(fit, {**got.value, rate[0]: cut}).get("맞음"):
|
||||
got.put(
|
||||
rate[0],
|
||||
cut,
|
||||
"B5",
|
||||
None,
|
||||
f"품 × {plain(Decimal(str(k)).quantize(Decimal('0.01')))}",
|
||||
)
|
||||
|
||||
|
||||
def bind(master: mf.Master, key: str, src: dict, fit: Callable | None = None) -> dict:
|
||||
"""옵션값 읽기 → `{입력값, 출처, 엇갈림, 안씀, 입력필요, 비움, 기본}` — 설계 642-A 2-5 ④-3."""
|
||||
row = master.logic(key)
|
||||
specs = specs_of(row)
|
||||
got = _Got(specs)
|
||||
line, block = src.get("줄"), src.get("블록") or {}
|
||||
_b1(got, line)
|
||||
if not line and block:
|
||||
_b1(got, {"이름": block.get("이름"), "규격": "", "행": (block.get("행") or [None])[0]})
|
||||
_b6(got, line, src.get("같은줄") or [])
|
||||
uses = finds(master, row) if not isinstance(row.get("흐름"), dict) else {}
|
||||
_b3(got, src.get("상수") or [], master, uses)
|
||||
if not isinstance(row.get("흐름"), dict):
|
||||
_b4(got, src.get("식") or src.get("대입식") or [], row)
|
||||
whole = " ".join(
|
||||
[
|
||||
*(block.get("글") or []),
|
||||
str((line or {}).get("이름") or ""),
|
||||
str((line or {}).get("규격") or ""),
|
||||
]
|
||||
)
|
||||
direct = {} if isinstance(row.get("흐름"), dict) else ma._uses(row)[0]
|
||||
_b2(got, src.get("선택값") or [], whole, direct)
|
||||
if fit:
|
||||
_b5(got, master, row, uses, fit, _needed(master, key, row, got.value))
|
||||
need = _needed(master, key, row, got.value)
|
||||
base = ma.defaults(master, row)
|
||||
unread = [s["이름"] for s in specs if s["이름"] not in got.value]
|
||||
skip = [n for n in unread if n in SKIP]
|
||||
want = [n for n in unread if n in need and n not in SKIP and n not in got.clash]
|
||||
unused = [n for n in unread if n not in skip and n not in want and n not in got.clash]
|
||||
return {
|
||||
"입력값": {k: plain(v) for k, v in got.value.items() if k not in got.clash},
|
||||
"출처": {k: v for k, v in got.where.items() if k not in got.clash},
|
||||
"엇갈림": got.clash,
|
||||
"안씀": unused,
|
||||
"입력필요": want,
|
||||
"비움": skip,
|
||||
"기본": {n: plain(base.get(n)) for n in [*want, *unused, *got.clash]},
|
||||
}
|
||||
|
||||
|
||||
def _needed(master: mf.Master, key: str, row: dict, have: dict) -> set:
|
||||
"""못 읽은 칸 중 쓰이는 것 — 빈 품 줄이 기대는 칸(기본값으로 0 이 되는 줄 = 꺼진 줄 · 뺌) +
|
||||
품 있는 줄의 요소 글에 든 칸(기계 · 지역 등 단가 몫)."""
|
||||
if isinstance(row.get("흐름"), dict):
|
||||
return {s["이름"] for s in specs_of(row)} - set(have)
|
||||
lines = quantities(master, key, have)
|
||||
base = {k: v for k, v in ma.defaults(master, row).items() if v is not None and k not in have}
|
||||
probe = (
|
||||
quantities(master, key, {**base, **have}) if any(x["품"] is None for x in lines) else lines
|
||||
)
|
||||
items = [*row.get("호표", []), *({"수량": x["식"]} for x in row.get("덧줄", []))]
|
||||
need: set = set()
|
||||
for item, now, then in zip(items, lines, probe, strict=True):
|
||||
if now.get("까닭") and then["품"] != 0:
|
||||
need |= deps(row, item["수량"])
|
||||
elif now["품"] not in (None, 0):
|
||||
need |= _element_names(row, item.get("요소"))
|
||||
# ponytail: 0 이 되는 줄 = 꺼진 줄로 봄(기본값 하나로 판정) — 값마다 다른 줄이 나오면 칸마다 검산으로
|
||||
return need - set(have)
|
||||
@@ -5,7 +5,7 @@
|
||||
몸이 비면 = 모두 고정(705-3 · [모두 고정으로 들여오기]).
|
||||
서버 재검사(브라우저 값을 받아 적지 않음 · 금액 · 품 칸 없음) — 차례 = 그 호표 줄 · 대상 하나 =
|
||||
돈 로직 · M01 자재품목 · 확인된 호표 중 하나 · 입력 이름 = 그 로직 입력 · 배 0 넘음 · 몫 (0, 1] ·
|
||||
호표 = 만들 층에 확인된 사전조합(확인 안 된 하위 = 409 · 하위 먼저 = 옮기기 `M02_Hopyo_Make` · 777-1).
|
||||
호표 = 만들 층에 확인된 사전조합(확인 안 된 하위 = 409 · 하위 먼저 = [내 일위대가로] `build_all` · 777-1).
|
||||
호표 하나 = 사전조합 하나 — 이름 = 품명 + 규격 · 단위 = 엑셀 단위 · 구분 「실무자료」 ·
|
||||
상세구분 = 파일 · 본뜬 = {층 「실무자료」, 키 「HP-###### 제N호표」, 파일, 원본 [층, 주인],
|
||||
확인 {누가, 때}} · 만드는 층 = 부르는 층(일위대가 문서 층).
|
||||
@@ -33,7 +33,7 @@
|
||||
`{까닭, 남은줄}`(저장 안 함 · `save` 거짓 = 막지 않고 `남은줄` 만).
|
||||
수량(742-1 · 요구 584-1) = 엑셀 수량을 줄 · 자재 저장 수량(`{손}`)으로 · 같은 값 `본뜬.실무수량 {줄: {차례: 수},
|
||||
자재: {코드: 수}}`(출처 기록 · 엑셀 대조 `M02_Hopyo_Compare` — 하위 호표 것까지 `excel_given`).
|
||||
785-5 — 확인한 줄 몸 = `M02_Hopyo_Make.Lines`(옛 대상 꼴 `put` · 새 고른 것 꼴 `picks` = 몸 `{차례: {고른}}`) ·
|
||||
785-5 — 확인한 줄 몸 = `M02_Hopyo_Make.Lines`(대상 꼴 `put` · 옛 판정 고른 것 길은 809-1 에서 지움) ·
|
||||
여기는 모두 고정 길 · 줄 차례 · 쓰기(`Builder` · `_order` · `_origins` · `build_all` = B09 빌림).
|
||||
"""
|
||||
|
||||
@@ -255,12 +255,6 @@ class Builder:
|
||||
book[ledger.hopyo_key(hopyo)] = self._row(hopyo, "짝", whole)
|
||||
for at, line in enumerate(rows):
|
||||
got = checked.get(at)
|
||||
if got and "고른" in got: # 새 길 고른 것(785-5 · Make 가 검사 · 다시 셈)
|
||||
poomsem.append(at)
|
||||
body.picks(at, line, got["고른"])
|
||||
pairs = [hmake.ledger_pick(p) for p in got["고른"]]
|
||||
book[ledger.key(line)] = self._row(line, "짝", pairs)
|
||||
continue
|
||||
if whole:
|
||||
missing.append(_missing(line, WHOLE))
|
||||
continue
|
||||
|
||||
@@ -1,19 +1,8 @@
|
||||
"""M02 일위대가 실무자료 단가산출 출처 풀이 · 조항 색인(PLAN 785-1 · 요구 642 · 설계 642-A ④-1) —
|
||||
엑셀 「단가산출」 블록 품셈 번호 → M01 로직 후보(색인 · 순위 아님).
|
||||
"""M02 일위대가 실무자료 단가산출 출처 풀이(PLAN 785-1 · 809-1 · 요구 642 · 702) — 엑셀 「단가산출」 블록 품셈 번호 풀이.
|
||||
|
||||
출처 풀이(`settle` · 781-1 · 옛 `M02_Hopyo_Basis` 에서 옮김) = 건설품셈(부문 모름) 번호 →
|
||||
뜻 낱말로 부문 · 임도품셈(옛) → 대응표 현행 산림 번호 ·
|
||||
기초 없음 표지(`no_basis` · 781-2) = 출처 조항에 우리 로직 0 · 옛 번호.
|
||||
조항 색인(`index`) = M01 로직 {(구분, 원문번호): [키]}.
|
||||
블록 근거(`basis`) = 머리 줄(첫 절 앞) 출처 › 머리에 품셈 글이 없으면 몸 글 출처(절 제목 뺌) ·
|
||||
절 제목 출처(「4. 기초지정[2025년 표준품셈3-4-8]」)는 그 절 후보 ·
|
||||
색인: 같음 › 윗마디(엑셀 6-2 ⊃ 6-2-2 · 6-2-3) · 부문 모르는 건설품셈 = 건설 부문 모두 ·
|
||||
옛 책 = 없음 · 윗마디 후보 여럿 = 일 절(`Formula.works`) 제목 ↔ 후보 이름 뜻 낱말 많이
|
||||
겹친 것을 그 절에(안 맞는 후보 뺌) ·
|
||||
결과단위 거름 = 후보 결과단위 성격(면적 · 체적 · 길이 · 무게 · 식) ≠ 블록 단위 성격이면 뺌 ·
|
||||
표지 = 번호 없음(머리 · 몸에 번호 없음) › 준용(머리 글 「준용」 · 후보 참고만) ›
|
||||
옛 번호 · 기초 없음(`no_basis`).
|
||||
자동 확정 없음 · M01 · 원본 로직은 읽기만.
|
||||
출처 풀이(`settle` · 781-1) = 건설품셈(부문 모름) 번호 → 뜻 낱말로 부문 · 임도품셈(옛) → 대응표 현행 산림 번호.
|
||||
풀이 글 = 원문 패널 「엑셀 글의 품셈 정보」 카드(807-1) 몫 · 조항 → 우리 기초 판정(색인 · 블록 근거 · 기초 없음 표지)은
|
||||
809-1 에서 지움(경쟁사 로직 인정 · 요구 703).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -25,9 +14,6 @@ from typing import Any
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M01_MasterData import M01_MasterData_Store_Make as make
|
||||
from M02_MasterTemplete import M02_Hopyo_Formula as formula
|
||||
from M02_MasterTemplete import M02_Hopyo_ReadEst as est
|
||||
from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
|
||||
|
||||
|
||||
def _flat(value: Any) -> str:
|
||||
@@ -35,59 +21,6 @@ def _flat(value: Any) -> str:
|
||||
|
||||
|
||||
# ── 우리 쪽 조항 · 직종 ────────────────────────────────────────────────
|
||||
@lru_cache(maxsize=2)
|
||||
def _ours(whole_id: int) -> dict[str, dict[str, Any]]:
|
||||
"""{로직 키: {책, 부문, 구분, 조항: {절 번호}, 직종: {이름}}} — 조항 = `sources()` 묶음 ·
|
||||
직종 = 호표 인력(LB) 요소."""
|
||||
files, whole = m01.loaded()
|
||||
lb = {str(row.get("키")): str(row.get("이름")) for row in whole.numbers.get("LB", {}).values()}
|
||||
out = {}
|
||||
for data in files.values():
|
||||
if data.get("그룹") != "로직":
|
||||
continue
|
||||
for row in data.get("줄") or []:
|
||||
kind = str(row.get("구분") or "")
|
||||
book, _, part = kind.partition(" ")
|
||||
trades = {
|
||||
lb[str(h.get("요소"))]
|
||||
for h in row.get("호표") or []
|
||||
if isinstance(h, dict) and str(h.get("요소")) in lb
|
||||
}
|
||||
clauses = set(labor_inputs._NUMBER.findall(str(labor_inputs.sources(row) or "")))
|
||||
out[str(row.get("키"))] = {
|
||||
"책": book,
|
||||
"부문": part,
|
||||
"구분": kind,
|
||||
"조항": clauses,
|
||||
"직종": trades,
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def ours() -> dict[str, dict[str, Any]]:
|
||||
return _ours(id(m01.loaded()[1]))
|
||||
|
||||
|
||||
def _head(number: str) -> str:
|
||||
return number.rsplit("-", 1)[0]
|
||||
|
||||
|
||||
def clause(theirs: list[list[str]], our: dict[str, Any]) -> tuple[float, list[str], str]:
|
||||
"""엑셀 인용 조항 `[[책, 번호]]` ↔ 우리 로직(`ours()` 한 줄) → (점수, 맞은 우리 조항 글,
|
||||
꼬리표) — 같은 책(부문을 알면 같은 구분 · 「(옛)」 = 없음) · 같음 1 ·
|
||||
윗마디 0.9(엑셀 6-2 ⊃ 우리 6-2-2) · 앞 마디 0.5."""
|
||||
nums = [n for book, n in theirs if book in (our["책"], our["구분"])]
|
||||
same = sorted(n for n in nums if n in our["조항"])
|
||||
if same:
|
||||
return 1.0, [f"{our['구분']} {n}" for n in same], "조항 같음"
|
||||
kids = sorted({c for c in our["조항"] for n in nums if c.startswith(n + "-")})
|
||||
if kids:
|
||||
return 0.9, [f"{our['구분']} {n}" for n in kids], "윗마디 같음"
|
||||
heads = {_head(n) for n in nums}
|
||||
near = sorted(n for n in our["조항"] if _head(n) in heads)
|
||||
if near:
|
||||
return 0.5, [f"{our['구분']} {n}" for n in near], "앞 마디 같음"
|
||||
return 0.0, [], ""
|
||||
|
||||
|
||||
# ── 출처 풀이(781-1 · 781-2) — 건설 부문 · 임도 옛 번호 대응 · 기초 없음 ───────────
|
||||
@@ -185,185 +118,4 @@ def settle(book: dict[str, Any]) -> dict[str, Any]:
|
||||
return book
|
||||
|
||||
|
||||
def has_logic(kind: str, number: str) -> bool:
|
||||
"""그 구분 · 조항(윗마디 · 아랫마디 포함)에 M01 로직이 있나."""
|
||||
return _has(id(m01.loaded()[1]), kind, number)
|
||||
|
||||
|
||||
@lru_cache(maxsize=512)
|
||||
def _has(whole_id: int, kind: str, number: str) -> bool:
|
||||
named = _named(whole_id)
|
||||
parts = number.split("-")
|
||||
ups = {"-".join(parts[:i]) for i in range(2, len(parts) + 1)}
|
||||
return any(k == kind and (no in ups or no.startswith(number + "-")) for k, no in named)
|
||||
|
||||
|
||||
def no_basis(theirs: list | None) -> dict[str, str] | None:
|
||||
"""출처 조항이 모두 우리 기초 밖이면 표지 `{판정, 글, 이름}` — 기초 없음(로직 0) › 옛 번호
|
||||
(「(옛)」 · 지금 원문에 없는 번호) · 로직 있는 조항 · 건설품셈(부문 모름)이 하나라도
|
||||
있으면 None."""
|
||||
none, old = [], []
|
||||
for book, number in theirs or []:
|
||||
base, _, part = book.partition(" ")
|
||||
if book == "건설품셈":
|
||||
return None
|
||||
if book.endswith(OLD) or base not in make.BOOKS:
|
||||
old.append((book.removesuffix(OLD), number))
|
||||
elif has_logic(book, number):
|
||||
return None
|
||||
elif (title := _sections(base).get((part, number))) is None:
|
||||
old.append((book, number))
|
||||
else:
|
||||
none.append((book, number, title))
|
||||
if none:
|
||||
book, number, title = none[0]
|
||||
where = f"{book.replace('품셈', '')} {number} {title}"
|
||||
return {
|
||||
"판정": "기초 없음",
|
||||
"글": f"근거 조항 우리 기초에 없음 — {book} {number} {title}",
|
||||
"이름": f"기초 없음 — {where}",
|
||||
}
|
||||
if old:
|
||||
return {
|
||||
"판정": "옛 번호",
|
||||
"글": f"옛 번호 — 대응 미확인 — {old[0][0]} {old[0][1]}",
|
||||
"이름": "옛 번호 — 근거 못 씀",
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
# ── 조항 색인 · 블록 근거(785-1) ──────────────────────────────────────────
|
||||
_NATURE = {
|
||||
**dict.fromkeys(("㎡", "m2", "ha"), "면적"),
|
||||
**dict.fromkeys(("㎥", "m3", "공㎥"), "체적"),
|
||||
**dict.fromkeys(("m", "km"), "길이"),
|
||||
**dict.fromkeys(("ton", "t", "톤", "kg", "㎏"), "무게"),
|
||||
"식": "식",
|
||||
}
|
||||
|
||||
|
||||
def nature(unit: Any) -> str | None:
|
||||
"""단위 → 성격(면적 · 체적 · 길이 · 무게 · 식) · 모름 = None ·
|
||||
「원/10㎡」 = ㎡ · 여러 단위 = 모름."""
|
||||
text = str(unit or "").strip().removeprefix("원/")
|
||||
if any(c in text for c in "·(또"):
|
||||
return None
|
||||
return _NATURE.get(re.sub(r"^[\d,]+", "", text).strip().lower())
|
||||
|
||||
|
||||
@lru_cache(maxsize=2)
|
||||
def _index(whole_id: int) -> dict[tuple[str, str], list[dict[str, Any]]]:
|
||||
out: dict[tuple[str, str], list[dict[str, Any]]] = {}
|
||||
for data in m01.loaded()[0].values():
|
||||
if data.get("그룹") != "로직":
|
||||
continue
|
||||
for row in data.get("줄") or []:
|
||||
key = (str(row.get("구분") or ""), str(row.get("원문번호") or "").split(" ")[0])
|
||||
one = {"키": str(row.get("키")), "이름": str(row.get("이름") or "")}
|
||||
one |= {"구분": key[0], "원문번호": key[1], "결과단위": row.get("결과단위")}
|
||||
out.setdefault(key, []).append(one)
|
||||
return out
|
||||
|
||||
|
||||
def index() -> dict[tuple[str, str], list[str]]:
|
||||
"""{(구분, 원문번호): [로직 키]} — M01 로직."""
|
||||
return {k: [o["키"] for o in v] for k, v in _index(id(m01.loaded()[1])).items()}
|
||||
|
||||
|
||||
def lookup(book: str, number: str) -> list[dict[str, Any]]:
|
||||
"""[책, 번호] → 로직 줄 + 맞음 같음 · 윗마디 — 같은 것이 있으면 같음만."""
|
||||
table = _index(id(m01.loaded()[1]))
|
||||
if book == "건설품셈":
|
||||
kinds = {k for k, _ in table if k.startswith("건설품셈 ")}
|
||||
elif book.endswith(OLD):
|
||||
return []
|
||||
else:
|
||||
kinds = {book}
|
||||
same = [o for k in kinds for o in table.get((k, number), [])]
|
||||
if same:
|
||||
return [{**o, "맞음": "같음", "출처": [book, number]} for o in same]
|
||||
return [
|
||||
{**o, "맞음": "윗마디", "출처": [book, number]}
|
||||
for (k, no), rows in table.items()
|
||||
if k in kinds and no.startswith(number + "-")
|
||||
for o in rows
|
||||
]
|
||||
|
||||
|
||||
def _paths(nodes: list[dict[str, Any]], up: str = "") -> list[tuple[str, dict[str, Any]]]:
|
||||
out = []
|
||||
for one in nodes:
|
||||
path = f"{up}.{one['번']}" if up else one["번"]
|
||||
out += [(path, one), *_paths(one["아래"], path)]
|
||||
return out
|
||||
|
||||
|
||||
def _sources(lines: list[str], name: Any, whole: str) -> list[list[str]]:
|
||||
raw: list[list[str]] = []
|
||||
for line in lines:
|
||||
raw += [one for one in est.sources(line) if one not in raw]
|
||||
return resolve(raw, name, whole)
|
||||
|
||||
|
||||
def _split(found: list[dict[str, Any]], nodes: list[dict[str, Any]]) -> tuple[list, list]:
|
||||
"""윗마디 후보 여럿 → 일 절 제목과 뜻 낱말 가장 많이 겹친 후보를 그 절에 · 남은 후보는 뺌."""
|
||||
works = {id(w) for w in formula.works(nodes)}
|
||||
picked: dict[str, str] = {}
|
||||
for path, one in _paths(nodes):
|
||||
if id(one) not in works:
|
||||
continue
|
||||
mine = _grams(_flat(one["제목"]))
|
||||
score = {o["키"]: len(mine & _grams(_flat(o["이름"]))) for o in found}
|
||||
best = max(score.values(), default=0)
|
||||
for key, n in score.items():
|
||||
if best and n == best:
|
||||
picked.setdefault(key, path)
|
||||
if not picked:
|
||||
return found, []
|
||||
keep = [{**o, "절": picked[o["키"]]} for o in found if o["키"] in picked]
|
||||
drop = [{"키": o["키"], "까닭": "절 이름 안 맞음"} for o in found if o["키"] not in picked]
|
||||
return keep, drop
|
||||
|
||||
|
||||
def basis(block: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Book 단산 블록 → {표지, 글, 출처, 후보 [{키, 이름, 구분, 원문번호, 결과단위, 맞음,
|
||||
출처, 절}], 뺌 [{키, 까닭}]}."""
|
||||
text = [str(t) for t in block.get("글") or []]
|
||||
name, whole = block.get("이름"), " ".join(text)
|
||||
nodes = block.get("절") or formula.tree(text, block.get("글행"))
|
||||
top = []
|
||||
for line in text:
|
||||
if formula._title(line):
|
||||
break
|
||||
top.append(line)
|
||||
head = " ".join(top)
|
||||
used = _sources(top, name, whole)
|
||||
if not used and not re.search(r"품[셈줌]", head):
|
||||
used = _sources([t for t in text if not formula._title(t)], name, whole)
|
||||
found = [{**o, "절": None} for book, no in used for o in lookup(book, no)]
|
||||
out: dict[str, Any] = {"표지": None, "글": "", "출처": list(used), "후보": [], "뺌": []}
|
||||
if len(found) > 1 and any(o["맞음"] == "윗마디" for o in found):
|
||||
found, out["뺌"] = _split(found, nodes)
|
||||
for path, one in _paths(nodes):
|
||||
for book, no in _sources([one["제목"]], one["제목"], whole):
|
||||
out["출처"] += [[book, no]] if [book, no] not in out["출처"] else []
|
||||
found += [{**o, "절": path} for o in lookup(book, no)]
|
||||
if not out["출처"]:
|
||||
out["표지"], out["글"] = "번호 없음", "품셈 번호 없음 — 이름 후보로"
|
||||
elif "준용" in head:
|
||||
out["표지"], out["글"] = "준용", "준용 — 후보 참고만"
|
||||
elif mark := no_basis(out["출처"]):
|
||||
out["표지"], out["글"] = mark["판정"], mark["글"]
|
||||
mine = nature(block.get("단위"))
|
||||
for o in found:
|
||||
theirs = nature(o["결과단위"])
|
||||
if any(o["키"] == c["키"] for c in out["후보"]):
|
||||
continue
|
||||
if mine and theirs and mine != theirs:
|
||||
why = f"결과단위 다름 — {o['결과단위']} ≠ {block.get('단위')}"
|
||||
out["뺌"].append({"키": o["키"], "까닭": why})
|
||||
else:
|
||||
out["후보"].append(o)
|
||||
if out["표지"] is None and not out["후보"]:
|
||||
out["표지"], out["글"] = "기초 없음", "결과단위 맞는 우리 기초 없음"
|
||||
return out
|
||||
|
||||
@@ -116,8 +116,8 @@ class Linker:
|
||||
out["자재"] = self._same(self._mats(line), price["재료비"])
|
||||
return out
|
||||
|
||||
def _machine(self, line: dict[str, Any]) -> str | None:
|
||||
"""기계 줄 → EQ 하나 — 이름 · 규격 글 같음 › 갈래 + 규격 수 · 괄호 낱말(하나일 때만)."""
|
||||
def _eqs(self, line: dict[str, Any]) -> list[str]:
|
||||
"""기계 줄 → EQ 후보 — 이름 · 규격 글 같음 › 갈래 + 규격 수 · 괄호 낱말."""
|
||||
name, spec = mm_plain(line.get("이름")), mm_plain(line.get("규격"))
|
||||
same = [c for c, _, _, n, s in self.machines if n == name and s == spec]
|
||||
if len(same) != 1:
|
||||
@@ -125,6 +125,20 @@ class Linker:
|
||||
kinds, nums = mtypes(text), set(specnums(text))
|
||||
same = [c for c, t, s, _, _ in self.machines if kinds & t and nums and nums == s]
|
||||
same = elem.narrow(text, [(c, self.eq_names.get(c)) for c in same])
|
||||
return same
|
||||
|
||||
def candidates(self, line: dict[str, Any]) -> list[str]:
|
||||
"""못 이은 줄의 이음 후보 키(809-1 [수정] 보기 「후보 N」) — 노임 같은 직종 · 기계 EQ · 같은 자재."""
|
||||
kind = kind_of(line)
|
||||
if kind == "노임":
|
||||
return [code for code, _ in self.labor.get(shares.trade(line.get("이름")) or "", [])]
|
||||
if kind == "기계":
|
||||
return self._eqs(line)
|
||||
return [code for code, _ in self._mats(line)] if kind in hm.MATERIAL_KINDS else []
|
||||
|
||||
def _machine(self, line: dict[str, Any]) -> str | None:
|
||||
"""기계 줄 → EQ 하나(하나일 때만)."""
|
||||
same = self._eqs(line)
|
||||
if len(same) == 1:
|
||||
return same[0]
|
||||
self.near += bool(same)
|
||||
|
||||
@@ -1,364 +0,0 @@
|
||||
"""M02 엑셀 불러오기 판정 · 검산(PLAN 785-5 · 요구 642 · 설계 642-A 2-6 ⑤ · 2-9) — 서버 단독 · 읽기만.
|
||||
|
||||
판정 = 1차 줄 판(`M02_Hopyo_Row.rows`) + 단산 줄 2차 블록 풀이(`Clause.basis` 후보 → `Solve.parts` →
|
||||
`Solve.block`) · 목록(`listing`) · 호표 하나(`hopyo`) · 블록 하나(`block`).
|
||||
블록 풀이 = 파생 저장본(HP 폴더 `_derived/hopyo_judge_{키}` · 입력 지문 = HP + M01 파일 내용 + 풀이 판 ·
|
||||
지문 다를 때만 다시 셈 · GET 은 안 지움 · 올림 뒤 한 번 뒤에서 `ensure` · 셈 중 = `셈중` · 판정 None) ·
|
||||
블록 판정 읽기 = `judged`(Marks 칠) · [끝내기] = `forget`.
|
||||
줄 판 줄에 엑셀 칸(이름 · 규격 · 단위 · 단가) · 블록에 엑셀 금액 · 절에 몫(785-6 화면이 읽음).
|
||||
검산(`check`) = 고른 것마다 — 기초 = 입력값(`inputs` · 엔진이 쓴 칸 모두 · 지역 · 자재지역 · 조종원직종
|
||||
비움) · 입력필요 · 엑셀 단위 줄과 품 대조(`Solve.pair`) · 금액(엔진 한 번 · 입력 필요면 셈 안 함) /
|
||||
그 밖 = 「확인」 · 수량 = 엑셀 × 몫 × 배(`Row.quantity` · 사용자 배 우선).
|
||||
저장 없음 · 지어 넣은 값 없음(입력 필요 칸은 비워 둠 · 안 쓰는 칸 = 기초 기본값).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import threading
|
||||
from collections import Counter
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from common_util import common_util_derived_store as derived
|
||||
from M02_MasterTemplete import M02_Hopyo_Bind as hb
|
||||
from M02_MasterTemplete import M02_Hopyo_Build as hbuild
|
||||
from M02_MasterTemplete import M02_Hopyo_Clause as clause
|
||||
from M02_MasterTemplete import M02_Hopyo_Ledger as ledger
|
||||
from M02_MasterTemplete import M02_Hopyo_Row as hrow
|
||||
from M02_MasterTemplete import M02_Hopyo_Sangeun as sangeun
|
||||
from M02_MasterTemplete import M02_Hopyo_Solve as solve
|
||||
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
|
||||
mf, ma = hb.mf, hb.ma
|
||||
EXCEL = ("이름", "규격", "단위", "단가")
|
||||
CHECKED = "확인"
|
||||
SUGGEST = "제안 있음" # 1차 줄 판정(`M02_Hopyo_Row`)
|
||||
KIND = "hopyo_judge_" # 파생 저장본 종류 = 이것 + HP 키(HP 폴더 `_derived/`)
|
||||
VERSION = "785-5.1" # 풀이 꼴이 바뀌면 올림
|
||||
_LOCK, _RUNNING = threading.Lock(), set()
|
||||
|
||||
|
||||
def master() -> mf.Master:
|
||||
return hb.m01.cm.master()
|
||||
|
||||
|
||||
def _bad(text: str, status: int = 400) -> store.StoreError:
|
||||
return store.StoreError(status, text)
|
||||
|
||||
|
||||
def _blocks(book: dict[str, Any]) -> dict[int, dict[str, Any]]:
|
||||
return {int(b["번"]): b for b in book.get("단산") or [] if b.get("번") is not None}
|
||||
|
||||
|
||||
def _unit(book: dict[str, Any], no: int) -> dict[str, Any]:
|
||||
got = next((h for h in book.get("호표") or [] if int(h["번"]) == int(no)), None)
|
||||
if got is None:
|
||||
raise _bad(f"없는 호표 「제{no}호표」", 404)
|
||||
return got
|
||||
|
||||
|
||||
def _made(folder: Path, key: str, to: Path) -> tuple[dict[str, str], dict[str, str]]:
|
||||
"""(만든 SC {본뜬 키: SC}, 만든 SK {본뜬 키: SK}) — 만들 층 · 이 파일 층 것만."""
|
||||
source = refs.where(folder)
|
||||
return hbuild.confirmed(to, source), sangeun.made(to, key, source)
|
||||
|
||||
|
||||
# ── 기초 입력값 ───────────────────────────────────────────────────────
|
||||
def inputs(m: mf.Master, code: str, given: dict[str, Any] | None) -> tuple[dict, list[str]]:
|
||||
"""(저장 입력값, 입력필요) — 고른 값(그 기초 칸 · 빈 값 뺌) + 안 쓰는 칸 = 기초 기본값 ·
|
||||
쓰는데 빈 칸 = 입력필요(비워 둠) · 지역 · 자재지역 · 조종원직종 = 비움(프로젝트가 정함)."""
|
||||
row = m.logic(code)
|
||||
names = [s["이름"] for s in hb.specs_of(row)]
|
||||
have = {
|
||||
k: v
|
||||
for k, v in (given or {}).items()
|
||||
if k in names and k not in hb.SKIP and v not in (None, "")
|
||||
}
|
||||
need = [n for n in names if n in hb._needed(m, code, row, have) and n not in hb.SKIP]
|
||||
base = ma.defaults(m, row)
|
||||
idle = [n for n in names if n not in have and n not in need and n not in hb.SKIP]
|
||||
full = {n: hb.plain(base[n]) for n in idle if base.get(n) is not None}
|
||||
return {**full, **{k: hb.plain(v) for k, v in have.items()}}, need
|
||||
|
||||
|
||||
def money(m: mf.Master, code: str, given: dict[str, Any]) -> dict[str, Any]:
|
||||
"""기초 한 줄 셈(일위대가 로직 줄과 같은 길 `Labor_Doc.run_line` · 끝수 · 비운 지역 칸 = 자동값 ·
|
||||
참고 셈만) → {노무비, 재료비, 경비, 계} · 못 셈 = {계 None, 까닭}."""
|
||||
got = labor.run_line(m, code, given)
|
||||
if got.get("결과") is not None:
|
||||
return {"결과": hb.plain(got["결과"])}
|
||||
if got.get("기준") is None:
|
||||
return {"계": None, "까닭": got.get("까닭")}
|
||||
return {k: hb.plain(v) for k, v in got["기준"].items()}
|
||||
|
||||
|
||||
# ── 판정 ─────────────────────────────────────────────────────────────
|
||||
def _row(line: dict[str, Any], one: dict[str, Any]) -> dict[str, Any]:
|
||||
return {**one, **{k: line.get(k) for k in EXCEL}}
|
||||
|
||||
|
||||
def _solve(m: mf.Master, blk: dict, line: dict | None, same: list[dict]) -> dict[str, Any]:
|
||||
"""블록 풀이 — 조항 색인 후보 → 절 → 검산 · 판정 + 근거 · 엑셀 금액 · 절 몫."""
|
||||
basis = clause.basis(blk)
|
||||
parts = solve.parts(blk, basis["후보"])
|
||||
got = solve.block(m, blk, parts, {"줄": line, "같은줄": same})
|
||||
for sec, part in zip(got["절"], parts, strict=True):
|
||||
share = part.get("몫")
|
||||
sec["몫"] = share.get("값") if isinstance(share, dict) else share
|
||||
sec["번"] = part.get("번")
|
||||
got["근거"] = {k: basis[k] for k in ("표지", "글", "출처", "후보", "뺌")}
|
||||
got["금액"] = blk.get("금액")
|
||||
return got
|
||||
|
||||
|
||||
# ── 블록 풀이 파생 저장본(올림 뒤 한 번 · 뒤에서) ─────────────────────────
|
||||
def _stamp(key: str, book: dict[str, Any]) -> str:
|
||||
"""입력 지문 — HP(지문 · 판 · 올린때) + M01 첫 층 파일 내용 + 풀이 판."""
|
||||
folder = Path(hb.m01.FOLDER)
|
||||
files = {
|
||||
p.name: derived.file_digest(p)
|
||||
for p in sorted(folder.glob("*.json"))
|
||||
if not p.name.startswith("_")
|
||||
}
|
||||
head = (VERSION, key, book.get("지문"), book.get("판"), book.get("올린때"))
|
||||
return derived.derived_key(*head, files)
|
||||
|
||||
|
||||
def _caller(book: dict[str, Any], no: int) -> tuple[dict | None, list[dict]]:
|
||||
"""블록을 부르는 첫 줄 · 그 호표 다른 줄(B1 · B6) — 일위대가 밖 = (None, [])."""
|
||||
line = sangeun.calls(book)[1].get(no)
|
||||
if not line:
|
||||
return None, []
|
||||
unit = _unit(book, line["_호표"])
|
||||
same = [x for x in unit.get("줄") or [] if x.get("차례") != line.get("차례")]
|
||||
return {k: v for k, v in line.items() if k != "_호표"}, same
|
||||
|
||||
|
||||
def solved(folder: Path, key: str) -> dict[str, Any] | None:
|
||||
"""저장본 `{번 글: 풀이}` — 지문 다르거나 없으면 None(GET 은 안 지움)."""
|
||||
book = hopyo_store.read(folder, key)
|
||||
got = derived.read_derived(hopyo_store._dir(folder), KIND + key, _stamp(key, book))
|
||||
return got["value"] if got else None
|
||||
|
||||
|
||||
def solve_all(folder: Path, key: str) -> dict[str, Any]:
|
||||
"""블록 모두 풀어 저장본으로(부르는 첫 줄 기준)."""
|
||||
book = hopyo_store.read(folder, key)
|
||||
m, out = master(), {}
|
||||
for no, blk in sorted(_blocks(book).items()):
|
||||
out[str(no)] = _solve(m, blk, *_caller(book, no))
|
||||
root = hopyo_store._dir(folder)
|
||||
derived.write_derived(root, KIND + key, _stamp(key, book), out, keep=1)
|
||||
return out
|
||||
|
||||
|
||||
def _work(folder: Path, key: str) -> None:
|
||||
try:
|
||||
solve_all(folder, key)
|
||||
except Exception: # noqa: BLE001 — 뒤 셈 실패 = 다음 읽기에서 다시(파생 저장본)
|
||||
pass
|
||||
finally:
|
||||
with _LOCK:
|
||||
_RUNNING.discard(f"{folder}|{key}")
|
||||
|
||||
|
||||
def _start(folder: Path, key: str) -> None:
|
||||
tag = f"{folder}|{key}"
|
||||
with _LOCK:
|
||||
if tag in _RUNNING:
|
||||
return
|
||||
_RUNNING.add(tag)
|
||||
threading.Thread(target=_work, args=(Path(folder), key), daemon=True).start()
|
||||
|
||||
|
||||
def ensure(folder: Path, key: str) -> bool:
|
||||
"""저장본 있음 = 참 · 없으면 뒤 셈 시작(화면 안 막음 · 같은 파일 한 번) = 거짓."""
|
||||
if solved(folder, key) is not None:
|
||||
return True
|
||||
_start(folder, key)
|
||||
return False
|
||||
|
||||
|
||||
def judged(folder: Path, key: str) -> dict[int, dict[str, Any]] | None:
|
||||
"""블록 판정 `{번: {판정, 고를수}}` — 셈 중 = None(뒤 셈 시작 · Marks 칠이 부름)."""
|
||||
got = solved(folder, key)
|
||||
if got is None:
|
||||
_start(folder, key)
|
||||
return None
|
||||
return {int(no): {"판정": v["판정"], "고를수": v["고를수"]} for no, v in got.items()}
|
||||
|
||||
|
||||
def forget(folder: Path, key: str) -> None:
|
||||
"""[끝내기] — 그 HP 저장본 지움."""
|
||||
shutil.rmtree(derived.derived_dir(hopyo_store._dir(folder), KIND + key), ignore_errors=True)
|
||||
|
||||
|
||||
def listing(folder: Path, key: str, to: Path) -> dict[str, Any]:
|
||||
"""호표 목록(1차 판정 수 · 만든 SC) · 블록 목록(부름 · 표지 · 판정 · 고를수 · 만든 SK) ·
|
||||
`셈중` = 블록 풀이 뒤 셈 중(블록 판정 · 고를수 None)."""
|
||||
book = hopyo_store.read(folder, key)
|
||||
rows = hrow.rows(book, folder, ledger.doc_id(folder, key))
|
||||
sc, sk = _made(folder, key, to)
|
||||
units = []
|
||||
for h in book.get("호표") or []:
|
||||
no = int(h["번"])
|
||||
count = Counter(r["판정"] for r in rows.get(h["번"]) or [])
|
||||
head = {"번": no, **{k: h.get(k) for k in ("이름", "규격", "단위", "단가")}}
|
||||
units.append(
|
||||
{**head, "줄수": len(h.get("줄") or []), "판정": dict(count),
|
||||
"만든": sc.get(hbuild.mark(key, no))}
|
||||
) # fmt: skip
|
||||
call, done = sangeun.callers(book), judged(folder, key)
|
||||
blocks = []
|
||||
for no, b in sorted(_blocks(book).items()):
|
||||
got = (done or {}).get(no) or {"판정": None, "고를수": None}
|
||||
blocks.append(
|
||||
{"번": no, "이름": b.get("이름"), "단위": b.get("단위"), "금액": b.get("금액"),
|
||||
"부름": call.get(no, []), "표지": clause.basis(b)["표지"], **got,
|
||||
"만든": sk.get(sangeun.mark(key, no))}
|
||||
) # fmt: skip
|
||||
out = {"키": key, "파일": book.get("파일"), "셈중": done is None}
|
||||
return {**out, "호표": units, "블록": blocks}
|
||||
|
||||
|
||||
def view(folder: Path, key: str, to: Path) -> dict[str, Any]:
|
||||
"""엑셀 보기 머리 · 목록(옛 `Match.view` 꼴) — 파일 줄 + 검산 · 호표마다 `맞춤` [제안 있음, 줄 수] ·
|
||||
`줄상태 [[차례, 1차 판정, 제안 있음]]` · `만든` SC · `셈` = 블록 풀이 뒤 셈(`끝` 거짓 = 셈 중)."""
|
||||
book = hopyo_store.read(folder, key)
|
||||
rows = hrow.rows(book, folder, ledger.doc_id(folder, key))
|
||||
sc, _ = _made(folder, key, to)
|
||||
units = []
|
||||
for h in book.get("호표") or []:
|
||||
no = int(h["번"])
|
||||
state = [
|
||||
[i, r["판정"], r["판정"] == SUGGEST] for i, r in enumerate(rows.get(h["번"]) or [])
|
||||
]
|
||||
head = {k: h.get(k) for k in ("번", "이름", "규격", "단위", "금액", "행") if k in h}
|
||||
units.append(
|
||||
{**head, "맞춤": [sum(x[2] for x in state), len(state)], "줄상태": state,
|
||||
"만든": sc.get(hbuild.mark(key, no))}
|
||||
) # fmt: skip
|
||||
ready = [sum(h["맞춤"][0] for h in units), sum(h["맞춤"][1] for h in units)]
|
||||
calc = {"끝": judged(folder, key) is not None, "한": 0, "모두": None, "까닭": "블록 풀이"}
|
||||
head = {**hopyo_store.brief(book), "검산": book.get("검산"), "셈": calc}
|
||||
return {**head, "호표": units, "맞춤": ready}
|
||||
|
||||
|
||||
def hopyo(folder: Path, key: str, no: int, to: Path) -> dict[str, Any]:
|
||||
"""호표 하나 — 줄 판(+ 엑셀 칸) · 단산 줄 `풀이`(부르는 첫 줄이면 저장본)."""
|
||||
book = hopyo_store.read(folder, key)
|
||||
unit = _unit(book, no)
|
||||
rows = hrow.rows({**book, "호표": [unit]}, folder, ledger.doc_id(folder, key))[unit["번"]]
|
||||
lines, blocks, m = unit.get("줄") or [], _blocks(book), None
|
||||
cache, first = solved(folder, key) or {}, sangeun.calls(book)[1]
|
||||
out = []
|
||||
for r, line in zip(rows, lines, strict=True):
|
||||
r = _row(line, r)
|
||||
sub = r.get("번")
|
||||
if "단가산출" in r["갈래"] and sub is not None and int(sub) in blocks:
|
||||
head = first.get(int(sub)) or {}
|
||||
mine = head.get("_호표") == unit["번"] and head.get("차례") == line.get("차례")
|
||||
if mine and str(sub) in cache:
|
||||
r["풀이"] = cache[str(sub)]
|
||||
else:
|
||||
m = m or master()
|
||||
others = [x for x in lines if x is not line]
|
||||
r["풀이"] = _solve(m, blocks[int(sub)], line, others)
|
||||
out.append(r)
|
||||
sc, _ = _made(folder, key, to)
|
||||
head = {k: unit.get(k) for k in ("번", "이름", "규격", "단위", "단가")}
|
||||
return {**head, "만든": sc.get(hbuild.mark(key, no)), "줄": out}
|
||||
|
||||
|
||||
def block(folder: Path, key: str, no: int, to: Path) -> dict[str, Any]:
|
||||
"""단산 블록 하나 — 풀이(저장본 › 셈) + 이름 · 단위 · 부름 · 만든(부르는 첫 줄로 B1 · B6)."""
|
||||
book = hopyo_store.read(folder, key)
|
||||
blocks = _blocks(book)
|
||||
if no not in blocks:
|
||||
raise _bad(f"없는 블록 「단산{no}」", 404)
|
||||
line, same = _caller(book, no)
|
||||
got = (solved(folder, key) or {}).get(str(no)) or _solve(master(), blocks[no], line, same)
|
||||
_, sk = _made(folder, key, to)
|
||||
blk = blocks[no]
|
||||
head = {"이름": blk.get("이름"), "단위": blk.get("단위") or (line or {}).get("단위")}
|
||||
head |= {"부름": sangeun.callers(book).get(no, []), "만든": sk.get(sangeun.mark(key, no))}
|
||||
return {**got, **head}
|
||||
|
||||
|
||||
# ── 검산 ─────────────────────────────────────────────────────────────
|
||||
def _want(m: mf.Master, pick: dict[str, Any]) -> Any:
|
||||
"""고른 것 단위 — 기초 = 결과단위(「원/㎥」 → ㎥ · 「원/자재 단위」 → 자재) · 그 밖 = 고른 것 단위."""
|
||||
if pick.get("갈래") == "기초" and pick.get("키"):
|
||||
unit = str(m.logic(pick["키"]).get("결과단위") or "")
|
||||
unit = unit.split("/", 1)[1] if unit.startswith("원/") else unit
|
||||
return unit.removesuffix(" 단위").strip() # 「원/자재 단위」 = 줄 단위 그대로
|
||||
return pick.get("단위")
|
||||
|
||||
|
||||
def quantity(m: mf.Master, line: dict, pick: dict) -> dict[str, Any]:
|
||||
"""줄 수량 `{엑셀, 몫, 배, 갈래, 우리}` — 사용자 배(단위 환산 값)가 있으면 그것."""
|
||||
got = hrow.quantity(line, _want(m, pick), pick.get("몫"))
|
||||
times = pick.get("배")
|
||||
if times is not None and line.get("수량") is not None:
|
||||
ours = Decimal(str(line["수량"])) * Decimal(str(pick.get("몫") or 1))
|
||||
got |= {"배": times, "갈래": "사용자", "우리": hrow._plain(ours * Decimal(str(times)))}
|
||||
return got
|
||||
|
||||
|
||||
def one_pick(m: mf.Master, pick: dict[str, Any], items: list[dict], share: Any) -> dict:
|
||||
"""고른 것 하나 검산."""
|
||||
out = {"갈래": pick["갈래"], "키": pick.get("키")}
|
||||
if pick["갈래"] != "기초":
|
||||
return {**out, "판정": CHECKED}
|
||||
code = str(pick.get("키") or "")
|
||||
try:
|
||||
m.logic(code)
|
||||
except Exception as e: # noqa: BLE001 — 없는 키
|
||||
raise _bad(f"없는 기초 「{code}」") from e
|
||||
full, need = inputs(m, code, pick.get("입력값"))
|
||||
rows = solve.pair(hb.quantities(m, code, full), solve._usable(items), share) if items else []
|
||||
if need:
|
||||
verdict = solve.NEED
|
||||
elif any(r["판정"] == solve.DIFF for r in rows):
|
||||
verdict = solve.DIFF
|
||||
else:
|
||||
verdict = solve.SAME if any(r["판정"] == solve.SAME for r in rows) else CHECKED
|
||||
got = money(m, code, full) if not need else {"계": None, "까닭": f"입력 필요 {len(need)}"}
|
||||
return {**out, "입력값": full, "입력필요": need, "줄": rows, "판정": verdict, "금액": got}
|
||||
|
||||
|
||||
def check(
|
||||
folder: Path, key: str, hno: int | None, at: int | None, bno: int | None, picks: list[dict]
|
||||
) -> dict[str, Any]:
|
||||
"""`{고른 [...], 수량}` — 호표 줄(`hno` · `at`) · 블록(`bno` · 줄이 단산이면 그 블록) · 고른 기초는
|
||||
맡은 절(`Solve.parts`) 단위 줄 · 몫과 대조."""
|
||||
book = hopyo_store.read(folder, key)
|
||||
blocks, line = _blocks(book), None
|
||||
if hno is not None:
|
||||
lines = _unit(book, hno).get("줄") or []
|
||||
if at is None or not 0 <= at < len(lines):
|
||||
raise _bad(f"없는 줄 「제{hno}호표 {at}」")
|
||||
line = lines[at]
|
||||
ref = line.get("참조") or {}
|
||||
if bno is None and ref.get("종류") == "단산" and ref.get("번") is not None:
|
||||
bno = int(ref["번"])
|
||||
if bno is not None and bno not in blocks:
|
||||
raise _bad(f"없는 블록 「단산{bno}」", 404)
|
||||
blk = blocks[bno] if bno is not None else {}
|
||||
parts = solve.parts(blk, clause.basis(blk)["후보"]) if blk else []
|
||||
m = master()
|
||||
out = []
|
||||
for i, pick in enumerate(picks):
|
||||
part = next((x for x in parts if pick.get("키") in x["후보"]), None) or {}
|
||||
items = part.get("항목") # 그 기초가 맡은 절 단위 줄(없으면 블록 통째)
|
||||
items = (blk.get("항목") or []) if items is None else items
|
||||
share = part.get("몫")
|
||||
share = pick.get("몫") or (share.get("값") if isinstance(share, dict) else share) or 1
|
||||
out.append({"차례": i, **one_pick(m, pick, items, share)})
|
||||
qty = quantity(m, line, picks[0]) if line is not None and picks else None
|
||||
return {"고른": out, "수량": qty}
|
||||
@@ -1,60 +1,23 @@
|
||||
"""M02 엑셀 불러오기 만들기(PLAN 785-5 · 요구 642 · 설계 642-A 2-7 ⑥) — 확인한 것 → 우리 SC · SK · 장부.
|
||||
"""M02 일위대가 실무자료 → 우리 SC 줄 몸(PLAN 785-5 · 809-1) — `M02_Hopyo_Build.Builder` 가 줄 차례대로 부름.
|
||||
|
||||
옮기기 몸 = `{호표 {번: {차례: [고른 것]}}, 블록 {번: [고른 것]}}` · 고른 것 = 785-3 꼴
|
||||
`{갈래 기초|단가산출|일위대가|인력|재료|유가|기계|율|고정, 키, 입력값, 몫, 배?, 비목?, 기준?, 별도계상?, 층?,
|
||||
주인?, 항목?}` · 서버가 다시 셈(브라우저 금액 · 품 안 받음 · 배 = `Row.factor` · 사용자 배 우선 ·
|
||||
입력값 = 엔진이 쓴 칸 모두 `Judge.inputs`) · 「입력 필요」 · 배 못 정함 · 없는 키 = 그 줄(블록)만 `막힘`.
|
||||
SC(`M02_Hopyo_Build.Builder` 가 줄 차례 · 확인 안 한 줄 = 모두 고정 길 그대로) — 한 엑셀 줄 = 고른 것마다
|
||||
우리 줄(같은 별칭 = 엑셀 품명 · 줄 `본뜬 {호표, 차례, 행}`) · 수량 {손 = 엑셀 수량 × 몫 × 배} ·
|
||||
기초 = 로직 줄(같은 로직 둘째 = 한 줄 SC 사본) · 재료 · 유가 = 자재 목록(별도계상) · 인력 · 기계 · 유가 이음 ·
|
||||
별도계상 줄(`고정` + 별도계상 · 786-1) = 단가 0 고정 줄(합 밖 · 메모 「별도계상 · 품목은 프로젝트에서」) ·
|
||||
율 = 고정 줄 `비율 {기준, 비목}` · 고정 = 엑셀 단가 고정 줄 · 단가산출 · 일위대가 = 참조
|
||||
(`단산N` = 같은 옮기기 블록 SK · `제N호표` = 같은 HP 호표 SC — 하위 먼저 · 확인 안 된 하위 = 모두 고정).
|
||||
SK(블록) = 바탕 없음 + 더한줄 — 기초 `{종류 로직, 입력값, 수량 = 몫}` · 참조 · 단위 줄(`항목`)에 이은
|
||||
인력 · 기계 · 재료 · 유가 · 율 = `SkUnit.adds` · 고정 = 엑셀 금액 · 쓰기 = `Sangeun.Maker.write`.
|
||||
같은 내용 재사용(`Kids.save` · `Sangeun.Maker.write`) · 장부 = 고른 것 꼴 짝(`Ledger.check_pair`) ·
|
||||
엑셀 대조 = `Compare.compare`(참고 · 비목마다 1 원).
|
||||
`Lines` = 확인한 줄 몸(옛 대상 꼴 `put` 은 옛 길 몫 · 785-7 지움).
|
||||
`Lines.put` = 대상 하나(`{로직|자재|호표|전국|이음|참조, 입력값, 배, 몫}` · 붙여넣기 · 구조물 조합으로 길) →
|
||||
우리 줄 · 자재(같은 로직 둘째 = 한 줄 SC 사본 · 재료 = 자재 목록 · 이음 = 고정 줄).
|
||||
확인 안 한 줄 = Builder 가 고정 길(`Fixed.line_of`)로. 옛 판정 고른 것 · [옮기기](785-5 `Move`)는 809-1 에서 지움 —
|
||||
불러오기 옮기기 = [내 일위대가로](`Build.build_all only` · 경쟁사 로직 인정 · 요구 703).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Build as hbuild
|
||||
from M02_MasterTemplete import M02_Hopyo_Fixed as hfix
|
||||
from M02_MasterTemplete import M02_Hopyo_Judge as judge
|
||||
from M02_MasterTemplete import M02_Hopyo_Kids as hkids
|
||||
from M02_MasterTemplete import M02_Hopyo_Ledger as ledger
|
||||
from M02_MasterTemplete import M02_Hopyo_Material as hmat
|
||||
from M02_MasterTemplete import M02_Hopyo_Sangeun as sangeun
|
||||
from M02_MasterTemplete import M02_Hopyo_SkUnit as skunit
|
||||
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
|
||||
from M02_MasterTemplete import M02_Hopyo_Units as units
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_Library_Same as lsame
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_Sangeun_Doc as sd
|
||||
from M02_MasterTemplete.M02_Hopyo_Compare import compare
|
||||
|
||||
_SUB = {"단가산출": re.compile(r"^단산(\d+)$"), "일위대가": re.compile(r"^제(\d+)호표$")}
|
||||
LINK = {"인력": "노임", "기계": "기계", "유가": "유가"}
|
||||
OLD_PICK = {"인력": "노임", "기계": "기계", "재료": "자재", "유가": "자재"}
|
||||
NEED = "입력 필요"
|
||||
APART_MEMO = "별도계상 · 품목은 프로젝트에서"
|
||||
COVER = "이미 옮긴 것이 있음 — 덮으려면 [덮어 옮기기](덮음)"
|
||||
|
||||
|
||||
class Stuck(Exception):
|
||||
"""그 줄(블록)만 못 옮김 — `{까닭, 칸?}`."""
|
||||
|
||||
def __init__(self, why: str, cells: list[str] | None = None) -> None:
|
||||
super().__init__(why)
|
||||
self.why, self.cells = why, cells or []
|
||||
|
||||
|
||||
def _num(value: Decimal) -> int | float:
|
||||
@@ -157,253 +120,3 @@ class Lines:
|
||||
}
|
||||
ref = {"키": "SC-미리"} if b.dry else hkids.save(wrap, b.same)[0] # 미리 보기 = 쓰기 없음
|
||||
self.lines.append({"참조": ref, "별칭": alias, "메모": memo, "수량": {"손": _num(qty)}})
|
||||
|
||||
def apart(self, line: dict, alias: str | None, mark: dict) -> None:
|
||||
"""별도계상 줄(786-1) = 합 밖 고정 줄 — 단가 0(셈에 안 듦) · 품명 · 규격 · 단위 · 수량 그대로 ·
|
||||
품목은 프로젝트에 복사할 때 정함(메모 표지)."""
|
||||
zero = {**line, "단가": {}, "금액": {}}
|
||||
self.put(zero, {"이음": dict.fromkeys(hfix.fixed.LINKS)}, alias, None, mark)
|
||||
self.lines[-1]["메모"] = f"{APART_MEMO} · {self.lines[-1].get('메모') or ''}".strip(" ·")
|
||||
|
||||
def picks(self, at: int, line: dict, picks: list[dict]) -> None:
|
||||
"""고른 것(검사 · 다시 셈 끝난 것) → 그 엑셀 줄의 우리 줄 · 자재."""
|
||||
alias = str(line.get("이름") or "")[: labor.ALIAS_MAX].strip() or None
|
||||
mark = {"호표": self.b.no, "차례": at, "행": line.get("행")}
|
||||
for p in picks:
|
||||
kind, code = p["갈래"], p.get("키")
|
||||
one = {"배": p["배"], "몫": p.get("몫")}
|
||||
qty = p["수량"]
|
||||
if kind == "기초":
|
||||
self.put(line, {**one, "로직": code, "입력값": p["입력값"]}, alias, qty, mark)
|
||||
elif kind in ("재료", "유가") and not p.get("이음"):
|
||||
self.put(
|
||||
line, {**one, "자재": code, "별도계상": p.get("별도계상")}, alias, qty, mark
|
||||
)
|
||||
elif kind in ("단가산출", "일위대가"):
|
||||
self.put(line, {**one, "참조": p["참조"]}, alias, qty, mark)
|
||||
elif kind == "율":
|
||||
self.put(line, {"이음": {"비율": p["비목"]}}, alias, None, mark)
|
||||
self.lines[-1]["고정"]["비율"] = {"기준": p["기준"], "비목": p["비목"]}
|
||||
elif kind == "고정" and p.get("별도계상"):
|
||||
self.apart(line, alias, mark)
|
||||
elif kind == "고정":
|
||||
self.put(line, {"이음": dict.fromkeys(hfix.fixed.LINKS)}, alias, None, mark)
|
||||
else:
|
||||
self.put(line, {"이음": {LINK[kind]: code}}, alias, None, mark)
|
||||
|
||||
|
||||
def ledger_pick(p: dict[str, Any]) -> dict[str, Any]:
|
||||
"""장부 짝(고른 것 꼴 · `Ledger.check_pair`) — 참조 = 만든 SK · SC 키."""
|
||||
keep = ("갈래", "키", "입력값", "배", "몫", "비목", "기준", "별도계상")
|
||||
out = {k: p[k] for k in keep if p.get(k) is not None}
|
||||
return out | ({"키": p["참조"]["키"]} if "참조" in p else {})
|
||||
|
||||
|
||||
# ── 고른 것 검사 · 다시 셈 ──────────────────────────────────────────────
|
||||
class Move:
|
||||
"""한 번 옮기기 — 파일 · M01 · 만든 SC · SK 를 한 번 읽음(쓰기 = `store.root()`)."""
|
||||
|
||||
def __init__(self, folder: Path, source: Path, key: str, who: Any) -> None:
|
||||
self.folder, self.source, self.key, self.who = Path(folder), Path(source), key, who
|
||||
self.hp = hopyo_store.read(source, key)
|
||||
self.units = {int(h["번"]): h for h in self.hp.get("호표") or []}
|
||||
self.m = judge.master()
|
||||
self.known = ledger._known()
|
||||
self.sk = sangeun.Maker(source, key, who)
|
||||
self.same = lsame.Same()
|
||||
self.out: dict[str, Any] = {"만듦": [], "단가산출": [], "막힘": [], "하위": []}
|
||||
self.out["장부"] = {"씀": 0}
|
||||
|
||||
def _ref(self, p: dict) -> dict[str, Any]:
|
||||
"""단가산출 · 일위대가 키 → 참조 칸(같은 HP 블록 · 호표 = 만든 것) · 없으면 막힘."""
|
||||
code, kind = str(p.get("키") or ""), p["갈래"]
|
||||
hit = _SUB[kind].match(code)
|
||||
here = refs.here()
|
||||
if hit:
|
||||
n = int(hit.group(1))
|
||||
made = (
|
||||
self.sk.keys.get(sangeun.mark(self.key, n))
|
||||
if kind == "단가산출"
|
||||
else hbuild.confirmed(self.folder, refs.where(self.source)).get(
|
||||
hbuild.mark(self.key, n)
|
||||
)
|
||||
)
|
||||
if not made:
|
||||
raise Stuck(f"{code} 먼저 옮김")
|
||||
return {"층": here[0], "주인": here[1], "키": made}
|
||||
head = "SK-" if kind == "단가산출" else "SC-"
|
||||
ref = {"층": p.get("층") or here[0], "주인": p.get("주인"), "키": code}
|
||||
if p.get("층") is None:
|
||||
ref["주인"] = here[1]
|
||||
if not code.startswith(head) or refs.read_ref(ref) is None:
|
||||
raise Stuck(f"없는 {kind} 「{code}」")
|
||||
return ref
|
||||
|
||||
def pick(self, p: dict, line: dict | None, where: str) -> dict[str, Any]:
|
||||
"""고른 것 하나 재검사 · 다시 셈 → `{갈래, 키, 입력값, 배, 몫, 수량, 참조?, …}`."""
|
||||
kind = p["갈래"]
|
||||
got = {k: p[k] for k in ("갈래", "키", "몫", "비목", "기준", "별도계상") if k in p}
|
||||
if kind == "기초":
|
||||
full, need = judge.inputs(self.m, str(p.get("키") or ""), p.get("입력값"))
|
||||
if need:
|
||||
raise Stuck(f"{NEED} {len(need)}", need)
|
||||
got["입력값"] = full
|
||||
if kind in _SUB:
|
||||
got["참조"] = self._ref(p)
|
||||
else:
|
||||
plain = {k: v for k, v in got.items() if k in ledger.PICK_KEYS}
|
||||
ledger.check_pair(plain, where, self.known)
|
||||
if kind in ("재료", "유가") and not got.get("키"):
|
||||
raise Stuck(f"{kind} 키 없음")
|
||||
if line is not None:
|
||||
qty = judge.quantity(self.m, line, p)
|
||||
if qty["배"] is None:
|
||||
raise Stuck("단위 환산 입력")
|
||||
got["배"], got["수량"] = qty["배"], Decimal(str(qty["우리"] or 0))
|
||||
return got
|
||||
|
||||
# ── SC ──
|
||||
def hopyo(self, no: int, rows: dict[int, list[dict]]) -> None:
|
||||
unit = self.units[no]
|
||||
lines = unit.get("줄") or []
|
||||
checked = {}
|
||||
for at, picks in sorted(rows.items()):
|
||||
if not 0 <= at < len(lines):
|
||||
raise store.StoreError(400, f"없는 줄 「제{no}호표 {at}」")
|
||||
where = f"제{no}호표 {at + 1}째 줄"
|
||||
if not picks:
|
||||
continue
|
||||
try:
|
||||
checked[at] = {"고른": [self.pick(p, lines[at], where) for p in picks]}
|
||||
except Stuck as e:
|
||||
self.out["막힘"].append({"호표": no, "차례": at, "까닭": e.why, "칸": e.cells})
|
||||
b = hbuild.Builder(self.folder, self.source, self.key, no, self.who)
|
||||
b.strict, b.same = False, self.same
|
||||
made = b.build(checked, [])
|
||||
ref = made["참조"]
|
||||
head = {"번": no, "키": ref["키"], "이름": unit.get("이름")}
|
||||
head |= {k: made[k] for k in ("다시씀", "재사용")}
|
||||
self.out["만듦"].append({**head, "비교": compare(ref, unit)})
|
||||
self.out["장부"]["씀"] += made["장부"]["씀"]
|
||||
|
||||
def kid(self, no: int) -> None:
|
||||
"""확인 안 된 하위 호표 — 모두 고정(705-3 길)으로 먼저."""
|
||||
b = hbuild.Builder(self.folder, self.source, self.key, no, self.who)
|
||||
b.strict, b.same = False, self.same
|
||||
if b.done.get(hbuild.mark(self.key, no)):
|
||||
return
|
||||
made = b.build({}, [])
|
||||
self.out["하위"].append({"번": no, "키": made["참조"]["키"], "새로": True})
|
||||
|
||||
# ── SK ──
|
||||
def block(self, no: int, picks: list[dict]) -> None:
|
||||
blk = self.sk.blocks[no]
|
||||
items = blk.get("항목") or []
|
||||
us = self.sk.units()
|
||||
made: list[dict] = []
|
||||
rows: list[dict] = []
|
||||
for p in picks:
|
||||
got = self.pick(p, None, f"단산{no}")
|
||||
name = str(p.get("이름") or "").strip() or str(blk.get("이름") or f"단산{no}")
|
||||
share = got.get("몫") or 1
|
||||
at = p.get("항목")
|
||||
item = items[at] if isinstance(at, int) and 0 <= at < len(items) else None
|
||||
if got["갈래"] == "기초":
|
||||
made.append({"종류": "로직", "로직": got["키"], "입력값": got["입력값"],
|
||||
"이름": name, "수량": share}) # fmt: skip
|
||||
continue
|
||||
if got["갈래"] in _SUB:
|
||||
qty = float(item.get("품") or 1) if item else share
|
||||
made.append({"참조": got["참조"], "이름": name, "수량": qty})
|
||||
continue
|
||||
if item is None:
|
||||
raise Stuck(f"{got['갈래']} — 블록 단위 줄(항목) 고름")
|
||||
if got["갈래"] == "고정":
|
||||
parts = hfix._parts(item.get("금액"))
|
||||
fx = {"이름": name, "단가": {k: float(v) for k, v in parts.items()}, "이음": {}}
|
||||
made.append({"고정": fx, "이름": name, "수량": 1})
|
||||
continue
|
||||
old = {"기준": got.get("기준"), "비목": got.get("비목")}
|
||||
if got["갈래"] in OLD_PICK:
|
||||
old = {OLD_PICK[got["갈래"]]: got["키"], "별도계상": got.get("별도계상")}
|
||||
unit = skunit._picked(item, old, us)["단위"]
|
||||
rows.append({"차례": at, "행": item.get("행"), "상태": units.LINKED, "단위": unit})
|
||||
extra, _ = skunit.adds(blk, rows, us, self.sk.ref_of)
|
||||
doc = {"키": "SK-000000", "이름": "옮김", "단위": blk.get("단위") or "식", "바탕": None}
|
||||
try:
|
||||
adds = sd.clean({**doc, "더한줄": made + extra}, None, self.sk.whole)["더한줄"]
|
||||
except (ValueError, store.StoreError) as e:
|
||||
raise Stuck(f"더한줄 틀림 — {e}") from e
|
||||
plan = {"갈래": "고름", "바탕": None, "입력값": {}, "덮개": {}, "자재표시": {}, "뺀줄": []}
|
||||
plan |= {"더한줄": adds, "못옮김": [], "놓음": []}
|
||||
mark_ = sangeun.mark(self.key, no)
|
||||
key = self.sk.write(no, plan, self.sk.keys.get(mark_))
|
||||
self.sk.keys[mark_] = key
|
||||
self.out["단가산출"].append({"번": no, "키": key, "이름": blk.get("이름")})
|
||||
|
||||
def blocks(self, sure: dict[int, list[dict]]) -> None:
|
||||
"""하위 단산(블록 단위 줄 하위) 먼저."""
|
||||
done: set[int] = set()
|
||||
|
||||
def visit(no: int, path: tuple = ()) -> None:
|
||||
if no in done or no in path:
|
||||
return
|
||||
for one in self.sk.blocks[no].get("항목") or []:
|
||||
sub = one.get("하위") or {}
|
||||
if sub.get("종류") == "단산" and int(sub.get("번") or 0) in sure:
|
||||
visit(int(sub["번"]), (*path, no))
|
||||
done.add(no)
|
||||
try:
|
||||
self.block(no, sure[no])
|
||||
except Stuck as e:
|
||||
self.out["막힘"].append({"블록": no, "까닭": e.why, "칸": e.cells})
|
||||
|
||||
for no in sorted(sure):
|
||||
visit(no)
|
||||
|
||||
|
||||
def move(
|
||||
folder: Path,
|
||||
source: Path,
|
||||
key: str,
|
||||
units_: dict[int, dict[int, list[dict]]],
|
||||
blocks: dict[int, list[dict]],
|
||||
who: Any = None,
|
||||
cover: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""[옮기기] → `{만듦, 단가산출, 막힘, 하위, 장부}` — 블록 SK 먼저 · 호표 하위 먼저 ·
|
||||
이미 옮긴 호표 · 블록(같은 HP 출처 SC · SK)은 `cover`(덮음) 때만 덮음 · 아니면 409 아무것도 안 씀."""
|
||||
mv = Move(folder, source, key, who)
|
||||
bad = [f"제{n}호표" for n in units_ if n not in mv.units]
|
||||
bad += [f"단산{n}" for n in blocks if n not in mv.sk.blocks]
|
||||
if bad:
|
||||
raise store.StoreError(404, f"없는 {bad[0]}")
|
||||
done = hbuild.confirmed(mv.folder, refs.where(mv.source))
|
||||
had = {
|
||||
"호표": [
|
||||
{"번": n, "키": done[k]} for n in sorted(units_) if (k := hbuild.mark(key, n)) in done
|
||||
]
|
||||
}
|
||||
had["블록"] = [
|
||||
{"번": n, "키": mv.sk.keys[k]}
|
||||
for n in sorted(blocks)
|
||||
if (k := sangeun.mark(key, n)) in mv.sk.keys
|
||||
]
|
||||
if not cover and (had["호표"] or had["블록"]):
|
||||
raise store.StoreError(409, {"까닭": COVER, **had})
|
||||
mv.blocks(blocks)
|
||||
wanted = {
|
||||
int(hit.group(1))
|
||||
for rows in units_.values()
|
||||
for picks in rows.values()
|
||||
for p in picks
|
||||
if p.get("갈래") == "일위대가" and (hit := _SUB["일위대가"].match(str(p.get("키") or "")))
|
||||
}
|
||||
order = hbuild._order(mv.units)
|
||||
for no in order: # 고른 것이 부르는 확인 안 된 하위 = 모두 고정으로 먼저
|
||||
if no in wanted and no not in units_:
|
||||
mv.kid(no)
|
||||
for no in order:
|
||||
if no in units_:
|
||||
mv.hopyo(no, units_[no])
|
||||
return mv.out
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
"""M02 불러오기 엑셀 보기 하이라이트 재료(PLAN 779-2 · 요구서 621 3장 5) — 읽기만(파일 · storage 변화 0).
|
||||
|
||||
블록 `[{번, 판정, 고를수, SK, 갈래, 못옮김, 부름}]` = 단가산출 만들기 결과(`Sangeun.made`) + 블록 판정을 읽기만.
|
||||
- `판정` = 블록 판정(785-6 · `M02_Hopyo_Judge.judged` 파생 저장본 = 기초로 풂 · 기초+덧 · 줄 조합 · 고를 것 ·
|
||||
`고를수`) · 안 불리거나 셈 전이면 None
|
||||
블록 `[{번, 이름, 단위, 판정, SK, 갈래, 못옮김, 부름}]` = 단가산출 만들기 결과(`Sangeun.made`)를 읽기만.
|
||||
- `판정` = 불리는 블록 「가져온 식」(809-1 · 경쟁사 로직 인정 · 기초 판정 없음) · 안 불리면 None
|
||||
- `부름` = 부르는 호표 번 [] = 일위대가 밖(780-1) · `어긋남` · `줄` = 모든 블록(밖 포함)
|
||||
- `SK` = 만든 SK 키 · 없으면 None / `갈래` · `못옮김` = 그 SK 본뜬(출처별) · 없으면 None · []
|
||||
줄 상태는 목록(`M02_Hopyo_Judge.view`) 호표마다 `줄상태 [[차례, 1차 판정, 제안 있음]]` · `만든`(SC 키)로 실려 감.
|
||||
줄 상태는 목록(`M02_Hopyo_View.listing`) 호표마다 `줄상태 [[차례, 표지, 이어짐]]` · `만든`(SC 키)로 실려 감.
|
||||
|
||||
칠 꼴(화면 779-4 가 정함 · 상태 이름 맞춤표 — 「이어짐」 = 확인 창과 한 규칙 `체크`):
|
||||
| 칠 | 줄 상태 (`줄상태`) | 블록 (`판정` · `SK` · `못옮김`) |
|
||||
| 초록 이어짐 | 맞음 · 별칭 · 호표(`체크`) · 체크한 다른 점 | SK 있음 + 못옮김 [] (갈래 「기초」 포함) |
|
||||
| 주황 엑셀과 다름 | 다른 점 + `체크` | SK 있음 + 갈래 「SK」(차이) |
|
||||
| 파랑 고를 것 | 후보 · `체크` 아닌 다른 점 · 호표(`체크` 아님) | SK 없음 + 판정 후보 · 고름 대기 |
|
||||
| 빨강 못 이음 | 없음 · 노임 · (안 이은 줄 = 마법사 몫) | SK 있음 + 못옮김 남음 |
|
||||
| 흐림 밖 | % · 뺌 | 판정 None (안 불리는 블록) |
|
||||
| 띠 내가 고름 | 화면 캐시(서버 아님) | — |
|
||||
최종 판정은 화면(`SheetMap`)이 이 재료 + 캐시 고름으로 내림 — 여기는 상태 이름을 그대로 실음.
|
||||
칠 꼴(화면 779-4 `SheetMap`) — 줄 = 이어짐(연결됨 · 로직) 초록 · 후보 파랑 · 못 이음 빨강 / 블록 = 만든 SK 갈래
|
||||
(못옮김 남음 빨강 · 차이 SK 주황 · 그 밖 초록) · 없으면 「가져온 식」 초록 · 안 불림 흐림.
|
||||
|
||||
`어긋남`(778-5 D-6 · 요구 623 · 624) = 불리는 블록의 엑셀 셈 어긋남(엑셀 = 과거 값 · 사용자가 보고 판단 ·
|
||||
칠 · 판정에 안 씀) — `{줄합, 금액, 차, 덮음, 줄 [{행, 이름, 묶음, 셈, 금액, 차, 까닭}], 까닭}` · 없으면 None.
|
||||
@@ -31,7 +23,6 @@ import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Judge as hjudge
|
||||
from M02_MasterTemplete import M02_Hopyo_Sangeun as sangeun
|
||||
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
|
||||
from M02_MasterTemplete import M02_Hopyo_UnitLine as unit_line
|
||||
@@ -39,6 +30,9 @@ from M02_MasterTemplete import M02_Hopyo_Units as hunits
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
|
||||
|
||||
IMPORTED = "가져온 식" # 불리는 블록 = 경쟁사 로직 그대로(809-1)
|
||||
|
||||
|
||||
def _origin(to: Path, sk: str, mark: str, here: refs.Here) -> dict[str, Any]:
|
||||
try:
|
||||
doc = json.loads((to / sangeun.KIND / f"{sk}.json").read_bytes())
|
||||
@@ -130,18 +124,17 @@ def blocks(folder: Path, key: str, to: Path | None = None) -> list[dict[str, Any
|
||||
all_ = sangeun.calls(hp)[0]
|
||||
units = hunits.Units() if all_ else None
|
||||
call = sangeun.callers(hp)
|
||||
judge = hjudge.judged(folder, key) or {} # 셈 중 = None
|
||||
out = []
|
||||
for no in sorted(all_):
|
||||
mark = sangeun.mark(key, no)
|
||||
sk = have.get(mark)
|
||||
origin = _origin(to, sk, mark, here) if sk else {}
|
||||
new = judge.get(no) or {}
|
||||
out.append(
|
||||
{
|
||||
"번": no,
|
||||
"판정": new.get("판정"),
|
||||
"고를수": new.get("고를수"),
|
||||
"이름": all_[no].get("이름"),
|
||||
"단위": all_[no].get("단위"),
|
||||
"판정": IMPORTED if call.get(no) else None,
|
||||
"SK": sk,
|
||||
"갈래": origin.get("갈래"),
|
||||
"못옮김": origin.get("못옮김") or [],
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
"""M02 일위대가 실무자료 길(PLAN 785-5 · 요구 642 · 설계 642-A 2-9) — `/api/m02/layers/{층}/hopyo…`.
|
||||
|
||||
새 길(글 먼저 · 결정적 매칭) — 판정 `hopyo-judge`(1차 `M02_Hopyo_Row` + 2차 `M02_Hopyo_Solve` ·
|
||||
`M02_Hopyo_Judge`) · 검산 `hopyo/{키}/check`(저장 없음) · 옮기기 `hopyo/{키}/move`(확인한 것 →
|
||||
SC · SK · 장부 · `M02_Hopyo_Make`) · 경로 층 = 파일 층(판정 · 검산 · `to` = 만들 층) / 만들 층(옮기기).
|
||||
올림 = Book + 격자 + 엑셀 사전(`Row.lexicon`) + 블록 풀이 뒤 셈(`Judge.ensure` · 파생 저장본) ·
|
||||
옛 맞춤 뒤 셈 · 시동 데움 없음 · [끝내기] = 저장본도 지움.
|
||||
근거 제안(793-3a) = `hopyo-basis/{키}`(목록) · `hopyo-basis/{키}/{번}`(블록) · `M02_San_Basis_Store`
|
||||
(올림 때 HP `숨은식` + 뒤 셈 · 파생 저장본 · 판정 길 `hopyo-judge` 칸은 그대로).
|
||||
[수정] 보기 `hopyo-view/{키}/{번}`(809-1 · `M02_Hopyo_View` · 경쟁사 로직 인정 · 요소만 연결 · 읽기만) ·
|
||||
엑셀 보기 머리 · 목록 = `View.listing`(줄 표지) · 옮기기 = [내 일위대가로] `hopyo-build-all {번}`(807-1).
|
||||
옛 판정 · 검산 · 옮기기 · 근거 제안 길(785-5 `hopyo-judge` · `check` · `move` · `price` · 793-3a `hopyo-basis`)은
|
||||
809-1 에서 지움(요구 703 — 기초로 바꾸기 없음).
|
||||
그대로 쓰는 길(목록 · 올림 · 엑셀 보기 머리 · 격자 · 지우기 · 장부 · 모두 고정 · 붙여넣기 · 다시 잇기 ·
|
||||
구조물 조합 · 칠 = B09 · M01 이 씀) · 옛 맞춤 길(호표 하나 맞춤 · 미리 셈 · 실무 호표 · 확인 만들기 · 안 이은 줄 ·
|
||||
단가산출 만들기)은 785-7 에서 지움.
|
||||
@@ -19,7 +16,7 @@ from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from common_util.common_util_auth import verify_session
|
||||
from M02_MasterTemplete import M02_Hopyo_Build as hopyo_build
|
||||
@@ -27,239 +24,37 @@ from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel
|
||||
from M02_MasterTemplete import M02_Hopyo_Forget as hopyo_forget
|
||||
from M02_MasterTemplete import M02_Hopyo_Grid as hopyo_grid
|
||||
from M02_MasterTemplete import M02_Hopyo_Item as hopyo_item
|
||||
from M02_MasterTemplete import M02_Hopyo_Judge as hopyo_judge
|
||||
from M02_MasterTemplete import M02_Hopyo_Ledger as hopyo_ledger
|
||||
from M02_MasterTemplete import M02_Hopyo_Make as hopyo_maker
|
||||
from M02_MasterTemplete import M02_Hopyo_Marks as hopyo_marks
|
||||
from M02_MasterTemplete import M02_Hopyo_View as hopyo_view_
|
||||
from M02_MasterTemplete import M02_Hopyo_Paste as hopyo_paste
|
||||
from M02_MasterTemplete import M02_Hopyo_Relink as hopyo_relink
|
||||
from M02_MasterTemplete import M02_Hopyo_Row as hopyo_row
|
||||
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
from M02_MasterTemplete import M02_Layer_Access as access
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_Sangeun_FromHopyo as from_hopyo
|
||||
from M02_MasterTemplete import M02_San_Basis_Store as san_basis
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root
|
||||
|
||||
router = APIRouter(prefix="/api/m02", tags=["M02 Hopyo"])
|
||||
|
||||
|
||||
# ── 새 길(785-5) — 판정 · 검산 · 옮기기 ─────────────────────────────────────
|
||||
def _to(session: Session, folder: Path, to: Layer | None, to_owner: str | None) -> Path:
|
||||
return _root(session, to, to_owner) if to is not None else folder
|
||||
# ── [수정] 보기(809-1) — 경쟁사 로직 인정 · 요소만 연결 · 읽기만 ─────────────────
|
||||
|
||||
|
||||
@router.get("/layers/{layer}/hopyo-judge/{key}")
|
||||
def hopyo_judge_list(
|
||||
layer: Layer,
|
||||
key: str,
|
||||
owner: str | None = None,
|
||||
to: Layer | None = None,
|
||||
to_owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
) -> dict:
|
||||
"""호표 목록 + 1차 판정 수 · 블록 목록 `{키, 파일, 호표 [...], 블록 [...]}`(읽기만)."""
|
||||
folder = _root(session, layer, owner)
|
||||
target = _to(session, folder, to, to_owner)
|
||||
return _call(folder, lambda: hopyo_judge.listing(folder, key, target))
|
||||
|
||||
|
||||
@router.get("/layers/{layer}/hopyo-judge/{key}/{no}")
|
||||
def hopyo_judge_one(
|
||||
layer: Layer,
|
||||
key: str,
|
||||
no: int,
|
||||
owner: str | None = None,
|
||||
to: Layer | None = None,
|
||||
to_owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
) -> dict:
|
||||
"""호표 하나 — 줄 판(1차) · 단산 줄 `풀이`(2차 블록 풀이)."""
|
||||
folder = _root(session, layer, owner)
|
||||
target = _to(session, folder, to, to_owner)
|
||||
return _call(folder, lambda: hopyo_judge.hopyo(folder, key, no, target))
|
||||
|
||||
|
||||
@router.get("/layers/{layer}/hopyo-judge/{key}/block/{no}")
|
||||
def hopyo_judge_block(
|
||||
layer: Layer,
|
||||
key: str,
|
||||
no: int,
|
||||
owner: str | None = None,
|
||||
to: Layer | None = None,
|
||||
to_owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
) -> dict:
|
||||
"""단산 블록 하나 — 블록 풀이(`Solve.block`) + 이름 · 단위 · 부름 · 근거 · 만든."""
|
||||
folder = _root(session, layer, owner)
|
||||
target = _to(session, folder, to, to_owner)
|
||||
return _call(folder, lambda: hopyo_judge.block(folder, key, no, target))
|
||||
|
||||
|
||||
@router.get("/layers/{layer}/hopyo-basis/{key}")
|
||||
def hopyo_basis_list(
|
||||
layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session)
|
||||
) -> dict:
|
||||
"""근거 제안(793-3a) — `{셈중, 블록 {번: 제안}, 보강}` · 셈 중 = 뒤 셈 시작(읽기만)."""
|
||||
folder = _root(session, layer, owner)
|
||||
return _call(folder, lambda: san_basis.listing(folder, key))
|
||||
|
||||
|
||||
@router.get("/layers/{layer}/hopyo-basis/{key}/{no}")
|
||||
def hopyo_basis_block(
|
||||
@router.get("/layers/{layer}/hopyo-view/{key}/{no}")
|
||||
def hopyo_view(
|
||||
layer: Layer,
|
||||
key: str,
|
||||
no: int,
|
||||
owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
) -> dict:
|
||||
"""근거 제안 블록 하나 — 저장본 › 그 자리 셈(`M02_San_Basis.suggest` 꼴)."""
|
||||
"""호표 하나 → 일위대가 문서 미리보기(엑셀 숫자 그대로 고정 줄) + 줄 표지(로직 · 요소) — 저장 없음."""
|
||||
folder = _root(session, layer, owner)
|
||||
return _call(folder, lambda: san_basis.block(folder, key, no))
|
||||
return _call(folder, hopyo_view_.view, folder, key, no)
|
||||
|
||||
|
||||
class Pick(BaseModel):
|
||||
"""고른 것(785-3 꼴) — 금액 · 품 칸 없음(서버가 셈) · 고정 단가 = 엑셀 줄 것."""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
갈래: Literal["기초", "단가산출", "일위대가", "인력", "재료", "유가", "기계", "율", "고정"]
|
||||
키: str | None = Field(None, max_length=40)
|
||||
이름: str | None = Field(None, max_length=200)
|
||||
단위: str | None = Field(None, max_length=40)
|
||||
입력값: dict[str, Any] = Field(default_factory=dict)
|
||||
몫: float | None = None
|
||||
배: float | None = None
|
||||
비목: str | None = None
|
||||
기준: str | list[str] | None = None
|
||||
별도계상: bool | None = None
|
||||
층: Layer | None = None
|
||||
주인: str | None = None
|
||||
항목: int | None = None # 블록 고른 것 — 블록 단위 줄 차례(수량 = 그 줄 품)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _no_money(cls, raw: Any) -> Any:
|
||||
"""금액 · 품 · 수량 칸 = 400(서버가 셈 · 브라우저 값 안 받아 적음)."""
|
||||
if isinstance(raw, dict) and {"금액", "품", "수량"} & set(raw):
|
||||
raise ValueError("금액 · 품 · 수량은 서버가 셈")
|
||||
return raw
|
||||
|
||||
|
||||
class CheckBody(BaseModel):
|
||||
"""`호표` + `차례` = 일위대가 줄 · `블록` = 단산 블록(항목 줄과 대조) · `고른` = 고른 것."""
|
||||
|
||||
호표: int | None = None
|
||||
차례: int | None = None
|
||||
블록: int | None = None
|
||||
고른: list[Pick] = Field(default_factory=list, max_length=20)
|
||||
|
||||
|
||||
@router.post("/layers/{layer}/hopyo/{key}/check")
|
||||
def hopyo_check(
|
||||
layer: Layer,
|
||||
key: str,
|
||||
body: CheckBody,
|
||||
owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
) -> dict:
|
||||
"""검산(저장 없음) — 고른 것마다 입력값(엔진이 쓴 칸 모두) · 입력필요 · 줄 대조 · 금액(서버 셈)."""
|
||||
folder = _root(session, layer, owner)
|
||||
picks = [p.model_dump(exclude_none=True) for p in body.고른]
|
||||
args = (folder, key, body.호표, body.차례, body.블록, picks)
|
||||
return _call(folder, hopyo_judge.check, *args)
|
||||
|
||||
|
||||
class MoveBody(BaseModel):
|
||||
"""확인한 것 — 파일 층 `층` · `주인` · `호표 {번: {차례: [고른 것]}}` · `블록 {번: [고른 것]}`."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
층: Layer
|
||||
주인: str | None = None
|
||||
호표: dict[int, dict[int, list[Pick]]] = Field(default_factory=dict, max_length=500)
|
||||
블록: dict[int, list[Pick]] = Field(default_factory=dict, max_length=500)
|
||||
덮음: bool = False # 이미 옮긴 호표 · 블록(같은 HP 출처) 덮기 — 아니면 409 {까닭, 호표, 블록}
|
||||
|
||||
|
||||
@router.post("/layers/{layer}/hopyo/{key}/move")
|
||||
def hopyo_move(
|
||||
layer: Layer,
|
||||
key: str,
|
||||
body: MoveBody,
|
||||
owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
) -> dict:
|
||||
"""[옮기기] — 확인한 것 → 이 층 SC · SK · 장부(하위 먼저 · 서버가 다시 셈) →
|
||||
`{만듦, 단가산출, 막힘, 하위, 장부}`."""
|
||||
folder = _root(session, layer, owner, write=True)
|
||||
source = _root(session, body.층, body.주인)
|
||||
dump = lambda ps: [p.model_dump(exclude_none=True) for p in ps] # noqa: E731
|
||||
units = {no: {at: dump(ps) for at, ps in rows.items()} for no, rows in body.호표.items()}
|
||||
blocks = {no: dump(ps) for no, ps in body.블록.items()}
|
||||
args = (folder, source, key, units, blocks, _who(session), body.덮음)
|
||||
return _call(folder, hopyo_maker.move, *args)
|
||||
|
||||
|
||||
class PriceBody(BaseModel):
|
||||
"""우리 금액 미리 셈(786-2) — 파일 층 `층` · `주인` · `호표` 번 · `줄 {차례: [고른 것]}`."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
층: Layer
|
||||
주인: str | None = None
|
||||
호표: int
|
||||
줄: dict[int, list[Pick]] = Field(default_factory=dict, max_length=300)
|
||||
|
||||
|
||||
def _price(folder: Path, source: Path, key: str, no: int, rows: dict[int, list[dict]]) -> dict:
|
||||
"""줄마다 옮기기와 같은 길(`Make.Move.pick` 재검사 · `Make.Lines.picks` 줄 · 자재)로
|
||||
한 줄 문서 → `Labor_Doc.calc`(정본 셈 · 저장 없음 · 같은 기초 둘째 = 미리 참조) ·
|
||||
막힘 = 그 줄 까닭만."""
|
||||
mv = hopyo_maker.Move(folder, source, key, None)
|
||||
if no not in mv.units:
|
||||
raise store.StoreError(404, f"없는 제{no}호표")
|
||||
lines = mv.units[no].get("줄") or []
|
||||
b = hopyo_build.Builder(folder, source, key, no, None)
|
||||
b.dry, b.strict = True, False
|
||||
where = {"파일": mv.hp.get("파일") or "", "호표": no}
|
||||
out: dict[int, dict[str, Any]] = {}
|
||||
for at, picks in rows.items():
|
||||
if not 0 <= at < len(lines) or not picks:
|
||||
continue
|
||||
try:
|
||||
got = [mv.pick(p, lines[at], f"제{no}호표 {at + 1}째 줄") for p in picks]
|
||||
except hopyo_maker.Stuck as e:
|
||||
out[at] = {"금액": None, "까닭": [e.why], "칸": e.cells}
|
||||
continue
|
||||
body = hopyo_maker.Lines(b, lines, where, "", hopyo_maker.hfix.Linker(False))
|
||||
body.picks(at, lines[at], got)
|
||||
doc = {"종류": "일위대가", "열": "hopyo-price", "단위": None, "담은로직": body.lines}
|
||||
try:
|
||||
calc = labor.calc(labor.clean({**doc, "자재": list(body.mats.values())}), {})
|
||||
except (ValueError, store.StoreError) as e:
|
||||
out[at] = {"금액": None, "까닭": [str(getattr(e, "detail", e))], "칸": []}
|
||||
continue
|
||||
why = [x["까닭"] for x in [*calc.get("줄", []), *calc.get("자재", [])] if x.get("까닭")]
|
||||
out[at] = {"금액": calc.get("반영합"), "자재": calc.get("자재", []), "까닭": why, "칸": []}
|
||||
return {"줄": out}
|
||||
|
||||
|
||||
@router.post("/layers/{layer}/hopyo/{key}/price")
|
||||
def hopyo_price(
|
||||
layer: Layer,
|
||||
key: str,
|
||||
body: PriceBody,
|
||||
owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
) -> dict:
|
||||
"""우리 금액 미리 셈(786-2 [수정] 모달 · 저장 없음) — 경로 층 = 만들 층(참조 읽기) ·
|
||||
몸 층 = 파일 층 → `{줄 {차례: {금액 {노무비 · 재료비 · 경비 · 계}, 자재, 까닭, 칸}}}`."""
|
||||
folder = _root(session, layer, owner)
|
||||
source = _root(session, body.층, body.주인)
|
||||
rows = {at: [p.model_dump(exclude_none=True) for p in ps] for at, ps in body.줄.items()}
|
||||
return _call(folder, _price, folder, source, key, body.호표, rows)
|
||||
|
||||
|
||||
# ── 옛 길(785-7 까지 · 옛 화면) · 그대로 쓰는 길 ─────────────────────────────
|
||||
# ── 그대로 쓰는 길 ─────────────────────────────────────────────────────
|
||||
HOPYO_MAX_BYTES = 20 * 1024 * 1024
|
||||
|
||||
|
||||
@@ -274,8 +69,6 @@ def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]:
|
||||
cannot = None
|
||||
if not from_hopyo.has(store.root(), same["키"]): # 793-3b 전에 올린 HP
|
||||
cannot = from_hopyo.keep(store.root(), same["키"], name, data)
|
||||
hopyo_judge.ensure(store.root(), same["키"])
|
||||
san_basis.ensure(store.root(), same["키"])
|
||||
return {**same, "이어서": True, **({"읽기불가": cannot} if cannot else {})}
|
||||
try:
|
||||
parsed = hopyo_excel.read_book(name, data)
|
||||
@@ -285,15 +78,10 @@ def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]:
|
||||
grid = hopyo_grid.read(name, data, parsed)
|
||||
except Exception: # 격자는 보기용 덤 — 못 만들어도 올리기는 됨(보기 창 404)
|
||||
grid = None
|
||||
sheets, _ = hopyo_excel._open(name, data, hopyo_row.SHEETS)
|
||||
parsed["사전"] = hopyo_row.lexicon(sheets) # 엑셀 사전(785-3 · 1차 줄 판정)
|
||||
parsed["숨은식"] = san_basis.hidden(parsed, sheets) # STmate 숨은 G 식(793-3a 근거 제안)
|
||||
row = hopyo_store.save(store.root(), name, parsed, who, grid, mark)
|
||||
cannot = from_hopyo.keep(
|
||||
store.root(), row["키"], name, data
|
||||
) # 산근 시트(793-3b 가져온 식 열기)
|
||||
hopyo_judge.ensure(store.root(), row["키"]) # 블록 풀이 뒤 셈(파생 저장본 · 화면 안 기다림)
|
||||
san_basis.ensure(store.root(), row["키"]) # 근거 제안 뒤 셈(793-3a)
|
||||
return (
|
||||
{**row, "읽기불가": cannot} if cannot else row
|
||||
) # 797-1 가져온 식 못 씀 사유 → 화면 토스트
|
||||
@@ -328,10 +116,10 @@ def hopyo_upload(
|
||||
def hopyo_read(
|
||||
layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session)
|
||||
) -> dict:
|
||||
"""실무자료 하나(엑셀 보기 머리 · 목록) — 검산 · 호표마다 1차 판정 `맞춤` [제안 있음, 줄 수] · `줄상태` ·
|
||||
`만든` · 블록 풀이 `셈`(`M02_Hopyo_Judge.view`)."""
|
||||
"""실무자료 하나(엑셀 보기 머리 · 목록) — 검산 · 호표마다 `맞춤` [연결됨 + 로직 줄, 줄 수] · `줄상태` ·
|
||||
`만든`(`M02_Hopyo_View.listing` · 809-1)."""
|
||||
folder = _root(session, layer, owner)
|
||||
return _call(folder, hopyo_judge.view, folder, key, folder)
|
||||
return _call(folder, hopyo_view_.listing, folder, key, folder)
|
||||
|
||||
|
||||
@router.get("/layers/{layer}/hopyo-grid/{key}")
|
||||
@@ -456,8 +244,6 @@ def hopyo_delete(
|
||||
만든 일위대가 · 단가산출은 엑셀 사본만 걷고 남음 · 별칭 장부 그대로 → `{지움, 만든}`."""
|
||||
folder = _root(session, layer, owner, write=True)
|
||||
got = _call(folder, hopyo_forget.finish, folder, key)
|
||||
hopyo_judge.forget(folder, key) # 블록 풀이 저장본(785-5)
|
||||
san_basis.forget(folder, key) # 근거 제안 저장본(793-3a)
|
||||
return got
|
||||
|
||||
|
||||
|
||||
@@ -1,67 +1,31 @@
|
||||
"""M02 엑셀 불러오기 1차 줄 판정(PLAN 785-3 · 요구 641 · 642 · 설계 642-A 2-3 · 분석 642-C 5장).
|
||||
"""M02 엑셀 줄 → M01 요소 후보 · 환산(PLAN 785-3 · 809-1) — 서버 단독 · 읽기만.
|
||||
|
||||
엑셀 사전 = 단가표 시트 → 갈래 + M01 키 후보(한 번 · `lexicon`) — JSON 꼴 `{꼴, 줄 [사전 줄]}` ·
|
||||
사전 줄 = `{갈래, 이름, 규격, 단위, 단가, 시트, 행, 코드?, 후보 [고른 것]}`.
|
||||
EST = 자재단가(재료 · 이름이 M01 유가전력이면 유가) · 노임단가(인력) · 기계경비총괄표(기계) ·
|
||||
기계경비적용기준(기본재료비 = 유가 · 환율 = 환율 · 줄 꼴 없음 → 꼬리표만) · 단가산출총괄표 ·
|
||||
일위대가총괄표 · 머리 줄 = 앞 열 줄 안 「품명」 칸(열은 머리 글로 찾음).
|
||||
STmate = 목록표(`Stmate._lists`) · 줄 코드가 열쇠(L 인력 · X 기계 · M 재료 · D 단가산출 ·
|
||||
S · W 고정).
|
||||
세부행 갈래(위가 먼저) = ① 비고 「단산N · 일위N참조」(이름으로 이은 줄 뺌) ② 품명 · 규격
|
||||
「별도계상 · 별도계산」 → 재료 ③ 단위 % · 규격 「…비의」 → 율 · hr · 시간 → 기계 · 인 → 인력
|
||||
④ 사전(코드 › 품명+규격 › 품명) · 갈래 여럿 = 금액 칸 꼴(재료비만 → 재료 · 유가)로 가름 ·
|
||||
그 밖 = 고정.
|
||||
후보 찾기 차례 = 장부(`Ledger.recall` · 고른 것 꼴) › M01 표마다(LB · MT · MO · EQ) › M02 층
|
||||
SK · SC 같은 이름(위 층까지 · 참고).
|
||||
판정 = 제안 있음(장부 짝 · 후보 하나 · 별도계상 = 고정 줄 하나(`품목` 표지) · 하위 호표 · 율) ·
|
||||
2차 필요(단가산출 줄 =
|
||||
`Bind` · `Solve` 몫) · 후보 여럿 · 후보 없음(고정 금액 제안).
|
||||
고른 것 = `{갈래 기초|단가산출|일위대가|인력|재료|유가|기계|율|고정, 키, 이름, 규격, 단위, 단가,
|
||||
입력값, 출처, 몫, 비목?, 기준?, 별도계상?, 층?, 주인?}`(설계 2-1 · 만들기 = `Make` 몫).
|
||||
수량 = `{엑셀, 배, 갈래}` — 우리 수량 = 엑셀 수량 × 몫 × 배(`factor` · 옮김 · 같은 단위 = 1).
|
||||
서버 단독 · 읽기만(M01 · 층 문서 · 장부).
|
||||
`m01_picks` = 갈래(인력 · 재료 · 유가 · 기계) + 줄 이름 · 규격 · 단위 → M01 표마다(LB · MT · MO · EQ) 후보
|
||||
(가져온 식 이음 `M02_San_Emit` 이 부름) · `CURRENCY` = 환율 낱말 · `factor` · `quantity` = 단위 환산 배 ·
|
||||
우리 수량 = 엑셀 수량 × 몫 × 배.
|
||||
옛 1차 줄 판정(엑셀 사전 · 제안 · 2차 필요 · 기초 후보)은 809-1 에서 지움(경쟁사 로직 인정 · 요구 703).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
import unicodedata
|
||||
from decimal import Decimal
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M02_MasterTemplete import M02_Hopyo_Elem as elem
|
||||
from M02_MasterTemplete import M02_Hopyo_Ledger as ledger
|
||||
from M02_MasterTemplete import M02_Hopyo_Match as hm
|
||||
from M02_MasterTemplete import M02_Hopyo_Material as hmat
|
||||
from M02_MasterTemplete import M02_Hopyo_ReadEst as est
|
||||
from M02_MasterTemplete import M02_Hopyo_Stmate as stmate
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
|
||||
mm, mp = hm.mm, hm.mp
|
||||
KINDS = ("기초", "단가산출", "일위대가", "인력", "재료", "유가", "기계", "율", "고정")
|
||||
VERDICTS = ("제안 있음", "2차 필요", "후보 여럿", "후보 없음")
|
||||
COSTS = ("노무비", "재료비", "경비")
|
||||
SHEET_KIND = {
|
||||
"자재단가": "재료",
|
||||
"노임단가": "인력",
|
||||
"기계경비총괄표": "기계",
|
||||
"기계경비적용기준": "유가",
|
||||
"단가산출총괄표": "단가산출",
|
||||
"일위대가총괄표": "일위대가",
|
||||
}
|
||||
SHEETS = (*SHEET_KIND, *stmate.LISTS)
|
||||
CODE_KIND = {"L": "인력", "X": "기계", "M": "재료", "D": "단가산출", "B": "일위대가"}
|
||||
APART = ("별도계상", "별도계산")
|
||||
MACHINE_UNITS = ("hr", "시간", "h")
|
||||
CURRENCY = (("달러", "USD"), ("$", "USD"), ("엔", "JPY"), ("유로", "EUR"), ("파운드", "GBP"))
|
||||
_RATE_BASE = re.compile(r"(노무비|재료비|경비|인건비|자재비|노임|합계)\s*의")
|
||||
_BASE_COST = {"노": "노무비", "인": "노무비", "품": "노무비", "재": "재료비", "자": "재료비"}
|
||||
TOP = 5
|
||||
CURRENCY = (("달러", "USD"), ("$", "USD"), ("엔", "JPY"), ("유로", "EUR"), ("파운드", "GBP"))
|
||||
|
||||
|
||||
def flat(value: Any) -> str:
|
||||
@@ -85,103 +49,6 @@ def _plain(value: Any) -> int | float | None:
|
||||
|
||||
|
||||
# ── 엑셀 사전 ───────────────────────────────────────────────────────────
|
||||
def _header(rows: list[list[Any]]) -> tuple[int, dict[str, int]] | None:
|
||||
for at, row in enumerate(rows[:10]):
|
||||
heads = [flat(v) for v in row]
|
||||
if "품명" in heads:
|
||||
return at, {h: i for i, h in reversed(list(enumerate(heads))) if h}
|
||||
return None
|
||||
|
||||
|
||||
def _list_rows(rows: list[list[Any]], sheet: str, kind: str) -> list[dict[str, Any]]:
|
||||
"""단가표 시트 줄 — No(수 · 「제N호표」) · 품명 있는 줄 · 단가 = 적용단가 › 합계 › 단가."""
|
||||
got = _header(rows)
|
||||
if got is None:
|
||||
return []
|
||||
at, cols = got
|
||||
name = cols["품명"]
|
||||
no = cols.get("no", cols.get("호표", name - 1))
|
||||
price = next((cols[h] for h in ("적용단가", "합계", "단가") if h in cols), None)
|
||||
cell = lambda r, c: r[c] if c is not None and c < len(r) else None # noqa: E731
|
||||
out = []
|
||||
for i, row in enumerate(rows[at + 1 :], at + 2):
|
||||
label = flat(cell(row, no))
|
||||
if not flat(cell(row, name)) or not (label.isdigit() or re.fullmatch(r"제\d+호표", label)):
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"갈래": kind,
|
||||
"이름": str(cell(row, name)).strip(),
|
||||
"규격": str(cell(row, cols.get("규격")) or "").strip(),
|
||||
"단위": str(cell(row, cols.get("단위")) or "").strip(),
|
||||
"단가": _plain(_num(cell(row, price))),
|
||||
"시트": sheet,
|
||||
"행": i,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _rates(rows: list[list[Any]], sheet: str) -> list[dict[str, Any]]:
|
||||
"""기계경비적용기준 환율 칸 — 「화폐」 머리 아래 (화폐, 환율) 줄."""
|
||||
for at, row in enumerate(rows[:10]):
|
||||
heads = [flat(v) for v in row]
|
||||
if "화폐" in heads:
|
||||
c = heads.index("화폐")
|
||||
return [
|
||||
{
|
||||
"갈래": "환율",
|
||||
"이름": str(r[c]).strip(),
|
||||
"규격": "",
|
||||
"단위": "원",
|
||||
"단가": _plain(_num(r[c + 1])),
|
||||
"시트": sheet,
|
||||
"행": i,
|
||||
} # fmt: skip
|
||||
for i, r in enumerate(rows[at + 1 :], at + 2)
|
||||
if c + 1 < len(r) and flat(r[c]) and _num(r[c + 1])
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
def lexicon(sheets: dict[str, list[list[Any]]], keys: bool = True) -> dict[str, Any]:
|
||||
"""시트 줄 → 엑셀 사전 `{꼴, 줄}` — `keys` = 줄마다 M01 키 후보(한 번)."""
|
||||
if stmate.is_stmate(sheets):
|
||||
rows = [
|
||||
{
|
||||
"갈래": CODE_KIND.get(code[0], "고정"),
|
||||
**one,
|
||||
"단가": None,
|
||||
"시트": "목록표",
|
||||
"행": None,
|
||||
"코드": code,
|
||||
} # fmt: skip
|
||||
for code, one in stmate._lists(sheets).items()
|
||||
]
|
||||
shape = "STmate"
|
||||
else:
|
||||
rows = []
|
||||
for sheet, kind in SHEET_KIND.items():
|
||||
if sheets.get(sheet):
|
||||
rows += _list_rows(sheets[sheet], sheet, kind)
|
||||
rows += _rates(sheets.get("기계경비적용기준") or [], "기계경비적용기준")
|
||||
shape = "EST"
|
||||
for row in rows:
|
||||
if row["갈래"] == "재료" and elem.oil_rows(row["이름"]):
|
||||
row["갈래"] = "유가"
|
||||
if keys:
|
||||
row["후보"] = m01_picks(row["갈래"], row)
|
||||
return {"꼴": shape, "줄": rows}
|
||||
|
||||
|
||||
def _index(lex: dict[str, Any] | None) -> dict[str, list[dict[str, Any]]]:
|
||||
out: dict[str, list[dict[str, Any]]] = {}
|
||||
for row in (lex or {}).get("줄") or []:
|
||||
if row.get("코드"):
|
||||
out.setdefault("코드|" + row["코드"], []).append(row)
|
||||
out.setdefault(f"{flat(row['이름'])}|{flat(row['규격'])}", []).append(row)
|
||||
out.setdefault(flat(row["이름"]), []).append(row)
|
||||
return out
|
||||
|
||||
|
||||
# ── M01 키 후보(표마다) ───────────────────────────────────────────────────
|
||||
@@ -246,169 +113,12 @@ def _m01_picks(kind: str, name: str, spec: str, unit: str, _whole: int) -> list[
|
||||
|
||||
|
||||
# ── M02 층 SK · SC(같은 이름 · 참고) ────────────────────────────────────────
|
||||
def layer_docs(folder: str | Path | None) -> dict[str, list[dict[str, Any]]]:
|
||||
"""`{정규 이름: [고른 것]}` — 같은 층 → 위 층 · 단가산출 SK · 일위대가 SC."""
|
||||
out: dict[str, list[dict[str, Any]]] = {}
|
||||
if folder is None:
|
||||
return out
|
||||
for one in ledger.chain(folder):
|
||||
layer, owner = refs.where(one)
|
||||
for kind, sub in (("단가산출", "sangeun"), ("일위대가", "precombo")):
|
||||
for path in sorted((Path(one) / sub).glob("S[CK]-*.json")):
|
||||
doc = json.loads(path.read_bytes()) if path.is_file() else {}
|
||||
name = doc.get("이름") or path.stem
|
||||
got = pick(kind, path.stem, name, "", doc.get("단위"), 층=layer, 주인=owner)
|
||||
out.setdefault(mm.plain(name), []).append(got)
|
||||
return out
|
||||
|
||||
|
||||
# ── 줄 판정 ─────────────────────────────────────────────────────────────
|
||||
def first(line: dict[str, Any]) -> tuple[str | None, str, int | None]:
|
||||
"""1차 ①~③ — (갈래, 근거, 번) · 못 가름 = (None, "", None)."""
|
||||
ref = line.get("참조") or {}
|
||||
if ref and line.get("이음") != "이름":
|
||||
kind = "단가산출" if ref.get("종류") == "단산" else "일위대가"
|
||||
return kind, f"비고 {line.get('비고') or line.get('코드') or ''}".strip(), ref.get("번")
|
||||
text = flat(f"{line.get('이름')}{line.get('규격')}")
|
||||
if any(w in text for w in APART) or str(line.get("비고") or "").startswith("별산"):
|
||||
return "재료", "별도계상", None
|
||||
unit = flat(line.get("단위"))
|
||||
if unit == "%" or _RATE_BASE.search(str(line.get("규격") or "")):
|
||||
return "율", "단위 %", None
|
||||
if unit in MACHINE_UNITS:
|
||||
return "기계", "단위 hr", None
|
||||
if unit == "인":
|
||||
return "인력", "단위 인", None
|
||||
return None, "", None
|
||||
|
||||
|
||||
def _shape(line: dict[str, Any]) -> tuple[str, ...]:
|
||||
price = line.get("단가") or {}
|
||||
return tuple(k for k in COSTS if price.get(k))
|
||||
|
||||
|
||||
def second(line: dict[str, Any], index: dict[str, list[dict[str, Any]]]) -> list[dict[str, Any]]:
|
||||
"""④ 사전 — 코드 › 품명+규격 › 품명 · 갈래 여럿이면 금액 칸 꼴로 가름 → 사전 줄 목록."""
|
||||
code = str(line.get("코드") or "")
|
||||
hits = index.get("코드|" + code) if code else None
|
||||
hits = hits or index.get(f"{flat(line.get('이름'))}|{flat(line.get('규격'))}")
|
||||
hits = hits or index.get(flat(line.get("이름"))) or []
|
||||
if len({h["갈래"] for h in hits}) > 1 and _shape(line) == ("재료비",):
|
||||
hits = [h for h in hits if h["갈래"] in ("재료", "유가")] or hits
|
||||
if not hits and code[:1] in CODE_KIND:
|
||||
hits = [{"갈래": CODE_KIND[code[0]], "이름": line.get("이름"), "규격": line.get("규격")}]
|
||||
return hits
|
||||
|
||||
|
||||
def _rate_pick(line: dict[str, Any]) -> dict[str, Any]:
|
||||
"""율 줄 — 기준 = 규격 「…비의」 글(EST 노 · 품 · 인 → 노무비 · 재 · 자 → 재료비 · 경 → 경비 ·
|
||||
합계 → 비목 셋 중 줄 금액 칸) · 비목 = 줄 단가가 든 칸(없으면 기준)."""
|
||||
hit = _RATE_BASE.search(f"{line.get('규격')}{line.get('이름')}")
|
||||
word = hit.group(1) if hit else ""
|
||||
base = _BASE_COST.get(word[:1], "경비" if word.startswith("경") else None)
|
||||
cost = next(iter(_shape(line)), None) or base or "재료비"
|
||||
one = pick("율", None, line.get("이름"), line.get("규격"), "%", 비목=cost, 기준=base or cost)
|
||||
return one
|
||||
|
||||
|
||||
# 별도계상 줄 표지 `품목` — 품목은 프로젝트에 복사할 때 정함(요구 645-2)
|
||||
ITEM_LATER = "프로젝트에서"
|
||||
|
||||
|
||||
def _fixed(line: dict[str, Any], apart: bool = False) -> dict[str, Any]:
|
||||
price = {k: _plain((line.get("단가") or {}).get(k)) or 0 for k in COSTS}
|
||||
more = {"별도계상": True, "품목": ITEM_LATER} if apart else {}
|
||||
return pick(
|
||||
"고정", None, line.get("이름"), line.get("규격"), line.get("단위"), 단가=price, **more
|
||||
)
|
||||
|
||||
|
||||
def _from_ledger(row: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""장부 짝 → 고른 것 — 새 꼴(`갈래`) 그대로 · 옛 꼴 로직 = 기초 · 자재 = 재료 · 유가."""
|
||||
out = []
|
||||
for p in row.get("짝") or []:
|
||||
if p.get("갈래"):
|
||||
got = pick(p["갈래"], p.get("키"), p.get("이름"), "", p.get("단위"))
|
||||
got.update({k: p[k] for k in ("입력값", "몫", "비목", "기준", "별도계상") if k in p})
|
||||
elif p.get("로직"):
|
||||
got = {**pick("기초", p["로직"]), "입력값": p.get("입력값") or {}, "몫": p.get("몫")}
|
||||
else:
|
||||
code = str(p.get("자재") or "")
|
||||
got = pick("유가" if code.startswith("MO") else "재료", code)
|
||||
out.append({**got, "출처": {"장부": row.get("층")}, "배": p.get("배")})
|
||||
return out
|
||||
|
||||
|
||||
def judge(
|
||||
line: dict[str, Any],
|
||||
index: dict[str, list[dict[str, Any]]],
|
||||
docs: dict[str, list[dict[str, Any]]] | None = None,
|
||||
book: list | None = None,
|
||||
doc: str | None = None,
|
||||
folder: str | Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""줄 하나 → 줄 판 `{차례, 행, 갈래 [..], 근거, 번?, 후보, 판정, 수량, 장부?}`."""
|
||||
kind, why, no = first(line)
|
||||
hits = [] if kind else second(line, index)
|
||||
kinds = [kind] if kind else list(dict.fromkeys(h["갈래"] for h in hits)) or ["고정"]
|
||||
if not kind and hits:
|
||||
h = hits[0]
|
||||
why = f"사전 {h.get('시트')} {h.get('행') or h.get('코드') or ''}".strip()
|
||||
out = {"차례": line.get("차례"), "행": line.get("행"), "갈래": kinds, "근거": why}
|
||||
if no is not None:
|
||||
out["번"] = no
|
||||
qty = {"엑셀": line.get("수량"), "배": 1, "갈래": "같음"}
|
||||
picks: list[dict[str, Any]] = []
|
||||
if why == "별도계상": # 모아서 한 번에 셈 — 품목 후보 · 장부 짝 안 찾음(고정 줄 하나 · 786-1)
|
||||
return {**out, "후보": [_fixed(line, True)], "판정": "제안 있음", "수량": qty}
|
||||
found = None
|
||||
if folder is not None:
|
||||
found = ledger.recall(line, folder, book, doc=doc)
|
||||
if found and found.get("갈래") == "짝":
|
||||
picks = _from_ledger(found)
|
||||
for k in kinds if not picks else ():
|
||||
if k == "일위대가" and no is not None:
|
||||
picks.append(pick("일위대가", f"제{no}호표", line.get("이름"), line.get("규격"),
|
||||
line.get("단위"), 출처={"엑셀": f"제{no}호표"})) # fmt: skip
|
||||
elif k in ("단가산출", "일위대가"):
|
||||
pass
|
||||
elif k == "율":
|
||||
picks.append(_rate_pick(line))
|
||||
elif any(h["갈래"] == k and "후보" in h for h in hits): # 사전이 붙인 M01 후보
|
||||
picks += [p for h in hits if h["갈래"] == k for p in h["후보"]]
|
||||
else:
|
||||
picks += m01_picks(k, line)
|
||||
if not found:
|
||||
picks += [p for p in (docs or {}).get(mm.plain(line.get("이름")), []) if p["갈래"] in kinds]
|
||||
picks = list({(p["갈래"], p.get("키"), p.get("층")): p for p in picks}.values())
|
||||
if found and found.get("갈래") in ("짝", "우리에 없음", "안 씀"):
|
||||
out["장부"] = {k: found.get(k) for k in ("갈래", "층", "제안", "규격다름")}
|
||||
if "단가산출" in kinds: # 단산 줄 = 늘 2차 블록 풀이(장부 짝 = 1차 후보 힌트만 · 785-7)
|
||||
verdict = "2차 필요"
|
||||
elif (
|
||||
found and found.get("갈래") in ("짝", "안 씀", "우리에 없음") and not found.get("규격다름")
|
||||
):
|
||||
verdict = "제안 있음"
|
||||
elif kinds[0] in ("일위대가", "율") or len(picks) == 1:
|
||||
verdict = "제안 있음"
|
||||
elif picks:
|
||||
verdict = "후보 여럿"
|
||||
else:
|
||||
verdict, picks = "후보 없음", [_fixed(line)]
|
||||
return {**out, "후보": picks, "판정": verdict, "수량": qty}
|
||||
|
||||
|
||||
def rows(
|
||||
book: dict[str, Any], folder: str | Path | None = None, doc: str | None = None
|
||||
) -> dict[int, list[dict[str, Any]]]:
|
||||
"""Book(+ `사전`) → `{호표 번: [줄 판]}` — 장부 · 층 문서는 한 번 읽어 넘김."""
|
||||
index = _index(book.get("사전"))
|
||||
got = ledger.books(folder) if folder is not None else None
|
||||
docs = layer_docs(folder)
|
||||
return {
|
||||
h["번"]: [judge(x, index, docs, got, doc, folder) for x in h.get("줄") or []]
|
||||
for h in book.get("호표") or []
|
||||
}
|
||||
|
||||
|
||||
# ── 환산(`M02_Hopyo_Match.factor` 옮김) ─────────────────────────────────────
|
||||
|
||||
@@ -1,279 +0,0 @@
|
||||
"""M02 일위대가 실무자료 블록 풀이 검산 · 판정(PLAN 785-2 · 요구 642 · 설계 642-A 2-5 ④-4 · ④-5) —
|
||||
후보 기초마다 옵션값 읽기(`M02_Hopyo_Bind`) → 엔진 한 번(품 · 금액 참고) → 엑셀 단위 줄과 짝 · 판정.
|
||||
|
||||
짝(같은 묶음끼리): L 노무 = 직종 이름 · X 기계 = 시간 단위 로직 줄 · M 자재 = 뜻 낱말(남은 하나끼리) ·
|
||||
R 율 = 덧줄 율(같은 율 › 남은 하나끼리) · E 값 · S 하위 = 엑셀만.
|
||||
품 같음(`same`) = 엑셀 자릿수 반올림 폭 안(0.67 ↔ 0.6667) 또는 0.5 % · 금액은 참고(노임 · 단가 해가 다름).
|
||||
줄 판정 = 품 같음 · 품 다름 · 입력 필요 · 엑셀만 · 우리만(우리 줄 품 0 = 꺼진 줄 · 안 보임).
|
||||
절 판정 = 기초 풂(품 다름 없는 후보 하나) · 후보 여럿 · 기초 없음 /
|
||||
블록 판정 = 기초로 풂 · 기초+덧(엑셀만 줄 → `Units` 잇기) · 줄 조합(기초 없음뿐) · 고를 것 n.
|
||||
서버 단독 · 읽기만 · 자동 고름 없음(제안 · 사용자 확인).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from statistics import median
|
||||
from typing import Any, Callable
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Bind as hb
|
||||
from M02_MasterTemplete import M02_Hopyo_Match as hm
|
||||
|
||||
mf, mm, ma = hb.mf, hb.mm, hb.ma
|
||||
SAME, DIFF, NEED, EXCEL, OURS = "품 같음", "품 다름", "입력 필요", "엑셀만", "우리만"
|
||||
SOLVED, SOME, NONE = "기초 풂", "후보 여럿", "기초 없음"
|
||||
BY_BASE, BASE_PLUS, BY_LINES, PICK = "기초로 풂", "기초+덧", "줄 조합", "고를 것"
|
||||
SHARE = Decimal("0.005")
|
||||
HOURS = {"시간", "hr", "h", "시"}
|
||||
|
||||
|
||||
def _d(value: Any) -> Decimal | None:
|
||||
try:
|
||||
return None if value is None else Decimal(str(round(float(value), 10)))
|
||||
except (ArithmeticError, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def same(excel: Any, ours: Any) -> bool:
|
||||
"""품 같음 — 엑셀 적힌 자릿수의 반올림 폭 안 또는 0.5 % 안."""
|
||||
e, o = _d(excel), _d(ours)
|
||||
if e is None or o is None:
|
||||
return False
|
||||
gap = abs(e - o)
|
||||
places = -e.normalize().as_tuple().exponent
|
||||
width = Decimal(5) * Decimal(10) ** -(places + 1) if 0 <= places <= 4 else Decimal(0)
|
||||
return gap <= width or gap <= abs(e) * SHARE
|
||||
|
||||
|
||||
def _kind(line: dict) -> str | None:
|
||||
kind = line.get("종류")
|
||||
if kind in ("로직", "기계"):
|
||||
return "X" if str(line.get("단위") or "").strip() in HOURS else "S"
|
||||
return {"인력": "L", "재료": "M", "율": "R"}.get(kind)
|
||||
|
||||
|
||||
def _strict(item: dict, line: dict) -> bool:
|
||||
kind = item.get("묶음")
|
||||
if kind == "L":
|
||||
return hm.tidy(line.get("이름")) == hm.tidy(item.get("직종") or item.get("이름"))
|
||||
if kind == "M":
|
||||
return hm.near(hm.words(item.get("이름")), hm.words(line.get("이름")))
|
||||
if kind == "R":
|
||||
return same(item.get("품"), line.get("품"))
|
||||
return kind == "X"
|
||||
|
||||
|
||||
def _excel(item: dict, share: Decimal) -> dict:
|
||||
q = _d(item.get("품"))
|
||||
return {
|
||||
"묶음": item.get("묶음"),
|
||||
"이름": item.get("이름"),
|
||||
"품": hb.plain(q / share if q is not None and item.get("묶음") != "R" else q),
|
||||
"행": item.get("행"),
|
||||
**({"별도": True} if item.get("어떻게") == "별도" else {}),
|
||||
}
|
||||
|
||||
|
||||
def _row(item: dict | None, line: dict | None, share: Decimal) -> dict:
|
||||
ex = _excel(item, share) if item else None
|
||||
us = {k: hb.plain(line.get(k)) for k in ("이름", "종류", "단위", "품")} if line else None
|
||||
if line and line.get("까닭"):
|
||||
us["까닭"] = line["까닭"]
|
||||
if ex is None:
|
||||
verdict = NEED if line.get("까닭") else OURS
|
||||
elif us is None:
|
||||
verdict = EXCEL
|
||||
elif us["품"] is None:
|
||||
verdict = NEED if line.get("까닭") else DIFF
|
||||
else:
|
||||
verdict = SAME if same(ex["품"], us["품"]) else DIFF
|
||||
out = {"엑셀": ex, "우리": us, "판정": verdict}
|
||||
names = f"{(ex or {}).get('이름') or ''}|{(us or {}).get('이름') or ''}"
|
||||
if item and line and item.get("묶음") == "X" and "타이어" in names and "무한궤도" in names:
|
||||
out["표지"] = "기계 갈래 엇갈림"
|
||||
return out
|
||||
|
||||
|
||||
def pair(lines: list[dict], items: list[dict], share: Any = 1) -> list[dict]:
|
||||
"""엑셀 단위 줄 ↔ 우리 줄 짝 → 줄 판정(엑셀 차례 · 남은 우리 줄 뒤)."""
|
||||
share = _d(share) or Decimal(1)
|
||||
used: set = set()
|
||||
got: dict = {}
|
||||
late = []
|
||||
for i, item in enumerate(items):
|
||||
pool = [j for j, ln in enumerate(lines) if j not in used and _kind(ln) == item.get("묶음")]
|
||||
hits = [j for j in pool if _strict(item, lines[j])]
|
||||
exact = [j for j in hits if lines[j].get("이름") == (item.get("직종") or item.get("이름"))]
|
||||
if hits:
|
||||
got[i] = (exact or hits)[0]
|
||||
used.add(got[i])
|
||||
else:
|
||||
late.append(i)
|
||||
for i in late: # 남은 하나끼리(결속선 ↔ 어닐링철선)
|
||||
kind = items[i].get("묶음")
|
||||
pool = [j for j, ln in enumerate(lines) if j not in used and _kind(ln) == kind]
|
||||
rest = [k for k in late if items[k].get("묶음") == kind and k not in got]
|
||||
if len(pool) == 1 and len(rest) == 1 and kind in ("M", "R"):
|
||||
got[i] = pool[0]
|
||||
used.add(pool[0])
|
||||
rows = [_row(item, lines[got[i]] if i in got else None, share) for i, item in enumerate(items)]
|
||||
for j, line in enumerate(lines):
|
||||
if (
|
||||
j not in used
|
||||
and line.get("품") != 0
|
||||
and (line.get("품") is not None or line.get("까닭"))
|
||||
):
|
||||
rows.append(_row(None, line, share))
|
||||
return rows
|
||||
|
||||
|
||||
def _usable(items: list[dict]) -> list[dict]:
|
||||
return [x for x in items if not x.get("밖")]
|
||||
|
||||
|
||||
def fitter(master: mf.Master, key: str, items: list[dict], share: Any = 1) -> Callable:
|
||||
"""입력값 → `{맞음, 배}` — 맞음 = 짝 하나 이상 · 모두 품 같음 / 배 = 노무 · 기계 줄이 모두 같은 배
|
||||
(자재 · 율 줄은 같음) — 표 거꾸로 · 감률 읽기(B5)가 씀."""
|
||||
items = _usable(items)
|
||||
|
||||
def fit(given: dict) -> dict:
|
||||
rows = pair(hb.quantities(master, key, given), items, share)
|
||||
both = [r for r in rows if r["엑셀"] and r["우리"] and r["엑셀"]["품"] and r["우리"]["품"]]
|
||||
ok = bool(both) and all(r["판정"] == SAME for r in both)
|
||||
labor = [r for r in both if r["엑셀"]["묶음"] in ("L", "X")]
|
||||
k = None
|
||||
if labor:
|
||||
k = median(_d(r["엑셀"]["품"]) / _d(r["우리"]["품"]) for r in labor)
|
||||
flat = all(same(r["엑셀"]["품"], _d(r["우리"]["품"]) * k) for r in labor)
|
||||
rest = all(r["판정"] == SAME for r in both if r not in labor)
|
||||
k = float(k) if flat and rest else None
|
||||
return {"맞음": ok, "배": k}
|
||||
|
||||
return fit
|
||||
|
||||
|
||||
def _money(master: mf.Master, key: str, bound: dict, given: dict) -> dict:
|
||||
"""금액 참고 — 입력 필요가 없을 때 엔진 한 번(안 씀 · 비움 = 기본 제안 · 저장 안 함)."""
|
||||
if bound["입력필요"]:
|
||||
return {"계": None, "까닭": f"입력 필요 {len(bound['입력필요'])}"}
|
||||
row = master.logic(key)
|
||||
base = ma.defaults(master, row)
|
||||
fill = {n: base.get(n) for n in [*bound["안씀"], *bound["비움"]]}
|
||||
try:
|
||||
got = mf.run(master, key, mf.fill_empty(row, {**fill, **given}))
|
||||
except mf.FormulaError as e:
|
||||
return {"계": None, "까닭": str(e)}
|
||||
if "결과" in got:
|
||||
return {"결과": hb.plain(got["결과"])}
|
||||
return {k: hb.plain(got.get(k)) for k in ("노무비", "재료비", "경비", "계")}
|
||||
|
||||
|
||||
def candidate(master: mf.Master, key: str, src: dict, items: list[dict], share: Any = 1) -> dict:
|
||||
"""후보 기초 하나 — 바인딩 · 줄 대조 · 판정 · 금액 참고."""
|
||||
row = master.logic(key)
|
||||
bound = hb.bind(master, key, src, fitter(master, key, items, share))
|
||||
given = {**bound["입력값"], **{n: v[0]["값"] for n, v in bound["엇갈림"].items()}}
|
||||
idle = {n: bound["기본"][n] for n in bound["안씀"] if bound["기본"].get(n) is not None}
|
||||
rows = pair(hb.quantities(master, key, {**idle, **given}), _usable(items), share)
|
||||
count = {v: sum(r["판정"] == v for r in rows) for v in (SAME, DIFF, NEED, EXCEL, OURS)}
|
||||
if count[DIFF]:
|
||||
verdict = DIFF
|
||||
elif bound["입력필요"] or count[NEED] or bound["엇갈림"]:
|
||||
verdict = NEED
|
||||
else:
|
||||
verdict = SAME if count[SAME] else EXCEL
|
||||
return {
|
||||
"키": key,
|
||||
"이름": row.get("이름"),
|
||||
"바인딩": bound,
|
||||
"줄": rows,
|
||||
"셈": count,
|
||||
"판정": verdict,
|
||||
"금액": _money(master, key, bound, given),
|
||||
}
|
||||
|
||||
|
||||
def _section(master: mf.Master, block: dict, part: dict, src: dict) -> dict:
|
||||
items = part.get("항목")
|
||||
items = _usable(block.get("항목") or []) if items is None else items
|
||||
mine = {**src, **{k: part[k] for k in ("선택값", "상수", "식", "대입식") if k in part}}
|
||||
share = part.get("몫")
|
||||
share = (share.get("값") if isinstance(share, dict) else share) or 1
|
||||
cands = [candidate(master, k, mine, items, share) for k in part.get("후보") or []]
|
||||
good = [c for c in cands if c["셈"][DIFF] == 0 and (c["셈"][SAME] or len(cands) == 1)]
|
||||
verdict = NONE if not cands else SOLVED if len(good) == 1 else SOME
|
||||
pick = good[0]["키"] if verdict == SOLVED else None
|
||||
return {
|
||||
"제목": part.get("제목", ""),
|
||||
"판정": verdict,
|
||||
"고름": pick,
|
||||
"후보": cands,
|
||||
"항목": items,
|
||||
}
|
||||
|
||||
|
||||
def block(
|
||||
master: mf.Master, blk: dict, parts: list[dict], src: dict | None = None, units: Any = None
|
||||
) -> dict:
|
||||
"""블록 풀이 — 절마다 후보 검산 · 절 판정 · 덧 줄(엑셀만 → `Units`) · 블록 판정."""
|
||||
src = {**(src or {}), "블록": blk}
|
||||
secs = [_section(master, blk, p, src) for p in parts]
|
||||
extra, n = [], 0
|
||||
for sec in secs:
|
||||
chosen = next((c for c in sec["후보"] if c["키"] == sec["고름"]), None)
|
||||
if sec["판정"] == SOME:
|
||||
n += 1
|
||||
if chosen:
|
||||
b = chosen["바인딩"]
|
||||
n += len(b["입력필요"]) + len(b["엇갈림"]) + chosen["셈"][DIFF]
|
||||
alone = [r for r in chosen["줄"] if r["판정"] == EXCEL]
|
||||
lone = [x for x in sec["항목"] if any(r["엑셀"]["행"] == x.get("행") for r in alone)]
|
||||
else:
|
||||
lone = sec["항목"] if sec["판정"] == NONE else []
|
||||
for item in lone:
|
||||
if units is None:
|
||||
from M02_MasterTemplete import M02_Hopyo_Units as hu
|
||||
|
||||
units = hu.Units()
|
||||
link = units.line(item)
|
||||
extra.append(
|
||||
{"절": sec["제목"], "행": item.get("행"), "이름": item.get("이름"), **link}
|
||||
)
|
||||
n += link.get("상태") != "이어짐"
|
||||
for sec in secs:
|
||||
sec.pop("항목")
|
||||
if secs and all(s["판정"] == NONE for s in secs):
|
||||
verdict = BY_LINES
|
||||
elif n:
|
||||
verdict = PICK
|
||||
else:
|
||||
verdict = BASE_PLUS if extra else BY_BASE
|
||||
return {"번": blk.get("번"), "판정": verdict, "고를수": n, "절": secs, "덧": extra}
|
||||
|
||||
|
||||
def _gather(nodes: list[dict], key: str) -> list:
|
||||
return [x for one in nodes for x in [*one.get(key, []), *_gather(one.get("아래", []), key)]]
|
||||
|
||||
|
||||
def parts(blk: dict, cands: list[Any]) -> list[dict]:
|
||||
"""절 나무(785-1 `Formula.works`) + 후보(키 · `Clause` 후보 {키, 절}) → 풂 절 — 절 표 있는
|
||||
후보가 있으면 일 절마다 그 절 후보 + 절 표 없는 후보 · 없으면 블록 통째 한 절 ·
|
||||
단위 줄 · 상수 · 식 · 선택값(머리 것 더함) · 몫."""
|
||||
from M02_MasterTemplete import M02_Hopyo_Formula as fo
|
||||
|
||||
nodes = blk.get("절") or fo.tree(blk.get("글") or [], blk.get("글행"), blk.get("항목") or [])
|
||||
top = fo.head(blk.get("글") or [], blk.get("글행"))
|
||||
cands = [c if isinstance(c, dict) else {"키": c} for c in cands]
|
||||
items = blk.get("항목") or []
|
||||
work = fo.works(nodes) if any(c.get("절") for c in cands) else []
|
||||
if not work: # 블록 통째 한 일(절 표 있는 후보가 없으면 한 기초가 블록 전부)
|
||||
whole = {"제목": blk.get("이름", ""), "후보": [c["키"] for c in cands], "항목": None}
|
||||
return [whole | {k: top[k] + _gather(nodes, k) for k in ("상수", "식", "선택값")}]
|
||||
out = []
|
||||
for one in work:
|
||||
mine = [c["키"] for c in cands if c.get("절") in (None, one["번"])]
|
||||
at = [i for i in _gather([one], "항목") if not items[i].get("밖")]
|
||||
part = {"제목": one["제목"], "번": one["번"], "후보": mine, "항목": [items[i] for i in at]}
|
||||
part |= {k: top[k] + _gather([one], k) for k in ("상수", "식", "선택값")}
|
||||
out.append(part | {"몫": one.get("몫")})
|
||||
return out
|
||||
@@ -0,0 +1,98 @@
|
||||
"""M02 엑셀 불러오기 [수정] 보기(PLAN 809-1 · 요구 702 · 703) — 호표 하나 → 일위대가 문서 미리보기 + 줄 표지 · 읽기만.
|
||||
|
||||
경쟁사 로직 인정 · 요소만 연결 — 문서 줄 = 엑셀 줄 하나씩 고정 줄(엑셀 숫자 그대로 · `Fixed.line_of` ·
|
||||
[내 일위대가로] 와 같은 이음 규칙 · 금액 = 엑셀). 줄 표지:
|
||||
· 로직 줄 = 단산 N 참조 → 「단가산출」(번 · 가져온 식 SK 로 엶) · 일위 N 참조 → 「일위대가」(번 · 하위 호표 보기)
|
||||
· 요소 줄 = 그 밖(인력 · 재료 · 기계 · 유가 · 율 · 고정 수) — 이음 있음 = 「연결됨」(이음) ·
|
||||
후보 있음 = 「후보」(키 · 이름) · 없음 = 「못 이음」(엑셀 값 그대로)
|
||||
· 엑셀 = 줄 금액(노무비 · 재료비 · 경비 · 계)
|
||||
기초 판정 · 2차 풀이 없음 · 저장 없음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Build as hbuild
|
||||
from M02_MasterTemplete import M02_Hopyo_Fixed as hfix
|
||||
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
|
||||
LOGIC, ELEMENT = "로직", "요소"
|
||||
SK, SC = "단가산출", "일위대가"
|
||||
LINKED, NEAR, NONE = "연결됨", "후보", "못 이음"
|
||||
MONEY = ("노무비", "재료비", "경비", "계")
|
||||
|
||||
|
||||
def _name(linker: hfix.Linker, code: str) -> str:
|
||||
row = (linker.whole.numbers.get(code[:2]) or {}).get(code) or {}
|
||||
return str(row.get("이름") or "")
|
||||
|
||||
|
||||
def mark(line: dict[str, Any], one: dict[str, Any], linker: hfix.Linker) -> dict[str, Any]:
|
||||
"""엑셀 줄 하나 표지 — 로직(단가산출 · 일위대가) / 요소(연결됨 · 후보 · 못 이음)."""
|
||||
ref = line.get("참조") or {}
|
||||
money = line.get("금액") or {}
|
||||
out: dict[str, Any] = {
|
||||
"차례": int(line.get("차례", 0)),
|
||||
"엑셀": {k: money.get(k) for k in MONEY},
|
||||
}
|
||||
if ref.get("종류") == "단산" and ref.get("번"):
|
||||
return {**out, "갈래": LOGIC, "표지": SK, "번": int(ref["번"])}
|
||||
if ref.get("종류") == "일위" or hfix.kind_of(line) == "호표":
|
||||
return {**out, "갈래": LOGIC, "표지": SC, "번": int(ref.get("번") or 0) or None}
|
||||
fx = one.get("고정") or {}
|
||||
if hfix.linked(one):
|
||||
link = {k: v for k, v in (fx.get("이음") or {}).items() if v}
|
||||
rate = fx.get("비율")
|
||||
return {**out, "갈래": ELEMENT, "표지": LINKED, "이음": link or {"비율": rate["기준"]}}
|
||||
near = [{"키": c, "이름": _name(linker, c)} for c in linker.candidates(line)]
|
||||
if near:
|
||||
return {**out, "갈래": ELEMENT, "표지": NEAR, "후보": near}
|
||||
return {**out, "갈래": ELEMENT, "표지": NONE}
|
||||
|
||||
|
||||
def view(source: str | Path, key: str, no: int) -> dict[str, Any]:
|
||||
"""파일 층 HP 의 호표 하나 보기(`of`)."""
|
||||
return of(hopyo_store.read(Path(source), key), no)
|
||||
|
||||
|
||||
def of(book: dict[str, Any], no: int, linker: hfix.Linker | None = None) -> dict[str, Any]:
|
||||
"""호표 하나 → `{번, 이름, 규격, 단위, 금액, 문서 {이름, 단위, 담은로직, 자재}, 줄 [표지]}` — 문서 줄 차례 = 엑셀 줄 차례."""
|
||||
hopyo = next((h for h in book.get("호표") or [] if int(h["번"]) == int(no)), None)
|
||||
if hopyo is None:
|
||||
raise store.StoreError(404, f"없는 호표 「제{no}호표」")
|
||||
rows = hopyo.get("줄") or []
|
||||
linker, taken = linker or hfix.Linker(True), set()
|
||||
where = {"파일": book.get("파일") or "", "호표": int(no)}
|
||||
lines, marks = [], []
|
||||
for line in rows:
|
||||
one = hfix.line_of(line, [x for x in rows if x is not line], where, taken, linker)
|
||||
lines.append(one)
|
||||
marks.append(mark(line, one, linker))
|
||||
name = " ".join(x for x in (hopyo.get("이름"), hopyo.get("규격")) if x)
|
||||
doc = {"이름": name, "단위": hopyo.get("단위") or None, "담은로직": lines, "자재": []}
|
||||
head = {k: hopyo.get(k) for k in ("번", "이름", "규격", "단위", "금액")}
|
||||
return {**head, "문서": doc, "줄": marks}
|
||||
|
||||
|
||||
def listing(folder: str | Path, key: str, to: str | Path) -> dict[str, Any]:
|
||||
"""엑셀 보기 머리 · 목록 — 파일 줄 + 검산 · 호표마다 `맞춤` [연결됨 + 로직 줄, 줄 수] ·
|
||||
`줄상태 [[차례, 표지, 이어짐]]` · `만든` SC(만들 층 `to` · 이 파일 층 것만)."""
|
||||
book = hopyo_store.read(Path(folder), key)
|
||||
made = hbuild.confirmed(to, refs.where(Path(folder)))
|
||||
linker, units = hfix.Linker(True), []
|
||||
for h in book.get("호표") or []:
|
||||
no = int(h["번"])
|
||||
marks = of(book, no, linker)["줄"]
|
||||
state = [
|
||||
[i, m["표지"], m["갈래"] == LOGIC or m["표지"] == LINKED] for i, m in enumerate(marks)
|
||||
]
|
||||
head = {k: h.get(k) for k in ("번", "이름", "규격", "단위", "금액", "행") if k in h}
|
||||
units.append({**head, "맞춤": [sum(x[2] for x in state), len(state)], "줄상태": state,
|
||||
"만든": made.get(hbuild.mark(key, no))}) # fmt: skip
|
||||
ready = [sum(h["맞춤"][0] for h in units), sum(h["맞춤"][1] for h in units)]
|
||||
head = {**hopyo_store.brief(book), "검산": book.get("검산"), "셈": {"끝": True}}
|
||||
return {**head, "호표": units, "맞춤": ready}
|
||||
@@ -149,30 +149,6 @@
|
||||
min-height: 8rem;
|
||||
}
|
||||
|
||||
.m02-hopyo-sheet__lines {
|
||||
flex: 0 1 45%;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.m02-hopyo-sheet__lines[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 좁은 폭(785-7 · 390) — 줄 판이 열리면 위 칸(시트 + 줄 판)을 넓히고 줄 판 높이 바닥을 둠 */
|
||||
@media (max-width: 900px) {
|
||||
.m02-hopyo-sheet:has(.m02-hopyo-sheet__lines:not([hidden])) {
|
||||
grid-template-rows: minmax(0, 5fr) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.m02-hopyo-sheet__lines {
|
||||
flex: 1 1 60%;
|
||||
min-height: 16rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* 세 보기 막대 · 범례(779-4) — 시트 위 한 줄씩 · 넘치면 감김 */
|
||||
.m02-hopyo-sheet__nav {
|
||||
display: flex;
|
||||
@@ -286,54 +262,7 @@
|
||||
margin-top: var(--spacing-12);
|
||||
}
|
||||
|
||||
/* [수정] 모달 「확인 — 제N호표」 줄 표(786-2 · 옛 359-3 꼴) — 일위대가 상세 꼴(m02-labrow) · 확인 · 행 · 수량 · 단위 =
|
||||
줄바꿈 없음 · 단추 = 세로로 쌓아 좁게 · 이름 · 별칭 · 품 비교가 남는 폭 */
|
||||
.m02-hopyo__table.m02-hopyo__lines :is(th, td) {
|
||||
padding: var(--spacing-8);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.m02-hopyo__lines :is(td:first-child, td:nth-child(2), .m02-labrow__qty, .m02-labrow__unit) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m02-hopyo__lines th > .ui-badge {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.m02-hopyo__lines .m02-labrow__name {
|
||||
max-width: none;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.m02-hopyo__lines .m02-labrow__name > small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.m02-hopyo__lines td.m02-hopyo__compare {
|
||||
min-width: 7em;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.m02-hopyo__lines td.m02-hopyo__acts {
|
||||
width: 1%;
|
||||
}
|
||||
|
||||
.m02-hopyo__lines .m02-hopyo__acts > * {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m02-hopyo__lines .m02-hopyo__acts > * + * {
|
||||
margin: var(--spacing-4) 0 0;
|
||||
}
|
||||
|
||||
.m02-hopyo__solved > td {
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.m02-hopyo__subhead {
|
||||
margin: var(--spacing-8) 0 0;
|
||||
font-size: var(--text-body-sm);
|
||||
/* 줄 바꿈 도구 막대(가져온 식 · 소요량 이음) */
|
||||
.m02-judge__wrap {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* M02_MasterTemplete_Hopyo_Api.ts
|
||||
* 일위대가 실무자료(PLAN 324 · 338) 서버 길 — `/api/m02/layers/{층}/hopyo…` · 층 = 화면 층(`Api_Fetch` 지금 층) ·
|
||||
* 올리기 = FormData(숨은 file 입력) · 그 밖 = 공용 `call`(서버 까닭 글 그대로) ·
|
||||
* 목록 · 올림 · 엑셀 보기 머리(1차 판정 줄상태) · 격자 · 지우기 · 별칭 장부 · 붙여넣기 · 다시 잇기 —
|
||||
* 판정 · 검산 · 옮기기 길 = `Hopyo_JudgeApi` · 옛 맞춤 꼴(줄 맞춤 · 확인 만들기 · 실무 호표)은 785-7 에서 지움.
|
||||
* 목록 · 올림 · 엑셀 보기 머리(809-1 줄 표지 줄상태) · 격자 · 지우기 · 별칭 장부 · 붙여넣기 · 다시 잇기 —
|
||||
* [수정] 보기 = `Hopyo_View`(809-1) · 판정 · 검산 · 옮기기 길(785)은 809-1 에서 지움(요구 703).
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
|
||||
@@ -1,442 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_Block.ts
|
||||
* 엑셀 불러오기 단산 블록 판(PLAN 785-6 · 요구 641 · 642 · 설계 642-A 2-5 · 2-6) — 줄 판 단산 줄 펼침 · 단가산출 탭 줄 판 한 몸.
|
||||
* · 블록 머리 = 블록 판정(기초로 풂 · 기초+덧 · 줄 조합 · 고를 것 n) · 근거 표지 · 금액 참고(우리 · 엑셀)
|
||||
* · 절마다 = 맡은 절 · 기초 후보 칩(여럿 고름) · 고른 후보마다 입력값 칸(출처 꼬리표 B1~B6 · 사용자 · 기본 ·
|
||||
* 「입력 필요」 붉은 칸 · 엇갈림 고르기) · 안 씀 · 줄 대조(품 같음 · 품 다름 · 엑셀만 · 우리만 · 입력 필요) · 금액
|
||||
* · 덧 줄(엑셀만) = 후보 칩 → 재료 · 인력 · 기계 고른 것
|
||||
* · 머리 [가져온 식으로 열기](793-3b · 새) = `Hopyo_Imported` 모달(이 블록 → 가져온 식 SK · 시트 · 단가 · 기초로 바꾸기)
|
||||
* · [검산] = 서버 셈(저장 없음 · 옛 요청 끊기) · 고름 · 입력은 캐시(`createJudgeState` = sessionStorage) — 옮김은 [옮기기] 뒤 서버
|
||||
* 판정 · 금액은 서버 · 여기는 보이기 · 고르기만.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createBadge, type BadgeTone } from "@ui/ui_template_badge";
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
createSelectField,
|
||||
el,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import { createPanelHead } from "@ui/ui_template_panel_head";
|
||||
import { createTable, type Cell } from "@ui/ui_template_table";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { createToggle } from "@ui/ui_template_toggle";
|
||||
import { formatNumber, formatPrice } from "../common_util/common_util_money";
|
||||
import type { HopyoScope, Money } from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import {
|
||||
checkJudge,
|
||||
readJudgeBlock,
|
||||
type BaseCand,
|
||||
type CandMoney,
|
||||
type CheckOne,
|
||||
type CheckResult,
|
||||
type ExtraLine,
|
||||
type PairLine,
|
||||
type PickKind,
|
||||
type Section,
|
||||
type Solved,
|
||||
} from "./M02_MasterTemplete_Hopyo_JudgeApi";
|
||||
import {
|
||||
bare,
|
||||
basePick,
|
||||
blockPicks,
|
||||
confirmable,
|
||||
draftKey,
|
||||
emptyDraft,
|
||||
missing,
|
||||
parseDraft,
|
||||
putBlock,
|
||||
samePick,
|
||||
setInput,
|
||||
togglePick,
|
||||
type DraftPick,
|
||||
type JudgeDraft,
|
||||
} from "./logic/M02_MasterTemplete_Hopyo_Picks";
|
||||
import "./M02_MasterTemplete_Hopyo_Judge.css";
|
||||
|
||||
/** 고름 캐시 손잡이 — 파일 하나 · sessionStorage(포트별) · 바뀌면 알림 */
|
||||
export function createJudgeState(file: HopyoScope, key: string) {
|
||||
const k = draftKey(file.layer, file.owner, key);
|
||||
let d: JudgeDraft = parseDraft(sessionStorage.getItem(k));
|
||||
const subs = new Set<() => void>();
|
||||
return {
|
||||
file,
|
||||
key,
|
||||
get: (): JudgeDraft => d,
|
||||
set(next: JudgeDraft): void {
|
||||
d = next;
|
||||
sessionStorage.setItem(k, JSON.stringify(next));
|
||||
subs.forEach((f) => f());
|
||||
},
|
||||
clear(): void {
|
||||
sessionStorage.removeItem(k);
|
||||
d = emptyDraft();
|
||||
subs.forEach((f) => f());
|
||||
},
|
||||
on(f: () => void): () => void {
|
||||
subs.add(f);
|
||||
return () => subs.delete(f);
|
||||
},
|
||||
};
|
||||
}
|
||||
export type JudgeState = ReturnType<typeof createJudgeState>;
|
||||
|
||||
const TONE: Record<string, BadgeTone> = {
|
||||
"기초로 풂": "success",
|
||||
"기초+덧": "success",
|
||||
"줄 조합": "info",
|
||||
"고를 것": "warning",
|
||||
"기초 풂": "success",
|
||||
"후보 여럿": "warning",
|
||||
"기초 없음": "danger",
|
||||
"품 같음": "success",
|
||||
"품 다름": "warning",
|
||||
"입력 필요": "danger",
|
||||
엑셀만: "info",
|
||||
우리만: "neutral",
|
||||
확인: "success",
|
||||
};
|
||||
const badge = (text: string, at?: string): HTMLElement => {
|
||||
const b = createBadge(text, TONE[text] ?? "neutral");
|
||||
if (at) b.dataset[at] = text;
|
||||
return b;
|
||||
};
|
||||
const won = (v: number | null | undefined): string =>
|
||||
v === null || v === undefined ? "—" : formatPrice(Math.floor(v));
|
||||
const show = (v: unknown): string =>
|
||||
v === null || v === undefined ? "" : typeof v === "number" ? formatNumber(v) : String(v);
|
||||
const bar = (kids: HTMLElement[]): HTMLElement =>
|
||||
el("div", { className: "ui-toolbar m02-judge__wrap", children: kids });
|
||||
|
||||
/** 금액 한 줄 — 노무 · 재료 · 경비 · 계(없으면 까닭) */
|
||||
export function moneyText(m: CandMoney | Partial<Money> | null | undefined): string {
|
||||
if (!m) return "—";
|
||||
if (m.계 === null && "까닭" in m) return String(m.까닭);
|
||||
const x = m as Partial<Money>;
|
||||
return `${won(x.노무비)} · ${won(x.재료비)} · ${won(x.경비)} = ${won(x.계)}`;
|
||||
}
|
||||
|
||||
export interface SolvedBodyOptions {
|
||||
solved: Solved;
|
||||
picks: DraftPick[];
|
||||
/** 고름 · 입력 바뀜 — 부르는 쪽이 캐시에 넣음 */
|
||||
onPicks: (list: DraftPick[]) => void;
|
||||
/** 서버 검산(없으면 단추 안 보임) */
|
||||
onCheck?: (list: DraftPick[], signal: AbortSignal) => Promise<CheckResult>;
|
||||
/** 엑셀 보기 단가산출 상세 그 행 */
|
||||
onSheet?: (행: number) => void;
|
||||
excel?: Partial<Money> | null;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
/** 입력값 표 — 엔진 입력값 + 입력 필요 + 엇갈림 칸 */
|
||||
function inputTable(
|
||||
opt: SolvedBodyOptions,
|
||||
cand: BaseCand,
|
||||
p: DraftPick,
|
||||
put: (p: DraftPick) => void,
|
||||
): HTMLElement {
|
||||
const b = cand.바인딩;
|
||||
const names = [...new Set([...Object.keys(p.입력값), ...b.입력필요, ...Object.keys(b.엇갈림)])];
|
||||
const need = new Set(missing(p));
|
||||
const rows: Cell[][] = names.map((name) => {
|
||||
const src = p.출처[name]?.길;
|
||||
const tags: HTMLElement[] = [];
|
||||
if (src) {
|
||||
const tag = createBadge(src === "사용자" ? L("M02_JudgeUser") : src === "기본" ? L("M02_JudgeDefault") : src, src.startsWith("B") ? "accent" : "info"); // prettier-ignore
|
||||
tag.dataset.judgeSrc = src;
|
||||
if (p.출처[name]?.글) tag.title = String(p.출처[name]?.글);
|
||||
tags.push(tag);
|
||||
}
|
||||
if (need.has(name)) tags.push(badge(L("M02_JudgeNeed"), "judgeNeed"));
|
||||
const split = b.엇갈림[name];
|
||||
if (split) tags.push(badge(L("M02_JudgeConflict"), "judgeConflict"));
|
||||
let field: HTMLElement;
|
||||
if (split && !opt.readOnly) {
|
||||
const picked = split.findIndex((s) => s.값 === p.입력값[name]);
|
||||
const sel = createSelectField({
|
||||
options: [{ value: "", text: "—" }].concat(split.map((s, i) => ({ value: String(i), text: `${show(s.값)} (${s.길})` }))), // prettier-ignore
|
||||
value: picked < 0 ? "" : String(picked),
|
||||
compact: true,
|
||||
size: 24,
|
||||
ariaLabel: name,
|
||||
onChange: (v) => put(v === "" ? setInput(p, name, "") : setInput(p, name, split[+v].값, split[+v].길)), // prettier-ignore
|
||||
});
|
||||
sel.select.dataset.judgeInput = name;
|
||||
field = sel.root;
|
||||
} else {
|
||||
const f = createInputField({ compact: true, size: 24, value: show(p.입력값[name]) });
|
||||
f.input.dataset.judgeInput = name;
|
||||
f.input.readOnly = !!opt.readOnly;
|
||||
f.input.addEventListener("change", () => put(setInput(p, name, f.input.value)));
|
||||
if (need.has(name)) f.setError(L("M02_JudgeNeed"));
|
||||
field = f.root;
|
||||
}
|
||||
const dflt = b.기본[name];
|
||||
if (need.has(name) && dflt !== undefined && dflt !== null && !opt.readOnly) {
|
||||
const use = createButton({ label: `${L("M02_JudgeDefault")} ${show(dflt)}`, size: 24, variant: "link", onClick: () => put(setInput(p, name, dflt, "기본")) }); // prettier-ignore
|
||||
use.dataset.judgeDefault = name;
|
||||
tags.push(use);
|
||||
}
|
||||
return [name, field, bar(tags)];
|
||||
});
|
||||
return createTable(
|
||||
[{ label: L("M02_JudgeColInput"), width: "9em" }, { label: L("M02_JudgeColValue"), width: "12em" }, { label: L("M02_JudgeColSource") }], // prettier-ignore
|
||||
rows,
|
||||
{ density: "data" },
|
||||
);
|
||||
}
|
||||
|
||||
/** 줄 대조 표 */
|
||||
function pairTable(opt: SolvedBodyOptions, lines: PairLine[]): HTMLElement {
|
||||
const rows: Cell[][] = lines.map((x) => {
|
||||
const at = x.엑셀?.행;
|
||||
const jump =
|
||||
at && opt.onSheet
|
||||
? createButton({ label: L("M02_JudgeSheet"), size: 24, variant: "link", onClick: () => opt.onSheet!(at) }) // prettier-ignore
|
||||
: "";
|
||||
return [
|
||||
x.엑셀?.묶음 ?? x.우리?.종류 ?? "",
|
||||
x.엑셀 ? `${x.엑셀.이름} ${show(x.엑셀.품)}` : "—",
|
||||
x.우리 ? `${x.우리.이름} ${show(x.우리.품)}${x.우리.까닭 ? ` · ${x.우리.까닭}` : ""}` : "—",
|
||||
bar([badge(x.판정, "judgePair"), ...(x.표지 ? [createBadge(x.표지, "neutral")] : [])]),
|
||||
jump,
|
||||
];
|
||||
});
|
||||
return createTable(
|
||||
[
|
||||
{ label: L("M02_JudgeColGroup"), width: "48px" },
|
||||
{ label: L("M02_JudgeColExcel") },
|
||||
{ label: L("M02_JudgeColOurs") },
|
||||
{ label: L("M02_JudgeColPair") },
|
||||
{ label: L("M02_JudgeSheet"), width: "52px" },
|
||||
],
|
||||
rows,
|
||||
{ density: "data" },
|
||||
);
|
||||
}
|
||||
|
||||
/** 덧 줄 후보 → 고른 것 */
|
||||
function extraPick(x: ExtraLine, c: NonNullable<ExtraLine["후보"]>[number]): DraftPick {
|
||||
const 갈래: PickKind = c.자재 ? "재료" : c.노임 ? "인력" : "기계";
|
||||
const 키 = c.자재 ?? c.노임 ?? c.키 ?? null;
|
||||
return { 갈래, 키, 이름: c.이름 ?? x.이름, 규격: c.규격 ?? "", 단위: c.단위 ?? "", 입력값: {}, 출처: {}, 몫: null }; // prettier-ignore
|
||||
}
|
||||
|
||||
/** 블록 풀이 몸 — 상태(고름 · 검산 결과)를 쥐고 바뀌면 다시 그림 */
|
||||
export function solvedBody(opt: SolvedBodyOptions): {
|
||||
root: HTMLElement;
|
||||
set: (picks: DraftPick[]) => void;
|
||||
} {
|
||||
const root = el("div", {
|
||||
className: "m02-judge",
|
||||
attrs: { "data-judge-solved": String(opt.solved.번) },
|
||||
});
|
||||
let picks = opt.picks;
|
||||
let checked: Record<string, CheckOne> = {};
|
||||
let ask: AbortController | null = null;
|
||||
|
||||
const put = (list: DraftPick[]): void => {
|
||||
picks = list;
|
||||
checked = {};
|
||||
opt.onPicks(list);
|
||||
draw();
|
||||
};
|
||||
const swap = (old: DraftPick, next: DraftPick): void =>
|
||||
put(picks.map((x) => (samePick(x, old) ? next : x)));
|
||||
|
||||
async function check(): Promise<void> {
|
||||
if (!opt.onCheck) return;
|
||||
ask?.abort();
|
||||
ask = new AbortController();
|
||||
try {
|
||||
const got = await opt.onCheck(picks, ask.signal);
|
||||
checked = Object.fromEntries(got.고른.map((c) => [String(c.키), c]));
|
||||
draw();
|
||||
} catch (e) {
|
||||
if ((e as Error).name !== "AbortError") showToast((e as Error).message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function candBox(cand: BaseCand): HTMLElement | null {
|
||||
const p = picks.find((x) => x.갈래 === "기초" && x.키 === cand.키);
|
||||
if (!p) return null;
|
||||
const got = checked[cand.키];
|
||||
const box = el("div", { className: "m02-judge__cand", attrs: { "data-judge-cand": cand.키 } });
|
||||
const head = [createText(cand.이름, "muted"), badge(got?.판정 ?? cand.판정, "judgeVerdict")];
|
||||
box.append(bar(head));
|
||||
box.append(inputTable(opt, cand, p, (n) => swap(p, n)));
|
||||
if (cand.바인딩.안씀.length)
|
||||
box.append(createText(`${L("M02_JudgeUnused")} — ${cand.바인딩.안씀.join(" · ")}`, "hint"));
|
||||
box.append(pairTable(opt, got?.줄 ?? cand.줄));
|
||||
const money = el("span", { text: `${L("M02_JudgeOursMoney")} ${moneyText(got?.금액 ?? cand.금액)}` }); // prettier-ignore
|
||||
money.dataset.judgeMoney = cand.키;
|
||||
box.append(bar([money]));
|
||||
return box;
|
||||
}
|
||||
|
||||
function secBox(sec: Section, i: number): HTMLElement {
|
||||
const box = el("div", { className: "m02-judge__sec", attrs: { "data-judge-sec": String(i) } });
|
||||
const title = `${L("M02_JudgeSection")} ${sec.번 ?? i + 1}. ${sec.제목}`;
|
||||
const chips: HTMLElement[] = sec.후보.map((c) => {
|
||||
const on = picks.some((x) => x.갈래 === "기초" && x.키 === c.키);
|
||||
const chip = createToggle({ kind: "chip", label: `${c.키} ${c.이름}`, checked: on, disabled: opt.readOnly, onChange: () => put(on ? picks.filter((x) => !(x.갈래 === "기초" && x.키 === c.키)) : [...picks, basePick(c, sec)]) }); // prettier-ignore
|
||||
chip.root.dataset.judgeBase = c.키;
|
||||
return chip.root;
|
||||
});
|
||||
if (!chips.length) chips.push(createText(L("M02_JudgeNoBase"), "warning"));
|
||||
box.append(bar([createText(title, "muted", 14), badge(sec.판정, "judgeSec")]), bar(chips));
|
||||
for (const c of sec.후보) {
|
||||
const cb = candBox(c);
|
||||
if (cb) box.append(cb);
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
function extraBox(): HTMLElement | null {
|
||||
if (!opt.solved.덧.length) return null;
|
||||
const rows: Cell[][] = opt.solved.덧.map((x) => {
|
||||
const chips = (x.후보 ?? []).slice(0, 4).map((c) => {
|
||||
const p = extraPick(x, c);
|
||||
const on = picks.some((y) => samePick(y, p));
|
||||
const chip = createToggle({ kind: "chip", label: `${p.이름} ${p.규격 ?? ""}`.trim(), checked: on, disabled: opt.readOnly, onChange: () => put(togglePick(picks, p)) }); // prettier-ignore
|
||||
chip.root.dataset.judgeExtra = String(p.키);
|
||||
return chip.root;
|
||||
});
|
||||
return [x.절, x.이름, x.방법 ?? "", bar(chips)];
|
||||
});
|
||||
const box = el("div", { className: "m02-judge__sec", attrs: { "data-judge-extra": "" } });
|
||||
box.append(createText(L("M02_JudgeExtra"), "muted", 14));
|
||||
box.append(createTable([{ label: L("M02_JudgeSection"), width: "8em" }, { label: L("M02_JudgeColExcel") }, { label: "" }, { label: L("M02_JudgeColPick") }], rows, { density: "data" })); // prettier-ignore
|
||||
return box;
|
||||
}
|
||||
|
||||
function draw(): void {
|
||||
const s = opt.solved;
|
||||
const verdict = s.판정 === "고를 것" ? L("M02_JudgePickN").replace("{n}", String(s.고를수)) : s.판정; // prettier-ignore
|
||||
const v = createBadge(verdict, TONE[s.판정] ?? "neutral");
|
||||
v.dataset.judgeBlockVerdict = s.판정;
|
||||
const ours = picks.reduce(
|
||||
(a, p) => {
|
||||
const c = s.절.flatMap((x) => x.후보).find((x) => x.키 === p.키);
|
||||
const m = (checked[String(p.키)]?.금액 ?? c?.금액) as Partial<Money> | undefined;
|
||||
return a === null || !m || m.계 === null || m.계 === undefined ? null : a + m.계;
|
||||
},
|
||||
picks.length ? 0 : (null as number | null),
|
||||
);
|
||||
const head: HTMLElement[] = [v, el("span", { text: `${L("M02_JudgeOursMoney")} ${won(ours)}`, attrs: { "data-judge-ours": "" } })]; // prettier-ignore
|
||||
if (opt.excel) head.push(el("span", { text: `${L("M02_JudgeExcelMoney")} ${moneyText(opt.excel)}`, attrs: { "data-judge-excel": "" } })); // prettier-ignore
|
||||
if (opt.onCheck) {
|
||||
const go = createButton({ label: L("M02_JudgeCheck"), size: 24, variant: "ghost", disabled: !picks.length, onClick: () => void check() }); // prettier-ignore
|
||||
go.dataset.judgeCheck = "";
|
||||
head.push(go);
|
||||
}
|
||||
root.replaceChildren(bar(head), createText(L("M02_JudgeAmountNote"), "hint"));
|
||||
s.절.forEach((sec, i) => root.append(secBox(sec, i)));
|
||||
const extra = extraBox();
|
||||
if (extra) root.append(extra);
|
||||
}
|
||||
|
||||
draw();
|
||||
return {
|
||||
root,
|
||||
set(list: DraftPick[]): void {
|
||||
picks = list;
|
||||
draw();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface JudgeBlockOptions {
|
||||
host: HTMLElement;
|
||||
/** 파일 층 */
|
||||
file: HopyoScope;
|
||||
key: string;
|
||||
/** 만들 층 */
|
||||
target: HopyoScope;
|
||||
state: JudgeState;
|
||||
no: number;
|
||||
onSheet?: (행: number) => void;
|
||||
onClose?: () => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
/** 단가산출 탭 줄 판 — 블록 하나 · [확인] = 그 블록 SK 로 옮길 것 */
|
||||
export function createJudgeBlock(opt: JudgeBlockOptions): {
|
||||
root: HTMLElement;
|
||||
destroy: () => void;
|
||||
} {
|
||||
const root = el("section", {
|
||||
className: "m02-judge",
|
||||
attrs: { "data-judge-block": String(opt.no) },
|
||||
});
|
||||
root.append(createText(L("M02_JudgeLoading"), "hint"));
|
||||
opt.host.replaceChildren(root);
|
||||
let alive = true;
|
||||
|
||||
void readJudgeBlock(opt.file, opt.key, opt.no, opt.target)
|
||||
.then((blk) => {
|
||||
if (!alive) return;
|
||||
const line = () => opt.state.get().블록[String(blk.번)];
|
||||
const ok = createToggle({
|
||||
kind: "chip",
|
||||
label: L("M02_JudgeConfirm"),
|
||||
checked: !!line()?.확인,
|
||||
disabled: opt.readOnly,
|
||||
onChange: (on) => {
|
||||
const list = blockPicks(opt.state.get(), blk);
|
||||
if (on && !confirmable(list)) {
|
||||
ok.set(false);
|
||||
showToast(`${L("M02_JudgeBlocked")} — ${list.flatMap(missing).join(" · ")}`, "warning");
|
||||
return;
|
||||
}
|
||||
opt.state.set(putBlock(opt.state.get(), blk.번, { 고른: list, 확인: on }));
|
||||
},
|
||||
});
|
||||
ok.root.dataset.judgeOk = "block";
|
||||
const actions: HTMLElement[] = [ok.root];
|
||||
const open = createButton({ label: L("M02_CheckImported"), size: 24, variant: "ghost", disabled: opt.readOnly, onClick: () => void import("./M02_MasterTemplete_Hopyo_Imported").then((m) => m.openImported({ ...{ file: opt.file, key: opt.key, no: blk.번, target: opt.target, canWrite: !opt.readOnly } })) }); // prettier-ignore
|
||||
open.dataset.judgeImported = String(blk.번);
|
||||
// [내 단가산출로](807-1) — 늘 개인 층 · 만들 층이 개인이면 [가져온 식으로 열기]는 숨김(같은 일)
|
||||
const mine = createButton({ label: L("M02_ToMySk"), size: 24, variant: "ghost", onClick: () => void import("./M02_MasterTemplete_Hopyo_Imported").then((m) => m.openImported({ ...{ file: opt.file, key: opt.key, no: blk.번, target: { layer: "personal", owner: null }, canWrite: true } })) }); // prettier-ignore
|
||||
mine.dataset.judgeMine = String(blk.번);
|
||||
open.hidden = opt.target.layer === "personal" && !opt.target.owner;
|
||||
const fresh = createBadge(L("M02_CheckNew"), "accent", { size: 18 }); // 「새」(793-3b) — CheckRows.newBadge 꼴(돌림 import 피함)
|
||||
fresh.dataset.checkNew = "";
|
||||
actions.push(open, mine, fresh);
|
||||
if (opt.onClose) actions.push(createButton({ label: L("Common_Btn_Close"), size: 24, variant: "ghost", onClick: opt.onClose })); // prettier-ignore
|
||||
const title = `${L("M02_JudgeBlock").replace("{n}", String(blk.번))} ${blk.이름} (${blk.단위})`;
|
||||
const sub = [
|
||||
blk.근거?.표지,
|
||||
blk.근거?.출처.map((x) => x.join(" ")).join(" · "),
|
||||
blk.부름.length ? `${L("M02_JudgeCalls")} ${blk.부름.map((n) => L("M02_JudgeHopyo").replace("{n}", String(n))).join(" · ")}` : "", // prettier-ignore
|
||||
blk.만든 ? `${L("M02_JudgeMade")} ${blk.만든}` : "",
|
||||
].filter(Boolean).join(" · "); // prettier-ignore
|
||||
const body = solvedBody({
|
||||
solved: blk,
|
||||
picks: blockPicks(opt.state.get(), blk),
|
||||
excel: blk.금액,
|
||||
readOnly: opt.readOnly,
|
||||
onSheet: opt.onSheet,
|
||||
onPicks: (list) => {
|
||||
opt.state.set(putBlock(opt.state.get(), blk.번, { 고른: list, 확인: false }));
|
||||
ok.set(false);
|
||||
},
|
||||
onCheck: (list, signal) => checkJudge(opt.file, opt.key, { 블록: blk.번, 고른: list.map((p) => bare(p)) }, signal), // prettier-ignore
|
||||
});
|
||||
root.replaceChildren(createPanelHead({ title, sub, actions, divider: true }).root, body.root);
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
if (alive) root.replaceChildren(createText(e.message, "error"));
|
||||
});
|
||||
|
||||
return {
|
||||
root,
|
||||
destroy(): void {
|
||||
alive = false;
|
||||
root.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,360 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_Check.ts
|
||||
* 불러오기 [수정] 모달 「확인 — 제N호표」(PLAN 786-2 · 요구 644 · 645 · 785 전 옛 확인 창 꼴 되살림) —
|
||||
* 엑셀 보기 오른쪽 호표 목록 [수정](`Hopyo_Sheet`)이 엶.
|
||||
* · 머리 = 호표 · 엑셀 단가 · 제안 n / 줄 m · 확인 n · 만든 사전조합 ·
|
||||
* 단추 [호표 제안 모두 확인](새) · [사전조합 생성] · [구조물 조합으로] · [엑셀 보기]
|
||||
* · 몸 = 로직 표 + 자재 표(구조물 조합 일위대가 상세 꼴 · `Hopyo_CheckRows`) · 줄 칸 부품 = `Hopyo_Rows.createRowKit`
|
||||
* · 우리 금액 = 서버 `/hopyo/{키}/price`(옮기기와 같은 길로 한 줄 문서 → `Labor_Doc.calc` · 저장 없음 ·
|
||||
* 고름이 바뀐 줄만 · 묶어 한 번 · 줄마다 최신 응답만)
|
||||
* · 고름 · 확인 = 판정 캐시(`createJudgeState` · 시트 칠 · 오른쪽 [옮기기 (n)] 과 한 벌) · 자동 확인 · 자동 옮김 없음
|
||||
* · [사전조합 생성] = 옮기기 길(`runMove`) · 이 호표의 확인한 줄만 · 서버가 정본으로 다시 셈
|
||||
* · [구조물 조합으로] = 이 호표가 사전조합으로 옮겨진 뒤만 · 구조물 조합 만들기 길(`Hopyo_ToItem`)
|
||||
* · 단산 줄 [가져온 식으로 열기](793-3b) = `Hopyo_Imported` 모달(가져온 식 SK) · [이 줄에 잇기] = 그 줄 고른 것 = 그 SK
|
||||
* · 행 번호 · [엑셀 보기] = 모달 닫고 시트 그 행(고름은 캐시로 남음 · 다시 [수정] 하면 그대로)
|
||||
* · 옛 [맞음·별칭 일괄 체크] · [품셈으로 바꾸기] · [모두 고정으로] = 지운 서버 길 몫 — [호표 제안 모두 확인]이 갈음
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import { loadingError, showLoading } from "@ui/ui_template_loading";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source";
|
||||
import { formatPrice } from "../common_util/common_util_money";
|
||||
import { call } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { at, HOPYO_BUILT, type HopyoBrief, type HopyoScope } from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import {
|
||||
checkJudge,
|
||||
readJudgeBlock,
|
||||
readJudgeOne,
|
||||
type JudgeOne,
|
||||
type JudgeRow,
|
||||
type Pick,
|
||||
} from "./M02_MasterTemplete_Hopyo_JudgeApi";
|
||||
import {
|
||||
checkTables,
|
||||
newBadge,
|
||||
NO_LOGIC,
|
||||
type LinePrice,
|
||||
type PriceLine,
|
||||
} from "./M02_MasterTemplete_Hopyo_CheckRows";
|
||||
import {
|
||||
createRowKit,
|
||||
runMove,
|
||||
type JudgeState,
|
||||
type SheetRole,
|
||||
} from "./M02_MasterTemplete_Hopyo_Rows";
|
||||
import { openImported } from "./M02_MasterTemplete_Hopyo_Imported";
|
||||
import { toItem } from "./M02_MasterTemplete_Hopyo_ToItem";
|
||||
import { fetchLogicInputs } from "./M02_MasterTemplete_Labor_Api";
|
||||
import { openLaborInput } from "./M02_MasterTemplete_Labor_Input";
|
||||
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
|
||||
import {
|
||||
bare,
|
||||
confirmSuggested,
|
||||
lineOf,
|
||||
moveBody,
|
||||
samePick,
|
||||
setInput,
|
||||
type DraftPick,
|
||||
} from "./logic/M02_MasterTemplete_Hopyo_Picks";
|
||||
import "./M02_MasterTemplete_Hopyo.css";
|
||||
|
||||
const enc = encodeURIComponent;
|
||||
const why = (e: unknown): string => (e instanceof Error ? e.message : String(e));
|
||||
const note = (text: string): HTMLElement => createText(text, "muted");
|
||||
const won = (v: number | null | undefined): string => (v === null || v === undefined ? "—" : formatPrice(v)); // prettier-ignore
|
||||
|
||||
/** 우리 금액 미리 셈 — 경로 층 = 만들 층 · 몸 층 = 파일 층(저장 없음) */
|
||||
const priceJudge = (
|
||||
target: HopyoScope,
|
||||
file: HopyoScope,
|
||||
key: string,
|
||||
no: number,
|
||||
줄: Record<string, Pick[]>,
|
||||
): Promise<{ 줄: Record<string, PriceLine> }> =>
|
||||
call(at(target, `/hopyo/${enc(key)}/price`), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ 층: file.layer, ...(file.owner ? { 주인: file.owner } : {}), 호표: no, 줄 }), // prettier-ignore
|
||||
});
|
||||
|
||||
export interface CheckOptions {
|
||||
/** 실무자료 파일 층 */
|
||||
file: HopyoScope;
|
||||
key: string;
|
||||
/** 사전조합 · 구조물 조합을 만들 층 */
|
||||
target: HopyoScope;
|
||||
state: JudgeState;
|
||||
no: number;
|
||||
/** 목록 한 줄(엑셀 단가 · 제안 n / 줄 m) */
|
||||
brief?: HopyoBrief;
|
||||
canWrite: boolean;
|
||||
/** 모달 닫은 뒤 엑셀 보기 그 행 · 그 호표 */
|
||||
onSheet: (행: number, role: SheetRole) => void;
|
||||
onHopyo: () => void;
|
||||
/** 옮긴 뒤(목록 · 칠 다시 읽음) */
|
||||
onMoved: () => void;
|
||||
}
|
||||
|
||||
/** [수정] 모달 */
|
||||
export function openHopyoCheck(opt: CheckOptions): { close: () => void } {
|
||||
const readOnly = !opt.canWrite;
|
||||
const no = opt.no;
|
||||
const root = el("div", { className: "m02-hopyo m02-hopyo-check", attrs: { "data-hopyo-check": `${opt.key}|${no}` } }); // prettier-ignore
|
||||
let one: JudgeOne | null = null;
|
||||
let shut: () => void = () => {};
|
||||
let off: () => void = () => {};
|
||||
let busy = false;
|
||||
const leave = (): void => {
|
||||
off();
|
||||
shut();
|
||||
};
|
||||
|
||||
// ── 우리 금액(줄마다 최신 응답만 · 바뀐 줄만 묶어 한 번) ──
|
||||
const priced = new Map<number, { sig: string; seq: number; val: LinePrice }>();
|
||||
let queued: Record<string, Pick[]> = {};
|
||||
let seq = 0;
|
||||
let timer = 0;
|
||||
let pending = false;
|
||||
function soon(): void {
|
||||
if (pending) return;
|
||||
pending = true;
|
||||
window.setTimeout(() => {
|
||||
pending = false;
|
||||
if (root.isConnected) draw();
|
||||
}, 0);
|
||||
}
|
||||
function flush(): void {
|
||||
const ask = queued;
|
||||
queued = {};
|
||||
const my = ++seq;
|
||||
for (const k of Object.keys(ask)) {
|
||||
const slot = priced.get(Number(k));
|
||||
if (slot) slot.seq = my;
|
||||
}
|
||||
priceJudge(opt.target, opt.file, opt.key, no, ask).then(
|
||||
(got) => {
|
||||
for (const k of Object.keys(ask)) {
|
||||
const slot = priced.get(Number(k));
|
||||
const line = got.줄[k];
|
||||
if (slot?.seq === my) slot.val = line ? { got: line } : { fail: L("M02_CheckNoCalc") };
|
||||
}
|
||||
soon();
|
||||
},
|
||||
(e: unknown) => {
|
||||
for (const k of Object.keys(ask)) {
|
||||
const slot = priced.get(Number(k));
|
||||
if (slot?.seq === my) slot.val = { fail: why(e) };
|
||||
}
|
||||
soon();
|
||||
},
|
||||
);
|
||||
}
|
||||
function price(row: JudgeRow): LinePrice | null {
|
||||
const picks = kit.picksOf(row);
|
||||
if (!picks.length) return null;
|
||||
const 배 = lineOf(opt.state.get(), no, row.차례)?.배;
|
||||
const body = picks.map((p) => bare(p, 배));
|
||||
const sig = JSON.stringify(body);
|
||||
const have = priced.get(row.차례);
|
||||
if (have?.sig === sig) return have.val;
|
||||
priced.set(row.차례, { sig, seq: -1, val: { wait: true } });
|
||||
queued[String(row.차례)] = body;
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(flush, 250);
|
||||
return { wait: true };
|
||||
}
|
||||
|
||||
const kit = createRowKit({
|
||||
file: opt.file,
|
||||
key: opt.key,
|
||||
state: opt.state,
|
||||
no,
|
||||
readOnly,
|
||||
redraw: () => draw(),
|
||||
onSheet: (r, role) => {
|
||||
leave();
|
||||
opt.onSheet(r, role);
|
||||
},
|
||||
});
|
||||
|
||||
/** [원문 보기] — 엑셀 인용 조항(단산 줄 = 블록 근거) · 우리 로직 출처 */
|
||||
async function showSource(row: JudgeRow): Promise<void> {
|
||||
const blk = row.번 !== undefined ? await readJudgeBlock(opt.file, opt.key, row.번, opt.target).catch(() => null) : null; // prettier-ignore
|
||||
const excel = (blk?.근거?.출처 ?? []).map((x) => x.join(" ").trim()).filter(Boolean);
|
||||
const codes = kit.picksOf(row).flatMap((p) => (p.갈래 === "기초" && p.키 ? [p.키] : []));
|
||||
const ours = (await fetchLogicInputs(codes).catch(() => ({ 로직: [] }))).로직.flatMap((x) =>
|
||||
x.출처 ? [`${x.로직} — ${x.출처}`] : [],
|
||||
);
|
||||
openModal({
|
||||
title: `${L("M02_CheckSource")} — ${row.이름 ?? ""}`,
|
||||
closeLabel: L("Common_Btn_Close"),
|
||||
wide: true,
|
||||
dialogClass: "m02-modal m02-hopyo__src",
|
||||
mount: (body) => {
|
||||
if (row.근거) body.append(note(row.근거));
|
||||
const part = (title: string, list: string[], cut: (s: string) => string): void => {
|
||||
body.append(el("h4", { text: title }));
|
||||
if (!list.length) body.append(note(L("M02_CheckNone")));
|
||||
for (const s of list) {
|
||||
const view = createSourceView({ path: "/m02/logic-lib/source", pdf: "/m02/logic-lib/source-pdf" }); // prettier-ignore
|
||||
view.show(cut(s));
|
||||
body.append(note(s), view.root);
|
||||
}
|
||||
};
|
||||
part(L("M02_CheckSourceExcel"), excel, (s) => s);
|
||||
part(L("M02_CheckSourceOurs"), ours, (s) => s.split(" — ").slice(1).join(" — "));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 기초 하나 바꿔 넣음(같은 것 자리) */
|
||||
const swap = (row: JudgeRow, from: DraftPick, to: DraftPick): void =>
|
||||
kit.edit(
|
||||
row,
|
||||
kit.picksOf(row).map((x) => (samePick(x, from) ? to : x)),
|
||||
);
|
||||
|
||||
/** [옵션] — 그 기초 입력값 모달(구조물 조합 상세와 같은 창) · 바뀐 칸 = 출처 「사용자」 */
|
||||
function options(row: JudgeRow, p: DraftPick): void {
|
||||
fetchLogicInputs([String(p.키)]).then(
|
||||
(res) =>
|
||||
openLaborInput({
|
||||
title: `${p.키} ${p.이름}`.trim(),
|
||||
logic: res.로직[0] ?? null,
|
||||
values: p.입력값 as never,
|
||||
memo: null,
|
||||
readOnly,
|
||||
done: (r) => {
|
||||
let next = p;
|
||||
const got = r.입력값 as Record<string, unknown>;
|
||||
for (const k of new Set([...Object.keys(got), ...Object.keys(p.입력값)]))
|
||||
if (got[k] !== p.입력값[k]) next = setInput(next, k, got[k] ?? "");
|
||||
swap(row, p, next);
|
||||
},
|
||||
}),
|
||||
(e: unknown) => showToast(why(e), "error"),
|
||||
);
|
||||
}
|
||||
|
||||
/** [고르기] — 로직 찾기 창(구조물 조합 [로직 추가]와 같은 창) → 검산으로 입력값 · 입력 필요 받아 더함 */
|
||||
function pick(row: JudgeRow): void {
|
||||
openLogicPicker((logic) => {
|
||||
const raw: DraftPick = { 갈래: "기초", 키: logic.키, 이름: logic.이름, 입력값: {}, 출처: {}, 몫: null }; // prettier-ignore
|
||||
checkJudge(opt.file, opt.key, { 호표: no, 차례: row.차례, 고른: [bare(raw)] }).then(
|
||||
(res) => {
|
||||
const c = res.고른[0];
|
||||
const p: DraftPick = { ...raw, 입력값: c?.입력값 ?? {}, 필요: c?.입력필요 ?? [] };
|
||||
kit.edit(row, [...kit.picksOf(row).filter((x) => !samePick(x, p)), p]);
|
||||
},
|
||||
(e: unknown) => showToast(why(e), "error"),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** [가져온 식으로 열기](793-3b) — 단산 블록 → 가져온 식 SK 모달 · [이 줄에 잇기] = 이 줄 고른 것 */
|
||||
const imported = (row: JudgeRow): void =>
|
||||
openImported({
|
||||
...{ file: opt.file, key: opt.key, no: row.번!, target: opt.target, canWrite: opt.canWrite },
|
||||
onLink: (p) => kit.edit(row, [p]),
|
||||
});
|
||||
|
||||
/** 추가(빈 값) = 제안으로 되돌림 · 로직없음 = 고정 줄(엑셀 숫자 그대로) */
|
||||
function out(row: JudgeRow, value: string): void {
|
||||
if (value === NO_LOGIC) {
|
||||
kit.edit(row, [{ 갈래: "고정", 키: null, 이름: row.이름 ?? "", 입력값: {}, 출처: {}, 몫: null }]); // prettier-ignore
|
||||
return;
|
||||
}
|
||||
const d = opt.state.get();
|
||||
const lines = { ...(d.호표[String(no)] ?? {}) };
|
||||
delete lines[String(row.차례)];
|
||||
opt.state.set({ ...d, 호표: { ...d.호표, [String(no)]: lines } });
|
||||
}
|
||||
|
||||
/** [사전조합 생성] — 이 호표의 확인한 줄만 옮김 */
|
||||
async function make(): Promise<void> {
|
||||
const all = moveBody(opt.state.get(), opt.file);
|
||||
const mine = all.body.호표[String(no)] ?? {};
|
||||
const n = Object.keys(mine).length;
|
||||
if (!n) return void showToast(L("M02_JudgeMoveNone"), "info");
|
||||
const ask = L("M02_CheckMakeAsk").replace("{a}", String(n)).replace("{b}", String((one?.줄.length ?? 0) - n)); // prettier-ignore
|
||||
if (!(await showConfirmDialog(ask, L("M02_CheckMake")))) return;
|
||||
busy = true;
|
||||
draw();
|
||||
const stuck = all.막힘.filter((x) => x.호표 === no);
|
||||
const r = await runMove(opt, { ...all.body, 호표: { [String(no)]: mine }, 블록: {} }, stuck);
|
||||
busy = false;
|
||||
if (r) {
|
||||
document.dispatchEvent(new Event(HOPYO_BUILT));
|
||||
opt.onMoved();
|
||||
await load(true);
|
||||
} else draw();
|
||||
}
|
||||
|
||||
function draw(): void {
|
||||
if (!one) return;
|
||||
const h = one;
|
||||
const done = h.줄.filter((r) => lineOf(opt.state.get(), no, r.차례)?.확인).length;
|
||||
const [a, m] = opt.brief?.맞춤 ?? [h.줄.filter((r) => r.판정 === "제안 있음").length, h.줄.length]; // prettier-ignore
|
||||
const all = createButton({ label: L("M02_JudgeConfirmAll"), size: "sm", variant: "ghost", disabled: readOnly, onClick: () => opt.state.set(confirmSuggested(opt.state.get(), no, h.줄)) }); // prettier-ignore
|
||||
all.dataset.judgeAll = String(no);
|
||||
const go = createButton({ label: L("M02_CheckMake"), size: "sm", variant: "filled", disabled: readOnly || busy || !done, onClick: () => void make() }); // prettier-ignore
|
||||
go.dataset.hopyoMake = "";
|
||||
const item = createButton({ label: L("M02_CheckToItem"), size: "sm", disabled: readOnly || !h.만든, onClick: () => void toItem({ target: opt.target, sc: h.만든!, name: [h.이름, h.규격].filter(Boolean).join(" "), unit: h.단위 }) }); // prettier-ignore
|
||||
item.dataset.hopyoToItem = "";
|
||||
item.title = h.만든 ? "" : L("M02_CheckToItemHint");
|
||||
const sheet = createButton({ label: L("M02_CheckSheet"), size: "sm", onClick: () => { leave(); opt.onHopyo(); } }); // prettier-ignore
|
||||
sheet.dataset.hopyoSheet = "";
|
||||
const head = el("div", {
|
||||
className: "ui-toolbar m02-hopyo__bar m02-judge__wrap",
|
||||
children: [
|
||||
el("strong", { text: `${L("M02_JudgeHopyo").replace("{n}", String(no))} ${h.이름}` }),
|
||||
note(h.규격),
|
||||
note(h.단위),
|
||||
note(`${L("M02_CheckExcelPrice")} ${won(opt.brief?.금액.계)}`),
|
||||
note(L("M02_CheckMoneyHint")),
|
||||
note(L("M02_CheckCount").replace("{a}", String(a)).replace("{m}", String(m)).replace("{c}", String(done))), // prettier-ignore
|
||||
...(h.만든 ? [note(`${L("M02_JudgeMade")} ${L("M02_PrecomboTitle")} ${h.만든}`)] : []),
|
||||
newBadge(),
|
||||
all,
|
||||
go,
|
||||
item,
|
||||
sheet,
|
||||
],
|
||||
});
|
||||
const legend = el("div", { className: "ui-toolbar m02-judge__wrap", children: [newBadge(), note(L("M02_CheckNewHint"))] }); // prettier-ignore
|
||||
const ctx = { kit, readOnly, price, pick, source: (r: JudgeRow) => void showSource(r), options, out, imported }; // prettier-ignore
|
||||
root.replaceChildren(head, legend, ...(h.줄.length ? checkTables(h.줄, ctx) : [note(L("M02_CheckNoLines"))])); // prettier-ignore
|
||||
}
|
||||
|
||||
async function load(quiet = false): Promise<void> {
|
||||
const stop = quiet ? () => {} : showLoading(root);
|
||||
try {
|
||||
one = await readJudgeOne(opt.file, opt.key, no, opt.target);
|
||||
draw();
|
||||
} catch (e) {
|
||||
if (!quiet) root.replaceChildren(loadingError(why(e), () => void load()));
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
}
|
||||
|
||||
openModal({
|
||||
title: L("M02_CheckTitle").replace("{n}", String(no)),
|
||||
closeLabel: L("Common_Btn_Close"),
|
||||
wide: true,
|
||||
dialogClass: "m02-modal m02-hopyo__check-modal",
|
||||
beforeClose: () => {
|
||||
off();
|
||||
return true;
|
||||
},
|
||||
mount: (body, close) => {
|
||||
shut = close;
|
||||
body.append(root);
|
||||
},
|
||||
});
|
||||
off = opt.state.on(() => draw());
|
||||
void load();
|
||||
return { close: leave };
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_CheckRows.ts
|
||||
* [수정] 모달 줄 표(PLAN 786-2 · 요구 644 · 옛 359-3 꼴 되살림) — 구조물 조합 일위대가 상세 꼴
|
||||
* (`Labor_Rows.moneyCell` · `PRICE_SLOTS` · `Labor_MatMarks` 빌려 씀 · 그 파일은 안 고침)에 새 판정 칸(785)을 얹음.
|
||||
* · 로직 표 = [확인] · 행 · 구분(엑셀 갈래 + 1차 판정) · 이름(고름 칩 · 단산 [블록 풀이] · 셈 못 함 까닭) ·
|
||||
* 별칭(엑셀 품명) · 수량산출(위 엑셀 / 아래 우리 = 엑셀 × 배 · 단위 환산 칸) · 단위 ·
|
||||
* 노무비 · 재료비 · 경비 · 합계(위 엑셀 단가 / 아래 우리 = 서버 미리 셈 ÷ 엑셀 수량) · 품 비교 · 단추
|
||||
* · 자재 표 = [확인] · 행 · 코드 · 이름 · 규격 · 수량산출 · 단위 · 세 칸(글만) · 가격 다섯 · 엑셀 / 우리 합계 · 단추
|
||||
* · 단추 = [고르기] · [원문 보기] · [옵션](기초마다) · 추가 / 로직없음(= 고정 줄) — 옛 「사용안함」(뺌)은 새 옮기기 길에 없어 안 살림
|
||||
* · 「새」 배지(785 새 기능) = 머리 칸 [확인] · 구분(판정) · 이름(고름 · 블록 풀이) · 수량산출(단위 환산)
|
||||
* · 단산 줄(793-3b) = 판정 칩 「2차 필요」 대신 결과 칩(가져온 식 있음 · 근거 확정 · 근접 · 사람 고름 · 입력 필요 n ·
|
||||
* SK 안 이었으면 블록 풀이 판정) · 단추 [가져온 식으로 열기](새 · `Hopyo_Imported` 모달)
|
||||
* 줄 표 = `tableOf`(고른 것이 모두 재료 · 유가 · 없으면 엑셀 갈래 · 별도계상 줄 = 자재 표).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createBadge } from "@ui/ui_template_badge";
|
||||
import { createButton, createSelectField, el } from "@ui/ui_template_elements";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import { formatPrice } from "../common_util/common_util_money";
|
||||
import type { JudgeRow } from "./M02_MasterTemplete_Hopyo_JudgeApi";
|
||||
import { apartRow, bar, show, type RowKit } from "./M02_MasterTemplete_Hopyo_Rows";
|
||||
import type { CalcMaterial } from "./M02_MasterTemplete_Labor_Calc";
|
||||
import { markCells, markHeads } from "./M02_MasterTemplete_Labor_MatMarks";
|
||||
import { moneyCell, PRICE_SLOTS } from "./M02_MasterTemplete_Labor_Rows";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import { rowChips } from "./logic/M02_Hopyo_Imported_Model";
|
||||
import type { DraftPick } from "./logic/M02_MasterTemplete_Hopyo_Picks";
|
||||
|
||||
type Money4 = Record<"노무비" | "재료비" | "경비" | "계", number | null>;
|
||||
|
||||
/** 서버 미리 셈 한 줄(`/hopyo/{키}/price`) */
|
||||
export interface PriceLine {
|
||||
금액: Money4 | null;
|
||||
자재?: CalcMaterial[];
|
||||
까닭: string[];
|
||||
칸: string[];
|
||||
}
|
||||
/** 줄 금액 — 셈 중 = wait · 고른 것 없음 = null */
|
||||
export type LinePrice = { wait: true } | { got: PriceLine } | { fail: string };
|
||||
|
||||
export interface CheckCtx {
|
||||
kit: RowKit;
|
||||
readOnly: boolean;
|
||||
price: (row: JudgeRow) => LinePrice | null;
|
||||
pick: (row: JudgeRow) => void;
|
||||
source: (row: JudgeRow) => void;
|
||||
options: (row: JudgeRow, p: DraftPick) => void;
|
||||
/** 추가(빈 값 = 제안으로 되돌림) · 로직없음(고정 줄) */
|
||||
out: (row: JudgeRow, value: string) => void;
|
||||
/** 단산 줄 [가져온 식으로 열기](793-3b) */
|
||||
imported?: (row: JudgeRow) => void;
|
||||
}
|
||||
|
||||
const COSTS = ["노무비", "재료비", "경비", "계"] as const;
|
||||
const COST_HEAD = {
|
||||
노무비: "Prev_Labor",
|
||||
재료비: "Prev_Material",
|
||||
경비: "Prev_Expense",
|
||||
계: "Prev_Sum",
|
||||
} as const;
|
||||
const MAT_KINDS = ["재료", "유가"];
|
||||
export const NO_LOGIC = "로직없음";
|
||||
|
||||
const small = (text: string, cls = "m02-labrow__note"): HTMLElement =>
|
||||
el("small", { className: cls, text, attrs: { title: text } });
|
||||
|
||||
/** 「새」 배지(785 새 기능 표지) */
|
||||
export function newBadge(): HTMLElement {
|
||||
const b = createBadge(L("M02_CheckNew"), "accent", { size: 18 });
|
||||
b.dataset.checkNew = "";
|
||||
b.title = L("M02_CheckNewHint");
|
||||
return b;
|
||||
}
|
||||
|
||||
/** 줄이 들어갈 표 */
|
||||
export function tableOf(row: JudgeRow, picks: DraftPick[]): "logic" | "mat" {
|
||||
if (apartRow(row)) return "mat";
|
||||
if (picks.length) return picks.every((p) => MAT_KINDS.includes(p.갈래)) ? "mat" : "logic";
|
||||
return row.갈래.length > 0 && row.갈래.every((k) => MAT_KINDS.includes(k)) ? "mat" : "logic";
|
||||
}
|
||||
|
||||
/** 우리 금액 → 엑셀 1 단위(서버 반영합 ÷ 엑셀 수량 · 수량 없음 = 그대로) */
|
||||
function perUnit(v: number | null | undefined, row: JudgeRow): number | null {
|
||||
if (v === null || v === undefined) return null;
|
||||
const q = row.수량.엑셀;
|
||||
return q ? v / q : v;
|
||||
}
|
||||
|
||||
const priced = (p: LinePrice | null): PriceLine | null => (p && "got" in p ? p.got : null);
|
||||
const waiting = (p: LinePrice | null): boolean => !!p && "wait" in p;
|
||||
|
||||
/** 이름 칸 덧글 — 셈 못 함 까닭 · 입력 필요 칸 */
|
||||
function notes(p: LinePrice | null): HTMLElement[] {
|
||||
const got = priced(p);
|
||||
const why = [...(p && "fail" in p ? [p.fail] : []), ...(got?.까닭 ?? [])];
|
||||
const out = why.map((t) => small(`${L("M02_CheckNoCalc")}: ${t}`, "ui-text ui-text--error"));
|
||||
if (got?.칸.length) out.push(small(`${L("M02_JudgeNeed")}: ${got.칸.join(" · ")}`, "ui-text ui-text--error")); // prettier-ignore
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 품 비교 — 고른 기초의 줄 대조 판정 수(단산 줄 · 블록 풀이) */
|
||||
export function compareText(row: JudgeRow, picks: DraftPick[]): string {
|
||||
const keys = new Set(picks.map((p) => p.키));
|
||||
const count = new Map<string, number>();
|
||||
for (const sec of row.풀이?.절 ?? [])
|
||||
for (const c of sec.후보)
|
||||
if (keys.has(c.키)) for (const x of c.줄) count.set(x.판정, (count.get(x.판정) ?? 0) + 1);
|
||||
return [...count].map(([k, n]) => `${k} ${n}`).join("\n");
|
||||
}
|
||||
|
||||
/** 수량산출 — 위 엑셀 수량 · 아래 우리(엑셀 × 배) + 단위 환산 칸 */
|
||||
function qtyCell(row: JudgeRow, ctx: CheckCtx, picks: DraftPick[]): HTMLElement {
|
||||
const 배 = ctx.kit.timesOf(row);
|
||||
const ex = row.수량.엑셀;
|
||||
const ours = ex === null || 배 === null ? "—" : show(ex * 배);
|
||||
const unit = ctx.kit.unitInput(row, picks);
|
||||
return el("td", {
|
||||
className: "m02-labrow__qty",
|
||||
attrs: { "data-label": tc("Labor_QtyCol") },
|
||||
children: [
|
||||
el("small", { className: "m02-labrow__base", text: ex === null ? "—" : `${show(ex)} ${row.단위 ?? ""}`.trim(), attrs: { title: L("M02_JudgeColExcel") } }), // prettier-ignore
|
||||
el("span", { className: "m02-labrow__applied", text: ours, attrs: { title: L("M02_JudgeColOurs") } }), // prettier-ignore
|
||||
...(unit ? [unit] : []),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function actsCell(row: JudgeRow, ctx: CheckCtx, picks: DraftPick[]): HTMLElement {
|
||||
const ghost = (label: string, run: () => void, at: string): HTMLElement => {
|
||||
const b = createButton({ label, variant: "ghost", size: "sm", onClick: run });
|
||||
b.dataset.checkAct = at;
|
||||
return b;
|
||||
};
|
||||
const kids: HTMLElement[] = [];
|
||||
if (!ctx.readOnly && !apartRow(row)) kids.push(ghost(L("M02_CheckPick"), () => ctx.pick(row), "pick")); // prettier-ignore
|
||||
if (row.번 !== undefined && ctx.imported) {
|
||||
const b = ghost(L("M02_CheckImported"), () => ctx.imported!(row), "imported");
|
||||
(b as HTMLButtonElement).disabled = ctx.readOnly;
|
||||
kids.push(el("span", { className: "m02-judge__wrap", children: [b, newBadge()] }));
|
||||
}
|
||||
kids.push(ghost(L("M02_CheckSource"), () => ctx.source(row), "source"));
|
||||
for (const p of picks.filter((x) => x.갈래 === "기초")) {
|
||||
const b = ghost(L("M02_CheckOption"), () => ctx.options(row, p), "option");
|
||||
b.title = `${p.키} ${L("M02_CheckOption")}`;
|
||||
kids.push(b);
|
||||
}
|
||||
if (!apartRow(row)) {
|
||||
const fixed = picks.length === 1 && picks[0].갈래 === "고정";
|
||||
const out = createSelectField({
|
||||
options: [
|
||||
{ value: "", text: L("M02_CheckAdd") },
|
||||
{ value: NO_LOGIC, text: L("M02_CheckNoLogic") },
|
||||
],
|
||||
value: fixed ? NO_LOGIC : "",
|
||||
compact: true,
|
||||
ariaLabel: L("M02_CheckNoLogic"),
|
||||
onChange: (v) => ctx.out(row, v),
|
||||
});
|
||||
out.select.disabled = ctx.readOnly;
|
||||
out.select.dataset.checkOut = String(row.차례);
|
||||
out.root.title = L("M02_CheckNoLogicHint");
|
||||
kids.push(out.root);
|
||||
}
|
||||
return el("td", { className: "m02-hopyo__acts", children: kids });
|
||||
}
|
||||
|
||||
const rowOf = (
|
||||
row: JudgeRow,
|
||||
ctx: CheckCtx,
|
||||
picks: DraftPick[],
|
||||
cells: HTMLElement[],
|
||||
): HTMLElement =>
|
||||
// prettier-ignore
|
||||
el("tr", {
|
||||
className: "m02-labrow__row m02-hopyo__line",
|
||||
attrs: { "data-line": String(row.차례), "data-verdict": row.판정 },
|
||||
children: [
|
||||
el("td", { children: [ctx.kit.okChip(row, picks)] }),
|
||||
el("td", { className: "m02-labrow__no", children: [ctx.kit.rowJump(row)] }),
|
||||
...cells,
|
||||
actsCell(row, ctx, picks),
|
||||
],
|
||||
});
|
||||
|
||||
/** 단산 줄 결과 칩(793-3b) — SK 고른 것 표지 · 없으면 블록 풀이 판정 · 단산 아니면 판정 칩 */
|
||||
function verdictOf(row: JudgeRow, ctx: CheckCtx): HTMLElement[] {
|
||||
if (row.번 === undefined) return [ctx.kit.verdict(row)];
|
||||
const got = rowChips(ctx.kit.picksOf(row));
|
||||
const list = got ?? (row.풀이?.판정 ? [row.풀이.판정] : []);
|
||||
if (!list.length) return [ctx.kit.verdict(row)];
|
||||
return list.map((text) => {
|
||||
const tone = text.startsWith("입력 필요") ? "danger" : text.includes("확정") || text.startsWith("가져온") ? "success" : text.includes("근접") ? "warning" : "info"; // prettier-ignore
|
||||
const b = createBadge(text, tone);
|
||||
b.dataset.judgeResult = text;
|
||||
return b;
|
||||
});
|
||||
}
|
||||
|
||||
const kindCell = (row: JudgeRow, ctx: CheckCtx): HTMLElement =>
|
||||
el("td", {
|
||||
className: "m02-labrow__kind",
|
||||
attrs: { title: row.근거 },
|
||||
children: [bar([...row.갈래.map((k) => createBadge(k, "neutral")), ...verdictOf(row, ctx)])],
|
||||
});
|
||||
|
||||
function logicRow(row: JudgeRow, ctx: CheckCtx): HTMLElement[] {
|
||||
const picks = ctx.kit.picksOf(row);
|
||||
const p = ctx.price(row);
|
||||
const got = priced(p);
|
||||
const wait = waiting(p);
|
||||
const unit = picks[0]?.단위 || row.단위 || "";
|
||||
const tr = rowOf(row, ctx, picks, [
|
||||
kindCell(row, ctx),
|
||||
el("td", { className: "m02-labrow__name m02-hopyo__parts", children: [ctx.kit.chips(row, picks), ...notes(p)] }), // prettier-ignore
|
||||
el("td", {
|
||||
className: "m02-labrow__alias",
|
||||
attrs: { title: `${row.이름 ?? ""} ${row.규격 ?? ""}`.trim() },
|
||||
children: [row.이름 ?? "", ...(row.규격 ? [small(row.규격)] : [])],
|
||||
}),
|
||||
qtyCell(row, ctx, picks),
|
||||
el("td", { className: "m02-labrow__unit", text: unit }),
|
||||
...COSTS.map(
|
||||
(c) =>
|
||||
moneyCell(row.단가?.[c] ?? null, got ? perUnit(got.금액?.[c], row) : null, wait, tc(COST_HEAD[c])), // prettier-ignore
|
||||
),
|
||||
el("td", { className: "m02-hopyo__compare", text: compareText(row, picks) }),
|
||||
]);
|
||||
const body = ctx.kit.solved(row);
|
||||
if (!body) return [tr];
|
||||
const wide = el("td", { children: [body], attrs: { colspan: "15" } });
|
||||
return [tr, el("tr", { className: "m02-hopyo__solved", children: [wide] })];
|
||||
}
|
||||
|
||||
function matRow(row: JudgeRow, ctx: CheckCtx): HTMLElement {
|
||||
const picks = ctx.kit.picksOf(row);
|
||||
const p = ctx.price(row);
|
||||
const got = priced(p);
|
||||
const wait = waiting(p);
|
||||
const mats = got?.자재 ?? [];
|
||||
const first = mats[0];
|
||||
const ours = mats.length ? mats.reduce((s, m) => s + (m.반영 ?? 0), 0) : null;
|
||||
const cell = (v: number | null | undefined): string =>
|
||||
wait ? "…" : v === null || v === undefined ? "—" : formatPrice(v);
|
||||
const pk = picks[0];
|
||||
const apart = apartRow(row) || !!pk?.별도계상;
|
||||
return rowOf(row, ctx, picks, [
|
||||
el("td", { className: "m02-labrow__code", text: picks.map((x) => x.키 ?? "").filter(Boolean).join(" · ") || "—" }), // prettier-ignore
|
||||
el("td", {
|
||||
className: "m02-labrow__name m02-hopyo__parts",
|
||||
children: [ctx.kit.chips(row, picks), small(`${L("M02_JudgeColExcel")} ${row.이름 ?? ""}`.trim()), ...notes(p)], // prettier-ignore
|
||||
}),
|
||||
el("td", { className: "m02-labrow__spec", text: pk?.규격 || row.규격 || "", attrs: { title: pk?.규격 || row.규격 || "" } }), // prettier-ignore
|
||||
qtyCell(row, ctx, picks),
|
||||
el("td", { className: "m02-labrow__unit", text: pk?.단위 || row.단위 || "" }),
|
||||
...markCells({ 별도계상: apart ? "별도계상" : "직접계산" }),
|
||||
...PRICE_SLOTS.map((slot) =>
|
||||
el("td", {
|
||||
className: `m02-labrow__num m02-labrow__price${first?.기준칸 === slot ? " is-base-price" : ""}`,
|
||||
text: cell(first?.가격?.[slot]),
|
||||
attrs: { "data-label": slot, "data-price-slot": slot },
|
||||
}),
|
||||
),
|
||||
moneyCell(row.단가?.계 ?? null, got ? perUnit(ours, row) : null, wait, tc("Labor_AppliedSum")),
|
||||
]);
|
||||
}
|
||||
|
||||
/** 머리 칸 — 글 · 새 기능이면 「새」 배지 */
|
||||
const head = (text: string, fresh = false): HTMLElement =>
|
||||
el("th", { children: fresh ? [text, " ", newBadge()] : [text] });
|
||||
|
||||
const grid = (heads: HTMLElement[], rows: HTMLElement[], kind: string): HTMLElement =>
|
||||
el("div", {
|
||||
className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head",
|
||||
children: [
|
||||
el("table", {
|
||||
className: "ui-general-block__table m02-labrow m02-hopyo__table m02-hopyo__lines",
|
||||
attrs: { "data-hopyo-table": kind },
|
||||
children: [
|
||||
el("thead", { children: [el("tr", { children: heads })] }),
|
||||
el("tbody", { children: rows }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
/** 줄 표 둘 — 로직 표 · 자재 표(줄 없는 표는 안 그림) */
|
||||
export function checkTables(rows: JudgeRow[], ctx: CheckCtx): HTMLElement[] {
|
||||
const logic = rows.filter((r) => tableOf(r, ctx.kit.picksOf(r)) === "logic");
|
||||
const mat = rows.filter((r) => !logic.includes(r));
|
||||
const first = (): HTMLElement[] => [head(L("M02_JudgeColOk"), true), head(L("M02_JudgeColRow"))]; // 표마다 새 노드
|
||||
const out: HTMLElement[] = [];
|
||||
if (logic.length)
|
||||
out.push(
|
||||
grid(
|
||||
[
|
||||
...first(),
|
||||
head(`${tc("Sub")} · ${L("M02_JudgeColVerdict")}`, true),
|
||||
head(`${tc("Col_Name")} · ${L("M02_JudgeColPick")}`, true),
|
||||
head(tc("Col_Alias")),
|
||||
head(tc("Labor_QtyCol"), true),
|
||||
head(tc("Col_Unit")),
|
||||
...COSTS.map((c) => head(tc(COST_HEAD[c]))),
|
||||
head(L("M02_CheckCompare")),
|
||||
head(""),
|
||||
],
|
||||
logic.flatMap((r) => logicRow(r, ctx)),
|
||||
"logic",
|
||||
),
|
||||
);
|
||||
if (mat.length)
|
||||
out.push(
|
||||
el("h4", { className: "m02-hopyo__subhead", text: L("M02_CheckMat") }),
|
||||
grid(
|
||||
[
|
||||
...first(),
|
||||
head(tc("Labor_Code")),
|
||||
head(`${tc("Col_Name")} · ${L("M02_JudgeColPick")}`, true),
|
||||
head(tc("Labor_Spec")),
|
||||
head(tc("Labor_QtyCol"), true),
|
||||
head(tc("Labor_Unit")),
|
||||
...markHeads().map((t) => head(t)),
|
||||
...PRICE_SLOTS.map((t) => head(t)),
|
||||
head(`${L("M02_JudgeColExcel")} / ${L("M02_JudgeColOurs")} ${tc("Labor_AppliedSum")}`),
|
||||
head(""),
|
||||
],
|
||||
mat.map((r) => matRow(r, ctx)),
|
||||
"mat",
|
||||
),
|
||||
);
|
||||
return out;
|
||||
}
|
||||
@@ -2,9 +2,9 @@
|
||||
* M02_MasterTemplete_Hopyo_Import.ts
|
||||
* 일위대가 불러오기 본 칸 보기(PLAN 785-4 · 요구 642-11 — 마법사 `Hopyo_Wizard` 갈음 · 가운데 상세 페이지 없음) —
|
||||
* 왼쪽 [불러오기] → 층 · 파일 고름 → 올림(`Hopyo_Side`) → 바로 이 보기:
|
||||
* · 머리 막대 = 엑셀 보기 몸(`Hopyo_Sheet` — 시트 · 호표 · 검산 · 제안)의 머리에 층 · [끝내기]를 덧붙임
|
||||
* · 아래 = 엑셀(탭 셋) + 오른쪽 호표 · 단가산출 목록(`Hopyo_Sheet` · `Hopyo_SheetSk`) · 만들기 = 목록 아래 [옮기기]
|
||||
* · [끝내기] = 물음(확인하고 안 옮긴 수 · 안 옮긴 단가산출 알림) → 서버가 이번 HP · 격자 · 블록 풀이 저장본 지움 ·
|
||||
* · 머리 막대 = 엑셀 보기 몸(`Hopyo_Sheet` — 시트 · 호표 · 검산 · 연결)의 머리에 층 · [끝내기]를 덧붙임
|
||||
* · 아래 = 엑셀(탭 셋) + 오른쪽 호표 · 단가산출 목록(`Hopyo_Sheet` · `Hopyo_SheetSk`) · 만들기 = 호표 줄 [내 일위대가로](807-1)
|
||||
* · [끝내기] = 물음(안 만든 단가산출 알림) → 서버가 이번 HP · 격자 저장본 지움 ·
|
||||
* 만든 일위대가 · 단가산출(엑셀 사본 걷고 출처만) · 별칭 장부 남음 · 옛 「안 이은 줄」 띠는 785-7 에서 지움(판정 「고를 것」)
|
||||
* · 작업 중 = 이 화면 동안만(743-1) — 화면 떠남(`leave` · 페이지 닫음) = 이 화면에서 올리거나 이은 파일 지움 ·
|
||||
* 하루 지난 것은 서버가 지움 · 엑셀 자료는 보관하지 않음(필요하면 다시 올림)
|
||||
@@ -17,7 +17,6 @@ import { createText } from "@ui/ui_template_text";
|
||||
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
|
||||
import { deleteHopyo, type HopyoFile, type HopyoScope } from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import type { HopyoSheetBody } from "./M02_MasterTemplete_Hopyo_Sheet";
|
||||
import { draftKey, moveBody, parseDraft } from "./logic/M02_MasterTemplete_Hopyo_Picks";
|
||||
import { accessFor, type ManageLayer } from "./M02_MasterTemplete_Layer";
|
||||
import "./M02_MasterTemplete_Hopyo.css";
|
||||
|
||||
@@ -48,7 +47,7 @@ export interface HopyoImport {
|
||||
root: HTMLElement;
|
||||
/** 화면 떠남 — 이 화면에서 올리거나 이은 파일 지움(743-1) */
|
||||
leave: () => void;
|
||||
/** 확인하고 안 옮긴 판정 고름이 있음 — 다른 엑셀로 옮기기 전 물음 */
|
||||
/** 안 옮긴 고름 — 판정 고름 캐시 걷음(809-1) 뒤 늘 거짓 */
|
||||
dirty: () => boolean;
|
||||
}
|
||||
|
||||
@@ -76,17 +75,12 @@ export function buildHopyoImport(a: HopyoImportArgs): HopyoImport {
|
||||
const finish = createButton({ label: "끝내기", variant: "filled", size: 28, onClick: () => void end() }); // prettier-ignore
|
||||
finish.dataset.hopyoFinish = "";
|
||||
const layerNote = createBadge(`${layerText(layer)} 층`, "neutral");
|
||||
/** 확인하고 안 옮긴 줄 · 블록 수(판정 고름 캐시 — 옮기면 캐시에서 빠짐) */
|
||||
const unmoved = (): number =>
|
||||
moveBody(parseDraft(sessionStorage.getItem(draftKey(layer, null, file.키))), scope).수;
|
||||
|
||||
async function end(): Promise<void> {
|
||||
finish.disabled = true;
|
||||
const loose = unmoved();
|
||||
const w = body?.skCount(); // 안 옮긴 단가산출(일위대가 밖 포함) — 묻기만
|
||||
const ok = await showConfirmDialog(
|
||||
`「${file.파일}」 불러오기를 끝냄 — 올린 파일(엑셀 결과 · 격자 · 블록 풀이)을 지움 · 만든 일위대가 · 단가산출(출처만 남김)과 별칭 장부는 남음 · 같은 엑셀은 필요하면 다시 올림` +
|
||||
(loose ? ` · 확인하고 안 옮긴 줄 · 블록 ${loose} 개는 빠짐` : "") +
|
||||
(w?.블록
|
||||
? ` · ${L("M02_SkUnmoved").replace("{n}", String(w.블록)).replace("{m}", String(w.밖))}`
|
||||
: ""), // 780-2 막지 않음
|
||||
@@ -127,6 +121,6 @@ export function buildHopyoImport(a: HopyoImportArgs): HopyoImport {
|
||||
return {
|
||||
root,
|
||||
leave,
|
||||
dirty: () => unmoved() > 0,
|
||||
dirty: () => false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,39 +1,27 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_Imported.ts
|
||||
* 불러오기 단산 줄 [가져온 식으로 열기] 모달(PLAN 793-3b · 요구 646 · 647 · 652 갈래 C) —
|
||||
* [수정] 모달 단산 줄(`Hopyo_Check`) · 오른쪽 목록 단가산출 탭 블록(`Hopyo_Block`)이 엶.
|
||||
* 불러오기 단산 블록 [가져온 식으로 열기] 모달(PLAN 793-3b · 809-1 · 요구 646 · 702) —
|
||||
* 엑셀 보기 단가산출 탭 목록(`Hopyo_SheetSk`)이 엶 · 경쟁사 로직 그대로(기초로 바꾸기 없음 · 요구 703).
|
||||
* · 열기 = 서버 `…/sangeun/from-hopyo`(그 블록 → 가져온 식 SK · 만들 층 · 엑셀 단가 그대로 · 같은 내용 = 그 SK 다시 씀)
|
||||
* → 그 SK 를 읽어 우리 단가산출 화면(`Sangeun_Detail` 시트 · 고침 = 시트 그대로) + 머리 판(`Hopyo_ImportedPanel`)
|
||||
* · [우리 단가로] · [엑셀 단가로] · [이음 고르기] · [우리 기초로 바꾸기] = 서버 `…/sangeun/imported/{price|link|to-base}`
|
||||
* (화면 캐시 문서 고침 · 저장 없음) → 시트 다시 엶 · 기초로 바꿀 때 옵션 = 블록 풀이 그 기초 후보 바인딩(엑셀 값 · 기본) →
|
||||
* 블록 검산으로 입력 필요 · 단위 성격이 다르면 「환산 계수」도 입력 필요
|
||||
* · [우리 단가로] · [엑셀 단가로] · [이음 고르기] = 서버 `…/sangeun/imported/{price|link}`
|
||||
* (화면 캐시 문서 고침 · 저장 없음) → 시트 다시 엶
|
||||
* · [이음 보기](797-2) = 소요량 · 계수 후보 모달(`Hopyo_QtyLinks`) — 고르기 · 버리기 = 서버 `…/imported/{qty|qty-pick|qty-drop}`(기록만 · 금액 그대로)
|
||||
* · [저장] = 그 SK 저장(서버가 다시 셈) · [이 줄에 잇기] = 저장 뒤 단산 줄 고른 것 = 그 SK(갈래 단가산출 · 키) —
|
||||
* 확인 · [사전조합 생성]은 기존 길(자동 확인 · 옮김 없음) · 입력 필요가 비면 그 줄 확인 막힘
|
||||
* · [저장] = 그 SK 저장(서버가 다시 셈)
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton } from "@ui/ui_template_button";
|
||||
import { el, showToast } from "@ui/ui_template_elements";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { loadingError, showLoading } from "@ui/ui_template_loading";
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { askConfirm, openModal } from "@ui/ui_template_modal";
|
||||
import { call, readTemplate, saveTemplate } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { openFindPicker } from "./M02_MasterTemplete_Find_Pick";
|
||||
import { at, type HopyoScope } from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import { checkJudge, readJudgeBlock } from "./M02_MasterTemplete_Hopyo_JudgeApi";
|
||||
import { importedPanel, tx, type PanelState } from "./M02_MasterTemplete_Hopyo_ImportedPanel";
|
||||
import { importedPanel, type PanelState } from "./M02_MasterTemplete_Hopyo_ImportedPanel";
|
||||
import { openQtyLinks, type QtyRow, type QtyTally } from "./M02_MasterTemplete_Hopyo_QtyLinks";
|
||||
import { mountSangeunDetail } from "./M02_MasterTemplete_Sangeun_Detail";
|
||||
import {
|
||||
marksOf,
|
||||
skPick,
|
||||
type BasisCand,
|
||||
type ImportedDoc,
|
||||
type Money4,
|
||||
type Tier,
|
||||
} from "./logic/M02_Hopyo_Imported_Model";
|
||||
import { bare, type DraftPick } from "./logic/M02_MasterTemplete_Hopyo_Picks";
|
||||
import { type ImportedDoc, type Money4 } from "./logic/M02_Hopyo_Imported_Model";
|
||||
|
||||
const TEXT = {
|
||||
Title: ["가져온 식 단가산출 — 단산{n}", "Imported build-up — block {n}"],
|
||||
@@ -41,8 +29,6 @@ const TEXT = {
|
||||
Made: ["{k} 만듦(엑셀 단가 그대로)", "{k} made (Excel prices)"],
|
||||
Save: ["저장", "Save"],
|
||||
Saved: ["{k} 저장함", "{k} saved"],
|
||||
Link: ["이 줄에 잇기", "Use for this line"],
|
||||
Linked: ["단산 줄 = {k} — [확인] 뒤 [사전조합 생성]", "Line uses {k} — confirm, then build"],
|
||||
Discard: ["저장 안 한 고침이 있음 — 버리고 닫을까?", "Discard unsaved edits?"],
|
||||
Close: ["닫기", "Close"],
|
||||
} as const satisfies Record<string, readonly [string, string]>;
|
||||
@@ -58,8 +44,6 @@ export interface ImportedOptions {
|
||||
/** SK 를 만들 층 */
|
||||
target: HopyoScope;
|
||||
canWrite: boolean;
|
||||
/** 단산 줄에서 열었을 때만 — [이 줄에 잇기] */
|
||||
onLink?: (pick: DraftPick) => void;
|
||||
}
|
||||
|
||||
interface Made {
|
||||
@@ -73,24 +57,8 @@ const fromHopyo = (o: ImportedOptions): Promise<Made> =>
|
||||
body: JSON.stringify({ 층: o.file.layer, ...(o.file.owner ? { 주인: o.file.owner } : {}), 키: o.key, 번: o.no }), // prettier-ignore
|
||||
});
|
||||
|
||||
/** 기초 옵션 — 블록 풀이 그 기초 후보 바인딩(엑셀 값 · 기본 · 근거 옵션) → 블록 검산 입력 필요 + 엇갈림 */
|
||||
async function bind(
|
||||
o: ImportedOptions,
|
||||
code: string,
|
||||
given: Record<string, unknown>,
|
||||
): Promise<{ 입력값: Record<string, unknown>; 필요: string[] }> {
|
||||
const blk = await readJudgeBlock(o.file, o.key, o.no, o.target).catch(() => null);
|
||||
const b = blk?.절.flatMap((s) => s.후보).find((c) => c.키 === code)?.바인딩;
|
||||
const values = { ...(b?.기본 ?? {}), ...(b?.입력값 ?? {}), ...given };
|
||||
const pick = bare({ 갈래: "기초", 키: code, 이름: code, 입력값: values, 출처: {}, 몫: null });
|
||||
const res = await checkJudge(o.file, o.key, { 블록: o.no, 고른: [pick] });
|
||||
const need = [...(res.고른[0]?.입력필요 ?? []), ...Object.keys(b?.엇갈림 ?? {})];
|
||||
return { 입력값: values, 필요: [...new Set(need)] };
|
||||
}
|
||||
|
||||
interface EditAnswer {
|
||||
문서: ImportedDoc;
|
||||
필요?: string[];
|
||||
전?: Money4 | null;
|
||||
후?: Money4 | null;
|
||||
이음없음?: { 자리: string; 이름: string }[];
|
||||
@@ -109,13 +77,9 @@ export function openImported(o: ImportedOptions): void {
|
||||
const host = el("div", { attrs: { "data-sk-body": "" } });
|
||||
let sk = "";
|
||||
let 판 = "";
|
||||
let tier: Tier | null = null;
|
||||
/** 우리 기초로 바꾸기 전 가져온 식 · 고른 기초(환산 계수 넣을 때 다시 바꿈) */
|
||||
let based: { src: ImportedDoc; code: string } | null = null;
|
||||
let dirty = false;
|
||||
let close = (): void => {};
|
||||
let handle: ReturnType<typeof mountSangeunDetail> | null = null;
|
||||
const st: PanelState = { doc: {}, unlinked: [], qty: null, price: null, need: [], busy: false, readOnly }; // prettier-ignore
|
||||
const st: PanelState = { doc: {}, unlinked: [], qty: null, price: null, busy: false, readOnly }; // prettier-ignore
|
||||
|
||||
const edit = (how: string, body: Record<string, unknown>): Promise<EditAnswer> =>
|
||||
call(at(o.target, `/sangeun/imported/${how}`), { method: "POST", body: JSON.stringify(body) });
|
||||
@@ -131,19 +95,11 @@ export function openImported(o: ImportedOptions): void {
|
||||
});
|
||||
draw();
|
||||
}
|
||||
/** 환산 계수 없이는 못 셈 — 시트 안 엶(셈 길 400 없음) · 머리 판 붉은 칸만 */
|
||||
function hold(doc: ImportedDoc): void {
|
||||
handle?.destroy();
|
||||
handle = null;
|
||||
st.doc = doc;
|
||||
host.replaceChildren(createText(tx("NeedHint"), "warning", 14));
|
||||
}
|
||||
const now = (): ImportedDoc => (handle?.getDoc() as ImportedDoc | undefined) ?? st.doc;
|
||||
|
||||
function draw(): void {
|
||||
top.replaceChildren(importedPanel(st, { price, link, toBase, pickBase, fill, qty: openQty }));
|
||||
top.replaceChildren(importedPanel(st, { price, link, qty: openQty }));
|
||||
saveBtn.disabled = readOnly || st.busy;
|
||||
linkBtn.disabled = readOnly || st.busy;
|
||||
}
|
||||
|
||||
/** 서버 고침 한 번(눌림 막음) → 시트 다시 엶 */
|
||||
@@ -157,8 +113,7 @@ export function openImported(o: ImportedOptions): void {
|
||||
try {
|
||||
const got = await edit(how, { 문서: from, ...body });
|
||||
dirty = true;
|
||||
if (got.필요?.includes("환산")) hold(got.문서);
|
||||
else mount(got.문서);
|
||||
mount(got.문서);
|
||||
return got;
|
||||
} catch (e) {
|
||||
showToast(why(e), "error");
|
||||
@@ -224,49 +179,6 @@ export function openImported(o: ImportedOptions): void {
|
||||
});
|
||||
}
|
||||
|
||||
async function toBase(code: string, cand: BasisCand | null): Promise<void> {
|
||||
st.busy = true;
|
||||
draw();
|
||||
let bound: Awaited<ReturnType<typeof bind>>;
|
||||
try {
|
||||
bound = await bind(o, code, cand?.옵션 ?? {});
|
||||
} catch (e) {
|
||||
st.busy = false;
|
||||
draw();
|
||||
return void showToast(why(e), "error");
|
||||
}
|
||||
st.busy = false;
|
||||
const src = now();
|
||||
const got = await run("to-base", { 로직: code, 입력값: bound.입력값 }, src);
|
||||
if (!got) return;
|
||||
based = { src, code };
|
||||
tier = cand?.단 ?? null;
|
||||
st.need = [...new Set([...bound.필요, ...(cand?.입력필요 ?? []), ...(got.필요 ?? [])])];
|
||||
st.unlinked = [];
|
||||
st.qty = null;
|
||||
draw();
|
||||
}
|
||||
|
||||
function pickBase(): void {
|
||||
openFindPicker({ layer: o.target.layer, owner: o.target.owner, kinds: ["logic"], logic: (x) => void toBase(x.키, null) }); // prettier-ignore
|
||||
}
|
||||
|
||||
/** 입력 필요 칸 — 옵션 = SK 입력값 · 「환산」 = SK 환산 계수 */
|
||||
function fill(name: string, value: string): void {
|
||||
const doc = now();
|
||||
const v = value.trim();
|
||||
dirty = true;
|
||||
if (name === "환산" && based) {
|
||||
// 가져온 식에서 다시 바꿈(바꿈 전 · 후 = 서버 정본 · 옵션은 지금 값)
|
||||
const swap = v && Number(v) > 0 ? { 계수: Number(v) } : null;
|
||||
return void run("to-base", { 로직: based.code, 입력값: doc.입력값 ?? {}, 환산: swap }, based.src); // prettier-ignore
|
||||
}
|
||||
const kept = { ...(doc.입력값 ?? {}) };
|
||||
if (v) kept[name] = v;
|
||||
else delete kept[name];
|
||||
mount({ ...doc, 입력값: kept });
|
||||
}
|
||||
|
||||
async function save(): Promise<boolean> {
|
||||
try {
|
||||
const doc = now();
|
||||
@@ -284,20 +196,7 @@ export function openImported(o: ImportedOptions): void {
|
||||
|
||||
const saveBtn = createButton({ label: t("Save"), variant: "filled", onClick: () => void save() });
|
||||
saveBtn.dataset.importedSave = "";
|
||||
const linkBtn = createButton({
|
||||
label: t("Link"),
|
||||
variant: "ghost",
|
||||
onClick: async () => {
|
||||
if (dirty && !(await save())) return;
|
||||
o.onLink!(skPick(sk, st.doc, st.need, marksOf(st.doc, tier)));
|
||||
showToast(t("Linked").replace("{k}", sk), "info");
|
||||
dirty = false;
|
||||
close();
|
||||
},
|
||||
});
|
||||
linkBtn.dataset.importedLinkRow = "";
|
||||
linkBtn.hidden = !o.onLink;
|
||||
saveBtn.disabled = linkBtn.disabled = true;
|
||||
saveBtn.disabled = true;
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const stop = showLoading(root);
|
||||
@@ -318,13 +217,13 @@ export function openImported(o: ImportedOptions): void {
|
||||
}
|
||||
}
|
||||
|
||||
({ close } = openModal({
|
||||
openModal({
|
||||
title: t("Title").replace("{n}", String(o.no)),
|
||||
closeLabel: t("Close"),
|
||||
width: "1100px",
|
||||
wide: true,
|
||||
dialogClass: "m02-modal m02-hopyo__imported-modal",
|
||||
actions: [saveBtn, linkBtn],
|
||||
actions: [saveBtn],
|
||||
beforeClose: async () => {
|
||||
if (dirty && !readOnly && !(await askConfirm({ message: t("Discard"), danger: true })))
|
||||
return false;
|
||||
@@ -332,6 +231,6 @@ export function openImported(o: ImportedOptions): void {
|
||||
return true;
|
||||
},
|
||||
mount: (body) => body.append(root),
|
||||
}));
|
||||
});
|
||||
void load();
|
||||
}
|
||||
|
||||
@@ -5,42 +5,30 @@
|
||||
* 바꾸기 전후 금액 · [원본 글 보기]
|
||||
* · 줄 2 = 이음 없는 자원 = 표지 + [이음 고르기]
|
||||
* · 줄 2-2 = 소요량 · 계수 이음(797-2 · 후보 집계 + [이음 보기] → `Hopyo_QtyLinks` 모달)
|
||||
* · 줄 3 = 근거 제안 세 단(확정 · 근접(차이 · 까닭) · 사람 고름 · 793-3a) — 후보마다 [우리 기초로 바꾸기] · [기초 고르기]
|
||||
* · 우리 기초로 바꾼 뒤 = 전후 차이 띠 · 「입력 필요」 붉은 칸(채우기 전엔 그 줄 확인 · 옮김 막힘)
|
||||
* · 경쟁사 로직 그대로 — 기초로 바꾸기 · 근거 기초 제안 없음(809-1 · 요구 703)
|
||||
* 부품 = 단추 · 배지 · 글 · 입력 · 띠 · 표 · 모달(날 단추 · 입력 없음) · 서버 길은 부르는 쪽.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createBadge } from "@ui/ui_template_badge";
|
||||
import { createBanner } from "@ui/ui_template_banner";
|
||||
import { createButton } from "@ui/ui_template_button";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { createInputField } from "@ui/ui_template_input";
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { createTable } from "@ui/ui_template_table";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { createToolbar } from "@ui/ui_template_toolbar";
|
||||
import { formatMoney } from "../common_util/common_util_money";
|
||||
import { newBadge } from "./M02_MasterTemplete_Hopyo_CheckRows";
|
||||
import { tallyBadges, type QtyTally } from "./M02_MasterTemplete_Hopyo_QtyLinks";
|
||||
import {
|
||||
gapText,
|
||||
isImported,
|
||||
needValues,
|
||||
reasonText,
|
||||
stillNeed,
|
||||
tiers,
|
||||
TIERS,
|
||||
wrongLines,
|
||||
type BasisCand,
|
||||
type ImportedDoc,
|
||||
type Money4,
|
||||
type Tier,
|
||||
} from "./logic/M02_Hopyo_Imported_Model";
|
||||
|
||||
const TEXT = {
|
||||
Imported: ["가져온 식", "Imported formula"],
|
||||
Based: ["우리 기초로 바꿈", "Converted to our basis"],
|
||||
Same: ["검산 같음", "Check matches"],
|
||||
Diff: ["검산 다름 {n}", "Check differs {n}"],
|
||||
NoCheck: ["검산 없음", "No check"],
|
||||
@@ -55,19 +43,7 @@ const TEXT = {
|
||||
Now: ["지금", "Now"],
|
||||
Unlinked: ["이음 없는 자원 {n}", "Unlinked resources {n}"],
|
||||
Link: ["이음 고르기", "Pick link"],
|
||||
Basis3: ["근거 제안", "Basis suggestions"],
|
||||
확정: ["확정", "Confirmed"],
|
||||
근접: ["근접", "Near"],
|
||||
사람: ["사람 고름", "Human pick"],
|
||||
None: ["없음", "None"],
|
||||
Qty: ["품 {v}", "Qty {v}"],
|
||||
NoCand: ["근거 제안 없음 — [기초 고르기]로 고름", "No suggestion — pick a basis"],
|
||||
ToBase: ["우리 기초로 바꾸기", "Convert to our basis"],
|
||||
PickBase: ["기초 고르기", "Pick basis"],
|
||||
Gap: ["바꾸기 전 {a} → 후 {b} ({d})", "Before {a} → after {b} ({d})"],
|
||||
Need: ["입력 필요", "Input needed"],
|
||||
Swap: ["환산 계수(기초 1 단위 = 이 단가산출 몇 단위)", "Unit factor (1 basis unit = n of this)"],
|
||||
NeedHint: ["채워야 이 줄을 확인 · 옮김", "Fill to confirm and move this line"],
|
||||
QtyLinks: ["소요량 · 계수 이음 {n}", "Quantity / coefficient links {n}"],
|
||||
QtyOpen: ["이음 보기", "Show links"],
|
||||
Close: ["닫기", "Close"],
|
||||
@@ -83,8 +59,6 @@ export interface PanelState {
|
||||
qty: QtyTally | null;
|
||||
/** 마지막 단가 바꿈 전후 */
|
||||
price: { 전: Money4 | null; 후: Money4 | null } | null;
|
||||
/** 우리 기초로 바꾼 뒤 입력 필요 칸 */
|
||||
need: string[];
|
||||
busy: boolean;
|
||||
readOnly: boolean;
|
||||
}
|
||||
@@ -92,9 +66,6 @@ export interface PanelState {
|
||||
export interface PanelActs {
|
||||
price: (basis: "엑셀" | "우리") => void;
|
||||
link: (spot: string, name: string) => void;
|
||||
toBase: (code: string, cand: BasisCand | null) => void;
|
||||
pickBase: () => void;
|
||||
fill: (name: string, value: string) => void;
|
||||
qty: () => void;
|
||||
}
|
||||
|
||||
@@ -144,59 +115,13 @@ function checkBadge(doc: ImportedDoc): HTMLElement {
|
||||
return b;
|
||||
}
|
||||
|
||||
function cand(c: BasisCand, s: PanelState, a: PanelActs): HTMLElement {
|
||||
const opts = Object.entries(c.옵션 ?? {})
|
||||
.map(([k, v]) => `${k} ${String(v)}`)
|
||||
.join(" · ");
|
||||
const go = createButton({ label: tx("ToBase"), size: 24, variant: "ghost", disabled: s.readOnly || s.busy, onClick: () => a.toBase(c.기초, c) }); // prettier-ignore
|
||||
go.dataset.importedToBase = c.기초;
|
||||
return el("div", {
|
||||
className: "ui-toolbar m02-judge__wrap",
|
||||
attrs: { "data-basis-cand": c.기초, "data-basis-tier": c.단 },
|
||||
children: [
|
||||
createBadge(`${c.기초}${c.이름 ? ` ${c.이름}` : ""}`, "accent"),
|
||||
...(c.원문번호 ? [muted(c.원문번호)] : []),
|
||||
...(opts ? [muted(opts)] : []),
|
||||
...(c.품검산 ? [createBadge(tx("Qty").replace("{v}", c.품검산), c.품검산 === "같음" ? "success" : "warning")] : []), // prettier-ignore
|
||||
...(c.입력필요?.length ? [createBadge(`${tx("Need")} ${c.입력필요.length}`, "danger")] : []),
|
||||
...(reasonText(c) ? [muted(reasonText(c))] : []),
|
||||
go,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** 근거 제안 세 단 */
|
||||
function basisPart(s: PanelState, a: PanelActs): HTMLElement {
|
||||
const by = tiers(s.doc.가져온?.근거후보);
|
||||
const any = TIERS.some((k) => by[k].length);
|
||||
const pick = createButton({ label: tx("PickBase"), size: 24, variant: "ghost", disabled: s.readOnly || s.busy, onClick: a.pickBase }); // prettier-ignore
|
||||
pick.dataset.importedPickBase = "";
|
||||
const tierRow = (k: Tier): HTMLElement =>
|
||||
el("div", {
|
||||
className: "ui-toolbar m02-judge__wrap",
|
||||
attrs: { "data-basis-tiers": k },
|
||||
children: [
|
||||
createBadge(`${tx(k)} ${by[k].length}`, k === "확정" ? "success" : k === "근접" ? "warning" : "neutral"), // prettier-ignore
|
||||
...(by[k].length ? by[k].map((c) => cand(c, s, a)) : [muted(tx("None"))]),
|
||||
],
|
||||
});
|
||||
return el("div", {
|
||||
attrs: { "data-imported-basis": "" },
|
||||
children: [
|
||||
createToolbar({ items: [newBadge(), createText(tx("Basis3"), "muted", 14), pick] }),
|
||||
...(any ? TIERS.map(tierRow) : [muted(tx("NoCand"))]),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** 머리 판 */
|
||||
export function importedPanel(s: PanelState, a: PanelActs): HTMLElement {
|
||||
const meta = s.doc.가져온 ?? {};
|
||||
const src = meta.원본;
|
||||
const here = isImported(s.doc);
|
||||
const head: (HTMLElement | "divider")[] = [
|
||||
newBadge(),
|
||||
createBadge(here ? tx("Imported") : tx("Based"), here ? "accent" : "info"),
|
||||
createBadge(tx("Imported"), "accent"),
|
||||
muted([src?.파일, src?.시트, src?.행?.join("~")].filter(Boolean).join(" · ")),
|
||||
checkBadge(s.doc),
|
||||
];
|
||||
@@ -237,33 +162,5 @@ export function importedPanel(s: PanelState, a: PanelActs): HTMLElement {
|
||||
const tag = createBadge(tx("QtyLinks").replace("{n}", String(s.qty.자리)), "info");
|
||||
kids.push(createToolbar({ items: [tag, ...tallyBadges(s.qty), open] }));
|
||||
}
|
||||
const gone = meta.바꿈;
|
||||
if (!here && gone) {
|
||||
const g = tx("Gap").replace("{a}", won(gone.전)).replace("{b}", won(gone.후));
|
||||
kids.push(createBanner({ tone: "info", text: g.replace("{d}", gapText(gone.전, gone.후)) })); // prettier-ignore
|
||||
}
|
||||
const values = needValues(s.doc);
|
||||
const left = stillNeed(s.need, values);
|
||||
if (s.need.length) {
|
||||
const fields = s.need.map((k) => {
|
||||
const label = k === "환산" ? tx("Swap") : k;
|
||||
const f = createInputField({ label, size: 28, value: String(values[k] ?? "") });
|
||||
f.input.dataset.importedNeed = k;
|
||||
f.input.disabled = s.readOnly || s.busy;
|
||||
if (left.includes(k)) f.setError(tx("Need"));
|
||||
f.input.addEventListener("change", () => a.fill(k, f.input.value));
|
||||
return f.root;
|
||||
});
|
||||
kids.push(
|
||||
el("div", {
|
||||
attrs: { "data-imported-needs": String(left.length) },
|
||||
children: [
|
||||
createToolbar({ items: [createBadge(`${tx("Need")} ${left.length}`, left.length ? "danger" : "success"), muted(tx("NeedHint"))] }), // prettier-ignore
|
||||
el("div", { className: "ui-toolbar m02-judge__wrap", children: fields }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
kids.push(basisPart(s, a));
|
||||
return el("div", { className: "m02-std-sheet__body", attrs: { "data-imported-panel": "" }, children: kids }); // prettier-ignore
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
/* 엑셀 불러오기 판정 화면(PLAN 785-6) — 줄 판 · 블록 판 자리 잡기만(단추 · 표 · 배지 · 칩은 부품 상자) */
|
||||
.m02-judge {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-judge__sec {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.m02-judge__cand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding-left: var(--spacing-8);
|
||||
border-left: 2px solid var(--color-accent-soft);
|
||||
}
|
||||
|
||||
.m02-judge__cand.is-off {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.m02-judge__wrap {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_JudgeApi.ts
|
||||
* 엑셀 불러오기 새 판정 길(PLAN 785-5 · 785-6 · 요구 641 · 642 · 설계 642-A 2-9) — 타입 + 부름만.
|
||||
* · GET `/layers/{파일 층}/hopyo-judge/{키}` = 호표 · 블록 목록(판정 수 · 만든)
|
||||
* · GET `/layers/{파일 층}/hopyo-judge/{키}/{번}` = 호표 줄 판(785-3 줄 판 + 단산 줄 `풀이`)
|
||||
* · GET `/layers/{파일 층}/hopyo-judge/{키}/block/{번}` = 블록 풀이(`Solve.block` 꼴 + 이름 · 근거 · 만든)
|
||||
* · POST `/layers/{파일 층}/hopyo/{키}/check` = 고른 것 검산(저장 없음 · 서버 셈)
|
||||
* · POST `/layers/{만들 층}/hopyo/{키}/move` = 확인한 것 옮김(서버가 다시 셈 · 금액 · 품 칸 안 보냄)
|
||||
* 판정 · 금액은 서버 — 화면은 보이기 · 고르기만(옛 `Hopyo_Api.ts` 는 785-7 에서 걷음).
|
||||
* ========================================================================== */
|
||||
|
||||
import { call } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { at, type HopyoScope, type Money } from "./M02_MasterTemplete_Hopyo_Api";
|
||||
|
||||
export type PickKind =
|
||||
"기초" | "단가산출" | "일위대가" | "인력" | "재료" | "유가" | "기계" | "율" | "고정";
|
||||
export type RowVerdict = "제안 있음" | "2차 필요" | "후보 여럿" | "후보 없음";
|
||||
export type BlockVerdict = "기초로 풂" | "기초+덧" | "줄 조합" | "고를 것";
|
||||
|
||||
/** 값 출처 — 길 B1~B6 · 사용자 · 기본 */
|
||||
export interface PickSource {
|
||||
길: string;
|
||||
행?: number | null;
|
||||
글?: string;
|
||||
}
|
||||
|
||||
/** 고른 것(785-3 꼴 + 배) */
|
||||
export interface Pick {
|
||||
갈래: PickKind;
|
||||
키: string | null;
|
||||
이름: string;
|
||||
규격?: string;
|
||||
단위?: string;
|
||||
단가?: Partial<Money> | number | null;
|
||||
입력값: Record<string, unknown>;
|
||||
출처: Record<string, PickSource>;
|
||||
몫: number | null;
|
||||
비목?: string;
|
||||
기준?: unknown;
|
||||
별도계상?: boolean;
|
||||
층?: string;
|
||||
주인?: string | null;
|
||||
배?: number | null;
|
||||
}
|
||||
|
||||
export interface Binding {
|
||||
입력값: Record<string, unknown>;
|
||||
출처: Record<string, PickSource>;
|
||||
엇갈림: Record<string, { 값: unknown; 길: string; 행: number | null }[]>;
|
||||
안씀: string[];
|
||||
입력필요: string[];
|
||||
비움: string[];
|
||||
기본: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type PairVerdict = "품 같음" | "품 다름" | "입력 필요" | "엑셀만" | "우리만";
|
||||
|
||||
/** 줄 대조 — 엑셀 단위 줄 ↔ 우리 줄 */
|
||||
export interface PairLine {
|
||||
엑셀: { 묶음: string; 이름: string; 품: number | null; 행: number | null; 별도?: boolean } | null;
|
||||
우리: { 이름: string; 종류: string; 단위: string; 품: number | null; 까닭?: string } | null;
|
||||
판정: PairVerdict;
|
||||
표지?: string;
|
||||
}
|
||||
|
||||
export type CandMoney = Money | { 계: null; 까닭: string };
|
||||
|
||||
/** 절 기초 후보 하나 */
|
||||
export interface BaseCand {
|
||||
키: string;
|
||||
이름: string;
|
||||
바인딩: Binding;
|
||||
줄: PairLine[];
|
||||
셈: Partial<Record<PairVerdict, number>>;
|
||||
판정: string;
|
||||
금액: CandMoney;
|
||||
}
|
||||
|
||||
export interface Section {
|
||||
제목: string;
|
||||
번?: string | null;
|
||||
판정: "기초 풂" | "후보 여럿" | "기초 없음";
|
||||
고름: string | null;
|
||||
후보: BaseCand[];
|
||||
/** 절 몫(곱수) — 수 · 옛 꼴 {값} */
|
||||
몫?: number | { 값: number | null } | null;
|
||||
}
|
||||
|
||||
/** 덧 줄(엑셀만 줄 → 덧 줄 후보 · `Units` 잇기) */
|
||||
export interface ExtraLine {
|
||||
절: string;
|
||||
행: number | null;
|
||||
이름: string;
|
||||
방법?: string;
|
||||
후보?: {
|
||||
자재?: string;
|
||||
키?: string;
|
||||
노임?: string;
|
||||
이름?: string;
|
||||
규격?: string;
|
||||
단위?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface Solved {
|
||||
번: number;
|
||||
판정: BlockVerdict;
|
||||
고를수: number;
|
||||
절: Section[];
|
||||
덧: ExtraLine[];
|
||||
}
|
||||
|
||||
export interface Basis {
|
||||
표지: string | null;
|
||||
글: string;
|
||||
출처: [string, string][];
|
||||
}
|
||||
|
||||
export interface JudgeBlock extends Solved {
|
||||
이름: string;
|
||||
단위: string;
|
||||
부름: number[];
|
||||
근거: Basis | null;
|
||||
만든: string | null;
|
||||
/** 엑셀 블록 금액(참고 · 길이 실을 때만) */
|
||||
금액?: Partial<Money> | null;
|
||||
}
|
||||
|
||||
/** 호표 줄 판 한 줄(785-3 + 엑셀 글 · 단산 줄 풀이) */
|
||||
export interface JudgeRow {
|
||||
차례: number;
|
||||
행: number | null;
|
||||
갈래: PickKind[];
|
||||
근거: string;
|
||||
번?: number;
|
||||
후보: Pick[];
|
||||
판정: RowVerdict;
|
||||
수량: { 엑셀: number | null; 배: number | null; 갈래: string };
|
||||
이름?: string;
|
||||
규격?: string;
|
||||
단위?: string;
|
||||
단가?: Partial<Money> | null;
|
||||
풀이?: Solved | null;
|
||||
/** 별칭 장부 짝(짝 = 지난번 고른 것이 후보 · 우리에 없음 · 안 씀) */
|
||||
장부?: { 갈래: string; 층?: string | null; 제안?: unknown; 규격다름?: boolean } | null;
|
||||
}
|
||||
|
||||
export interface JudgeBrief {
|
||||
번: number;
|
||||
이름: string;
|
||||
규격: string;
|
||||
단위: string;
|
||||
줄수: number;
|
||||
판정: Partial<Record<RowVerdict, number>>;
|
||||
만든: string | null;
|
||||
}
|
||||
|
||||
export interface JudgeBlockBrief {
|
||||
번: number;
|
||||
이름: string;
|
||||
단위: string;
|
||||
부름: number[];
|
||||
표지: string | null;
|
||||
만든: string | null;
|
||||
/** 블록 풀이 파생 저장본(셈 전 = 없음) */
|
||||
판정?: BlockVerdict | null;
|
||||
고를수?: number | null;
|
||||
}
|
||||
|
||||
export interface JudgeList {
|
||||
키: string;
|
||||
파일: string;
|
||||
/** 블록 풀이 뒤 셈 중(블록 판정 · 고를수 None) */
|
||||
셈중?: boolean;
|
||||
호표: JudgeBrief[];
|
||||
블록: JudgeBlockBrief[];
|
||||
}
|
||||
|
||||
export interface JudgeOne {
|
||||
번: number;
|
||||
이름: string;
|
||||
규격: string;
|
||||
단위: string;
|
||||
만든: string | null;
|
||||
줄: JudgeRow[];
|
||||
}
|
||||
|
||||
export interface CheckBody {
|
||||
호표?: number;
|
||||
차례?: number;
|
||||
블록?: number;
|
||||
고른: Pick[];
|
||||
}
|
||||
|
||||
export interface CheckOne {
|
||||
차례: number;
|
||||
갈래: PickKind;
|
||||
키: string | null;
|
||||
입력값: Record<string, unknown>;
|
||||
입력필요: string[];
|
||||
줄?: PairLine[];
|
||||
판정: string;
|
||||
금액: CandMoney;
|
||||
}
|
||||
|
||||
export interface CheckResult {
|
||||
고른: CheckOne[];
|
||||
수량?: { 엑셀: number | null; 배: number | null; 몫: number | null };
|
||||
}
|
||||
|
||||
export interface MoveBody {
|
||||
층: string;
|
||||
주인?: string | null;
|
||||
호표: Record<string, Record<string, Pick[]>>;
|
||||
블록: Record<string, Pick[]>;
|
||||
/** 이미 옮긴 호표 · 블록 덮기(아니면 409) */
|
||||
덮음?: boolean;
|
||||
}
|
||||
|
||||
export interface MoveBlocked {
|
||||
호표?: number;
|
||||
블록?: number;
|
||||
차례?: number;
|
||||
까닭: string;
|
||||
칸: string[];
|
||||
}
|
||||
|
||||
export interface MoveResult {
|
||||
만듦: { 번: number; 키: string; 이름: string; 다시씀?: boolean; 재사용?: boolean }[];
|
||||
단가산출: { 번: number; 키: string; 이름: string }[];
|
||||
막힘: MoveBlocked[];
|
||||
하위: { 번: number; 키: string; 새로: boolean }[];
|
||||
장부: { 씀: number };
|
||||
}
|
||||
|
||||
const enc = encodeURIComponent;
|
||||
const toQuery = (to?: HopyoScope): string =>
|
||||
to ? `?to=${to.layer}${to.owner ? `&to_owner=${enc(to.owner)}` : ""}` : "";
|
||||
|
||||
export const readJudge = (scope: HopyoScope, key: string, to?: HopyoScope): Promise<JudgeList> =>
|
||||
call(at(scope, `/hopyo-judge/${enc(key)}${toQuery(to)}`));
|
||||
|
||||
export const readJudgeOne = (
|
||||
scope: HopyoScope,
|
||||
key: string,
|
||||
no: number,
|
||||
to?: HopyoScope,
|
||||
): Promise<JudgeOne> => call(at(scope, `/hopyo-judge/${enc(key)}/${no}${toQuery(to)}`));
|
||||
|
||||
export const readJudgeBlock = (
|
||||
scope: HopyoScope,
|
||||
key: string,
|
||||
no: number,
|
||||
to?: HopyoScope,
|
||||
): Promise<JudgeBlock> => call(at(scope, `/hopyo-judge/${enc(key)}/block/${no}${toQuery(to)}`));
|
||||
|
||||
/** 검산(저장 없음) — `signal` = 옛 요청 끊기 */
|
||||
export const checkJudge = (
|
||||
scope: HopyoScope,
|
||||
key: string,
|
||||
body: CheckBody,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CheckResult> =>
|
||||
call(at(scope, `/hopyo/${enc(key)}/check`), {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
});
|
||||
|
||||
/** 옮기기 — `target` = 만들 층 · 몸 `층` · `주인` = 파일 층 */
|
||||
export const moveJudge = (target: HopyoScope, key: string, body: MoveBody): Promise<MoveResult> =>
|
||||
call(at(target, `/hopyo/${enc(key)}/move`), { method: "POST", body: JSON.stringify(body) });
|
||||
@@ -1,306 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_Rows.ts
|
||||
* 엑셀 불러오기 줄 칸 부품 · 옮기기(PLAN 785-6 · 786-2 · 요구 641 · 642 · 644 · 설계 642-A 2-3 · 2-4 · 2-6).
|
||||
* · `createRowKit` = 호표 하나의 줄 칸 부품 — [수정] 모달 「확인 — 제N호표」(`Hopyo_Check` · `Hopyo_CheckRows`)가
|
||||
* 옛 확인 창 표 칸 안에 담음: 1차 판정 칩 · 후보 복수 고름 칩(기초 · 단가산출 · 일위대가 · 인력 · 재료 · 유가 ·
|
||||
* 기계 · 율 · 고정) · 단산 줄 [블록 풀이] 펼침(`Hopyo_Block.solvedBody` · 절마다 기초 · 입력값 칸 · 출처 꼬리표) +
|
||||
* 「단가산출 통째」 칩(단산N → SK) · 단위 환산 칸(`logic/Hopyo_Unit` · 같은 단위 = 숨김) · [확인]
|
||||
* · `createJudgeMove` = 오른쪽 판 아래 [옮기기 (n)] · `runMove` = 옮기기 한 번(모달 [사전조합 생성]도 이 길 · 이 호표만)
|
||||
* · 이미 옮긴 것(409) = 물음 한 번 뒤 덮음
|
||||
* · 고름 · 확인 = 캐시(`createJudgeState`) · 자동 확인 · 자동 옮김 없음 · 옮김 = 서버가 정본으로 다시 셈
|
||||
* (785-6 시트 아래 줄 판 몸은 786-2 에서 모달로 옮김)
|
||||
* ========================================================================== */
|
||||
|
||||
import { createBadge, type BadgeTone } from "@ui/ui_template_badge";
|
||||
import {
|
||||
askConfirm,
|
||||
createButton,
|
||||
createInputField,
|
||||
el,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { createToggle } from "@ui/ui_template_toggle";
|
||||
import { formatNumber } from "../common_util/common_util_money";
|
||||
import { StaleError } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import type { HopyoScope } from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import { createJudgeState, solvedBody, type JudgeState } from "./M02_MasterTemplete_Hopyo_Block";
|
||||
import {
|
||||
checkJudge,
|
||||
moveJudge,
|
||||
type JudgeRow,
|
||||
type MoveBody,
|
||||
type MoveBlocked,
|
||||
type MoveResult,
|
||||
} from "./M02_MasterTemplete_Hopyo_JudgeApi";
|
||||
import {
|
||||
apartPick,
|
||||
bare,
|
||||
confirmable,
|
||||
editLine,
|
||||
lineOf,
|
||||
linePicks,
|
||||
missing,
|
||||
moveBody,
|
||||
putLine,
|
||||
samePick,
|
||||
togglePick,
|
||||
type DraftPick,
|
||||
} from "./logic/M02_MasterTemplete_Hopyo_Picks";
|
||||
import { unitView } from "./logic/M02_MasterTemplete_Hopyo_Unit";
|
||||
|
||||
export { createJudgeState, type JudgeState };
|
||||
|
||||
const VERDICT: Record<string, BadgeTone> = {
|
||||
"제안 있음": "success",
|
||||
"2차 필요": "info",
|
||||
"후보 여럿": "warning",
|
||||
"후보 없음": "danger",
|
||||
};
|
||||
export const show = (v: unknown): string =>
|
||||
v === null || v === undefined ? "" : typeof v === "number" ? formatNumber(v) : String(v);
|
||||
export const bar = (kids: (HTMLElement | string)[]): HTMLElement =>
|
||||
el("div", { className: "ui-toolbar m02-judge__wrap", children: kids });
|
||||
|
||||
export type SheetRole = "일위대가표" | "단가산출상세";
|
||||
|
||||
export interface RowKitOptions {
|
||||
/** 파일 층 */
|
||||
file: HopyoScope;
|
||||
key: string;
|
||||
state: JudgeState;
|
||||
no: number;
|
||||
/** 엑셀 보기 그 행(일위대가표 · 단가산출 상세) */
|
||||
onSheet?: (행: number, role: SheetRole) => void;
|
||||
readOnly?: boolean;
|
||||
/** [블록 풀이] 펼침 · 접음 — 부르는 쪽이 다시 그림 */
|
||||
redraw: () => void;
|
||||
}
|
||||
|
||||
/** 2차 줄 「단가산출 통째」 고른 것 — 단산N(같은 옮기기 블록 먼저 SK) */
|
||||
const wholeSk = (row: JudgeRow): DraftPick => ({
|
||||
갈래: "단가산출",
|
||||
키: `단산${row.번}`,
|
||||
이름: row.이름 ?? `단산${row.번}`,
|
||||
규격: row.규격 ?? "",
|
||||
단위: row.단위 ?? "",
|
||||
입력값: {},
|
||||
출처: {},
|
||||
몫: null,
|
||||
});
|
||||
|
||||
/** 별도계상 줄(786-1 · 서버 제안 = 고정 줄 하나 `apartPick`) — 고름 칩 없이 글만 */
|
||||
export const apartRow = (row: JudgeRow): boolean => row.후보.length === 1 && apartPick(row.후보[0]);
|
||||
|
||||
/** 호표 하나 줄 칸 부품 — 판정 칩 · 고름 칩 · 수량(단위 환산) · [확인] · 블록 풀이 몸 */
|
||||
export function createRowKit(opt: RowKitOptions) {
|
||||
const no = opt.no;
|
||||
const open = new Set<number>();
|
||||
const bodies = new Map<number, ReturnType<typeof solvedBody>>();
|
||||
const picksOf = (row: JudgeRow): DraftPick[] => linePicks(opt.state.get(), no, row);
|
||||
const edit = (row: JudgeRow, list: DraftPick[]): void =>
|
||||
opt.state.set(editLine(opt.state.get(), no, row, list));
|
||||
|
||||
function verdict(row: JudgeRow): HTMLElement {
|
||||
const v = createBadge(row.판정, VERDICT[row.판정] ?? "neutral");
|
||||
v.dataset.judgeVerdict = row.판정;
|
||||
return v;
|
||||
}
|
||||
|
||||
function chips(row: JudgeRow, picks: DraftPick[]): HTMLElement {
|
||||
const kids: HTMLElement[] = [];
|
||||
if (apartRow(row)) return bar([createText(L("M02_CheckApart"), "muted")]);
|
||||
if (row.번 !== undefined && row.풀이) {
|
||||
const solve = createToggle({ kind: "chip", label: L("M02_JudgeSolve"), checked: open.has(row.차례), onChange: (on) => { if (on) open.add(row.차례); else open.delete(row.차례); opt.redraw(); } }); // prettier-ignore
|
||||
solve.root.dataset.judgeSolve = String(row.번);
|
||||
const sk = wholeSk(row);
|
||||
const whole = createToggle({ kind: "chip", label: `${L("M02_JudgeWholeSk")} ${sk.키}`, checked: picks.some((p) => samePick(p, sk)), disabled: opt.readOnly, onChange: () => edit(row, togglePick(picks, sk)) }); // prettier-ignore
|
||||
whole.root.dataset.judgeWhole = String(sk.키);
|
||||
kids.push(solve.root, whole.root);
|
||||
for (const p of picks.filter((x) => x.갈래 === "기초")) {
|
||||
const b = createBadge(`${p.키}`, missing(p).length ? "danger" : "accent");
|
||||
b.dataset.judgePicked = String(p.키);
|
||||
if (missing(p).length) b.title = `${L("M02_JudgeNeed")} ${missing(p).join(" · ")}`;
|
||||
kids.push(b);
|
||||
}
|
||||
} else {
|
||||
const seen = [...row.후보];
|
||||
for (const p of picks) if (!seen.some((x) => samePick(x, p))) seen.push(p);
|
||||
for (const c of seen) {
|
||||
const on = picks.some((p) => samePick(p, c));
|
||||
const label = `${c.갈래} ${c.이름 || c.키 || ""}${c.규격 ? ` ${c.규격}` : ""}`;
|
||||
const chip = createToggle({ kind: "chip", label, checked: on, disabled: opt.readOnly, onChange: () => edit(row, togglePick(picks, { ...c })) }); // prettier-ignore
|
||||
chip.root.dataset.judgeCand = `${c.갈래}:${c.키 ?? c.이름}`;
|
||||
chip.root.title = c.키 ?? "";
|
||||
kids.push(chip.root);
|
||||
const need = picks.find((p) => samePick(p, c));
|
||||
if (need && missing(need).length) {
|
||||
const b = createBadge(`${L("M02_JudgeNeed")} ${missing(need).length}`, "danger");
|
||||
b.dataset.judgeNeed = missing(need).join(",");
|
||||
kids.push(b);
|
||||
}
|
||||
}
|
||||
if (!seen.length) kids.push(createText(L("M02_JudgeNoCand"), "warning"));
|
||||
}
|
||||
return bar(kids);
|
||||
}
|
||||
|
||||
/** 단위 환산 칸 — 같은 단위면 null */
|
||||
function unitInput(row: JudgeRow, picks: DraftPick[]): HTMLElement | null {
|
||||
const line = lineOf(opt.state.get(), no, row.차례);
|
||||
const 배 = line?.배 ?? row.수량.배;
|
||||
const view = unitView({ 배, 환산: { 배: row.수량.배, 갈래: row.수량.갈래 } }, row.단위, picks[0]?.단위); // prettier-ignore
|
||||
if (view.mode !== "input" || opt.readOnly) return null;
|
||||
const f = createInputField({ compact: true, size: 24, value: show(배), unit: view.to, placeholder: L("M02_JudgeUnitHint").replace("{from}", view.from) }); // prettier-ignore
|
||||
f.input.dataset.judgeTimes = String(row.차례);
|
||||
f.input.addEventListener("change", () => {
|
||||
const n = Number(f.input.value.replace(/,/g, ""));
|
||||
const next = f.input.value.trim() === "" || !Number.isFinite(n) ? null : n;
|
||||
opt.state.set(putLine(opt.state.get(), no, row.차례, { 고른: picks, 확인: false, 배: next }));
|
||||
});
|
||||
if (배 === null) f.setError(L("M02_JudgeNeed"));
|
||||
return bar([L("M02_JudgeUnitHint").replace("{from}", view.from), f.root]);
|
||||
}
|
||||
|
||||
/** 지금 배(사용자 단위 환산 › 서버) */
|
||||
const timesOf = (row: JudgeRow): number | null =>
|
||||
lineOf(opt.state.get(), no, row.차례)?.배 ?? row.수량.배;
|
||||
|
||||
function okChip(row: JudgeRow, picks: DraftPick[]): HTMLElement {
|
||||
const line = lineOf(opt.state.get(), no, row.차례);
|
||||
const ok = createToggle({
|
||||
kind: "chip",
|
||||
label: line?.확인 ? L("M02_JudgeConfirmed") : L("M02_JudgeConfirm"),
|
||||
checked: !!line?.확인,
|
||||
disabled: opt.readOnly,
|
||||
onChange: (on) => {
|
||||
if (on && !confirmable(picks)) {
|
||||
ok.set(false);
|
||||
const why = picks.length ? picks.flatMap(missing).join(" · ") : L("M02_JudgeNoCand");
|
||||
showToast(`${L("M02_JudgeBlocked")} — ${why}`, "warning");
|
||||
return;
|
||||
}
|
||||
opt.state.set(putLine(opt.state.get(), no, row.차례, { ...line, 고른: picks, 확인: on }));
|
||||
},
|
||||
});
|
||||
ok.root.dataset.judgeOk = String(row.차례);
|
||||
return ok.root;
|
||||
}
|
||||
|
||||
/** 행 번호 단추 — 엑셀 보기 그 행 */
|
||||
function rowJump(row: JudgeRow): HTMLElement | string {
|
||||
if (row.행 === null || !opt.onSheet) return show(row.행);
|
||||
const b = createButton({ label: String(row.행), size: 24, variant: "link", onClick: () => opt.onSheet!(row.행!, "일위대가표") }); // prettier-ignore
|
||||
b.dataset.judgeRow = String(row.행);
|
||||
return b;
|
||||
}
|
||||
|
||||
/** 블록 풀이 몸이 쥐는 고른 것 = 단가산출 통째 칩 빼고(기초 · 덧 줄) */
|
||||
const isSk = (p: DraftPick): boolean => p.갈래 === "단가산출";
|
||||
const inner = (row: JudgeRow): DraftPick[] => picksOf(row).filter((p) => !isSk(p));
|
||||
|
||||
/** 펼친 단산 줄의 블록 풀이 몸(접힘 = null) */
|
||||
function solved(row: JudgeRow): HTMLElement | null {
|
||||
if (!row.풀이 || !open.has(row.차례)) return null;
|
||||
let body = bodies.get(row.차례);
|
||||
if (!body) {
|
||||
body = solvedBody({
|
||||
solved: row.풀이,
|
||||
picks: inner(row),
|
||||
readOnly: opt.readOnly,
|
||||
excel: row.단가,
|
||||
onSheet: opt.onSheet ? (r) => opt.onSheet!(r, "단가산출상세") : undefined,
|
||||
onPicks: (list) => edit(row, [...picksOf(row).filter(isSk), ...list]),
|
||||
onCheck: (list, signal) =>
|
||||
checkJudge(opt.file, opt.key, { 호표: no, 차례: row.차례, 고른: list.map((p) => bare(p)) }, signal), // prettier-ignore
|
||||
});
|
||||
bodies.set(row.차례, body);
|
||||
} else body.set(inner(row));
|
||||
return body.root;
|
||||
}
|
||||
|
||||
return { picksOf, edit, verdict, chips, unitInput, timesOf, okChip, rowJump, solved };
|
||||
}
|
||||
export type RowKit = ReturnType<typeof createRowKit>;
|
||||
|
||||
export interface JudgeMoveOptions {
|
||||
file: HopyoScope;
|
||||
key: string;
|
||||
target: HopyoScope;
|
||||
state: JudgeState;
|
||||
readOnly?: boolean;
|
||||
onMoved?: (r: MoveResult) => void;
|
||||
}
|
||||
|
||||
/** 옮기기 한 번 — 409 = 물음 뒤 덮음 · 옮긴 것은 캐시에서 걷음 · 알림 · 실패 = null */
|
||||
export async function runMove(
|
||||
opt: Pick<JudgeMoveOptions, "target" | "key" | "state">,
|
||||
body: MoveBody,
|
||||
막힘: MoveBlocked[],
|
||||
): Promise<MoveResult | null> {
|
||||
try {
|
||||
const r = await moveJudge(opt.target, opt.key, body).catch(async (e: unknown) => {
|
||||
// 이미 옮긴 호표 · 블록 — 물음 한 번 뒤 덮음(아니면 그만)
|
||||
if (!(e instanceof StaleError) || !(await askConfirm({ message: e.message }))) throw e;
|
||||
return moveJudge(opt.target, opt.key, { ...body, 덮음: true });
|
||||
});
|
||||
const stuck = new Set(r.막힘.map((x) => `${x.호표 ?? "b" + x.블록}:${x.차례 ?? ""}`));
|
||||
let d = opt.state.get();
|
||||
for (const [h, lines] of Object.entries(body.호표))
|
||||
for (const at of Object.keys(lines))
|
||||
if (!stuck.has(`${h}:${at}`)) {
|
||||
const { [at]: _gone, ...rest } = d.호표[h] ?? {};
|
||||
d = { ...d, 호표: { ...d.호표, [h]: rest } };
|
||||
}
|
||||
for (const b of Object.keys(body.블록))
|
||||
if (!stuck.has(`b${b}:`)) {
|
||||
const { [b]: _gone, ...rest } = d.블록;
|
||||
d = { ...d, 블록: rest };
|
||||
}
|
||||
opt.state.set(d);
|
||||
const msg = L("M02_JudgeMoved")
|
||||
.replace("{a}", String(r.만듦.length))
|
||||
.replace("{b}", String(r.단가산출.length))
|
||||
.replace("{c}", String(r.막힘.length + 막힘.length));
|
||||
showToast(msg, r.막힘.length + 막힘.length ? "warning" : "success");
|
||||
return r;
|
||||
} catch (e) {
|
||||
showToast((e as Error).message, "error");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 오른쪽 판 아래 [옮기기 (n)] — 확인한 줄 · 블록만 · 서버가 정본으로 다시 셈 · 옮긴 것은 캐시에서 걷음 */
|
||||
export function createJudgeMove(opt: JudgeMoveOptions): { root: HTMLElement; destroy: () => void } {
|
||||
const btn = createButton({ label: L("M02_JudgeMove"), size: 28, variant: "filled", onClick: () => void go() }); // prettier-ignore
|
||||
btn.dataset.judgeMove = "";
|
||||
const root = bar([btn]);
|
||||
const count = (): void => {
|
||||
const n = moveBody(opt.state.get(), opt.file).수;
|
||||
btn.textContent = `${L("M02_JudgeMove")} (${n})`;
|
||||
btn.disabled = !!opt.readOnly || n === 0;
|
||||
};
|
||||
|
||||
async function go(): Promise<void> {
|
||||
const { body, 막힘, 수 } = moveBody(opt.state.get(), opt.file);
|
||||
if (!수) {
|
||||
showToast(L("M02_JudgeMoveNone"), "info");
|
||||
return;
|
||||
}
|
||||
btn.disabled = true;
|
||||
const r = await runMove(opt, body, 막힘);
|
||||
count();
|
||||
if (r) opt.onMoved?.(r);
|
||||
}
|
||||
|
||||
const off = opt.state.on(count);
|
||||
count();
|
||||
return {
|
||||
root,
|
||||
destroy(): void {
|
||||
off();
|
||||
root.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_Sheet.ts
|
||||
* 일위대가 실무자료 엑셀 보기 몸(PLAN 329 · 338-3 · 359-1 · 359-2 · 785-4) — 본 칸 불러오기 보기(`Hopyo_Import`)가 바로 담음 ·
|
||||
* 머리 막대 = 시트 · 호표 수 · 검산 · 제안 n / 줄 m(1차 판정) · 블록 풀이 셈 중 + 부르는 쪽 덧붙임(`headExtra` — 층 · [끝내기]) ·
|
||||
* 머리 막대 = 시트 · 호표 수 · 검산 · 연결 n / 줄 m(809-1 줄 표지 · 연결됨 + 로직 줄) + 부르는 쪽 덧붙임(`headExtra` — 층 · [끝내기]) ·
|
||||
* 왼쪽 = 올린 엑셀을 연 것처럼 읽기 전용 시트(A00 산출근거 스프레드시트 · 탭 셋 일위대가표 · 단가산출표 · 단가산출 상세 ·
|
||||
* 칠 · 범례 · 뛰기 · 뒤로 = `Hopyo_SheetNav` · 779-4 · 같은 파일 격자 다시 씀 · 779-6) ·
|
||||
* 오른쪽 = 호표 목록(제N호표 [수정] · 찾기 · 제안 n/m — 다 제안이면 녹색 · 뒤 셈 중이면 3 초마다 다시) ·
|
||||
* 오른쪽 = 호표 목록(제N호표 [수정] · [내 일위대가로] · 찾기 · 연결 n/m — 다 연결이면 녹색) ·
|
||||
* 단가산출표 · 상세 탭이면 단가산출 목록으로 바뀜(780-2 · `Hopyo_SheetSk`).
|
||||
* · 목록 누름 → 시트에서 그 호표 영역(B열 ~ 끝 열) 고름 · 칸 누름 → 그 행 호표를 목록에서 강조 · 머리 행이면 영역 고름
|
||||
* · [수정] = 모달 「확인 — 제N호표」(786-2 `Hopyo_Check` · 줄 누름과 따로) · 단가산출 탭 블록 = 시트 아래 블록 판 ·
|
||||
* 오른쪽 판 아래 [옮기기](확인한 줄 · 블록 · 고름 캐시 `createJudgeState` 한 벌)
|
||||
* · [수정] = 일위대가 컨테이너 꼴 모달(809-1 `Hopyo_View` · 경쟁사 로직 인정 · 요소만 연결 · 줄 누름과 따로) ·
|
||||
* 옮기기 = [내 일위대가로](807-1) 하나 · 단가산출 탭 블록 = [가져온 식으로 열기](`Hopyo_SheetSk`) · 기초 판정 · [옮기기] 판 없음(요구 703)
|
||||
* · 격자 = 서버 `/hopyo-grid/{키}` · 옛 파일(격자 없음)은 시트 자리에 까닭 글 · 목록은 됨
|
||||
* · `buildHopyoSheet` = 몸(머리 · 띠 · 시트 + 목록) · 옛 [저장] · [모두 고정으로 들여오기] · 모달 껍데기는 785-7 에서 지움
|
||||
* (만들기 = [옮기기] · 모두 고정 서버 길은 B09 · 붙여넣기가 씀)
|
||||
* (모두 고정 서버 길은 B09 · 붙여넣기가 씀)
|
||||
* ⚠ 부르는 쪽은 `import()` 로 엶 · 스프레드시트도 열 때 불러옴(다른 화면 번들에 안 섞이게).
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -20,7 +20,6 @@ import { createInputField } from "@ui/ui_template_input";
|
||||
import type { Sheet } from "../A00_Common/spreadsheet/spreadsheet_types";
|
||||
import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet";
|
||||
import {
|
||||
POLL_MS,
|
||||
readHopyo,
|
||||
type HopyoBrief,
|
||||
type HopyoGrid,
|
||||
@@ -28,10 +27,8 @@ import {
|
||||
type HopyoView,
|
||||
type HopyoScope,
|
||||
} from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import { openHopyoCheck } from "./M02_MasterTemplete_Hopyo_Check";
|
||||
import { mineButton } from "./M02_MasterTemplete_Hopyo_Mine";
|
||||
import { createJudgeMove, createJudgeState } from "./M02_MasterTemplete_Hopyo_Rows";
|
||||
import { lineOf } from "./logic/M02_MasterTemplete_Hopyo_Picks";
|
||||
import { openHopyoView } from "./M02_MasterTemplete_Hopyo_View";
|
||||
import * as Nav from "./M02_MasterTemplete_Hopyo_SheetNav";
|
||||
import { createSheetSk, type SheetSk } from "./M02_MasterTemplete_Hopyo_SheetSk";
|
||||
import "./M02_MasterTemplete_Hopyo.css";
|
||||
@@ -45,7 +42,7 @@ export interface HopyoSheetOptions {
|
||||
scope: HopyoScope;
|
||||
key: string;
|
||||
파일: string;
|
||||
/** 만들 층 쓰기 권한 — 없으면 옮기기 없음(보기만) */
|
||||
/** 만들 층 쓰기 권한 — 없으면 단가산출 모달 읽기만 */
|
||||
canWrite: boolean;
|
||||
/** 사전조합을 만들 층(없으면 `scope` · [로직 추가] = 문서 층) */
|
||||
target?: HopyoScope;
|
||||
@@ -57,7 +54,7 @@ export interface HopyoSheetOptions {
|
||||
onClose?: () => void;
|
||||
/** 머리 막대 끝에 덧붙일 것(본 칸 불러오기 보기 — 층 · 안 이은 줄 · [끝내기]) */
|
||||
headExtra?: HTMLElement[];
|
||||
/** 단가산출 판정 · 옮김 뒤 */
|
||||
/** 단가산출 목록 읽은 뒤 */
|
||||
onSkChange?: () => void;
|
||||
/** 엑셀 결과를 읽었음 · 다시 읽었음(뒤 셈 중 3 초 다시 읽기 포함) — 호표 번 목록을 바로 넘김 */
|
||||
onRead?: (file: HopyoView) => void;
|
||||
@@ -70,7 +67,7 @@ export interface HopyoSheetBody {
|
||||
destroy: () => void;
|
||||
/** 안 옮긴 단가산출 블록 수(판정 전 null) */
|
||||
skCount: () => { 블록: number; 밖: number; 줄: number } | null;
|
||||
/** 그 단가산출 블록으로 뜀 + 블록 판 엶 */
|
||||
/** 그 단가산출 블록으로 뜀 */
|
||||
showSk: (번: number) => void;
|
||||
}
|
||||
|
||||
@@ -130,9 +127,6 @@ export function buildHopyoSheet(opt: HopyoSheetOptions): HopyoSheetBody {
|
||||
let current: number | null = opt.호표 ?? null;
|
||||
const items = new Map<number, HTMLElement>();
|
||||
const target = opt.target ?? opt.scope;
|
||||
const state = createJudgeState(opt.scope, opt.key); // 판정 고름 캐시(785-6)
|
||||
let panel: { destroy: () => void } | null = null; // 시트 아래 블록 판 몸
|
||||
const offPaint = state.on(() => nav?.repaint()); // 확인 바뀜 = 다시 칠
|
||||
|
||||
const head = el("div", {
|
||||
className: "ui-toolbar m02-hopyo__bar",
|
||||
@@ -147,22 +141,7 @@ export function buildHopyoSheet(opt: HopyoSheetOptions): HopyoSheetBody {
|
||||
className: "m02-hopyo-sheet__grid",
|
||||
children: [note("엑셀을 읽는 중…")],
|
||||
});
|
||||
const lines = el("div", {
|
||||
className: "m02-hopyo-sheet__lines",
|
||||
attrs: { "data-hopyo-lines": "" },
|
||||
}); // 시트 아래 블록 판 자리(단가산출 탭 · SheetSk)
|
||||
lines.hidden = true;
|
||||
const closeLines = (): void => {
|
||||
panel?.destroy();
|
||||
panel = null;
|
||||
lines.hidden = true;
|
||||
};
|
||||
const openLines = (make: (host: HTMLElement) => { destroy: () => void }): void => {
|
||||
panel?.destroy();
|
||||
lines.hidden = false;
|
||||
panel = make(lines);
|
||||
};
|
||||
const col = el("div", { className: "m02-hopyo-sheet__col", children: [left, lines] });
|
||||
const col = el("div", { className: "m02-hopyo-sheet__col", children: [left] });
|
||||
const searchField = createInputField({
|
||||
type: "search",
|
||||
placeholder: "호표 · 품명 · 규격 찾기",
|
||||
@@ -172,14 +151,6 @@ export function buildHopyoSheet(opt: HopyoSheetOptions): HopyoSheetBody {
|
||||
const search = searchField.input;
|
||||
const list = el("div", { className: "m02-hopyo-sheet__list" });
|
||||
const count = note("");
|
||||
const move = createJudgeMove({
|
||||
file: opt.scope,
|
||||
key: opt.key,
|
||||
target,
|
||||
state,
|
||||
readOnly: !opt.canWrite,
|
||||
onMoved: () => void refresh(),
|
||||
});
|
||||
const right = el("div", {
|
||||
className: "m02-hopyo-sheet__side",
|
||||
children: [
|
||||
@@ -189,16 +160,12 @@ export function buildHopyoSheet(opt: HopyoSheetOptions): HopyoSheetBody {
|
||||
children: [count],
|
||||
}),
|
||||
list,
|
||||
move.root,
|
||||
],
|
||||
});
|
||||
|
||||
const finish = (): void => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
closeLines();
|
||||
offPaint();
|
||||
move.destroy();
|
||||
sk?.destroy();
|
||||
nav?.destroy();
|
||||
sheet?.destroy();
|
||||
@@ -208,13 +175,11 @@ export function buildHopyoSheet(opt: HopyoSheetOptions): HopyoSheetBody {
|
||||
/** 머리 막대 — 시트 · 호표 수 · 검산 · 제안 · 셈 중 + 덧붙임 */
|
||||
function paintHead(file: HopyoView): void {
|
||||
const wrong = file.검산.어긋남;
|
||||
const busy = !!file.셈 && !file.셈.끝;
|
||||
head.replaceChildren(
|
||||
note(`시트 ${file.시트}`),
|
||||
note(`호표 ${file.호표수}`),
|
||||
note(wrong.length ? `검산 어긋남 ${wrong.length}` : "검산 맞음"),
|
||||
note(`제안 ${file.맞춤[0]} / 줄 ${file.맞춤[1]}`),
|
||||
...(busy ? [note(`${file.셈?.까닭 ?? "블록 풀이"} 셈 중`)] : []),
|
||||
note(`연결 ${file.맞춤[0]} / 줄 ${file.맞춤[1]}`),
|
||||
...(opt.headExtra ?? []),
|
||||
);
|
||||
opt.onRead?.(file);
|
||||
@@ -240,7 +205,7 @@ export function buildHopyoSheet(opt: HopyoSheetOptions): HopyoSheetBody {
|
||||
if (at.시트 !== ilwi || at.범위 !== text) sheet.select(text, ilwi);
|
||||
}
|
||||
|
||||
/** [수정] — 그 호표 자리 + 모달 「확인 — 제N호표」(786-2 · 줄 누름과 따로) */
|
||||
/** [수정] — 그 호표 자리 + 일위대가 컨테이너 꼴 모달(809-1 · 줄 누름과 따로) */
|
||||
const editButton = (h: HopyoBrief): HTMLElement => {
|
||||
const b = createButton({
|
||||
label: "수정",
|
||||
@@ -249,17 +214,14 @@ export function buildHopyoSheet(opt: HopyoSheetOptions): HopyoSheetBody {
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
focus(h.번);
|
||||
openHopyoCheck({
|
||||
openHopyoView({
|
||||
file: opt.scope,
|
||||
key: opt.key,
|
||||
target,
|
||||
state,
|
||||
no: h.번,
|
||||
brief: h,
|
||||
canWrite: opt.canWrite,
|
||||
onSheet: (r, role) => nav?.jumpTo(role === "일위대가표" ? ilwi : detail, r),
|
||||
onHopyo: () => focus(h.번),
|
||||
onMoved: () => void refresh(),
|
||||
onSheet: () => focus(h.번),
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -312,11 +274,10 @@ export function buildHopyoSheet(opt: HopyoSheetOptions): HopyoSheetBody {
|
||||
left.replaceChildren();
|
||||
const lastOf = (sid: string): string =>
|
||||
lastColumn(book.시트.find((x) => x.id === sid) ?? first);
|
||||
const judged = (no: number, at: number): boolean => !!lineOf(state.get(), no, at)?.확인;
|
||||
const more = { ...opt, target, grid, last: lastOf, sheet: () => sheet, host: left, judged };
|
||||
const more = { ...opt, target, grid, last: lastOf, sheet: () => sheet, host: left };
|
||||
nav = ids.일위대가표 ? Nav.createSheetNav(more) : null;
|
||||
if (nav) warn.after(nav.root);
|
||||
sk = nav ? createSheetSk({ ...opt, target, nav, state, side: right, openLines, closeLines, onMoved: () => void refresh(), ...(opt.onSkChange ? { onChange: opt.onSkChange } : {}) }) : null; // prettier-ignore
|
||||
sk = nav ? createSheetSk({ ...opt, target, nav, side: right, ...(opt.onSkChange ? { onChange: opt.onSkChange } : {}) }) : null; // prettier-ignore
|
||||
sheet = createSpreadsheet(left, book, {
|
||||
readOnly: true,
|
||||
칠: nav?.fill,
|
||||
@@ -339,32 +300,6 @@ export function buildHopyoSheet(opt: HopyoSheetOptions): HopyoSheetBody {
|
||||
});
|
||||
}
|
||||
|
||||
/** 뒤 숫자 판정 셈 중이면 3 초 뒤 목록 「제안 n/m」 · 머리 다시 읽음 — 셈 끝 · 창 닫음 · 못 읽음이면 멈춤 */
|
||||
function poll(file: HopyoView): void {
|
||||
if (!file.셈 || file.셈.끝) return;
|
||||
window.setTimeout(async () => {
|
||||
const next = closed ? null : await readHopyo(opt.scope, opt.key).catch(() => null);
|
||||
if (!next || closed) return;
|
||||
briefs = next.호표;
|
||||
paintHead(next);
|
||||
paintList();
|
||||
nav?.marks(briefs);
|
||||
poll(next);
|
||||
}, POLL_MS);
|
||||
}
|
||||
|
||||
/** 옮긴 뒤 — 목록 · 칠 · 단가산출 목록 다시 읽음 */
|
||||
async function refresh(): Promise<void> {
|
||||
const next = await readHopyo(opt.scope, opt.key).catch(() => null);
|
||||
if (!next || closed) return;
|
||||
briefs = next.호표;
|
||||
paintHead(next);
|
||||
paintList();
|
||||
nav?.marks(briefs);
|
||||
sk?.reload();
|
||||
opt.onSkChange?.();
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
try {
|
||||
const [file, grid] = await Promise.all([
|
||||
@@ -376,7 +311,6 @@ export function buildHopyoSheet(opt: HopyoSheetOptions): HopyoSheetBody {
|
||||
spans = grid instanceof Error ? [] : grid.호표;
|
||||
paintHead(file);
|
||||
paintList();
|
||||
poll(file);
|
||||
if (grid instanceof Error) return void left.replaceChildren(note(grid.message));
|
||||
await mountSheet(grid);
|
||||
const rows = opt.단산행 === undefined ? null : rowsText(opt.단산행);
|
||||
|
||||
@@ -87,8 +87,6 @@ export interface SheetNavOptions {
|
||||
sheet: () => SpreadsheetHandle | null;
|
||||
/** 두 번 누름을 들을 자리(시트 칸) */
|
||||
host: HTMLElement;
|
||||
/** 판정 화면에서 확인한 줄(785-6 고름 캐시) */
|
||||
judged?: (no: number, at: number) => boolean;
|
||||
}
|
||||
|
||||
const toCol = (letters: string): number =>
|
||||
@@ -208,7 +206,7 @@ export function createSheetNav(opt: SheetNavOptions) {
|
||||
}
|
||||
|
||||
function repaint(): void {
|
||||
paint = paintOf(ix, briefs, blocks, () => null, opt.judged); // 옛 확인 창 캐시 없음(785-7)
|
||||
paint = paintOf(ix, briefs, blocks, () => null); // 줄 표지 = 서버(809-1)
|
||||
opt.sheet()?.repaint();
|
||||
paintBar();
|
||||
}
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_SheetSk.ts
|
||||
* 엑셀 보기 창 오른쪽 판 바꾸기(PLAN 780-2 · 785-6 · 요구 627 · 642) — 탭 따라:
|
||||
* 엑셀 보기 창 오른쪽 판 바꾸기(PLAN 780-2 · 809-1 · 요구 627 · 702) — 탭 따라:
|
||||
* · 일위대가표 = 호표 목록(`Hopyo_Sheet` 판 그대로) · 단가산출표 · 단가산출 상세 = 단가산출 목록
|
||||
* (새 블록 판정 `hopyo-judge` 목록 블록 줄 = 판정 · 고를 것 n · 표지 · 부름 · 만든 SK · 캐시 확인)
|
||||
* · 목록 블록 줄 누름 → 지금 탭의 그 블록 자리 + 시트 아래 줄 판 = 블록 판(`Hopyo_Block.createJudgeBlock` · [확인])
|
||||
* · 옮김 = 이 판 아래 [옮기기](`Hopyo_Rows.createJudgeMove` · 호표 목록 판 것과 같은 캐시 · 한 몸)
|
||||
* (블록 줄 = 단산 N 이름 · 단위 · 부름 수 / 일위대가 밖 · 만든 가져온 식 SK · 재료 = `hopyo-marks`)
|
||||
* · 블록 줄 누름 → 지금 탭의 그 블록 자리 · 줄 끝 [가져온 식으로 열기](만들 층) · [내 단가산출로](개인 층 · 807-1) = `Hopyo_Imported` 모달
|
||||
* (경쟁사 로직 그대로 · 엑셀 단가 · 요소 이음 · 소요량 이음)
|
||||
* · 시트 칸 고름 → 그 행 블록을 목록에서 강조(`SheetNav.blockAt`)
|
||||
* · 목록은 처음 단가산출 탭에 올 때 읽음 · 만들 층 ≠ 파일 층 · 쓰기 권한 없음 = 보기만
|
||||
* · 목록은 처음 단가산출 탭에 올 때 읽음 · 기초 판정 · 블록 판 · [옮기기] 없음(요구 703)
|
||||
* ========================================================================== */
|
||||
|
||||
import { createBadge } from "@ui/ui_template_badge";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import { createListRow } from "@ui/ui_template_list_row";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { POLL_MS, type HopyoScope } from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import { createJudgeBlock, type JudgeState } from "./M02_MasterTemplete_Hopyo_Block";
|
||||
import { createJudgeMove } from "./M02_MasterTemplete_Hopyo_Rows";
|
||||
import { readJudge, type JudgeBlockBrief } from "./M02_MasterTemplete_Hopyo_JudgeApi";
|
||||
import type { SheetNav } from "./M02_MasterTemplete_Hopyo_SheetNav";
|
||||
import type { HopyoScope } from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import { openImported } from "./M02_MasterTemplete_Hopyo_Imported";
|
||||
import { readHopyoMarks, type SheetNav } from "./M02_MasterTemplete_Hopyo_SheetNav";
|
||||
import type { BlockMark } from "./logic/M02_MasterTemplete_Hopyo_SheetMap";
|
||||
|
||||
export interface SheetSkOptions {
|
||||
scope: HopyoScope;
|
||||
@@ -26,71 +25,46 @@ export interface SheetSkOptions {
|
||||
target: HopyoScope;
|
||||
canWrite: boolean;
|
||||
nav: SheetNav;
|
||||
state: JudgeState;
|
||||
/** 호표 목록 판 — 단가산출 탭이면 숨김 · 그 뒤에 단가산출 판을 붙임 */
|
||||
side: HTMLElement;
|
||||
/** 시트 아래 줄 판 자리 열기 · 닫기(`Hopyo_Sheet` 가 쥠) */
|
||||
openLines: (make: (host: HTMLElement) => { destroy: () => void }) => void;
|
||||
closeLines: () => void;
|
||||
/** 판정 · 옮김 뒤(부르는 쪽 끝내기 글) */
|
||||
/** 목록 읽은 뒤(부르는 쪽 끝내기 글) */
|
||||
onChange?: () => void;
|
||||
/** 옮긴 뒤(목록 · 칠 다시 읽음) */
|
||||
onMoved?: () => void;
|
||||
}
|
||||
|
||||
const sameLayer = (a: HopyoScope, b: HopyoScope): boolean =>
|
||||
a.layer === b.layer && (a.owner ?? null) === (b.owner ?? null);
|
||||
const STRIPE = {
|
||||
done: "var(--color-success)",
|
||||
ready: "var(--color-info)",
|
||||
pick: "var(--color-warning)",
|
||||
} as const;
|
||||
const STRIPE = { done: "var(--color-success)", ready: "var(--color-info)", out: "var(--color-border)" } as const; // prettier-ignore
|
||||
|
||||
export function createSheetSk(opt: SheetSkOptions) {
|
||||
const list = el("div", { className: "m02-hopyo-sheet__list" });
|
||||
const readOnly = !opt.canWrite || !sameLayer(opt.scope, opt.target);
|
||||
const move = createJudgeMove({
|
||||
file: opt.scope,
|
||||
key: opt.key,
|
||||
target: opt.target,
|
||||
state: opt.state,
|
||||
readOnly,
|
||||
...(opt.onMoved ? { onMoved: opt.onMoved } : {}),
|
||||
});
|
||||
const root = el("div", {
|
||||
className: "m02-hopyo-sheet__side m02-hopyo-sheet__sk",
|
||||
children: [list, move.root],
|
||||
});
|
||||
const root = el("div", { className: "m02-hopyo-sheet__side m02-hopyo-sheet__sk", children: [list] }); // prettier-ignore
|
||||
root.setAttribute("data-hopyo-sk-side", "");
|
||||
root.hidden = true;
|
||||
opt.side.after(root);
|
||||
let blocks: JudgeBlockBrief[] | null = null;
|
||||
let blocks: BlockMark[] | null = null;
|
||||
let asked = false;
|
||||
let sheet = "";
|
||||
let current: number | null = null;
|
||||
|
||||
const mineAt = { layer: "personal", owner: null } as const; // [내 단가산출로](807-1) = 늘 개인 층
|
||||
const open = (no: number, mine: boolean): void =>
|
||||
openImported({ file: opt.scope, key: opt.key, no, target: mine ? mineAt : opt.target, canWrite: mine || opt.canWrite }); // prettier-ignore
|
||||
|
||||
function draw(): void {
|
||||
if (!blocks) return;
|
||||
const d = opt.state.get();
|
||||
list.replaceChildren(
|
||||
...blocks.map((b) => {
|
||||
const ok = !!d.블록[String(b.번)]?.확인;
|
||||
const sub = [
|
||||
b.단위,
|
||||
b.판정 ? `${b.판정}${b.판정 === "고를 것" && b.고를수 ? ` ${b.고를수}` : ""}` : "",
|
||||
b.표지 ?? "",
|
||||
b.부름.length ? `${L("M02_JudgeCalls")} ${b.부름.length}` : L("M02_JudgeOutside"),
|
||||
].filter(Boolean).join(" · "); // prettier-ignore
|
||||
const tail = b.만든
|
||||
? createBadge(`${L("M02_JudgeMade")} ${b.만든}`, "success")
|
||||
: ok
|
||||
? createBadge(L("M02_JudgeConfirmed"), "accent")
|
||||
: "";
|
||||
const calls = b.부름?.length ?? 0;
|
||||
const sub = [b.단위 ?? "", calls ? `${L("M02_JudgeCalls")} ${calls}` : L("M02_JudgeOutside")].filter(Boolean).join(" · "); // prettier-ignore
|
||||
const go = createButton({ label: L("M02_CheckImported"), size: 24, variant: "ghost", onClick: (e) => { e.stopPropagation(); open(b.번, false); } }); // prettier-ignore
|
||||
go.dataset.skOpen = String(b.번);
|
||||
go.hidden = opt.target.layer === "personal" && !opt.target.owner; // 만들 층 = 개인이면 [내 단가산출로]와 같은 일
|
||||
const mine = createButton({ label: L("M02_ToMySk"), size: 24, variant: "ghost", onClick: (e) => { e.stopPropagation(); open(b.번, true); } }); // prettier-ignore
|
||||
mine.dataset.skMine = String(b.번);
|
||||
const tail = el("span", { className: "ui-toolbar", children: [...(b.SK ? [createBadge(`${L("M02_JudgeMade")} ${b.SK}`, "success")] : []), go, mine] }); // prettier-ignore
|
||||
const row = createListRow({
|
||||
label: `${L("M02_JudgeBlock").replace("{n}", String(b.번))} ${b.이름}`,
|
||||
label: `${L("M02_JudgeBlock").replace("{n}", String(b.번))} ${b.이름 ?? ""}`,
|
||||
sub,
|
||||
trailing: tail,
|
||||
stripe: b.만든 || ok ? STRIPE.done : b.판정 === "고를 것" ? STRIPE.pick : STRIPE.ready,
|
||||
stripe: b.SK ? STRIPE.done : calls ? STRIPE.ready : STRIPE.out,
|
||||
active: b.번 === current,
|
||||
size: 36,
|
||||
onClick: () => pick(b.번),
|
||||
@@ -106,18 +80,16 @@ export function createSheetSk(opt: SheetSkOptions) {
|
||||
if (asked) return;
|
||||
asked = true;
|
||||
if (!blocks) list.replaceChildren(createText(L("M02_JudgeLoading"), "hint"));
|
||||
void readJudge(opt.scope, opt.key, opt.target)
|
||||
void readHopyoMarks(opt.scope, opt.key, opt.target)
|
||||
.then((got) => {
|
||||
blocks = got.블록;
|
||||
blocks = got;
|
||||
draw();
|
||||
opt.onChange?.();
|
||||
// 블록 풀이 뒤 셈 중 — 끝나면 판정 · 고를 것이 실림(3 초마다 · 판이 붙어 있는 동안)
|
||||
if (got.셈중) setTimeout(() => list.isConnected && ((asked = false), ensure()), POLL_MS);
|
||||
})
|
||||
.catch((e: Error) => list.replaceChildren(createText(e.message, "error")));
|
||||
}
|
||||
|
||||
/** 블록 고름 — 그 자리 뜀 + 줄 판 = 블록 판 */
|
||||
/** 블록 고름 — 그 자리 뜀 */
|
||||
function pick(no: number): void {
|
||||
current = no;
|
||||
draw();
|
||||
@@ -126,43 +98,21 @@ export function createSheetSk(opt: SheetSkOptions) {
|
||||
? (opt.nav.spotOf(no, "단가산출표") ?? opt.nav.spotOf(no, "단가산출상세"))
|
||||
: (opt.nav.spotOf(no, "단가산출상세") ?? opt.nav.spotOf(no, "단가산출표"));
|
||||
if (spot) opt.nav.jumpTo(spot.시트, spot.행);
|
||||
const detail = opt.nav.spotOf(no, "단가산출상세")?.시트;
|
||||
opt.openLines((host) =>
|
||||
createJudgeBlock({
|
||||
host,
|
||||
file: opt.scope,
|
||||
key: opt.key,
|
||||
target: opt.target,
|
||||
state: opt.state,
|
||||
no,
|
||||
readOnly,
|
||||
onClose: opt.closeLines,
|
||||
...(detail ? { onSheet: (r: number) => opt.nav.jumpTo(detail, r) } : {}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const off = opt.state.on(draw);
|
||||
|
||||
return {
|
||||
root,
|
||||
/** 아직 안 옮긴 블록 수 · 그중 일위대가 밖 · 고를 것 합 — 목록 전 null */
|
||||
/** 아직 SK 안 만든 블록 수 · 그중 일위대가 밖 — 목록 전 null */
|
||||
count: (): { 블록: number; 밖: number; 줄: number } | null => {
|
||||
if (!blocks) return null;
|
||||
const left = blocks.filter((b) => !b.만든);
|
||||
const 밖 = left.filter((b) => !b.부름.length).length;
|
||||
return { 블록: left.length, 밖, 줄: left.reduce((a, b) => a + (b.고를수 ?? 0), 0) };
|
||||
const left = blocks.filter((b) => !b.SK);
|
||||
return { 블록: left.length, 밖: left.filter((b) => !b.부름?.length).length, 줄: 0 };
|
||||
},
|
||||
/** 그 블록으로 뜀 + 블록 판 */
|
||||
/** 그 블록으로 뜀 */
|
||||
show(번: number): void {
|
||||
ensure();
|
||||
pick(번);
|
||||
},
|
||||
/** 옮긴 뒤 — 목록 다시 읽음(만든 SK) */
|
||||
reload(): void {
|
||||
asked = false;
|
||||
if (!root.hidden) ensure();
|
||||
},
|
||||
/** 시트 고름 · 탭 바꿈 — 판 바꾸기 · 강조 */
|
||||
at(sel: { 시트: string; 칸: string }): void {
|
||||
sheet = sel.시트;
|
||||
@@ -179,10 +129,7 @@ export function createSheetSk(opt: SheetSkOptions) {
|
||||
draw();
|
||||
}
|
||||
},
|
||||
destroy(): void {
|
||||
off();
|
||||
move.destroy();
|
||||
},
|
||||
destroy(): void {},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_View.ts
|
||||
* 엑셀 불러오기 [수정] 모달(PLAN 809-1 · 요구 702 · 703) — 엑셀 보기 오른쪽 호표 목록 [수정](`Hopyo_Sheet`)이 엶.
|
||||
* 경쟁사 로직 인정 · 요소만 연결 — 몸 = 일위대가 컨테이너 부품(`Labor_Table` · 806-1 조합과 한 벌 · 읽기만) + 덧칸 둘:
|
||||
* · 표지 = 로직 줄 「단가산출 N」 · 「일위대가 N」 칩 / 요소 줄 「✓ 연결됨」 · 「후보 n」 · 「못 이음」(엑셀 값 그대로)
|
||||
* · 엑셀 금액 = 그 줄 엑셀 금액 계
|
||||
* 줄 = 서버 `GET …/hopyo-view/{키}/{번}`(`M02_Hopyo_View` · 엑셀 숫자 그대로 고정 줄 · 저장 없음 · 금액 = 엑셀).
|
||||
* · 단가산출 줄 누름 = 그 블록 가져온 식 SK(서버 `…/sangeun/from-hopyo` · 만들 층 · 같은 내용 = 있는 것) →
|
||||
* 단가산출 「수식」 탭 모달(`Labor_SkEdit` 부르기만 · [엑셀 단가 그대로 | 우리 단가로])
|
||||
* · 일위대가 줄 누름 = 그 하위 호표 [수정] 모달 · 후보 줄 누름 = 후보 목록(고르기 = [내 일위대가로] 뒤 일위대가 [연결])
|
||||
* · 머리 = 표지 수 · 엑셀 단가 · [내 일위대가로](807-1 `Hopyo_Mine`) · [구조물 조합으로](만든 SC 있을 때 · `Hopyo_ToItem`) · [엑셀 보기]
|
||||
* 기초 판정 · 「우리 기초로 바꾸기」 없음(요구 703).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createBadge } from "@ui/ui_template_badge";
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import { loadingError, showLoading } from "@ui/ui_template_loading";
|
||||
import { currentLanguageIndex, t as L } from "@ui/ui_template_locale";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { createTable } from "@ui/ui_template_table";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { createToolbar } from "@ui/ui_template_toolbar";
|
||||
import { formatPrice } from "../common_util/common_util_money";
|
||||
import { call } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { at, type HopyoBrief, type HopyoScope } from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import { mineButton } from "./M02_MasterTemplete_Hopyo_Mine";
|
||||
import { toItem } from "./M02_MasterTemplete_Hopyo_ToItem";
|
||||
import type { LaborDetailHandle } from "./M02_MasterTemplete_Labor_Detail";
|
||||
import { openSkEdit } from "./M02_MasterTemplete_Labor_SkEdit";
|
||||
import { mountLaborTable } from "./M02_MasterTemplete_Labor_Table";
|
||||
|
||||
const TEXT = {
|
||||
Title: ["제{n}호표 — 엑셀 불러오기", "Unit cost #{n} — Excel import"],
|
||||
Mark: ["표지", "Status"],
|
||||
Excel: ["엑셀 금액", "Excel amount"],
|
||||
Linked: ["✓ 연결됨", "✓ Linked"],
|
||||
Near: ["후보 {n}", "{n} candidates"],
|
||||
None: ["못 이음", "Not linked"],
|
||||
Logic: ["로직 {n}", "Logic {n}"],
|
||||
ExcelPrice: ["엑셀 단가", "Excel price"],
|
||||
Hint: [
|
||||
"경쟁사 로직 그대로 · 요소만 연결 — 단가산출 줄을 누르면 수식 탭 · 금액 = 엑셀",
|
||||
"Competitor logic kept · elements linked — click a build-up line for its formula",
|
||||
],
|
||||
Sheet: ["엑셀 보기", "Show in Excel"],
|
||||
NearTitle: ["이음 후보 — {name}", "Link candidates — {name}"],
|
||||
NearHint: [
|
||||
"고르기 = [내 일위대가로] 뒤 내 일위대가의 [연결]",
|
||||
"Pick after [To my unit costs] with [Link]",
|
||||
],
|
||||
Key: ["키", "Key"],
|
||||
Name: ["이름", "Name"],
|
||||
Close: ["닫기", "Close"],
|
||||
} as const satisfies Record<string, readonly [string, string]>;
|
||||
const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0];
|
||||
const why = (e: unknown): string => (e instanceof Error ? e.message : String(e));
|
||||
const enc = encodeURIComponent;
|
||||
|
||||
type Money = Record<"노무비" | "재료비" | "경비" | "계", number | null>;
|
||||
/** 줄 표지(서버 `M02_Hopyo_View.mark`) */
|
||||
export interface ViewMark {
|
||||
차례: number;
|
||||
갈래: "로직" | "요소";
|
||||
표지: "단가산출" | "일위대가" | "연결됨" | "후보" | "못 이음";
|
||||
번?: number | null;
|
||||
이음?: Record<string, string>;
|
||||
후보?: { 키: string; 이름: string }[];
|
||||
엑셀: Money;
|
||||
}
|
||||
export interface ViewReply {
|
||||
번: number;
|
||||
이름: string;
|
||||
규격: string | null;
|
||||
단위: string | null;
|
||||
금액: Money | null;
|
||||
문서: unknown;
|
||||
줄: ViewMark[];
|
||||
}
|
||||
|
||||
export const readHopyoView = (file: HopyoScope, key: string, no: number): Promise<ViewReply> =>
|
||||
call(at(file, `/hopyo-view/${enc(key)}/${no}`));
|
||||
|
||||
/** 표지 칸 글 */
|
||||
export function markText(m: ViewMark): string {
|
||||
if (m.갈래 === "로직") return `${m.표지}${m.번 ? ` ${m.번}` : ""}`;
|
||||
if (m.표지 === "연결됨") return t("Linked");
|
||||
if (m.표지 === "후보") return t("Near").replace("{n}", String(m.후보?.length ?? 0));
|
||||
return t("None");
|
||||
}
|
||||
|
||||
/** 표지 칸 꼴 — 로직 = 칩 · 연결됨 초록 · 후보 주황 · 못 이음 빨강 */
|
||||
const markTone = (m: ViewMark): string =>
|
||||
m.갈래 === "로직"
|
||||
? "ui-hopyo-grid__tag is-pum"
|
||||
: m.표지 === "연결됨"
|
||||
? "ui-text--success"
|
||||
: m.표지 === "후보"
|
||||
? "ui-text--warning"
|
||||
: "ui-text--error";
|
||||
|
||||
export interface ViewOptions {
|
||||
/** 실무자료 파일 층 */
|
||||
file: HopyoScope;
|
||||
key: string;
|
||||
/** 가져온 식 SK 를 만들 층 */
|
||||
target: HopyoScope;
|
||||
no: number;
|
||||
/** 목록 한 줄([내 일위대가로] · 엑셀 단가) */
|
||||
brief?: HopyoBrief;
|
||||
canWrite: boolean;
|
||||
/** 모달 닫고 엑셀 보기 그 호표 */
|
||||
onSheet?: () => void;
|
||||
}
|
||||
|
||||
/** 후보 줄 — 후보 목록 */
|
||||
function showNear(name: string, m: ViewMark): void {
|
||||
openModal({
|
||||
title: t("NearTitle").replace("{name}", name),
|
||||
closeLabel: t("Close"),
|
||||
dialogClass: "m02-modal",
|
||||
mount: (body) => {
|
||||
const rows = (m.후보 ?? []).map((c) => [c.키, c.이름]);
|
||||
body.append(createTable([{ label: t("Key") }, { label: t("Name") }], rows, { density: "data" })); // prettier-ignore
|
||||
body.append(createText(t("NearHint"), "hint"));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** [구조물 조합으로] — 만든 SC(이 파일 층에서 옮긴 것)로 구조물 조합 만들기 */
|
||||
const itemButton = (target: HopyoScope, sc: string, name: string, unit: string): HTMLElement =>
|
||||
createButton({ label: L("M02_CheckToItem"), size: 28, variant: "ghost", onClick: () => toItem({ target, sc, name, unit }) }); // prettier-ignore
|
||||
|
||||
/** [수정] 모달 */
|
||||
export function openHopyoView(o: ViewOptions): void {
|
||||
const root = el("div", { className: "m02-hopyo", attrs: { "data-hopyo-view": `${o.key}|${o.no}` } }); // prettier-ignore
|
||||
const host = el("div", { attrs: { "data-hopyo-view-body": "" } });
|
||||
let table: LaborDetailHandle | null = null;
|
||||
let close = (): void => {};
|
||||
|
||||
/** 단가산출 줄 — 가져온 식 SK(만들 층) → 수식 탭 모달 */
|
||||
async function openSk(m: ViewMark, name: string, head: string): Promise<void> {
|
||||
try {
|
||||
const made = await call<{ 이름: string }>(at(o.target, "/sangeun/from-hopyo"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ 층: o.file.layer, ...(o.file.owner ? { 주인: o.file.owner } : {}), 키: o.key, 번: m.번 }), // prettier-ignore
|
||||
});
|
||||
const ref = { 층: o.target.layer, 주인: o.target.owner, 키: made.이름 };
|
||||
void openSkEdit({ ref, title: `${name} — ${made.이름}`, head, readOnly: !o.canWrite, closed: () => {} }); // prettier-ignore
|
||||
} catch (e) {
|
||||
showToast(why(e), "error");
|
||||
}
|
||||
}
|
||||
|
||||
function draw(r: ViewReply): void {
|
||||
const count = (k: ViewMark["표지"]): number => r.줄.filter((m) => m.표지 === k).length;
|
||||
const logic = r.줄.filter((m) => m.갈래 === "로직").length;
|
||||
const name = [r.이름, r.규격].filter(Boolean).join(" ");
|
||||
const head = [name, r.단위 ? `1 ${r.단위} 당` : ""].filter(Boolean).join(" · ");
|
||||
const badges = [
|
||||
createBadge(t("Logic").replace("{n}", String(logic)), "accent"),
|
||||
createBadge(`${t("Linked")} ${count("연결됨")}`, "success"),
|
||||
createBadge(t("Near").replace("{n}", String(count("후보"))), "warning"),
|
||||
createBadge(`${t("None")} ${count("못 이음")}`, "danger"),
|
||||
];
|
||||
badges.forEach((b, i) => (b.dataset.viewCount = ["로직", "연결됨", "후보", "못 이음"][i]));
|
||||
const sheet = createButton({ label: t("Sheet"), size: 28, variant: "ghost", onClick: () => { close(); o.onSheet?.(); } }); // prettier-ignore
|
||||
const items: (HTMLElement | "divider")[] = [
|
||||
el("strong", { text: `제${r.번}호표 ${name}` }),
|
||||
...badges,
|
||||
"divider",
|
||||
createText(`${t("ExcelPrice")} ${r.금액?.계 == null ? "—" : formatPrice(r.금액.계)}`, "muted", 14), // prettier-ignore
|
||||
...(o.brief ? [mineButton(o.file, o.key, o.brief)] : []),
|
||||
...(o.brief?.만든 && o.canWrite
|
||||
? [itemButton(o.target, o.brief.만든, name, r.단위 ?? "")]
|
||||
: []),
|
||||
...(o.onSheet ? [sheet] : []),
|
||||
];
|
||||
root.replaceChildren(createToolbar({ items }), createText(t("Hint"), "hint"), host);
|
||||
table?.destroy();
|
||||
table = mountLaborTable(host, r.문서, {
|
||||
layer: o.target.layer,
|
||||
owner: o.target.owner,
|
||||
column: `제${r.번}호표`,
|
||||
mode: "precombo",
|
||||
readOnly: true,
|
||||
onChanged: () => {},
|
||||
view: {
|
||||
cols: [
|
||||
{ key: "x_mark", label: t("Mark"), width: 96 },
|
||||
{ key: "x_excel", label: t("Excel"), kind: "number", decimals: 1, trim: true, width: 96 },
|
||||
],
|
||||
cell: (n, key) => {
|
||||
const m = r.줄[n];
|
||||
if (!m) return null;
|
||||
return key === "x_mark" ? markText(m) : m.엑셀.계;
|
||||
},
|
||||
tone: (n, key) => (key === "x_mark" && r.줄[n] ? markTone(r.줄[n]) : null),
|
||||
onRow: (n) => {
|
||||
const m = r.줄[n];
|
||||
const line = (r.문서 as { 담은로직?: { 별칭?: string | null }[] }).담은로직?.[n];
|
||||
const label = line?.별칭 ?? name;
|
||||
if (m?.표지 === "단가산출" && m.번) return (void openSk(m, label, head), true);
|
||||
if (m?.표지 === "일위대가" && m.번) return void openHopyoView({ ...o, no: m.번, brief: undefined }), true; // prettier-ignore
|
||||
if (m?.표지 === "후보") return (void showNear(label, m), true);
|
||||
return true; // 요소 줄 = 엑셀 숫자 그대로(모달 없음)
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const stop = showLoading(root);
|
||||
try {
|
||||
draw(await readHopyoView(o.file, o.key, o.no));
|
||||
} catch (e) {
|
||||
root.replaceChildren(loadingError(why(e), () => void load()));
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
}
|
||||
|
||||
({ close } = openModal({
|
||||
title: t("Title").replace("{n}", String(o.no)),
|
||||
closeLabel: t("Close"),
|
||||
wide: true,
|
||||
width: "1100px",
|
||||
dialogClass: "m02-modal m02-hopyo__view-modal",
|
||||
beforeClose: () => {
|
||||
table?.destroy();
|
||||
return true;
|
||||
},
|
||||
mount: (body) => body.append(root),
|
||||
}));
|
||||
void load();
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
* 양식 길 읽기 · [저장] · [다시 읽기]는 UI_Main.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DataColumn } from "@ui/ui_template_data_table";
|
||||
import type { Layer } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import type { LaborCalc, LaborDoc } from "./M02_MasterTemplete_Labor_Api";
|
||||
import { mountLaborTable } from "./M02_MasterTemplete_Labor_Table";
|
||||
@@ -24,6 +25,8 @@ export interface LaborDetailOptions {
|
||||
card?: boolean;
|
||||
/** 일위대가 사전조합 서고(50-3) — 사전조합 문서(`column` = 키) */
|
||||
mode?: "precombo";
|
||||
/** 덧칸(809-1 불러오기 [수정] 보기 · 읽기만) — 줄 표지 · 엑셀 금액 열 · 줄 누름 가로챔 */
|
||||
view?: LaborView;
|
||||
/** 그 층 주인(134-3 · 사전조합 층 · null = 자기 층) — 없으면 페이지 층 것 */
|
||||
owner?: string | null;
|
||||
/** 셈 · 면적 자리 주입(142-1 · 고르기 모달 = 프로젝트 복사본) — 없으면 층 길 */
|
||||
@@ -31,6 +34,14 @@ export interface LaborDetailOptions {
|
||||
readArea?: () => Promise<{ 문서: unknown; 판?: string | null }>;
|
||||
}
|
||||
|
||||
/** 덧칸 — `cell(n, 열)` = 문서 줄 n 의 값 · `onRow(n)` 참 = 기본 줄 모달 안 엶 */
|
||||
export interface LaborView {
|
||||
cols: DataColumn[];
|
||||
cell: (n: number, key: string) => string | number | null;
|
||||
tone?: (n: number, key: string) => string | null;
|
||||
onRow?: (n: number) => boolean;
|
||||
}
|
||||
|
||||
export interface LaborDetailHandle {
|
||||
getDoc: () => LaborDoc;
|
||||
/** [엑셀에서 고르기]로 바뀐 면적산출 문서를 저장(일위대가 [저장] 앞) · 저장했으면 참(없으면 거짓) —
|
||||
|
||||
@@ -154,7 +154,11 @@ export function mountLaborTable(
|
||||
const grid = createHopyoGrid(
|
||||
[block],
|
||||
{
|
||||
columns: g.columns.map((c) => (label[c.key] ? { ...c, label: label[c.key] } : c)),
|
||||
// 809-1 덧칸 = 명칭 바로 뒤(넓은 표에서도 보임)
|
||||
columns: g.columns
|
||||
.map((c) => (label[c.key] ? { ...c, label: label[c.key] } : c))
|
||||
.flatMap((c) => (c.key === "name" ? [c, ...(opt.view?.cols ?? [])] : [c])),
|
||||
...(opt.view ? { rows: viewRows(g) } : {}), // 809-1 덧칸 값
|
||||
// 조합 갈래 — 수량 칸 누름 = 수량산출 모달(줄 모달은 안 엶)
|
||||
onCellClick: combo
|
||||
? (i, key) => {
|
||||
@@ -167,6 +171,7 @@ export function mountLaborTable(
|
||||
onRowClick: (i) => {
|
||||
if (qtyHit) return void (qtyHit = false);
|
||||
const n = at(i);
|
||||
if (n !== null && opt.view?.onRow?.(n)) return;
|
||||
if (n !== null) openAt(n);
|
||||
},
|
||||
// 줄 끝 ⋯(731-1) — 우클릭 · 길게 누름이 없어도 같은 메뉴(위로 · 아래로 · 별칭 · 삭제)
|
||||
@@ -191,7 +196,7 @@ export function mountLaborTable(
|
||||
cellClass: (i, key) => {
|
||||
const n = at(i);
|
||||
if (key === "note" && n !== null && empties.has(n)) return "ui-hopyo-grid__blank";
|
||||
return hopyoCellClass(g.rows, i, key);
|
||||
return (n !== null && opt.view?.tone?.(n, key)) || hopyoCellClass(g.rows, i, key);
|
||||
},
|
||||
},
|
||||
edit ? (text) => void pasteInto(cur, text, afterPaste) : undefined,
|
||||
@@ -205,7 +210,7 @@ export function mountLaborTable(
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => openTreeMenu(ev, menuAt(n))));
|
||||
});
|
||||
tipBorn(grid.root, at, (n) => (cur.담은로직[n] ?? cur.자재[n - cur.담은로직.length])?.본뜬); // 740-2
|
||||
const bar = unlinkedBar(cur, edit && ((qtyOnly) => changed(qtyOnly))); // 729-3 안 이은 줄 띠
|
||||
const bar = opt.view ? null : unlinkedBar(cur, edit && ((qtyOnly) => changed(qtyOnly))); // 729-3 안 이은 줄 띠 · 덧칸 = 표지 열이 대신
|
||||
const specs = combo && edit ? [specBar(cur, calc ?? last, (qtyOnly) => changed(qtyOnly))] : [];
|
||||
gridHost.replaceChildren(...(bar ? [bar] : []), ...specs, grid.root);
|
||||
dg?.draw(cur, logics, calc ?? last, openAt);
|
||||
@@ -213,6 +218,14 @@ export function mountLaborTable(
|
||||
for (const fn of watchers) fn(calc ?? last);
|
||||
};
|
||||
|
||||
/** 덧칸 값(809-1) — 구성 줄만 */
|
||||
const viewRows = (g: ReturnType<typeof hopyoGrid>) =>
|
||||
g.rows.map((r, i) => {
|
||||
const n = g.at.get(i)?.[1];
|
||||
const v = opt.view;
|
||||
return n === undefined || !v ? r : { ...r, ...Object.fromEntries(v.cols.map((c) => [c.key, v.cell(n, c.key)])) }; // prettier-ignore
|
||||
});
|
||||
|
||||
/** 서버 다시 셈 — 늦게 온 답은 버림 */
|
||||
const recalc = (): void => {
|
||||
window.clearTimeout(timer);
|
||||
|
||||
@@ -1,602 +0,0 @@
|
||||
"""근거 제안 엔진(PLAN 793-3a · 요구 648 · 649 · 652 갈래 C · 분석 648-F H1~H5 · 648-D2) — 서버
|
||||
단독 · 읽기만.
|
||||
|
||||
산근 블록(HP `단산` 꼴) → 절마다 번호 후보(`M02_Hopyo_Clause.basis`) ∩ 값
|
||||
후보(`M02_San_Basis_Value` ·
|
||||
역색인 · 유의 4) ∩ 제목 후보(이름 두 글자 묶음 Dice ≥ 0.35 · 같은 말 묶음) · 기계 작업량 절(q · K
|
||||
· f · E · Cm
|
||||
둘+)은 일 갈래(번호 · 제목) 먼저 · 차례 = 분석 648-D2 1 장 · 후보 2~6 이면 엔진 품 대조로 하나만
|
||||
맞으면 확정 ·
|
||||
같은 기초 절은 묶어 검산(`M02_San_Basis_Check`) · 절 2+ 블록은 블록 통째 길도 셈해 나은 꼴.
|
||||
꼴(648-D2) = 확정·전부 · 확정·일부 · 확정·일부(절 미확정) · 후보 2~3 · 없음.
|
||||
단(근거후보 · 화면 · 793-2 SK) = 확정(기초 확정 · 엔진 품 = 엑셀 품 · 품 다름 0) · 근접(기초 확정
|
||||
· 품 다름 ·
|
||||
품 대조 못 함 · 까닭 표지) · 사람(후보 2~3) · 없음(M01 없음 의심 → `보강` = 794-2 근거).
|
||||
자동 바꿈 없음 · 지어 넣는 값 없음(못 읽은 칸 · 글에 없는 감률 · 할증 = 입력필요).
|
||||
794-2c — 같은 말 묶음(SYN) · 옛 번호 → 현행 절(`old_cands` · `M02_San_Basis_Map.OLD`) 번호 후보 ·
|
||||
Q 식 절은 줄 글에서 기호 끝값을 더 읽고(`_fill`) 값 · 제목 길이 품 검산으로 안 받쳐지면 그 기계
|
||||
「작업량당 기계경비」 로직(`by_machine` · 차례 「기계」).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Bind as hb
|
||||
from M02_MasterTemplete import M02_Hopyo_Clause as clause
|
||||
from M02_MasterTemplete import M02_Hopyo_Formula as formula
|
||||
from M02_MasterTemplete import M02_Hopyo_Solve as solve
|
||||
from M02_MasterTemplete import M02_San_Basis_Check as chk
|
||||
from M02_MasterTemplete import M02_San_Basis_Const as bc
|
||||
from M02_MasterTemplete import M02_San_Basis_Index as bi
|
||||
from M02_MasterTemplete import M02_San_Basis_Map as bm
|
||||
from M02_MasterTemplete import M02_San_Basis_Value as bv
|
||||
|
||||
TITLE_MIN, TITLE_GAP, TITLE_TOP = 0.35, 0.15, 5
|
||||
SYN = [
|
||||
("B/H", "굴착기"),
|
||||
("BACK-HOE", "굴착기"),
|
||||
("BACKHOE", "굴착기"),
|
||||
("백호", "굴착기"),
|
||||
("굴삭", "굴착"),
|
||||
("D/Z", "불도저"),
|
||||
("도자", "불도저"),
|
||||
("불도우저", "불도저"),
|
||||
("D/P", "덤프"),
|
||||
("브레카", "브레이커"),
|
||||
("브레이카", "브레이커"),
|
||||
("깍기", "깎기"),
|
||||
("뒷채움", "뒤채움"),
|
||||
("로울러", "롤러"),
|
||||
("콤펙터", "콤팩터"),
|
||||
("레미콘", "레디믹스트콘크리트"),
|
||||
("컨테이너", "콘테이너"),
|
||||
("휠라", "채움재"),
|
||||
("평떼", "평잔디"),
|
||||
("육샹", "육상"),
|
||||
]
|
||||
ORDER = ["확정·전부", "확정·일부", "확정·일부(절 미확정)", "후보 2~3", "없음"]
|
||||
NONE = {"확정": [], "동점": [], "약함": []}
|
||||
|
||||
|
||||
class Ctx:
|
||||
"""역색인 + M01 Master + 로직 이름 묶음(역색인 지문마다 한 벌)."""
|
||||
|
||||
def __init__(self, idx: bi.Index):
|
||||
self.idx, self.m = idx, hb.m01.cm.master()
|
||||
self.logic = {
|
||||
**self.m.index.get("GC", {}),
|
||||
**self.m.index.get("GF", {}),
|
||||
**self.m.index.get("GX", {}),
|
||||
}
|
||||
self.grams = {k: grams(r.get("이름")) for k, r in self.logic.items()}
|
||||
self.mach = bm.machines(self.logic)
|
||||
|
||||
|
||||
_CTX: list = [None, None]
|
||||
|
||||
|
||||
def ctx() -> Ctx:
|
||||
idx = bi.get()
|
||||
if _CTX[0] is not idx:
|
||||
_CTX[:] = [idx, Ctx(idx)]
|
||||
return _CTX[1]
|
||||
|
||||
|
||||
# ── 제목 → 로직 이름 후보 ───────────────────────────────────────────
|
||||
def syn(text: Any) -> str:
|
||||
t = str(text or "")
|
||||
for a, b in SYN:
|
||||
t = re.sub(re.escape(a), b, t, flags=re.I)
|
||||
return t
|
||||
|
||||
|
||||
def grams(text: Any) -> set:
|
||||
return clause._grams(re.sub(r"\d", " ", syn(text)))
|
||||
|
||||
|
||||
def title_cands(c: Ctx, text: str, unit: Any) -> list[tuple[str, float]]:
|
||||
"""제목 ↔ 로직 이름 Dice ≥ 0.35 · 1등과 0.15 안 · 다섯까지 · 결과단위 성격 다르면 뺌."""
|
||||
mine = grams(text)
|
||||
if not mine:
|
||||
return []
|
||||
nat, scored = clause.nature(unit), []
|
||||
for k, gs in c.grams.items():
|
||||
theirs = clause.nature(c.logic[k].get("결과단위"))
|
||||
if not gs or nat and theirs and nat != theirs:
|
||||
continue
|
||||
s = 2 * len(mine & gs) / (len(mine) + len(gs))
|
||||
if s >= TITLE_MIN:
|
||||
scored.append((k, round(s, 3)))
|
||||
scored.sort(key=lambda x: (-x[1], x[0]))
|
||||
return [x for x in scored if x[1] >= scored[0][1] - TITLE_GAP][:TITLE_TOP] if scored else []
|
||||
|
||||
|
||||
def dice(c: Ctx, key: str, text: str) -> float:
|
||||
a, b = grams(text), c.grams.get(key, set())
|
||||
return 2 * len(a & b) / (len(a) + len(b)) if a and b else 0.0
|
||||
|
||||
|
||||
# ── 블록 · 절 ───────────────────────────────────────────────────────
|
||||
def _flat(t: Any) -> str:
|
||||
return re.sub(r"[\s.()\[\]:·,]", "", str(t or ""))
|
||||
|
||||
|
||||
def parts_of(blk: dict) -> tuple[list[dict], dict, list]:
|
||||
"""일 절(`Formula.works`)마다 {제목, 번, 길, 항목, 상수, 식, 선택값, 몫} · 블록 통째 상수 · 쓸
|
||||
항목.
|
||||
"""
|
||||
nodes = blk.get("절") or formula.tree(
|
||||
blk.get("글") or [], blk.get("글행"), blk.get("항목") or []
|
||||
)
|
||||
top = formula.head(blk.get("글") or [], blk.get("글행"))
|
||||
works = formula.works(nodes)
|
||||
paths = {id(n): p for p, n in clause._paths(nodes)}
|
||||
items = blk.get("항목") or []
|
||||
usable = solve._usable(items)
|
||||
lines = [str(t) for t in blk.get("글") or []]
|
||||
whole = {k: top[k] + solve._gather(nodes, k) for k in ("상수", "식", "선택값")}
|
||||
_fill(whole, lines)
|
||||
if len(works) < 2:
|
||||
one = {"제목": "(블록 통째)", "번": None, "길": None, "항목": usable, "글줄": lines}
|
||||
one |= whole
|
||||
one["몫"] = works[0].get("몫") if works else None
|
||||
return [one], whole, usable
|
||||
out = []
|
||||
for w in works:
|
||||
at = [i for i in solve._gather([w], "항목") if not items[i].get("밖")]
|
||||
part = {
|
||||
"제목": f"{w['번']}. {w['제목']}",
|
||||
"번": w["번"],
|
||||
"길": paths.get(id(w)),
|
||||
"항목": [items[i] for i in at],
|
||||
"글줄": lines[w["글"][0] : _end(w) + 1] if w.get("글") else [],
|
||||
}
|
||||
part |= {k: top[k] + solve._gather([w], k) for k in ("상수", "식", "선택값")}
|
||||
_fill(part, part["글줄"])
|
||||
part["몫"] = w.get("몫")
|
||||
out.append(part)
|
||||
if not any(p["항목"] for p in out): # STmate — 글행 없음 → 항목 절 이름으로 붙임
|
||||
for it in usable:
|
||||
name = _flat(it.get("윗절") or it.get("절"))
|
||||
hit = next(
|
||||
(
|
||||
p
|
||||
for p in out
|
||||
if name and (_flat(p["제목"]).endswith(name) or name in _flat(p["제목"]))
|
||||
),
|
||||
None,
|
||||
)
|
||||
if hit:
|
||||
hit["항목"].append(it)
|
||||
return out, whole, usable
|
||||
|
||||
|
||||
def _end(node: dict) -> int:
|
||||
return max([node["글"][1], *(_end(n) for n in node.get("아래") or [] if n.get("글"))])
|
||||
|
||||
|
||||
def _fill(part: dict, lines: list[str]) -> None:
|
||||
"""기계 작업량 절(Q 식 줄) — 못 읽은 기호(q · K · f · E · Cm)를 줄 글 끝값으로 더함 · 고르는
|
||||
차례(`is_machine`)는 채우기 전 상수로."""
|
||||
if bm.has_q(lines):
|
||||
part["기계식"] = is_machine(part)
|
||||
have = {chk.base_sym(x.get("기호") or "") for x in part["상수"]}
|
||||
part["상수"] = part["상수"] + [
|
||||
x for x in bm.consts(lines) if chk.base_sym(x["기호"]) not in have
|
||||
]
|
||||
|
||||
|
||||
def old_cands(c: Ctx, basis: dict, title: str, unit: Any) -> list[dict]:
|
||||
"""옛 번호 → 현행 절 로직(`M02_San_Basis_Map`) · 건설품셈(옛) 같은 번호는 같은 절 · 제목과 뜻
|
||||
겹침만 · 결과단위 성격 거름."""
|
||||
mine, have, out = clause.nature(unit), {x["키"] for x in basis["후보"]}, []
|
||||
for book, no in basis["출처"]:
|
||||
if not book.endswith(clause.OLD):
|
||||
continue
|
||||
rows = bm.old_rows(book, no)
|
||||
if not rows and book == "건설품셈" + clause.OLD:
|
||||
rows = [
|
||||
o
|
||||
for o in clause.lookup("건설품셈", no)
|
||||
if o["맞음"] == "같음" and dice(c, o["키"], title) > 0
|
||||
]
|
||||
for o in rows:
|
||||
theirs = clause.nature(o["결과단위"])
|
||||
if o["키"] not in have and not (mine and theirs and mine != theirs):
|
||||
have.add(o["키"])
|
||||
out.append({**o, "절": None})
|
||||
return out
|
||||
|
||||
|
||||
def value_ev(rec: dict | None, parts: list[dict]) -> tuple[dict, list[dict], dict]:
|
||||
"""값 길 — 블록 {확정, 동점, 약함} · 절마다 · 로직별 옵션."""
|
||||
if rec is None:
|
||||
return dict(NONE), [dict(NONE) for _ in parts], {}
|
||||
opts: dict = {}
|
||||
for b in rec.get("기초", []) + rec.get("동점", []):
|
||||
opts.setdefault(b["로직"], {}).update(b.get("옵션") or {})
|
||||
blk = {
|
||||
"확정": [b["로직"] for b in rec.get("기초", [])] if rec["판정"] in ("U", "P") else [],
|
||||
"동점": [b["로직"] for b in rec.get("동점", [])] if rec["판정"] == "A" else [],
|
||||
"약함": [b["로직"] for b in rec.get("약한후보", [])[:1]],
|
||||
}
|
||||
secs, out = rec.get("절") or [], []
|
||||
for i, p in enumerate(parts):
|
||||
s = None
|
||||
if len(parts) > 1:
|
||||
head = lambda x: _flat(x["절"]).split(".")[-1][:6] # noqa: E731
|
||||
s = (
|
||||
secs[i]
|
||||
if len(secs) == len(parts)
|
||||
else next((x for x in secs if head(x) and head(x) in _flat(p["제목"])), None)
|
||||
)
|
||||
one = {"확정": [], "동점": [], "약함": []}
|
||||
if s and s["판정"] in ("U", "P") and s.get("기초"):
|
||||
one["확정"] = [s["기초"]["로직"]]
|
||||
opts.setdefault(s["기초"]["로직"], {}).update(s["기초"].get("옵션") or {})
|
||||
elif s and s["판정"] == "A":
|
||||
one["동점"] = list(s.get("동점") or [])
|
||||
out.append(one)
|
||||
return blk, out, opts
|
||||
|
||||
|
||||
def is_machine(part: dict) -> bool:
|
||||
syms = {chk.base_sym(c.get("기호") or "") for c in part["상수"]} | {
|
||||
chk.base_sym(f.get("기호") or "") for f in part["식"]
|
||||
}
|
||||
return len(syms - {""}) >= 2
|
||||
|
||||
|
||||
def pick(
|
||||
c: Ctx, N: list, T: list, Vb: dict, Vs: dict, machine: bool, single: bool, title: str
|
||||
) -> tuple[list, str]:
|
||||
"""일반: 번호∩값 › 값 › 번호∩제목 › 값동점∩제목 › 번호 › 제목 /
|
||||
기계 절: 번호∩제목 › 번호∩값 › 제목∩값 › 번호 › 제목 › 값."""
|
||||
Tk = [k for k, _ in T]
|
||||
sure = [
|
||||
k
|
||||
for k in (Vs["확정"] or (Vb["확정"] if single else []))
|
||||
if not title or dice(c, k, title) >= 0.2
|
||||
]
|
||||
anyv = list(dict.fromkeys(Vs["확정"] + Vs["동점"] + Vb["확정"] + Vb["동점"] + Vb["약함"]))
|
||||
|
||||
def inter(a, b):
|
||||
return [x for x in a if x in set(b)]
|
||||
|
||||
if machine:
|
||||
steps = [
|
||||
("번호∩제목", inter(N, Tk)),
|
||||
("번호∩값", inter(N, anyv)),
|
||||
("제목∩값", inter(Tk, anyv)),
|
||||
("번호", N),
|
||||
("제목", Tk),
|
||||
("값", sure),
|
||||
]
|
||||
else:
|
||||
steps = [
|
||||
("번호∩값", inter(N, anyv)),
|
||||
("값", sure),
|
||||
("번호∩제목", inter(N, Tk)),
|
||||
("값동점∩제목", inter(Vs["동점"] + Vb["동점"], Tk)),
|
||||
("번호", N),
|
||||
("제목", Tk),
|
||||
]
|
||||
for name, s in steps:
|
||||
if s:
|
||||
return list(dict.fromkeys(s)), name
|
||||
return [], "없음"
|
||||
|
||||
|
||||
def _good(v: dict) -> bool:
|
||||
return "오류" not in v and not v["셈"][solve.DIFF] and bool(v["셈"][solve.SAME])
|
||||
|
||||
|
||||
def resolve(S: list, how: str, check) -> tuple[list, str]:
|
||||
"""후보 2~6 → 검산(품 같음 있고 품 다름 없는 후보 하나)으로 거름."""
|
||||
if 2 <= len(S) <= 6:
|
||||
good = [k for k in S if _good(check(k))]
|
||||
if len(good) == 1:
|
||||
return good, how + "+검산"
|
||||
return S, how
|
||||
|
||||
|
||||
def by_machine(c: Ctx, p: dict, S: list, how: str, title: str, check) -> tuple[list, str]:
|
||||
"""기계 작업량 절(Q 식) — 번호 길이 아니고 고른 기초가 품 검산으로 안 받쳐지면 그 기계
|
||||
「작업량당 기계경비」 로직(품 같음 · 또는 고른 것 없고 품 짝 있음)."""
|
||||
if how.startswith("번호") or not bm.has_q(p["글줄"]) or not is_machine(p):
|
||||
return S, how
|
||||
names = " ".join(str(i.get("이름") or "") for i in p["항목"])
|
||||
mk = bm.machine_key(c.mach, syn(f"{p['제목']} {title} {names}"))
|
||||
if not mk or mk in S or (len(S) == 1 and _good(check(S[0]))):
|
||||
return S, how
|
||||
v = check(mk)
|
||||
cnt = v.get("셈") or {}
|
||||
if _good(v) or ("오류" not in v and (cnt.get(solve.SAME) or cnt.get(solve.DIFF))):
|
||||
return [mk], "기계"
|
||||
return S, how
|
||||
|
||||
|
||||
def kind_of(S: list) -> str:
|
||||
return "확정" if len(S) == 1 else "후보 2~3" if 2 <= len(S) <= 3 else "없음"
|
||||
|
||||
|
||||
def _found(c: Ctx, key: str, v: dict, mc: list, rec: dict | None, how: str) -> dict:
|
||||
kind, missing, differ = chk.judge_one(v, mc)
|
||||
return {
|
||||
"꼴": kind,
|
||||
"기초": key,
|
||||
"기초이름": c.logic.get(key, {}).get("이름", ""),
|
||||
"차례": how,
|
||||
"옵션": v.get("입력값"),
|
||||
"옵션길": v.get("길"),
|
||||
"입력필요": v.get("입력필요"),
|
||||
"0간주": v.get("0간주"),
|
||||
"글에없음": missing,
|
||||
"값다름": differ,
|
||||
"기계상수": mc,
|
||||
"셈": v.get("셈"),
|
||||
"다름까닭": chk.causes(v, mc, rec) if differ else [],
|
||||
}
|
||||
|
||||
|
||||
def run_block(c: Ctx, blk: dict, line: dict | None, same: list, rec: dict | None) -> dict:
|
||||
"""블록 하나 합친 길 — {꼴, 블록길, 절 [...], 통째, 기초 [키]} (648-D2 꼴)."""
|
||||
basis = clause.basis(blk)
|
||||
parts, whole, usable = parts_of(blk)
|
||||
title_b = f"{blk.get('이름') or ''} {blk.get('규격') or ''}".strip()
|
||||
basis["후보"] += old_cands(c, basis, title_b, blk.get("단위"))
|
||||
src = {"줄": line, "같은줄": same, "블록": blk}
|
||||
src["제목줄"] = {
|
||||
"글": re.sub(r"[-_/()\[\],~]+", " ", f"{blk.get('이름') or ''} {blk.get('규격') or ''}"),
|
||||
"행": None,
|
||||
}
|
||||
Vb, Vsecs, vopts = value_ev(rec, parts)
|
||||
allN = list(dict.fromkeys(x["키"] for x in basis["후보"]))
|
||||
memo: dict = {}
|
||||
|
||||
def check(key, items, cons, share):
|
||||
tag = (key, id(items), share, repr(cons)) # 값에 items 를 쥐어 id 를 다시 안 씀
|
||||
if tag not in memo:
|
||||
memo[tag] = (items, chk.verify(c.m, key, items, cons, src, share, vopts.get(key, {})))
|
||||
return memo[tag][1]
|
||||
|
||||
def share_of(p):
|
||||
s = p.get("몫")
|
||||
return (s.get("값") if isinstance(s, dict) else s) or 1
|
||||
|
||||
secs = []
|
||||
for p, Vs in zip(parts, Vsecs, strict=True):
|
||||
if p["길"]:
|
||||
N = [
|
||||
x["키"]
|
||||
for x in basis["후보"]
|
||||
if x.get("절") in (None, p["길"]) or str(p["길"]).startswith(f"{x.get('절')}.")
|
||||
]
|
||||
text = f"{p['제목']} {title_b}"
|
||||
else:
|
||||
N, text = allN, title_b
|
||||
N, mach = list(dict.fromkeys(N)), p.get("기계식", is_machine(p))
|
||||
T = title_cands(c, text, blk.get("단위"))
|
||||
S, how = pick(c, N, T, Vb, Vs, mach, len(parts) == 1, text)
|
||||
cons = {x: p[x] for x in ("상수", "식", "선택값")}
|
||||
S, how = resolve(S, how, lambda k, p=p, cons=cons: check(k, p["항목"], cons, share_of(p)))
|
||||
S, how = by_machine(
|
||||
c, p, S, how, title_b, lambda k, p=p, cons=cons: check(k, p["항목"], cons, share_of(p))
|
||||
)
|
||||
secs.append(
|
||||
{
|
||||
"절": p["제목"],
|
||||
"길": p["길"],
|
||||
"항목수": len(p["항목"]),
|
||||
"기계식": mach,
|
||||
"번호": N,
|
||||
"제목후보": [k for k, _ in T],
|
||||
"차례": how,
|
||||
"후보": S,
|
||||
"_p": p,
|
||||
}
|
||||
)
|
||||
chosen = {i: s["후보"][0] for i, s in enumerate(secs) if len(s["후보"]) == 1}
|
||||
bases = list(dict.fromkeys(chosen.values()))
|
||||
group = {}
|
||||
for b in bases: # 같은 기초 절끼리 묶어 검산(기초 하나뿐이면 블록 통째)
|
||||
if len(bases) == 1:
|
||||
items, cons, ps = usable, whole, list(parts)
|
||||
else:
|
||||
ps = [secs[i]["_p"] for i, k in chosen.items() if k == b]
|
||||
items = [x for p in ps for x in p["항목"]]
|
||||
cons = {x: [y for p in ps for y in p[x]] for x in ("상수", "식", "선택값")}
|
||||
v = check(b, items, cons, share_of(ps[0]) if len(ps) == 1 else 1)
|
||||
mc = (
|
||||
chk.mach_check(c.idx, b, chk.mach_consts(cons))
|
||||
if any(s["기계식"] for s in secs)
|
||||
else []
|
||||
)
|
||||
group[b] = (v, mc)
|
||||
for i, s in enumerate(secs):
|
||||
s.pop("_p")
|
||||
if (
|
||||
i in chosen
|
||||
and s["차례"] == "제목"
|
||||
and not _good({"셈": group[chosen[i]][0].get("셈") or {solve.DIFF: 0, solve.SAME: 0}})
|
||||
):
|
||||
s |= {"꼴": "후보 2~3", "제목단독": True} # 제목 하나뿐 · 품 대조로 못 받침 = 사람 확인
|
||||
elif i in chosen:
|
||||
s |= _found(c, chosen[i], *group[chosen[i]], rec, s["차례"])
|
||||
else:
|
||||
s["꼴"] = kind_of(s["후보"])
|
||||
kinds = [s["꼴"] for s in ([s for s in secs if s["항목수"]] or secs)]
|
||||
if all(k == "확정·전부" for k in kinds):
|
||||
bk = "확정·전부"
|
||||
elif all(k.startswith("확정") for k in kinds):
|
||||
bk = "확정·일부"
|
||||
elif any(k.startswith("확정") for k in kinds):
|
||||
bk = "확정·일부(절 미확정)"
|
||||
else:
|
||||
bk = "후보 2~3" if "후보 2~3" in kinds else "없음"
|
||||
out = {"절": secs, "표지": basis["표지"], "출처": basis["출처"], "번호후보": allN}
|
||||
wb = None
|
||||
if len(parts) > 1: # 블록 통째 길 — 블록 제목 · 번호 · 블록 값으로 기초 하나 → 블록 전부로 검산
|
||||
mach = any(s["기계식"] for s in secs)
|
||||
S, how = pick(
|
||||
c, allN, title_cands(c, title_b, blk.get("단위")), Vb, dict(NONE), mach, True, title_b
|
||||
)
|
||||
S, how = resolve(S, how, lambda k: check(k, usable, whole, 1))
|
||||
if len(S) == 1:
|
||||
v = check(S[0], usable, whole, 1)
|
||||
mc = chk.mach_check(c.idx, S[0], chk.mach_consts(whole)) if mach else []
|
||||
wb = _found(c, S[0], v, mc, rec, how)
|
||||
if how == "제목" and not _good({"셈": v.get("셈") or {solve.DIFF: 0, solve.SAME: 0}}):
|
||||
wb["꼴"] = "후보 2~3"
|
||||
else:
|
||||
wb = {"꼴": kind_of(S), "후보": S, "차례": how}
|
||||
if wb and ORDER.index(wb["꼴"]) < ORDER.index(bk) and (wb.get("셈") or {}).get(solve.SAME):
|
||||
out |= {"꼴": wb["꼴"], "블록길": "통째", "통째": wb, "기초": [wb["기초"]]}
|
||||
else:
|
||||
out |= {"꼴": bk, "블록길": "절 모음" if len(parts) > 1 else "통째(절 하나)", "통째": wb}
|
||||
out["기초"] = list(dict.fromkeys(s["기초"] for s in secs if s.get("기초")))
|
||||
return out
|
||||
|
||||
|
||||
# ── 세 단 · 근거후보 ────────────────────────────────────────────────
|
||||
def chosen_units(r: dict) -> list[dict]:
|
||||
"""블록 판정에 쓰인 단위 — 통째 길이면 통째 하나 · 아니면 기초마다 첫 절."""
|
||||
if r["블록길"] == "통째" and r.get("통째"):
|
||||
return [r["통째"]]
|
||||
out, seen = [], set()
|
||||
for s in r["절"]:
|
||||
if s.get("기초") and s["기초"] not in seen:
|
||||
seen.add(s["기초"])
|
||||
out.append(s)
|
||||
return out
|
||||
|
||||
|
||||
def checked(s: dict) -> str:
|
||||
"""품 검산 — 다름 · 같음 · 짝 없음."""
|
||||
cnt = s.get("셈") or {}
|
||||
return "다름" if cnt.get(solve.DIFF) else "같음" if cnt.get(solve.SAME) else "짝 없음"
|
||||
|
||||
|
||||
def _tier(s: dict) -> str:
|
||||
return "확정" if checked(s) == "같음" and not s["값다름"] else "근접"
|
||||
|
||||
|
||||
def cands(c: Ctx, r: dict) -> list[dict]:
|
||||
"""근거후보 [{기초, 이름, 원문번호, 옵션, 옵션길, 입력필요, 품검산, 단, 까닭, 글에없음,
|
||||
값다름}] ·
|
||||
사람 = 후보 2~3(각 후보 · 옵션 없음) · 없음 = []."""
|
||||
if r["꼴"].startswith("확정"):
|
||||
out = []
|
||||
for s in chosen_units(r):
|
||||
opts = {k: v for k, v in (s.get("옵션") or {}).items()}
|
||||
row = c.logic.get(s["기초"], {})
|
||||
out.append(
|
||||
{
|
||||
"기초": s["기초"],
|
||||
"이름": row.get("이름", ""),
|
||||
"원문번호": row.get("원문번호"),
|
||||
"차례": s["차례"],
|
||||
"옵션": opts,
|
||||
"옵션길": s.get("옵션길") or {},
|
||||
"입력필요": list(s["입력필요"] or []) + list(s["0간주"] or []),
|
||||
"품검산": checked(s),
|
||||
"단": _tier(s),
|
||||
"까닭": [[chk.CAUSE[t], x] for t, x in s["다름까닭"]],
|
||||
"글에없음": s["글에없음"],
|
||||
"값다름": s["값다름"],
|
||||
}
|
||||
)
|
||||
return out
|
||||
if r["꼴"] == "후보 2~3":
|
||||
keys = list(
|
||||
dict.fromkeys(
|
||||
k for s in r["절"] if s["꼴"] == "후보 2~3" for k in (s.get("후보") or [])
|
||||
)
|
||||
)
|
||||
return [
|
||||
{
|
||||
"기초": k,
|
||||
"이름": c.logic.get(k, {}).get("이름", ""),
|
||||
"원문번호": c.logic.get(k, {}).get("원문번호"),
|
||||
"옵션": {},
|
||||
"입력필요": [],
|
||||
"품검산": None,
|
||||
"단": "사람",
|
||||
"까닭": [],
|
||||
}
|
||||
for k in keys[:6]
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
def lack(blk: dict, r: dict) -> dict:
|
||||
"""없음 까닭 — a 기초 없음(번호 확인) · a′ 품목 없음 의심 · f 후보 4+ · 증거 흩어짐 · 794-2
|
||||
보강 근거.
|
||||
"""
|
||||
if r["표지"] == "기초 없음":
|
||||
why = "a 기초 없음(번호 확인)"
|
||||
elif any(s.get("후보") for s in r["절"]):
|
||||
why = "f 후보 4+ · 증거 흩어짐"
|
||||
else:
|
||||
why = "a′ 품목 없음 의심"
|
||||
name = re.sub(r"\(.*?\)|\[.*?\]|[^가-힣]", "", f"{blk.get('이름') or ''}")[:8]
|
||||
return {"까닭": why, "이름": name, "품셈": [f"{b} {n}" for b, n in r["출처"]]}
|
||||
|
||||
|
||||
def suggest(
|
||||
blk: dict,
|
||||
kind: str,
|
||||
line: dict | None = None,
|
||||
same: list | None = None,
|
||||
g: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""산근 블록 하나 → 근거 제안 {단, 꼴, 근거후보, 절, 보강?, 값판정}.
|
||||
`kind` = 책 꼴(STmate · 엑셀꼴) · `line` · `same` = 부르는 첫 줄 · 그 호표 다른 줄(B1 · B6) ·
|
||||
`g` = STmate 숨은 G 식(없으면 보이는 글)."""
|
||||
c = ctx()
|
||||
pr = bc.prep(blk, kind, g)
|
||||
rec = bv.value(c.idx, pr)
|
||||
r = run_block(c, blk, line, same or [], rec)
|
||||
out = {**r, "값판정": rec["판정"], "근거후보": cands(c, r)}
|
||||
if r["꼴"].startswith("확정"):
|
||||
tiers = {x["단"] for x in out["근거후보"]}
|
||||
outside = [
|
||||
m[6:] for x in out["근거후보"] for m in x["글에없음"] if m.startswith("기초 밖 줄 ")
|
||||
]
|
||||
out["까닭"] = [["기초 밖 줄", ", ".join(outside)]] if outside else [] # 엑셀에만 있는 줄
|
||||
sure = tiers == {"확정"} and r["꼴"] != "확정·일부(절 미확정)"
|
||||
out["단"] = "확정" if sure else "근접"
|
||||
else:
|
||||
out["단"] = {"후보 2~3": "사람"}.get(r["꼴"], "없음")
|
||||
weak = (rec.get("약한후보") or [None])[0]
|
||||
title = f"{blk.get('이름') or ''} {blk.get('규격') or ''}"
|
||||
if (
|
||||
weak
|
||||
and weak["로직"] not in {x["기초"] for x in out["근거후보"]}
|
||||
and dice(c, weak["로직"], title) >= 0.2
|
||||
):
|
||||
row = c.logic.get(
|
||||
weak["로직"], {}
|
||||
) # 값 길 약한 1 등(유의 4 미만) — 제목과 뜻 겹치면 사람 후보 맨 앞
|
||||
opts = {k: v for k, v in weak["옵션"].items()}
|
||||
out["근거후보"].insert(
|
||||
0,
|
||||
{
|
||||
"기초": weak["로직"],
|
||||
"이름": row.get("이름", ""),
|
||||
"원문번호": row.get("원문번호"),
|
||||
"차례": "값약함",
|
||||
"옵션": opts,
|
||||
"옵션길": dict.fromkeys(opts, "V"),
|
||||
"입력필요": [],
|
||||
"품검산": None,
|
||||
"단": "사람",
|
||||
"까닭": [],
|
||||
},
|
||||
)
|
||||
out["단"] = "사람"
|
||||
if out["단"] == "없음":
|
||||
out["보강"] = lack(blk, r)
|
||||
return out
|
||||
@@ -1,446 +0,0 @@
|
||||
"""근거 제안 4 — 기초 하나 검산 · 옵션 · 까닭(PLAN 793-3a · 분석 648-D2 1 장) · 읽기만 · 지어 넣는
|
||||
값 없음.
|
||||
|
||||
옵션 = 기존 `M02_Hopyo_Bind.bind`(B1 부르는 줄 규격 · B2 선택값 + 블록 제목 낱말 · B3 기호표 · B4
|
||||
대입식 ·
|
||||
B5 표 거꾸로 · B6 레미콘 규격) → 못 읽은 칸 = 값 길 옵션(「V」) → 그래도 고르기 칸이면 고르기
|
||||
값마다 넣어
|
||||
엑셀 품과 맞댐(조합 60 까지 · 다 같음 하나 = 「검산」 · ±15 % 근접 하나 = 「근접」) → 남은 칸 =
|
||||
입력 필요.
|
||||
감률 · 할증 · 가산 · 보정 · 구멍땜 수 칸은 셈 때만 0(검산 몫) · 옵션으로 안 냄(입력 필요 · 요구
|
||||
647-1).
|
||||
품 대조 = `M02_Hopyo_Solve.pair`(엑셀 표시 자릿수 반올림 폭) · 기계 상수(q · K · f · E · E0 · Cm) =
|
||||
그 로직(하위 포함)이 닿는 표의 같은 기호 칸 · 같은 기호 줄 값과 대조(`mach_check`).
|
||||
까닭(`causes`) = b 판 차이 · c 현장 값 · d 감률 · 할증 배수 · e 단위 기준 차이 · f 그 밖(분석
|
||||
648-D2 5-2 규칙).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import math
|
||||
import re
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Bind as hb
|
||||
from M02_MasterTemplete import M02_Hopyo_Solve as solve
|
||||
from M02_MasterTemplete import M02_San_Basis_Index as bi
|
||||
|
||||
RATIOS = {
|
||||
8: "일↔시간 ×8",
|
||||
1 / 8: "일↔시간 ÷8",
|
||||
100: "% ×100",
|
||||
0.01: "% ÷100",
|
||||
1000: "t↔kg ×1000",
|
||||
0.001: "t↔kg ÷1000",
|
||||
10: "×10",
|
||||
0.1: "÷10",
|
||||
60: "분↔시간 ×60",
|
||||
1 / 60: "분↔시간 ÷60",
|
||||
}
|
||||
CAUSE = {
|
||||
"b": "판 차이",
|
||||
"c": "현장 값",
|
||||
"d": "감률 · 할증 배수",
|
||||
"e": "단위 기준 차이",
|
||||
"f": "그 밖",
|
||||
}
|
||||
ZERO = re.compile(r"감률|할증|가산|구멍땜|보정|증가|추가")
|
||||
|
||||
|
||||
# ── 기계 상수 ↔ 그 로직 표 ──────────────────────────────────────────
|
||||
def _norm(s: Any) -> str:
|
||||
s = re.sub(r"\(.*?\)", "", str(s or "").replace("㎝", "cm").lower())
|
||||
return re.sub(r"[^a-z0-9]", "", s)
|
||||
|
||||
|
||||
def base_sym(s: str) -> str:
|
||||
m = re.match(r"(cm|e0|q|k|f|e)\d*$", _norm(s))
|
||||
return m.group(1) if m else ""
|
||||
|
||||
|
||||
def mach_consts(cons: dict) -> list[dict]:
|
||||
out = list(cons.get("상수", []))
|
||||
for f in cons.get("식", []): # 식 끝값(Cm = t1+…+t4 = 10.7)은 셈 결과 — 대입 수만
|
||||
out += [{"기호": k, "값": v} for k, v in (f.get("대입") or {}).items()]
|
||||
return out
|
||||
|
||||
|
||||
def mach_check(idx: bi.Index, key: str, consts: list[dict]) -> list[dict]:
|
||||
out, seen = [], set()
|
||||
tables = idx.item_tables.get(key, set())
|
||||
for c in consts:
|
||||
sym, v = base_sym(c.get("기호") or ""), c.get("값")
|
||||
if not sym or not isinstance(v, (int, float)) or (c.get("기호"), v) in seen:
|
||||
continue
|
||||
seen.add((c.get("기호"), v))
|
||||
pool = []
|
||||
for t in tables:
|
||||
tb = idx.tables[t]
|
||||
for row in tb.get("줄", []):
|
||||
row_sym = any(isinstance(x, str) and base_sym(x) == sym for x in row.values())
|
||||
for col in tb.get("값칸", {}):
|
||||
if base_sym(col) == sym or row_sym:
|
||||
pool += bi.nums_of(row.get(col))
|
||||
if not pool:
|
||||
out.append({"기호": c["기호"], "값": v, "대조": "칸 없음"})
|
||||
continue
|
||||
d = len(str(v).split(".")[1]) if "." in str(v) else 0
|
||||
near = min(pool, key=lambda x: abs(x - v))
|
||||
hit = abs(near - v) <= max(0.5 * 10 ** (-d), abs(v) * 0.005)
|
||||
out.append(
|
||||
{
|
||||
"기호": c["기호"],
|
||||
"값": v,
|
||||
"대조": "표에 있음" if hit else "표에 없음",
|
||||
"가까운": near,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
# ── 옵션 · 검산 ─────────────────────────────────────────────────────
|
||||
def zeroable(m: Any, key: str, name: str) -> bool:
|
||||
"""글에 없으면 0(없음)으로 볼 만한 수 칸 — 고르기 없음 · 이름이 감률 · 할증 · 가산 · 보정 류."""
|
||||
spec = next((x for x in hb.specs_of(m.logic(key)) if x["이름"] == name), {})
|
||||
return not spec.get("고르기") and bool(ZERO.search(name))
|
||||
|
||||
|
||||
def _conv(v: Any) -> Any:
|
||||
if isinstance(v, list):
|
||||
ends = [x for x in v if isinstance(x, (int, float))]
|
||||
return sum(ends) / len(ends) if ends else None
|
||||
return v
|
||||
|
||||
|
||||
_RANGE = re.compile(r"^\s*([\d.]+)\s*(?:[∼~]\s*([\d.]+))?\s*$")
|
||||
_SIZE = re.compile(r"용량|규격|톤|출력")
|
||||
|
||||
|
||||
def _ranges(opts: list, base: dict) -> list:
|
||||
"""고르기가 모두 범위(「0.6∼0.8」 · 수 하나)면 이미 든 용량 · 규격 값이 드는 범위만(794-2c ·
|
||||
버킷용량 → 규격구분) · 하나로 안 좁혀지면 그대로."""
|
||||
spans = [_RANGE.match(str(o)) for o in opts]
|
||||
if not all(spans):
|
||||
return opts
|
||||
nums = [
|
||||
float(v)
|
||||
for n, v in base.items()
|
||||
if _SIZE.search(n) and isinstance(v, (int, float, Decimal))
|
||||
]
|
||||
hit = [
|
||||
o for o, m in zip(opts, spans) if any(float(m[1]) <= x <= float(m[2] or m[1]) for x in nums)
|
||||
]
|
||||
return hit if len(hit) == 1 else opts
|
||||
|
||||
|
||||
def probe(
|
||||
m: Any, key: str, specs: dict, hard: list, base: dict, items: list, share: Any
|
||||
) -> dict | None:
|
||||
"""못 읽은 고르기 칸을 고르기 값마다 넣어 엑셀 품과 맞댐 — 「검산」 · 「근접」 옵션."""
|
||||
if not hard or not all(specs.get(n, {}).get("고르기") for n in hard):
|
||||
return None
|
||||
opts = [_ranges(specs[n]["고르기"], base) for n in hard]
|
||||
if math.prod(len(o) for o in opts) > 60:
|
||||
return None
|
||||
exact, close = [], []
|
||||
|
||||
def sig(rr):
|
||||
return tuple(round(r["우리"]["품"] or 0, 6) for r in rr if r.get("우리"))
|
||||
|
||||
for combo in itertools.product(*opts):
|
||||
g2 = dict(zip(hard, combo))
|
||||
try:
|
||||
rr = solve.pair(hb.quantities(m, key, {**base, **g2}), solve._usable(items), share)
|
||||
except Exception: # noqa: BLE001 — 그 조합은 셈 못 함
|
||||
continue
|
||||
same = sum(r["판정"] == solve.SAME for r in rr)
|
||||
if any(r["판정"] == solve.NEED for r in rr) or not same:
|
||||
continue
|
||||
ratios = [
|
||||
r["엑셀"]["품"] / r["우리"]["품"]
|
||||
for r in rr
|
||||
if r["판정"] == solve.DIFF and r["엑셀"]["품"] and r["우리"]["품"]
|
||||
]
|
||||
if not ratios:
|
||||
exact.append((g2, rr))
|
||||
elif all(abs(x - 1) <= 0.15 for x in ratios):
|
||||
close.append((same, -len(ratios), g2, rr))
|
||||
if len(exact) == 1:
|
||||
return {"값": exact[0][0], "줄": exact[0][1], "길": "검산"}
|
||||
if len(exact) > 1 and len({sig(rr) for _, rr in exact}) == 1:
|
||||
return {"값": exact[0][0], "줄": exact[0][1], "길": "검산(같은 품 여럿)"}
|
||||
if not exact and close:
|
||||
close.sort(key=lambda x: (-x[0], -x[1]))
|
||||
top = [c for c in close if (c[0], c[1]) == (close[0][0], close[0][1])]
|
||||
if len(top) == 1 or len({sig(c[3]) for c in top}) == 1:
|
||||
return {
|
||||
"값": close[0][2],
|
||||
"줄": close[0][3],
|
||||
"길": "근접" + ("" if len(top) == 1 else "(같은 품 여럿)"),
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def verify(m: Any, key: str, items: list, consts: dict, src: dict, share: Any, vopts: dict) -> dict:
|
||||
"""기초 하나 검산 — bind(B1~B6) · 값 길 옵션 · 고르기 맞댐 · 엑셀 품 대조."""
|
||||
mine = {**src, **consts}
|
||||
if src.get("제목줄"):
|
||||
mine["선택값"] = list(consts.get("선택값") or []) + [src["제목줄"]]
|
||||
try:
|
||||
b = hb.bind(m, key, mine, solve.fitter(m, key, items, share))
|
||||
specs = {s["이름"]: s for s in hb.specs_of(m.logic(key))}
|
||||
given = {**b["입력값"], **{n: v[0]["값"] for n, v in b["엇갈림"].items()}}
|
||||
fromv = {
|
||||
n: _conv(vopts[n])
|
||||
for n in b["입력필요"] + b["안씀"]
|
||||
if n in vopts and _conv(vopts[n]) is not None
|
||||
}
|
||||
need = [n for n in b["입력필요"] if n not in fromv]
|
||||
zero = {n: 0 for n in need if zeroable(m, key, n)}
|
||||
idle = {
|
||||
n: b["기본"][n] for n in b["안씀"] if b["기본"].get(n) is not None and n not in fromv
|
||||
}
|
||||
base = {**idle, **given, **fromv, **zero}
|
||||
rows = solve.pair(hb.quantities(m, key, base), solve._usable(items), share)
|
||||
got = probe(m, key, specs, [n for n in need if n not in zero], base, items, share)
|
||||
if got:
|
||||
fromv |= got["값"]
|
||||
need = [n for n in need if n not in got["값"]]
|
||||
rows = got["줄"]
|
||||
same_q = [] # 엇갈림인데 어느 값이든 품이 같음 = 문제 아님
|
||||
for n, alts in b["엇갈림"].items():
|
||||
sigs = set()
|
||||
for alt in alts:
|
||||
q = hb.quantities(m, key, {**idle, **given, **fromv, **zero, n: alt["값"]})
|
||||
sigs.add(
|
||||
tuple(round(float(x["품"]), 6) if x.get("품") is not None else None for x in q)
|
||||
)
|
||||
if len(sigs) == 1:
|
||||
same_q.append(n)
|
||||
except Exception as e: # noqa: BLE001 — 검산 못 함 = 오류 표지(확정 안 함)
|
||||
return {"키": key, "오류": repr(e)[:160]}
|
||||
blk = src["블록"]
|
||||
text = " ".join(
|
||||
[str(blk.get("이름") or ""), str(blk.get("규격") or "")]
|
||||
+ [str(t) for t in blk.get("글") or []]
|
||||
)
|
||||
words = {"소량": "소량|소형|소규모", "높이": "높이|수직고"}
|
||||
absorbed = [
|
||||
[n, hb.plain(given[n])]
|
||||
for n, w in b["출처"].items()
|
||||
if w["길"] == "B5"
|
||||
and n.endswith("감률")
|
||||
and n in given
|
||||
and not re.search(words.get(n.replace("감률", ""), n.replace("감률", "") or "소량"), text)
|
||||
]
|
||||
cnt = {
|
||||
v: sum(r["판정"] == v for r in rows)
|
||||
for v in (solve.SAME, solve.DIFF, solve.NEED, solve.EXCEL, solve.OURS)
|
||||
}
|
||||
diff = []
|
||||
for r in rows:
|
||||
if r["판정"] == solve.DIFF:
|
||||
ex, us = r["엑셀"]["품"], r["우리"]["품"]
|
||||
diff.append(
|
||||
{
|
||||
"줄": r["엑셀"].get("이름") or r["우리"].get("이름"),
|
||||
"엑셀": ex,
|
||||
"우리": us,
|
||||
"배": round(ex / us, 4) if ex and us else None,
|
||||
"묶음": r["엑셀"].get("묶음"),
|
||||
}
|
||||
)
|
||||
outside = [
|
||||
r["엑셀"].get("이름")
|
||||
for r in rows
|
||||
if r["판정"] == solve.EXCEL and r["엑셀"] and r["엑셀"].get("품")
|
||||
]
|
||||
way = {n: alts[0]["길"] for n, alts in b["엇갈림"].items()} # 엇갈림 = 첫 값
|
||||
way |= {k: v["길"] for k, v in b["출처"].items()}
|
||||
way |= {k: "V" for k in fromv if not got or k not in got["값"]}
|
||||
way |= {k: got["길"] for k in got["값"]} if got else {}
|
||||
return {
|
||||
"키": key,
|
||||
"셈": cnt,
|
||||
"기초밖": outside,
|
||||
"감률흡수": absorbed,
|
||||
"입력값": {
|
||||
**{k: hb.plain(v) for k, v in given.items()},
|
||||
**{k: hb.plain(v) for k, v in fromv.items()},
|
||||
},
|
||||
"길": way,
|
||||
"입력필요": [n for n in need if n not in zero],
|
||||
"0간주": list(zero),
|
||||
"엇갈림": {
|
||||
k: [hb.plain(x["값"]) for x in v] for k, v in b["엇갈림"].items() if k not in same_q
|
||||
},
|
||||
"품다름": diff,
|
||||
}
|
||||
|
||||
|
||||
def judge_one(v: dict, mach: list[dict]) -> tuple[str, list, list]:
|
||||
"""(확정·전부 | 확정·일부, 글에 없음, 값이 M01 과 다름)."""
|
||||
if "오류" in v:
|
||||
return "확정·일부", [], [f"검산 오류 {v['오류'][:60]}"]
|
||||
missing = list(v["입력필요"]) + [f"옵션 여럿 {k} {vals}" for k, vals in v["엇갈림"].items()]
|
||||
missing += [f"기초 밖 줄 {x}" for x in v.get("기초밖", [])]
|
||||
differ = [
|
||||
f"품 {d['줄']} 엑셀 {d['엑셀']:.4g} ≠ 우리 {d['우리']:.4g}"
|
||||
for d in v["품다름"]
|
||||
if d["엑셀"] and d["우리"]
|
||||
]
|
||||
differ += [
|
||||
f"기계 {x['기호']}={x['값']} 표에 없음(가까운 {x['가까운']:g})"
|
||||
for x in mach
|
||||
if x["대조"] == "표에 없음"
|
||||
]
|
||||
differ += [f"감률 {n}={x} 로 흡수(글에 그 말 없음) — 품 배수" for n, x in v.get("감률흡수", [])]
|
||||
differ += [
|
||||
f"근접 옵션 {k}={v['입력값'].get(k)}"
|
||||
for k, w in (v.get("길") or {}).items()
|
||||
if str(w).startswith("근접")
|
||||
]
|
||||
if not v["셈"].get(solve.SAME) and not v["셈"].get(solve.DIFF):
|
||||
missing.append("(품 짝 없음)")
|
||||
return ("확정·전부" if not missing and not differ else "확정·일부"), missing, differ
|
||||
|
||||
|
||||
def causes(v: dict, mach: list[dict], rec: dict | None) -> list[list[str]]:
|
||||
"""값 다름 → [갈래 b~f, 글] 【추정 규칙 · 648-D2 5-2】."""
|
||||
out = []
|
||||
user = bool(rec) and (
|
||||
bool(rec.get("입력"))
|
||||
or any(
|
||||
"kg/" in str(k.get("단위", "")).lower() or "㎏/" in str(k.get("단위", ""))
|
||||
for k in rec.get("K", [])
|
||||
)
|
||||
)
|
||||
labor = [d["배"] for d in v.get("품다름", []) if d["배"] and d["묶음"] in ("L", "X")]
|
||||
uniform = len(labor) >= 2 and all(abs(x / labor[0] - 1) <= 0.02 for x in labor)
|
||||
for d in v.get("품다름", []):
|
||||
r = d["배"]
|
||||
if not r:
|
||||
out.append(["f", f"{d['줄']} 품 0"])
|
||||
continue
|
||||
norm = next((name for k, name in RATIOS.items() if abs(r / k - 1) <= 0.01), None)
|
||||
if norm:
|
||||
out.append(["e", f"{d['줄']} {norm}"])
|
||||
elif uniform and abs(r - 1) > 0.15:
|
||||
out.append(["d", f"{d['줄']} 같은 배 ×{r:g}"])
|
||||
elif abs(r - 1) <= 0.15:
|
||||
out.append(["c" if user else "b", f"{d['줄']} ×{r:g}"])
|
||||
else:
|
||||
out.append(["c" if user else "f", f"{d['줄']} ×{r:g}"])
|
||||
for n, x in v.get("감률흡수", []):
|
||||
out.append(["b" if isinstance(x, (int, float)) and x < 15 else "d", f"감률 {n}={x} 흡수"])
|
||||
for x in mach:
|
||||
if x["대조"] == "표에 없음":
|
||||
out.append(
|
||||
[
|
||||
"b" if x["가까운"] and abs(x["값"] / x["가까운"] - 1) <= 0.15 else "c",
|
||||
f"기계 {x['기호']}={x['값']}",
|
||||
]
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
# ── 판 표지(PLAN 794-2b · 분석 652-794-2a 5 장) — 근거후보 까닭 「판 차이」에 판 번호 ──────────
|
||||
STRONG = ("판 차이", "현장 값", "그 밖") # 둘 이상 파일 같은 배면 「판 차이 강함」 으로 올림
|
||||
_TEXT_YEAR = re.compile(r"(?<!\d)((?:20)?\d\d)\s*년\s*(?:도\s*)?[^\d\n]{0,12}?품셈")
|
||||
_RATIO = re.compile(r"×([0-9.]+(?:e[-+]?\d+)?)")
|
||||
|
||||
|
||||
def _year(m: re.Match | None) -> int | None:
|
||||
y = int(m.group(1)) % 100 if m else None
|
||||
return None if y is None else y + (2000 if y < 50 else 1900)
|
||||
|
||||
|
||||
def file_year(name: Any) -> int | None:
|
||||
"""파일 이름 연도 — 「2024년 …」 · 「00.25년_…」 · 「20250527 2025년 …」."""
|
||||
s = str(name or "")
|
||||
return _year(re.search(r"(20\d\d)\s*년", s) or re.search(r"(?<!\d)(\d\d)\s*년", s))
|
||||
|
||||
|
||||
def text_year(blk: dict) -> int | None:
|
||||
"""블록 글 속 판 — 「2017년 품셈」 · 「24년 건설표준품셈」(가장 옛 것)."""
|
||||
text = " ".join(str(t) for t in [blk.get("이름"), blk.get("규격"), *(blk.get("글") or [])])
|
||||
found = [_year(m) for m in _TEXT_YEAR.finditer(text)]
|
||||
return min(found) if found else None
|
||||
|
||||
|
||||
def edition_tag(key: str, rev: list, year: int, by: str) -> str:
|
||||
"""기초 절 개정 가운데 블록 판 뒤 것 — 「'24 보완 · 파일 2024」 · 없으면 빈 글.
|
||||
|
||||
글 속 판 해 = 그 해 개정을 담음(뒤 것만) · 파일 해 = 같은 해 개정이 앞일 수도(같은 해 포함)."""
|
||||
later = [int(y) for y in rev or [] if int(y) > year or (by == "파일" and int(y) == year)]
|
||||
if not later:
|
||||
return ""
|
||||
word = "제정" if key.startswith("GF") else "보완"
|
||||
return f"{' · '.join(f'{chr(39)}{y % 100:02d}' for y in later)} {word} · {by} {year}"
|
||||
|
||||
|
||||
def edition(out: dict, file_name: Any, blk: dict) -> dict:
|
||||
"""제안 근거후보마다 판 표지 `판`(덧칸) · 까닭 「판 차이」 → 「판 차이(판 표지)」.
|
||||
|
||||
셈 · 단 · 꼴 안 바뀜(표지 글만)."""
|
||||
by_text, by_file = text_year(blk), file_year(file_name)
|
||||
if by_text and (not by_file or by_text <= by_file):
|
||||
year, by = by_text, "글"
|
||||
else:
|
||||
year, by = by_file, "파일"
|
||||
if not year:
|
||||
return out
|
||||
index = hb.m01.cm.master().index
|
||||
for c in out.get("근거후보") or []:
|
||||
key = str(c.get("기초") or "")
|
||||
tag = edition_tag(key, (index.get(key[:2], {}).get(key) or {}).get("개정"), year, by)
|
||||
if tag:
|
||||
c["판"] = tag
|
||||
c["까닭"] = [
|
||||
[f"판 차이({tag})" if lab == "판 차이" else lab, txt] for lab, txt in c["까닭"]
|
||||
]
|
||||
return out
|
||||
|
||||
|
||||
def _ratios(c: dict) -> list[float]:
|
||||
return [
|
||||
float(m.group(1))
|
||||
for lab, txt in c.get("까닭") or []
|
||||
if lab.startswith(STRONG) and (m := _RATIO.search(str(txt)))
|
||||
]
|
||||
|
||||
|
||||
def strong(got: dict, others: list[dict]) -> dict:
|
||||
"""같은 기초 · 같은 배(±2 %)가 다른 파일에도 판 표지와 함께 있으면 「판 차이 강함(…)」."""
|
||||
seen = [
|
||||
(c["기초"], r)
|
||||
for o in others
|
||||
for p in o.values()
|
||||
for c in p.get("근거후보") or []
|
||||
if c.get("판")
|
||||
for r in _ratios(c)
|
||||
]
|
||||
if not seen:
|
||||
return got
|
||||
|
||||
def hit(key: str, lab: str, text: Any) -> bool:
|
||||
m = _RATIO.search(str(text)) if lab.startswith(STRONG) else None
|
||||
return bool(m) and any(
|
||||
k == key and r and abs(float(m.group(1)) / r - 1) <= 0.02 for k, r in seen
|
||||
)
|
||||
|
||||
out = {}
|
||||
for no, p in got.items():
|
||||
cs = []
|
||||
for c in p.get("근거후보") or []:
|
||||
if c.get("판"):
|
||||
why = [
|
||||
[f"판 차이 강함({c['판']})", t] if hit(c["기초"], lab, t) else [lab, t]
|
||||
for lab, t in c["까닭"]
|
||||
]
|
||||
c = {**c, "까닭": why}
|
||||
cs.append(c)
|
||||
out[no] = {**p, "근거후보": cs}
|
||||
return out
|
||||
@@ -1,378 +0,0 @@
|
||||
"""근거 제안 2 — 산근 블록 글 → 상수 지문(PLAN 793-3a · 분석 648-D 1-2 뽑기) · 읽기만.
|
||||
|
||||
뽑기 = 엑셀꼴(EST · STmate 보이는 글): 콜론 앞 이름 · 단가 앞 규격 · 쉼표 금액 · 항목 단가 · 「=」
|
||||
뒤 결과
|
||||
(앞 마디가 셈일 때) · 소계 줄 · 절 제목 줄 · 품셈 근거 줄 · 품셈 번호 · 연도 뺌 · 정수 끝 점 지움 /
|
||||
STmate 숨은 G 식: 따옴표 글 뺌 · 수 바로 뒤 짧은 따옴표 = 단위 · `&…&` 참조 뺌.
|
||||
N9 일반 수(1 · 100 · 1000 · 60 분 · 8 시간 = 「일반」 표지) · N10 입력(L · 거리 · km) 뺌 ·
|
||||
인력 줄 몫(「a 인 / b」 · 「a / Q」 = 블록 안 대입)에 직종 표지.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Formula as formula
|
||||
from M02_MasterTemplete import M02_Hopyo_ReadEst as est
|
||||
from M02_MasterTemplete import M02_San_Basis_Index as bi
|
||||
|
||||
fam = bi.fam
|
||||
UNIT = (
|
||||
r"(㎡|㎥|㎏|kg|Kg|KG|ton|Ton|TON|톤|m3|M3|m2|M2|㎝|cm|㎜|mm|km|Km|KM|ℓ|hr|Hr|HR"
|
||||
r"|시간|분|초|sec|min|일|인|회|본|개|매|조|대|%|EA|ea|t(?![a-zA-Z])|m(?![a-zA-Z²³])|M(?![a-zA-Z]))"
|
||||
)
|
||||
NUMTOK = re.compile(
|
||||
r"(?<![A-Za-z가-힣_\d.,ⅠⅡⅢⅣ#])(\d[\d,]*(?:\.\d*)?)\s*(?:⟨([^⟩]*)⟩|" + UNIT + r")?"
|
||||
)
|
||||
DROP = [
|
||||
re.compile(
|
||||
r"(?<![=\d.])\b\d{1,2}(?:\s*-\s*\d{1,3}){1,3}\b|(?:19|20)\d\d\s*[-.]\s*\d{1,2}"
|
||||
), # 품셈 번호 · 날짜
|
||||
re.compile(r"(?:19|20)?\d\d\s*년\s*(?:도)?"),
|
||||
re.compile(r"Type\s*-?\s*[ⅠⅡⅢIV]+(?:\s*-\s*\d)?|[ⅠⅡⅢ]\s*-\s*\d", re.I),
|
||||
re.compile(r"제\s*\d+\s*호표?|\d+\s*호표|#\s*\d+"),
|
||||
re.compile(r"\d+\s*차선"),
|
||||
]
|
||||
HEADNO = re.compile(r"^\s*(?:\d+\s*\.(?!\d)|[가-하]\s*\.|\(\d+\)|\d+\)|[①-⑳ㅇ○◇□※·\-*]+)")
|
||||
INPUT_SYM = re.compile(r"^(L\d*|l\d*|거리|운반거리|경사\w*|구배|α|θ)$")
|
||||
SYM = re.compile(r"([A-Za-z][A-Za-z0-9]{0,3}|[가-힣]{2,6}|[α-ωΑ-Ω])\s*=\s*$")
|
||||
OPER = re.compile(r"[\d)A-Za-z]\s*[*/+\-]\s*[\d(A-Za-z]")
|
||||
QUO = re.compile(
|
||||
r"(?<![A-Za-z가-힣_\d.,])(\d+(?:\.\d+)?)\s*(?:⟨인⟩|인|명)?\s*(?:\*\s*1\s*(?:⟨일⟩|일)\s*)?/\s*(\d+(?:\.\d+)?|[A-Za-z][A-Za-z0-9]{0,3})(?![A-Za-z0-9.])"
|
||||
)
|
||||
|
||||
|
||||
def nk(s: Any) -> str:
|
||||
s = unicodedata.normalize("NFKC", str(s or ""))
|
||||
return s.replace("×", "*").replace("÷", "/").replace("=", "=").replace(":", ":")
|
||||
|
||||
|
||||
def _f(s: Any) -> float | None:
|
||||
try:
|
||||
return float(str(s).replace(",", "").rstrip("."))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def g_text(g: str) -> tuple[str, str]:
|
||||
"""STmate G 식 → (절 나누기 글, 수 뽑기 글) — 따옴표 글은 이름 · 수 뒤 짧은 따옴표 = 단위."""
|
||||
parts = g.split("'")
|
||||
out: list[str] = []
|
||||
for i, p in enumerate(parts):
|
||||
if i % 2 == 0:
|
||||
out.append(p)
|
||||
elif re.search(r"\d\s*$", parts[i - 1]) and len(p) <= 8 and not re.search(r"\d", p):
|
||||
out[-1] = parts[i - 1].rstrip()
|
||||
out.append(f"⟨{p}⟩")
|
||||
else:
|
||||
out.append(" ")
|
||||
s = re.sub(r"&[^&]*&", " ¤ ", "".join(out)) # 단가 · 금액 범위 참조
|
||||
s = re.sub(r":=\s*A\d+|:\s*T\d+|->|\(-?=+\)|\[[A-Za-z]\w*\]", " ", s)
|
||||
return g.replace("'", ""), s
|
||||
|
||||
|
||||
def result_spans(s: str) -> list[tuple[int, int]]:
|
||||
"""「=」 마디 중 바로 앞 마디가 셈인 마디의 첫 수 = 결과 — 여러 대입 줄(「q = 0.7 K =
|
||||
0.9」)은 결과 아님.
|
||||
"""
|
||||
out, at = [], 0
|
||||
segs = s.split("=")
|
||||
for i, seg in enumerate(segs):
|
||||
last = i == len(segs) - 1
|
||||
if (
|
||||
i
|
||||
and OPER.search(segs[i - 1].split(",")[-1])
|
||||
and (last or not OPER.search(seg.split(",")[0]))
|
||||
):
|
||||
m = re.search(r"\d[\d,]*(?:\.\d+)?", seg)
|
||||
if m and not seg[: m.start()].strip(" ({"):
|
||||
out.append((at + m.start(), at + m.end()))
|
||||
at += len(seg) + 1
|
||||
return out
|
||||
|
||||
|
||||
def extract(text: str, prices: set, results: set, g: bool) -> tuple[list[dict], list[dict], str]:
|
||||
"""줄 하나 → (상수, 입력, 수 글) — 결과 · 단가 · 금액 · 이름 · 번호 뺌."""
|
||||
s = nk(text)
|
||||
if not g and "=" not in s and (formula._title(s.strip()) or re.search(r"품\s*셈", s)):
|
||||
return [], [], "" # 절 제목 · 품셈 근거 줄 — 규격 · 번호뿐
|
||||
for r in DROP:
|
||||
s = r.sub(" ", s)
|
||||
s = HEADNO.sub(" ", s)
|
||||
s = re.sub(r"(?<=\d)\.(?!\d)", "", s) # 엑셀꼴 정수 끝 점(「5. 인 / 45. ㎡」)
|
||||
if not g:
|
||||
if ":" in s and "=" not in s.split(":")[0]:
|
||||
s = s.split(":", 1)[1]
|
||||
else: # 콜론 없는 재료 · 인력 줄 — 단가 앞(이름 · 규격) 자름
|
||||
for m in re.finditer(r"\d[\d,]*(?:\.\d+)?", s):
|
||||
x = _f(m.group())
|
||||
if (
|
||||
x is not None
|
||||
and ("," in m.group() or x in prices)
|
||||
and "=" not in s[: m.start()]
|
||||
):
|
||||
s = s[m.start() :]
|
||||
break
|
||||
skip = [] if g else result_spans(s)
|
||||
consts, inputs = [], []
|
||||
for m in NUMTOK.finditer(s):
|
||||
if any(a <= m.start(1) < z for a, z in skip):
|
||||
continue # 셈 결과
|
||||
raw = m.group(1)
|
||||
if "," in raw.strip(","):
|
||||
continue # 금액 · 단가
|
||||
raw = raw.rstrip(".,")
|
||||
x = _f(raw)
|
||||
if x is None or x == 0:
|
||||
continue
|
||||
unit = m.group(2) if m.group(2) is not None else (m.group(3) or "")
|
||||
if not unit and re.match(r"[가-힣]", s[m.end() : m.end() + 1]):
|
||||
continue # 「2차선」 · 「3층」 같은 낱말 속 수
|
||||
sym_hit = SYM.search(s[: m.start()])
|
||||
sym = sym_hit.group(1) if sym_hit else ""
|
||||
prev = s[: m.start()].rstrip()[-1:]
|
||||
one = {
|
||||
"v": x,
|
||||
"d": len(raw.split(".")[1]) if "." in raw else 0,
|
||||
"단위": unit,
|
||||
"기호": sym,
|
||||
"글": text.strip()[:90],
|
||||
}
|
||||
if x in prices or (not g and x in results):
|
||||
continue
|
||||
speed = re.match(r"\s*/\s*h", s[m.end() : m.end() + 4], re.I)
|
||||
if (
|
||||
(sym and INPUT_SYM.match(sym))
|
||||
or fam(unit) == "길이"
|
||||
and unit.lower() in ("km",)
|
||||
and not speed
|
||||
):
|
||||
inputs.append(one)
|
||||
continue
|
||||
if (
|
||||
(x in (1, 100, 1000) and unit != "%")
|
||||
or (x == 60 and fam(unit) in (None, "시간"))
|
||||
or (
|
||||
x == 8
|
||||
and (prev == "/" or fam(unit) == "시간" or re.search(r"hr|시간", s, re.I))
|
||||
and fam(unit) in (None, "시간")
|
||||
)
|
||||
):
|
||||
one["일반"] = True
|
||||
consts.append(one)
|
||||
return consts, inputs, s
|
||||
|
||||
|
||||
def quotients(body: str, labor: bool, syms: dict) -> list[dict]:
|
||||
"""인력 줄 「3 / 4.5」 · 「5인 / 45」 · 「2인 * 1일 / 50」 · 「5인 / Q」 → 몫 상수(인/양)."""
|
||||
out = []
|
||||
for m in QUO.finditer(body):
|
||||
a, bt = _f(m.group(1)), m.group(2)
|
||||
b = syms.get(bt) if re.match(r"[A-Za-z]", bt) else _f(bt)
|
||||
unit_p = bool(re.search(r"⟨인⟩|인|명", body[m.start() : m.start(1) + len(m.group(1)) + 4]))
|
||||
if not a or not b or not (labor or unit_p) or b in (8, 60, 100):
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"v": a / b,
|
||||
"d": 9,
|
||||
"단위": "인",
|
||||
"기호": "",
|
||||
"몫": f"{a:g}/{b:g}",
|
||||
"글": body.strip()[:90],
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def trade_of(text: str) -> str | None:
|
||||
"""인력 줄 이름 → 우리 인력 이름(모르면 None)."""
|
||||
m = est._COLON.match(nk(text))
|
||||
if not m:
|
||||
return None
|
||||
name = re.sub(r"^[0-9가-힣]\.|^[○◇□※·\-*ㅇ\s]+", "", re.sub(r"\s+", "", m.group("name")))
|
||||
return bi.trade(name)
|
||||
|
||||
|
||||
# ── 인력 줄 가름(엑셀꼴) ─────────────────────────────────────────────
|
||||
_SWAP = [
|
||||
("m3", "㎥"),
|
||||
("M3", "㎥"),
|
||||
("m2", "㎡"),
|
||||
("M2", "㎡"),
|
||||
("m", "m"),
|
||||
("Ton", "톤"),
|
||||
("TON", "톤"),
|
||||
("ton", "톤"),
|
||||
("굴착기", "굴삭기"),
|
||||
("백호", "굴삭기"),
|
||||
("B/H", "굴삭기"),
|
||||
("BACK-HOE", "굴삭기"),
|
||||
("브레카", "브레이커"),
|
||||
("브레이카", "브레이커"),
|
||||
("뒤채움", "뒷채움"),
|
||||
("Ⅰ", "I"),
|
||||
("Ⅱ", "II"),
|
||||
("ll", "II"),
|
||||
("=", "="),
|
||||
(":", ":"),
|
||||
]
|
||||
_TOTAL = re.compile(r"^(소계|계|합계|총계|인부소계|자재소계|주자재소계|인건비소계)[:]?#?$")
|
||||
|
||||
|
||||
def _fk(s: Any) -> str:
|
||||
s = unicodedata.normalize("NFKC", str(s or ""))
|
||||
for a, b in _SWAP:
|
||||
s = s.replace(a, b)
|
||||
return s
|
||||
|
||||
|
||||
def _skel(line: str) -> str:
|
||||
s = re.sub(r"(?<=[\d\s)])[xX×](?=[\s\d(])", "*", _fk(line)).replace("×", "*").replace("÷", "/")
|
||||
s = re.sub(r"(?<=\d)\.(?=[*/=%+\-)\s]|$)", "", re.sub(r"\s+", "", s))
|
||||
return re.sub(r"\d[\d,]*(?:\.\d+)?", "#", s)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _names() -> dict:
|
||||
return est.trade_names()
|
||||
|
||||
|
||||
def labor(line: str) -> bool:
|
||||
"""인력 줄(「보통인부 : …」) — 소계 · 품셈 근거 줄 뺌."""
|
||||
s = _fk(line).strip()
|
||||
f, sk = re.sub(r"\s+", "", s), _skel(s)
|
||||
if (
|
||||
not f
|
||||
or _TOTAL.match(sk)
|
||||
or re.match(r"^(\d+\.)?(합계|소계|계)[:]?", f)
|
||||
and "*" not in sk
|
||||
and "/" not in sk
|
||||
):
|
||||
return False
|
||||
if re.search(r"품[셈줌]", s) and "=" not in s:
|
||||
return False
|
||||
cm = est._COLON.match(s)
|
||||
nm = (
|
||||
re.sub(
|
||||
r"^[○◇□※·\-*]+", "", re.sub(r"^[0-9가-힣]\.", "", re.sub(r"\s+", "", cm.group("name")))
|
||||
)
|
||||
if cm
|
||||
else ""
|
||||
)
|
||||
return bool(cm and (_names().get(nm) or bi.trade(nm)))
|
||||
|
||||
|
||||
# ── 블록 → 줄 · 절 · 상수 ────────────────────────────────────────────
|
||||
def results_of(lines: list[str], items: list[dict]) -> tuple[set, set]:
|
||||
prices, results = set(), set()
|
||||
for it in items or []:
|
||||
prices |= {
|
||||
float(v) for v in (it.get("단가") or {}).values() if isinstance(v, (int, float)) and v
|
||||
}
|
||||
if isinstance(it.get("금액"), (int, float)):
|
||||
results.add(float(it["금액"]))
|
||||
if isinstance(it.get("기준값"), (int, float)):
|
||||
results.add(round(float(it["기준값"]), 1))
|
||||
for t in lines:
|
||||
s = nk(t)
|
||||
results |= {_f(s[a:z]) for a, z in result_spans(s)}
|
||||
if re.search(r"(소\s*계|합\s*계|총\s*계|^\s*계\s*:)", s):
|
||||
results |= {_f(m.group()) for m in re.finditer(r"\d[\d,]*(?:\.\d+)?", s)}
|
||||
results.discard(None)
|
||||
return prices, results
|
||||
|
||||
|
||||
def block_lines(blk: dict, kind: str, g: list[str] | None) -> list[dict]:
|
||||
"""[{글(절 나누기), 수글, g}] — STmate + 숨은 G 식이 있으면 G · 그 밖 보이는 글."""
|
||||
out = []
|
||||
if kind == "STmate" and g:
|
||||
for gl in g:
|
||||
tree, s = g_text(gl)
|
||||
if tree.strip() and not re.fullmatch(r"\s*\(-?=+\)\s*", tree):
|
||||
out.append({"글": tree, "수글": s, "g": True})
|
||||
else:
|
||||
out = [{"글": t, "수글": t, "g": False} for t in blk.get("글") or [] if t and t.strip()]
|
||||
return out
|
||||
|
||||
|
||||
def sections(lines: list[dict]) -> list[tuple[str, list[int]]]:
|
||||
texts = [ln["글"] for ln in lines]
|
||||
works = formula.works(formula.tree(texts, list(range(len(texts)))))
|
||||
if not works:
|
||||
return [("(블록 통째)", list(range(len(texts))))]
|
||||
return [
|
||||
(f"{w['번']}. {w['제목']}"[:40], list(range(w["글"][0], w["글"][1] + 1))) for w in works
|
||||
]
|
||||
|
||||
|
||||
def prep(blk: dict, kind: str, g: list[str] | None = None) -> dict:
|
||||
"""블록 → 줄 · 줄별 (상수, 입력) · 절."""
|
||||
lines = block_lines(blk, kind, g)
|
||||
prices, results = results_of(blk.get("글") or [], blk.get("항목") or [])
|
||||
per_line, bodies = [], []
|
||||
for ln in lines:
|
||||
cs, ins, body = extract(ln["수글"], prices, results, ln["g"])
|
||||
per_line.append([cs, ins])
|
||||
bodies.append(body)
|
||||
syms: dict = {}
|
||||
for cs, _ in per_line:
|
||||
for c in cs:
|
||||
if c["기호"] and c["기호"] not in syms:
|
||||
syms[c["기호"]] = c["v"]
|
||||
for i, ln in enumerate(lines):
|
||||
lab = re.search(r"&L\d", ln["글"]) if ln["g"] else labor(ln["글"])
|
||||
tr = trade_of(ln["글"]) if lab else None
|
||||
qs = quotients(bodies[i], bool(lab), syms)
|
||||
if tr:
|
||||
tops = {q["몫"].split("/")[0] for q in qs}
|
||||
for c in per_line[i][0]:
|
||||
if fam(c["단위"]) == "사람" or f"{c['v']:g}" in tops:
|
||||
c["직종"] = tr
|
||||
for q in qs:
|
||||
q["직종"] = tr
|
||||
per_line[i][0] += qs
|
||||
in_vals = {round(c["v"], 9) for _, ins in per_line for c in ins if c.get("기호")}
|
||||
for cs, _ in per_line:
|
||||
cs[:] = [c for c in cs if round(c["v"], 9) not in in_vals or c.get("몫")]
|
||||
return {"lines": lines, "per_line": per_line, "secs": sections(lines) if lines else []}
|
||||
|
||||
|
||||
def gather(per_line: list, ids: Any) -> tuple[list[dict], list[dict]]:
|
||||
"""줄 묶음 → (상수 K, 입력) — 같은 수 · 율과 소수 쌍(2 % · 0.02)은 하나(단위 있는 쪽)."""
|
||||
K: list[dict] = []
|
||||
seen, ins, at = set(), [], {}
|
||||
for i in ids:
|
||||
cs, ii = per_line[i]
|
||||
ins += ii
|
||||
for c in cs:
|
||||
vk = (round(c["v"], 9), c.get("직종"), bool(c.get("몫")))
|
||||
if vk in at and fam(c["단위"]) != "율" and fam(K[at[vk]]["단위"]) != "율":
|
||||
if not K[at[vk]]["단위"] and c["단위"]:
|
||||
K[at[vk]] = {**c, "줄": i}
|
||||
continue
|
||||
key = (round(c["v"], 9), fam(c["단위"]), c.get("직종"))
|
||||
twin = (
|
||||
(round(c["v"] * 100, 9), "율", None)
|
||||
if fam(c["단위"]) is None and c["v"] < 1
|
||||
else ((round(c["v"] / 100, 9), None, None) if fam(c["단위"]) == "율" else None)
|
||||
)
|
||||
if key in seen or twin in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
at.setdefault(vk, len(K))
|
||||
K.append({**c, "줄": i})
|
||||
return K, ins
|
||||
|
||||
|
||||
def corpus(preps: list[dict]) -> list[list]:
|
||||
"""보정 자료 — 블록들 상수(일반 뺌 · 같은 꼴 하나) [[v, d, 단위, 직종, 몫]]."""
|
||||
keys: dict = {}
|
||||
for pr in preps:
|
||||
for c in gather(pr["per_line"], range(len(pr["lines"])))[0]:
|
||||
if not c.get("일반"):
|
||||
keys[bi.kkey(c)] = None
|
||||
return [list(k) for k in keys]
|
||||
@@ -8,8 +8,8 @@
|
||||
(하위 로직 3 겹까지 펼침 · 인자 이름 바꿈) · 식 속 수(`inline`) · 호표 수량 속 수(`inline_trade` ·
|
||||
직종 표지).
|
||||
정규화(`lookup`) — N1 표시 자릿수 반올림 · N2 % ↔ 소수 · N3 t ↔ kg · N4 인/양 몫 · N5 직종 묶음 ·
|
||||
N6 표 기준 · N7 식 속 수 · N8 단위 성격 · N9 일반 수 · N10 입력 뺌(N9 · N10 =
|
||||
`M02_San_Basis_Const`).
|
||||
N6 표 기준 · N7 식 속 수 · N8 단위 성격(N9 · N10 옛 근거 제안 몫은 809-1 에서 지움 ·
|
||||
쓰는 곳 = 소요량 · 계수 이음 `M02_Sangeun_QtyLinks`).
|
||||
우연 보정(`q_of` · `pb_sig`) = 보정 자료(실무 여덟 파일 상수 · `M02_San_Basis_corpus.json`) 몫 ·
|
||||
포아송 이항 꼬리 −log10.
|
||||
짓기 = M01 폴더 지문(`stamp`)마다 한 번(`get` · 프로세스 안 캐시 · 지문 바뀌면 다시).
|
||||
@@ -31,10 +31,12 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from common_util import common_util_derived_store as derived
|
||||
from M02_MasterTemplete import M02_Hopyo_Bind as hb
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M02_MasterTemplete import M02_Hopyo_ReadEst as est
|
||||
|
||||
mf, ma = hb.mf, hb.ma
|
||||
mf = m01.cm.mf
|
||||
import master_auto as ma # noqa: E402 — mf 를 불러 scripts 경로가 깔린 뒤
|
||||
|
||||
CORPUS = Path(__file__).with_name("M02_San_Basis_corpus.json")
|
||||
INLINE_MAX = 20 # 식 속 수가 이보다 많은 로직(표를 식으로 펼친 것)은 식 속 수 맞음을 안 셈
|
||||
PERSON = ("인",)
|
||||
@@ -135,7 +137,7 @@ def same_trade(a: str, b: str) -> bool:
|
||||
|
||||
# ── M01 읽기 · 로직 훑기 ─────────────────────────────────────────────
|
||||
def folder() -> Path:
|
||||
return Path(hb.m01.FOLDER)
|
||||
return Path(m01.FOLDER)
|
||||
|
||||
|
||||
def stamp() -> str:
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
"""근거 제안 5 — 옛 번호 대조표 · 기계 작업량 읽기(PLAN 794-2c · 요구 653 · 분석 652-794-2a 3 장) ·
|
||||
읽기만 · 지어 넣는 값 없음.
|
||||
|
||||
옛 번호 → 현행 절 = 대조표 `OLD`(근거 = 원문 표 값 · 실무 글) › 사방품셈(옛) = 지식DB 판 대응표
|
||||
「2018 통합 산림사업 표준품셈」 확정 한 대응 › 건설품셈(옛) 같은 번호 = 현행 절 로직 이름과 블록
|
||||
제목(같은 말 묶음 뒤)이 뜻 낱말을 나눌 때만(엔진 몫).
|
||||
기계 작업량 절 = 글에 「3600 … Cm」 Q 식 · 기호(q · K · f · E · Cm) 끝값을 줄 글에서 읽음(여러 대입
|
||||
한 줄 · 「q (버킷용량) = 0.7」 꼴) · 기계 낱말 → 그 기계 「작업량당 기계경비」 로직(이름에서 뽑음).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Clause as clause
|
||||
|
||||
# (옛 책, 옛 번호) → (현행 구분, 현행 번호, 근거)
|
||||
OLD = {
|
||||
("건설품셈(옛)", "11-3"): (
|
||||
"건설품셈 공통",
|
||||
"8-2-3",
|
||||
"실무 글 「건설표준품셈 11-3(굴삭기)」(봉화 · 울진대흥 2024) = 현행 8-2-3 굴착기",
|
||||
),
|
||||
("건설품셈(옛)", "3-7"): (
|
||||
"건설품셈 공통",
|
||||
"3-9-1",
|
||||
"현행 3-9-1 벌목 표(5m미만 벌목부 2.14 · 보통인부 0.51 · 굴착기+집게 2.71) = 블록 글"
|
||||
" 「건설표준품셈 3-7」(동부청영월 2024)",
|
||||
),
|
||||
("건설품셈(옛)", "8-2-35"): (
|
||||
"건설품셈 공통",
|
||||
"8-2-32",
|
||||
"현행 8-2-32 이동식 임목파쇄기 93.25㎾ Q 6.0 · 발전기 250㎾ · 보통인부 2인 · 굴착기 0.7㎥"
|
||||
"+집게 = 블록 글 「표준품셈 8-2-35」(동부청영월 2024)",
|
||||
),
|
||||
}
|
||||
_SABANG = "2018 통합 산림사업 표준품셈"
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def sabang_map() -> dict[str, str]:
|
||||
"""사방품셈(옛) 번호 → 현행 산림 번호 — 판 대응표 「2018 통합」 판 · 확정 · 한 대응만."""
|
||||
with open(clause._ROAD_MAP, encoding="utf-8-sig", newline="") as f:
|
||||
rows = list(csv.DictReader(f))
|
||||
out = {}
|
||||
for row in rows:
|
||||
old, new = clause._OLD_NO.match(row["과거_절"].strip()), row["현행_절"].strip()
|
||||
if row["STmate_인용판"] != _SABANG or row["판정"] != "확정" or not old:
|
||||
continue
|
||||
if clause._OLD_NO.fullmatch(new) and " " not in new:
|
||||
out[old.group(1)] = new
|
||||
return out
|
||||
|
||||
|
||||
def old_rows(book: str, number: str) -> list[dict]:
|
||||
"""옛 출처 하나 → 현행 로직 줄(`clause.lookup` 꼴) · 대조 없음 = []."""
|
||||
if (book, number) in OLD:
|
||||
return clause.lookup(*OLD[(book, number)][:2])
|
||||
if book == "사방품셈" + clause.OLD and number in sabang_map():
|
||||
return clause.lookup("산림품셈", sabang_map()[number])
|
||||
return []
|
||||
|
||||
|
||||
# ── 기계 작업량 절 ──────────────────────────────────────────────────
|
||||
_Q = re.compile(r"3,?600\.?\s*[×x*]")
|
||||
_SYM = re.compile(
|
||||
r"(?<![A-Za-z가-힣/*×·])(q|K|k|f|F|E|Cm|CM)\d?\s*(?:\((?:[^()=]|\([^()]*\))*\))?\s*="
|
||||
)
|
||||
_NUM = re.compile(r"\d[\d,]*(?:\.\d*)?")
|
||||
_RATIO = re.compile(r"\s*\d[\d.]*\s*/\s*\d[\d.]*\s*,?\s*")
|
||||
_CANON = {"k": "K", "F": "f", "CM": "Cm"}
|
||||
|
||||
|
||||
def has_q(lines: list[str]) -> bool:
|
||||
"""Q = 3600 × q × K × f × E / Cm 식 줄이 있나."""
|
||||
text = " ".join(lines)
|
||||
return bool(_Q.search(text)) and "Cm" in text
|
||||
|
||||
|
||||
def consts(lines: list[str]) -> list[dict[str, Any]]:
|
||||
"""줄 글 → [{기호, 값, 덧?}] — 기호 마디마다 괄호 글 뺀 마지막 수(「f = 1/1.3 = 0.77」 = 0.77)
|
||||
· Cm 괄호 각도 = 덧 · 첫 값만(같은 기호 둘째부터 뺌)."""
|
||||
out, seen = [], set()
|
||||
for line in lines:
|
||||
hits = list(_SYM.finditer(line))
|
||||
for i, m in enumerate(hits):
|
||||
body = line[m.end() : hits[i + 1].start() if i + 1 < len(hits) else len(line)]
|
||||
plain = re.sub(r"\((?:[^()]|\([^()]*\))*\)", " ", body)
|
||||
nums = [float(x.replace(",", "")) for x in _NUM.findall(plain.split("sec")[0])]
|
||||
sym = _CANON.get(m.group(1), m.group(1))
|
||||
if not nums or sym in seen:
|
||||
continue
|
||||
ratio = _RATIO.fullmatch(plain) # 「f = 1/1.25」 끝값 없는 나눗셈
|
||||
one: dict[str, Any] = {
|
||||
"기호": sym,
|
||||
"값": round(nums[0] / nums[1], 6) if ratio else nums[-1],
|
||||
}
|
||||
if sym == "Cm" and (deg := re.search(r"\(\s*(\d+)\s*˚?\s*\)", body)):
|
||||
one["덧"] = f"{deg.group(1)}˚"
|
||||
seen.add(sym)
|
||||
out.append(one)
|
||||
return out
|
||||
|
||||
|
||||
def machines(logic: dict[str, dict]) -> dict[str, str]:
|
||||
"""{기계 낱말: 「작업량당 기계경비」 로직 키} — 로직 이름 첫 낱말(괄호 앞)."""
|
||||
out = {}
|
||||
for k, r in sorted(logic.items()):
|
||||
name = str(r.get("이름") or "")
|
||||
if "작업량당 기계경비" in name:
|
||||
out.setdefault(re.split(r"[\s(]", name)[0], k)
|
||||
return out
|
||||
|
||||
|
||||
def machine_key(table: dict[str, str], text: str) -> str | None:
|
||||
"""글(같은 말 묶음 뒤)에 기계 낱말이 하나뿐이면 그 로직 키."""
|
||||
found = {k for w, k in table.items() if w in text}
|
||||
return found.pop() if len(found) == 1 else None
|
||||
@@ -1,151 +0,0 @@
|
||||
"""근거 제안 길 · 저장본(PLAN 793-3a) — 서버 단독 · 읽기만(HP 는 안 고침).
|
||||
|
||||
올림 = HP 에 `숨은식 {번: [G 식]}`(STmate 단가산출근거 숨은 G 열 · `hidden`) + 뒤 셈(`ensure`).
|
||||
저장본 = HP 폴더 `_derived/san_basis_{키}` · 입력 지문 = HP(지문 · 판 · 올린때) + M01 역색인 지문
|
||||
+ 판 ·
|
||||
지문 다를 때만 다시 셈 · GET 은 안 지움 · [끝내기] = `forget`.
|
||||
답 꼴 = `listing` {셈중, 블록 {번: 제안}, 보강 [{이름, 품셈, 까닭, 블록 [번]}]} · `block` = 제안
|
||||
하나
|
||||
(`M02_San_Basis.suggest` 꼴 — 단 · 꼴 · 근거후보 · 절 · 보강?).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from common_util import common_util_derived_store as derived
|
||||
from M02_MasterTemplete import M02_Hopyo_Judge as judge
|
||||
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
|
||||
from M02_MasterTemplete import M02_San_Basis as basis
|
||||
from M02_MasterTemplete import M02_San_Basis_Check as chk
|
||||
from M02_MasterTemplete import M02_San_Basis_Index as bi
|
||||
|
||||
KIND = "san_basis_"
|
||||
VERSION = "794-2b.1" # 제안 꼴이 바뀌면 올림
|
||||
SHEET = "단가산출근거"
|
||||
_LOCK, _RUNNING = threading.Lock(), set()
|
||||
|
||||
|
||||
def hidden(book: dict[str, Any], sheets: dict[str, list[list[Any]]]) -> dict[str, list[str]]:
|
||||
"""STmate 블록마다 숨은 G 열 원문 식(「_x0007_」 머리 줄만) · 그 밖 = {}."""
|
||||
rows = sheets.get(SHEET) or []
|
||||
if book.get("꼴") != "STmate" or not rows:
|
||||
return {}
|
||||
out = {}
|
||||
for blk in book.get("단산") or []:
|
||||
start, end = blk["행"]
|
||||
got = []
|
||||
for r in range(start, end + 1):
|
||||
row = rows[r - 1] if 0 < r <= len(rows) else []
|
||||
v = str(row[6]) if len(row) > 6 and row[6] is not None else ""
|
||||
if v.startswith("_x0007_") and v != "_x0007_":
|
||||
got.append(v.replace("_x0007_", "").strip())
|
||||
out[str(blk["번"])] = got
|
||||
return out
|
||||
|
||||
|
||||
def one(book: dict[str, Any], no: int) -> dict[str, Any]:
|
||||
blk = next(b for b in book.get("단산") or [] if int(b["번"]) == int(no))
|
||||
line, same = judge._caller(book, int(no))
|
||||
g = (book.get("숨은식") or {}).get(str(no))
|
||||
out = basis.suggest(blk, book.get("꼴") or "", line, same, g)
|
||||
return chk.edition(out, book.get("파일"), blk) # 794-2b 판 표지
|
||||
|
||||
|
||||
def _stamp(key: str, book: dict[str, Any]) -> str:
|
||||
return derived.derived_key(
|
||||
VERSION, key, book.get("지문"), book.get("판"), book.get("올린때"), bi.stamp()
|
||||
)
|
||||
|
||||
|
||||
def solved(folder: Path, key: str) -> dict[str, Any] | None:
|
||||
book = hopyo_store.read(folder, key)
|
||||
got = derived.read_derived(hopyo_store._dir(folder), KIND + key, _stamp(key, book))
|
||||
return got["value"] if got else None
|
||||
|
||||
|
||||
def solve_all(folder: Path, key: str) -> dict[str, Any]:
|
||||
book = hopyo_store.read(folder, key)
|
||||
out = {
|
||||
str(b["번"]): one(book, int(b["번"]))
|
||||
for b in book.get("단산") or []
|
||||
if b.get("번") is not None
|
||||
}
|
||||
derived.write_derived(hopyo_store._dir(folder), KIND + key, _stamp(key, book), out, keep=1)
|
||||
return out
|
||||
|
||||
|
||||
def _work(folder: Path, key: str) -> None:
|
||||
try:
|
||||
solve_all(folder, key)
|
||||
except Exception: # noqa: BLE001 — 뒤 셈 실패 = 다음 읽기에서 다시(파생 저장본)
|
||||
pass
|
||||
finally:
|
||||
with _LOCK:
|
||||
_RUNNING.discard(f"{folder}|{key}")
|
||||
|
||||
|
||||
def ensure(folder: Path, key: str) -> bool:
|
||||
"""저장본 있음 = 참 · 없으면 뒤 셈 시작(같은 파일 한 번) = 거짓."""
|
||||
if solved(folder, key) is not None:
|
||||
return True
|
||||
tag = f"{folder}|{key}"
|
||||
with _LOCK:
|
||||
if tag in _RUNNING:
|
||||
return False
|
||||
_RUNNING.add(tag)
|
||||
threading.Thread(target=_work, args=(Path(folder), key), daemon=True).start()
|
||||
return False
|
||||
|
||||
|
||||
def shortfall(got: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""없음 블록 → M01 보강 목록(794-2 근거) — 이름 · 품셈 묶음마다 블록 번."""
|
||||
out: dict[tuple, dict] = {}
|
||||
for no, r in got.items():
|
||||
if r.get("보강"):
|
||||
b = r["보강"]
|
||||
one_ = out.setdefault((b["이름"], tuple(b["품셈"])), {**b, "블록": []})
|
||||
one_["블록"].append(int(no))
|
||||
return sorted(out.values(), key=lambda x: (-len(x["블록"]), x["이름"]))
|
||||
|
||||
|
||||
def _others(folder: Path, key: str) -> list[dict[str, Any]]:
|
||||
"""같은 폴더 다른 HP 저장본(셈 끝난 것만) — 판 차이 강함(794-2b) 대조."""
|
||||
out = []
|
||||
for r in hopyo_store.rows(folder):
|
||||
if r.get("키") and r["키"] != key:
|
||||
try:
|
||||
got = solved(folder, r["키"])
|
||||
except Exception: # noqa: BLE001 — 못 읽는 HP = 대조에서 뺌
|
||||
got = None
|
||||
if got:
|
||||
out.append(got)
|
||||
return out
|
||||
|
||||
|
||||
def listing(folder: Path, key: str) -> dict[str, Any]:
|
||||
"""{셈중, 블록 {번: 제안}, 보강} — 셈 중 = 블록 {} · 뒤 셈 시작."""
|
||||
got = solved(folder, key)
|
||||
if got is None:
|
||||
ensure(folder, key)
|
||||
return {"키": key, "셈중": True, "블록": {}, "보강": []}
|
||||
got = chk.strong(got, _others(folder, key))
|
||||
return {"키": key, "셈중": False, "블록": got, "보강": shortfall(got)}
|
||||
|
||||
|
||||
def block(folder: Path, key: str, no: int) -> dict[str, Any]:
|
||||
"""블록 하나 — 저장본 › 그 자리 셈."""
|
||||
got = (solved(folder, key) or {}).get(str(no))
|
||||
if got is not None:
|
||||
return chk.strong({str(no): got}, _others(folder, key))[str(no)]
|
||||
book = hopyo_store.read(folder, key)
|
||||
if not any(b.get("번") is not None and int(b["번"]) == int(no) for b in book.get("단산") or []):
|
||||
raise judge._bad(f"없는 블록 「단산{no}」", 404)
|
||||
return one(book, no)
|
||||
|
||||
|
||||
def forget(folder: Path, key: str) -> None:
|
||||
shutil.rmtree(derived.derived_dir(hopyo_store._dir(folder), KIND + key), ignore_errors=True)
|
||||
@@ -1,307 +0,0 @@
|
||||
"""근거 제안 3 — 값 길: 상수 지문 → M01 로직 후보(PLAN 793-3a · 분석 648-D 1-3) · 읽기만.
|
||||
|
||||
로직마다 입력 값 조합(400 넘으면 입력마다 혼자 가장 많이 덮는 값) → 찾기마다 줄 하나(새로 덮는
|
||||
상수가
|
||||
가장 많은 줄부터) → 덮은 상수 · 우연 보정 유의(−log10 꼬리 확률) · 판정 U 유일 · 다 설명 · 옵션
|
||||
하나 /
|
||||
P 유일 / A 동점(유의 1 안) / N 유의 4 미만 / N0 상수 없음 · 동점이 모두 한 하위 로직을 부르면 그
|
||||
하위(핵).
|
||||
블록 = 일 절 2+ · 상수 2+ 주 절이 있으면 주 절 판정 모음 + 절 밖 상수 · 그 밖 블록 통째 + 덧 기초.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import json
|
||||
import math
|
||||
from collections import defaultdict
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_San_Basis_Const as bc
|
||||
from M02_MasterTemplete import M02_San_Basis_Index as bi
|
||||
|
||||
TIE = 1.0 # 동점 폭(유의)
|
||||
SIG_MIN = 4.0 # 1등 유의가 이 아래면 N — 로직 1,515 중 우연 1등 ≈ 10^-3.2
|
||||
fam = bi.fam
|
||||
|
||||
|
||||
def solve(hits: list[tuple]) -> dict:
|
||||
"""hits = [(로직, 부름id, 줄, 묶음)] · 식 속 수 = 부름id None → 덮음 · 옵션 · 옵션여럿."""
|
||||
inl = {h[0] for h in hits if h[1] is None}
|
||||
bound = [h for h in hits if h[1] is not None]
|
||||
inputs = sorted({i for h in bound for i in h[3] if not i.startswith("§")})
|
||||
vals = {i: sorted({h[3][i] for h in bound if i in h[3]}) for i in inputs}
|
||||
by_call: dict = defaultdict(lambda: defaultdict(set))
|
||||
row_bind = {}
|
||||
for k, call, row, b in bound:
|
||||
by_call[call][row].add(k)
|
||||
row_bind[(call, row)] = b
|
||||
order = sorted(by_call, key=lambda c: -max(len(x) for x in by_call[c].values()))
|
||||
|
||||
def run(combo: dict):
|
||||
cov, fixed, pick = set(inl), dict(combo), {}
|
||||
while True:
|
||||
best = None
|
||||
for call in order:
|
||||
if call in pick:
|
||||
continue
|
||||
for r, ks in by_call[call].items():
|
||||
gain = len(ks - cov)
|
||||
if (
|
||||
gain
|
||||
and (best is None or gain > best[0])
|
||||
and all(
|
||||
fixed.get(i, v) == v if i.startswith("§") else combo.get(i) == v
|
||||
for i, v in row_bind[(call, r)].items()
|
||||
)
|
||||
):
|
||||
best = (gain, call, r, ks)
|
||||
if best is None:
|
||||
break
|
||||
_, call, r, ks = best
|
||||
cov |= ks
|
||||
pick[call] = r
|
||||
fixed |= {i: v for i, v in row_bind[(call, r)].items() if i.startswith("§")}
|
||||
return cov, pick, -len(pick)
|
||||
|
||||
if (math.prod(len(v) for v in vals.values()) if vals else 1) <= 400:
|
||||
combos = [dict(zip(inputs, c)) for c in itertools.product(*[vals[i] for i in inputs])] or [
|
||||
{}
|
||||
]
|
||||
else: # ponytail: 입력 값 조합이 많으면 입력마다 혼자 가장 많이 덮는 값 — 상호작용 놓칠 수 있음
|
||||
cnt = defaultdict(set)
|
||||
for h in bound:
|
||||
for i, v in h[3].items():
|
||||
cnt[(i, v)].add(h[0])
|
||||
combos = [{i: max(vals[i], key=lambda v: len(cnt[(i, v)])) for i in inputs}]
|
||||
best, outs = (-1, -1), []
|
||||
for combo in combos:
|
||||
cov, pick, ev = run(combo)
|
||||
score = (len(cov), ev)
|
||||
if score > best:
|
||||
best, outs = score, [(combo, cov, pick)]
|
||||
elif score == best:
|
||||
outs.append((combo, cov, pick))
|
||||
used = {
|
||||
i
|
||||
for _, _, pick in outs
|
||||
for call, r in pick.items()
|
||||
for i in row_bind[(call, r)]
|
||||
if not i.startswith("§")
|
||||
}
|
||||
resolved, opt_amb = {}, {}
|
||||
for i in sorted(used):
|
||||
seen = sorted({o[0].get(i) for o in outs if o[0].get(i) is not None})
|
||||
if len(seen) == 1:
|
||||
resolved[i] = json.loads(seen[0])
|
||||
elif seen:
|
||||
opt_amb[i] = [json.loads(x) for x in seen]
|
||||
same_cov = len({frozenset(o[1]) for o in outs}) == 1
|
||||
return {"덮음": outs[0][1], "옵션": resolved, "옵션여럿": opt_amb, "조합같은덮음": same_cov}
|
||||
|
||||
|
||||
def rank(idx: bi.Index, K: list[dict]) -> list[dict]:
|
||||
"""상수 목록 → 로직 후보(유의 차례)."""
|
||||
per_item, anchored, how_of = defaultdict(list), set(), defaultdict(dict)
|
||||
for ki, k in enumerate(K):
|
||||
for item, call, row, bd, how, anc in idx.explain(k):
|
||||
per_item[item].append((ki, call, row, bd))
|
||||
how_of[item].setdefault(ki, how)
|
||||
if anc:
|
||||
anchored.add(item)
|
||||
real = sorted(i for i, k in enumerate(K) if not k.get("일반"))
|
||||
ub = []
|
||||
for it, hs in per_item.items():
|
||||
if it in anchored:
|
||||
c = len({h[0] for h in hs} & set(real))
|
||||
ub.append((bi.pb_sig([idx.q_of(it, K[i]) for i in real], c), c, it))
|
||||
ub.sort(key=lambda x: (-x[0], -x[1], x[2]))
|
||||
out, best = [], 0.0
|
||||
for s_ub, c, item in ub:
|
||||
if c == 0 or s_ub < best - TIE or len(out) >= 200:
|
||||
break
|
||||
got = solve(per_item[item])
|
||||
got_real = got["덮음"] & set(real)
|
||||
s_val = bi.pb_sig([idx.q_of(item, K[i]) for i in real], len(got_real))
|
||||
best = max(best, s_val)
|
||||
out.append({"로직": item, **got, "꼴": how_of[item], "실수": len(got_real), "유의": s_val})
|
||||
out.sort(key=lambda o: (-o["유의"], -o["실수"], o["로직"]))
|
||||
return out
|
||||
|
||||
|
||||
def collapse(idx: bi.Index, tied: list[dict]) -> tuple[list[dict], str]:
|
||||
"""동점 정리 — 다른 동점이 모두 그 로직을 부르면 그 하위(핵) · 위 로직 하나면 그 위 로직 ·
|
||||
그 밖 = 동점 위 로직이 덮는 하위는 뺌."""
|
||||
for c in tied:
|
||||
others = [o for o in tied if o is not c]
|
||||
if others and all(o["로직"] in idx.callers.get(c["로직"], set()) for o in others):
|
||||
return (
|
||||
(others, "위 로직 하나")
|
||||
if len(others) == 1
|
||||
else ([c], f"핵 하위 로직(위 로직 {len(others)})")
|
||||
)
|
||||
keep = []
|
||||
for o in tied:
|
||||
if not [
|
||||
p
|
||||
for p in tied
|
||||
if p is not o
|
||||
and p["로직"] in idx.callers.get(o["로직"], set())
|
||||
and o["덮음"] <= p["덮음"]
|
||||
]:
|
||||
keep.append(o)
|
||||
return keep, ""
|
||||
|
||||
|
||||
def judge(idx: bi.Index, K: list[dict]) -> dict:
|
||||
"""한 단위(절 · 블록) 판정 — U · P · A · N · N0."""
|
||||
real = {i for i, k in enumerate(K) if not k.get("일반")}
|
||||
if not real:
|
||||
return {"판정": "N0", "까닭": "지문 없음(상수 0)", "후보": [], "K": K}
|
||||
cands = [o for o in rank(idx, K) if o["실수"]]
|
||||
if not cands:
|
||||
return {"판정": "N", "까닭": "맞는 표 없음", "후보": [], "K": K}
|
||||
top = cands[0]
|
||||
if top["유의"] < SIG_MIN:
|
||||
return {
|
||||
"판정": "N",
|
||||
"까닭": f"유의 못 미침(1등 {top['실수']}/{len(real)} · 유의 {top['유의']})",
|
||||
"후보": cands[:5],
|
||||
"K": K,
|
||||
}
|
||||
tied, note = collapse(idx, [o for o in cands if o["유의"] >= top["유의"] - TIE])
|
||||
if len(tied) >= 2:
|
||||
return {
|
||||
"판정": "A",
|
||||
"까닭": f"동점 {len(tied)}",
|
||||
"후보": tied[:12],
|
||||
"n": len(tied),
|
||||
"K": K,
|
||||
"남음": sorted(real - top["덮음"]),
|
||||
}
|
||||
top = tied[0]
|
||||
left = sorted(real - top["덮음"])
|
||||
full = not left and not top["옵션여럿"] and top["조합같은덮음"]
|
||||
rest = [o for o in cands if o is not top]
|
||||
return {
|
||||
"판정": "U" if full else "P",
|
||||
"까닭": note,
|
||||
"후보": [top] + rest[:4],
|
||||
"K": K,
|
||||
"남음": left,
|
||||
}
|
||||
|
||||
|
||||
def cover(idx: bi.Index, K: list[dict], first: dict) -> list[dict]:
|
||||
"""블록 덮기 — 첫 기초 뒤 남은 상수로 다음 기초(새 상수 2+ · 유일 · 넷까지)."""
|
||||
picks, done = [first], set(first["덮음"])
|
||||
for _ in range(3):
|
||||
rest = [k if i not in done else {**k, "일반": True} for i, k in enumerate(K)]
|
||||
if all(k.get("일반") for k in rest):
|
||||
break
|
||||
got = judge(idx, rest)
|
||||
if got["판정"] not in ("U", "P"):
|
||||
break
|
||||
o = got["후보"][0]
|
||||
if len(o["덮음"] - done) < 2:
|
||||
break
|
||||
picks.append({**o, "동점": 1})
|
||||
done |= o["덮음"]
|
||||
return picks
|
||||
|
||||
|
||||
def assemble(idx: bi.Index, bK: list[dict], mains: list) -> dict:
|
||||
"""절 2+ 블록 — 주 절 판정 모음 + 절 밖 상수(머리 · 비목 절)는 고른 기초가 덮는지 ·
|
||||
남으면 따로."""
|
||||
picks, verdicts, covered = [], [], set()
|
||||
|
||||
def key(c):
|
||||
return (round(c["v"], 9), fam(c["단위"]), c.get("직종"))
|
||||
|
||||
for _, _, K, sj in mains:
|
||||
verdicts.append(sj["판정"])
|
||||
if sj["판정"] in ("U", "P"):
|
||||
o = sj["후보"][0]
|
||||
mine = {key(K[j]) for j in o["덮음"]}
|
||||
picks.append({**o, "덮음": {i for i, c in enumerate(bK) if key(c) in mine}})
|
||||
covered |= {key(c) for c in K}
|
||||
rest = [c if key(c) not in covered else {**c, "일반": True} for c in bK]
|
||||
left = {i for i, c in enumerate(rest) if not c.get("일반")}
|
||||
if left and picks:
|
||||
chosen = {p["로직"] for p in picks}
|
||||
for o in rank(idx, rest):
|
||||
if o["로직"] not in chosen:
|
||||
continue
|
||||
got = o["덮음"] & left
|
||||
for p in picks:
|
||||
if p["로직"] != o["로직"]:
|
||||
continue
|
||||
p["덮음"] = p["덮음"] | got
|
||||
if got: # 절 밖 상수가 정한 옵션도 얹음(같은 입력 다른 값 = 여럿)
|
||||
for kk, vv in o["옵션"].items():
|
||||
if kk in p["옵션"] and p["옵션"][kk] != vv:
|
||||
p["옵션여럿"] = {**p["옵션여럿"], kk: [p["옵션"][kk], vv]}
|
||||
else:
|
||||
p["옵션"] = {**p["옵션"], kk: vv}
|
||||
left -= got
|
||||
if len(left) >= 2:
|
||||
extra = judge(idx, [c if i in left else {**c, "일반": True} for i, c in enumerate(bK)])
|
||||
if extra["판정"] in ("U", "P"):
|
||||
picks.append(extra["후보"][0])
|
||||
left -= extra["후보"][0]["덮음"]
|
||||
verdicts.append(extra["판정"])
|
||||
if not picks:
|
||||
out = {
|
||||
"판정": "A" if "A" in verdicts else ("N" if "N" in verdicts else "N0"),
|
||||
"후보": [],
|
||||
"K": bK,
|
||||
}
|
||||
a = next((x[3] for x in mains if x[3]["판정"] == "A"), None)
|
||||
return out | ({"후보": a["후보"], "n": a["n"], "K": a["K"]} if a else {})
|
||||
allu = not left and all(not p["옵션여럿"] for p in picks)
|
||||
return {
|
||||
"판정": "U" if allu else "P",
|
||||
"후보": picks,
|
||||
"K": bK,
|
||||
"기초목록": picks,
|
||||
"남음": sorted(left),
|
||||
}
|
||||
|
||||
|
||||
def _show(o: dict) -> dict:
|
||||
return {"로직": o["로직"], "옵션": o["옵션"], "옵션여럿": o["옵션여럿"]}
|
||||
|
||||
|
||||
def value(idx: bi.Index, pr: dict) -> dict:
|
||||
"""블록 준비(`Const.prep`) → 값 판정 {판정 U|P|A|N|N0, 기초 · 동점 · 약한후보 [{로직, 옵션}],
|
||||
절 [{절, 판정, 기초?, 동점?}], K, 입력}."""
|
||||
lines, per_line, secs = pr["lines"], pr["per_line"], pr["secs"]
|
||||
bK, bIns = bc.gather(per_line, range(len(lines)))
|
||||
sec_res = []
|
||||
for title, ids in secs:
|
||||
K, _ = bc.gather(per_line, ids)
|
||||
sec_res.append((title, ids, K, judge(idx, K)))
|
||||
mains = [x for x in sec_res if len([k for k in x[2] if not k.get("일반")]) >= 2]
|
||||
multi = len(secs) >= 2 and len(mains) >= 1
|
||||
bj = assemble(idx, bK, mains) if multi else judge(idx, bK)
|
||||
rec: dict[str, Any] = {
|
||||
"판정": bj["판정"],
|
||||
"K": [{"단위": c["단위"]} for c in bK],
|
||||
"입력": [c["v"] for c in bIns],
|
||||
}
|
||||
if bj["판정"] in ("U", "P"):
|
||||
rec["기초"] = [_show(p) for p in (bj.get("기초목록") or cover(idx, bK, bj["후보"][0]))]
|
||||
elif bj["판정"] == "A":
|
||||
rec["동점"] = [_show(o) for o in bj["후보"]]
|
||||
rec["n"] = bj["n"]
|
||||
elif bj["판정"] == "N" and bj.get("후보"):
|
||||
rec["약한후보"] = [_show(o) for o in bj["후보"][:3]]
|
||||
rec["절"] = []
|
||||
for title, _, _, sj in sec_res:
|
||||
one = {"절": title, "판정": sj["판정"]}
|
||||
if sj["판정"] in ("U", "P"):
|
||||
one["기초"] = _show(sj["후보"][0])
|
||||
elif sj["판정"] == "A":
|
||||
one["동점"] = [o["로직"] for o in sj["후보"]]
|
||||
rec["절"].append(one)
|
||||
return rec
|
||||
@@ -4,8 +4,8 @@
|
||||
`실무자료/산근/<HP>.json` 에 둠
|
||||
(원본 엑셀은 안 둠 · HP 지울 때 같이 지움) → [가져온 식으로 열기] = 그 블록을 793-2a 내보내기
|
||||
(`M02_San_Emit` · 이음 = 1차 줄 판정) → 793-2 만들기(`M02_Sangeun_Imported.create` · 개인 층 ·
|
||||
엑셀 단가 그대로 · `가져온.근거후보` = 793-3a 근거 제안 `M02_San_Basis_Store.block` 근거후보) ·
|
||||
같은 내용 SK 가 있으면 그것(다시씀 · 근거후보 그대로).
|
||||
엑셀 단가 그대로 · 경쟁사 로직 인정 — 근거 기초 제안 없음 · 809-1 · 요구 703) ·
|
||||
같은 내용 SK 가 있으면 그것(다시씀).
|
||||
블록 번 = 판정 블록 번(STmate = 코드 「D00102」 숫자 · EST = 단가산출 블록 번).
|
||||
STmate 블록 끝전(비목버림 | 합버림_큰나머지)은 파일 설정 — 참조 평가기로 둘 다 셈해
|
||||
엑셀 계와 맞는 것.
|
||||
@@ -168,7 +168,6 @@ def _link_inner(source: Path, key: str, no: int, out: dict, trail: tuple) -> Non
|
||||
def make(source: Path, key: str, no: int, name: str = "", _trail: tuple = ()) -> dict[str, Any]:
|
||||
"""지금 층(만들 층)에 가져온 식 SK — 같은 내용이 있으면 그 SK(`다시씀`) ·
|
||||
안쪽 산근 블록은 형제 SK 를 먼저 만들어 참조(797-1)."""
|
||||
from M02_MasterTemplete import M02_San_Basis_Store as san_basis
|
||||
from M02_MasterTemplete import M02_Sangeun_Imported as imp
|
||||
|
||||
out = emit(source, key, no)
|
||||
@@ -182,11 +181,7 @@ def make(source: Path, key: str, no: int, name: str = "", _trail: tuple = ()) ->
|
||||
pass
|
||||
title = str(name or "").strip() or str(out["로직"].get("이름") or f"단산{no}")
|
||||
try:
|
||||
basis = san_basis.block(source, key, no).get("근거후보") or None
|
||||
except Exception: # noqa: BLE001 — 근거 제안 못 셈 = 빈 칸(가져온 식은 만듦)
|
||||
basis = None
|
||||
try:
|
||||
got = imp.create(out["로직"], out["원본"], title, basis=basis)
|
||||
got = imp.create(out["로직"], out["원본"], title)
|
||||
return {**got, "다시씀": False}
|
||||
except store.StoreError as e:
|
||||
same = (e.detail or {}).get("같은것") if isinstance(e.detail, dict) else None
|
||||
|
||||
@@ -17,8 +17,7 @@
|
||||
글줄 [{행, 글, 자리?(셈 줄 cid | mid:이름), 값?(그 줄 엑셀 금액)}]}.
|
||||
SK 문서 덧칸 `가져온` = {로직(엑셀 단가 그대로 · 고치지 않음), 단가기준 엑셀|우리, 원본,
|
||||
검산 {같음, 까닭, 셈 {노..계}, 엑셀 {노..계}, 줄 [{자리, 행, 셈, 엑셀, 같음, 까닭?}]},
|
||||
근거후보 [{기초, 옵션, 품검산, 단 확정|근접|사람}](793-3 이 채움),
|
||||
바꿈?(우리 기초로 바꾼 뒤 = 그때 로직 · 단가기준 · 전 · 후 · 차이)} ·
|
||||
근거후보 · 바꿈(옛 문서 읽기만 · 809-1 부터 안 씀)} ·
|
||||
바탕 = null · 가져온 식 SK = `가져온.로직` 있음 · 검산 = 저장 때 서버가 다시.
|
||||
단가기준 엑셀 = 줄 단가 · 값묶음 그대로 /
|
||||
우리 = 이음 있는 줄만 단가 · 값묶음 떼고 M01(B09 = 사본 단가) 지금 값 ·
|
||||
@@ -28,9 +27,7 @@ SK 문서 덧칸 `가져온` = {로직(엑셀 단가 그대로 · 고치지 않
|
||||
→ {이름(SK 키), 검산, 단가기준}(서버가 다시 셈해 엑셀과 맞댐 · 같은 내용 409 = 기존 길)
|
||||
POST …/sangeun/imported/price 몸 {문서, 기준} → {문서, 전, 후, 이음없음 [{자리, 이름}]}
|
||||
POST …/sangeun/imported/link 몸 {문서, 자리, 이음} → {문서}(이음 고름 · 덮개 열쇠 옮김)
|
||||
POST …/sangeun/imported/to-base 몸 {문서, 로직(M01 GC), 입력값, 환산?} → {문서, 전, 후, 차이,
|
||||
필요?}(기초 결과단위 성격이 SK 단위와 달라 환산 계수가 없으면 후 = None ·
|
||||
필요 ["환산"] · 793-3b)
|
||||
(우리 기초로 바꾸기 `to-base` 는 809-1 에서 지움 — 경쟁사 로직 인정 · 요구 703)
|
||||
셈 답(`…/sangeun/calc`) = 옛 꼴 그대로 · `원래` = 엑셀 금액(「M01 원래」 자리) · 덧칸 `가져온`
|
||||
{단가기준, 파일, 시트, 행, 갈래, 엑셀금액, 글줄 [{행, 글, 자리, 엑셀, 지금, 같음}], 이음없음}.
|
||||
"""
|
||||
@@ -503,35 +500,10 @@ def _rate_rekey(one: Any, move: dict) -> Any:
|
||||
return {**one, "고정": {**one["고정"], "비율": {**rate, "기준": keys}}}
|
||||
|
||||
|
||||
def to_base(doc: Any, code: str, given: dict | None = None, swap: Any = None) -> dict[str, Any]:
|
||||
"""[우리 기초로 바꾸기] — 가져온 식 SK → M01 기초(바탕 로직) + 옵션 SK · 고침 걷힘 ·
|
||||
원본 · 검산 · 근거후보 보존 + `바꿈` {로직, 단가기준, 전, 후, 차이}(1 단위) ·
|
||||
`swap` = SK 단위 환산 {계수} · 없는데 단위 성격이 다르면 후 = None · `필요` ["환산"](793-3b)."""
|
||||
doc = _need(doc)
|
||||
whole = _whole()
|
||||
before = _money(_run(doc, whole))
|
||||
head = {k: doc[k] for k in ("키", "이름", "단위", "구분", "상세구분") if k in doc}
|
||||
new = {**head, "바탕": {"로직": str(code or "").strip()}, "입력값": dict(given or {})}
|
||||
new |= {"열린입력": [], "고름": {}, "자재표시": {}, "덮개": {}, "뺀줄": [], "더한줄": []}
|
||||
new |= {"환산": swap} if swap else {}
|
||||
need: list[str] = []
|
||||
try:
|
||||
after = _money(_run(new, whole))
|
||||
except sd.DocError as e:
|
||||
if swap or not str(e).startswith("단가산출 단위"):
|
||||
raise
|
||||
after, need = None, ["환산"]
|
||||
gap = {k: after[k] - before[k] for k in after} if before and after else None
|
||||
keep = {k: doc[KEY][k] for k in ("원본", "검산", "근거후보") if doc[KEY].get(k)}
|
||||
was = {k: doc[KEY].get(k) for k in ("로직", "단가기준")}
|
||||
new[KEY] = {**keep, "바꿈": {**was, "전": before, "후": after, "차이": gap}}
|
||||
return {"문서": new, "전": before, "후": after, "차이": gap} | ({"필요": need} if need else {})
|
||||
|
||||
|
||||
# ── 만들기(개인 층) ───────────────────────────────────────────────────
|
||||
def create(
|
||||
logic: Any, source: Any, name: str = "", unit: str = "", head: dict | None = None,
|
||||
new: bool = False, use: str | None = None, basis: list | None = None,
|
||||
new: bool = False, use: str | None = None,
|
||||
) -> dict[str, Any]: # fmt: skip
|
||||
"""엔진 로직 줄 + 원본 → 지금 층 새 가져온 식 SK — 서버가 다시 셈해 엑셀과 맞댐(검산 ·
|
||||
다르면 `같음` False 로 표지 · 막지 않음) · 같은 층 같은 내용 = 409(`ask` · 733-2)."""
|
||||
@@ -547,8 +519,6 @@ def create(
|
||||
meta = {"로직": row, "단가기준": "엑셀"}
|
||||
if source not in (None, {}):
|
||||
meta["원본"] = _meta(source, "원본")
|
||||
if basis: # 근거 제안(793-3a `제안.근거후보` 그대로 · 793-3b)
|
||||
meta["근거후보"] = _meta(basis, "근거후보")
|
||||
doc[KEY] = meta
|
||||
got = check(sd.clean(doc, None, whole), whole)
|
||||
key = ask.ask(sd.KIND, doc, new, use) or refs.create(sd.KIND, doc)["이름"]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
[단가산출로 저장](715-3) `POST /api/m02/layers/{층}/sangeun/from-line`(층 쓰기 권한) ·
|
||||
식 칸 검사(792-1) `POST /api/m02/layers/{층}/sangeun/formula-check`(셈 없음 · 층 읽기 권한) ·
|
||||
가져온 식(793-2 · `M02_Sangeun_Imported`) `…/sangeun/imported`(만들기 · 층 쓰기) · `/price` ·
|
||||
`/link` · `/to-base`(화면 캐시 문서 고침 · 저장 없음 · 층 읽기) ·
|
||||
`/link`(화면 캐시 문서 고침 · 저장 없음 · 층 읽기 · 기초로 바꾸기 없음 · 809-1) ·
|
||||
불러오기 단산 블록 → 가져온 식(793-3b · `M02_Sangeun_FromHopyo`) `…/sangeun/from-hopyo`
|
||||
(만들 층 쓰기).
|
||||
로직 사본(795-1 · `M02_Sangeun_Copy`) `…/sangeun/copy/price` [단가 갱신] · `/regen` [재생성]
|
||||
@@ -160,9 +160,6 @@ class ImportedEditBody(BaseModel):
|
||||
기준: str | None = None # price
|
||||
자리: str | None = None # link
|
||||
이음: str | None = None # link
|
||||
로직: str | None = None # to-base
|
||||
입력값: dict[str, Any] | None = None # to-base
|
||||
환산: dict[str, Any] | None = None # to-base(793-3b · SK 단위 환산 계수)
|
||||
칸: dict[str, Any] | None = None # qty-pick(797-2 · 고른 M01 표 칸 {표, 줄, 칸})
|
||||
되돌리기: bool = False # qty-drop(797-2 · 버림 풀기)
|
||||
|
||||
@@ -182,7 +179,6 @@ def _sd(fn: Any) -> Any:
|
||||
_EDIT = {
|
||||
"price": lambda b: (imp.to_price, b.문서, b.기준),
|
||||
"link": lambda b: (imp.link, b.문서, b.자리, b.이음),
|
||||
"to-base": lambda b: (imp.to_base, b.문서, b.로직, b.입력값, b.환산),
|
||||
"qty": lambda b: (qty.view, b.문서),
|
||||
"qty-pick": lambda b: (qty.pick, b.문서, b.자리, b.칸),
|
||||
"qty-drop": lambda b: (qty.drop, b.문서, b.자리, b.되돌리기),
|
||||
@@ -197,12 +193,9 @@ def sangeun_imported_edit(
|
||||
owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
) -> dict:
|
||||
"""가져온 식 고치기(793-2) — price [우리 · 엑셀 단가로] · link 이음 고름 · to-base [우리 기초로
|
||||
바꾸기] · 답 = 고친 문서(화면 캐시 · 저장 없음) + 전 · 후 금액."""
|
||||
"""가져온 식 고치기(793-2) — price [우리 · 엑셀 단가로] · link 이음 고름 · qty 소요량 이음 · 답 = 고친 문서(화면 캐시 · 저장 없음) + 전 · 후 금액."""
|
||||
if how not in _EDIT:
|
||||
raise HTTPException(
|
||||
status_code=404, detail="price · link · to-base · qty · qty-pick · qty-drop"
|
||||
)
|
||||
raise HTTPException(status_code=404, detail="price · link · qty · qty-pick · qty-drop")
|
||||
fn, *args = _EDIT[how](body)
|
||||
return _call(_root(session, layer, owner), _sd(fn), *args)
|
||||
|
||||
|
||||
@@ -1,34 +1,11 @@
|
||||
/* =============================================================================
|
||||
* logic/M02_Hopyo_Imported_Model.ts
|
||||
* 불러오기 단산 줄 = 가져온 식 단가산출(PLAN 793-3b · 요구 646 · 647) — 꼴 · 읽기 · 바꾸기만.
|
||||
* · SK `가져온` 덧칸 꼴(793-2 `M02_Sangeun_Imported.py` 머리) · 근거 제안 세 단(확정 · 근접 · 사람 고름 · 793-3a 가 채움)
|
||||
* · 단산 줄 고른 것 = {갈래 단가산출, 키 SK-…, 필요(입력 필요), 표지(결과 칩)} — 층 = 옮길 층 — 필요가 비어야 확인 · 옮김
|
||||
* · 줄 결과 칩 = 가져온 식 있음 · 근거 확정 · 근접 · 사람 고름 · 입력 필요 n(「2차 필요」 대신)
|
||||
* 불러오기 단산 블록 = 가져온 식 단가산출(PLAN 793-3b · 809-1 · 요구 646 · 702) — 꼴 · 읽기만.
|
||||
* · SK `가져온` 덧칸 꼴(793-2 `M02_Sangeun_Imported.py` 머리) · 경쟁사 로직 그대로(기초로 바꾸기 없음 · 요구 703)
|
||||
* 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DraftPick } from "./M02_MasterTemplete_Hopyo_Picks";
|
||||
|
||||
export type Money4 = Record<"노무비" | "재료비" | "경비" | "계", number>;
|
||||
export type Tier = "확정" | "근접" | "사람";
|
||||
export const TIERS: Tier[] = ["확정", "근접", "사람"];
|
||||
|
||||
/** 근거 후보 하나(793-3a `제안.근거후보`) — 까닭 = [[표지(판 차이 · 현장 값 · 감률 · 할증 배수 · 단위 기준 차이 · 그 밖), 글]] */
|
||||
export interface BasisCand {
|
||||
기초: string;
|
||||
이름?: string;
|
||||
원문번호?: string;
|
||||
옵션?: Record<string, unknown>;
|
||||
/** 글에 없는 칸(감률 · 할증 포함) — 사용자 입력 */
|
||||
입력필요?: string[];
|
||||
품검산?: "같음" | "다름" | "짝 없음" | null;
|
||||
단: Tier;
|
||||
까닭?: [string, string][];
|
||||
}
|
||||
|
||||
/** 근거 후보 까닭 글(「표지 글」 · 줄마다) */
|
||||
export const reasonText = (c: BasisCand): string =>
|
||||
(c.까닭 ?? []).map(([tag, text]) => `${tag} ${text}`.trim()).join(" · ");
|
||||
|
||||
export interface CheckLine {
|
||||
자리: string;
|
||||
@@ -58,82 +35,23 @@ export interface ImportedMeta {
|
||||
엑셀?: Money4;
|
||||
줄: CheckLine[];
|
||||
};
|
||||
근거후보?: BasisCand[];
|
||||
바꿈?: { 전?: Money4 | null; 후?: Money4 | null; 차이?: Money4 | null };
|
||||
}
|
||||
|
||||
export interface ImportedDoc {
|
||||
이름?: string;
|
||||
단위?: string;
|
||||
바탕?: { 로직?: string } | null;
|
||||
입력값?: Record<string, unknown>;
|
||||
/** SK 단위 환산(기초 결과단위 성격이 다를 때 · 728-2) */
|
||||
환산?: { 계수?: number | string };
|
||||
가져온?: ImportedMeta;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
/** 가져온 식(아직 우리 기초로 안 바꿈) */
|
||||
/** 가져온 식(경쟁사 로직이 든 SK) */
|
||||
export const isImported = (d: ImportedDoc | null | undefined): boolean => !!d?.가져온?.로직;
|
||||
|
||||
/** 근거 후보 세 단 */
|
||||
export function tiers(list: BasisCand[] | undefined): Record<Tier, BasisCand[]> {
|
||||
const out: Record<Tier, BasisCand[]> = { 확정: [], 근접: [], 사람: [] };
|
||||
for (const c of list ?? []) if (TIERS.includes(c.단)) out[c.단].push(c);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 검산 다른 줄 */
|
||||
export const wrongLines = (d: ImportedDoc): CheckLine[] =>
|
||||
(d.가져온?.검산?.줄 ?? []).filter((x) => !x.같음);
|
||||
|
||||
const blank = (v: unknown): boolean => v === undefined || v === null || String(v).trim() === "";
|
||||
|
||||
/** 입력 필요 칸 값 — 입력값 + 「환산」 = 환산 계수 */
|
||||
export const needValues = (d: ImportedDoc): Record<string, unknown> => ({
|
||||
...(d.입력값 ?? {}),
|
||||
환산: d.환산?.계수,
|
||||
});
|
||||
|
||||
/** 입력 필요 중 아직 빈 칸 */
|
||||
export const stillNeed = (need: string[], values: Record<string, unknown> | undefined): string[] =>
|
||||
need.filter((k) => blank(values?.[k]));
|
||||
|
||||
/** 단산 줄 고른 것 — 그 SK(필요 = 빈 입력 필요 칸 · `missing` 이 막음) · 층 · 주인 안 둠 = 옮길 층(SK 를 만든 층)의 SK */
|
||||
export function skPick(
|
||||
sk: string,
|
||||
doc: ImportedDoc,
|
||||
need: string[],
|
||||
marks: string[],
|
||||
): DraftPick & { 표지: string[] } {
|
||||
const values = needValues(doc);
|
||||
return {
|
||||
갈래: "단가산출",
|
||||
키: sk,
|
||||
이름: String(doc.이름 ?? sk),
|
||||
단위: String(doc.단위 ?? ""),
|
||||
입력값: Object.fromEntries(need.map((k) => [k, values[k] ?? ""])),
|
||||
출처: {},
|
||||
몫: null,
|
||||
필요: need,
|
||||
표지: marks,
|
||||
};
|
||||
}
|
||||
|
||||
/** 결과 표지 — 가져온 식 있음 | 우리 기초(+ 근거 단) */
|
||||
export function marksOf(doc: ImportedDoc, tier: Tier | null): string[] {
|
||||
if (isImported(doc)) return ["가져온 식 있음"];
|
||||
return tier ? [`근거 ${tier}`] : ["사람 고름"];
|
||||
}
|
||||
|
||||
/** 단산 줄 결과 칩 글 — SK 고른 것이 있으면 그 표지 + 입력 필요 n · 없으면 null(옛 판정 칩) */
|
||||
export function rowChips(picks: (DraftPick & { 표지?: string[] })[]): string[] | null {
|
||||
const sk = picks.find((p) => p.갈래 === "단가산출" && /^SK-/.test(String(p.키 ?? "")));
|
||||
if (!sk) return null;
|
||||
const need = stillNeed(sk.필요 ?? [], sk.입력값);
|
||||
return [...(sk.표지 ?? ["가져온 식 있음"]), ...(need.length ? [`입력 필요 ${need.length}`] : [])];
|
||||
}
|
||||
|
||||
/** 금액 차이 글(후 − 전) */
|
||||
export function gapText(
|
||||
before: Money4 | null | undefined,
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_Picks.ts
|
||||
* 엑셀 불러오기 판정 화면 고름 캐시(PLAN 785-6 · 요구 641 · 642 · 설계 642-A 2-6) — 순수 셈.
|
||||
* · 캐시 꼴 `{호표 {번: {차례: 고름}}, 블록 {번: 고름}}` · 고름 = `{고른 [고른 것 + 필요?], 확인, 배?}` —
|
||||
* sessionStorage 에 넣고 빼는 것은 화면(`Hopyo_Rows`) · 여기는 꼴 · 읽기 · 바꾸기만
|
||||
* · 제안 = 미리 체크로만(캐시에 없으면 서버 제안) · 확인 = 사용자 [확인] · [호표 제안 모두 확인] 누름만(자동 없음)
|
||||
* · 기초 고른 것 `필요` = 엔진이 쓰는데 못 읽은 칸(입력 필요) + 엇갈림 칸 — 값이 비면 그 줄 확인 · 옮김 막음
|
||||
* · 옮기기 몸 = 확인한 줄 · 블록만 · `필요` 걷음 · 줄 `배`(단위 환산 사용자 값) 얹음 · 금액 · 품 칸 안 보냄(서버가 다시 셈)
|
||||
* · 별도계상 줄 = 서버 제안이 고정 줄 하나(`apartPick`) — 고름 없이 [확인] · [옮기기] 됨(막는 칸 없음)
|
||||
* · 칠 = 확인함 success · 품 다름 warning · 고를 것 info · 후보 없음 danger
|
||||
* 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장).
|
||||
* ========================================================================== */
|
||||
|
||||
import type {
|
||||
BaseCand,
|
||||
JudgeRow,
|
||||
MoveBody,
|
||||
MoveBlocked,
|
||||
Pick,
|
||||
Section,
|
||||
Solved,
|
||||
} from "../M02_MasterTemplete_Hopyo_JudgeApi";
|
||||
import type { Tone } from "./M02_MasterTemplete_Hopyo_SheetMap";
|
||||
|
||||
/** 캐시 안 고른 것 — `필요` = 비면 막는 칸(화면만 · 몸에선 걷음) */
|
||||
export type DraftPick = Pick & { 필요?: string[] };
|
||||
|
||||
export interface LineDraft {
|
||||
고른: DraftPick[];
|
||||
확인: boolean;
|
||||
/** 단위 환산 사용자 값(엑셀 1 단위 = 우리 배) */
|
||||
배?: number | null;
|
||||
}
|
||||
|
||||
export interface JudgeDraft {
|
||||
호표: Record<string, Record<string, LineDraft>>;
|
||||
블록: Record<string, LineDraft>;
|
||||
}
|
||||
|
||||
export const draftKey = (layer: string, owner: string | null, key: string): string =>
|
||||
`m02-hopyo-judge:${layer}:${owner ?? ""}:${key}`;
|
||||
|
||||
export const emptyDraft = (): JudgeDraft => ({ 호표: {}, 블록: {} });
|
||||
|
||||
export function parseDraft(text: string | null): JudgeDraft {
|
||||
try {
|
||||
const d = JSON.parse(text ?? "") as Partial<JudgeDraft>;
|
||||
if (d && typeof d === "object" && d.호표 && d.블록) return { 호표: d.호표, 블록: d.블록 };
|
||||
} catch {
|
||||
/* 빈 · 깨진 캐시 = 빈 캐시 */
|
||||
}
|
||||
return emptyDraft();
|
||||
}
|
||||
|
||||
const blank = (v: unknown): boolean => v === undefined || v === null || String(v).trim() === "";
|
||||
|
||||
/** 절 기초 후보 → 고른 것(엔진 입력값 · 출처 · 몫 · 필요 = 입력 필요 + 엇갈림) */
|
||||
export function basePick(cand: BaseCand, sec: Section): DraftPick {
|
||||
const b = cand.바인딩;
|
||||
return {
|
||||
갈래: "기초",
|
||||
키: cand.키,
|
||||
이름: cand.이름,
|
||||
입력값: { ...b.입력값 },
|
||||
출처: { ...b.출처 },
|
||||
몫: typeof sec.몫 === "number" ? sec.몫 : (sec.몫?.값 ?? null),
|
||||
필요: [...new Set([...b.입력필요, ...Object.keys(b.엇갈림)])],
|
||||
};
|
||||
}
|
||||
|
||||
/** 블록 풀이 제안 — 절마다 고름 기초 하나 */
|
||||
export function solvedPicks(s: Solved | null | undefined): DraftPick[] {
|
||||
const out: DraftPick[] = [];
|
||||
for (const sec of s?.절 ?? []) {
|
||||
const c = sec.후보.find((x) => x.키 === sec.고름);
|
||||
if (c) out.push(basePick(c, sec));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 줄 제안(캐시 없을 때) — 장부 짝 = 고른 것 모두 · 제안 있음 · 후보 없음 = 첫 후보 · 2차 = 블록 풀이 · 후보 여럿 = 빈 */
|
||||
export function rowDefaults(row: JudgeRow): DraftPick[] {
|
||||
if (row.장부?.갈래 === "짝") return row.후보.map((p) => ({ ...p }));
|
||||
if (row.판정 === "2차 필요") return solvedPicks(row.풀이);
|
||||
if (row.판정 === "후보 여럿") return [];
|
||||
return row.후보.slice(0, 1).map((p) => ({ ...p }));
|
||||
}
|
||||
|
||||
export const lineOf = (d: JudgeDraft, no: number, at: number): LineDraft | undefined =>
|
||||
d.호표[String(no)]?.[String(at)];
|
||||
|
||||
/** 지금 고른 것 — 캐시 › 제안 */
|
||||
export const linePicks = (d: JudgeDraft, no: number, row: JudgeRow): DraftPick[] =>
|
||||
lineOf(d, no, row.차례)?.고른 ?? rowDefaults(row);
|
||||
|
||||
export const blockPicks = (d: JudgeDraft, s: Solved): DraftPick[] =>
|
||||
d.블록[String(s.번)]?.고른 ?? solvedPicks(s);
|
||||
|
||||
/** 별도계상 줄(786-1) — 고정 줄 하나 · 품목은 프로젝트에 복사할 때 정함 · 고름 없이 확인 · 옮김 */
|
||||
export const apartPick = (p: Pick): boolean => p.갈래 === "고정" && p.별도계상 === true;
|
||||
|
||||
export const samePick = (a: Pick, b: Pick): boolean =>
|
||||
a.갈래 === b.갈래 && a.키 === b.키 && a.이름 === b.이름 && (a.규격 ?? "") === (b.규격 ?? "");
|
||||
|
||||
/** 복수 고름 — 있으면 뺌 · 없으면 더함 */
|
||||
export function togglePick(list: DraftPick[], p: DraftPick): DraftPick[] {
|
||||
return list.some((x) => samePick(x, p)) ? list.filter((x) => !samePick(x, p)) : [...list, p];
|
||||
}
|
||||
|
||||
/** 입력값 한 칸 — 출처 「사용자」(엇갈림에서 고르면 그 길) · 빈 글 = 칸 지움 */
|
||||
export function setInput(p: DraftPick, name: string, value: unknown, 길 = "사용자"): DraftPick {
|
||||
const 입력값 = { ...p.입력값 };
|
||||
const 출처 = { ...p.출처 };
|
||||
if (blank(value)) {
|
||||
delete 입력값[name];
|
||||
delete 출처[name];
|
||||
} else {
|
||||
const n = typeof value === "string" ? Number(value.replace(/,/g, "")) : NaN;
|
||||
입력값[name] = typeof value === "string" && value.trim() !== "" && Number.isFinite(n) ? n : value; // prettier-ignore
|
||||
출처[name] = { 길 };
|
||||
}
|
||||
return { ...p, 입력값, 출처 };
|
||||
}
|
||||
|
||||
/** 아직 빈 막는 칸 */
|
||||
export const missing = (p: DraftPick): string[] => (p.필요 ?? []).filter((k) => blank(p.입력값[k]));
|
||||
|
||||
/** 줄 막힘 — 고른 것 없음 · 빈 막는 칸 */
|
||||
export function blockedOf(list: DraftPick[]): string[] {
|
||||
return list.length ? [...new Set(list.flatMap(missing))] : ["고른 것 없음"];
|
||||
}
|
||||
|
||||
export const confirmable = (list: DraftPick[]): boolean =>
|
||||
list.length > 0 && list.every((p) => missing(p).length === 0);
|
||||
|
||||
/** 호표 줄 고름 넣기(새 캐시) */
|
||||
export function putLine(d: JudgeDraft, no: number, at: number, line: LineDraft): JudgeDraft {
|
||||
const h = { ...(d.호표[String(no)] ?? {}), [String(at)]: line };
|
||||
return { ...d, 호표: { ...d.호표, [String(no)]: h } };
|
||||
}
|
||||
|
||||
export function putBlock(d: JudgeDraft, no: number, line: LineDraft): JudgeDraft {
|
||||
return { ...d, 블록: { ...d.블록, [String(no)]: line } };
|
||||
}
|
||||
|
||||
/** 줄 하나 바꿈 — 고른 것이 바뀌면 확인 풀림 */
|
||||
export function editLine(d: JudgeDraft, no: number, row: JudgeRow, 고른: DraftPick[]): JudgeDraft {
|
||||
const was = lineOf(d, no, row.차례);
|
||||
return putLine(d, no, row.차례, { ...was, 고른, 확인: false });
|
||||
}
|
||||
|
||||
/** 제안이 다 풀린 줄 — 제안 있음(후보 하나) · 2차 기초로 풂 · 기초+덧(막는 칸 0) */
|
||||
export function suggestReady(row: JudgeRow): boolean {
|
||||
if (row.판정 === "제안 있음") return row.후보.length > 0;
|
||||
if (row.판정 !== "2차 필요" || !row.풀이) return false;
|
||||
const ok = row.풀이.판정 === "기초로 풂" || row.풀이.판정 === "기초+덧";
|
||||
return ok && confirmable(solvedPicks(row.풀이));
|
||||
}
|
||||
|
||||
/** [호표 제안 모두 확인] — 다 풀린 제안 줄만 확인 켬(사용자 누름 · 이미 손댄 줄은 그대로) */
|
||||
export function confirmSuggested(d: JudgeDraft, no: number, rows: JudgeRow[]): JudgeDraft {
|
||||
let out = d;
|
||||
for (const row of rows) {
|
||||
if (lineOf(out, no, row.차례) || !suggestReady(row)) continue;
|
||||
out = putLine(out, no, row.차례, { 고른: rowDefaults(row), 확인: true });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const PAIR_BAD = new Set(["품 다름"]);
|
||||
const pairBad = (s: Solved | null | undefined, keys: Set<string | null>): boolean =>
|
||||
(s?.절 ?? []).some((sec) =>
|
||||
sec.후보.some((c) => keys.has(c.키) && c.줄.some((x) => PAIR_BAD.has(x.판정))),
|
||||
);
|
||||
|
||||
/** 줄 칠 — 확인 = success(품 다름 = warning) · 후보 없음 = danger · 그 밖 = info */
|
||||
export function rowTone(row: JudgeRow, line: LineDraft | undefined): Tone {
|
||||
if (line?.확인) {
|
||||
const keys = new Set(line.고른.map((p) => p.키));
|
||||
return pairBad(row.풀이, keys) ? "warning" : "success";
|
||||
}
|
||||
return row.판정 === "후보 없음" ? "danger" : "info";
|
||||
}
|
||||
|
||||
/** 블록 칠 — 만든 SK · 확인 = success · 고를 것 = info · 줄 조합 = info · 후보 0 = danger */
|
||||
export function blockTone(s: Solved, line: LineDraft | undefined, made: string | null): Tone {
|
||||
if (line?.확인 || made) return "success";
|
||||
return s.절.every((sec) => sec.후보.length === 0) && s.판정 !== "줄 조합" ? "danger" : "info";
|
||||
}
|
||||
|
||||
/** 몸 꼴 — `필요` 걷음 · 배 얹음 */
|
||||
export const bare = (p: DraftPick, 배?: number | null): Pick => {
|
||||
const { 필요: _need, ...rest } = p;
|
||||
return 배 === undefined || 배 === null ? rest : { ...rest, 배 };
|
||||
};
|
||||
|
||||
/** 옮기기 몸 — 확인한 것만 · 막는 칸 남은 줄 = 막힘(몸에서 뺌) */
|
||||
export function moveBody(
|
||||
d: JudgeDraft,
|
||||
file: { layer: string; owner: string | null },
|
||||
): { body: MoveBody; 막힘: MoveBlocked[]; 수: number } {
|
||||
const body: MoveBody = { 층: file.layer, 호표: {}, 블록: {} };
|
||||
if (file.owner) body.주인 = file.owner;
|
||||
const 막힘: MoveBlocked[] = [];
|
||||
let 수 = 0;
|
||||
for (const [no, lines] of Object.entries(d.호표)) {
|
||||
for (const [at, line] of Object.entries(lines)) {
|
||||
if (!line.확인) continue;
|
||||
const 칸 = line.고른.length ? line.고른.flatMap(missing) : ["고른 것 없음"];
|
||||
if (칸.length) {
|
||||
막힘.push({ 호표: Number(no), 차례: Number(at), 까닭: "입력 필요", 칸 });
|
||||
continue;
|
||||
}
|
||||
(body.호표[no] ??= {})[at] = line.고른.map((p) => bare(p, line.배));
|
||||
수 += 1;
|
||||
}
|
||||
}
|
||||
for (const [no, line] of Object.entries(d.블록)) {
|
||||
if (!line.확인) continue;
|
||||
const 칸 = blockedOf(line.고른);
|
||||
if (line.고른.length && 칸.length === 0) {
|
||||
body.블록[no] = line.고른.map((p) => bare(p));
|
||||
수 += 1;
|
||||
} else 막힘.push({ 블록: Number(no), 까닭: "입력 필요", 칸 });
|
||||
}
|
||||
return { body, 막힘, 수 };
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_SheetMap.ts
|
||||
* 엑셀 보기 창 세 보기 칠 · 뛸 곳(PLAN 779-4 · 요구서 621 3장 4 · 5) — 순수 셈.
|
||||
* · 재료 = 서버 격자 `보기 · 목록 · 줄 · 호표 · 단산`(779-1 `M02_Hopyo_Grid`) · 목록 `줄상태 · 만든`(785-7 `Judge.view` 1차 판정) ·
|
||||
* · 재료 = 서버 격자 `보기 · 목록 · 줄 · 호표 · 단산`(779-1 `M02_Hopyo_Grid`) · 목록 `줄상태 · 만든`(809-1 `Hopyo_View.listing` 줄 표지) ·
|
||||
* 블록 `[{번, 판정, SK, 갈래, 못옮김}]`(779-2 `M02_Hopyo_Marks` — 칠 꼴 표가 그 머리) · 확인 창 캐시 고름
|
||||
* · 칠 = success 이어짐 · warning 이어짐 · 엑셀과 다름 · info 고를 것 · danger 못 이음 · muted 밖 + mine(내가 고름 띠)
|
||||
* · 「이어짐」 = 1차 판정 「제안 있음」 · 판정 화면 확인함 · 만든 SC(옛 확인 창 캐시 `draftOf` 는 785-7 부터 늘 null)
|
||||
* · 「이어짐」 = 줄 표지 연결됨 · 로직 줄(809-1 `Hopyo_View` · 경쟁사 로직 인정) · 만든 SC · 못 이음 = 빨강 · 후보 = 파랑
|
||||
* · 호표 머리 행 = 그 호표 줄 칠 중 가장 나쁜 것 · 블록 = 만든 SK 갈래 › 없으면 부르는 첫 줄 판정 「예상」 · 안 불림 = 밖
|
||||
* · 블록 안 줄(779-5) = 블록 `줄 [[행, 상태, 차]]`(Marks · D-2 줄 잇기 상태) → 그 출처 행만 칠 · 머리 행 = 줄 가장 나쁜 칠 ·
|
||||
* 「엑셀과 다름」 = 이어졌는데 금액 참고 차만 있을 때 · 줄 재료 없으면 옛 블록 칠(범위 통째)
|
||||
@@ -42,10 +42,12 @@ export interface BriefMarks {
|
||||
}
|
||||
export interface BlockMark {
|
||||
번: number;
|
||||
/** 새 블록 판정(785-6 · 기초로 풂 · 기초+덧 · 줄 조합 · 고를 것) › 옛 부르는 첫 줄 맞춤셈 상태 */
|
||||
/** 블록 판정(809-1 · 불리는 블록 = 「가져온 식」 · 안 불림 = null) */
|
||||
판정: string | null;
|
||||
고를수?: number | null;
|
||||
부름?: number[];
|
||||
/** 블록 이름 · 단위(809-1 단가산출 목록) */
|
||||
이름?: string | null;
|
||||
단위?: string | null;
|
||||
SK: string | null;
|
||||
갈래: string | null;
|
||||
못옮김: unknown[];
|
||||
@@ -110,15 +112,15 @@ export function lineTone(
|
||||
const on = (d?.체크[at] ?? check) && ok;
|
||||
if (state === "%") return { tone: on ? "success" : "muted", mine };
|
||||
if (on) return { tone: state === "다른 점" && !parts ? "warning" : "success", mine };
|
||||
return { tone: state === "없음" || state === "노임" ? "danger" : "info", mine };
|
||||
return { tone: state === "못 이음" || state === "없음" || state === "노임" ? "danger" : "info", mine }; // prettier-ignore
|
||||
}
|
||||
|
||||
/** 가장 나쁜 칠(밖만이면 밖) */
|
||||
export const worst = (tones: Tone[]): Tone =>
|
||||
tones.reduce<Tone>((a, b) => (RANK[b] > RANK[a] ? b : a), "muted");
|
||||
|
||||
/** 새 블록 판정 칠(785-6) — 확인 전이라 제안 칠만(확인함 = 만든 SK) */
|
||||
const BLOCK_TONE: Record<string, Tone> = { "기초로 풂": "success", "기초+덧": "success", "줄 조합": "info", "고를 것": "info" }; // prettier-ignore
|
||||
/** 블록 판정 칠(809-1) — 불리는 블록 = 가져온 식(경쟁사 로직 인정) · 만든 SK 전 제안 칠 */
|
||||
const BLOCK_TONE: Record<string, Tone> = { "가져온 식": "success" };
|
||||
|
||||
/** 블록 칠 — 만든 SK 갈래 › 없으면 부르는 첫 줄 판정 「예상」 · 판정 없음 = 「일위대가 밖」 */
|
||||
export function blockTone(m: BlockMark | undefined): { tone: Tone; 글: string } {
|
||||
@@ -131,9 +133,8 @@ export function blockTone(m: BlockMark | undefined): { tone: Tone; 글: string }
|
||||
if (!m.판정) return { tone: "muted", 글: "일위대가 밖" }; // 부르는 일위대가 줄 없음(780-2)
|
||||
const fresh = BLOCK_TONE[m.판정];
|
||||
if (fresh) {
|
||||
const n = m.판정 === "고를 것" && m.고를수 ? ` ${m.고를수}` : "";
|
||||
const out = m.부름 && !m.부름.length ? " · 일위대가 밖" : "";
|
||||
return { tone: fresh, 글: `제안 — ${m.판정}${n}${out}` };
|
||||
return { tone: fresh, 글: `${m.판정}${out}` };
|
||||
}
|
||||
const tone: Tone = m.판정 === "맞음" ? "success" : m.판정 === "다른 점" ? "warning" : "info";
|
||||
return { tone, 글: `예상 — 부르는 첫 줄 ${m.판정}` };
|
||||
|
||||
Reference in New Issue
Block a user