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

- 충돌 시험 셋(717-4 · 719-2 · hopyo build) = 740-1 펼침 단언 + 742-1 저장 수량 단언 합침
- 740-2 일위대가표 풍선 줄의 body(742-1 이 걷은 휘발 셈 몸) → cur

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-02 21:47:51 +09:00
co-authored by Claude Opus 5.5
66 changed files with 2663 additions and 244 deletions
+12 -3
View File
@@ -377,13 +377,22 @@ def run_job(folder: Path, key: str) -> dict[str, Any]:
if unit not in out["블록"]:
out["블록"][unit] = compute(block, line, targets)
out["셈"] = [at, len(todo)]
if at % EVERY == 0:
atomic_write_json(path, out)
if at % EVERY == 0 and not _put(folder, key, path, out):
return out
out["끝"] = True
atomic_write_json(path, out)
_put(folder, key, path, out)
return out
def _put(folder: Path, key: str, path: Path, out: dict[str, Any]) -> bool:
"""캐시 씀 — 셈 도중 HP 를 끝냈으면(743-1) 안 쓰고 지움 → 거짓."""
if not _hp_path(folder, key).is_file():
path.unlink(missing_ok=True)
return False
atomic_write_json(path, out)
return True
def _work() -> None:
while True:
folder, key = _JOBS.get()
+205
View File
@@ -0,0 +1,205 @@
"""M02 일위대가 불러오기 끝냄(PLAN 743-1 · 요구 582-3 · 584-2) — 올린 엑셀은 그 불러오기 동안만.
끝냄 한 길 `finish` = [끝내기] · 화면 떠남 · 하루 지남(`Store.EXPIRE`) · 붙여넣기 만듦 뒤 ·
엑셀 보기 [삭제]:
HP · 격자 · 맞춤셈 캐시 지움 · 그 HP 로 만든 이 층 일위대가(SC) · 단가산출(SK) 엑셀 사본 걷음
(`본뜬` → 출처 `{층 「실무자료」, 파일, 때, 누가}` + 전국자재 · 복사원본 ·
SC 줄 메모 「실무 …」 걷음 · `본뜬.실무수량` = 모든 값이 그 줄 저장 `수량.손` 과 같을 때만
걷음(742-1 · 다르면 남김 = 금액 그대로)) · SK `본뜬.또` = 그 HP 몫만 뺌 ·
문서 몸(줄 · 덮개 · 더한줄 · 별칭 · 구분)과 별칭 장부는 그대로 ·
끝낸 기록 없음(같은 엑셀 다시 올림 됨 · 키 번은 `불러옴.json` 번으로 다시 안 씀).
옛 파일 정리 `tidy`(한 번 · 층 폴더마다 · 미리 보기 = 표만) — 옛 HP(지문 없음) · 하루 지난 HP 끝냄 ·
끝낸 기록 지문 비움 · HP 가 없는 SC · SK 엑셀 사본 걷음.
`python -m M02_MasterTemplete.M02_Hopyo_Forget <층 폴더>… [--apply]`(없으면 미리 보기).
"""
from __future__ import annotations
import json
import re
import sys
from collections.abc import Callable
from datetime import datetime
from pathlib import Path
from typing import Any
from common_util.common_util_json import atomic_write_json
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
from M02_MasterTemplete import M02_Labor_Ref as refs
ORIGIN = "실무자료"
KEEP = ("층", "파일", "전국자재", "복사원본")
_MEMO = re.compile(r"^실무 ")
_HP = re.compile(r"^(HP-\d{6}) ")
# ── 사본 걷음(순수) ───────────────────────────────────────────────────
def _slim(got: dict[str, Any]) -> dict[str, Any]:
"""출처만 — 파일 · 때 · 누가(+ 전국자재 · 복사원본)."""
sure = got.get("확인") if isinstance(got.get("확인"), dict) else {}
out = {k: got[k] for k in KEEP if got.get(k) is not None}
return {**out, "층": ORIGIN, "때": sure.get("때"), "누가": sure.get("누가")}
def _hand(one: Any) -> Any:
qty = one.get("수량") if isinstance(one, dict) else None
return qty.get("손") if isinstance(qty, dict) else None
def _carried(doc: dict[str, Any], hands: dict[str, Any]) -> bool:
"""실무수량 값이 모두 그 줄 · 자재 저장 수량과 같음(742-1 저장 뒤)."""
lines = doc.get("담은로직") or []
mats = {m.get("코드"): m for m in doc.get("자재") or [] if isinstance(m, dict)}
for at, value in (hands.get("줄") or {}).items():
i = int(at) if str(at).isdigit() else -1
if not 0 <= i < len(lines) or _hand(lines[i]) != value:
return False
return all(_hand(mats.get(code)) == value for code, value in (hands.get("자재") or {}).items())
def strip_sc(doc: dict[str, Any]) -> dict[str, Any]:
"""일위대가(SC) 엑셀 사본 걷음 — 본뜬 출처만 · 줄 메모 「실무 …」 뺌."""
got = doc.get("본뜬") or {}
lines = [
{k: v for k, v in one.items() if k != "메모"}
if isinstance(one, dict) and _MEMO.match(str(one.get("메모") or ""))
else one
for one in doc.get("담은로직") or []
]
out = {**doc, "담은로직": lines, "본뜬": _slim(got)}
hands = got.get("실무수량")
if isinstance(hands, dict) and hands and not _carried(out, hands):
out["본뜬"]["실무수량"] = hands # 742 전 저장본 — 셈이 기대므로 남김
return out
def strip_sk(doc: dict[str, Any], gone: Callable[[dict], bool]) -> dict[str, Any]:
"""단가산출(SK) — 끝낸 출처(`gone(본뜬 · 또 한 칸)`) 몫만 걷음 · 다른 불러오기 몫은 그대로."""
got = doc.get("본뜬") or {}
also = [x for x in got.get("또") or [] if isinstance(x, dict) and not gone(x)]
own = _slim(got) if gone(got) else {k: v for k, v in got.items() if k != "또"}
return {**doc, "본뜬": {**own, "또": also} if also else own}
# ── 끝냄 ─────────────────────────────────────────────────────────────
def _docs(folder: Path, kind: str):
prefix = "SC" if kind == "precombo" else "SK"
for path in sorted((folder / kind).glob(f"{prefix}-*.json")):
try:
doc = json.loads(path.read_bytes())
except (OSError, ValueError):
continue
got = doc.get("본뜬") if isinstance(doc, dict) else None
if isinstance(got, dict) and got.get("층") == ORIGIN:
yield path, doc
def _here(got: dict[str, Any], here: refs.Here) -> bool:
"""출처 원본이 이 층(원본 없는 옛 것 = 이 층)."""
return not got.get("원본") or tuple(got["원본"]) == tuple(here)
def forget(folder: str | Path, gone: Callable[[str], bool], apply: bool = True) -> list[str]:
"""`gone(본뜬 키)` 인 이 층 SC · SK 엑셀 사본 걷음 → 걷은 문서 키(`apply` 거짓 = 셈만)."""
folder, out = Path(folder), []
here = refs.where(folder)
def hit(one: dict[str, Any]) -> bool:
mark = str(one.get("키") or "")
return bool(mark) and _here(one, here) and gone(mark)
for kind in ("precombo", "sangeun"):
for path, doc in _docs(folder, kind):
got = doc["본뜬"]
if not any(hit(x) for x in [got, *(got.get("또") or [])] if isinstance(x, dict)):
continue
new = strip_sc(doc) if kind == "precombo" else strip_sk(doc, hit)
if new != doc:
out.append(path.stem)
if apply:
atomic_write_json(path, new)
return out
def _caches(folder: Path, key: str) -> list[Path]:
from M02_MasterTemplete import M02_Hopyo_Fit as fit
try:
got = fit.cache_path(folder, key)
except Exception: # noqa: BLE001 — 캐시 자리 못 찾음 = 지울 것 없음
return []
return list(got.parent.glob(f"{'_'.join(got.name.split('_')[:2])}_*.json"))
def finish(folder: str | Path, key: str) -> dict[str, Any]:
"""불러오기 끝냄 — 만든 문서 사본 걷음 · HP · 격자 · 맞춤셈 캐시 지움 → `{지움, 만든}`."""
folder = Path(folder)
hopyo_store.read(folder, key) # 없으면 404
head = f"{key} "
made = forget(folder, lambda mark: mark.startswith(head))
caches = _caches(folder, key) # HP 지우기 전에 자리 셈(HP 지문)
hopyo_store.delete(folder, key) # 먼저 지움 — 뒤 셈은 HP 없으면 캐시 안 씀(`Fit._put`)
# ponytail: 뒤 셈이 HP 확인 직후 · 쓰기 직전이면 한 벌 남을 수 있음(ms 틈) — tmp 캐시라 둠
for old in caches:
old.unlink(missing_ok=True)
return {"지움": key, "만든": len(made)}
def expired(folder: str | Path, now: datetime | None = None) -> list[str]:
"""하루 지난 작업 중 HP 키(`M02_Hopyo_Store.stale`)."""
out = []
for path in sorted((Path(folder) / hopyo_store.DIRNAME).glob("HP-*.json")):
doc = hopyo_store._load(path)
if isinstance(doc, dict) and doc.get("지문") and hopyo_store.stale(doc, now):
out.append(path.stem)
return out
def sweep(folder: str | Path, now: datetime | None = None) -> list[str]:
"""하루 지난 HP 끝냄 → 끝낸 키."""
for key in (got := expired(folder, now)):
finish(folder, key)
return got
# ── 옛 파일 정리(한 번) ───────────────────────────────────────────────
def tidy(folder: str | Path, apply: bool = False) -> list[dict[str, str]]:
"""옛 실무자료 정리 표 `[{할일, 대상}]` — `apply` 거짓 = 미리 보기(쓰기 없음)."""
folder = Path(folder)
home = folder / hopyo_store.DIRNAME
rows: list[dict[str, str]] = []
every = sorted(p.stem for p in home.glob("HP-*.json"))
old = [k for k in every if not (hopyo_store._load(home / f"{k}.json") or {}).get("지문")]
ended = [*old, *expired(folder)]
live = set(every) - set(ended)
for key in ended:
rows.append({"할일": "HP 끝냄(옛 · 하루 지남)", "대상": f"{hopyo_store.DIRNAME}/{key}"})
if apply:
finish(folder, key)
log = hopyo_store.done(folder)
if log["지문"]:
rows.append({"할일": f"끝낸 기록 지문 {len(log['지문'])} 비움", "대상": "불러옴.json"})
if apply:
hopyo_store._put_done(folder, {"번": log["번"]})
def gone(mark: str) -> bool:
m = _HP.match(mark)
return bool(m) and m.group(1) not in live
for key in forget(folder, gone, apply):
rows.append({"할일": "엑셀 사본 걷음(출처만)", "대상": key})
return rows
def main(argv: list[str]) -> int:
apply = "--apply" in argv
for one in [a for a in argv if a != "--apply"]:
rows = tidy(one, apply)
print(f"# {one} — {'고침' if apply else '미리 보기'} {len(rows)}")
for row in rows:
print(f"{row['할일']}\t{row['대상']}")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+6 -1
View File
@@ -309,7 +309,12 @@ def _grouped(got: dict, line: dict, ctx: Ctx) -> dict:
return {**c, "뜻겹침": hm.near(mine, one["words"]), "출처같음": same, "책": book}
named = [mark(c) for c in hm.name_cands(line, ctx.got)]
return {**got, "후보": [mark(c) for c in got.get("후보") or []], "이름후보": named}
cands = [mark(c) for c in got.get("후보") or []]
# 745-1 미리 고름 점 — 「다른 점」 1위 + 출처 같음 · 누르기는 사용자
top = cands[0] if cands else {}
if top.get("판정") == "다른 점" and top.get("출처같음"):
top["미리"] = True
return {**got, "후보": cands, "이름후보": named}
def _ledger_off(entry: dict | None, alias: dict) -> bool:
+6 -2
View File
@@ -24,6 +24,7 @@ from typing import Any
from M02_MasterTemplete import M02_Hopyo_Build as hb
from M02_MasterTemplete import M02_Hopyo_Excel as hx
from M02_MasterTemplete import M02_Hopyo_Fixed as hfix
from M02_MasterTemplete import M02_Hopyo_Forget as forget
from M02_MasterTemplete import M02_Hopyo_Store as hstore
from M02_MasterTemplete import M02_MasterTemplete_Store as store
@@ -296,7 +297,8 @@ def paste(
folder: Any, text: str, cols: Any, who: Any, lines_only: bool = False, taken: Any = ()
) -> dict[str, Any]:
"""[Ctrl+V] — `갈래` 「물음」(열 짝 짐작 + 보기 · 안 씀) · 「줄」(호표 머리 없음 · `lines_only`) ·
「만듦」(호표 머리 있음 = 실무자료 「붙여넣기 …」 + 호표마다 사전조합 · 705-3 `build_all` 그대로)."""
「만듦」(호표 머리 있음 = 실무자료 「붙여넣기 …」 + 호표마다 사전조합 · 705-3 `build_all` 그대로
· 만든 뒤 실무자료 끝냄 743-1)."""
try:
parsed = read_text(text, cols)
except PasteError as error:
@@ -310,4 +312,6 @@ def paste(
return {**head, "갈래": "줄", **fixed_lines(parsed, taken)}
keep = {k: parsed[k] for k in ("꼴", "시트", "호표", "단산", "검산")}
row = hstore.save(folder, f"{SHEET} {datetime.now():%m-%d %H:%M}", keep, who)
return {**head, "갈래": "만듦", "키": row["키"], **hb.build_all(folder, folder, row["키"], who)}
made = hb.build_all(folder, folder, row["키"], who)
forget.finish(folder, row["키"]) # 743-1 — 붙인 글 실무자료는 만든 뒤 지움(엑셀 사본 걷음)
return {**head, "갈래": "만듦", "키": row["키"], **made}
+20 -19
View File
@@ -4,9 +4,9 @@
파일 = 읽은 결과만(원본 엑셀 안 둠) `{키, 파일, 올린때, 올린이, 시트, 호표, 검산}` · 키 `HP-######`
(층마다 다음 번호) · 맞춤 기억 = `{줄: {줄 열쇠: {로직?|자재?|빼기?, 입력값, 환산, 확정}}}`.
층 종류(`layers.KINDS`) 밖 폴더 — 양식 목록 · 복사 · 프로젝트 씨앗에 안 섞임 · 권한은 길 몫.
한시 불러오기(718-1) — 파일에 `지문`(올린 엑셀 sha256) · 목록 = 지문 있는 것(작업 중)만 · 옛 HP 는
파일 그대로 목록에서만 숨김 · 끝낸 기록 `실무자료/불러옴.json`
`{번, 지문 {지문: {파일, 키, 끝낸때, 만든}}}` · 번 = 낸 키 가장 큰 번(지운 뒤에도 키를 다시 안 씀).
한시 불러오기(718-1 · 743-1) — 파일에 `지문`(올린 엑셀 sha256) · 목록 = 지문 있고 하루 안 지난 것
(작업 중)만 · 옛 HP · 하루 지난 HP 는 목록에서 숨김(지움 = `M02_Hopyo_Forget`) ·
`실무자료/불러옴.json` `{번}` = 낸 키 가장 큰 번(지운 뒤에도 키를 다시 안 씀 · 옛 `지문` 안 읽음).
"""
from __future__ import annotations
@@ -15,6 +15,7 @@ import hashlib
import json
import re
import threading
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
@@ -27,6 +28,7 @@ MEMORY = "맞춤"
GRID = "격자"
DONE = "불러옴"
NO_GRID = "격자 없음 — 다시 올리면 보임"
EXPIRE = timedelta(days=1) # 작업 중 HP 보관 — 올린 뒤 하루
_KEY = re.compile(r"^HP-(\d{6})$")
# ponytail: 프로세스 안 잠금 — 서버를 여럿 띄우면 파일 잠금으로
_LOCK = threading.Lock()
@@ -68,18 +70,27 @@ def fingerprint(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def stale(doc: dict[str, Any], now: datetime | None = None) -> bool:
"""하루 지난 HP — 올린때 기준 · 못 읽는 때 = 지난 것."""
try:
when = datetime.fromisoformat(str(doc.get("올린때") or "").replace("Z", "+00:00"))
except ValueError:
return True
return (now or datetime.now(timezone.utc)) - when > EXPIRE
def rows(folder: str | Path) -> list[dict[str, Any]]:
"""층 실무자료 목록(키 차례) — 작업 중(지문 있음)만 · 옛 HP 는 숨김."""
"""층 실무자료 목록(키 차례) — 작업 중(지문 있음 · 하루 안)만 · 옛 HP · 지난 HP 는 숨김."""
out = []
for path in sorted(_dir(folder).glob("HP-*.json")):
doc = _load(path)
if isinstance(doc, dict) and _KEY.match(path.stem) and doc.get("지문"):
if isinstance(doc, dict) and _KEY.match(path.stem) and doc.get("지문") and not stale(doc):
out.append({**brief(doc), "지문": doc["지문"]})
return out
def done(folder: str | Path) -> dict[str, Any]:
"""끝낸 기록 `{번, 지문 {지문: {파일, 키, 끝낸때, 만든}}}`."""
"""키 번 기록 `{번, 지문}`(지문 = 743 전 끝낸 기록 · 안 읽음 · 정리 `Forget.tidy`)."""
doc = _load(_dir(folder) / f"{DONE}.json")
doc = doc if isinstance(doc, dict) else {}
marks = doc.get("지문")
@@ -137,22 +148,12 @@ def grid(folder: str | Path, key: str) -> dict[str, Any]:
return doc
def delete(folder: str | Path, key: str, made: list[str] | None = None) -> None:
"""HP · 격자 지움 · 만든 것(`made` SC 키)이 있으면 지문을 끝낸 기록에 남김(다시 올림 막음)."""
def delete(folder: str | Path, key: str) -> None:
"""HP · 격자 지움(끝낸 기록 없음 · 같은 엑셀 다시 올림 됨 · 743-1)."""
path = _path(folder, key)
with _LOCK:
doc = _load(path)
if not isinstance(doc, dict):
if not isinstance(_load(path), dict):
raise store.StoreError(404, f"없는 실무자료 「{key}」")
if made and doc.get("지문"):
log = done(folder)
log["지문"][doc["지문"]] = {
"파일": doc.get("파일"),
"키": key,
"끝낸때": utc_now_text(),
"만든": made,
}
_put_done(folder, log)
path.unlink()
_grid_path(folder, key).unlink(missing_ok=True)
+62 -4
View File
@@ -50,7 +50,7 @@ def freeze(copies: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]:
from M02_MasterTemplete import M02_Sangeun_Calc as calc
sks = sorted(rid for rid, doc in copies.items() if is_sangeun(doc))
if not sks:
if not sks and not any(_patched(doc) for doc in copies.values()):
return copies
whole = m01.loaded()[1]
@@ -86,19 +86,77 @@ def freeze(copies: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]:
if got["켜쌓기율"] is not None:
frozen["켜쌓기율"] = prices.to_json(got["켜쌓기율"])
out[rid] = frozen
for rid in sorted(rid for rid, doc in copies.items() if _patched(doc)):
out[rid] = _freeze_lines(rid, copies[rid], whole, read, taken)
return out
def _patched(doc: Any) -> list[int]:
"""일위대가(SC) 사본의 덮개 줄(로직 줄 + `패치` · 740-1 SK 넣기 꼴) 차례."""
if not isinstance(doc, dict) or doc.get("종류") != refs.TITLE["precombo"]:
return []
lines = doc.get("담은로직") or []
return [
n for n, x in enumerate(lines) if isinstance(x, dict) and x.get("로직") and x.get("패치")
]
def _freeze_lines(rid: str, doc: dict, whole: Any, read: Any, taken: set[str]) -> dict:
"""SC 사본 덮개 줄마다 고친 줄을 풀어 굳힘(740-1 · B09 대가 · 사본 셈이 덮개를 읽게) —
줄은 그대로 두고 덧칸 `굳힌 {키, 줄, 입력값, 켜쌓기율, 고름, 자재표시, 정체}`(고름 ·
자재표시 = 굳힌 줄 차례) · 못 푼 줄 = 그대로(사본 셈이 옛 길)."""
from M02_MasterTemplete import M02_Item_Prices as prices
from M02_MasterTemplete import M02_Labor_Patch as patch
from M02_MasterTemplete import M02_Sangeun_Calc as calc
from M02_MasterTemplete.M02_Labor_Expand import Expand
lines = list(doc.get("담은로직") or [])
for n in _patched(doc):
one = lines[n]
try:
doc_ = sd.clean(patch.as_doc(one, whole), None, whole)
got = calc.resolve(doc_, whole, read, (rid,), line=True) # 일위대가 기본 별도계상
row = {**got["줄"], "호표": [dict(x) for x in got["줄"]["호표"]]}
calc._bundles(row, whole, Expand(whole, read), (rid,)) # 고정 더한줄 = 값묶음
except sd.DocError:
continue
key = frozen_key(f"{rid}#{n}", taken)
fz = {"키": key, "줄": prices.to_json({**row, "키": key}), "정체": list(got["정체"])}
fz |= {
"입력값": prices.to_json(got["입력값"]),
"고름": got["고름"],
"자재표시": got["자재표시"],
}
if got["켜쌓기율"] is not None:
fz["켜쌓기율"] = prices.to_json(got["켜쌓기율"])
lines[n] = {**one, FROZEN: fz}
return {**doc, "담은로직": lines}
FROZEN = "굳힌"
def rows(copies: dict[str, Any]) -> list[dict[str, Any]]:
"""굳힌 SK 줄 — 사본 `단가.json` `FILE` 에 넣음(이름표 차례)."""
return [doc[ROW] for _, doc in sorted(copies.items()) if is_sangeun(doc) and ROW in doc]
"""굳힌 SK 줄 + SC 덮개 줄 굳힌 줄(740-1) — 사본 `단가.json` `FILE` 에 넣음(이름표 차례)."""
out = [doc[ROW] for _, doc in sorted(copies.items()) if is_sangeun(doc) and ROW in doc]
for _, doc in sorted(copies.items()):
out += [
x[FROZEN]["줄"]
for x in doc.get("담은로직") or []
if isinstance(x, dict) and x.get(FROZEN)
]
return out
def materials(copies: dict[str, Any]) -> list[str]:
"""굳힌 SK 고른 자재 키 — 단가 묶음에 담음."""
return [
out = [
str(mt)
for doc in copies.values()
if is_sangeun(doc)
for mt in (doc.get("고름") or {}).values()
]
for doc in copies.values():
lines = [x for x in doc.get("담은로직") or [] if isinstance(x, dict) and x.get(FROZEN)]
out += [str(mt) for x in lines for mt in (x[FROZEN].get("고름") or {}).values()]
return out
+7 -2
View File
@@ -109,8 +109,13 @@ class Expand:
"""사전조합 안 로직 줄 — 일위대가 로직 줄과 같은 `run_line` · 배는 물량 알약 것."""
from M02_MasterTemplete import M02_Labor_Doc as labor
pill = pill or {}
if one.get(patch.FIELD): # 줄 덮개(715-3) = 이름 없는 단가산출 · 별도계상까지
pill, fz = pill or {}, one.get("굳힌")
if isinstance(fz, dict) and fz.get("키"): # 사본 굳힌 덮개 줄(740-1 · `Item_Sangeun`)
got = labor.run_line(
self.whole, fz["키"], fz.get("입력값"), fz.get("켜쌓기율"), fz.get("고름") or None
)
got = labor.drop_apart(got, fz.get("자재표시"))
elif one.get(patch.FIELD): # 줄 덮개(715-3) = 이름 없는 단가산출 · 별도계상까지
got = patch.run(self.whole, one, self.read)
else:
got = labor.run_line(
+7 -4
View File
@@ -15,7 +15,8 @@ from typing import Any
from M02_MasterTemplete import M02_Sangeun_Doc as sd
FIELD = "패치"
KEEP = ("덮개", "뺀줄", "더한줄")
CALC = ("덮개", "뺀줄", "더한줄") # 셈에 드는 칸(`as_doc` · 화면 짝 꼴)
KEEP = (*CALC, "뺀더한줄") # 뺀더한줄 = [되살림] 기억(744-1)
TEMP = "SK-000000" # 이름 없는 줄 단가산출 — 저장 안 함
@@ -41,7 +42,9 @@ def clean(one: dict, where: str, whole: Any, row: dict | None, opened: list | No
)
except sd.DocError as e:
raise _bad(f"{where} · {e}") from e
out = {k: got[k] for k in KEEP if got[k]}
out = {k: got[k] for k in KEEP if got.get(k)}
for add in out.get("더한줄") or []: # 처음수량(744-1) = 넣을 때 수량 · 없으면 지금 수량
add.setdefault("처음수량", add["수량"])
return {FIELD: out} if out else {}
@@ -71,7 +74,7 @@ def _keyed(value: Any, ids: list[str]) -> dict:
def as_doc(one: dict, whole: Any, name: str = "줄", unit: str = "식") -> dict[str, Any]:
"""일위대가 줄 → SK 문서(바탕 = 그 로직 · 그 SK) — 고름 · 자재표시는 cid 로."""
body = {k: (one.get(FIELD) or {}).get(k) or ([] if k != "덮개" else {}) for k in KEEP}
body = {k: (one.get(FIELD) or {}).get(k) or ([] if k != "덮개" else {}) for k in CALC}
head = {"키": TEMP, "이름": name, "단위": unit, **body}
if "참조" in one:
return {**head, "바탕": {"참조": one["참조"]}}
@@ -97,7 +100,7 @@ def run(whole: Any, one: dict, read: Any = None) -> dict[str, Any]:
try:
doc = sd.clean(as_doc(one, whole), None, whole)
return calc.run(doc, whole, None, read or refs.read_ref)
return calc.run(doc, whole, None, read or refs.read_ref, line=True)
except sd.DocError as e:
return {"기준": None, "까닭": str(e)}
+60 -4
View File
@@ -42,6 +42,7 @@ _OWNER = {"company": re.compile(r"^\d+$"), "personal": re.compile(r"^\d+/\d+$")}
Ref = dict[str, Any]
Here = tuple[str, str | None]
COPY = "사본" # 740-1 하위 호표 사본 블록 — 참조 꼴 {층: 사본, 주인: 몸 지문, 키: 본 SC, 몸}
def _bad(text: str, status: int = 400) -> store.StoreError:
@@ -103,7 +104,28 @@ def ref_path(ref: Ref) -> Path:
return folder_of(ref["층"], ref.get("주인")) / kind_of(ref["키"]) / f"{ref['키']}.json"
def is_copy(ref: Any) -> bool:
"""사본 블록(740-1) — 일위대가 안 일위대가 몸을 줄 안에 담음(원본 연결 없음 · 키 = 출처)."""
return isinstance(ref, dict) and ref.get("층") == COPY and isinstance(ref.get("몸"), dict)
def copy_ref(body: dict[str, Any], key: str) -> Ref:
"""사본 블록 참조 — 몸(종류 · 이름 · 단위 · 담은로직 · 자재 · 본뜬) · 주인 = 몸 지문."""
import hashlib
keep = {k: body[k] for k in ("종류", "이름", "단위", "담은로직", "자재", "본뜬") if body.get(k)}
raw = json.dumps(keep, ensure_ascii=False, sort_keys=True, default=str)
return {
"층": COPY,
"주인": hashlib.sha256(raw.encode()).hexdigest()[:12],
"키": key,
"몸": keep,
}
def read_ref(ref: Ref) -> dict[str, Any] | None:
if is_copy(ref):
return ref["몸"]
try:
got = json.loads(ref_path(ref).read_bytes())
except (OSError, ValueError):
@@ -114,6 +136,8 @@ def read_ref(ref: Ref) -> dict[str, Any] | None:
def readable(at: Here, ref: Ref) -> bool:
"""`at` 층이 `ref` 를 가리켜도 되나 — 위 층만(프로젝트는 아무것도 · 굳힌 사본만 씀)."""
layer, owner = at
if ref.get("층") == COPY: # 사본 블록 = 줄 안 몸(어느 층도 읽음)
return True
if layer == "project":
return False
if ref["층"] == "system":
@@ -127,6 +151,8 @@ def check_ref(raw: Any, at: str, here_: Here) -> Ref:
"""참조 꼴 · 위 층만 · 있는 문서 — 아니면 400."""
if not isinstance(raw, dict):
raise _bad(f"{at} · 참조는 {{층, 주인, 키}}")
if raw.get("층") == COPY:
return _check_copy(raw, at)
layer, owner, key = raw.get("층"), raw.get("주인"), str(raw.get("키") or "")
if layer == "system":
owner = None
@@ -142,6 +168,20 @@ def check_ref(raw: Any, at: str, here_: Here) -> Ref:
return ref
def _check_copy(raw: Ref, at: str) -> Ref:
"""사본 블록 검사 — 키 = SC · 몸 줄 · 자재 = 일위대가 검사 그대로 · 지문 다시 셈."""
from M02_MasterTemplete import M02_Labor_Doc as labor
body, key = raw.get("몸"), str(raw.get("키") or "")
if not isinstance(body, dict) or not _KEY.match(key) or kind_of(key) != "precombo":
raise _bad(f"{at} · 사본 블록은 {{층: 사본, 키: SC-…, 몸}}")
try:
labor.clean({"담은로직": body.get("담은로직") or [], "자재": body.get("자재") or []})
except store.StoreError as e:
raise _bad(f"{at} · 사본 블록 · {e.detail}") from e
return copy_ref(body, key)
# ── 서고 문서 정리 ─────────────────────────────────────────────────────
def check_key(kind: str, name: str) -> None:
found = _KEY.match(name)
@@ -155,6 +195,10 @@ def clean_doc(kind: str, doc: Any) -> dict[str, Any]:
raise _bad("서고 문서가 묶음 아님")
key = str(doc.get("키") or "")
check_key(kind, key)
if here()[0] != "project": # 740-1 — M02 참조 줄 = M01 키 줄로 펼침(못 펼친 줄은 그대로)
from M02_MasterTemplete import M02_Library_Flat as flat
doc = flat.flat_doc(kind, doc, me={"층": here()[0], "주인": here()[1], "키": key})
name, unit = str(doc.get("이름") or "").strip(), str(doc.get("단위") or "").strip()
if kind == "sangeun":
from M02_MasterTemplete import M02_Sangeun_Calc as calc
@@ -245,13 +289,14 @@ def _combo_lines(doc: dict, key: str) -> list[dict[str, Any]]:
일위대가와 같은 길(`labor.clean` · 같은 로직 · 같은 참조 두 번 · 입력값) · 수량 기본 {손: 1}."""
from M02_MasterTemplete import M02_Labor_Doc as labor
lines = []
for one in labor.clean({"담은로직": doc.get("담은로직") or []})["담은로직"]:
lines, raw = [], doc.get("담은로직") or []
for one, sent in zip(labor.clean({"담은로직": raw})["담은로직"], raw):
head = {k: one[k] for k in ("참조", "고정", "패치") if k in one} # 패치 = 줄 덮개(715-3)
if "로직" in one:
head.update(로직=one["로직"], 입력값=one["입력값"])
head.update({k: one[k] for k in ("켜쌓기율", "고름", "자재표시") if k in one})
head.update({"별칭": one["별칭"]} if one["별칭"] else {}) # 실무 품명(324-3)
head.update(_mark(sent)) # 넣은 줄 출처(740-1 · 셈에 안 씀)
lines.append({**head, "수량": one["수량"], "메모": one["메모"]})
at_ = here()
me = {"층": at_[0], "주인": at_[1], "키": key}
@@ -269,13 +314,20 @@ def _combo_mats(doc: dict, lines: list[dict[str, Any]]) -> list[dict[str, Any]]:
로직 자재와 같은 품목 400(`labor.no_twice`)."""
from M02_MasterTemplete import M02_Labor_Doc as labor
mats = labor.clean({"자재": doc.get("자재") or []})["자재"]
raw = doc.get("자재") or []
mats = [{**m, **_mark(s)} for m, s in zip(labor.clean({"자재": raw})["자재"], raw)]
if mats:
got = {"담은로직": lines, "자재": mats}
labor.no_twice(got, labor.calc(got, {}))
return mats
def _mark(sent: Any) -> dict[str, Any]:
"""줄 `본뜬`(740-1 · 펼쳐 넣은 출처 {층, 주인, 키, 이름, 때}) — 묶음만 · 셈에 안 씀."""
got = sent.get("본뜬") if isinstance(sent, dict) else None
return {"본뜬": got} if isinstance(got, dict) else {}
def _no_loop(ref: Ref, trail: list[Ref]) -> None:
"""저장 때 순환 · 깊이 — 아래로 따라가 처음 문서를 다시 만나면 422 「A → B → A」."""
if len(trail) > DEPTH:
@@ -412,8 +464,12 @@ def _reused(kind: str, doc: dict[str, Any], ref: Ref) -> str | None:
def carry(doc: dict[str, Any], source: Here) -> dict[str, Any]:
"""복사(원본 층 `source` → 지금 층) — 지금 층이 못 읽는 참조는 그 문서를 지금 층에 새 키로
같이 복사(`본뜬` = 원래 참조) · 참조를 새 키로 바꿈 · 같은 원본 같은 내용 사본이 이미 있으면
그 키(733-2). 원본 층 읽기 권한은 부르는 쪽 몫."""
그 키(733-2). 원본 층 읽기 권한은 부르는 쪽 몫 · 단가산출 · 일위대가 = 먼저 펼침(740-1)."""
target, done = here(), {}
if doc.get("종류") in (TITLE["sangeun"], TITLE["precombo"]):
from M02_MasterTemplete import M02_Library_Flat as flat
doc = flat.flat_doc("sangeun" if doc["종류"] == TITLE["sangeun"] else "precombo", doc)
def move(ref: Ref, depth: int) -> Ref:
if readable(target, ref):
+7 -32
View File
@@ -8,8 +8,8 @@
일위대가 실무자료(324 · `M02_Hopyo_*` · 올리기 · 지우기 = 층 쓰기 · 목록 · 읽기 = 층 읽기) ·
338 = 확인한 줄만 만들기(몸 없음 400 · 옛 즉시 확정 길 없앰) · 별칭 장부 보기 · 고치기 ·
올린 뒤 맞춤 뒤 셈(`M02_Hopyo_Fit.ensure`) · 시동 때 우리 지문 데움(`M02_Hopyo_Ours.warm`) ·
718-1 한시 불러오기 = 올림 지문(끝낸 같은 엑셀 409 · 작업 중 같은 엑셀 = 그 파일 `이어서`) ·
지우기 = [끝내기](HP · 격자 · 맞춤셈 캐시 지움 · 만든 일위대가가 있으면 지문 기록 · 별칭 장부 남음).
718-1 한시 불러오기 = 올림 지문(작업 중 같은 엑셀 = 그 파일 `이어서` · 끝낸 것 = 새로 · 743-1) ·
지우기 = [끝내기](`M02_Hopyo_Forget.finish` · 올림 때 하루 지난 HP 도 끝냄).
층 권한 = `Router_Items._root`(`M02_Layer_Access` 한 벌).
⚠ 등록은 `main.py`(로그인만).
"""
@@ -27,6 +27,7 @@ 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
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_Ledger as hopyo_ledger
@@ -229,14 +230,7 @@ def _who(session: Session) -> dict[str, Any]:
def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]:
mark = hopyo_store.fingerprint(data)
if old := hopyo_store.done(store.root())["지문"].get(mark):
made = len(old.get("만든") or [])
when = str(old.get("끝낸때") or "")[:10]
raise store.StoreError(
409,
f"이미 불러온 엑셀 「{old.get('파일')}」({when} · 만든 일위대가 · 단가산출 {made})"
" — 다시 만들지 않음",
)
hopyo_forget.sweep(store.root()) # 하루 지난 작업 중 HP 끝냄(743-1)
if same := next((r for r in hopyo_store.rows(store.root()) if r.get("지문") == mark), None):
return {**same, "이어서": True}
try:
@@ -502,29 +496,10 @@ def hopyo_ledger_write(
def hopyo_delete(
layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session)
) -> dict:
"""실무자료 지우기 = 불러오기 [끝내기](718-1) — HP · 격자 · 맞춤셈 캐시 지움 · 만든 일위대가
(이 층 확인된 본뜬 「키 제N호표」)는 그대로 · 있으면 지문 기록(같은 엑셀 다시 올림 막음) ·
별칭 장부 그대로 → `{지움, 만든}`."""
"""실무자료 지우기 = [끝내기](718-1 · 743-1 · 화면 떠남도) — HP · 격자 · 맞춤셈 캐시 지움 ·
만든 일위대가 · 단가산출은 엑셀 사본만 걷고 남음 · 별칭 장부 그대로 → `{지움, 만든}`."""
folder = _root(session, layer, owner, write=True)
return _call(folder, _finish, folder, key)
def _finish(folder: Path, key: str) -> dict[str, Any]:
head = f"{key} 제"
made = sorted(
{sc for mark, sc in hopyo_build.confirmed(folder).items() if mark.startswith(head)}
)
made += sorted(hopyo_sangeun.made(folder, key).values()) # 단가산출(SK)
if (cache_path := _later("M02_Hopyo_Fit", "cache_path")) is not None:
try:
got = cache_path(folder, key)
except (OSError, store.StoreError):
got = None
# ponytail: 뒤 셈이 도는 중이면 끝낸 뒤 캐시를 다시 쓸 수 있음 — tmp 캐시라 둠
for old in got.parent.glob(f"{'_'.join(got.name.split('_')[:2])}_*.json") if got else []:
old.unlink(missing_ok=True)
hopyo_store.delete(folder, key, made)
return {"지움": key, "만든": len(made)}
return _call(folder, hopyo_forget.finish, folder, key)
class SangeunBody(_Strict):
+573
View File
@@ -0,0 +1,573 @@
"""M02 단가산출(SK) · 일위대가(SC) 펼침 · 초기화 · 옮기기(PLAN 740-1 · 요구 581 · 분석 579-2).
서로 독립 — 연결 고리 = M01 키만. M02 문서를 넣으면(찾아 넣기 `-flat` · 저장 · 읽기 · 층 사이
복사) 그 문서 몸을 M01 키 줄 + 그 문서 덮개 사본으로 펼쳐 담음 → 원본이 나중에 바뀌어도
넣은 쪽 금액 그대로. 줄 `본뜬 {층, 주인, 키, 이름, 때}` = 출처 정보(셈에 안 씀).
SK 에 SK 더한줄 로직 한 줄 = 원본 몸 통째(바탕 기초 · 입력값 · 덮개 · 뺀줄 · 고름 · 자재표시 ·
켜쌓기율 · 더한줄 — `M02_Sangeun_Doc.BODY` · % 덧줄까지 같은 금액) ·
수량 = 넣은 수 ÷ 배(728 환산) · 바탕 없는 SK = 그 더한줄 × 넣은 수
SK 에 SC SC 줄마다 더한줄(로직 줄 = 로직 한 줄 + 패치 몸 · 고정 · 자재 = 재료) ·
수량 = 넣은 수 × 줄 수량(저장 `수량.손` › 실무수량 › 1 · 742-1) ÷ 배(물량 알약 배)
SC 에 SK 로직 줄 하나 + 패치(= SK 몸) · 별칭 = SK 이름 · 같은 자리(안 줄 알약 키 그대로) ·
바탕 없는 SK · 단위 환산 SK = 못 넣음(값 단위가 달라짐)
SC 에 SC 사본 블록 — 안쪽 SC 몸 통째(M01 키 줄 + 덮개 · 실무수량)를 그 줄 참조 꼴
`{층: 사본, 주인: 몸 지문, 키: 본 SC, 몸}` 에 담음(`M02_Labor_Ref.copy_ref` · 원본
연결 없음 · 하위 호표 끝수 단계 · B09 하위 대가 블록 그대로 · 같은 기초 겹침도
블록 안)
바탕 SK 참조 · 패치 있는 SK 참조 줄 = 위 덮개를 아래 덮개에 겹쳐 한 벌(`compose`)
못 펼친 줄 = 저장 · 읽기 · 옮기기에선 옛 참조 그대로(값 그대로) · 넣기 길(`strict`)에선 400 까닭.
초기화(`reset`) = 덮개 · 뺀줄 · 더한줄 단가 걷음(안쪽 넣은 몸 · 사본 블록까지) · 이음 기계 고정 줄
단가 = M01 기계 한 시간 값 다시 받음(736-3) · 항목 · 수량 · 입력값 · 고름 · 자재표시 · 켜쌓기율 ·
기본정보 남김 → 값 = 연결된 M01 키 값.
옮기기(`migrate`) = 층 문서 펼쳐 다시 씀 — 전후 1 단위 금액 같을 때만(안 줄 수량 모두 1 로 대조) ·
공유 storage 는 브레인이 돌림.
"""
from __future__ import annotations
import json
import re
import time
from decimal import Decimal
from typing import Any
from M02_MasterTemplete import M02_Labor_Fixed as fixed
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_Sangeun_Doc as sd
ADD = re.compile(r"^add:#(\d+)$")
FAIL = (ArithmeticError, KeyError, TypeError, ValueError) # 못 펼침(DocError · FormulaError 포함)
EMPTY = (None, {}, [], "")
Read = Any
def _whole() -> Any:
from M01_MasterData import M01_MasterData_Store as m01
return m01.loaded()[1]
def _d(value: Any) -> Decimal:
return Decimal(str(value))
def mark(ref: refs.Ref, doc: dict) -> dict[str, Any]:
"""줄 출처 — 펼친 원본 {층, 주인, 키, 이름, 때(UTC)}."""
when = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
return {
**{k: ref.get(k) for k in ("층", "주인", "키")},
"이름": doc.get("이름") or "",
"때": when,
}
def _read(ref: refs.Ref, read: Read, trail: tuple) -> tuple[dict, tuple]:
rid = refs.ref_id(ref)
if rid in trail:
raise sd.DocError("순환 — " + " → ".join(t.rsplit("|", 1)[-1] for t in (*trail, rid)), 422)
if len(trail) >= refs.DEPTH:
raise sd.DocError(f"참조가 {refs.DEPTH}단을 넘음", 422)
got = read(ref)
if not isinstance(got, dict):
raise sd.DocError(f"없는 참조 「{ref.get('키')}」")
return got, (*trail, rid)
# ── SK ────────────────────────────────────────────────────────────────
def flat_sk(doc: dict, read: Read, whole: Any, trail: tuple = (), strict: bool = True) -> dict:
"""SK 문서 → M01 키만(더한줄 참조 펼침 · 바탕 참조 겹침) · `strict` False = 못 펼친 줄
그대로."""
adds = []
for one in doc.get("더한줄") or []:
ref = one.get("참조") if isinstance(one, dict) else None
if not isinstance(ref, dict):
adds.append(one)
continue
try:
adds += into_sk(ref, one.get("수량", 1), read, whole, trail)
except FAIL as e:
if strict:
raise sd.DocError(f"「{ref.get('키')}」 · {e}") from e
adds.append(one)
out = {**doc, "더한줄": adds}
base = doc.get("바탕") or {}
if isinstance(base.get("참조"), dict):
try:
under, path = _read(base["참조"], read, trail)
under = flat_sk(sd.clean(under, None, whole), read, whole, path)
out = compose(under, out, whole)
except FAIL:
if strict:
raise
return out
def into_sk(ref: refs.Ref, qty: Any, read: Read, whole: Any, trail: tuple = ()) -> list[dict]:
"""SK 에 넣을 더한줄 — 넣는 문서(SK · SC)를 M01 키 줄로(`qty` = 그 문서 단위 수)."""
src, path = _read(ref, read, trail)
kind, q, tag = refs.kind_of(ref.get("키")), _d(qty), mark(ref, src)
src = sd.clean(src, None, whole) if kind == "sangeun" else src
if kind == "sangeun":
return sk_adds(flat_sk(src, read, whole, path), q, whole, tag)
if kind == "precombo":
return sc_adds(flat_sc(src, read, whole, path), q, whole, tag)
raise sd.DocError("옛 단가승격은 펼치지 않음")
def filled_adds(doc: dict, whole: Any) -> list[dict]:
"""더한줄 로직 빈 입력 = 그 SK 입력값(셈 `_fill` 의 공통 옵션 몫) — 옮겨도 같은 옵션."""
common, out = doc.get("입력값") or {}, []
for one in doc.get("더한줄") or []:
if one.get("종류") == "로직":
try:
names = [str(s.get("이름")) for s in whole.logic(one["로직"]).get("입력") or []]
except Exception: # noqa: BLE001 — 없는 로직 = 검사 · 셈이 까닭
names = []
mine = dict(one.get("입력값") or {})
for n in names:
if mine.get(n) in EMPTY and common.get(n) not in EMPTY:
mine[n] = common[n]
one = {**one, "입력값": mine}
out.append(one)
return out
def _scaled(one: dict, q: Decimal, tag: dict) -> dict:
return {**one, "수량": sd.plain(_d(one.get("수량") or 0) * q), "본뜬": tag}
def sk_adds(y: dict, q: Decimal, whole: Any, tag: dict) -> list[dict]:
"""펼친 SK → 더한줄 — 바탕 기초 있으면 한 줄(몸 통째) · 없으면 그 더한줄 × q."""
from M02_MasterTemplete import M02_Sangeun_Calc as calc
adds = filled_adds(y, whole)
code = (y.get("바탕") or {}).get("로직")
if not code:
return [_scaled(one, q, tag) for one in adds]
row = whole.logic(code)
names = {str(s.get("이름")) for s in row.get("입력") or []}
unit = calc.base_unit(row)
entry: dict[str, Any] = {
"종류": "로직",
"로직": code,
"이름": y.get("이름") or code,
"단위": unit[0] if unit else "",
"수량": sd.plain(q / calc.ratio_of(y, row, whole)),
"입력값": {k: v for k, v in (y.get("입력값") or {}).items() if k in names},
}
entry |= {
k: y[k] for k in ("덮개", "뺀줄", "고름", "자재표시", "켜쌓기율") if y.get(k) not in EMPTY
}
return [{**entry, **({"더한줄": adds} if adds else {}), "본뜬": tag}]
def _hand(one: dict, kept: Any) -> Decimal:
"""SC 안 줄 수량 — 저장 `수량.손`(742-1 사용자 입력) › 실무수량 › 1."""
raw = one.get("수량")
got = raw.get("손") if isinstance(raw, dict) else None
return _d(got if got not in EMPTY else kept if kept not in EMPTY else 1)
def sc_adds(z: dict, q: Decimal, whole: Any, tag: dict) -> list[dict]:
"""펼친 SC → SK 더한줄 — 줄 수량 = q × `_hand` · 로직 줄 ÷ 물량 알약 배."""
from M02_MasterTemplete import M02_Labor_Patch as patch
from M02_MasterTemplete import M02_Sangeun_Calc as calc
hands = (z.get("본뜬") or {}).get("실무수량") or {}
lines, mats = hands.get("줄") or {}, hands.get("자재") or {}
out = []
for at, one in enumerate(z.get("담은로직") or []):
n = q * _hand(one, lines.get(str(at)))
if one.get("로직"):
row = whole.logic(one["로직"])
body = patch.as_doc(one, whole)
entry = {
"종류": "로직",
"로직": one["로직"],
"이름": one.get("별칭") or row.get("이름") or "",
}
entry |= {"수량": sd.plain(n / calc.times_of(row)), "입력값": body["입력값"]}
keep = ("덮개", "뺀줄", "고름", "자재표시", "켜쌓기율", "더한줄")
entry |= {k: body[k] for k in keep if body.get(k) not in EMPTY}
elif fixed.is_fixed(one) and not refs.is_rate(one):
entry = {
"고정": one["고정"],
"이름": one["고정"].get("이름") or "",
"수량": sd.plain(n),
}
elif refs.is_copy(one.get("참조")): # 하위 호표 사본 블록 = 그 줄들도 더한줄로
out += sc_adds(one["참조"]["몸"], n, whole, tag)
continue
else:
raise sd.DocError("비율 고정 줄 · 참조 줄은 단가산출에 못 담음")
out.append({**entry, "본뜬": tag})
for mat in z.get("자재") or []:
code = str(mat.get("코드") or "")
if not code.startswith("MT"):
raise sd.DocError(f"개인 자재 「{code}」 는 단가산출에 못 담음")
n = q * _hand(mat, mats.get(code))
one = {
"종류": "재료",
"요소": code,
"이름": mat.get("이름") or "",
"단위": mat.get("단위") or "",
}
apart = {"별도계상": True} if mat.get("별도계상") == "별도계상" else {}
out.append({**one, "수량": sd.plain(n), **apart, "본뜬": tag})
return out
# ── 겹침(바탕 SK · 패치 SK 참조) ─────────────────────────────────────────
def _merge(a: dict | None, b: dict) -> dict:
"""아래 덮개 a 위에 위 덮개 b — 품 qty 바꿈 · 배율 곱 · 단가 위 것."""
if not a:
return dict(b)
out = {k: v for x in (a, b) for k, v in x.items() if k in sd.PARTS}
if "qty" in b:
out["qty"] = b["qty"]
elif "배율" in b and "qty" in a:
out["qty"] = sd.plain(_d(a["qty"]) * _d(b["배율"]))
elif "배율" in b:
out["배율"] = sd.plain(_d(a.get("배율", 1)) * _d(b["배율"]))
else:
out |= {k: a[k] for k in ("qty", "배율") if k in a}
return out | ({"원값": a["원값"]} if "원값" in a else {})
def _cover_add(add: dict, cover: dict) -> dict:
"""위 덮개를 아래 더한줄 항목에 — 수량 · 그 비목 단가만(그 밖 = 못 겹침)."""
out = dict(add)
if "qty" in cover:
out["수량"] = cover["qty"]
elif "배율" in cover:
out["수량"] = sd.plain(_d(add.get("수량") or 0) * _d(cover["배율"]))
prices = {sd.PARTS[k]: cover[k] for k in sd.PARTS if k in cover}
if prices and (add.get("종류") not in sd.ADD_PART or set(prices) != {add.get("비목")}):
raise sd.DocError("더한줄 단가 덮개를 못 겹침")
if prices:
out["단가"] = prices[add["비목"]]
return out
def compose(under: dict, over: dict, whole: Any) -> dict:
"""펼친 바탕 SK `under` 위에 `over`(덮개 · 뺀줄 · 고름 · 자재표시 · 더한줄 · 입력값) → 한 벌 —
위 줄 정체를 아래 바탕 정체로 옮김(`patched` 의 `정체` 짝) · 짝 잃은 위 고침은 버림(값 없음)."""
base = under.get("바탕")
row = whole.logic(base["로직"]) if (base or {}).get("로직") else sd.empty_row(under)
got = sd.patched(row, under)
where = dict(zip(sd.line_ids(got["줄"]), got["정체"]))
covers = {c: dict(v) for c, v in (under.get("덮개") or {}).items()}
drop, picks = list(under.get("뺀줄") or []), dict(under.get("고름") or {})
marks, adds, gone = (
dict(under.get("자재표시") or {}),
[dict(x) for x in under.get("더한줄") or []],
set(),
)
def to(cid: str) -> tuple[str, Any] | None:
o = where.get(cid)
hit = ADD.match(o) if o else None
return None if o is None else ("add", int(hit[1])) if hit else ("base", o)
for cid, cover in (over.get("덮개") or {}).items():
if t := to(cid):
if t[0] == "base":
covers[t[1]] = _merge(covers.get(t[1]), cover)
else:
adds[t[1]] = _cover_add(adds[t[1]], cover)
for cid in over.get("뺀줄") or []:
if (t := to(cid)) and t[0] == "add":
gone.add(t[1])
elif t and t[1] not in drop:
drop.append(t[1])
for name, mine, into in (
("고름", over.get("고름"), picks),
("자재표시", over.get("자재표시"), marks),
):
for cid, value in (mine or {}).items():
if (t := to(cid)) and t[0] == "add":
raise sd.DocError(f"더한줄 {name}를 못 겹침")
if t:
into[t[1]] = value
rate = over.get("켜쌓기율", under.get("켜쌓기율"))
out = {**over, "바탕": base, "덮개": covers, "뺀줄": drop, "고름": picks, "자재표시": marks}
out["더한줄"] = [a for k, a in enumerate(adds) if k not in gone] + list(
over.get("더한줄") or []
)
out["입력값"] = {**(under.get("입력값") or {}), **(over.get("입력값") or {})}
return out | ({"켜쌓기율": rate} if rate is not None else {})
# ── SC ────────────────────────────────────────────────────────────────
def sk_line(y: dict, one: dict, whole: Any, tag: dict) -> dict:
"""펼친 SK → 일위대가 로직 줄 하나(+ 패치) — 옛 참조 줄 `one` 의 별칭 · 메모 · 수량 · 패치
이어받음."""
from M02_MasterTemplete import M02_Labor_Patch as patch
from M02_MasterTemplete import M02_Sangeun_Calc as calc
if one.get(patch.FIELD):
y = compose(y, {"입력값": {}, **one[patch.FIELD]}, whole)
code = (y.get("바탕") or {}).get("로직")
if not code:
raise sd.DocError("바탕 기초 없는 단가산출은 일위대가 줄로 못 담음 — 그 안 줄을 따로 넣음")
row = whole.logic(code)
if calc.ratio_of(y, row, whole) != calc.times_of(row):
raise sd.DocError("단위 환산 단가산출은 일위대가 줄로 못 담음(값 단위가 바뀜)")
ids = sd.line_ids(row)
names = {str(s.get("이름")) for s in row.get("입력") or []}
at = {c: str(n) for n, c in enumerate(ids)}
body = {k: y[k] for k in ("덮개", "뺀줄") if y.get(k) not in EMPTY}
body |= {"더한줄": adds} if (adds := filled_adds(y, whole)) else {}
line: dict[str, Any] = {
"로직": code,
"입력값": {k: v for k, v in (y.get("입력값") or {}).items() if k in names},
"별칭": one.get("별칭") or y.get("이름"),
"메모": one.get("메모"),
"수량": one.get("수량"),
}
for name in ("고름", "자재표시"):
if got := {at[c]: v for c, v in (y.get(name) or {}).items() if c in at}:
line[name] = got
if y.get("켜쌓기율") is not None:
line["켜쌓기율"] = y["켜쌓기율"]
return {**line, **({patch.FIELD: body} if body else {}), "본뜬": tag}
def _ids(doc: dict) -> list[Any]:
from M02_MasterTemplete import M02_Labor_Doc as labor
lines = [labor.line_id(x) for x in doc.get("담은로직") or [] if isinstance(x, dict)]
return lines + [f"자재|{m.get('코드')}" for m in doc.get("자재") or [] if isinstance(m, dict)]
def _twice(doc: dict) -> str | None:
seen: set[str] = set()
for one in _ids(doc):
key = json.dumps(one, ensure_ascii=False, sort_keys=True, default=str)
if key in seen:
return str(one)
seen.add(key)
return None
def flat_sc(doc: dict, read: Read, whole: Any, trail: tuple = (), strict: bool = True) -> dict:
"""SC 문서 → M01 키만 — SK 참조 = 로직 줄 + 패치(같은 자리) · SC 참조 = 사본 블록(같은 자리) ·
`strict` False = 못 펼친 줄 그대로(겹침 · 바탕 없음 · 환산 · 없는 참조)."""
lines = []
for at, one in enumerate(doc.get("담은로직") or []):
ref = one.get("참조") if isinstance(one, dict) else None
kind = refs.kind_of(ref.get("키")) if isinstance(ref, dict) else None
if kind not in ("sangeun", "precombo") or refs.is_copy(ref):
lines.append(one)
continue
try:
src, path = _read(ref, read, trail)
tag = mark(ref, src)
if kind == "sangeun":
y = flat_sk(sd.clean(src, None, whole), read, whole, path)
put = sk_line(y, one, whole, tag)
else:
body = flat_sc(src, read, whole, path)
put = {**one, "참조": refs.copy_ref(body, ref["키"]), "본뜬": tag}
rest = (doc.get("담은로직") or [])[at + 1 :]
trial = {"담은로직": [*lines, put, *rest], "자재": doc.get("자재") or []}
if twice := _twice(trial):
raise sd.DocError(f"같은 줄이 겹침 「{twice}」 — 펼쳐 넣지 못함")
except FAIL as e:
if strict:
raise sd.DocError(f"「{ref.get('키')}」 · {e}") from e
put = one
lines.append(put)
return {**doc, "담은로직": lines}
# ── 문 ────────────────────────────────────────────────────────────────
def flat_doc(
kind: str, doc: Any, read: Read = refs.read_ref, strict: bool = False, me: Any = None
) -> Any:
"""층 문서 하나 펼침(저장 · 읽기 · 복사 · 옮기기) — 참조 없으면 받은 그대로 · `me` = 이 문서
참조(자기 자신을 담은 줄 = 안 펼침 → 저장 검사가 422)."""
if not isinstance(doc, dict) or kind not in refs.PREFIX or not refs.refs_in(doc):
return doc
whole, trail = _whole(), (refs.ref_id(me),) if isinstance(me, dict) else ()
if kind == "sangeun":
return flat_sk(doc, read, whole, trail, strict)
return flat_sc(doc, read, whole, trail, strict)
def flat_lines(kind: str, ref: Any, qty: Any = 1, doc: Any = None) -> dict[str, Any]:
"""찾아 넣기(`-flat` 길) — `kind` 문서에 `ref` 를 넣을 줄 · 못 펼침 = StoreError 400 ·
SC = 로직 줄(SK) · 사본 블록 줄(SC) 하나 · 지금 화면 문서 `doc` 과 겹치면 400."""
from M02_MasterTemplete import M02_MasterTemplete_Store as store
at = refs.here()
try:
ref = refs.check_ref(ref, "넣을 문서", at)
whole = _whole()
if kind == "sangeun":
return {"더한줄": into_sk(ref, qty if qty not in EMPTY else 1, refs.read_ref, whole)}
got = flat_sc({"담은로직": [{"참조": ref, "수량": None}]}, refs.read_ref, whole)
host = doc if isinstance(doc, dict) else {}
trial = {"담은로직": [*(host.get("담은로직") or []), *got["담은로직"]]}
if twice := _twice({**trial, "자재": host.get("자재") or []}):
raise sd.DocError(f"이미 담은 줄과 겹침 「{twice}」")
return {"담은로직": got["담은로직"], "자재": []}
except FAIL as e:
raise store.StoreError(getattr(e, "status", 400), str(e)) from e
# ── 초기화 ────────────────────────────────────────────────────────────
def _reprice(fx: dict) -> tuple[dict, int]:
"""이음 기계 고정 줄(736-3) 단가 = M01 기계 한 시간 값 다시 받음 — (줄, 바뀐 수)."""
from M02_MasterTemplete import M02_Machine_Hourly as machine
key = (fx.get("이음") or {}).get("기계")
got = machine.hourly(str(key)) if key else {}
if not got.get("ok"):
return fx, 0
price = {k: got[k] for k in sd.PARTS.values()}
return {**fx, "단가": price}, int(price != fx.get("단가"))
def _reset_adds(adds: list) -> tuple[list, int]:
out, n = [], 0
for one in adds or []:
one = dict(one) if isinstance(one, dict) else one
if not isinstance(one, dict):
out.append(one)
continue
if "단가" in one:
one.pop("단가")
n += 1
n += len(one.pop("덮개", None) or {}) + len(one.pop("뺀줄", None) or [])
one.pop("바탕판", None)
if one.get("더한줄"):
one["더한줄"], k = _reset_adds(one["더한줄"])
n += k
if isinstance(one.get("고정"), dict):
one["고정"], k = _reprice(one["고정"])
n += k
out.append(one)
return out, n
def _reset_sc(doc: dict) -> tuple[dict, int]:
"""일위대가 초기화 — 줄 패치 · 사본 블록 몸 · 이음 기계 고정 줄."""
from M02_MasterTemplete import M02_Labor_Patch as patch
lines, n = [], 0
for one in doc.get("담은로직") or []:
if not isinstance(one, dict):
lines.append(one)
continue
ref, body = one.get("참조"), one.get(patch.FIELD)
if refs.is_copy(ref):
inner, k = _reset_sc(ref["몸"])
n += k
one = {**one, "참조": refs.copy_ref(inner, ref["키"])}
elif isinstance(one.get("고정"), dict):
fx, k = _reprice(one["고정"])
n += k
one = {**one, "고정": fx}
elif body:
adds, k = _reset_adds(body.get("더한줄") or [])
n += k + len(body.get("덮개") or {}) + len(body.get("뺀줄") or [])
keep = {x: v for x, v in body.items() if x not in ("덮개", "뺀줄", "더한줄")}
rest = {**keep, **({"더한줄": adds} if adds else {})}
one = {x: v for x, v in one.items() if x != patch.FIELD}
one = {**one, **({patch.FIELD: rest} if rest else {})}
lines.append(one)
return {**doc, "담은로직": lines}, n
def reset(kind: str, doc: dict) -> tuple[dict, int]:
"""[초기화] — (걷은 문서, 걷힌 고침 수) · 먼저 펼침(옛 참조 줄도 M01 키로)."""
doc = flat_doc(kind, doc)
if kind == "sangeun":
adds, n = _reset_adds(doc.get("더한줄") or [])
n += len(doc.get("덮개") or {}) + len(doc.get("뺀줄") or [])
return {**doc, "덮개": {}, "뺀줄": [], "더한줄": adds}, n
return _reset_sc(doc)
def reset_doc(kind: str, key: str | None, doc: Any, look: bool, version: str | None) -> dict:
"""`-reset` 길 — 미리 보기 = 걷힐 수 · 아니면 지금 층에 씀(판이 오면 그 판일 때만)."""
from M02_MasterTemplete import M02_MasterTemplete_Store as store
if doc is None:
if not key:
raise store.StoreError(400, "문서 또는 키")
found = store.read(kind, key)
doc, version = found["문서"], version or found["판"]
if not isinstance(doc, dict):
raise store.StoreError(400, "문서가 묶음 아님")
got, n = reset(kind, doc)
if look:
return {"걷힘": n, "문서": got}
name = str(got.get("키") or key or "")
if not version:
version = store.read(kind, name)["판"]
store.write(kind, name, version, got)
return {"걷힘": n, "문서": store.read(kind, name)["문서"]}
# ── 옮기기 ────────────────────────────────────────────────────────────
def _value(ref: refs.Ref, read: Read, whole: Any) -> Any:
from M02_MasterTemplete.M02_Labor_Expand import Expand, inner_pills
given = {p["키"]: 1 for p in inner_pills(ref, read)}
got = Expand(whole, read, given)(ref).get("기준")
return None if got is None else {k: round(float(v), 6) for k, v in got.items()}
def migrate(apply: bool = False) -> list[dict[str, Any]]:
"""저장소 층(마스터 · 회사 · 개인) SK · SC 의 M02 참조 줄을 펼쳐 다시 씀 — 줄마다 결과
{어디, 키, 펼침, 같음, 씀, 까닭} · `apply` False = 보기만."""
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Template_Layers as layers
whole, out = _whole(), []
for kind in ("sangeun", "precombo"):
for where_, path in layers.layer_files(kind):
try:
doc = json.loads(path.read_bytes())
except (OSError, ValueError):
continue
if not refs.refs_in(doc):
continue
layer, owner = refs.where(path.parent.parent)
ref = {"층": layer, "주인": owner, "키": path.stem}
got = flat_doc(kind, doc)
left = [
r.get("키")
for r in refs.refs_in(got)
if refs.kind_of(r.get("키")) != "promote" and not refs.is_copy(r)
]
row = {
"어디": where_,
"키": path.stem,
"펼침": left == [],
"남은참조": left,
"씀": False,
}
def mine(r: refs.Ref, _got: dict = got, _rid: str = refs.ref_id(ref)) -> dict | None:
return _got if refs.ref_id(r) == _rid else refs.read_ref(r)
row["전"], row["후"] = _value(ref, refs.read_ref, whole), _value(ref, mine, whole)
row["같음"] = row["전"] == row["후"]
if not row["같음"]:
row["까닭"] = "전후 금액 다름 — 안 옮김"
elif apply and got != doc:
with store.at(path.parent.parent):
store.write(kind, path.stem, store.read(kind, path.stem)["판"], got)
row["씀"] = True
out.append(row)
return out
if __name__ == "__main__": # 브레인 — `python -m M02_MasterTemplete.M02_Library_Flat [--apply]`
import sys
for one in migrate("--apply" in sys.argv):
print(json.dumps(one, ensure_ascii=False, default=str))
+5 -1
View File
@@ -24,7 +24,7 @@ from common_util.common_util_derived_store import derived_key
from M02_MasterTemplete import M02_Labor_Ref as refs
DROP = {"키", "이름", "구분", "상세구분", "본뜬", "메모", "별칭", "바탕판", "원값", "계산값", "열"}
DROP |= {"일위대가번", "종류", "수정일", "출처"}
DROP |= {"일위대가번", "종류", "수정일", "출처", "처음수량", "뺀더한줄"} # 744-1 되돌림 기억
UNIT = {"m3": "㎥", "m2": "㎡", "m³": "㎥", "m²": "㎡"}
EMPTY = (None, "", [], {})
@@ -80,6 +80,10 @@ class Same:
def finger(self, kind: str, doc: Any, trail: tuple = ()) -> str | None:
if not isinstance(doc, dict):
return None
if not trail: # 740-1 — 옛 참조 줄 = 펼친 꼴로 대조(펼쳐 담은 문서와 같은 지문)
from M02_MasterTemplete import M02_Library_Flat as flat
doc = flat.flat_doc(kind, doc)
body = self._sk(doc, trail) if kind == "sangeun" else self._sc(doc, trail)
if kind == "sangeun" and not body.get("바탕") and not body.get("더한줄"):
return None
@@ -11,6 +11,10 @@ POST …/undo {키} — `_합친것/` 에서 되돌림 · 옮긴 참조도 되
`M02_Library_Delete.undo`(문서 · 자리 · 고친 문서 앞판)
POST /layers/{층}/{kind}-tree/delete-check {대상} — 나무 지우기 미리 보기(735-1) · 지울 문서 ·
쓰는 줄
POST /layers/{층}/{kind}-flat {참조, 수량?, 문서?} — 넣을 줄(740-1 · M02 문서 → M01 키 줄 + 덮개
사본 · 본뜬 · `M02_Library_Flat.flat_lines`) · 층 읽기 권한 · 못 펼침 400
POST /layers/{층}/{kind}-reset {키 | 문서, 미리보기, 판?} — [초기화](740-1) · 걷힐 수 · 문서 ·
미리보기 false = 그 층에 씀(쓰기 권한)
지움 = 이 층 문서만 · 남김 = 이 층 또는 위 층(위 층과 같은 아래 층 문서 = 남김 = 위 층 키) ·
판 = 이 층 서고 나무 판(`{종류}_폴더.json`) · 권한 = 그 층 쓰기 · 프로젝트 사본 = 굳힌 값이라
안 건드림 · 지문 = `M02_Library_Same`(733-1) 읽기만.
@@ -31,6 +35,7 @@ from common_util.common_util_auth import verify_session
from common_util.common_util_json import atomic_write_json
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_Library_Delete as deleting
from M02_MasterTemplete import M02_Library_Flat as flat
from M02_MasterTemplete import M02_Library_Same as same
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Precombo_Folders as pre_folders
@@ -380,3 +385,51 @@ def library_same_undo(
) -> dict:
"""되돌림 — `_합친것/<키>` 문서 · 나무 자리 · 옮긴 참조."""
return _run(undo, session, _root(session, layer, owner, write=True), kind, body.키)
class FlatBody(BaseModel):
"""`참조` = 넣을 문서 {층, 주인, 키} · `수량` = 그 문서 단위 수(SK 만 · 기본 1) · `문서` = 지금
화면 문서(SC 겹침 검사)."""
참조: dict[str, Any]
수량: float | None = None
문서: dict[str, Any] | None = None
class ResetBody(BaseModel):
키: str | None = None
문서: dict[str, Any] | None = None
미리보기: bool = True
판: str | None = None
def _in(folder: Path, fn, *args):
with store.at(folder):
return fn(*args)
@router.post("/layers/{layer}/{kind}-flat")
def library_flat(
layer: Layer,
kind: Kind,
body: FlatBody,
owner: str | None = None,
session: Session = Depends(verify_session),
) -> dict:
"""찾아 넣기(740-1) — 넣을 줄(SK `더한줄` · SC `담은로직` + `자재`) · 저장 안 함."""
folder = _root(session, layer, owner)
return _run(_in, folder, flat.flat_lines, kind, body.참조, body.수량, body.문서)
@router.post("/layers/{layer}/{kind}-reset")
def library_reset(
layer: Layer,
kind: Kind,
body: ResetBody,
owner: str | None = None,
session: Session = Depends(verify_session),
) -> dict:
"""[초기화](740-1) — 덮개 · 뺀줄 · 더한줄 단가 걷음 → M01 키 값 · `{걷힘, 문서}`."""
folder = _root(session, layer, owner, write=not body.미리보기)
args = (kind, body.키, body.문서, body.미리보기, body.판)
return _run(_in, folder, flat.reset_doc, *args)
@@ -216,6 +216,17 @@ export const checkLibraryDelete = <T>(
method: "POST",
body: JSON.stringify({ 대상: picks }),
});
/** [초기화](740-2) — 미리 보기(`미리보기` true) = 걷힐 고침 수 · 아니면 그 층 문서에 씀(`판` 이 다르면 409) */
export const resetLibrary = (
kind: LibraryKind,
layer: Layer,
owner: string | null,
body: { 키: string; 미리보기: boolean; 판?: string },
): Promise<{ 걷힘: number; 문서: unknown }> =>
call(owned(layer, `/layers/${layer}/${kind}-reset`, owner), {
method: "POST",
body: JSON.stringify(body),
});
/** 복사해 둔 문서 · 폴더 → 이 층 `dest` 아래 새 키(폴더는 안의 것까지 · 같은 폴더 이름 「(1)」) */
export const pasteLibraryTree = (
kind: LibraryKind,
@@ -110,6 +110,8 @@ export interface HopyoTarget {
/** 묶음 표시(358-1 · 로직 후보만) — 품명 뜻 낱말 겹침 · 엑셀 출처 조항 같음(≥ 0.9) · 책(산림품셈 · 건설품셈) */
뜻겹침?: boolean;
출처같음?: boolean;
/** 745-1 미리 고름 점 — 「다른 점」 1위 + 출처 같음(서버) · 자동 확정 아님 · 만들기는 사용자가 누름 */
미리?: boolean;
책?: string;
}
@@ -469,8 +471,9 @@ export const saveLedger = async (
})
).줄;
export const deleteHopyo = (scope: HopyoScope, key: string): Promise<unknown> =>
call(at(scope, `/hopyo/${enc(key)}`), { method: "DELETE" });
/** [끝내기] · 화면 떠남(743-1) — `keepalive` = 페이지를 닫아도 요청은 감 */
export const deleteHopyo = (scope: HopyoScope, key: string, keepalive = false): Promise<unknown> =>
call(at(scope, `/hopyo/${enc(key)}`), { method: "DELETE", keepalive });
/** [확인하고 사전조합 만들기] — 확인한 줄만 · 직접 줄 호표 = `통째` → `target` 층 사전조합(파일 층 = `from`) */
export const buildHopyo = (
@@ -10,6 +10,7 @@
* 저장 안 함 — 고른 것은 부르는 쪽 캐시(확인 창)로만 감.
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { createButton, el } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import type {
@@ -30,6 +31,7 @@ const FOLD_TEXT: Record<string, string> = {
"지역 변형": "하나 고르면 그 지역 · 「전국」으로 되돌림은 위",
숫자: "이름 · 출처 안 닮음 · 숫자 지문만",
};
const PRE_TIP = "미리 고름 — 엑셀 인용 조항 = 1위 기초 조항 · 누르기는 사용자";
const note = (text: string): HTMLElement =>
el("span", { className: "ui-text ui-text--muted", text, attrs: { "data-text": "14" } });
const num = (v: number): string => String(Math.round(v * 10000) / 10000);
@@ -270,8 +272,12 @@ export function openHopyoChoose(opt: ChooseOptions): void {
const tag = basisTag(c.근거);
return el("div", {
className: `m02-hopyo__cand${weakCand(c) ? " is-weak" : ""}`,
attrs: { "data-cand": c.로직 ?? c.자재 ?? (c.전국 ? "전국" : "") },
attrs: {
"data-cand": c.로직 ?? c.자재 ?? (c.전국 ? "전국" : ""),
...(c.미리 ? { "data-pre": "" } : {}),
},
children: [
...(c.미리 ? [createBadge(PRE_TIP, "accent", { kind: "dot" })] : []),
box,
createButton({
label: candText(c, twins),
@@ -287,6 +293,7 @@ export function openHopyoChoose(opt: ChooseOptions): void {
}),
]
: []),
...(c.미리 ? [note("미리 고름")] : []),
...(c.글 ? [note(c.글)] : []),
...(weakCand(c) ? [note("이름 뜻 안 겹침")] : []),
...(tag && c.근거
@@ -1,7 +1,7 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_Side.ts
* 좌측 「일위대가 불러오기」 컨테이너(PLAN 324-4 · 718-1 한시) — [불러오기](구조물 조합 층 단추 꼴 `m02-layers__btn`) →
* 본 칸 마법사(`M02_MasterTemplete_Hopyo_Wizard` · 결과 층 고름 · 작업 중 이어서 · [끝내기] 때 올린 파일 지움) ·
* 본 칸 마법사(`M02_MasterTemplete_Hopyo_Wizard` · 결과 층 고름 · 작업 중 이어서 · [끝내기] · 화면 떠남 때 올린 파일 지움) ·
* 옛 파일 목록 없음(옛 HP 는 서버 목록에서 숨김) ·
* 아래 [별칭 장부](338-3) → 본 칸 장부 화면(`M02_MasterTemplete_Hopyo_Ledger` · 층 = 구조물 조합 층).
* ========================================================================== */
@@ -40,7 +40,8 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
onClick: () => void showView(WIZARD),
});
start.classList.add("m02-layers__btn");
start.title = "다른 프로그램 일위대가 엑셀 → 우리 일위대가(한시 · 끝내면 올린 파일 지움)";
start.title =
"다른 프로그램 일위대가 엑셀 → 우리 일위대가(한시 · 끝내거나 떠나면 올린 파일 지움)";
start.dataset.hopyoWizardOpen = "";
const ledger = el("button", {
className: "m02-side__item ui-sidebar-row",
@@ -52,7 +53,7 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
className: "m02-hopyo-side",
children: [el("div", { className: "m02-side__row", children: [start] }), ledger],
});
let open: { key: string; view: HTMLElement } | null = null;
let open: { key: string; view: HTMLElement; leave?: () => void } | null = null;
const paintList = (): void => {
ledger.classList.toggle("is-active", open?.key === LEDGER_TITLE);
@@ -61,18 +62,18 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
async function showView(key: string): Promise<void> {
if (open?.key === key && open.view.isConnected) return;
if (!(await opt.confirmLeave())) return;
const view =
key === WIZARD
? buildHopyoWizard()
: buildHopyoLedger({ scope: pageScope(), canWrite: opt.canWrite() });
open?.leave?.(); // 마법사 떠남 = 올린 파일 지움(743-1)
const wiz = key === WIZARD ? buildHopyoWizard() : null;
const view = wiz?.root ?? buildHopyoLedger({ scope: pageScope(), canWrite: opt.canWrite() });
opt.show(key === WIZARD ? title : `${title} — ${LEDGER_TITLE}`, view);
opt.onOpen();
open = { key, view };
open = { key, view, ...(wiz ? { leave: wiz.leave } : {}) };
paintList();
}
const paint = (): void => {
if (open && !open.view.isConnected) {
open.leave?.(); // 다른 화면으로 = 마법사 떠남
open = null;
paintList();
}
@@ -2,14 +2,15 @@
* M02_MasterTemplete_Hopyo_Wizard.ts
* 일위대가 불러오기 = 한시 마법사(PLAN 718-1 · 요구서 내역12 4장 22) — 본 칸 한 화면 · 걸음 셋:
* ① 결과 층(쓸 수 있는 층만 · 기본 개인) · 결과(일위대가 늘 · 단가산출 체크) · 엑셀 고르기 → 올림
* (그 층에 둠 · 같은 엑셀 작업 중 = 그 파일 이어서 · 끝낸 같은 엑셀 = 서버 409 안내) · 작업 중 파일 이어서
* (그 층에 둠 · 같은 엑셀 작업 중 = 그 파일 이어서 · 끝낸 같은 엑셀 = 새로 올림) · 작업 중 파일 이어서
* ② 보기 · 확인 · 만들기 = 엑셀 보기 창(`Hopyo_Sheet` — [모두 고정으로 들여오기] · 호표 [수정] →
* 확인 창 만들기 · [구조물 조합으로] 그대로) · 만들 층 = 고른 층 ·
* 단가산출 체크면 단가산출 칸(`Hopyo_WizardSk` — 기초 · SK · 고름 대기 기초 고름 · 729-3) ·
* 안 이은 줄 띠(`Hopyo_Left` — 안 만든 호표의 남은 줄 · [고르기] = 줄마다 이을 것 · 729-3)
* ③ [끝내기] = 남은 줄 다시 셈 → 있으면 막음(띠 · 남은 수 · 표) · 없으면 물음 → 서버가 이번 HP · 격자 · 맞춤셈 캐시 지움 ·
* 만든 일위대가 · 단가산출 · 별칭 장부 남음 · 지문 기록
* 작업 중 = 서버에 보관(떠났다 와도 ① 목록에서 이어서) · 옛 HP(지문 없음)는 서버 목록에서 숨김.
* 만든 일위대가 · 단가산출(엑셀 사본 걷고 출처만) · 별칭 장부 남음
* 작업 중 = 이 화면 동안만(743-1) — 화면 떠남(`leave` · 페이지 닫음) = 이 화면에서 올리거나 이은 파일 지움 ·
* 하루 지난 것은 서버가 지움 · 엑셀 자료는 보관하지 않음(필요하면 다시 올림).
* ========================================================================== */
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
@@ -61,7 +62,13 @@ export async function writableLayers(): Promise<ManageLayer[]> {
return LAYERS.map((x) => x.layer).filter((layer) => accessFor(layer, null, user).canWrite);
}
export function buildHopyoWizard(): HTMLElement {
export interface HopyoWizard {
root: HTMLElement;
/** 화면 떠남 — 이 화면에서 올리거나 이은 파일 지움(743-1) */
leave: () => void;
}
export function buildHopyoWizard(): HopyoWizard {
const steps = createSegment({
options: STEPS.map((s) => ({ ...s, disabled: true })),
value: "1",
@@ -73,7 +80,7 @@ export function buildHopyoWizard(): HTMLElement {
attrs: { "data-hopyo-wizard": "" },
children: [
createText(
"한시 — 다른 프로그램 일위대가 엑셀을 우리 일위대가로 옮김 · 끝내면 올린 파일 지움 · 별칭 장부는 남음",
"한시 — 다른 프로그램 일위대가 엑셀을 우리 일위대가로 옮김 · 끝내거나 화면을 떠나면 올린 파일 지움 · 별칭 장부는 남음",
"hint",
),
el("div", { className: "ui-toolbar", children: [steps.root] }),
@@ -84,6 +91,16 @@ export function buildHopyoWizard(): HTMLElement {
let pick: ManageLayer = "personal";
let wantSangeun = false; // 결과 「단가산출」 체크
const scopeOf = (layer: ManageLayer): HopyoScope => ({ layer, owner: null });
const held = new Map<string, HopyoScope>(); // 이 화면이 쥔 파일 「층|키」 → 층
const hold = (layer: ManageLayer, key: string): void =>
void held.set(`${layer}|${key}`, scopeOf(layer));
const drop = (layer: ManageLayer, key: string): void => void held.delete(`${layer}|${key}`);
const leave = (): void => {
window.removeEventListener("pagehide", leave);
for (const [id, scope] of held) void deleteHopyo(scope, id.split("|")[1], true).catch(() => {});
held.clear();
};
window.addEventListener("pagehide", leave);
async function first(): Promise<void> {
steps.set("1");
@@ -166,6 +183,7 @@ export function buildHopyoWizard(): HTMLElement {
function second(layer: ManageLayer, file: HopyoFile): void {
steps.set("2");
hold(layer, file.키);
const scope = scopeOf(layer);
const view = createButton({
label: "엑셀 보기 · 확인 · 만들기",
@@ -179,7 +197,10 @@ export function buildHopyoWizard(): HTMLElement {
파일: file.파일,
target: scope,
canWrite: true,
onDeleted: () => void first(),
onDeleted: () => {
drop(layer, file.키);
void first();
},
}),
)
.catch((error: unknown) => showToast(why(error), "error")),
@@ -265,13 +286,14 @@ export function buildHopyoWizard(): HTMLElement {
(x) => !x.one.호표.만든?.확인 && hasDraft(draftKey(scope, file.키, x.one.호표.번)),
).length;
const ok = await showConfirmDialog(
`「${file.파일}」 불러오기를 끝냄 — 올린 파일(엑셀 결과 · 격자 · 맞춤 캐시)을 지움 · 만든 일위대가 · 단가산출과 별칭 장부는 남음 · 하나라도 만들었으면 같은 엑셀은 다시 올리지 못함` +
`「${file.파일}」 불러오기를 끝냄 — 올린 파일(엑셀 결과 · 격자 · 맞춤 캐시)을 지움 · 만든 일위대가 · 단가산출(출처만 남김)과 별칭 장부는 남음 · 같은 엑셀은 필요하면 다시 올림` +
(loose ? ` · ${L("M02_LeftLoose").replace("{n}", String(loose))}` : ""),
"끝내기",
);
if (!ok) return steps.set("2");
try {
const got = (await deleteHopyo(scope, file.키)) as { 만든?: number };
drop(layer, file.키);
showToast(`끝냄 — 만든 일위대가 · 단가산출 ${got.만든 ?? 0} · 올린 파일 지움`, "success");
} catch (error) {
showToast(why(error), "error");
@@ -282,5 +304,5 @@ export function buildHopyoWizard(): HTMLElement {
}
void first();
return root;
return { root, leave };
}
@@ -35,6 +35,13 @@
white-space: nowrap;
}
/* 740-2 넣어 펼친 줄 출처 꼬리표 — 이름 글 끝에 이어 붙음(줄 높이 그대로 · 넘치면 말줄임) */
.m02-labrow__born {
color: var(--color-text-secondary);
font-size: var(--text-caption);
font-weight: var(--font-weight-regular, 400);
}
.m02-labrow__key,
.m02-labrow__note {
color: var(--color-text-secondary);
@@ -111,6 +111,8 @@ export interface LaborRow {
고정?: LaborFixed;
/** 그 줄 덮개(715-3 · 717-3 단가산출 모달) — 로직 줄 · SK 참조 줄 · 서버가 검사 */
패치?: LinePatch;
/** 넣어 펼친 줄 출처(740-1 · 740-2 꼬리표) — {층, 주인, 키, 이름, 때} · 셈에 안 씀 */
본뜬?: Record<string, unknown>;
}
/** 자재 세 칸(148-4 · 서버 `M02_Labor_Doc.MARKS` 와 같음) — 빈 칸 = 읽을 때 기본값 · 금액 무관 */
export const MAT_MARKS = {
@@ -127,6 +129,9 @@ export type NationalMat = MatMarks & {
단위: string;
수량: { 손: number };
};
/** 본뜬 곳 글 — 「층 키」 · 끝낸 실무자료 = 「실무자료 파일」 · 없으면 null */
export const originText = (o: LaborDoc["본뜬"]): string | null =>
o?.키 || o?.파일 ? `${o.층} ${o.키 ?? o.파일}` : null;
/** 읽을 때 기본값(162-1 · 서버 `MARK_DEFAULT` 와 같음) — 저장은 비움 */
export const MAT_MARK_DEFAULT: Required<MatMarks> = {
자재구분: "사급",
@@ -151,6 +156,8 @@ function cleanShown(v: unknown): { 자재표시?: Record<string, MatMarks> } {
return Object.keys(out).length ? { 자재표시: out } : {};
}
export interface LaborMat extends MatMarks {
/** 넣어 펼친 자재 출처(740-2) */
본뜬?: Record<string, unknown>;
코드: string;
이름: string | null;
규격: string | null;
@@ -163,7 +170,14 @@ export interface LaborDoc {
열: string;
일위대가번?: string;
단위: string | null;
본뜬?: { 층: string; 주인: string | null; 키: string; 전국자재?: NationalMat[] } | null;
/** 실무자료 끝낸 뒤(743-1) = 키 없이 `파일 · 때 · 누가` 출처만 */
본뜬?: {
층: string;
주인?: string | null;
키?: string;
파일?: string;
전국자재?: NationalMat[];
} | null;
담은로직: LaborRow[];
자재: LaborMat[];
계산값?: unknown;
@@ -192,6 +206,7 @@ export function normalizeLabor(doc: unknown, column: string): LaborDoc {
...(r.참조 || r.고정 ? {} : cleanShown(r.자재표시)),
...(r.고정 ? { 고정: r.고정 } : {}), // 705-1 — 읽을 때 떨구면 [저장]에서 줄이 사라짐
...(r.패치 ? { 패치: r.패치 } : {}), // 717-3 — 떨구면 [저장]에서 줄 덮개가 사라짐
...(r.본뜬 ? { 본뜬: r.본뜬 } : {}), // 740-2 — 출처 꼬리표
})),
자재: (d.자재 ?? []).map((m) => ({
코드: m.코드,
@@ -199,6 +214,7 @@ export function normalizeLabor(doc: unknown, column: string): LaborDoc {
규격: m.규격 ?? null,
단위: m.단위 ?? null,
수량: m.수량 ?? null,
...(m.본뜬 ? { 본뜬: m.본뜬 } : {}), // 740-2
...cleanMarks(m),
})),
};
@@ -0,0 +1,37 @@
/* =============================================================================
* M02_MasterTemplete_Labor_Born.ts
* 일위대가 상세 출처 꼬리표(740-2) — 넣어 펼친 줄 · 자재의 `본뜬` 을 짧은 글로(풍선 = 키 · 이름 · 층 · 때).
* 일위대가표 격자 = 비고 칸 글 `bornNote` + 풍선 `tipBorn`(칸 폭 · 줄 높이 안 바뀜)
* 줄 표(`Labor_Rows` · `Labor_MatTable`) = 이름 글 끝 `bornSpan`(같은 줄 · 넘치면 말줄임)
* 색 단추 아님 · 셈에 안 씀.
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { bornText } from "./logic/M02_Born_Tag";
const SPLIT = "\n"; // 글 첫 줄 = 보임 · 나머지 = 풍선
/** 일위대가표 격자 비고 칸 풍선 — `at` = 격자 줄 → 일위대가 줄 번호(담은 로직 뒤 자재) */
export function tipBorn(
root: HTMLElement,
at: (row: number) => number | null,
born: (n: number) => unknown,
): void {
root.querySelectorAll<HTMLElement>("tr[data-i]").forEach((tr) => {
const n = at(Number(tr.dataset.i));
const [, ...tip] = n === null ? [] : bornText(born(n)).split(SPLIT);
if (tip.length)
tr.querySelector<HTMLElement>("td:nth-last-child(2)")?.setAttribute("title", tip.join(" "));
});
}
/** 줄 표 이름 끝 꼬리표 — `본뜬` 이 없으면 null */
export function bornSpan(born: unknown): HTMLElement | null {
const [shown, ...tip] = bornText(born).split(SPLIT);
if (!shown) return null;
return el("span", {
className: "m02-labrow__born",
text: ` ${shown}`,
attrs: { title: tip.join(" "), "data-born": "" },
});
}
@@ -44,6 +44,7 @@ import {
type LaborQty,
type LaborRow,
type MatMarkKey,
originText,
} from "./M02_MasterTemplete_Labor_Api";
import {
buildLaborDiagram,
@@ -567,7 +568,7 @@ export function mountLaborDetail(
);
drawUnit();
const origin = createInputField({ type: "text", label: tc("Labor_Origin") });
origin.input.value = cur.본뜬?.키 ? `${cur.본뜬.층} ${cur.본뜬.키}` : tc("Origin_Self");
origin.input.value = originText(cur.본뜬) ?? tc("Origin_Self");
origin.input.readOnly = true;
const section = (title: string, button: HTMLElement | null, body: HTMLElement): HTMLElement =>
@@ -19,6 +19,7 @@ import {
} from "@ui/ui_template_hopyo_grid";
import type { ItemLogic } from "./M02_MasterTemplete_Api_Fetch";
import type { LaborCalc, LaborCost, LaborDoc } from "./M02_MasterTemplete_Labor_Api";
import { bornNote } from "./logic/M02_Born_Tag";
import { lineId } from "./M02_MasterTemplete_Labor_Calc";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import { valueLine } from "./M02_Labor_Values_Model";
@@ -79,7 +80,8 @@ export function laborBlock(
amounts: money(line?.반영),
note:
line?.까닭 ??
(calc && qty === null ? tc("Grid_NoQty") : row.참조 ? layerWord(row.참조.층) : row.로직),
(bornNote(row.본뜬) || // 740-2 넣은 줄 = 출처 글
(calc && qty === null ? tc("Grid_NoQty") : row.참조 ? layerWord(row.참조.층) : row.로직)),
tag: HOPYO_TAG.pum,
};
});
@@ -101,7 +103,7 @@ export function laborBlock(
? tc("Grid_NoQty")
: mat.별도계상 !== "직접계산"
? tc("Grid_Apart") // 짝 `settle` 의 aside 와 같은 규칙
: mat.코드),
: bornNote(mat.본뜬) || mat.코드),
};
});
const lines = [...logicLines, ...matLines];
@@ -13,6 +13,7 @@ import type {
LaborCalcMat,
LaborMat,
} from "./M02_MasterTemplete_Labor_Api";
import { bornSpan } from "./M02_MasterTemplete_Labor_Born";
import { markCells, markHeads } from "./M02_MasterTemplete_Labor_MatMarks";
import {
moneyCell,
@@ -66,7 +67,10 @@ export function matTable(data: MatTableData, on?: Omit<RowHandlers, "move">): HT
el("td", {
className: "m02-labrow__name",
children: [
el("strong", { text: mat.이름 ?? "" }),
el("strong", {
text: mat.이름 ?? "",
children: [bornSpan(mat.본뜬)].filter((x): x is HTMLElement => !!x),
}),
...(line?.까닭
? [el("small", { className: "ui-text ui-text--error", text: line.까닭 })]
: []),
@@ -24,6 +24,7 @@ import type {
LaborRow,
MatMarkKey,
} from "./M02_MasterTemplete_Labor_Api";
import { bornSpan } from "./M02_MasterTemplete_Labor_Born";
import { lineId } from "./M02_MasterTemplete_Labor_Calc";
import { attachRowDrag } from "./M02_MasterTemplete_Labor_RowDrag";
import { tc } from "./M02_MasterTemplete_Labor_Text";
@@ -414,6 +415,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
}),
el("strong", {
text: fx?.이름 ?? (row.참조 ? line?.이름 : logic?.이름) ?? row.참조?.키 ?? row.로직,
children: [bornSpan(row.본뜬)].filter((x): x is HTMLElement => !!x),
}),
...(values.empty
? [
@@ -31,9 +31,11 @@ import {
type LaborCalc,
type LaborDoc,
type LaborRow,
originText,
} from "./M02_MasterTemplete_Labor_Api";
import type { LaborDetailHandle, LaborDetailOptions } from "./M02_MasterTemplete_Labor_Detail";
import { laborBlock } from "./M02_MasterTemplete_Labor_Grid";
import { tipBorn } from "./M02_MasterTemplete_Labor_Born";
import { unlinkedBar } from "./M02_MasterTemplete_Labor_Unlinked";
import { pasteInto } from "./M02_MasterTemplete_Labor_Paste";
import { openLogicPicker, withHopyo } from "./M02_MasterTemplete_Labor_Pick";
@@ -176,6 +178,7 @@ export function mountLaborTable(
// 칸 초점이 표를 밀면(scroll) 메뉴가 바로 닫힘 — 밀림이 끝난 다음 그림에 엶
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 안 이은 줄 띠
gridHost.replaceChildren(...(bar ? [bar] : []), grid.root);
showSource();
@@ -412,7 +415,7 @@ export function mountLaborTable(
);
drawUnit();
const origin = createInputField({ type: "text", label: tc("Labor_Origin") });
origin.input.value = cur.본뜬?.키 ? `${cur.본뜬.층} ${cur.본뜬.키}` : tc("Origin_Self");
origin.input.value = originText(cur.본뜬) ?? tc("Origin_Self");
origin.input.readOnly = true;
host.replaceChildren(
@@ -0,0 +1,156 @@
/* =============================================================================
* M02_MasterTemplete_Main_Actions.ts
* 단가산출 · 일위대가 상세 머리 단추 [초기화] [삭제](740-2 · 738-2 · 요구 579-3 · 581) — 차례 … [초기화] [삭제] [저장].
* [삭제] = 735 경고 창(쓰는 곳 표 · 키) → 나무 지우기 → 목록으로 · 쓰는 곳 없으면 한 줄 물음.
* [초기화] = 서버 `-reset` 미리 보기(걷힐 고침 수) → 물음 → 그 층에 씀 → 다시 읽음.
* 덮개 · 뺀줄 · 더한줄 단가를 M01 키 값으로 되돌림 · 옵션(입력값) · 기본정보는 그대로(서버 740-1).
* 둘 다 쓰기 권한이 있는 층에서만 보임(`update`) · 상세에 저장 안 한 고침이 있으면 먼저 물음.
* ========================================================================== */
import { createButton, showToast } from "@ui/ui_template_elements";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { askConfirm } from "@ui/ui_template_modal";
import {
editPrecomboTree,
fetchPrecomboTree,
resetLibrary,
type LibraryKind,
} from "./M02_MasterTemplete_Api_Fetch";
import { askLibraryDelete } from "./M02_MasterTemplete_Library_Delete";
import { precomboList } from "./M02_MasterTemplete_UI_Side_Precombo";
import { sangeunList } from "./M02_MasterTemplete_UI_Side_Sangeun";
import type { Selection } from "./M02_MasterTemplete_UI_Main";
const TEXT = {
Reset: ["초기화", "Reset"],
ResetTip: [
"고친 값을 연결된 M01 키 값으로 되돌림 — 옵션 · 기본정보는 그대로",
"Revert edited values to their linked M01 key values — options and basic info stay",
],
Delete: ["삭제", "Delete"],
DeleteTip: [
"이 항목을 지움 — 쓰는 곳이 있으면 먼저 알림",
"Delete this item — warns first if used",
],
ResetAsk: [
"고친 값 {n} 개를 M01 값으로 되돌림{k} — 옵션 · 기본정보는 그대로",
"Revert {n} edited value(s) to M01 values{k} — options and basic info stay",
],
Restored: [" (빼 둔 줄 {k} 개 되살림)", " ({k} removed line(s) restored)"],
Nothing: ["되돌릴 고침이 없음", "Nothing to revert"],
Done: ["M01 값으로 되돌렸음", "Reverted to M01 values"],
DeleteAsk: ["「{name}」 을(를) 지울까?", "Delete 「{name}」?"],
Deleted: ["지웠음", "Deleted"],
Failed: ["못 했음 — {why}", "Failed — {why}"],
} as const;
const tx = (key: keyof typeof TEXT): string =>
String(TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]);
const fill = (text: string, vars: Record<string, string | number>): string =>
Object.entries(vars).reduce((s, [k, v]) => s.replace(`{${k}}`, String(v)), text);
export interface MainActionsCtx {
sel: () => Selection | null;
/** 지금 읽은 문서의 판 */
version: () => string;
/** 지금 읽은 문서(저장본) — 뺀 줄 수를 셈 */
loaded: () => unknown;
confirmLeave: () => Promise<boolean>;
open: (sel: Selection | null) => Promise<void>;
/** 좌측 목록을 다시 읽음 */
changed: () => void;
}
/** 문서 안 모든 `뺀줄` 길이 합(단가산출 · 일위대가 줄 안 덮개까지) */
function removedCount(node: unknown): number {
if (Array.isArray(node)) return node.reduce<number>((n, x) => n + removedCount(x), 0);
if (!node || typeof node !== "object") return 0;
return Object.entries(node as Record<string, unknown>).reduce(
(n, [k, v]) => n + (k === "뺀줄" && Array.isArray(v) ? v.length : removedCount(v)),
0,
);
}
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
export function createMainActions(ctx: MainActionsCtx): {
reset: HTMLButtonElement;
del: HTMLButtonElement;
update: (writable: boolean) => void;
} {
const reset = createButton({ label: tx("Reset"), variant: "ghost" });
const del = createButton({ label: tx("Delete"), variant: "danger-ghost" });
reset.title = tx("ResetTip");
del.title = tx("DeleteTip");
reset.dataset.mainReset = "";
del.dataset.mainDelete = "";
const target = (): { at: Selection; kind: LibraryKind } | null => {
const at = ctx.sel();
const kind = at?.kind;
return at && at.name && (kind === "sangeun" || kind === "precombo") ? { at, kind } : null;
};
reset.addEventListener("click", async () => {
const got = target();
if (!got || !(await ctx.confirmLeave())) return;
const { at, kind } = got;
try {
const look = await resetLibrary(kind, at.layer, at.owner ?? null, {
키: at.name,
미리보기: true,
});
if (!look.걷힘) return void showToast(tx("Nothing"), "success");
const k = removedCount(ctx.loaded());
const message = fill(tx("ResetAsk"), {
n: look.걷힘,
k: k ? fill(tx("Restored"), { k }) : "",
});
if (!(await askConfirm({ message, okLabel: tx("Reset"), danger: true }))) return;
await resetLibrary(kind, at.layer, at.owner ?? null, {
키: at.name,
미리보기: false,
판: ctx.version(),
});
await ctx.open(at); // 다시 그림
ctx.changed();
showToast(tx("Done"), "success");
} catch (error) {
showToast(fill(tx("Failed"), { why: why(error) }), "error");
}
});
del.addEventListener("click", async () => {
const got = target();
if (!got || !(await ctx.confirmLeave())) return;
const { at, kind } = got;
const owner = at.owner ?? null;
try {
const tree = await fetchPrecomboTree(at.layer, owner, kind);
const pick = { 경로: tree.자리?.[at.name] ?? [], 키: at.name };
const ask = fill(tx("DeleteAsk"), { name: at.label ?? at.name });
const ans = await askLibraryDelete(kind, at.layer, owner, [pick], ask, tx("Delete"));
if (!ans.ok) return;
await editPrecomboTree(
at.layer,
{ op: "delete", 대상: [pick], ...(ans.sure ? { 확인: true } : {}) },
tree.판,
owner,
kind,
);
const scope = { layer: at.layer, owner, canWrite: true } as Parameters<typeof sangeunList>[0];
await ctx.open(kind === "sangeun" ? sangeunList(scope) : precomboList(scope)); // 목록으로
ctx.changed();
showToast(tx("Deleted"), "success");
} catch (error) {
showToast(fill(tx("Failed"), { why: why(error) }), "error");
}
});
return {
reset,
del,
update: (writable) => {
const on = writable && !!target();
reset.hidden = del.hidden = !on;
},
};
}
@@ -27,6 +27,7 @@ from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool, run_with_connection
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_Layer_Access as access
from M02_MasterTemplete import M02_Library_Flat as flat
from M02_MasterTemplete import M02_MasterTemplete_Items as items_store
from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router
from M02_MasterTemplete import M02_MasterTemplete_Store as store
@@ -313,6 +314,7 @@ async def read_layer_template(
found["문서"] = refs.filled(kind, found["문서"]) # 옛 서고 문서 구분 · 상세구분(51-12)
if layer != "project": # 옛 단가승격 참조 줄 = 새 꼴(134-2 · 프로젝트 사본은 그대로)
found["문서"] = await asyncio.to_thread(refs.unpromote, kind, found["문서"])
found["문서"] = await asyncio.to_thread(flat.flat_doc, kind, found["문서"]) # 740-1
return found
@@ -52,10 +52,26 @@
font-weight: var(--font-weight-bold);
}
/* 740-2 이름 + 출처 꼬리표 한 줄 — 이름이 말줄임으로 줄고 꼬리표는 그대로 */
.m02-sk .m01lab__namerow {
display: flex;
align-items: center;
gap: var(--spacing-4);
min-width: 0;
}
.m02-sk .m01lab__namerow > span:first-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 727-2 기초 출처 꼬리표 — 칸 폭 고정 · 넘치면 말줄임(풍선 = 온 글) · 이름 칸 폭을 안 바꿈 */
.m02-sk .m01lab__src {
display: inline-block;
max-width: 6em;
flex: none;
max-width: 9em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -39,6 +39,7 @@ import { isPrecombo } from "./M02_MasterTemplete_UI_Side_Precombo";
import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet";
import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet_links";
import { createEmpty } from "@ui/ui_template_empty";
import { createMainActions } from "./M02_MasterTemplete_Main_Actions";
export interface Selection {
layer: Layer;
projectId: string | null;
@@ -141,6 +142,15 @@ export function createMain(
const title = el("h2", { className: "m02-main__title", text: L("M02_PickTemplate") });
const reload = createButton({ label: L("M02_Reload"), variant: "ghost" });
const save = createButton({ label: L("M02_Save"), variant: "filled" });
// 단가산출 · 일위대가 상세 [초기화] [삭제](740-2 · 738-2) — 차례 … [초기화] [삭제] [저장]
const actions = createMainActions({
sel: () => sel,
version: () => version,
loaded: () => loaded,
confirmLeave: () => confirmLeave(),
open: (at) => open(at),
changed: () => onSaved?.(),
});
// 구조물 도면 · 일위대가 · 면적산출 상세 화면 → 구조물집계표(그 열 고른 채) · 일반 단추(43-5 · 알약 · ghost 아님)
const back = createButton({ label: L("M02_Back") });
const helpSlot = el("span", { className: "m02-main__help" }); // 상세 [?](49-7) — 제목 바로 옆
@@ -171,6 +181,8 @@ export function createMain(
helpSlot,
slot,
reload,
actions.reset,
actions.del,
save,
...(extras.newItem ? [extras.newItem] : []),
],
@@ -256,6 +268,7 @@ export function createMain(
const bar = (): void => {
reload.hidden = !sel;
save.hidden = !sel || !writable(sel);
actions.update(!!sel && writable(sel));
back.hidden = !sel || !(sel.kind in NUMBER_FIELD);
nav.update(back.hidden ? null : sel); // 보임 조건 = 뒤로 단추와 같음
const kind = sel && sel.kind in HELP_TITLE ? (sel.kind as keyof typeof HELP_TITLE) : null;
+33 -4
View File
@@ -15,6 +15,8 @@ SK 단위 환산(728-2) = 배 = 바탕 기준단위(「100본」) → SK 단위(
「식」 · 빈 단위 = 앞 수 뗀 바탕 단위 = 옛 셈) ·
다른 성격 = 문서 `환산` {계수 | 단위중량 {종별, 형상}} · 없으면 셈 막음(까닭) ·
화면 셈 길 줄 · 계 = SK 1 단위 몫(`per_unit`).
바탕 재료(747-1) = 별도계상 빈 칸 = 직접계산(M01 기초처럼 재료비에 듦 · 「별도계상」 고른 줄만 밖) ·
일위대가 덮개 줄(`line` · `M02_Labor_Patch.run`)은 일위대가 기본(222-1 · 162-1) 그대로.
"""
from __future__ import annotations
@@ -126,7 +128,12 @@ def _base_of(doc: dict, whole: Any, read: Any, trail: tuple[str, ...]) -> dict[s
def resolve(
doc: dict, whole: Any, read: Any = refs.read_ref, trail: tuple = (), given: dict | None = None
doc: dict,
whole: Any,
read: Any = refs.read_ref,
trail: tuple = (),
given: dict | None = None,
line: bool = False,
) -> dict[str, Any]:
"""SK 문서 → 고친 줄 `{줄, 고름, 자재표시, 정체, 짝잃음, 입력값, 켜쌓기율, 배, 바탕판}` ·
`trail` = 지나온 참조 이름표(순환 · 깊이 422) · `given` = 공통 옵션(더한줄 로직 입력)."""
@@ -135,6 +142,8 @@ def resolve(
doc, lack = _fill(doc, whole, common)
got = sd.patched(base["줄"], doc, base["고름"], base["자재표시"])
items = got["줄"]["호표"]
if not line:
_direct(got["자재표시"], items)
for at, cid in enumerate(got["정체"][: len(items)]):
hit = re.match(r"^add:#(\d+)$", cid)
if hit and int(hit[1]) in lack:
@@ -155,6 +164,13 @@ def resolve(
}
def _direct(marks: dict, items: list) -> None:
"""SK 재료 줄 별도계상 빈 칸 = 직접계산(747-1 · 제자리) — 다른 두 칸은 그대로."""
for n, item in enumerate(items):
if item.get("종류") == "재료":
marks[str(n)] = {"별도계상": "직접계산", **labor.marks(marks.get(str(n)))}
# ── 못 셈 줄 = 그 줄만 빠짐(725-1 · 725-2) ─────────────────────────────────
NO_QTY = "수량 없음" # `M02_Labor_Expand.NO_QTY` 와 같은 글(가져오기 둘레 피함)
NO_QTY_WHY = "수량 없음 — 쓰는 자리에서 연결"
@@ -216,6 +232,14 @@ def sub_run(one: dict, whole: Any, patched: bool = True) -> dict[str, Any]:
row = whole.logic(code)
except Exception as e: # noqa: BLE001 — 엔진 FormulaError
return {"기준": None, "까닭": str(e), "정체": [], "짝잃음": [], "바탕판": ""}
if any(one.get(k) not in (None, {}, []) for k in sd.BODY): # 넣은 SK 몸(740-1) = 이름 없는 SK
keep = ("입력값", *sd.BODY, *(("덮개", "뺀줄") if patched else ()))
doc = {"키": "SK-000000", "이름": "줄", "단위": "식", "바탕": {"로직": code}}
doc |= {"덮개": {}, "뺀줄": [], "더한줄": [], **{k: one[k] for k in keep if k in one}}
try:
return {**run(doc, whole), "바탕판": fingerprint(row)}
except sd.DocError as e:
return {"기준": None, "까닭": str(e), "정체": [], "짝잃음": [], "바탕판": ""}
got = sd.patched(row, one if patched else {})
over = _Over(whole, code, {**got["줄"], "키": code})
out = labor.run_line(over, code, one.get("입력값"), None, got["고름"] or None)
@@ -241,7 +265,8 @@ def tag_of(whole: Any, code: str) -> str:
def _detail(one: dict, at: int, whole: Any) -> dict[str, Any]:
"""둘째 기초 세부 줄(화면) — 수량 · 금액 · 비목 = 세부 × 더한줄 수량 · cid `add:#k/세부 cid` ·
"""둘째 기초 세부 줄(화면) — 수량 · 금액 · 비목 = 세부 × 더한줄 수량(상자 합 몫) · `원문` =
그 기초 1 기준단위 몫(M01 원문 품 · 744-1 화면 품 칸) · cid `add:#k/세부 cid` ·
`바탕값` = 덮기 전 1 기준단위 품 · 단가 · `뺀세부` = 뺀 줄 이름."""
sub = sub_run(one, whole)
plain = sub_run(one, whole, False) if sd.sub_patched(one) else sub
@@ -264,6 +289,7 @@ def _detail(one: dict, at: int, whole: Any) -> dict[str, Any]:
**scaled,
"비목": parts,
"cid": f"add:#{at}/{cid}",
"원문": {k: line.get(k) for k in ("수량", "금액", "비목")},
"바탕값": _was(was[cid]) if cid in was else None,
**({"덮개": [k for k in sd.COVER_KEYS if k in mine]} if mine else {}),
**({"원값": mine["원값"]} if "원값" in mine else {}),
@@ -301,13 +327,14 @@ def run(
expand: Any = None,
trail: tuple = (),
detail: bool = False,
line: bool = False,
) -> dict[str, Any]:
"""SK 셈(저장 안 함) — `run_line` 답 + `{1단위, 배, 정체, 짝잃음, 바탕판}` · 줄마다 `cid` ·
고친 칸 `덮개`(칸 이름) · `원값`. 틀린 문서 · 순환 = `sd.DocError` ·
`detail` = 둘째 기초 세부 펼침(`_detail` · 화면 셈 길만)."""
`detail` = 둘째 기초 세부 펼침(`_detail` · 화면 셈 길만) · `line` = 일위대가 덮개 줄."""
from M02_MasterTemplete.M02_Labor_Expand import Expand
got = resolve(doc, whole, read, trail, given)
got = resolve(doc, whole, read, trail, given, line)
row = {**got["줄"], "호표": [dict(x) for x in got["줄"]["호표"]]}
_bundles(row, whole, expand or Expand(whole, read), trail)
head = {k: got[k] for k in ("정체", "짝잃음", "바탕판")} | {"배": got["배"]}
@@ -431,6 +458,8 @@ def clean_doc(doc: Any, here_: refs.Here) -> dict[str, Any]:
base = _base_of(rough, whole, refs.read_ref, ())
out = sd.clean(doc, base["줄"], whole)
out["바탕"], out["더한줄"] = rough["바탕"], rough["더한줄"]
for one in out["더한줄"]: # 처음수량(744-1) = 넣을 때 수량 · 없으면 지금 수량
one.setdefault("처음수량", one["수량"])
if out["덮개"]:
bare = run({**out, "덮개": {}}, whole) # 덮기 전 값 = 원값(그때 M01)
was = {x["cid"]: x for x in bare.get("줄") or []}
+45 -5
View File
@@ -8,7 +8,8 @@
덮개 {cid: {qty | 배율, labor, material, expense, 원값}},
더한줄 [{종류: 인력|재료|기계, 요소, 이름, 단위, 수량, 비목?, 단가?,
별도계상?(재료 · 없음 = 계 안)}
| {종류: 로직, 로직, 입력값, 수량, 덮개?, 뺀줄?, 바탕판?}
| {종류: 로직, 로직, 입력값, 수량, 덮개?, 뺀줄?, 바탕판?,
고름? · 자재표시? · 켜쌓기율? · 더한줄?(넣은 SK 몸 · 740-1) · 본뜬?(출처)}
| {참조 | 고정, 이름?, 단위?, 수량}],
뺀줄 [cid], 바탕판, 환산?}
SK 단위 환산(728-2) = `환산` {계수: 양수}(성격 다름 · 바탕 앞 수 뗀 1 단위 = SK 몇 단위) |
@@ -178,7 +179,7 @@ def _added(raw: Any, where: str, whole: Any) -> dict:
if row is not None:
head["입력값"] = {k: v for k, v in given.items() if k in names}
name = name or str(row.get("이름") or code)
head |= _sub_patch(raw, where)
head |= _sub_patch(raw, where) | _sub_body(raw, where, whole)
elif raw.get("종류") in ADD_PART:
key = str(raw.get("요소") or "")
got = _element(whole, key, where)
@@ -194,7 +195,24 @@ def _added(raw: Any, where: str, whole: Any) -> dict:
unit = unit or str((got or {}).get("단위") or "")
else:
raise DocError(f"{where} · 종류는 인력 · 재료 · 기계 · 로직 · 참조 · 고정")
return {**head, "이름": name, "단위": unit, "수량": qty}
mark = {"본뜬": raw["본뜬"]} if isinstance(raw.get("본뜬"), dict) else {} # 출처(740-1)
if raw.get("처음수량") is not None: # 넣을 때 수량(744-1 [되돌림] 값)
mark["처음수량"] = plain(_num(raw["처음수량"], f"{where} 처음수량"))
return {**head, "이름": name, "단위": unit, "수량": qty, **mark}
def _gone_adds(raw: Any, whole: Any) -> list[dict]:
"""뺀 더한줄 기억(744-1 · [되살림]) `[{차례, 줄}]` — 셈 안 함 · 못 읽는 줄은 버림."""
out = []
for one in raw if isinstance(raw, list) else []:
at = one.get("차례") if isinstance(one, dict) else None
if not isinstance(at, int) or isinstance(at, bool) or at < 0:
continue
try:
out.append({"차례": at, "줄": _added(one.get("줄"), "뺀 더한줄", whole)})
except DocError:
continue
return out
def _sub_patch(raw: dict, where: str) -> dict:
@@ -210,9 +228,29 @@ def _sub_patch(raw: dict, where: str) -> dict:
return {**({"덮개": covers} if covers else {}), **({"뺀줄": drop} if drop else {})}
BODY = ("고름", "자재표시", "켜쌓기율", "더한줄") # 넣은 SK 몸(740-1 · `M02_Library_Flat`)
def _sub_body(raw: dict, where: str, whole: Any) -> dict:
"""넣은 SK 한 줄(740-1) — 그 SK 의 고름 · 자재표시 · 켜쌓기율 · 더한줄(M01 키만) · `본뜬`(출처
정보 · 셈에 안 씀) · 빈 칸은 안 둠."""
out: dict[str, Any] = {}
if isinstance(raw.get("본뜬"), dict):
out["본뜬"] = raw["본뜬"]
if not any(raw.get(k) not in (None, {}, []) for k in BODY):
return out
body = {k: raw.get(k) for k in BODY if raw.get(k) not in (None, {}, [])}
doc = {"키": "SK-000000", "이름": "줄", "단위": "식", "바탕": {"로직": raw.get("로직")}, **body}
try:
got = clean(doc, None, whole)
except DocError as e:
raise DocError(f"{where} · {e}") from e
return out | {k: got[k] for k in BODY if got.get(k) not in (None, {}, [])}
def sub_patched(one: dict) -> bool:
"""고친 둘째 기초 — 더한줄 로직에 세부 덮개 · 뺀줄."""
return one.get("종류") == "로직" and bool(one.get("덮개") or one.get("뺀줄"))
"""고친 둘째 기초 — 더한줄 로직에 세부 덮개 · 뺀줄 · 넣은 SK 몸(740-1)."""
return one.get("종류") == "로직" and any(one.get(k) for k in ("덮개", "뺀줄", *BODY))
def _added_names(whole: Any, adds: Any) -> set[str]:
@@ -305,6 +343,8 @@ def clean(doc: Any, base_row: dict | None = None, whole: Any = None) -> dict[str
if len(set(drop)) != len(drop):
raise DocError("뺀줄에 같은 줄 두 번")
out["뺀줄"] = drop
if gone := _gone_adds(doc.get("뺀더한줄"), whole):
out["뺀더한줄"] = gone
return out
+53
View File
@@ -0,0 +1,53 @@
/* =============================================================================
* M02_Born_Tag.ts
* 넣은 줄 출처 꼬리표(740-2 · 요구 581) — 서버 `본뜬 {층, 주인, 키, 이름, 때}`(740-1) → 화면 글.
* 순수 계산 — 화면 · fetch 없음. 첫 줄 = 줄 옆에 보이는 짧은 글 · 둘째 줄 = 풍선(키 · 이름 · 층 · 때).
* 공용 상세 꼬리표 칸(`LogicLab_Ho` sourceTag)이 「\n」 앞만 보이고 전체를 풍선으로 씀.
* ========================================================================== */
export interface Born {
층?: string | null;
주인?: string | null;
키?: string | null;
이름?: string | null;
때?: string | null;
}
const LAYER: Record<string, string> = {
system: "마스터",
company: "회사",
personal: "개인",
project: "프로젝트",
};
/** 서버 때(UTC) → 이 컴퓨터 시각 「2026-11-02 20:13」 · 읽을 수 없으면 "" */
function localTime(at: unknown): string {
const d = new Date(String(at ?? ""));
if (Number.isNaN(d.getTime())) return "";
const z = (n: number): string => String(n).padStart(2, "0");
return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())} ${z(d.getHours())}:${z(d.getMinutes())}`;
}
/** `본뜬` 한 벌 → 꼬리표 글(없으면 ""). `before` = 이미 있는 꼬리표(기초 원문번호) — 앞에 둠 */
export function bornText(born: unknown, before = ""): string {
const b = born && typeof born === "object" ? (born as Born) : null;
if (!b || (!b.키 && !b.이름)) return before;
const shown = `${before ? `${before} · ` : ""}본뜬 ${b.키 ?? b.이름}`;
const layer = LAYER[String(b.층 ?? "")] ?? String(b.층 ?? "");
const tip = [`${b.키 ?? ""} ${b.이름 ?? ""}`.trim(), layer, localTime(b.때)]
.filter(Boolean)
.join(" · ");
return `${shown}\n${tip}`;
}
/** 단가산출 줄 열쇠(`add:#k` · `add:#k/…`) → 그 더한줄의 꼬리표 글 */
export function bornOfCid(cid: string, adds: readonly object[] | undefined, before = ""): string {
const hit = /^add:#(\d+)/.exec(cid);
return bornText(
hit ? (adds?.[Number(hit[1])] as { 본뜬?: unknown } | undefined)?.본뜬 : undefined,
before,
);
}
/** 격자 비고 칸 글 — 「본뜬 SK-000012」 · 없으면 "" (첫 줄만) */
export const bornNote = (born: unknown): string => bornText(born).split("\n")[0];
@@ -13,6 +13,7 @@
* 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장).
* ========================================================================== */
import { bornOfCid } from "./M02_Born_Tag";
import type { SheetGroup } from "../../A00_Common/calc_sheet";
export type CoverPart = "labor" | "material" | "expense";
@@ -470,7 +471,8 @@ export function hoView(
const l = lineBy.get(cid);
const name = l?.이름 ?? cid;
const at = subOf(cid);
const tag = tags.has(cid) ? { 꼬리표: tags.get(cid) } : {};
const born = bornOfCid(cid, doc.더한줄, tags.get(cid) ?? ""); // 넣은 줄 출처(740-2) — 기초 번호 뒤에 붙임
const tag = born ? { 꼬리표: born } : {};
if ((at?.cid ?? cid).startsWith("pct:")) {
extra.push([cid, { ...((known.get(cid) as HoExtra) ?? { 이름: name, 식: "" }), ...tag }]);
continue;