feat(807-1): 엑셀 불러오기 = 수식 문서 · 단가산출 · 일위대가 하나씩 개인 층으로 · 원문 패널 기초 찾기 + 「어디서 왔나」 카드

- 임도품셈 판 대응표 CSV 새 자리(STmate/old/30_원자료) — 불러오기 시험 26 실패 → 0
- 가져온 식 SK = 수식 문서(멈춘 wip/802-4b-1 M02_Doc_Import 를 메인 판에 다시 얹음 · 빈 산근 = 엑셀 0 원 줄) —
  줄 쌓기 「수식」 탭 길 열림 · 셈 = 단가기준(엑셀 값 기억 | 연결 값) · 화면 캐시 기록 · 수식 길 저장 `단가기준`
- 1,021 블록 전수(실무 여덟 + 표본 밖 셋) 엑셀 검산 · 엑셀 전후 · 우리 전후 모두 같음 · 셈됨 줄지 않음
- 옮기기 도구 「가져온식」 상태 걷음(가져온 줄 문서로 대조)
- [내 일위대가로] = 호표 하나 + 하위 → 개인 층 SC · 부르는 단산 = 가져온 식 SK(같은 내용 = 있는 것 · 못 이은 줄 = 고정)
- [내 단가산출로] = 단산 블록 판 → 개인 층 가져온 식 SK(개인 층이면 [가져온 식으로 열기] 숨김)
- 원문 패널(M01 로직 · M02 단가산출) 맨 위 「단가산출 기초 찾기」 · 가져온 식 SK 첫 화면 = 「어디서 왔나」 카드
  (프로그램 · 파일 · 블록 · 행 · 가져온 때 · 표지 · 엑셀 글의 품셈 절 글만)
- 수식 탭 [엑셀 단가 그대로 | 우리 단가로](단가산출 화면 · 저장 뒤 페이지 문서 단가기준 맞춤)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-08 17:11:09 +09:00
co-authored by Claude Opus 5.5
parent 5a0144004d
commit 9c803024e0
25 changed files with 1465 additions and 58 deletions
+416
View File
@@ -0,0 +1,416 @@
"""M02 가져온 식(엑셀 불러오기) 단가산출 = 수식 문서(PLAN 802-4b-1 · 요구 664 · 665) — 서버 단독.
경쟁사 산근 블록도 같은 꼴(식 + 값)로 들어옴 — 엑셀 단가 그대로 엔진 줄(`가져온.로직` + SK 고침)을
`master_doc_convert` 로 문서로 옮기고(손 값 = 숫자 연결 · 중간 = 식 줄 · 덧줄 = 금액 줄 식 · 끝수 ·
단계 · 조정 · 합밖 · 자리 그대로) 이은 것을 연결로 얹음 · 연결마다 엑셀 값을 기억(`엑셀` 칸):
인력 · 재료 · 유가 · 전력 · 환율 이음 = 요소 {키} · 기계 이음(EQ) = 기계경비 단가산출
{키 GC, 입력 기계 …, 칸 = 그 줄 비목}(시간당 자료 없음 = 숫자 그대로 · 설명) ·
로직 이음 「로직(GC…)」 = 단가산출 {키, 입력} · 산근 속 산근(797-1 참조) = 단가산출
{키 형제 SK, 참조 {층, 주인}, 칸?} ·
소요량 · 계수 고른 것(797-2 `가져온.연결.고름`) = 그 수 자리에 표 연결 기호(pct · kgt = ÷ 배).
문서 머리 `가져온` = {파일, 시트, 행, 갈래, 코드?, 표지(797-1a · 못 읽음 까닭 그대로), 알림?}.
셈(`run`) — 단가기준 엑셀(기본) = 엑셀 값 기억 연결을 숫자로 보고 셈 · 우리 = 연결 값으로 셈 ·
형제 SK 는 늘 연결(부르는 쪽 단가기준으로 · 안쪽이 바뀌면 따라감).
"""
from __future__ import annotations
import copy as cp
from decimal import Decimal
from typing import Any
from M02_MasterTemplete import M02_Doc_Copy as dc
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
XL = "엑셀" # 연결 칸 — 엑셀 값 기억
HEAD = ("파일", "시트", "행", "갈래", "코드")
PICK = "소요량" # 고른 표 칸 기호 이름 머리
_SCALE = {"eq": (1,), "pct": (100, 0.01), "kgt": (1000, 0.001)}
def is_imported(sk: Any) -> bool:
return imp.is_imported(sk)
def basis_of(sk: dict) -> str:
return "우리" if (sk.get(imp.KEY) or {}).get("단가기준") == "우리" else "엑셀"
# ── 엑셀 단가 그대로 엔진 줄(옛 셈 길 · SK 고침 얹은 것) ───────────────────
def _ctx(sk: dict, whole: Any) -> tuple:
"""엑셀 단가 기준 고친 줄 — 옛 셈 길(`OLD`)로 돌려 엔진에 넣은 (색인, 키, 입력, 답, 재료)."""
from M02_MasterTemplete import M02_Doc_Sk as dsk
from M02_MasterTemplete import M02_Sangeun_Calc as calc
raw = {**sk, imp.KEY: {**sk[imp.KEY], "단가기준": "엑셀"}}
box: list = []
token = dsk.OLD.set(True)
try:
calc.run(sd.clean(raw, None, whole), whole, catch=box)
finally:
dsk.OLD.reset(token)
return box[-1]
# ── 소요량 · 계수 고른 칸 → 수 자리에 기호 ──────────────────────────────
def _swap_nth(node: Any, n: int, new: tuple, seen: list) -> Any:
"""`M02_Sangeun_QtyLinks._literals` 차례의 n 번째 수 → `new` 마디(같은 걸음 규칙)."""
mf = dc.cv.mf
if not isinstance(node, tuple):
return node
kind = node[0]
if kind == "num":
seen[0] += 1
return new if seen[0] - 1 == n else node
if kind == "bin":
cmp_ = node[1] in mf._OPS
sides = [
side
if cmp_ and isinstance(side, tuple) and side[0] == "num"
else _swap_nth(side, n, new, seen)
for side in node[2:]
]
return (kind, node[1], *sides)
if kind in ("neg", "field"):
return (kind, _swap_nth(node[1], n, new, seen), *node[2:])
if kind == "call":
return ("call", node[1], [_swap_nth(a, n, new, seen) for a in node[2]])
return node
def _conds(table: dict, at: int) -> dict | None:
"""표 줄 차례 → 그 줄 하나만 맞는 조건 {조건: 값}(범위 = 위 끝 › 아래 끝) · 못 가림 = None."""
rows = table.get("줄") or []
if not (0 <= at < len(rows)):
return None
row, out = rows[at], {}
for cond, how in (table.get("조건") or {}).items():
if cond not in row:
continue
have = row[cond]
if how == "범위":
low, high = have
have = high if high is not None else low
if have is None:
return None
out[cond] = have
try:
hit = dc.ml.find(table, out)
except Exception: # noqa: BLE001 — 못 가림 = 수 그대로
return None
return out if hit is row else None
def _picks(row: dict, ids: list[str], base: list[str], meta: dict, master: Any) -> dict:
"""고른 칸(`가져온.연결.고름`) → 중간 줄(표 찾기 하나 · 연결 기호) · 수 자리에 그 기호 ÷ 배 ·
돌려줌 = {중간 이름: 엑셀 칸 값}. 자리 줄이 SK 고침으로 바뀌었으면 건너뜀."""
mf, mee = dc.cv.mf, dc.cv.mee
src = meta["로직"]
picked = ((meta.get("연결") or {}).get("고름")) or []
out: dict[str, Any] = {}
mids = row.setdefault("중간", [])
taken = {str(x.get("이름")) for x in [*(row.get("입력") or []), *mids]}
for one in picked:
part, i, n = str(one.get("자리") or "::").split(":")[:3]
field = "수량" if part == "호표" else "식"
lines = src.get(part) or []
if not (i.isdigit() and n.isdigit() and int(i) < len(lines)):
continue
cid = base[int(i) + (len(src.get("호표") or []) if part == "덧줄" else 0)]
if cid not in ids:
continue
at = ids.index(cid)
mine = (row["호표"] if at < len(row["호표"]) else row.get("덧줄") or [])[
at if at < len(row["호표"]) else at - len(row["호표"])
]
if str(mine.get(field)) != str(lines[int(i)].get(field)):
continue # SK 덮개가 그 식을 바꿈 — 수 자리 어긋남
try:
table = master.get(str(one.get("표")))
conds = _conds(table, int(one.get("줄")))
node = mf.parse(str(mine[field]))
except Exception: # noqa: BLE001 — 표 · 식 못 읽음 = 수 그대로
continue
if conds is None:
continue
name = next(f"{PICK}{k}" for k in range(1, 999) if f"{PICK}{k}" not in taken)
taken.add(name)
scale = next(
(m for m in _SCALE.get(str(one.get("꼴")), (1,)) if _near(one, m)), None
) # 엑셀 수 = 칸 값 ÷ 배
if scale is None:
continue
ref = (
("name", name)
if scale == 1
else ("bin", "/", ("name", name), ("num", Decimal(str(scale))))
)
mine[field] = mee.raw_text(_swap_nth(node, int(n), ref, [0]))
args = ", ".join(f"{k}={_lit(v)}" for k, v in conds.items())
mids.append(
{"이름": name, "식": f"찾기({one['표']}{', ' if args else ''}{args}).{_q(one['칸'])}"}
)
out[name] = Decimal(str(one.get("값")))
return out
def _near(one: dict, m: float) -> bool:
cell, v = float(one.get("값") or 0), float(one.get("수") or 0)
return abs(cell - v * m) <= 1e-9 * max(1.0, abs(cell))
def _lit(v: Any) -> str:
if isinstance(v, (int, float, Decimal)) and not isinstance(v, bool):
return dc.cv.mf._plain(Decimal(str(v)))
return "'" + str(v).replace("'", "") + "'"
def _q(name: Any) -> str:
return dc.cv.mee._q(str(name))
# ── 이음 → 연결 ────────────────────────────────────────────────────────
def hourly_link(eq: str, part: str) -> dict | None:
"""기계(EQ) 시간당 그 비목 = 기계경비 단가산출 부름(`M02_Machine_Hourly` 와 같은 짝 고름) ·
자료 없음 = None."""
from M02_MasterTemplete import M02_Machine_Hourly as machine
if part not in imp.COSTS or not imp.hourly_of(eq).get("ok"):
return None
code = str(imp._whole().get(eq).get("원문번호") or "")
fit = [r for r in machine._family() if code in [str(c) for c in machine._codes(r)]]
if not fit:
return None
logic = min(fit, key=machine._rank)
given = {"기계": _lit(code)}
for spec in logic.get("입력") or []:
if spec.get("이름") != "기계":
given[spec["이름"]] = _lit((spec.get("고르기") or [""])[0])
return {"갈래": "단가산출", "키": str(logic["키"]), "입력": given, "칸": part}
def _logic_link(text: str) -> dict | None:
"""「로직(GC…, 이름='값')」 → 단가산출 연결 · 인자가 수 · 글 아니면 None."""
try:
node = dc.cv.mf.parse(text)
except Exception: # noqa: BLE001
return None
if node[0] != "logic" or any(v[0] not in ("num", "str") for _, v in node[2]):
return None
return {"갈래": "단가산출", "키": node[1], "입력": {k: _lit(v[1]) for k, v in node[2]}}
def _link_of(item: dict) -> tuple[dict | None, str]:
"""가져온 줄 하나의 연결(엑셀 값 기억 없이) · 설명(못 이은 까닭)."""
if item.get("참조"):
ref = item["참조"]
link = {
"갈래": "단가산출",
"키": ref["키"],
"참조": {"층": ref["층"], "주인": ref.get("주인")},
}
return link | ({"칸": item["참조비목"]} if item.get("참조비목") else {}), ""
key = str(item.get("이음") or "")
if not key:
return None, ""
if item.get("종류") == "로직":
return _logic_link(key), ""
if key.startswith("EQ"):
got = hourly_link(key, str(item.get("비목") or ""))
return got, "" if got else f"기계 {key} 시간당 경비 자료 없음 — 엑셀 단가 그대로"
link = {"갈래": "요소", "키": key}
if key[:2] == dc.cv.mm.LINKED: # 품셈재료 = 줄 단위로 값이 갈림(M01 변환과 같음)
link["단위"] = str(item.get("단위") or "")
return link, ""
def _excel(item: dict) -> Any:
if item.get("값묶음"):
return {k: Decimal(str(item["값묶음"].get(k) or 0)) for k in imp.COSTS}
return Decimal(str(item["단가"])) if item.get("단가") is not None else None
def _attach(doc: dict, row: dict, ids: list[str], src: dict[str, dict]) -> None:
"""금액 줄 단가 숫자(엑셀 값) → 이은 연결 + 엑셀 값 기억(SK 고침으로 바뀐 값 = 숫자 그대로)."""
by_id = {dc.md.var_id(v): v for v in doc.get("변수") or []}
for at, line in enumerate((doc.get("금액") or [])[: len(row["호표"])]):
item = src.get(ids[at]) if at < len(ids) else None
price = str(line.get("단가") or "")
var = by_id.get(price.removesuffix(".계"))
if item is None or var is None or (var.get("연결") or {}).get("갈래") != "숫자":
continue
excel = _excel(item)
if not item.get("참조") and var["연결"].get("값") != excel:
continue # SK 덮개 단가 = 사용자 값
link, note = _link_of(item)
if note:
var["설명"] = note
if link is None:
continue
if item.get("참조비목") and isinstance(excel, dict):
excel = excel[item["참조비목"]]
line["단가"], line["비목"] = dc.md.var_id(var), item["참조비목"]
var["연결"] = {**link, XL: excel}
def _numbers(doc: dict, keep: dict) -> None:
"""수 하나뿐인 식 줄(EST 얼린 변수) → 숫자 연결 · 고른 칸 중간 → 엑셀 칸 값 기억."""
by_id = {dc.md.var_id(v): v for v in doc.get("변수") or []}
lines = []
for one in doc.get("식") or []:
var = by_id.get(str(one.get("기호")))
try:
node = dc.md.parse(one.get("식"))
except dc.md.DocError:
node = None
if var is not None and node is not None and node[0] == "num":
var["연결"] = {"갈래": "숫자", "값": node[1]}
continue
lines.append(one)
doc["식"] = lines
for var in doc.get("변수") or []:
if var.get("열쇠") in keep and (var.get("연결") or {}).get("갈래") == "표":
var["연결"][XL] = keep[var["열쇠"]]
def derive(sk: dict, ctx: tuple | None = None, whole: Any = None) -> dict:
"""가져온 식 SK → 수식 문서(키 = SK 키) — `ctx` = 엑셀 단가 기준 옛 셈 길이 엔진에 넣은 것."""
meta = sk[imp.KEY]
if ctx is None:
ctx = _ctx(sk, whole or imp._whole())
master, key, values, got, _ = ctx
row = cp.deepcopy(master.logic(key))
ids = list(got.get("정체") or [])
bare = imp.row_of({**sk, imp.KEY: {**meta, "단가기준": "엑셀"}}) or {}
base = sd.line_ids(bare)
src = dict(zip(base, meta["로직"].get("호표") or []))
keep = _picks(row, ids, base, meta, master)
given = dc.given_of(master, key, values)
doc, notes = dc.cv.convert(_Row(master, key, row), row, given)
_attach(doc, row, ids, src)
_numbers(doc, keep)
head = {k: (meta.get("원본") or {}).get(k) for k in HEAD if (meta.get("원본") or {}).get(k)}
marks = list((meta.get("원본") or {}).get("표지") or [])
if not (doc.get("금액") or doc.get("결과") or row["호표"] or row.get("덧줄")):
_empty(doc) # 빈 산근 = 엑셀 0 원(줄 없음 합) — 「—」 아님
marks.append(EMPTY)
doc["가져온"] = head | {"표지": marks} | ({"알림": notes} if notes else {})
return {**doc, "키": str(sk.get("키") or key), "이름": str(sk.get("이름") or doc.get("이름"))}
EMPTY = "참고 · 빈 산근 — 엑셀 줄 없음 · 금액 0 그대로"
def _empty(doc: dict) -> None:
"""줄 없는 엑셀 산근 — 0 묶음 줄 하나(엑셀 금액 0 = 줄 없는 합 · 지은 값 아님)."""
doc["변수"].append(
{"열쇠": "P_빈", "기호": "P", "아래": "빈", "위": "", "이름": "빈 산근", "단위": "",
"연결": {"갈래": "숫자", "값": dict.fromkeys(imp.COSTS, Decimal(0))}}
) # fmt: skip
doc["금액"] = [{"이름": "빈 산근", "단위": "식", "비목": "묶음", "단가": "P_빈"}]
class _Row:
"""변환 색인 — 이 SK 줄만 고친 줄(소요량 중간 더함)로 · 그 밖 그대로."""
def __init__(self, master: Any, key: str, row: dict) -> None:
self._m, self._k, self._r = master, key, row
def logic(self, ref: str) -> dict:
return self._r if ref == self._k else self._m.logic(ref)
def __getattr__(self, name: str) -> Any:
return getattr(self._m, name)
# ── 셈 ────────────────────────────────────────────────────────────────
def view(doc: dict, basis: str) -> dict:
"""단가기준 엑셀 = 엑셀 값 기억 연결(형제 SK 빼고)을 숫자로 · 우리 = 그대로 ·
TS 짝 = `common_util/common_util_formula_doc_import.ts` `importView`(거울 시험 test_802_4b1)."""
if basis == "우리":
return doc
out = cp.deepcopy(doc)
for var in out.get("변수") or []:
link = var.get("연결") or {}
if XL in link and not str(link.get("키") or "").startswith("SK-"):
var["연결"] = {"갈래": "숫자", "값": link[XL]}
return out
class Links(dc.ml.Links):
"""M01 연결(지금 M01 · 수식 문서 폴더) + 형제 가져온 식 SK(부르는 쪽 단가기준)."""
def __init__(self, master: Any, basis: str, trail: tuple = ()) -> None:
super().__init__(master, dc.folder())
self.basis, self.trail, self.mine = basis, trail, {}
def doc(self, key: str) -> dict | None:
if not str(key).startswith("SK-"):
return super().doc(key)
return self.mine.get(key)
def learn(self, doc: dict) -> None:
"""문서의 형제 SK 연결 → 그 SK 문서를 읽어 둠(깊이 · 순환 = 못 읽음 · 표지는 계산기)."""
for var in doc.get("변수") or []:
link = var.get("연결") or {}
key, ref = str(link.get("키") or ""), link.get("참조")
if not key.startswith("SK-") or key in self.mine or not isinstance(ref, dict):
continue
full = {**ref, "키": key}
rid = refs.ref_id(full)
if rid in self.trail or len(self.trail) >= refs.DEPTH:
continue
sib = refs.read_ref(full)
if not is_imported(sib):
continue
self.mine[key] = None # 순환 막음
try:
sub = view(of(sib), self.basis)
except Exception: # noqa: BLE001 — 못 지음 = 없는 단가산출(연결 끊김 표지)
continue
self.mine[key] = sub
self.trail = (*self.trail, rid)
self.learn(sub)
def of(sk: dict) -> dict:
"""SK 의 문서 — 수식 길로 고친 것(따로) = 저장본 · 아니면 지금 줄에서 지음."""
got = sk.get(dc.KEY) if dc.has(sk) else None
if got and got.get("따로") and isinstance(got.get("문서"), dict):
return got["문서"]
return derive(sk)
def record(doc: dict, sk: dict) -> dict[str, Any]:
"""수식 탭 화면 캐시(`M02_Doc_Screen.record` 짝) — 단가기준 보기 문서 셈이 쓴 연결 · 형제 SK 문서."""
import master_doc_record as mr # `M02_Doc_Copy` 가 scripts 를 길에 둔 뒤
basis = basis_of(sk)
links = Links(imp._whole(), basis)
mine = view(doc, basis)
rec = mr.Rec(links)
try:
links.learn(mine)
rec.prefetch(mine)
dc.mc.calc(mine, rec)
except Exception: # noqa: BLE001 — 셈 틀림 = 셈 답 표지 · 기록은 모은 데까지
pass
return rec.out()
def run(doc: dict, sk: dict, master: Any = None) -> dict:
"""가져온 식 문서 셈(SK 단가기준) — 계산기 = M01 과 같은 `master_doc_calc`."""
basis = basis_of(sk)
links = Links(master or imp._whole(), basis)
mine = view(doc, basis)
try:
links.learn(mine)
return dc.mc.calc(mine, links)
except Exception as e: # noqa: BLE001 — 계산기 예외 없음이 원칙 · 남은 틈도 「—」
return {
"셈됨": False,
"표지": [{"갈래": "셈 틀림", "글": str(e)}],
**dict.fromkeys(dc.COSTS),
}
+4 -5
View File
@@ -4,7 +4,8 @@
층 문서 — 전 = 옛 셈 길(`M02_Doc_Sk.OLD` · 로직 사본 엔진) · 후 = 수식 사본 문서 셈:
SK = 기준(노 · 재 · 경 · 계) · SC · 일위대가 = `Labor_Doc.calc` 기준합 · 반영합(원 단위 · 반올림 6 자리).
같음 = 파일에 `수식` 칸만 더해 씀(그 밖 칸 그대로) · 다름 · 못 셈 = 안 씀(목록 · 까닭) ·
가져온 식 SK = 802-4b(그대로) · 바탕 참조 SK · 로직 사본 없는 문서 = 필요없음 · 이미 있음 = 있음.
가져온 식 SK(807-1) = 가져온 줄 문서(`M02_Doc_Import`) · 전 = 옛 엔진 · 후 = 문서 셈 ·
바탕 참조 SK · 로직 사본 없는 문서 = 필요없음 · 이미 있음 = 있음.
B09 사본 — `수식.json`(`M02_Doc_B09.build` · 만든 때 `단가.json` 줄 기준) 더함 · 견적 셈은 그대로
(`prices.calc` 전후 같음) · 문서 대조 = 일위대가 로직 줄마다 옛 엔진(`run_line`) 대 문서 셈
(M01 규칙 · 같은 사본 값) — 다르면 목록.
@@ -75,15 +76,13 @@ def formula_of(kind: str, doc: dict) -> dict | None:
def check(kind: str, path: Path) -> dict[str, Any]:
"""층 문서 하나 — {상태: 있음 | 필요없음 | 가져온식 | 같음 | 다름 | 못셈, 전, 후, 수식?, 까닭?}."""
"""층 문서 하나 — {상태: 있음 | 필요없음 | 같음 | 다름 | 못셈, 전, 후, 수식?, 까닭?}."""
from M02_MasterTemplete import M02_MasterTemplete_Store as store
doc = json.loads(path.read_bytes())
head = {"이름": doc.get("이름") or ""}
if dc.has(doc):
return {**head, "상태": "있음"}
if kind == "sangeun" and doc.get("가져온"):
return {**head, "상태": "가져온식", "까닭": "엑셀 가져온 식 = 802-4b 몫(옛 길 그대로)"}
try:
with store.at(path.parent.parent):
copy = formula_of(kind, doc)
@@ -97,7 +96,7 @@ def check(kind: str, path: Path) -> dict[str, Any]:
if before == after:
return {**row, "상태": "같음", "수식": copy}
why = (mark or {}).get("까닭") or "전후 금액 다름 — 안 옮김"
if kind == "sangeun" and not (doc.get("바탕") or doc.get("더한줄")):
if kind == "sangeun" and not (doc.get("바탕") or doc.get("더한줄") or doc.get("가져온")):
why = "빈 SK(바탕 · 줄 없음) — 옛 0 · 새 빈 문서 「—」(요구서_664 0 장 · 브레인 802-4a ③)"
return {**row, "상태": "다름(빈 문서)", "까닭": why, "수식": copy}
return {**row, "상태": "다름", "까닭": why}
+1
View File
@@ -26,6 +26,7 @@ class DocBody(BaseModel):
끊음: dict[str, Any] | None = None
할일: str | None = None
판: str | None = None
단가기준: str | None = None # 가져온 식만(807-1 · 엑셀 | 우리)
@router.get("/layers/{layer}/sangeun/{key}/doc")
+32 -10
View File
@@ -9,7 +9,8 @@ SK `수식`(꼴 = `M02_Doc_Copy`) — `문서` = 이 SK 문서(키 = SK 키 ·
따로 — 수식 길(`M02_Doc_Router`)로 문서를 고침: 저장한 문서가 정본 · 옛 칸 고침 저장 = 409 ·
[재생성] = 지금 M01 로 다시 찍어 파생으로 돌아감(고친 문서 버림).
SK 금액(기준 · 1 단위 = 기준 ÷ 배) = 문서 셈 — 일위대가 · 사전조합 · 구조물 조합이 이 값을 씀.
가져온 식 SK(엑셀 · 바탕 null) = 옛 길 그대로(802-4b) · 바탕 참조 SK = 옛 길(값 사본 없음).
가져온 식 SK(807-1) = `M02_Doc_Import` 문서(파생 = 가져온 줄 + SK 고침에서 지음 · 따로 =
저장본) · 셈 = 단가기준(엑셀 = 엑셀 값 · 우리 = 연결 값) · 바탕 참조 SK = 옛 길(값 사본 없음).
"""
from __future__ import annotations
@@ -21,6 +22,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 as dim
from M02_MasterTemplete import M02_Sangeun_Copy as skc
from M02_MasterTemplete import M02_Sangeun_Doc as sd
@@ -35,8 +37,8 @@ SAVING: ContextVar[dict | None] = ContextVar("수식길저장", default=None) #
def own(doc: Any) -> bool:
"""수식 사본을 가질 SK — 가져온 식 · 바탕 참조 SK 빼고."""
if not isinstance(doc, dict) or doc.get("가져온"):
"""수식 사본을 가질 SK — 바탕 참조 SK 빼고(가져온 식 = 가져온 줄 문서 · 807-1)."""
if not isinstance(doc, dict) or (doc.get("가져온") and not dim.is_imported(doc)):
return False
return "참조" not in (doc.get("바탕") or {})
@@ -50,8 +52,11 @@ def _strip(v: Any) -> Any:
def old_fp(doc: dict) -> str:
"""옛 칸 지문 — 따로 문서 위 옛 칸 고침 막음."""
return dc.fp(_strip({k: doc.get(k) for k in OLD_FIELDS if doc.get(k) not in (None, {}, [])}))
"""옛 칸 지문 — 따로 문서 위 옛 칸 고침 막음(가져온 식 = 가져온 줄 · 고른 칸 · 이음도)."""
got = {k: doc.get(k) for k in OLD_FIELDS if doc.get(k) not in (None, {}, [])}
if dim.is_imported(doc):
got["가져온"] = {k: doc["가져온"].get(k) for k in ("로직", "연결")}
return dc.fp(_strip(got))
# ── 파생 — 엔진에 넣는 고친 줄 → 문서 ─────────────────────────────────
@@ -90,7 +95,10 @@ def derive(sk: dict, ctx: tuple) -> dict:
def seed(sk: dict) -> dict | None:
"""수식 사본 없는 SK(옮기기 전 · 새로)의 씨 — 로직 사본 값 + 빈 부름 · 빈 SK = 빈 문서."""
"""수식 사본 없는 SK(옮기기 전 · 새로)의 씨 — 로직 사본 값 + 빈 부름 · 빈 SK = 빈 문서 ·
가져온 식 = 빈 부름(문서는 셈마다 지음)."""
if dim.is_imported(sk):
return {"부름": {}}
if skc.has(sk):
return {**dc.values_of(sk[skc.KEY]), "부름": {}}
if sk.get("바탕") or sk.get("더한줄"):
@@ -109,6 +117,13 @@ def copy_of(sk: dict, ctx: tuple | None) -> dict | None:
return None
if got.get("따로") or ctx is None:
return got
if dim.is_imported(sk): # 가져온 식 — 엑셀 단가 기준 줄에서 지음(우리 = 한 번 더 돎)
try:
same = dim.basis_of(sk) == "엑셀"
root = dim.derive(sk, ctx if same else None, dc.base(ctx[0]))
except dc._ERR as e:
root = {**dc.md.blank(str(sk.get("키"))), "_틀림": str(e)}
return {**got, "문서": root, "부름": {}}
try:
root = derive(sk, ctx)
except dc._ERR as e:
@@ -127,7 +142,7 @@ def on_run(sk: dict, ans: dict, ctx: tuple | None) -> dict:
copy = copy_of(sk, ctx)
if copy is None:
return ans
got = dc.run(copy["문서"], copy)
got = run(sk, copy, ctx[0] if ctx else None)
money = dc.money(got)
base = {k: Decimal(str(v)) for k, v in money.items()} if money else None
times = Decimal(str(ans.get("배") or 1))
@@ -138,6 +153,13 @@ def on_run(sk: dict, ans: dict, ctx: tuple | None) -> dict:
return {**ans, "기준": base, "1단위": unit, "수식셈": mark}
def run(sk: dict, copy: dict, master: Any = None) -> dict:
"""문서 셈 — 가져온 식 = 단가기준 · 형제 SK(`dim.run`) · 그 밖 = 값 사본(`dc.run`)."""
if dim.is_imported(sk):
return dim.run(copy["문서"], sk, dc.base(master) if master is not None else None)
return dc.run(copy["문서"], copy)
# ── 저장 첫머리(옛 길 `clean_doc` 끝) ──────────────────────────────────
def stored(key: Any) -> dict | None:
from M02_MasterTemplete import M02_MasterTemplete_Store as store
@@ -174,15 +196,15 @@ def settle(out: dict, whole: Any, job: Any, raw: Any) -> dict:
"수식 문서로 고친 단가산출 — 옛 칸(덮개 · 줄) 고침은 수식 탭에서 · 또는 [재생성]",
409,
)
copy = dc.refresh(have)[0] if job == skc.PRICE else have
copy = dc.refresh(have)[0] if job == skc.PRICE and not dim.is_imported(out) else have
return {**out, KEY: norm(copy)}
# 빈 SK(바탕 · 줄 없음) = 빈 문서(요구서_664 0 장) · 수식 길로 고친 빈 SK = 위 따로(804-4)
if not skc.has(out):
if not skc.has(out) and not dim.is_imported(out):
empty = seed(out)
return {**out, KEY: norm(empty)} if empty else out
box: list = []
calc.run(out, whole, catch=box)
calls = {} if job == skc.REGEN else (have or {}).get("부름") or {}
first = {**dc.values_of(out[skc.KEY]), "부름": calls}
first = {**(dc.values_of(out[skc.KEY]) if skc.has(out) else {}), "부름": calls}
copy = copy_of({**out, KEY: first}, box[-1] if box else None)
return {**out, KEY: norm({k: v for k, v in (copy or first).items() if k != "따로"})}
+31 -19
View File
@@ -7,7 +7,8 @@ M01 수식 문서 길과 같은 꼴: 읽기 {문서, 셈} · 미리 셈 {문서}
저장 = 그 SK 저장(`store.write` — 층 쓰기 권한 · 판 409 · 옛 칸 · 로직 사본 그대로) ·
문서 고침 = 따로(이 문서가 정본) · 재생성 = 지금 M01 로 다시 찍어 파생으로(고친 문서 · 끊음 버림) ·
단가갱신 = 값 줄만 지금 M01(문서 그대로).
가져온 식 SK(엑셀) = 802-4b 몫(400) · 바탕 참조 SK = 값 사본 없음(400).
가져온 식 SK(807-1) = 가져온 줄 문서 · 셈 = 단가기준(엑셀 값 · 연결 값) · 부름 · 끊음 없음
(형제 SK 는 셈 때 읽음) · 단가갱신 = 문서 그대로 · 바탕 참조 SK = 값 사본 없음(400).
"""
from __future__ import annotations
@@ -16,6 +17,7 @@ import json
from typing import Any
from M02_MasterTemplete import M02_Doc_Copy as dc
from M02_MasterTemplete import M02_Doc_Import as dim
from M02_MasterTemplete import M02_Doc_Screen as screen
from M02_MasterTemplete import M02_Doc_Sk as dsk
from M02_MasterTemplete import M02_MasterTemplete_Store as store
@@ -54,8 +56,6 @@ def current(doc: dict, job: Any = None) -> dict:
from M02_MasterTemplete import M02_Sangeun_Calc as calc
if not dsk.own(doc):
if doc.get("가져온"):
raise _bad("가져온 식 단가산출 — 수식 문서는 엑셀 불러오기 몫(802-4b)")
raise _bad("바탕 참조 단가산출 — 수식 사본 없음")
clean, whole = _settled(doc, job)
if not dc.has(clean): # 옮기기 전 · 새 SK — 저장 때 지을 사본을 미리
@@ -72,18 +72,19 @@ def current(doc: dict, job: Any = None) -> dict:
return got if got is not None else clean[dsk.KEY]
def answer(copy: dict) -> dict[str, Any]:
"""{문서, 부름, 끊음, 셈, 사본 표지, 연결 기록} — 글 · 수는 JSON 꼴."""
got = dc.run(copy["문서"], copy)
def answer(copy: dict, sk: dict) -> dict[str, Any]:
"""{문서, 부름, 끊음, 셈, 사본 표지, 연결 기록} — 글 · 수는 JSON 꼴 · 가져온 식 표지 = 단가기준."""
got = dsk.run(sk, copy)
head = {k: copy.get(k) for k in ("시점", "단가시점")}
mark = {**head, "따로": bool(copy.get("따로")), **dc.state(copy)}
mark = {**head, "따로": bool(copy.get("따로"))}
mark |= {"단가기준": dim.basis_of(sk)} if dim.is_imported(sk) else dc.state(copy)
body = {
"문서": copy["문서"],
"부름": copy.get("부름") or {},
"끊음": copy.get("끊음") or {},
"셈": got,
"사본": mark,
"연결": screen.record(copy), # 화면 캐시(802-4b-2)
"연결": dim.record(copy["문서"], sk) if dim.is_imported(sk) else screen.record(copy),
}
return json.loads(json.dumps(dc.ml._norm(body), ensure_ascii=False, default=str))
@@ -91,7 +92,7 @@ def answer(copy: dict) -> dict[str, Any]:
def read(key: str) -> dict[str, Any]:
"""읽기 — {문서, 부름, 끊음, 셈, 사본, 판}."""
got = _file(key)
return {**answer(current(got["문서"])), "판": got["판"]}
return {**answer(current(got["문서"]), got["문서"]), "판": got["판"]}
# ── 몸 검사 ───────────────────────────────────────────────────────────
@@ -106,8 +107,8 @@ def _doc(raw: Any, key: str, where: str) -> dict:
return {**raw, "키": key}
def edited(copy: dict, body: dict, key: str) -> dict:
"""화면 고침 얹은 사본 — 문서 · 끊음 글만 받음(값 · 부름은 서버 것)."""
def edited(copy: dict, body: dict, key: str, sk: dict | None = None) -> dict:
"""화면 고침 얹은 사본 — 문서 · 끊음 글만 받음(값 · 부름은 서버 것 · 가져온 식 = 부름 없음)."""
if body.get("할일") not in (None, "", *JOBS):
raise _bad("할일은 단가갱신 · 재생성")
if body.get("할일") == skc.REGEN:
@@ -127,8 +128,8 @@ def edited(copy: dict, body: dict, key: str) -> dict:
*dc.calls(out["문서"]),
*(c for d in out.get("끊음", {}).values() for c in dc.calls(d)),
]
whole = dc.over(out)
out["부름"] = dc.closure(whole, calls, copy.get("부름"))
if not dim.is_imported(sk):
out["부름"] = dc.closure(dc.over(out), calls, copy.get("부름"))
out["따로"] = True
return out
@@ -136,12 +137,13 @@ def edited(copy: dict, body: dict, key: str) -> dict:
def preview(key: str, body: dict) -> dict[str, Any]:
"""미리 셈(저장 없음) — {셈, 전, 후, 바뀐자원?, 버림?}."""
doc = _file(key)["문서"]
mine = _basis(doc, body)
before = current(doc)
job = body.get("할일") or None
after = edited(current(doc, job) if job else before, body, key)
out = answer(after)
out |= {"전": dc.money(dc.run(before["문서"], before)), "후": dc.money(out["셈"])}
if job == skc.PRICE:
after = edited(current(doc, job) if job else before, body, key, doc)
out = answer(after, mine)
out |= {"전": dc.money(dsk.run(doc, before)), "후": dc.money(out["셈"])}
if job == skc.PRICE and not dim.is_imported(doc):
out["바뀐자원"] = dc.refresh(before)[1]
if job == skc.REGEN:
out["버림"] = {
@@ -151,16 +153,26 @@ def preview(key: str, body: dict) -> dict[str, Any]:
return json.loads(json.dumps(dc.ml._norm(out), ensure_ascii=False, default=str))
def _basis(doc: dict, body: dict) -> dict:
"""가져온 식 단가기준 바꿈(807-1 · 몸 `단가기준` 엑셀 | 우리) — 그 밖 SK · 빈 값 = 그대로."""
want = body.get("단가기준")
if not want or not dim.is_imported(doc):
return doc
if want not in ("엑셀", "우리"):
raise _bad("단가기준은 엑셀 · 우리")
return {**doc, "가져온": {**doc["가져온"], "단가기준": want}}
def save(key: str, body: dict) -> dict[str, Any]:
"""저장(층 쓰기) — 서버가 다시 셈한 사본을 그 SK 에 씀 · {문서, 부름, 끊음, 셈, 사본, 판}."""
got = _file(key)
if str(body.get("판") or "") != got["판"]:
raise _bad({"stale": [key], "판": got["판"]}, 409)
job = body.get("할일") or None
raw = {**got["문서"], **({skc.JOB: job} if job else {})}
raw = {**_basis(got["문서"], body), **({skc.JOB: job} if job else {})}
token = None
if job != skc.REGEN and (body.get("문서") is not None or body.get("끊음") is not None):
mine = edited(current(got["문서"], job), body, key)
mine = edited(current(got["문서"], job), body, key, got["문서"])
token = dsk.SAVING.set({"키": key, "사본": mine})
try:
store.write(sd.KIND, key, got["판"], raw)
+53 -7
View File
@@ -137,6 +137,9 @@ class Builder:
self.src, self.dry, self._sk = Path(source), False, None
self.same, self.reused = lsame.Same(), False # 같은 내용 SC 찾기(한 요청 메모)
self.strict = True # 남은 줄 있으면 409(붙여넣기 `build_all` = 거짓)
self.lenient = (
False # 하나 들이기(807-1) = 이름 이음은 하되 남은 줄 = 고정 줄(엑셀 숫자 그대로)
)
self.doc_id = ledger.doc_id(source, key) # 739-1 이 불러오기에서 이은 짝
# ── 재검사 ───────────────────────────────────────────────────────
@@ -300,7 +303,7 @@ class Builder:
doc["본뜬"]["품셈"] = poomsem
if self.dry:
return {"남은줄": left}
if left and save and self.strict:
if left and save and self.strict and not self.lenient:
raise store.StoreError(409, {"까닭": sangeun.LEFT, "남은줄": left})
fixed_n = sum(1 for x in lines if "고정" in x)
links = sum(1 for x in lines if any(((x.get("고정") or {}).get("이음") or {}).values()))
@@ -315,6 +318,7 @@ class Builder:
ref, again, self.reused = hkids.save(doc, self.same)
wrote = ledger.write(self.folder, book, self.who, file or None, self.doc_id)
out = {"참조": ref, "다시씀": again, "재사용": self.reused, "빠진줄": missing, "하위": sub}
out["남은줄"] = left
out["장부"] = {"씀": wrote}
return {**out, "줄수": {**count, "이음후보": linker.near}}
@@ -365,8 +369,8 @@ class Builder:
}
def _order(units: dict[int, dict[str, Any]]) -> list[int]:
"""하위 호표 먼저(호표 줄 참조) · 고리는 끊음."""
def _order(units: dict[int, dict[str, Any]], start: int | None = None) -> list[int]:
"""하위 호표 먼저(호표 줄 참조) · 고리는 끊음 · `start` = 그 호표와 하위만."""
out: list[int] = []
seen: set[int] = set()
@@ -380,20 +384,54 @@ def _order(units: dict[int, dict[str, Any]]) -> list[int]:
visit(int(ref.get("번") or 0))
out.append(no)
for no in sorted(units):
for no in [start] if start is not None else sorted(units):
visit(no)
return out
def _imported(folder: Path, source: Path, key: str, nos: list[int], units: dict) -> dict[str, str]:
"""하나 들이기(807-1) — 호표들이 부르는 단산 블록 = 이 층 가져온 식 SK(`FromHopyo.make` · 같은 내용 =
있는 것) · 이미 본뜬 SK 가 있는 블록 · 산근 시트 없음 · 못 만듦 = 건너뜀(그 줄 = 고정) → {본뜬 키: SK}."""
from M02_MasterTemplete import M02_Sangeun_FromHopyo as fh
if not fh.has(source, key):
return {}
have, out = sangeun.made(folder, key, refs.where(source)), {}
for no in nos:
for line in units[no].get("줄") or []:
ref = line.get("참조") or {}
if ref.get("종류") != "단산" or not ref.get("번"):
continue
mark_ = sangeun.mark(key, int(ref["번"]))
if mark_ in have or mark_ in out:
continue
try:
out[mark_] = fh.make(source, key, int(ref["번"]))["이름"]
except store.StoreError:
continue
return out
def build_all(
folder: Path, source: Path, key: str, who: Any = None, strict: bool = False
folder: Path,
source: Path,
key: str,
who: Any = None,
strict: bool = False,
only: int | None = None,
) -> dict[str, Any]:
"""[모두 고정으로 들여오기](705-3) — 시트 호표 모두 · 하위 먼저 · 줄 = 고정(하위 = 참조) ·
`strict`(불러오기 길 · 729-2) = 남은 줄 있는 호표는 안 만듦(`실패 {번, 까닭, 남은줄}`) ·
붙여넣기(705-2)는 막지 않음 ·
이미 확인된 호표는 덮지 않음(`그대로`) → `{호표, 줄, 품셈, 고정, 호표참조, 빠짐, 이음, 이음후보,
같음, 그대로, 다름 [{번, 이름, 엑셀, 우리, 차이}], 실패 [{번, 까닭}], 만듦 [{번, 이름, 키}]}`."""
같음, 그대로, 다름 [{번, 이름, 엑셀, 우리, 차이}], 실패 [{번, 까닭}], 만듦 [{번, 이름, 키}]}`.
`only`(807-1 [내 일위대가로]) = 그 호표 + 하위만 · 부르는 단산 = 가져온 식 SK(`단가산출`) ·
못 이은 줄 = 고정 줄(엑셀 숫자 그대로 · 만듦 `남은줄` 수)."""
units = {int(h["번"]): h for h in hopyo_store.read(source, key).get("호표") or []}
if only is not None and int(only) not in units:
raise _bad(f"없는 호표 「제{only}호표」", 404)
nos = _order(units, None if only is None else int(only))
sks = _imported(folder, source, key, nos, units) if only is not None else {}
sums = dict.fromkeys(("품셈", "고정", "호표", "빠짐", "이음", "이음후보"), 0)
out: dict[str, Any] = {
"호표": len(units),
@@ -405,12 +443,17 @@ def build_all(
"만듦": [],
}
shared, failed = lsame.Same(), set()
for no in _order(units):
if only is not None:
out["단가산출"] = sorted(set(sks.values()))
for no in nos:
out["줄"] += len(units[no].get("줄") or [])
bad = next((k for k in hkids.kids_of(units[no]) if k in failed), None)
try:
maker = Builder(folder, source, key, no, who)
maker.strict, maker.same = strict, shared
if only is not None:
maker.lenient = True
maker._sk = {**sangeun.made(folder, key, maker.source), **sks}
have = maker.done.get(mark(key, no))
if bad is not None and not have: # 777-1 — 하위 실패 연쇄 까닭 따로
raise store.StoreError(409, {"까닭": f"하위 제{bad}호표 실패", "하위": bad})
@@ -423,7 +466,10 @@ def build_all(
for k in sums:
sums[k] += made["줄수"][k]
got = compare(ref, maker.hopyo)
left = len(made.get("남은줄") or []) if not have else 0
out["만듦"].append({"번": no, "이름": units[no].get("이름"), "키": ref["키"]})
if only is not None:
out["만듦"][-1]["남은줄"] = left
except store.StoreError as e:
why = e.detail if isinstance(e.detail, dict) else {"까닭": str(e.detail)}
out["실패"].append({"번": no, **why})
+1 -1
View File
@@ -93,7 +93,7 @@ def clause(theirs: list[list[str]], our: dict[str, Any]) -> tuple[float, list[st
# ── 출처 풀이(781-1 · 781-2) — 건설 부문 · 임도 옛 번호 대응 · 기초 없음 ───────────
OLD = "(옛)"
_ROAD = "임도품셈" + OLD
_ROAD_MAP = make.BODY.parent / "경쟁사 프로그램/STmate 분석/30_원자료/임도품셈_판_대응표.csv"
_ROAD_MAP = make.BODY.parent / "경쟁사 프로그램/STmate/old/30_원자료/임도품셈_판_대응표.csv"
_OLD_NO = re.compile(r"^(\d+(?:-\d+)+)(\s+[가-하])?$")
_STOP_GRAMS = frozenset("설치 해체 작업 인력 기계 장비 보완 신설 공사".split())
+11 -2
View File
@@ -388,11 +388,13 @@ class BuildBody(_Strict):
class BuildAllBody(_Strict):
"""시트 통째(705-3) — 파일 층 `층` · `주인` · `키` 만(숫자 · 줄 칸 없음 · 서버가 파일에서 읽음)."""
"""시트 통째(705-3) — 파일 층 `층` · `주인` · `키` 만(숫자 · 줄 칸 없음 · 서버가 파일에서 읽음) ·
`번` = 그 호표 하나 + 하위(807-1 [내 일위대가로])."""
층: Layer
주인: str | None = None
키: str
번: int | None = None
@router.post("/layers/{layer}/hopyo-build-all")
@@ -406,7 +408,14 @@ def hopyo_make_all(
`{호표, 줄, 품셈, 고정, 호표참조, 빠짐, 이음, 이음후보, 같음, 그대로, 다름, 실패}`."""
folder = _root(session, layer, owner, write=True)
source = _root(session, body.층, body.주인)
args = (folder, source, body.키, _who(session), True) # 729-2 — 남은 줄 있는 호표 = 실패
args = (
folder,
source,
body.키,
_who(session),
True,
body.번,
) # 729-2 남은 줄 = 실패(번 = 고정)
return _call(folder, hopyo_build.build_all, *args)
@@ -399,9 +399,13 @@ export function createJudgeBlock(opt: JudgeBlockOptions): {
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, fresh);
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 = [
@@ -0,0 +1,65 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_Mine.ts
* 엑셀 보기 오른쪽 호표 목록 줄 [내 일위대가로](PLAN 807-1 · 요구 699) — `Hopyo_Sheet` 목록 줄이 붙임.
* 물음 → 서버 `POST /layers/personal/hopyo-build-all {층, 주인?, 키, 번}`(그 호표 + 하위 → 개인 층 SC ·
* 부르는 단산 = 가져온 식 SK · 같은 내용 = 있는 것 · 못 이은 줄 = 고정 줄 엑셀 숫자 그대로) → 알림(만든 SC · 단가산출 수 · 엑셀과 같음)
* 개인 층은 누구나 씀 · 파일 층 읽기 권한은 서버가 봄
* ========================================================================== */
import {
askConfirm,
createButton,
hideLoadingOverlay,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { call } from "./M02_MasterTemplete_Api_Fetch";
import { at, type HopyoBrief, type HopyoScope } from "./M02_MasterTemplete_Hopyo_Api";
interface MineReply {
만듦: { 번: number; 키: string; 남은줄?: number }[];
다름: { 번: number }[];
실패: { 번: number; 까닭?: string }[];
단가산출?: string[];
}
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
/** [내 일위대가로] 단추 — `scope` = 파일 층 · `key` = HP 키 */
export function mineButton(scope: HopyoScope, key: string, h: HopyoBrief): HTMLElement {
const b = createButton({
label: L("M02_ToMySc"),
variant: "ghost",
size: "sm",
onClick: async (e) => {
e.stopPropagation();
if (!(await askConfirm({ message: L("M02_ToMyScAsk").replace("{n}", String(h.번)) }))) return;
const file = { 층: scope.layer, ...(scope.owner ? { 주인: scope.owner } : {}) };
showLoadingOverlay();
try {
const got = await call<MineReply>(
at({ layer: "personal", owner: null }, "/hopyo-build-all"),
{
method: "POST",
body: JSON.stringify({ ...file, 키: key, 번: h.번 }),
},
);
const made = got.만듦.find((x) => x.번 === h.번);
if (!made) throw new Error(got.실패.map((x) => x.까닭).join(" · ") || "실패");
const same = got.다름.some((x) => x.번 === h.번) ? "다름" : "같음";
const text = L("M02_ToMyScDone")
.replace("{k}", made.키)
.replace("{s}", String(got.단가산출?.length ?? 0))
.replace("{same}", same);
showToast(text, same === "같음" ? "success" : "warning");
} catch (error) {
showToast(why(error), "error");
} finally {
hideLoadingOverlay();
}
},
});
b.setAttribute("data-hopyo-mine", String(h.번));
return b;
}
@@ -29,6 +29,7 @@ import {
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 * as Nav from "./M02_MasterTemplete_Hopyo_SheetNav";
@@ -282,6 +283,7 @@ export function buildHopyoSheet(opt: HopyoSheetOptions): HopyoSheetBody {
children: [
el("span", { className: "m02-hopyo-sheet__no", text: `제${h.번}호표` }),
editButton(h),
mineButton(opt.scope, opt.key, h), // [내 일위대가로](807-1)
el("span", { className: "m02-hopyo-sheet__name", text: `${h.이름} ${h.규격}`.trim() }),
linked,
],
@@ -126,7 +126,7 @@ export function mountSangeunDetail(
.문서 as SkDoc;
if (!alive) return;
const was = JSON.parse(saved) as SkDoc;
doc.수식 = was.수식 = got.수식;
[doc.수식, was.수식, doc.가져온, was.가져온] = [got.수식, got.수식, got.가져온, got.가져온]; // 807-1 단가기준
saved = JSON.stringify(was);
touch();
} catch {
@@ -426,10 +426,11 @@ export function mountSangeunDetail(
onInput,
cover: { ho, open: lineOpener(hoCtx) },
});
// prettier-ignore
side?.set([
...(base ? [tabOf(base.logic)] : []),
...extraKeys.flatMap((k) => (k === baseKey() ? [] : infoOf.has(k) ? [infoOf.get(k)!] : [])),
]);
], doc.가져온);
}
/** 재료 고름(줄 모달) = SK 문서 `고름`(cid → MT) — 둘째 기초 세부 줄은 보기만 */
@@ -4,15 +4,23 @@
* 오른쪽 서랍(`createSidePanels`) + 원문 보기(`createSourceView` · M02 로그인 길 `/m02/logic-lib/source`)
* 기초마다 탭 — 바탕 로직 · 둘째 기초(더한줄 로직) 각각의 출처 · 기초가 하나면 탭 없음 · 없으면 안내
* 처음 = 넓은 화면만 열림(좁으면 세로 띠로 접힘) · 열림 · 폭 기억 = `m02-sangeun-side`
* 맨 위 「단가산출 기초 찾기」(807-1 · M01 원문 패널과 같은 `LogicLab_SourceFind`) — 고르면 그 기초 원문(보기만 · 기억 안 함) ·
* 지우면 처음 화면 · 처음 화면 = 우리 SK 는 기초 원문 · 가져온 식 SK 는 「어디서 왔나」 카드
* (프로그램 · 파일 · 블록 · 행 · 가져온 때 · 표지 요약 + 엑셀 글의 품셈 절 글만 — 찾기 · 잇기 없음)
* 일위대가 줄 모달 안 상세는 안 만듦(부르는 쪽이 `source` 를 안 켬).
* ========================================================================== */
import { createSegment } from "@ui/ui_template_segment";
import { el } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { currentLanguageIndex, t as L } from "@ui/ui_template_locale";
import { createSidePanels } from "@ui/ui_template_side_panels";
import { createText } from "@ui/ui_template_text";
import { createButton } from "@ui/ui_template_button";
import { createCard } from "@ui/ui_template_card";
import { createKv } from "@ui/ui_template_kv";
import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source";
import { createSourceFind } from "../M01_MasterData/M01_MasterData_UI_LogicLab_SourceFind";
import { originOf, type Origin, type OriginMeta } from "./logic/M02_Sangeun_Origin";
import { tc } from "./M02_MasterTemplete_Labor_Text";
export interface SourceTab {
@@ -34,16 +42,83 @@ export const tabOf = (logic: {
/** 이 폭 이상일 때만 처음부터 열림 — 상세 표 여섯 칸이 좁아지지 않게 */
const OPEN_FROM = 1500;
const TEXT = {
Origin: ["가져온 단가산출 — 어디서 왔나", "Imported build-up — where from"],
Program: ["프로그램", "Program"],
File: ["파일", "File"],
Block: ["블록", "Block"],
Rows: ["행", "Rows"],
When: ["가져온 때", "Imported"],
Marks: ["표지", "Flags"],
MarksN: ["{n} 개", "{n}"],
Clauses: ["엑셀 글의 품셈 정보", "Standard clauses in the Excel text"],
NoClause: ["엑셀 글에 품셈 절 글 없음", "No standard clause in the Excel text"],
Hint: ["우리 기초 원문은 위 찾기로", "Find our base above to see its source"],
More: ["표지 모두 보기", "Show all flags"],
} as const;
const tx = (k: keyof typeof TEXT, n = 0): string =>
TEXT[k][currentLanguageIndex === 1 ? 1 : 0].replace("{n}", String(n));
/** 「어디서 왔나」 카드 — 표지는 셋까지(더 = 단추로 펼침) */
function originCard(o: Origin): HTMLElement {
const marks = el("div");
const showMarks = (all: boolean): void =>
marks.replaceChildren(
...(all ? o.표지 : o.표지.slice(0, 3)).map((m) => createText(m, "muted", 12)),
...(!all && o.표지.length > 3
? [createButton({ label: tx("More"), variant: "ghost", size: 24, onClick: () => showMarks(true) })] // prettier-ignore
: []),
);
showMarks(false);
const clauses = o.품셈.length
? o.품셈.map((c) =>
createKv([{ label: `${c.책} ${c.절}`, value: c.행 !== undefined ? `${c.행}행 · ${c.글}` : c.글 }]),
) // prettier-ignore
: [createText(tx("NoClause"), "muted", 12)];
const card = createCard({
title: tx("Origin"),
pad: 12,
body: [
createKv([
{ label: tx("Program"), value: o.프로그램 },
{ label: tx("File"), value: o.파일 },
{ label: tx("Block"), value: o.블록 },
{ label: tx("Rows"), value: o.행 },
{ label: tx("When"), value: o.때 },
{ label: tx("Marks"), value: tx("MarksN", o.표지.length) },
]),
marks,
el("strong", { text: tx("Clauses") }),
...clauses,
createText(tx("Hint"), "muted", 12),
],
});
card.setAttribute("data-sangeun-origin", "");
return card;
}
export function createSangeunSide(): {
wrap: (main: HTMLElement) => HTMLElement;
set: (tabs: SourceTab[]) => void;
/** 원문 탭 · 가져온 식 머리(`가져온` — 있으면 첫 화면 = 카드) */
set: (tabs: SourceTab[], imported?: unknown) => void;
} {
const view = createSourceView({
path: "/m02/logic-lib/source",
pdf: "/m02/logic-lib/source-pdf",
});
const host = el("div", { attrs: { "data-sangeun-source": "" } });
const content = el("div");
let found: string | null = null; // 찾기로 고른 기초 출처(지우면 처음 화면)
const find = createSourceFind({
onPick: (x) => ((found = x.출처 ?? ""), paint()),
onClear: () => ((found = null), paint()),
});
const host = el("div", {
className: "m01lab-src",
attrs: { "data-sangeun-source": "" },
children: [find.root, content],
});
let tabs: SourceTab[] = [];
let origin: Origin | null = null;
let at = 0;
const side = createSidePanels({
prefKey: "m02-sangeun-side",
@@ -54,8 +129,13 @@ export function createSangeunSide(): {
});
function paint(): void {
if (side.current() !== "source") return; // 접힘 = 찾지 않음
if (found !== null) {
view.show(found);
return void content.replaceChildren(view.root);
}
if (origin) return void content.replaceChildren(originCard(origin));
if (!tabs.length)
return void host.replaceChildren(createText(L("M02_SangeunSourceNone"), "muted"));
return void content.replaceChildren(createText(L("M02_SangeunSourceNone"), "muted"));
at = Math.min(at, tabs.length - 1);
view.show(tabs[at].src);
const bar =
@@ -70,13 +150,14 @@ export function createSangeunSide(): {
}).root,
]
: [];
host.replaceChildren(...bar, view.root);
content.replaceChildren(...bar, view.root);
}
return {
wrap: (main) => el("div", { className: "ui-side-panels-row", children: [main, side.root] }),
set: (next) => {
if (JSON.stringify(next) === JSON.stringify(tabs)) return;
tabs = next;
set: (next, imported) => {
const card = originOf(imported as OriginMeta | null);
if (JSON.stringify([next, card]) === JSON.stringify([tabs, origin])) return;
[tabs, origin] = [next, card];
paint();
},
};
@@ -19,6 +19,7 @@ from pathlib import Path
from typing import Any
from common_util.common_util_json import atomic_write_json
from common_util.common_util_time import utc_now_text
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_San_Emit as em
@@ -171,6 +172,7 @@ def make(source: Path, key: str, no: int, name: str = "", _trail: tuple = ()) ->
from M02_MasterTemplete import M02_Sangeun_Imported as imp
out = emit(source, key, no)
out["원본"]["가져온때"] = utc_now_text() # 원문 패널 「어디서 왔나」(807-1)
_link_inner(source, key, no, out, _trail)
try: # 소요량 · 계수 후보 표지(797-2 · 사람 확인) — 못 셈 = 표지 없이 만듦
from M02_MasterTemplete import M02_Sangeun_QtyLinks as qty
@@ -0,0 +1,92 @@
/* =============================================================================
* M02_Sangeun_Origin.ts
* 가져온 식 단가산출 「어디서 왔나」 카드 자료(PLAN 807-1 · 요구 699) — 원문 패널 첫 화면.
* SK `가져온.원본`(파일 · 시트 · 행 · 갈래 EST|STmate · 코드 · 표지 · 가져온때 · 글줄)에서 보이기 자료만 뽑음 ·
* 품셈 절 글 = 엑셀 글줄에 적힌 「…품셈 N-N-N」 글(책 · 절 번호 · 그 줄 글) — 찾기 · 잇기 없음(보이기만)
* 화면 · DOM · fetch 없음
* ========================================================================== */
export interface OriginMeta {
로직?: unknown;
원본?: {
파일?: string;
시트?: string;
행?: number[];
갈래?: string;
코드?: string | number;
표지?: string[];
가져온때?: string;
글줄?: { 행?: number; 글?: string }[];
};
}
export interface Clause {
책: string;
절: string;
글: string;
행?: number;
}
export interface Origin {
프로그램: string;
파일: string;
블록: string;
행: string;
때: string;
표지: string[];
품셈: Clause[];
}
const BOOKS: [RegExp, string][] = [
[/건설/, "건설표준품셈"],
[/산림/, "산림표준품셈"],
[/임도/, "임도품셈"],
];
/** 「건설공사표준품셈8-1-3」 · 「산림사업표준품셈 10-4」 · 「[2025년 표준품셈3-4-8]」 · 「임도품셈 5-1-2」 */
const CLAUSE =
/((?:건설(?:공사)?|산림(?:사업)?|임도)?\s*(?:표준\s*)?품셈)\s*[[(]?\s*(?:제\s*)?(\d{1,2}(?:-\d{1,3}){1,3})/g;
/** 글줄 → 품셈 절(책 · 절 같은 것은 처음 하나) */
export function clausesOf(lines: { 행?: number; 글?: string }[] = []): Clause[] {
const out: Clause[] = [];
const seen = new Set<string>();
for (const line of lines) {
const text = String(line.글 ?? "")
.replace(/\s+/g, " ")
.trim();
for (const m of text.matchAll(CLAUSE)) {
const book = BOOKS.find(([re]) => re.test(m[1]))?.[1] ?? "표준품셈";
const key = `${book} ${m[2]}`;
if (seen.has(key)) continue;
seen.add(key);
out.push({ 책: book, 절: m[2], 글: text, ...(line.행 !== undefined ? { 행: line.행 } : {}) });
}
}
return out;
}
const pad = (n: number): string => String(n).padStart(2, "0");
/** 서버 때(UTC 글) → 이 컴퓨터 시각 「YYYY-MM-DD HH:mm」 · 못 읽음 = 빈 글 */
const localTime = (s?: string): string => {
const at = new Date(s ?? "");
if (!s || Number.isNaN(at.getTime())) return "";
return `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())} ${pad(at.getHours())}:${pad(at.getMinutes())}`;
};
/** 가져온 식이 아니면 null */
export function originOf(meta: OriginMeta | null | undefined): Origin | null {
if (!meta?.로직 || !meta.원본) return null;
const src = meta.원본;
const code = src.코드 ?? "";
const block = src.갈래 === "EST" && code !== "" ? `단산${code}` : String(code);
const rows = (src.행 ?? []).filter((x) => Number.isFinite(x));
return {
프로그램: src.갈래 ?? "",
파일: src.파일 ?? "",
블록: block,
행: rows.length ? `${src.시트 ? `${src.시트} ` : ""}${rows.join("~")}행` : (src.시트 ?? ""),
때: localTime(src.가져온때),
표지: [...(src.표지 ?? [])],
품셈: clausesOf(src.글줄),
};
}