Merge remote-tracking branches 'origin/CODEX' and 'origin/sub_laptop_1' into sub_laptop_3

This commit is contained in:
2026-11-11 15:22:42 +09:00
48 changed files with 753 additions and 3903 deletions
+8 -70
View File
@@ -5,14 +5,10 @@
저절로: 지난번 이음 · 하나 수 · 남은 · 자리 · 이은(`board`)}
POST /api/projects/{id}/estimation/link/apply
{pairs [{line, target, mode?}], auto, mode, counts?, user?} · 알림 남은 = 안 이은 항목 수
잇기(`link`) — `auto` = 지난번 이음 · 하나도 더함 →
{user(새 캐시), 알림, payload(그 캐시로 셈)} ·
이은 짝 = 맞춤 기억에 씀(회사 층 = 회사 관리자 · 아니면 개인 층)
POST …/link/undo {target, user?} → {user, 알림, payload} — 잇기 풀기(맞춤 기억 그대로)
POST …/link/forget {key, user?} → {잊음, 기억층, 남음} — 이 짝 잊기(불러온 줄 · 이은 줄 키 →
맞춤 열쇠 · 쓸 수 있는 층에 잊음 표시 `{잊음: True}` — 느슨한 기억도 안 불러옴 ·
개인 층 표시가 회사 기억을 덮음 · 남음 = 그래도 살아 있는 기억)
맞춤 기억 = 층 `실무자료/내역맞춤.json`(실무자료 맞춤 꼴) · 읽기 = 회사 + 개인(개인이 이김).
잇기(`link`) → {user(새 캐시), 알림, payload(그 캐시로 셈)}
POST …/link/undo {target, user?} → {user, 알림, payload} — 잇기 풀기
공사 내역 맞춤 기억(`실무자료/내역맞춤.json` · 지난번 이음 · 잊기)은 걷음(815-2 · 요구 765-2) —
제안은 짝 표만 · 옛 기억 파일은 읽지도 쓰지도 않음(지우지 않음).
끼워 넣기 = `B09_Estimation_Router`(같은 머리 · 같은 권한) · 화면 = `B09_Estimation_UI_Link`.
"""
@@ -30,42 +26,9 @@ from B08_Quantity.B08_Quantity_Router_Hydro import _project_root
from B09_Estimation import B09_Estimation_Bill_Link as linker
from B09_Estimation import B09_Estimation_Store as store
from common_util.common_util_auth import verify_session
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
from M02_MasterTemplete import M02_Layer_Access as access
from M02_MasterTemplete import M02_MasterTemplete_Store as m02_store
router = APIRouter()
PATH = "/{project_id}/estimation/link"
LAYERS = ("company", "personal")
def _folders(session: dict[str, Any]) -> dict[str, tuple[Path, bool]]:
"""{층: (폴더, 쓰기)} — 회사 없음 등 = 그 층 건너뜀."""
out = {}
for layer in LAYERS:
try:
got = access.grant(session, layer)
out[layer] = (access.folder(got, create=True), got.write)
except HTTPException:
continue
return out
def recalled(session: dict[str, Any]) -> dict[str, dict[str, Any]]:
"""맞춤 기억 — 마스터(시스템 관리자 승인 · 718-2) 위에 회사 · 그 위에 개인을 덮음."""
out: dict[str, dict[str, Any]] = dict(hopyo_store.memory(m02_store.FOLDER, linker.MEMORY))
for folder, _write in _folders(session).values():
out.update(hopyo_store.memory(folder, linker.MEMORY))
return out
def keep(session: dict[str, Any], entries: dict[str, dict[str, Any]]) -> str | None:
"""이은 짝 → 회사 층(쓸 수 있으면) · 아니면 개인 층 → 쓴 층(없음 = None)."""
folders = _folders(session)
layer = next((n for n in LAYERS if n in folders and folders[n][1]), None)
if entries and layer:
hopyo_store.remember(folders[layer][0], entries, linker.MEMORY)
return layer if entries else None
def _draft(raw: Any) -> dict[str, Any] | None:
@@ -87,11 +50,6 @@ class UndoBody(BaseModel):
user: dict[str, Any] | None = None
class ForgetBody(BaseModel):
key: str = Field(min_length=1, max_length=200)
user: dict[str, Any] | None = None
class ApplyBody(BaseModel):
pairs: list[Pair] = Field(default_factory=list, max_length=2000)
auto: bool = False
@@ -115,8 +73,7 @@ async def suggest(
rows = await _rows(project_id, root, draft)
board = linker.board(draft, rows)
left = {r["key"] for r in board["남은"]}
memory = await asyncio.to_thread(recalled, session)
lines = [s for s in linker.suggest(rows, memory) if s["key"] in left]
lines = [s for s in linker.suggest(rows, {}) if s["key"] in left]
return {"줄": lines, "저절로": len(linker.auto_pairs(lines)), **board}
@@ -134,15 +91,14 @@ async def apply(
rows = await _rows(project_id, root, draft)
pairs = [(p.line, p.target) for p in body.pairs]
if body.auto:
memory = await asyncio.to_thread(recalled, session)
pairs += [p for p in linker.auto_pairs(linker.suggest(rows, memory)) if p not in pairs]
pairs += [p for p in linker.auto_pairs(linker.suggest(rows, {})) if p not in pairs]
made, report = linker.link(draft, rows, pairs, body.mode, body.counts, modes)
made = store.clean_user(made)
layer = await asyncio.to_thread(keep, session, report.pop("기억"))
report.pop("기억")
out = await payload(project_id, root, made, True)
left = linker.board(made, out["sheets"]["bill"]["rows"])["남은"]
report["남은"] = sum(1 for r in left if r["kind"] == "item")
return {"user": made, "알림": {**report, "기억층": layer}, "payload": out}
return {"user": made, "알림": report, "payload": out}
@router.post(PATH + "/undo")
@@ -162,21 +118,3 @@ async def undo(
"알림": {"풀음": 1},
"payload": await payload(project_id, root, made, True),
}
@router.post(PATH + "/forget")
async def forget(
project_id: UUID, body: ForgetBody, session: dict[str, Any] = Depends(verify_session)
) -> dict[str, Any]:
root = await _project_root(project_id)
draft = _draft(body.user) or await asyncio.to_thread(store.read_user, root)
rows = await _rows(project_id, root, draft)
board = linker.board(draft, rows)
hit = next((r for r in board["남은"] if r["key"] == body.key), None)
memo = linker.key_of(hit) if hit else None
memo = memo or next((r["memo"] for r in board["이은"] if r["key"] == body.key), None)
if not memo:
raise HTTPException(status_code=400, detail="불러온 줄 · 이은 줄이 아님")
layer = await asyncio.to_thread(keep, session, {memo: {linker.FORGOT: True}})
left = bool((await asyncio.to_thread(recalled, session)).get(memo, {}).get("확정"))
return {"잊음": memo, "기억층": layer, "남음": left}
+4 -26
View File
@@ -4,10 +4,9 @@
* 우리 자동 줄(토공 · 운반 · 구조물)에 이어 B08 수량이 붙게. 저장 안 함(받은 캐시 → [저장] 때 서버 정본).
*
* 길 = `B09_Estimation_Router_Link.py` link/suggest(제안 · 남은 줄 · 빈 자리 · 이은 줄) ·
* link/apply(잇기 · 맞춤 기억 씀) · link/undo(잇기 풀기) · link/forget(이 짝 잊기) — 셈 = 서버 단독
* link/apply(잇기) · link/undo(잇기 풀기) — 셈 = 서버 단독 · 맞춤 기억 · 잊기는 걷음(815-2)
* 창 = 제안 줄(꼬리표 지난번 이음 · 짝 표 · 이름 같음 · 후보 고름 · 수량 갈래 · 묶음 개수 · [잇기]) ·
* 제안 없는 줄([우리 공종으로 잇기] = 단가연결 고르기 창 재사용 · 우리 줄만) · 이은 줄([잇기 풀기] ·
* [이 짝 잊기]) · 머리 [모두 제안대로](짧은 길 = 불러오기 → 모두 제안대로 → [저장])
* 제안 없는 줄([우리 공종으로 잇기] = 단가연결 고르기 창 재사용 · 우리 줄만) · 이은 줄([잇기 풀기]) · 머리 [모두 제안대로](짧은 길 = 불러오기 → 모두 제안대로 → [저장])
* 수량 갈래 기본 = 불러올 때 고른 것(가 넘겨받기 · 나 단가만) · 줄에서 바꿈 ·
* 묶음 나 = 개수 칸(빈칸 = B08 수량 · 다르면 수량 칸 붉게)
* 고리 = 불러오기 끝(`…_UI_Import`) · 설계내역서 우클릭(`…_UI_Tabs_Bill_Edit`)
@@ -185,14 +184,6 @@ export async function openBillLink(
.map((s) => [s.key, Number(pickOf(s).count)])
.filter(([, n]) => Number.isFinite(n) && (n as number) > 0),
);
const forget = (key: string): void =>
run(async () => {
const got = await post("forget", { key, user: cur });
showToast(
got.남음 ? "이 짝 잊음 — 회사 기억이 남음(회사 관리자만 지움)" : "이 짝 잊음",
got.남음 ? "warning" : "success",
);
});
const suggestRow = (s: Row): HTMLElement => {
const p = pickOf(s);
@@ -233,10 +224,6 @@ export async function openBillLink(
onClick: () => run(() => linkPairs(cur, done, [pairOf(s)], countsOf([s]))),
}),
);
if (s.꼬리표 === "지난번 이음")
items.push(
createButton({ label: "이 짝 잊기", variant: "ghost", onClick: () => forget(s.key) }),
);
return createListRow({
label: label(s),
sub: s.까닭 || (s.kind === "item" ? "" : "묶음 → 구조물 줄 통째"),
@@ -269,7 +256,6 @@ export async function openBillLink(
variant: "ghost",
onClick: () => run(() => undoLink(cur, done, r.key)),
}),
createButton({ label: "이 짝 잊기", variant: "ghost", onClick: () => forget(r.key) }),
],
}),
});
@@ -326,23 +312,15 @@ async function undoLink(user: Row, done: LinkDone, target: string): Promise<Row>
return got.user;
}
/** 설계내역서 우클릭 — 불러온 줄 = 잇기 · 이은 줄(`was`) = 풀기 · 잊기 */
/** 설계내역서 우클릭 — 불러온 줄 = 잇기 · 이은 줄(`was`) = 풀기 */
export function linkMenu(ctx: TabContext, key: string): MapContextMenuItem[] {
const done: LinkDone = (payload, user) => {
Object.assign(ctx.payload, payload);
ctx.setUser(user);
};
const entry = ((ctx.user.bill?.rows as Row[]) || []).find((e) => e.key === key);
const forget = (): void =>
void post("forget", { key, user: ctx.user }).then(
(got) => showToast(got.남음 ? "회사 기억이 남음(회사 관리자만 지움)" : "이 짝 잊음", "info"),
fail("못 잊음"),
);
if (entry?.was)
return [
["잇기 풀기", () => void undoLink(ctx.user, done, key).catch(fail("못 풂"))],
["이 짝 잊기", forget],
];
return [["잇기 풀기", () => void undoLink(ctx.user, done, key).catch(fail("못 풂"))]];
if (!key.startsWith("hand:x")) return [];
return [["우리 공종으로 잇기", () => void openBillLink(ctx.user, done)]];
}
+177 -158
View File
@@ -1,17 +1,22 @@
"""M01 별칭 장부 시스템 모으기(PLAN 718-2 · 요구서 내역12 4장 23 · 4-1 확정 6).
"""M01 별칭 모아 보기 · 제안 → 승인(PLAN 815-2 · 요구 764 · 765) — 서버 단독.
모든 회사 · 개인 층의 별칭 장부(`실무자료/맞춤.json` · `M02_Hopyo_Ledger`)와 B09 공사 내역 맞춤
기억(`실무자료/내역맞춤.json` · 707-4)을 읽어 후보로 모음 — 읽기만(공유 storage 안 씀).
후보 = (종류, 열쇠, 서명) · 서명 = 짝 내용 지문(같은 짝이면 같은 서명) · 회사 수 · 사람 수만
(누가 · 처음파일 · 층 주인은 안 내보냄 = 익명) · 충돌 = 같은 열쇠 다른 서명 · 마스터와 다름.
승인 = 시스템 관리자만 → 마스터 층에 씀(맞춤 = `ledger.put` 재검사 · 내역맞춤 = 기억 파일) ·
반려 = 마스터 층 `실무자료/별칭반려.json` 에 서명 기억(같은 짝은 다시 안 보임) · 자동 반영 없음.
별칭 = 요소 · 로직 줄 칸 `별칭` 하나(콤마 여러 개 · 풀이 정본 `master_alias`).
GET /api/m01/alias → {줄: 별칭이 적힌 줄 전부(갈래 · 키 · 이름 · 규격 · 별칭 · 조각 ·
들어옴 수기/승인), 대기: 승인 대기 제안}
POST /api/m01/alias/save {키, 별칭} → 그 줄 `별칭` 만 고침(M01 저장 길 `make.save`)
POST /api/m01/alias/approve {id} → 그 요소 `별칭` 에 콤마로 더함 · 제안 = 「승인」 기록
POST /api/m01/alias/reject {id} → 제안 지움
POST /api/m01/alias/suggest {키, 이름, 규격?, 단위?(갈래는 서버가 키로 정함)} → 제안 하나(로그인만 · 일반 계정도)
제안 파일 = `storage/_shared/별칭제안.json` `{제안: {id: {…, 상태 대기|승인}}}`.
권한 = `router` 시스템 관리자 · `suggest_router` 로그인만(`main.py`).
"""
from __future__ import annotations
import hashlib
import json
import os
import threading
from pathlib import Path
from typing import Any
@@ -20,160 +25,161 @@ from pydantic import BaseModel, Field
from common_util.common_util_auth import verify_session
from common_util.common_util_time import utc_now_text
from M02_MasterTemplete import M02_Hopyo_Ledger as ledger
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M01_MasterData import M01_MasterData_Store as store
from M01_MasterData import M01_MasterData_Store_Make as make
from M02_MasterTemplete import M02_Template_Layers as layers
ALIAS, BILL = "맞춤", "내역맞춤"
KINDS = (ALIAS, BILL)
REJECT = "별칭반려"
import master_alias as ma # noqa: E402 (`store` 가 scripts 를 길에 둠)
router = APIRouter(prefix="/api/m01/alias-ledger", tags=["M01 별칭 장부"])
ALIAS = "별칭"
FUEL = "재료_유가전력.json"
ELEMENTS = ("인력.json", "기계.json", "재료_자재품목.json", FUEL) # 제안을 받는 요소 파일
_LOCK = threading.Lock()
router = APIRouter(prefix="/api/m01/alias", tags=["M01 별칭"])
suggest_router = APIRouter(prefix="/api/m01/alias", tags=["M01 별칭"])
# ── 읽기 ─────────────────────────────────────────────────────────────
def layer_folders() -> list[tuple[str, str | None, Path]]:
"""(회사, 사용자 | None, 층 폴더) — storage 의 회사 · 개인 층(숫자 폴더만 · 두 단만 훑음)."""
root = layers.storage_root()
out: list[tuple[str, str | None, Path]] = []
for c in sorted(root.iterdir() if root.is_dir() else []):
if not (c.is_dir() and c.name.isdigit()):
def kind_of(file: str, group: str) -> str:
if file == FUEL:
return "유가"
return "단가산출 기초" if group == "로직" else group
# ── 제안 파일 ─────────────────────────────────────────────────────────
def suggest_path() -> Path:
return layers.storage_root() / "_shared" / "별칭제안.json"
def _book() -> dict[str, dict[str, Any]]:
try:
got = json.loads(suggest_path().read_text(encoding="utf-8")).get("제안")
except (OSError, ValueError, AttributeError):
return {}
return got if isinstance(got, dict) else {}
def _keep(book: dict[str, dict[str, Any]]) -> None:
path = suggest_path()
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps({"제안": book}, ensure_ascii=False, indent=1), encoding="utf-8")
os.replace(tmp, path)
# ── 줄 찾기 · 고치기 ───────────────────────────────────────────────────
def _find(key: str, files: tuple[str, ...] | None = None) -> tuple[str, dict[str, Any]]:
loaded, _ = store.loaded()
for name, data in loaded.items():
if files and name not in files:
continue
out.append((c.name, None, layers.company_dir(c.name)))
for u in sorted(c.iterdir()):
if u.is_dir() and u.name.isdigit():
out.append((c.name, u.name, layers.personal_dir(c.name, u.name)))
return out
for row in data.get("줄") or []:
if str(row.get("키")) == key:
return name, row
raise store.StoreError(404, f"없는 키 「{key}」")
def _body(kind: str, row: dict[str, Any]) -> dict[str, Any]:
"""서명에 드는 내용 — 맞춤 = 갈래 · 짝 · 내역맞춤 = 확정 줄 키 · 이름."""
if kind == ALIAS:
return {"갈래": row.get("갈래"), "짝": row.get("짝") or []}
return {"확정": row.get("확정"), "이름": row.get("이름")}
def set_alias(key: str, text: str) -> dict[str, Any]:
"""그 줄 `별칭` 만 바꿔 M01 저장 길로(빈 글 = 칸 없음 · 별칭만이라 정본 개정 확인 안 물음)."""
file, _ = _find(key)
data, version = store.read(file)
row = dict(next(r for r in data["줄"] if str(r.get("키")) == key))
row[ALIAS] = text
change = {"op": "edit", "key": key, "row": row}
make.save([{"file": file, "version": version, "changes": [change]}], True)
return board()
def sign(kind: str, row: dict[str, Any]) -> str:
raw = json.dumps(_body(kind, row), ensure_ascii=False, sort_keys=True)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
def _quoted(piece: str) -> str:
return f'"{piece}"' if any(s in piece for s in (",", ",")) else piece
def _book(kind: str, folder: Path) -> dict[str, dict[str, Any]]:
if kind == ALIAS:
return ledger.read(folder)
got = hopyo_store.memory(folder, BILL)
return {
k: v
for k, v in got.items()
if isinstance(v, dict) and v.get("확정") and str(k).count("|") == 2
} # 잊음 · 깨진 열쇠 뺌
def _joined(text: Any, piece: str) -> str:
"""별칭 글 끝에 한 조각 — 이미 있으면(정규화 같음) 그대로."""
raw = str(text or "").strip()
if ma.norm(piece) in {ma.norm(x) for x in ma.names(raw)}:
return raw
return f"{raw}, {_quoted(piece)}" if raw else _quoted(piece)
def _origin(full: str, row: dict[str, Any]) -> dict[str, str]:
if isinstance(row.get("원래"), dict):
return {k: str(row["원래"].get(k) or "") for k in ("품명", "규격", "단위")}
return ledger._plain_of(full)
# ── 모아 보기 ─────────────────────────────────────────────────────────
def board() -> dict[str, Any]:
book = _book()
approved = {
(str(v.get("키")), ma.norm(v.get(ALIAS))) for v in book.values() if v.get("상태") == "승인"
}
rows = []
loaded, _ = store.loaded()
for file, data in loaded.items():
kind = kind_of(file, str(data.get("그룹") or ""))
for row in data.get("줄") or []:
if not str(row.get(ALIAS) or "").strip():
continue
key = str(row.get("키"))
pieces = [
{"글": p, "승인": (key, ma.norm(p)) in approved} for p in ma.names(row[ALIAS])
]
rows.append(
{
"갈래": kind,
"파일": file,
"키": key,
"이름": str(row.get("이름") or ""),
"규격": str(row.get("규격") or ""),
ALIAS: str(row[ALIAS]),
"조각": pieces,
"들어옴": "승인" if any(p["승인"] for p in pieces) else "수기",
}
)
waiting = [{"id": k, **v} for k, v in book.items() if v.get("상태") == "대기"]
waiting.sort(key=lambda x: str(x.get("때") or ""))
return {"줄": rows, "대기": waiting}
def rejected() -> dict[str, dict[str, Any]]:
return hopyo_store.memory(store.FOLDER, REJECT)
# ── 제안 · 승인 · 반려 ────────────────────────────────────────────────
def suggest(key: str, name: str, spec: str, unit: str, who: Any) -> dict[str, Any]:
file, row = _find(key, ELEMENTS)
piece = " ".join(x for x in (name.strip(), spec.strip()) if x)
if not piece:
raise store.StoreError(400, "가져온 이름이 비었음")
if _joined(row.get(ALIAS), piece) == str(row.get(ALIAS) or "").strip():
return {"이미": True} # 이미 그 별칭이 있음
sid = hashlib.sha256(f"{key}|{ma.norm(piece)}".encode()).hexdigest()[:16]
with _LOCK:
book = _book()
if book.get(sid, {}).get("상태") != "승인":
book[sid] = {
"갈래": kind_of(file, str(store.loaded()[0][file].get("그룹") or "")),
"키": key,
"이름": name.strip(),
"규격": spec.strip(),
"단위": unit.strip(),
ALIAS: piece,
"상태": "대기",
"누가": who,
"때": utc_now_text(),
}
_keep(book)
return {"id": sid}
def _reject_id(kind: str, full: str, sig: str) -> str:
return f"{kind}|{sig}|{full}"
def decide(sid: str, ok: bool, who: Any) -> dict[str, Any]:
with _LOCK:
book = _book()
one = book.get(sid)
if not one or one.get("상태") != "대기":
raise store.StoreError(404, "대기 중인 제안이 없음(이미 승인 · 반려)")
if ok:
_, row = _find(str(one["키"]), ELEMENTS)
set_alias(str(one["키"]), _joined(row.get(ALIAS), str(one[ALIAS])))
one.update(상태="승인", 승인={"누가": who, "때": utc_now_text()})
else:
del book[sid]
_keep(book)
return board()
def gather() -> dict[str, Any]:
"""후보 모음 `{후보, 모은곳{회사, 사람}, 반려}` — 마스터와 같은 짝 · 반려한 짝은 뺌."""
seen: dict[tuple[str, str, str], dict[str, Any]] = {}
firms, people = set(), set()
for company, user, folder in layer_folders():
for kind in KINDS:
for full, row in _book(kind, folder).items():
sig = sign(kind, row)
who = (
(row.get("누가") or {}).get("id") if isinstance(row.get("누가"), dict) else None
)
person = f"{company}/{user}" if user else f"{company}/{who or '-'}"
one = seen.setdefault(
(kind, full, sig),
{"종류": kind, "열쇠": full, "서명": sig, "원래": _origin(full, row),
**_body(kind, row), "회사": set(), "사람": set(), "때": None},
) # fmt: skip
one["회사"].add(company)
one["사람"].add(person)
firms.add(company)
people.add(person)
stamp = row.get("때")
if stamp and (one["때"] is None or str(stamp) > str(one["때"])):
one["때"] = stamp
master = {kind: _book(kind, Path(store.FOLDER)) for kind in KINDS}
gone = rejected()
live = []
for (kind, full, sig), one in seen.items():
mine = master[kind].get(full)
if (mine and sign(kind, mine) == sig) or _reject_id(kind, full, sig) in gone:
continue
one["마스터"] = "다름" if mine else None
live.append(one)
many: dict[tuple[str, str], int] = {}
for one in live:
many[(one["종류"], one["열쇠"])] = many.get((one["종류"], one["열쇠"]), 0) + 1
out = []
for one in sorted(live, key=lambda x: (x["종류"], x["열쇠"], -len(x["사람"]), x["서명"])):
clash = many[(one["종류"], one["열쇠"])] > 1 or one["마스터"] == "다름"
out.append({**one, "회사": len(one["회사"]), "사람": len(one["사람"]), "충돌": clash})
return {"후보": out, "모은곳": {"회사": len(firms), "사람": len(people)}, "반려": len(gone)}
def _find(kind: str, full: str, sig: str) -> dict[str, Any]:
if kind not in KINDS:
raise store.StoreError(400, f"종류는 {' · '.join(KINDS)}")
got = next(
(x for x in gather()["후보"] if (x["종류"], x["열쇠"], x["서명"]) == (kind, full, sig)),
None,
)
if got is None:
raise store.StoreError(404, f"후보가 없음(이미 승인 · 반려 · 바뀜) 「{full}」")
return got
# ── 쓰기(마스터 층만) ─────────────────────────────────────────────────
def approve(kind: str, full: str, sig: str, who: Any) -> dict[str, Any]:
"""후보 하나 → 마스터 장부(서버가 다시 모아 찾음 · 맞춤은 `ledger.put` 재검사)."""
one = _find(kind, full, sig)
if kind == ALIAS:
ledger.put(store.FOLDER, {full: {"원래": one["원래"], **_body(kind, one)}}, who)
else:
row = {**_body(kind, one), "누가": who, "때": utc_now_text()}
hopyo_store.remember(store.FOLDER, {full: row}, BILL)
return gather()
def reject(kind: str, full: str, sig: str, who: Any) -> dict[str, Any]:
_find(kind, full, sig)
row = {"종류": kind, "열쇠": full, "서명": sig, "누가": who, "때": utc_now_text()}
hopyo_store.remember(store.FOLDER, {_reject_id(kind, full, sig): row}, REJECT)
return gather()
def master_bill() -> list[dict[str, Any]]:
"""마스터 내역맞춤 줄(보기 · 지움) — 맞춤 장부는 `/api/m02/layers/system/hopyo-ledger`."""
book = _book(BILL, Path(store.FOLDER))
return [
{"열쇠": k, "원래": ledger._plain_of(k), **_body(BILL, v)} for k, v in sorted(book.items())
]
def forget_bill(full: str) -> list[dict[str, Any]]:
if full not in _book(BILL, Path(store.FOLDER)):
raise store.StoreError(404, f"마스터 내역맞춤에 없음 「{full}」")
hopyo_store.remember(store.FOLDER, {full: None}, BILL)
return master_bill()
# ── 길(시스템 관리자만 — main.py 에서 system_admin_only) ───────────────
# ── 길 ───────────────────────────────────────────────────────────────
def _who(session: dict[str, Any]) -> dict[str, Any]:
return {"id": session.get("user_id"), "이름": session.get("name")}
@@ -185,31 +191,44 @@ def _call(fn, *args):
raise HTTPException(error.status, error.detail) from error
class Pick(BaseModel):
종류: str = Field(max_length=10)
열쇠: str = Field(min_length=1, max_length=400)
서명: str = Field(min_length=16, max_length=16)
class AliasBody(BaseModel):
키: str = Field(min_length=1, max_length=20)
별칭: str = Field(max_length=2000)
class Decide(BaseModel):
id: str = Field(min_length=16, max_length=16)
class Suggest(BaseModel):
갈래: str = Field(default="", max_length=20)
키: str = Field(min_length=1, max_length=20)
이름: str = Field(min_length=1, max_length=200)
규격: str = Field(default="", max_length=200)
단위: str = Field(default="", max_length=40)
@router.get("")
def candidates() -> dict[str, Any]:
return {**gather(), "마스터내역": master_bill()}
def get_board() -> dict[str, Any]:
return board()
@router.post("/save")
def post_save(body: AliasBody) -> dict[str, Any]:
return _call(set_alias, body.키, body.별칭)
@router.post("/approve")
def approve_one(body: Pick, session: dict = Depends(verify_session)) -> dict[str, Any]:
return _call(approve, body.종류, body.열쇠, body.서명, _who(session))
def post_approve(body: Decide, session: dict = Depends(verify_session)) -> dict[str, Any]:
return _call(decide, body.id, True, _who(session))
@router.post("/reject")
def reject_one(body: Pick, session: dict = Depends(verify_session)) -> dict[str, Any]:
return _call(reject, body.종류, body.열쇠, body.서명, _who(session))
def post_reject(body: Decide, session: dict = Depends(verify_session)) -> dict[str, Any]:
return _call(decide, body.id, False, _who(session))
class Forget(BaseModel):
열쇠: str = Field(min_length=1, max_length=400)
@router.post("/master-bill/forget")
def forget_one(body: Forget) -> dict[str, Any]:
return {"마스터내역": _call(forget_bill, body.열쇠)}
@suggest_router.post("/suggest")
def post_suggest(body: Suggest, session: dict = Depends(verify_session)) -> dict[str, Any]:
args = (body.키, body.이름, body.규격, body.단위, _who(session))
return _call(suggest, *args)
+19 -2
View File
@@ -283,12 +283,15 @@ def save(batch: list[dict], confirmed: bool = False) -> dict:
"""`/save` — 로직 정본 줄 고침은 `confirmed`(「원문 개정 반영」) 없으면 409(`Store_Revise`) ·
자체 파일 새 줄(키 없음)은 서버가 칸을 잡고 GX 다음 키 — {files, added, warnings}."""
revise.guard(batch, confirmed)
for part in batch: # 별칭(815-2) = 글 그대로 · 앞뒤 빈칸만 뗌 · 빈 글이면 칸 없음
for part in batch:
for ch in part.get("changes") or []:
row = ch.get("row")
if isinstance(row, dict) and "별칭" in row:
if (
isinstance(row, dict) and "별칭" in row
): # 별칭(815-2) = 앞뒤 빈칸만 뗌 · 빈 글 = 칸 없음
text = str(row["별칭"] or "").strip()
row.update(별칭=text) if text else row.pop("별칭")
_keep_same(part)
fresh = False
for part in batch:
if part.get("file") != OWN:
@@ -314,6 +317,20 @@ def save(batch: list[dict], confirmed: bool = False) -> dict:
return {"files": files, "added": added, "warnings": []}
def _keep_same(part: dict) -> None:
"""고친 줄의 값이 같은 칸 = 파일 값 그대로(화면 왕복 「9.0」 → 「9」 막음 · 그 칸만 바뀜)."""
try:
data = store.read(str(part.get("file") or ""))[0]
except store.StoreError:
return
rows = {str(r.get("키")): r for r in data.get(store.items_key(data)) or []}
for ch in part.get("changes") or []:
old, row = rows.get(str(ch.get("key"))), ch.get("row")
if ch.get("op") == "edit" and isinstance(old, dict) and isinstance(row, dict):
same = {k for k, v in row.items() if k in old and store._dec(v) == old[k]}
ch["row"] = {k: old[k] if k in same else v for k, v in row.items()}
def _file_keys(file: str) -> list[str]:
try:
data = store.read(file)[0]
+190 -155
View File
@@ -1,67 +1,55 @@
/* =============================================================================
* M01_MasterData_UI_Alias.ts
* M01 별칭 장부(PLAN 718-3 · 요구서 내역12 4장 23 · 4-1 확정 6) — 본 칸 · 시스템 관리자만.
* · 「모인 후보」 = 모든 회사 · 개인 층 별칭 장부(일위대가 맞춤) + 공사 내역 맞춤을 서버가 모음
* (`/api/m01/alias-ledger` · 718-2) · 이름 · 짝 · 회사 수 · 사람 수(누군지 안 보임) · 충돌 ·
* [승인] = 마스터 장부에 넣음(모든 사용자 제안에 쓰임) · [반려] = 다시 안 보임 · 둘 다 확인 창
* · 「마스터 장부」 = M02 별칭 장부 부품(`buildHopyoLedger` · 마스터 층) 그대로 + 마스터 공사 내역 맞춤 줄 [지움]
* · 자동 반영 없음 — 승인한 것만 모두에게 · 승인 · 반려 · 지움은 누를 때 바로 서버(확인 창 뒤)
* M01 별칭 모아 보기(PLAN 815-2 · 요구 764 · 765) — 본 칸 · 시스템 관리자만.
* · 위 = 「승인 대기 n」 — 일반 사용자 제안(가져온 이름 · 규격 → 우리 요소) · [승인] = 그 요소 별칭에
* 콤마로 더함 · [반려] = 지움(둘 다 확인 창 뒤 바로 서버)
* · 아래 = 별칭이 적힌 줄 전부(인력 · 기계 · 재료 · 유가 · 단가산출 기초 — 수기로 넣은 것도 바로) ·
* 갈래 거름 · 찾기 · 별칭 칸 눌러 고침(콤마로 여러 개) → [저장](그 줄 별칭만 · `/api/m01/alias/save`) ·
* 줄을 누르면 그 요소 화면 그 줄 · 단가산출 기초는 로직 탭
* · 서버 = `M01_MasterData_Alias.py` · 조각 나누기 = 서버(`master_alias.names`)
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { createBadge } from "@ui/ui_template_badge";
import { askConfirm, createButton, el, showToast } from "@ui/ui_template_elements";
import { createInputField } from "@ui/ui_template_input";
import { loadingError, showLoading } from "@ui/ui_template_loading";
import { createPanelHead } from "@ui/ui_template_panel_head";
import { createSegment } from "@ui/ui_template_segment";
import { createTable, type Column } from "@ui/ui_template_table";
import { createTabs } from "@ui/ui_template_tabs";
import "@ui/ui_template_general_layout.css"; // 마스터 장부 부품(M02 Hopyo_Ledger) 표 꼴 — M01 엔 안 실려 있음
import type { LedgerPair } from "../M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api";
import {
buildHopyoLedger,
KIND_TEXT,
LEDGER_HINT,
pairText,
} from "../M02_MasterTemplete/M02_MasterTemplete_Hopyo_Ledger";
import { startEdit } from "./M01_MasterData_UI_Cells";
export const ALIAS_TITLE = "별칭 장부";
const KIND_NAME: Record<string, string> = { 맞춤: "일위대가", 내역맞춤: "공사 내역" };
export const ALIAS_TITLE = "별칭 모아 보기";
const KINDS = ["인력", "기계", "재료", "유가", "단가산출 기초"];
interface Origin {
품명: string;
export interface AliasRow {
갈래: string;
파일: string;
키: string;
이름: string;
규격: string;
별칭: string;
조각: { 글: string; 승인: boolean }[];
들어옴: "수기" | "승인";
}
interface Waiting {
id: string;
갈래: string;
키: string;
이름: string;
규격: string;
단위: string;
별칭: string;
누가?: { 이름?: string } | null;
때?: string;
}
export interface AliasCandidate {
종류: "맞춤" | "내역맞춤";
열쇠: string;
서명: string;
원래: Origin;
갈래?: string;
짝?: LedgerPair[];
확정?: string;
이름?: string;
회사: number;
사람: number;
때: string | null;
충돌: boolean;
마스터: "다름" | null;
}
interface MasterBill {
열쇠: string;
원래: Origin;
확정: string;
이름?: string;
}
export interface AliasBoard {
후보: AliasCandidate[];
모은곳: { 회사: number; 사람: number };
반려: number;
마스터내역?: MasterBill[];
interface Board {
줄: AliasRow[];
대기: Waiting[];
}
async function call<T>(path: string, body?: unknown): Promise<T> {
const res = await fetch(`${API_BASE_URL}/m01/alias-ledger${path}`, {
const res = await fetch(`${API_BASE_URL}/m01/alias${path}`, {
method: body === undefined ? "GET" : "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
@@ -72,72 +60,75 @@ async function call<T>(path: string, body?: unknown): Promise<T> {
return got;
}
/** 짝 글 — 일위대가 = 갈래 · 우리 로직 · 공사 내역 = 우리 공종 이름 */
export const candidateText = (c: AliasCandidate): string =>
c.종류 === "내역맞춤"
? `${c.이름 ?? ""} (${c.확정 ?? ""})`
: c.갈래 === "짝"
? (c.짝 ?? []).map(pairText).join(" + ")
: (KIND_TEXT[c.갈래 ?? ""] ?? c.갈래 ?? "");
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
const pick = (c: AliasCandidate) => ({ 종류: c.종류, 열쇠: c.열쇠, 서명: c.서명 });
const CANDIDATE_COLS: Column[] = [
{ label: "종류", width: "84px" },
{ label: "엑셀 품명" },
{ label: "규격" },
const WAIT_COLS: Column[] = [
{ label: "갈래", width: "96px" },
{ label: "가져온 이름 · 규격" },
{ label: "단위", width: "64px" },
{ label: "짝" },
{ label: "회사", width: "56px", align: "end" },
{ label: "사람", width: "56px", align: "end" },
{ label: "충돌", width: "96px" },
{ label: "우리 요소" },
{ label: "누가 · 때", width: "180px" },
{ label: "", width: "136px" },
];
const BILL_COLS: Column[] = [
{ label: "엑셀 품명" },
{ label: "규격" },
{ label: "단위", width: "64px" },
{ label: "우리 공종" },
{ label: "", width: "72px" },
const ROW_COLS: Column[] = [
{ label: "갈래", width: "96px" },
{ label: "키", width: "96px" },
{ label: "이름 · 규격" },
{ label: "별칭" },
{ label: "들어옴", width: "72px" },
];
export function buildAliasLedger(): HTMLElement {
const root = el("div", { className: "m01-alias", attrs: { "data-alias-ledger": "" } });
const body = el("div", { className: "m01-alias__body" });
let view = "candidates";
/** `open` = 줄을 눌렀을 때(왼쪽 판이 그 요소 화면 · 로직 탭으로) · `setDirty` = 저장 안 한 고침 표시 */
export function buildAliasBoard(opts: {
open: (row: AliasRow) => void;
setDirty?: (dirty: boolean) => void;
}): HTMLElement {
const root = el("div", { className: "m01-alias", attrs: { "data-alias-board": "" } });
const waitHost = el("div");
const listHost = el("div");
const edits = new Map<string, string>();
let board: Board = { 줄: [], 대기: [] };
let kind = "";
let q = "";
async function act(c: AliasCandidate, ok: boolean): Promise<void> {
const name = `${c.원래.품명} ${c.원래.규격}`.trim();
const save = createButton({ label: "저장", variant: "filled", disabled: true });
const undo = createButton({ label: "되돌림", variant: "ghost", disabled: true });
const mark = (): void => {
save.disabled = undo.disabled = edits.size === 0;
opts.setDirty?.(edits.size > 0);
};
async function decide(w: Waiting, ok: boolean): Promise<void> {
const yes = await askConfirm({
title: ok ? "마스터 장부에 승인" : "후보 반려",
title: ok ? "별칭 승인" : "제안 반려",
message: ok
? `「${name}」 → ${candidateText(c)}\n마스터 장부에 넣으면 모든 사용자 제안에 쓰임(각자 장부가 먼저).`
: `「${name}」 → ${candidateText(c)}\n반려하면 같은 짝은 후보에 다시 안 보임.`,
? `「${w.별칭}」 → ${w.키}\n그 요소 별칭에 더함(다음 불러오기부터 이어짐).`
: `「${w.별칭}」 → ${w.키}\n반려하면 제안이 지워짐.`,
okLabel: ok ? "승인" : "반려",
danger: !ok,
});
if (!yes) return;
try {
const got = await call<AliasBoard>(ok ? "/approve" : "/reject", pick(c));
showToast(ok ? "마스터 장부에 넣음" : "반려함", "success");
paintCandidates(got);
board = await call<Board>(ok ? "/approve" : "/reject", { id: w.id });
showToast(ok ? "별칭에 더함" : "반려함", "success");
paint();
} catch (error) {
showToast(`${ok ? "승인" : "반려"} 못 함 — ${why(error)}`, "error");
}
}
function candidateRow(c: AliasCandidate): (string | Node)[] {
const clash = c.마스터 === "다름" ? "마스터와 다름" : c.충돌 ? "다른 짝 있음" : "";
return [
KIND_NAME[c.종류] ?? c.종류,
c.원래.품명,
c.원래.규격,
c.원래.단위,
candidateText(c),
String(c.회사),
String(c.사람),
clash ? createBadge(clash, "warning") : "",
const target = (key: string): string => {
const one = board.줄.find((r) => r.키 === key);
return one ? `${key} · ${one.이름} ${one.규격}`.trim() : key;
};
function paintWaiting(): void {
const rows = board.대기.map((w) => [
w.갈래,
`${w.이름} ${w.규격}`.trim(),
w.단위,
target(w.키),
`${w.누가?.이름 ?? ""} · ${(w.때 ?? "").slice(0, 16).replace("T", " ")}`,
el("div", {
className: "ui-toolbar",
children: [
@@ -145,93 +136,137 @@ export function buildAliasLedger(): HTMLElement {
label: "승인",
variant: "filled",
size: "sm",
onClick: () => void act(c, true),
onClick: () => void decide(w, true),
}),
createButton({
label: "반려",
variant: "ghost",
size: "sm",
onClick: () => void act(c, false),
onClick: () => void decide(w, false),
}),
],
}),
];
}
function paintCandidates(board: AliasBoard): void {
const head = createPanelHead({
title: "모인 후보",
sub: `${LEDGER_HINT} · 회사 ${board.모은곳.회사} · 사람 ${board.모은곳.사람}의 장부에서 모음 · 반려 ${board.반려} · 승인한 것만 모두에게 · 누가 적었는지는 안 보임`,
});
body.replaceChildren(
head.root,
createTable(CANDIDATE_COLS, board.후보.map(candidateRow), {
density: "data",
empty: "모인 후보 없음",
}),
);
}
async function forget(b: MasterBill): Promise<void> {
const yes = await askConfirm({
title: "마스터 공사 내역 맞춤 지움",
message: `「${b.원래.품명} ${b.원래.규격}」 → ${b.이름 ?? b.확정}\n지우면 모든 사용자 제안에서 빠짐.`,
okLabel: "지움",
danger: true,
});
if (!yes) return;
try {
const got = await call<{ 마스터내역: MasterBill[] }>("/master-bill/forget", { 열쇠: b.열쇠 });
showToast("지움", "success");
paintMaster(got.마스터내역);
} catch (error) {
showToast(`지우지 못 함 — ${why(error)}`, "error");
}
}
function paintMaster(bill: MasterBill[]): void {
const rows = bill.map((b) => [
b.원래.품명,
b.원래.규격,
b.원래.단위,
`${b.이름 ?? ""} (${b.확정})`,
createButton({ label: "지움", variant: "danger", size: "sm", onClick: () => void forget(b) }),
]);
body.replaceChildren(
buildHopyoLedger({ scope: { layer: "system", owner: null }, canWrite: true }),
waitHost.replaceChildren(
createPanelHead({
title: "마스터 공사 내역 맞춤",
sub: "B09 내역 줄 ↔ 우리 공종(각자 기억이 먼저)",
title: `승인 대기 ${board.대기.length}`,
sub: "M02 에서 못 이은 줄을 사용자가 우리 요소로 고른 제안 · 승인 = 그 요소 별칭에 더함",
}).root,
createTable(BILL_COLS, rows, { density: "data", empty: "마스터 공사 내역 맞춤 없음" }),
createTable(WAIT_COLS, rows, { density: "data", empty: "승인 대기 없음" }),
);
}
function aliasCell(r: AliasRow): HTMLElement {
const box = el("div", { className: "ui-toolbar", attrs: { "data-alias-cell": r.키 } });
const edited = edits.get(r.키);
if (edited !== undefined) box.append(createBadge(edited || "(지움)", "warning"));
else for (const p of r.조각) box.append(createBadge(p.글, p.승인 ? "success" : "neutral"));
box.title = "눌러 고침 — 콤마로 여러 개";
box.addEventListener("click", (e) => {
e.stopPropagation();
startEdit(box, edited ?? r.별칭, (text) => {
if (text.trim() === r.별칭.trim()) edits.delete(r.키);
else edits.set(r.키, text.trim());
mark();
paintList();
});
});
return box;
}
function paintList(): void {
const words = q.toLowerCase().split(/\s+/).filter(Boolean);
const shown = board.줄.filter(
(r) =>
(!kind || r.갈래 === kind) &&
words.every((w) => `${r.키} ${r.이름} ${r.규격} ${r.별칭}`.toLowerCase().includes(w)),
);
const table = createTable(
ROW_COLS,
shown.map((r) => [
r.갈래,
r.키,
`${r.이름} ${r.규격}`.trim(),
aliasCell(r),
createBadge(r.들어옴, r.들어옴 === "승인" ? "success" : "neutral"),
]),
{ density: "data", empty: "별칭이 적힌 줄 없음" },
);
table.querySelectorAll("tbody tr").forEach((tr, i) => {
const r = shown[i];
if (!r) return;
tr.setAttribute("data-key", r.키);
tr.addEventListener("click", () => opts.open(r));
});
listHost.replaceChildren(table);
}
function paint(): void {
paintWaiting();
paintList();
}
async function load(): Promise<void> {
const stop = showLoading(body);
const stop = showLoading(listHost);
try {
const got = await call<AliasBoard>("");
if (view === "candidates") paintCandidates(got);
else paintMaster(got.마스터내역 ?? []);
board = await call<Board>("");
paint();
} catch (error) {
body.replaceChildren(loadingError(why(error), () => void load()));
listHost.replaceChildren(loadingError(why(error), () => void load()));
} finally {
stop();
}
}
const tabs = createTabs({
tabs: [
{ key: "candidates", label: "모인 후보" },
{ key: "master", label: "마스터 장부" },
],
value: view,
onChange: (key) => {
view = key;
void load();
save.addEventListener("click", async () => {
save.disabled = true;
try {
for (const [key, text] of [...edits]) {
board = await call<Board>("/save", { 키: key, 별칭: text });
edits.delete(key);
}
showToast("별칭 저장함", "success");
} catch (error) {
showToast(`저장 못 함 — ${why(error)}`, "error");
} finally {
mark();
paint();
}
});
undo.addEventListener("click", () => {
edits.clear();
mark();
paintList();
});
const kinds = createSegment({
options: [{ value: "", label: "전체" }, ...KINDS.map((k) => ({ value: k, label: k }))],
value: kind,
onChange: (v) => {
kind = v;
paintList();
},
ariaLabel: "갈래",
});
const find = createInputField({
type: "search",
placeholder: "키 · 이름 · 별칭 찾기",
compact: true,
onInput: (v) => {
q = v.trim();
paintList();
},
});
root.append(tabs.root, body); // 제목 = 본 칸 머리(ALIAS_TITLE · 왼쪽 판이 넘김)
const listHead = createPanelHead({
title: "별칭이 적힌 줄",
sub: "수기로 넣은 것 · 승인한 것 모두 · 줄을 누르면 그 요소 화면 · 별칭 칸을 눌러 고친 뒤 [저장]",
});
root.append(
waitHost,
listHead.root,
el("div", { className: "ui-toolbar", children: [kinds.root, find.root, undo, save] }),
listHost,
);
void load();
return root;
}
+1 -1
View File
@@ -2,7 +2,7 @@
* M01_MasterData_UI_Page.ts
* 마스터 요소 화면 — 좌측 도킹 패널(컨테이너: 인력·재료·기계·소요량·계수·환율·요율·단가산출 로직)
* · 일위대가 조합은 M02 마스터 템플릿(PLAN 27-2b) · 구조물 옵션 정의(716-1 · 화면은 M02 `StructOptions/`)
* / 우측 요소 표 · 표 목록 · 로직 편집 · 옵션 정의 · 별칭 장부(718-3) ·
* / 우측 요소 표 · 표 목록 · 로직 편집 · 옵션 정의 · 별칭 모아 보기(815-2) ·
* 주소 `?open=로직|옵션|별칭` = 그 칸을 고른 채 열림(716-2)
*
* 시스템 관리자만. 고친 것은 초안(sessionStorage)에 쌓이고 [저장] 한 번에 서버로 —
+15 -4
View File
@@ -3,7 +3,7 @@
* 왼쪽 패널 — 공용 접기 컨테이너(`ui-collapsible ui-sidebar-section`)
* 인력 · 재료 · 기계 · 소요량 · 계수 · 환율 · 요율 · 단가산출 로직(일위대가 조합은 M02 · PLAN 27-2b)
* ─ 구분선 ─ 구조물 옵션 정의(716-1 · 화면 코드는 M02 `StructOptions/` 그대로 · 입구만 여기)
* · 별칭 장부(718-3 · 누르면 본 칸에 모인 후보 · 마스터 장부 · `M01_MasterData_UI_Alias`)
* · 별칭 모아 보기(815-2 · 누르면 본 칸에 별칭이 적힌 줄 · 승인 대기 · `M01_MasterData_UI_Alias`)
* 펼치면 하위 목록(거름 · 파일) · 환율/요율은 누르면 바로 표 · 로직은 안에 로직 목록
* ========================================================================== */
@@ -15,7 +15,7 @@ import {
buildOptionsSection,
type OptionsSectionOptions,
} from "../M02_MasterTemplete/StructOptions/M02_SO_Options";
import { ALIAS_TITLE, buildAliasLedger } from "./M01_MasterData_UI_Alias";
import { ALIAS_TITLE, buildAliasBoard, type AliasRow } from "./M01_MasterData_UI_Alias";
import { fetchFiles, fetchSubsAll, type FileInfo, type SubInfo } from "./M01_MasterData_Api_Fetch";
import { tx } from "./M01_MasterData_UI_Logic_Text";
import { renderTree, type MakeRow } from "./M01_MasterData_UI_Tree";
@@ -504,13 +504,24 @@ ${first.file}`
// 구조물 옵션 정의 — 로직 칸 아래 구분선 + 칸 하나(시스템 관리자 화면이라 고침 가능)
const options = buildOptionsSection({ ...optionsOpt, isAdmin: true });
const optionsSec = section(options.title, options.body);
// 별칭 장부(718-3) — 옵션 칸 아래 칸 하나 · 누르면 본 칸(옵션 정의와 같은 자리)
// 별칭 모아 보기(815-2) — 옵션 칸 아래 칸 하나 · 누르면 본 칸(옵션 정의와 같은 자리)
const aliasSec = section(ALIAS_TITLE, null);
aliasSec.classList.add("m01-side__leaf");
buttons.set(ALIAS_ID, aliasSec);
/** 모아 보기 줄 → 그 요소 화면 그 줄 · 단가산출 기초는 로직 탭 */
const openAliasRow = (row: AliasRow): void => {
if (row.갈래 === "단가산출 기초") {
setPendingLogicKey(row.키);
return reveal("로직");
}
const group = row.갈래 === "유가" ? FUEL_GROUP : row.갈래;
jumpToMaster({ kind: "element", group, ref: row.키, file: row.파일 });
};
const openAlias = async (): Promise<void> => {
if (!(await optionsOpt.confirmLeave())) return;
optionsOpt.show(ALIAS_TITLE, buildAliasLedger());
let setDirty: (dirty: boolean) => void = () => {};
const board = buildAliasBoard({ open: openAliasRow, setDirty: (d) => setDirty(d) });
setDirty = optionsOpt.show(ALIAS_TITLE, board);
options.clear();
setActive(ALIAS_ID);
};
-696
View File
@@ -1,696 +0,0 @@
"""가져온 식 ↔ 우리 M01 기초 연결 찾기(PLAN 810-4 · 요구 707 · 709 · 703) — 서버 단독.
가져온 식 문서(원파일 확정 정보 · 우리 단가로 셈) 하나를 M01 수식 문서 전부와 맞대어
「확정」 · 「근접」 · 「없음」 을 냄 — 지어 맞추기 없음(값 보정 · 허용 오차 없음 · 원 단위 같아야 확정).
① 후보 = 결과단위 같음 + 노임 키 ⊂ M01 노임 키 + 재료 · 기계 있으면 M01 에도 ·
품셈 절 번호(가져온 「추정」 글 · M01 `출처`)가 겹치는 문서가 있으면 그것만.
② 옵션 풀이 = 그 문서의 선택 · 입력 변수 값을 줄마다 좁힘(줄 수량 · 단가가 의존하는 변수만 돌림 ·
값이 가져온 줄 수량 · 단가 묶음에 있거나 0(꺼진 줄)인 값만 남김) → 남은 조합(상한 `CAP_ALL`)을
문서 셈 · 입력 변수 후보 = 기본값 · 0 · 가져온 식에 나온 수 · 선택은 가져온 글에 든 값부터.
③ 판정 = 줄 대조 = 로직(812-2 · 요구 712 — `M02_Doc_Match_Note`): 다른 항 = 자원 · 기종 · 단위 환산 ·
비목 · 율 구조(대상 · 비목) · 줄 없음 · 이름 · 참고 = 단가 · 끝수 · 율 값 % · 같은 기종 규격 · 수량
(값 — 확정을 안 막음 · 812-11 `M02_Doc_Match_Value`) — 다른 항 0 = 「확정」 · 같은 줄이 하나 넘고
핵심 줄 반 넘게 짝 = 「근접」(다른 항 목록) · 그 밖 「없음」 · 값이 맞는 조합 없으면 기본 옵션 + 「옵션 확인」.
⓪ 기계 한 묶음 + 작업량 식 수량 = 「기계 작업」(우리 기초에 안 붙임 · 요구 728 · 735-4).
결과 `{판정, 키, 이름, 옵션, 다른, 참고, 바꿈, 금액 {가져온, 우리}, 후보수}` = 가져온 식 원본 `연결후보` ·
`바꿈` = [바꾸기] 때 갈아 끼울 기계 줄 [{기호(우리 단가 기호), 기계(가져온 원문번호), 이름, 규격}].
"""
from __future__ import annotations
import functools
import itertools
import json
import re
from decimal import Decimal, InvalidOperation
from typing import Any
from M02_MasterTemplete import M02_Doc_Copy as dc
from M02_MasterTemplete import M02_Doc_Match_Note as note
from M02_MasterTemplete import M02_Doc_Match_Value as val
md, mc, ml = dc.md, dc.mc, dc.ml
if not hasattr(
md.parse, "cache_info"
): # 식 글 → 나무 = 순수 함수 · 조합마다 같은 글을 다시 풀지 않음
md.parse = functools.lru_cache(maxsize=50000)(md.parse)
KEY = "연결후보"
SURE, NEAR, NONE = "확정", "근접", "없음"
CAP_LINE = 3000 # 줄 하나 좁힐 때 돌리는 조합 상한(넘으면 그 줄은 좁힘에 안 씀)
CAP_ALL = 192 # 문서 하나 끝 셈 조합 상한
CAP_INPUT = 12 # 입력 변수 후보 수 상한
NICE = (2, 3, 4, 5, 6, 8, 10, 12, 100, 1000) # 단위 환산으로 보는 배
_SEC = re.compile(r"(?<![\d-])(\d{1,2}(?:-\d{1,2}){1,3})(?![\d-])")
_NUMS = re.compile(r"\d+(?:\.\d+)?")
_UNIT = {"m3": "㎥", "m2": "㎡", "m": "m", "ea": "개", "톤": "ton", "t": "ton", "hr": "시간"}
# ── 수 ────────────────────────────────────────────────────────────────
def _key(v: Any) -> Any:
"""비교 열쇠 — 수 = 유효 12 자리(Decimal 셈 자리 차이만 지움) · 묶음 = 비목 튜플."""
if isinstance(v, dict):
return tuple(_key(v.get(k)) for k in md.COSTS)
if isinstance(v, Decimal):
try:
return v.normalize() if v == 0 else Decimal(f"{v:.12g}").normalize()
except InvalidOperation:
return None
return v
def unit_of(text: Any) -> str:
"""결과단위 「원/㎡」 → 「㎡」(소문자 · m3 → ㎥ 등) · 돈 아닌 단위 = ""."""
text = str(text or "").strip()
if not text.startswith("원/"):
return ""
unit = text[2:].strip().lower().replace(" ", "")
return _UNIT.get(unit, unit)
def sections(texts: list[str]) -> set[str]:
"""품셈 절 번호(「6-3-1」) — 앞 둘 · 셋 마디 모두."""
out = set()
for t in texts:
for s in _SEC.findall(str(t or "")):
parts = s.split("-")
out |= {"-".join(parts[:n]) for n in range(2, len(parts) + 1)}
return out
# ── 줄 사실 ───────────────────────────────────────────────────────────
def _single(text: Any) -> str | None:
try:
node = md.parse(text)
except md.DocError:
return None
return node[1] if node[0] == "var" else None
def _what(doc: dict, line: dict) -> tuple[str, str]:
"""금액 줄 자원 → (갈래, 코드) · 갈래 = 노임 · 재료 · 기계 · 산출 · 율 · 수."""
sym = _single(line.get("단가"))
var = next((v for v in doc.get("변수") or [] if sym and md.var_id(v) == sym), None)
if var is None:
return "율", ""
link = var.get("연결") or {}
kind, key = link.get("갈래"), str(link.get("키") or "")
if kind == "요소":
if key.startswith("LB"):
return "노임", key
if key.startswith("EQ"):
return "기계", key
return "재료", key
if kind == "단가산출":
given = link.get("입력") or {}
code = str(given.get("기계") or "").strip("'")
return ("기계", code) if "기계" in given else ("산출", key)
if kind == "숫자" and isinstance(link.get("값"), dict):
return "산출", ""
if kind == "식":
return "율", ""
return "수", ""
def facts(doc: dict, got: dict) -> list[dict]:
"""셈 답 줄 → 대조 사실 — 소계 보임(비목 없는 합 밖) · 꺼진 줄(수량 0 · 금액 0) 뺌."""
out = []
money = doc.get("금액") or []
for i, (line, row) in enumerate(zip(money, got.get("줄") or [])):
if line.get("합밖") and not line.get("비목"):
continue
if row.get("금액") is not None and row["금액"] == 0 and row.get("수량") == 0:
continue
kind, code = _what(doc, line)
if kind == "기계" and code in (got.get("값") or {}): # 옵션 기호 = 그 값(기계 코드)
code = mc.plain_text(got["값"][code])
if kind == "율" or (kind == "수" and line.get("수량") in (None, "")):
kind = "율"
out.append(
{
"번": i,
"이름": str(line.get("이름") or ""),
"갈래": kind,
"코드": code,
"수량": _key(row.get("수량")) if kind != "율" else None,
"단가": _key(row.get("단가")) if kind != "율" else None,
"비목": {k: _key(v) for k, v in (row.get("비목") or {}).items() if v},
"금액": _key(row.get("금액")),
"합밖": bool(row.get("합밖")),
"기호": _single(line.get("단가")),
"규격": str(line.get("규격") or ""),
"수량글": str(line.get("수량") or ""),
}
)
val.mark_combo(doc, out)
note.rates(doc, got, out)
return out
def resources(doc: dict) -> dict[str, set]:
"""문서가 쓸 수 있는 자원(옵션 무관) — {노임 키, 재료, 기계}."""
out: dict[str, set] = {"노임": set(), "재료": set(), "기계": set()}
for line in doc.get("금액") or []:
kind, code = _what(doc, line)
if kind == "노임":
out["노임"].add(code)
elif kind == "재료":
out["재료"].add(code or "고름")
elif kind in ("기계", "산출"):
out["기계"].add(code or kind)
return out
# ── 연결 기억 ─────────────────────────────────────────────────────────
class Memo:
"""M01 연결 + 셈 기억(요소 · 표 · 부른 단가산출) — 같은 값을 조합마다 다시 찾지 않음."""
def __init__(self, links: Any):
self.links, self.hits, self.calls = links, {}, {}
def _hit(self, tag: str, p: dict, fn: Any) -> Any:
key = (tag, json.dumps(p, sort_keys=True, ensure_ascii=False, default=str))
if key not in self.hits:
try:
self.hits[key] = (True, fn(p))
except (mc.Gap, mc.Broken) as e:
self.hits[key] = (False, e)
ok, got = self.hits[key]
if not ok:
raise got
return got
def element(self, p: dict) -> Any:
return self._hit("요소", p, self.links.element)
def table(self, p: dict) -> Any:
return self._hit("표", p, self.links.table)
def doc(self, key: str) -> dict | None:
return self.links.doc(key)
# ── ① 후보 ────────────────────────────────────────────────────────────
class Index:
"""M01 수식 문서 색인(결과단위 · 자원 · 절) — 프로세스 안 한 번."""
def __init__(self, docs: dict[str, dict]):
self.docs = docs
self.by_unit: dict[str, list[str]] = {}
self.res: dict[str, dict[str, set]] = {}
self.secs: dict[str, set[str]] = {}
self.regions: set[str] = set()
for key, doc in docs.items():
for v in doc.get("변수") or []:
if str(v.get("열쇠") or "").endswith("지역"):
self.regions |= {str(x) for x in (v.get("연결") or {}).get("목록") or []}
unit = unit_of(doc.get("결과단위"))
if not unit or not doc.get("금액"):
continue
self.by_unit.setdefault(unit, []).append(key)
self.res[key] = resources(doc)
self.secs[key] = sections([doc.get("출처")])
def candidates(self, doc: dict) -> list[str]:
mine = resources(doc)
keys = []
for key in self.by_unit.get(unit_of(doc.get("결과단위")), []):
have = self.res[key]
if not mine["노임"] <= have["노임"]:
continue
if mine["재료"] and not have["재료"]:
continue
if mine["기계"] and not have["기계"]:
continue
if not (mine["노임"] or mine["재료"] or mine["기계"]):
continue
keys.append(key)
secs = sections([x.get("글") for x in (doc.get("가져온") or {}).get("추정") or []])
hit = [k for k in keys if secs & self.secs[k]]
return hit or keys
# ── ② 옵션 풀이 ───────────────────────────────────────────────────────
def _closure(doc: dict) -> dict[str, set[str]]:
"""기호 → 그 기호가 (거쳐서) 부르는 이름 전부."""
direct: dict[str, set[str]] = {}
for v in doc.get("변수") or []:
direct[md.var_id(v)] = set(md.link_names(v.get("연결") or {}))
for line in doc.get("식") or []:
try:
direct[str(line.get("기호"))] = set(md.names(md.parse(line.get("식"))))
except md.DocError:
direct[str(line.get("기호"))] = set()
memo: dict[str, set[str]] = {}
def reach(name: str, seen: tuple = ()) -> set[str]:
if name in memo:
return memo[name]
out = set()
for n in direct.get(name, ()):
if n not in seen:
out |= {n} | reach(n, (*seen, name))
memo[name] = out
return out
return {n: reach(n) for n in direct}
def _uses(text: Any, close: dict) -> set[str] | None:
try:
names = md.names(md.parse(text)) if text not in (None, "") else []
except md.DocError:
return None
out = set(names)
for n in names:
out |= close.get(n, set())
return out
def numbers_of(doc: dict) -> list[Decimal]:
"""가져온 식에 나온 수(숫자 변수 · 식 · 수량 글 · 규격 · 이름)."""
got: list[Decimal] = []
texts = [doc.get("이름"), (doc.get("가져온") or {}).get("규격")]
for v in doc.get("변수") or []:
val = (v.get("연결") or {}).get("값")
if not isinstance(val, dict):
texts.append(val)
texts += [x.get("식") for x in doc.get("식") or []]
texts += [x.get("수량") for x in doc.get("금액") or []]
texts += [x.get("규격") for x in doc.get("금액") or []]
for t in texts:
for s in _NUMS.findall(str(t or "")):
d = Decimal(s).normalize()
if d not in got:
got.append(d)
return got
def _words(doc: dict) -> str:
"""가져온 식 글(이름 · 규격 · 줄 규격 · 부름 입력 값 — 줄 이름 뺌) — 선택 값의 글 근거."""
bits = [doc.get("이름"), (doc.get("가져온") or {}).get("규격")]
bits += [x.get("규격") for x in doc.get("금액") or []]
for v in doc.get("변수") or []:
bits += [str(x) for x in ((v.get("연결") or {}).get("입력") or {}).values()]
return " ".join(str(b or "") for b in bits).replace(" ", "").replace(",", "")
def _bare(o: Any) -> str:
return str(o).replace(" ", "").replace(",", "") # 「D1,800」 = 글 「D1800」
def domains(
doc: dict, imported: dict, regions: set[str] = frozenset()
) -> tuple[dict[str, list], dict[str, set]]:
"""선택 · 입력 변수 → (돌릴 값, 글 근거) — 기본 먼저 · 가져온 글에 든 선택 값(근거) 먼저 ·
글 입력 지역 = 가져온 글에 든 지역 이름."""
words, nums = _words(imported), numbers_of(imported)
out: dict[str, list] = {}
ev: dict[str, set] = {}
for v in doc.get("변수") or []:
link = v.get("연결") or {}
kind, base = link.get("갈래"), link.get("값")
if kind == "선택":
opts = list(link.get("목록") or [])
seen = [o for o in opts if _bare(o) in words and len(str(o)) > 1]
seen.sort(key=lambda o: -len(str(o)))
if seen:
ev[md.var_id(v)] = set(seen)
rest = [o for o in opts if o not in seen]
first = [base] if base in rest else []
out[md.var_id(v)] = seen + first + [o for o in rest if o != base]
elif kind == "입력":
try:
d = Decimal(str(base).replace(",", "")).normalize()
except (InvalidOperation, ValueError):
if str(v.get("열쇠") or "").endswith("지역"):
seen = sorted((r for r in regions if len(r) > 1 and r in words), key=len)
out[md.var_id(v)] = list(dict.fromkeys([base, *seen[::-1]]))
continue # 그 밖 글 입력 = 기본 그대로
low, high = (mc.number(x) for x in (link.get("범위") or [None, None]))
vals = [d, Decimal(0)] + nums
vals = [x for x in vals if (low is None or x >= low) and (high is None or x <= high)]
out[md.var_id(v)] = list(dict.fromkeys(vals))[:CAP_INPUT]
return out, ev
def _given(doc: dict, pick: dict) -> dict:
ids = {md.var_id(v): v.get("열쇠") or md.var_id(v) for v in doc.get("변수") or []}
return {ids[k]: v for k, v in pick.items()}
def _eval(doc: dict, links: Any, pick: dict, text: Any, memo: dict) -> Any:
run = mc._Run(doc, links, _given(doc, pick), (str(doc.get("키")),), links.calls)
got = run.expr(text, "줄")
if isinstance(got, Decimal) and got == 0:
return 0
return _key(got) if isinstance(got, (Decimal, dict)) else None
def narrow(
doc: dict, links: Any, dom: dict, want: dict[str, set], ev: dict[str, set] | None = None
) -> dict[str, list]:
"""줄마다 의존 변수만 돌려 가져온 값(수량 · 단가) · 0 에 닿는 값만 남김(두 바퀴) ·
글 근거 값(`ev`)은 안 지움(값이 달라도 그 조합을 셈 — 다른 항으로 드러냄)."""
ev = ev or {}
close = _closure(doc)
memo: dict = {}
base = {k: vs[0] for k, vs in dom.items()}
jobs = []
for line in doc.get("금액") or []:
for slot, field in (("수량", "수량"), ("단가", "단가")):
uses = _uses(line.get(field), close)
if uses is None or uses & (set(md.MONEY_NAMES) | _money_syms(doc)):
continue
free = sorted(u for u in uses if u in dom)
if free:
jobs.append((line.get(field), free, want[slot]))
for _ in range(2):
for text, free, targets in jobs:
space = [dom[f] for f in free]
if _size(space) > CAP_LINE:
continue
ok: dict[str, set] = {f: set() for f in free}
for combo in itertools.product(*space):
pick = {**base, **dict(zip(free, combo))}
got = _eval(doc, links, pick, text, memo)
if got == 0 or got in targets:
for f, c in zip(free, combo):
ok[f].add(c)
if all(ok.values()):
for f in free:
ok[f] |= ev.get(f, set())
dom[f] = [x for x in dom[f] if x in ok[f]]
base[f] = dom[f][0]
return dom
def _money_syms(doc: dict) -> set[str]:
return {str(x["기호"]) for x in doc.get("금액") or [] if x.get("기호")}
def _size(space: list[list]) -> int:
n = 1
for s in space:
n *= max(len(s), 1)
return n
def combos(doc: dict, dom: dict) -> list[dict]:
"""좁힌 값 조합(기본 쪽 먼저 · 상한 `CAP_ALL`) — 한 값뿐인 변수는 그 값."""
keys = [k for k, v in dom.items() if v]
out = []
for combo in itertools.product(*(dom[k] for k in keys)):
out.append(dict(zip(keys, combo)))
if len(out) >= CAP_ALL:
break
return out
# ── ③ 줄 대조 ─────────────────────────────────────────────────────────
def _ratio(a: Any, b: Any) -> str | None:
if not isinstance(a, Decimal) or not isinstance(b, Decimal) or not a or not b:
return None
for n in NICE:
if _key(a * n) == b:
return f"× {n}"
if _key(b * n) == a:
return f"÷ {n}"
return None
def compare(mine: list[dict], ours: list[dict]) -> dict[str, Any]:
"""가져온 줄(mine) ↔ M01 줄(ours) → {같음(로직 같은 줄), 짝, 핵심, 다른 [{항, 글}], 참고 [{항, 글}]}."""
left, right = list(mine), list(ours)
same = paired = 0
diffs: list[dict] = []
def take(test) -> list[tuple[dict, dict]]:
hits = []
for a in list(left):
b = next((x for x in right if test(a, x)), None)
if b is not None:
left.remove(a)
right.remove(b)
hits.append((a, b))
return hits
def core(a: dict, b: dict) -> bool:
return a["갈래"] != "율" and b["갈래"] != "율"
def cls(a: dict, b: dict) -> bool:
return a["갈래"] == b["갈래"] or {a["갈래"], b["갈래"]} <= {"기계", "산출"}
notes: list[dict] = []
exact = take(
lambda a, b: (
core(a, b)
and a["수량"] == b["수량"]
and a["단가"] == b["단가"]
and a["비목"] == b["비목"]
and (a["코드"] == b["코드"] or not (a["코드"] and b["코드"])) # 코드 다름 = 짝 대조
and val.same_combo(a, b)
)
)
same += len(exact)
def twin(a: dict, b: dict) -> bool:
return core(a, b) and cls(a, b) and a["수량"] == b["수량"]
for a, b in take(lambda a, b: twin(a, b) and not note.pair(a, b)[0]): # 로직 같은 짝 먼저
same += 1
notes += note.pair(a, b)[1]
for a, b in take(twin):
bad, ref = note.pair(a, b)
same, paired = (same + 1, paired) if not bad else (same, paired + 1)
diffs += bad
notes += ref
bad, ref, n = val.pairs(take, core, cls, note.pair, _ratio) # 같은 자원 수량 다름 = 값(812-11)
same, paired, diffs, notes = same + n, paired + len(bad), diffs + bad, notes + ref
for a, b in take(lambda a, b: core(a, b) and cls(a, b) and a["단가"] == b["단가"]):
paired += 1
how = _ratio(a["수량"], b["수량"])
what = "단위 환산" if how else "수량"
say = {"항": what, "글": f"{a['이름']} 수량 {a['수량']} ↔ {b['수량']} {how or ''}"}
diffs += note.pair(a, b)[0] or [say] # 같은 자원 아님 = 그 로직 까닭(비목 · 자원)
for a, b in take(lambda a, b: core(a, b) and a["갈래"] == b["갈래"] == "기계"):
paired += 1
diffs.append(
{"항": "기계 규격", "글": f"{a['이름']} {a['코드']} ↔ {b['코드']} · 수량 다름"}
)
rate_diffs, rate_notes = note.rate_pairs(left, right, take)
diffs += rate_diffs
notes += rate_notes
for side, rows in (("가져온 쪽만", left), ("우리 쪽만", right)):
for x in rows:
if x["갈래"] == "율":
what = "공구손료 유무" if "공구" in x["이름"] else "율 줄"
else:
what = "줄 없음"
diffs.append({"항": what, "글": f"{side} — {x['이름']}"})
n_core = sum(1 for a in mine if a["갈래"] != "율")
swaps = [x.pop("바꿈") for x in notes if "바꿈" in x]
return {
"같음": same,
"짝": same + paired,
"핵심": n_core,
"다른": diffs,
"참고": notes,
"바꿈": swaps,
}
def _totals(got: dict) -> dict:
return {k: _key(mc.number(got.get(k))) for k in md.BUNDLE}
EXPLAINED = (
"이름 다름",
"자원",
"산출 단가",
"율 대상",
"단위 환산",
"기계 규격",
"비목",
"율 줄",
"공구손료 유무",
)
def verdict(
mine: list[dict], ours: list[dict], got_a: dict, got_b: dict, off: list[dict] = ()
) -> dict:
"""확정 = 다른 항 0(옵션 글 근거까지 맞음 · 참고는 있어도 됨) · 근접 = 핵심 줄 반 넘게 짝 + (같은 줄
있음 또는 다른 항이 모두 까닭 있는 갈래 — 자원 · 단위 환산 · 기계 규격 · 비목 · 율) · 그 밖 없음 ·
끝수 = 줄 로직 · 단가 모두 같은데 합만 다름(참고)."""
res = compare(mine, ours)
res["다른"] = [*off, *res["다른"]]
ta, tb = _totals(got_a), _totals(got_b)
if not res["다른"] and not res["참고"] and ta != tb:
res["참고"].append(
{"항": "끝수", "글": f"줄은 같고 합이 다름 {_sum_txt(ta)} ↔ {_sum_txt(tb)}"}
)
if not res["다른"] and res["같음"]:
res["판정"] = SURE
elif res["짝"] * 2 >= res["핵심"] + 1 and (
res["같음"] or all(d["항"] in EXPLAINED for d in res["다른"])
):
res["판정"] = NEAR
else:
res["판정"] = NONE
return res
def _sum_txt(t: dict) -> str:
return " · ".join(f"{k} {note.txt(v)}" for k, v in t.items() if v is not None)
# ── 한 문서 ───────────────────────────────────────────────────────────
def _calc(doc: dict, links: Any, given: dict) -> dict | None:
try:
got = mc.calc(doc, links, given, (), getattr(links, "calls", None))
except Exception: # noqa: BLE001 — 못 셈 = 그 조합 버림
return None
return got if got.get("셈됨") else None
def try_doc(
base: dict, links: Any, imported: dict, got_imp: dict, mine: list[dict], regions: set[str]
) -> dict:
"""M01 문서 하나 — 옵션 풀이 · 가장 나은 조합 판정(이름 · 절 근거 없으면 「이름 다름」 = 확정 아님)."""
want = {
"수량": {x["수량"] for x in mine if x["수량"] is not None},
"단가": {x["단가"] for x in mine if x["단가"] is not None},
}
dom, ev = domains(base, imported, regions)
dom = narrow(base, links, dom, want, ev)
named = [] if same_name(imported, base) else [_unnamed(imported, base)]
best: dict | None = None
ties: list[tuple] = []
for pick in combos(base, dom) or [{}]:
got = _calc(base, links, _given(base, pick))
if got is None:
continue
off = [_off(base, k, pick[k], ev[k]) for k in ev if k in pick and pick[k] not in ev[k]]
off += named
res = verdict(mine, facts(base, got), got_imp, got, off)
values = sum(1 for x in res["참고"] if x["항"] in val.VALUE) if res["판정"] == SURE else 0
rank = (
res["판정"] == SURE,
-len(off),
-values, # 값 참고(규격 · 수량)가 적은 조합 먼저(812-11)
res["같음"],
res["짝"],
-len(res["다른"]),
-len(res["참고"]),
)
if best is None or rank > best["순위"]:
best = {**res, "순위": rank, "옵션": pick, "금액": _totals(got)}
ties.append((rank, pick))
if res["판정"] == SURE and not res["참고"]:
break
if best and best["판정"] == SURE and any(x["항"] in val.VALUE[1:] for x in best["참고"]):
same = [p for r, p in ties if r == best["순위"]] # 값으로 못 가른 조합(요구 733 ㉢)
best["참고"] += val.option_check(base, best["옵션"], same, _qty_vars(base))
return best or {
"판정": NONE,
"같음": 0,
"짝": 0,
"다른": [],
"참고": [],
"순위": (False, -9, 0, 0, 0, 0, 0),
}
def _qty_vars(doc: dict) -> set[str]:
"""금액 줄 수량이 (거쳐서) 부르는 변수."""
close = _closure(doc)
return set().union(*(_uses(x.get("수량"), close) or set() for x in doc.get("금액") or []))
def _off(doc: dict, vid: str, value: Any, seen: set) -> dict:
"""옵션 값이 가져온 글 근거와 다름 — 다른 항 「옵션 글」."""
var = next((v for v in doc.get("변수") or [] if md.var_id(v) == vid), {})
name = var.get("이름") or vid
return {"항": "옵션 글", "글": f"{name} 글 {'·'.join(map(str, seen))} ↔ {value}"}
def match(imported: dict, links_imp: Any, index: Index, links: Any) -> dict[str, Any]:
"""가져온 식 문서 → 연결후보 `{판정, 키, 이름, 옵션, 다른, 참고, 금액, 후보수}`."""
links = links if isinstance(links, Memo) else Memo(links)
imported = note.bare(imported) # 엑셀 이름 규격 · 단위 꼬리 뗌(814-1 · 맞대기만)
got_imp = _calc(imported, links_imp, {})
if got_imp is None:
return {"판정": NONE, "까닭": "가져온 식을 우리 단가로 못 셈", "후보수": 0}
mine = facts(imported, got_imp)
alone = val.work(imported, mine, note._names)
keys = index.candidates(imported)
best, best_key = None, None
for key in keys:
res = try_doc(index.docs[key], links, imported, got_imp, mine, index.regions)
if best is None or res["순위"] > best["순위"]:
best, best_key = res, key
if res["판정"] == SURE and not res["참고"]:
break
exact = best and best["판정"] == SURE and not {x["항"] for x in best["참고"]} & set(val.CHECK)
if alone and not exact: # 기계 작업 — 우리 기초와 수량 · 규격까지 같을 때만 확정으로 붙임
return alone
if best is None:
return {"판정": NONE, "후보수": 0}
base = index.docs[best_key]
if best["판정"] == NONE: # 가까운 후보(보이기만 — 단추 없음)
near = {"키": best_key, "이름": base.get("이름"), "다른": best["다른"][:12]}
return {"판정": NONE, "후보수": len(keys), "가까움": near}
return {
"판정": best["판정"],
"키": best_key,
"이름": base.get("이름"),
"옵션": _shown(base, best["옵션"]),
"다른": best["다른"],
"참고": best["참고"],
"바꿈": best.get("바꿈") or [],
"금액": {"가져온": _plain(_totals(got_imp)), "우리": _plain(best["금액"])},
"후보수": len(keys),
}
def _shown(doc: dict, pick: dict) -> dict[str, Any]:
"""고른 옵션(기본과 다른 것) — {열쇠: 값}(수 = 수 · 선택 목록의 수 값과 같은 꼴 · SK 입력값)."""
out = {}
for v in doc.get("변수") or []:
vid = md.var_id(v)
if vid in pick and mc.plain_text(mc.number(pick[vid])) != mc.plain_text(
mc.number((v.get("연결") or {}).get("값"))
):
out[str(v.get("열쇠") or vid)] = ml._norm(mc.number(pick[vid]))
return out
_STOP = {"설치", "해체", "인력", "기계", "작업", "운반", "타설", "제작"}
def _grams(texts: list[Any]) -> set[str]:
"""이름 낱말 — 한글 두 글자 묶음 · 영문 · 수 낱말(일반 낱말 뺌)."""
out: set[str] = set()
for t in texts:
t = re.sub(r"\s|및", "", str(t or ""))
for run in re.findall(r"[가-힣]+", t):
out |= {run[i : i + 2] for i in range(len(run) - 1)}
out |= {w.lower() for w in re.findall(r"[A-Za-z]{2,}", t)}
return out - _STOP
def same_name(imported: dict, base: dict) -> bool:
"""뜻 근거 — 품셈 절 번호가 같거나 이름 낱말이 겹침(값만 같은 남의 기초를 확정으로 안 묶음)."""
secs = sections([x.get("글") for x in (imported.get("가져온") or {}).get("추정") or []])
if secs & sections([base.get("출처")]):
return True
return bool(_grams([imported.get("이름")]) & _grams([base.get("이름")]))
def _unnamed(imported: dict, base: dict) -> dict:
return {"항": "이름 다름", "글": f"{imported.get('이름')} ↔ {base.get('이름')}"}
def _plain(d: dict) -> dict:
return {k: (None if v is None else float(v)) for k, v in d.items()}
-150
View File
@@ -1,150 +0,0 @@
"""가져온 식 SK ↔ 우리 M01 기초 — 연결 찾기 기록 · 「확정」 때만 우리 기초 사본으로 바꾸기 · 되돌리기
(PLAN 810-4 · 요구 707 · 708 · 709 · 703) — 서버 단독.
찾기(`find`) = 가져온 식 문서(우리 단가로 셈) → `M02_Doc_Match.match` → SK `가져온.원본.연결후보` 에 적음.
바꾸기(`swap`) = 서버가 다시 찾아 「확정」 일 때만 — 같은 SK 키에 M01 사본 SK(바탕 = 그 기초 ·
입력값 = 찾은 옵션 · 802-4a 꼴 · 수식 사본은 저장 때 서버가 찍음)를 씀 · 원래 가져온 식 SK 몸은
`가져온보관` 칸에 그대로 둠 · 일위대가 줄 참조 = 키 그대로(SK 내용만 바뀜 → 금액 다시 셈).
+ 찾은 `바꿈`(같은 기종 규격만 다른 기계 줄 · 812-11 · 요구 733-2) = 사본 문서 그 줄 기계 입력(원문번호)을
가져온 규격의 우리 기계로 갈아 끼움(수식 길 저장 = 따로) · 품 · 율 · 단가 · 조건 값은 우리 값 그대로 ·
기계가 옵션 기호 · 식이면 안 바꿈.
되돌리기(`back`) = `가져온보관` 몸을 그 키에 다시 씀(가져온 식 금액 그대로 · 수식 사본도 보관 몸 그대로).
우리 M01 기초는 어느 갈래에서도 안 바뀜(708) · 근접 · 없음 · 기계 작업 = 바꾸기 409(703).
"""
from __future__ import annotations
import copy as cp
import re
from typing import Any
from common_util.common_util_time import utc_now_text
from M02_MasterTemplete import M02_Doc_Copy as dc
from M02_MasterTemplete import M02_Doc_Import as dim
from M02_MasterTemplete import M02_Doc_Match as dm
from M02_MasterTemplete import M02_Doc_Sk as dsk
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Sangeun_Doc as sd
from M02_MasterTemplete import M02_Sangeun_Imported as imp
KEEP = "가져온보관" # 바꾼 SK 의 원래 가져온 식 몸(되돌리기)
_INDEX: dict[str, Any] = {"문서": None, "색인": None}
def _bad(why: str, status: int = 400) -> store.StoreError:
return store.StoreError(status, why)
def index() -> dm.Index:
"""M01 수식 문서 색인 — 폴더가 바뀌면(M01 관리자 고침) 다시."""
docs = dc.folder()
if _INDEX["문서"] is not docs:
aims = {k: d for k, d in docs.items() if not d.get("부품")} # 부품 문서 = 과녁 아님(813-2)
_INDEX["문서"], _INDEX["색인"] = docs, dm.Index(aims)
return _INDEX["색인"]
def _read(key: str) -> dict[str, Any]:
if not sd.KEY.match(str(key or "")):
raise _bad("키(SK-######)")
return store.read(sd.KIND, key)
def judge(sk: dict) -> dict[str, Any]:
"""가져온 식 SK → 연결후보(저장 없음)."""
whole = imp._whole()
links_imp = dim.Links(whole, "우리")
doc = dim.of(sk)
links_imp.learn(doc)
got = dm.match(doc, links_imp, index(), dm.ml.Links(whole, dc.folder()))
from M02_MasterTemplete import M02_San_Link as san_link # 우리 정보로 셈 줄 수(814-1 띠)
return {**got, "우리정보": san_link.tally(sk[imp.KEY].get("로직") or {}), "때": utc_now_text()}
def find(key: str, version: str) -> dict[str, Any]:
"""연결 찾기 → SK `가져온.원본.연결후보` 에 적음 → {연결후보, 판}."""
got = _read(key)
sk = got["문서"]
if not dim.is_imported(sk):
raise _bad("가져온 식 단가산출만 연결을 찾음")
found = judge(sk)
meta = sk[imp.KEY]
new = {**sk, imp.KEY: {**meta, "원본": {**(meta.get("원본") or {}), dm.KEY: found}}}
info = store.write(sd.KIND, key, version or got["판"], new)
return {dm.KEY: found, "판": info.get("판")}
def copy_body(sk: dict, found: dict) -> dict[str, Any]:
"""M01 사본 SK 몸(이름 · 단위 · 구분 그대로 · 바탕 = 찾은 기초 · 입력값 = 찾은 옵션)."""
keep = {k: sk.get(k) for k in ("키", "이름", "단위", "구분", "상세구분") if sk.get(k)}
old = {k: v for k, v in sk.items() if k not in ("키", "종류", KEEP)}
body = {
"덮개": {},
"뺀줄": [],
"더한줄": [],
"고름": {},
"자재표시": {},
} # M01 그대로(고침 없음)
base = {"바탕": {"로직": found["키"]}, "입력값": dict(found.get("옵션") or {})}
return {**keep, **body, **base, KEEP: old}
def swap(key: str, version: str) -> dict[str, Any]:
"""「확정」 일 때만 우리 기초 사본으로 — 서버가 다시 찾음(화면 판정을 믿지 않음)."""
got = _read(key)
sk = got["문서"]
if not dim.is_imported(sk):
raise _bad("가져온 식 단가산출만 바꿈")
found = judge(sk)
if found.get("판정") != dm.SURE:
raise _bad(f"우리 기초와 「확정」 일치가 아님({found.get('판정')}) — 바꾸지 않음", 409)
info = store.write(sd.KIND, key, version or got["판"], copy_body(sk, found))
done = refit(key, found.get("바꿈") or [])
return {dm.KEY: found, "판": done[0] or info.get("판"), "바꾼기계": done[1]}
def refit(key: str, swaps: list[dict]) -> tuple[str | None, list[dict]]:
"""사본 문서 기계 줄 → 가져온 규격 우리 기계(그 줄 단가 기호 연결의 입력 「기계」 · 줄 이름 · 규격) →
수식 길 저장 · (판, 바꾼 것)."""
from M02_MasterTemplete import M02_Doc_Sk_Path as path # 수식 길 — 늦게(돌아 부름 피함)
if not swaps:
return None, []
got = path.read(key)
doc = cp.deepcopy(got["문서"])
done = []
for one in swaps:
var = next((v for v in doc.get("변수") or [] if dm.md.var_id(v) == one["기호"]), None)
link = (var or {}).get("연결") or {}
given = link.get("입력") or {}
if link.get("갈래") != "단가산출" or not re.fullmatch(r"'[^']+'", str(given.get("기계"))):
continue
link["입력"] = {**given, "기계": f"'{one['기계']}'"}
for line in doc.get("금액") or []:
if dm._single(line.get("단가")) == one["기호"]:
if line.get("묶음") == line.get("이름"): # 그 줄 이름을 단 묶음 제목도
line["묶음"] = f"{one['이름']} {one['규격']}".strip()
line["이름"], line["규격"] = one["이름"], one["규격"]
done.append(one)
if not done:
return None, []
return path.save(key, {"문서": doc, "판": got["판"]})["판"], done
def back(key: str, version: str) -> dict[str, Any]:
"""우리 기초 사본 → 원래 가져온 식(보관 몸 그대로)."""
got = _read(key)
old = got["문서"].get(KEEP)
if not isinstance(old, dict) or not old.get(imp.KEY):
raise _bad("되돌릴 가져온 식 없음")
keep = (
old.get(dc.KEY) if dc.has(old) else None
) # 보관 몸 수식 사본 그대로(바꾼 기계 = 따로 버림)
token = dsk.SAVING.set({"키": key, "사본": keep}) if keep else None
try:
info = store.write(sd.KIND, key, version or got["판"], {**old, "키": key})
finally:
if token is not None:
dsk.SAVING.reset(token)
return {"판": info.get("판")}
-256
View File
@@ -1,256 +0,0 @@
"""연결 찾기 — 로직 차이(다른 항) 대 값 차이(참고) 가르기(PLAN 812-2 · 요구 712) — `M02_Doc_Match` 가 부름.
확정을 막는 「다른 항」 = 로직 차이만 — 자원 갈래 · 기종 · 노임 종류 · 자재 이름 종류 · 비목 · 산출 단가 ·
율 구조(있고 없음 · 대상 비목 — 율 값 % 는 참고) · 단위 환산(`M02_Doc_Match_Value`).
「참고」 = 값 차이 — 인력 · 재료 · 기계 시간당 단가 · 끝수(합만 다름) · 율 값(%) · 같은 기종 규격 · 수량(812-11) ·
그로 인한 비목 금액 · 계.
율 = 율 줄 셈값 ÷ 대상 금액 합 — 대상 = 율 식이 (거쳐서) 부르는 금액 줄 · 비목 이름이면 그 비목 몫 · 번지 합 = 핵심 줄 전부 ·
대상 금액 끝수 자리 차이만 지움(유효 6 자리 · 두 바탕 중 짧은 꼴) — 지어 맞추기 아님.
"""
from __future__ import annotations
import re
from decimal import Decimal, InvalidOperation
from typing import Any
from M02_MasterTemplete import M02_Doc_Copy as dc
from M02_MasterTemplete import M02_Doc_Match_Value as val
from M02_MasterTemplete import M02_Machine_Find as mfind
md = dc.md
PRICED = ("노임", "재료", "기계") # 시간당 · 단위 단가 = 값(참고)
# ── 율 구조 ───────────────────────────────────────────────────────────
def _names(text: Any) -> set[str]:
if text in (None, ""):
return set()
try:
return set(md.names(md.parse(text)))
except md.DocError:
return set()
def _reach(doc: dict) -> dict[str, set[str]]:
"""기호 → 바로 부르는 이름(변수 연결 · 식 줄 · 금액 줄 수량 · 단가)."""
direct: dict[str, set[str]] = {}
for v in doc.get("변수") or []:
direct[md.var_id(v)] = set(md.link_names(v.get("연결") or {}))
for line in doc.get("식") or []:
direct[str(line.get("기호"))] = _names(line.get("식"))
for line in doc.get("금액") or []:
if line.get("기호"):
direct[str(line["기호"])] = _names(line.get("수량")) | _names(line.get("단가"))
return direct
def _close(start: set[str], direct: dict[str, set[str]]) -> set[str]:
seen, todo = set(start), list(start)
while todo:
for n in direct.get(todo.pop(), ()):
if n not in seen:
seen.add(n)
todo.append(n)
return seen
def _short(r: Decimal) -> Decimal:
return Decimal(f"{r:.6g}").normalize()
def rates(doc: dict, got: dict, facts: list[dict]) -> None:
"""율 사실에 `율`(비율 · 못 셈 None) · `대상`({(자원 갈래, 비목)})을 채움(제자리)."""
money, rows = doc.get("금액") or [], got.get("줄") or []
direct = _reach(doc)
core = [f for f in facts if f["갈래"] != "율" and not f["합밖"]]
for f in facts:
if f["갈래"] != "율":
continue
line, row = money[f["번"]], rows[f["번"]]
reach = _close(_names(line.get("수량")) | _names(line.get("단가")), direct)
costs = reach & set(md.COSTS)
hits = [c for c in core if _hit(money[c["번"]], reach)]
if hits: # 줄 기호 · 줄 수량 · 단가 이름 = 그 줄 금액 전부
parts = [(c, tuple(c["비목"])) for c in hits]
elif costs: # 비목 이름(「노무비 × 4 %」) = 그 비목 몫만
parts = [(c, tuple(k for k in c["비목"] if k in costs)) for c in core]
else: # 번지 합 · 못 찾음 = 블록 핵심 줄 전부
parts = [(c, tuple(c["비목"])) for c in core]
parts = [(c, ks) for c, ks in parts if ks]
f["대상"] = frozenset((_cls(c["갈래"]), k) for c, ks in parts for k in ks)
f["율"] = None
amount = _mul(row.get("수량"), row.get("단가"))
if amount is None:
continue
bases = [sum((_num(c["비목"][k]) for c, ks in parts for k in ks), Decimal(0))]
if hits:
each = (_mul(rows[c["번"]].get("수량"), rows[c["번"]].get("단가")) for c in hits)
bases.append(sum((x or 0 for x in each), Decimal(0)))
found = [_short(amount / b) for b in bases if b]
if found:
f["율"] = min(found, key=lambda r: len(r.as_tuple().digits))
def _hit(line: dict, reach: set[str]) -> bool:
parts = _names(line.get("수량")) | _names(line.get("단가"))
return bool(line.get("기호") and str(line["기호"]) in reach) or bool(parts and parts <= reach)
def _cls(kind: str) -> str:
return "기계" if kind == "산출" else kind # 기계경비 묶음(코드 없음) = 기계
def _num(v: Any) -> Decimal:
if isinstance(v, dict):
return sum((_num(v.get(k)) for k in md.COSTS), Decimal(0))
try:
return Decimal(str(v)) if v is not None else Decimal(0)
except InvalidOperation:
return Decimal(0)
def _mul(q: Any, p: Any) -> Decimal | None:
if q is None or p is None:
return None
return _num(q) * _num(p)
def pct(r: Any) -> str:
"""비율 → 「9 %」."""
if not isinstance(r, Decimal):
return "?"
return f"{(r * 100).normalize():f} %"
# ── 짝 줄 ─────────────────────────────────────────────────────────────
def txt(v: Any) -> str:
"""단가 글 — 수 = 원 단위 쉼표 · 기계 묶음 = 노 · 재 · 경."""
if isinstance(v, tuple):
return " · ".join(f"{k[0]} {txt(x)}" for k, x in zip(md.COSTS, v) if x)
if isinstance(v, Decimal):
return f"{v.normalize():,f}"
return str(v)
def _plain(text: str) -> str:
"""표기 정규화(소리 표기 · 빈칸) — 기계 찾기와 같은 규칙(812-9 `M02_Machine_Find.norm`)."""
return mfind.norm(text)
def _head(text: str) -> str:
"""기계 이름 머리(괄호 · 규격 앞 한글) — 「머캐덤로울러(자주식)」 → 「머캐덤롤러」."""
m = re.match(r"[가-힣]+", _plain(text))
return m.group(0) if m else ""
def _same_machine(a: str, b: str) -> bool:
"""같은 기종 — 기계표 · 별칭 장부로 둘 다 기종을 찾으면 그 판정(812-9 · 박힌 짝 없음) ·
못 찾으면 이름 머리 품음."""
got = mfind.same_kind(a, b)
if got is not None:
return got
x, y = _head(a), _head(b)
return bool(x and y) and (x in y or y in x)
def _grams(text: str) -> set[str]:
t = _plain(text)
return {run[i : i + 2] for run in re.findall(r"[가-힣]+", t) for i in range(len(run) - 1)}
def pair(a: dict, b: dict) -> tuple[list[dict], list[dict]]:
"""짝 줄 → (다른 항, 참고) — 단가 · 같은 기종 규격만 다르면 참고 · 자원 · 기종 · 비목이 다르면 다른 항."""
diffs: list[dict] = []
spec: list[dict] = [] # 같은 기종 규격만 다름 = 값(812-11 · 요구 733-2)
kinds = {a["갈래"], b["갈래"]}
# 기계경비 숫자 묶음(코드 없음 · 원파일 값) ↔ 우리 기계 = 이름 종류로 같은 기계인지
loose = kinds == {"기계", "산출"} and not (a if a["갈래"] == "산출" else b)["코드"]
combo = not val.same_combo(a, b) # 조합 기계 ↔ 낱 기계 = 다른 기종
if a["갈래"] == b["갈래"] == "기계" and (a["코드"] != b["코드"] or combo):
if not combo and val.same_machine(a, b):
spec.append(val.spec_note(a, b))
else:
diffs.append(
{"항": "기계 규격", "글": f"{a['이름']} {a['코드']} ↔ {b['코드']} — 다른 기종"}
)
elif loose and not _same_machine(a["이름"], b["이름"]):
diffs.append({"항": "기계 규격", "글": f"{a['이름']} ↔ {b['이름']} {b['코드']}"})
elif loose:
spec += [x for x in (val.loose_note(a, b),) if x]
elif a["갈래"] == b["갈래"] == "노임" and a["코드"] != b["코드"]:
diffs.append({"항": "자원", "글": f"{a['이름']} {a['코드']} ↔ {b['이름']} {b['코드']}"})
elif a["갈래"] == b["갈래"] == "재료" and not _same_stuff(a, b):
diffs.append({"항": "자원", "글": f"{a['이름']} ↔ {b['이름']}"})
machine = a["갈래"] == b["갈래"] == "기계" or loose # 시간당 단가 구성(운전 노무비 등) = 값
priced = a["비목"] and b["비목"] # 한쪽 단가 없음(0) = 값
if priced and not machine and set(a["비목"]) != set(b["비목"]):
diffs.append(
{"항": "비목", "글": f"{a['이름']} 비목 {'·'.join(a['비목'])} ↔ {'·'.join(b['비목'])}"}
)
if a["단가"] == b["단가"]:
return diffs, spec
say = f"{a['이름']} 단가 {txt(a['단가'])} ↔ {txt(b['단가'])}"
if (a["갈래"] == b["갈래"] and a["갈래"] in PRICED) or loose:
return diffs, [*spec, {"항": "단가 차이", "글": say}]
return [*diffs, {"항": "산출 단가", "글": say}], spec
def _same_stuff(a: dict, b: dict) -> bool:
"""자재 종류 같음 — 두 코드가 있으면 코드 · 없으면 이름 낱말(한글 두 글자) 겹침."""
if a["코드"] and b["코드"] and a["코드"] == b["코드"]:
return True
return bool(_grams(a["이름"]) & _grams(b["이름"]))
def rate_pairs(left: list[dict], right: list[dict], take: Any) -> tuple[list[dict], list[dict]]:
"""율 줄 짝 → (다른 항, 참고) — 구조 = 대상 · 비목 · 같으면 로직 같음(율 값 % 다름 = 참고 · 값은 업데이트됨)."""
diffs: list[dict] = []
notes: list[dict] = []
def is_rate(a: dict, b: dict) -> bool:
return a["갈래"] == b["갈래"] == "율"
def same(a: dict, b: dict, *keys: str) -> bool:
return is_rate(a, b) and all(
(set(a[k]) == set(b[k])) if k == "비목" else a.get(k) == b.get(k) for k in keys
)
def name(a: dict, b: dict) -> str:
return f"{a['이름']} {pct(a.get('율'))} ↔ {b['이름']} {pct(b.get('율'))}"
def target(x: dict) -> str:
return "·".join(f"{k} {c}" for k, c in sorted(x.get("대상") or ())) or "없음"
def value(a: dict, b: dict) -> None:
if a.get("율") != b.get("율"):
notes.append({"항": "율 값", "글": f"{name(a, b)} — 바꾸면 우리 값"})
take(lambda a, b: same(a, b, "율", "대상", "비목")) # 로직 · 값 같음
for a, b in take(lambda a, b: same(a, b, "대상", "비목")): # 로직 같음 · 율 값만 다름
value(a, b)
for a, b in take(lambda a, b: same(a, b, "대상")):
tool = val.tool_cost(a, b) # 공구손료 비목만 다름 = 값(814-1)
(notes if tool else diffs).append(
tool or {"항": "비목", "글": f"율 줄 {name(a, b)} 비목 다름"}
)
value(a, b)
for a, b in take(lambda a, b: same(a, b, "율")):
diffs.append({"항": "율 대상", "글": f"{name(a, b)} 대상 {target(a)} ↔ {target(b)}"})
for a, b in take(is_rate):
diffs.append({"항": "율 대상", "글": f"{name(a, b)} 대상 {target(a)} ↔ {target(b)}"})
return diffs, notes
# ── 이름 꼬리(814-1 · 요구 743-3) ─────────────────────────────────────────
def bare(doc: dict) -> dict:
"""엑셀 단가산출 이름의 규격 · 단위 꼬리(「구조물터파기(인력) 경질토사 / M3」) 뗀 맞댈 문서 —
보이는 · 저장 이름은 그대로(연결 찾기만 이 사본을 씀)."""
name = str(doc.get("이름") or "")
unit = re.sub(r"^원\s*/\s*", "", str(doc.get("결과단위") or "")).strip()
spec = str((doc.get("가져온") or {}).get("규격") or "").strip()
got = re.sub(rf"\s*/\s*{re.escape(unit)}\s*$", "", name) if unit else name
if got != name and spec and got.rstrip().endswith(spec) and got.rstrip() != spec:
got = got.rstrip()[: -len(spec)]
got = got.strip()
return doc if not got or got == name else {**doc, "이름": got}
-233
View File
@@ -1,233 +0,0 @@
"""연결 찾기 값 기준 넓힘(PLAN 812-11 · 요구 714 · 733 · 734 · 735) — `M02_Doc_Match` 가 부름.
값(참고 · 확정을 안 막음 · 바꾸면 우리 값):
같은 기종 규격만 다름(굴착기 0.7㎥ ↔ 1.0㎥ — 기종 = 기계표 이름 머리 + 괄호 낱말 · 무한궤도 대 타이어 = 다른 기종 ·
조합 기계는 구성 줄마다) = 「규격」(바꾸면 가져온 규격 — [바꾸기] 때 그 줄 기계를 갈아 끼움 `바꿈`) ·
수량 차이(같은 자원 줄) = 「수량 끝자리」(1 % 안) · 「품 숫자」(가져온 수량 = 수 하나) · 「수량」(조건 값 · 비율 · 표 값).
로직(다른 항): 자원 줄 구성(갈래 · 기종 · 노임 직종 · 재료 종류 · 비목) · 줄 없음 · 율 구조 · 단위 환산 =
같은 배(× 6 등)가 두 줄 넘게 같이 걸림(결과 단위 기준이 다름) — 한 줄만 배가 맞는 것은 수량 값.
기계 작업(`work`) = 가져온 문서가 기계 한 묶음(한 기계 줄 · 조합 기계면 그 구성 줄들)뿐 · 수량이 작업량 식(Q) =
우리 기초에 안 붙임(요구 728 · 735-4 — 수량 · 규격까지 같은 우리 기초가 있을 때만 확정) · 공식 꼴이 그 기종
「작업량 틀」(813-2 부품)과 같으면 참고에 틀 이름.
"""
from __future__ import annotations
import re
from decimal import Decimal
from functools import lru_cache
from typing import Any
from M02_MasterTemplete import M02_Machine_Find as mfind
WORK = "기계 작업"
SPEC, QTY, TAIL, POOM = "규격", "수량", "수량 끝자리", "품 숫자"
VALUE = (SPEC, QTY, TAIL, POOM) # 확정 순위 — 값 참고가 적은 조합 · 기초 먼저
CHECK = (*VALUE, "옵션 확인") # 기계 작업 꼴이 확정으로 붙지 않는 참고
_NUMBER = re.compile(r"^\s*\d+(?:\.\d+)?\s*$")
# ── 공구손료 율 비목(814-1 · 요구 744 C1) ─────────────────────────────────
TOOL = "공구손료 비목"
def tool_cost(a: dict, b: dict) -> dict | None:
"""공구손료 율 줄 짝 — 대상은 같고 비목만 다름(실무 재료비 ↔ 우리 경비) = 값(참고 · 확정 안 막음 ·
[바꾸기] = 우리 기초 사본 = 우리 비목) · 공구손료 아님 = None."""
if "공구" not in str(a.get("이름")) + str(b.get("이름")):
return None
say = f"{a['이름']} 비목 {'·'.join(a['비목'])} ↔ {'·'.join(b['비목'])} — 바꾸면 우리 비목"
return {"항": TOOL, "글": say}
# ── 기종 ──────────────────────────────────────────────────────────────
def row_of(code: Any) -> Any:
"""원문번호 → 기계표 줄(`M02_Machine_Find.Row`) · 없음 None."""
key = mfind.by_code(code)
return mfind.table()[2].get(key) if key else None
def same_kind(a: Any, b: Any) -> bool:
"""두 원문번호 = 같은 기종 — 이름 머리 + 괄호 낱말 같음(규격만 다름)."""
x, y = row_of(a), row_of(b)
return bool(x and y) and x.base == y.base and x.words == y.words
@lru_cache(maxsize=4096)
def _combo(name: str) -> tuple | None:
"""조합 기계 이름 → 구성 기종 묶음(정렬) · 낱 기계 = None."""
got = mfind.parts(name)
return tuple(sorted(mfind.split_name(n)[0] for n, _ in got)) if len(got) > 1 else None
def mark_combo(doc: dict, facts: list[dict]) -> None:
"""기계 사실에 `조합`(구성 묶음 · 낱 = None) · `갈라짐`(가져온 조합을 둘로 가른 줄 = 낱 기계로도 맞음)."""
head = _machines(doc)
for f in facts:
if f["갈래"] in ("기계", "산출"):
f["조합"] = _combo(f["이름"])
f["갈라짐"] = any(head.get(str(f.get("기호")), {}).get(k) for k in ("조합", "조합첫"))
def same_combo(a: dict, b: dict) -> bool:
"""조합 여부 같음 — 구성 묶음 같음 · 가른 줄 ↔ 낱 기계도 같음(「굴착기+부착용 집게」 ↔ 굴착기 하나 = 다름)."""
x, y = a.get("조합"), b.get("조합")
return x == y or (a.get("갈라짐") and y is None) or (b.get("갈라짐") and x is None)
def same_machine(a: dict, b: dict) -> bool:
"""같은 기종 기계 줄 — 원문번호 기종 같음 + 조합 구성 같음."""
return same_kind(a["코드"], b["코드"]) and same_combo(a, b)
def _eq(code: Any) -> str:
r = row_of(code)
return f"{r.raw.get('이름')} {r.raw.get('규격') or ''}".strip() if r else str(code)
def spec_note(a: dict, b: dict) -> dict:
"""같은 기종 규격 다름 → 참고 「규격」 + 바꿈(우리 줄 단가 기호 → 가져온 원문번호)."""
ra = row_of(a["코드"])
say = f"규격 다름 {_eq(a['코드'])} ↔ {(row_of(b['코드']).raw.get('규격') or '')}"
swap = {"기호": b.get("기호"), "기계": a["코드"], "이름": ra.raw.get("이름"),
"규격": ra.raw.get("규격") or ""} # fmt: skip
return {"항": SPEC, "글": f"{say} — 바꾸면 가져온 규격", "바꿈": swap}
def loose_note(a: dict, b: dict) -> dict | None:
"""못 이은 가져온 기계(원파일 묶음 값) ↔ 우리 기계 — 규격 글이 다르면 「안 바꿈」 참고."""
loose, ours = (a, b) if a["갈래"] == "산출" else (b, a)
row = row_of(ours["코드"])
if not row or not loose.get("규격"):
return None
if mfind.spec_fit(mfind.tokens(loose["규격"]), row.spec) == "같음":
return None
say = f"규격 다름 {loose['이름']} {loose['규격']} ↔ {_eq(ours['코드'])}"
return {
"항": SPEC,
"글": f"{say} — 가져온 기계를 우리 표에서 못 찾음 · 바꾸면 우리 기계 그대로",
}
# ── 수량 ──────────────────────────────────────────────────────────────
def _show(v: Any) -> str:
return f"{v:.4g}" if isinstance(v, Decimal) else str(v)
def qty_note(a: dict, b: dict) -> dict:
"""같은 자원 줄 수량 다름 → 참고(끝자리 · 품 숫자 · 수량)."""
x, y = a["수량"], b["수량"]
if isinstance(x, Decimal) and isinstance(y, Decimal) and y and abs(x - y) <= abs(y) / 100:
what = TAIL
elif _NUMBER.match(str(a.get("수량글") or "")):
what = POOM
else:
what = QTY
return {"항": what, "글": f"{a['이름']} 수량 다름 {_show(x)} ↔ {_show(y)} — 바꾸면 우리 값"}
def pairs(
take: Any, core: Any, cls: Any, pair: Any, ratio: Any
) -> tuple[list[dict], list[dict], int]:
"""수량 다른 같은 자원 짝(로직 같음 · `pair` 다른 항 0) → (다른 항 = 단위 환산 무리, 참고, 같은 줄 수) ·
`ratio` = 단위 환산 배 글(「× 6」) · 없음 None."""
got = take(
lambda a, b: core(a, b) and cls(a, b) and a["수량"] != b["수량"] and not pair(a, b)[0]
)
hows = [ratio(a["수량"], b["수량"]) for a, b in got]
many = {
h for h in hows if h and hows.count(h) >= 2
} # 같은 배가 두 줄 넘게 = 결과 단위 기준 다름
diffs: list[dict] = []
notes: list[dict] = []
for (a, b), how in zip(got, hows):
if how in many:
diffs.append(
{"항": "단위 환산", "글": f"{a['이름']} 수량 {a['수량']} ↔ {b['수량']} {how}"}
)
else:
notes.append(qty_note(a, b))
notes += pair(a, b)[1]
return diffs, notes, len(got) - sum(1 for h in hows if h in many)
# ── 기계 작업 ─────────────────────────────────────────────────────────
def _machines(doc: dict) -> dict[str, dict]:
head = (doc.get("가져온") or {}).get("기계")
return {str(x.get("기호")): x for x in head or [] if isinstance(x, dict)}
def _one_group(rows: list[dict]) -> bool:
if len(rows) == 1:
return True
return sum(1 for x in rows if x.get("조합첫")) == 1 and all(
x.get("조합") or x.get("조합첫") for x in rows
)
def _form(text: Any) -> str:
"""작업량 식 꼴 — 끝수 함수 벗김 · 빈칸 뺌 · 작은 글자(Cm = cm)."""
t = re.sub(r"\s", "", str(text or ""))
m = re.match(r"^(?:반올림|버림|올림)\((.*),\d+\)$", t)
return (m.group(1) if m else t).lower()
def _q_form(doc: dict, names: set[str]) -> str:
for line in doc.get("식") or []:
if str(line.get("기호")) in names and str(line.get("기호")).lower().startswith("q"):
return _form(line.get("식"))
return ""
def work(doc: dict, mine: list[dict], names: Any) -> dict | None:
"""가져온 문서가 기계 한 묶음 + 작업량 식 수량이면 {판정 기계 작업, 참고} · 아니면 None
(`names` = 식 글 → 쓰는 이름 · 인력 · 재료 · 율 줄이 섞이면 보통 길)."""
heads = _machines(doc)
if not mine or not heads or any(f["갈래"] == "율" for f in mine):
return None
rows = [f for f in mine if not f["합밖"]]
if not rows or any(
str(f.get("기호")) not in heads or f["갈래"] not in ("기계", "산출") for f in rows
):
return None
if not _one_group([heads[str(f["기호"])] for f in rows]):
return None
used = [names(f.get("수량글")) for f in rows]
if not all(used): # 수량이 수뿐 = 작업량 식 아님
return None
notes = []
form = _q_form(doc, set().union(*used))
from M01_MasterData import M01_MasterData_Parts as parts # M02 가 M01 을 부름 — 늦게
for f in rows:
if not form or not row_of(f["코드"]):
continue
got = parts.of_machine(str(f["코드"]))
for t in got["틀"]:
if any(
_form(x.get("식")) == form
for x in t["문서"].get("식") or []
if x.get("기호") == "Q"
):
say = f"{got['기계']['이름']} — 작업량 공식 꼴 = {t['키']} {t['이름']}"
notes.append({"항": "작업량 틀", "글": say})
break
return {"판정": WORK, "참고": notes, "후보수": 0}
# ── 옵션 확인 ─────────────────────────────────────────────────────────
def option_check(doc: dict, pick: dict, same: list[dict], qty_vars: set[str]) -> list[dict]:
"""값이 정확히 맞는 조합이 없고 수량을 정하는 선택이 값으로 안 갈림(같은 순위 조합에 값 여럿) =
고른 값(기본 먼저) + 참고 「옵션 확인」."""
from M02_MasterTemplete.M02_Doc_Copy import md
out = []
for v in doc.get("변수") or []:
vid, link = md.var_id(v), v.get("연결") or {}
if link.get("갈래") != "선택" or vid not in qty_vars:
continue
if len({str(p.get(vid)) for p in same}) > 1:
how = "기본값" if pick.get(vid) == link.get("값") else "고른 값"
say = f"옵션 확인 — {v.get('이름') or vid} = {pick.get(vid)}({how} · 값으로 못 가름)"
out.append({"항": "옵션 확인", "글": say})
return out
+2 -34
View File
@@ -4,9 +4,7 @@
POST /api/m02/layers/{층}/sangeun/{키}/doc/preview 미리 셈(층 쓰기) {문서?, 끊음?, 할일?}
→ {…, 전, 후, 바뀐자원?, 버림?}
POST /api/m02/layers/{층}/sangeun/{키}/doc/save 저장(층 쓰기) {문서?, 끊음?, 할일?, 판} → 읽기 꼴
· 가져온 기계 줄에 고른 EQ = 이 층 별칭 장부(812-9)
POST /api/m02/layers/{층}/sangeun/{키}/match {할일 찾기|바꾸기|되돌리기, 판?}(층 쓰기 · 810-4)
→ {연결후보?, 판}(바꾸기 = 확정만 · 아니면 409)
옛 별칭 장부 쓰기(812-9) · 연결 찾기 길(810-4)은 걷음(815-2 · 요구 762-1 · 765).
⚠ 등록은 `main.py`(로그인만 · 층 권한은 길 안에서).
"""
@@ -19,7 +17,6 @@ from pydantic import BaseModel
from common_util.common_util_auth import verify_session
from M02_MasterTemplete import M02_Doc_Sk_Path as path
from M02_MasterTemplete import M02_Machine_Find as mfind
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root
router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Sangeun Doc"])
@@ -52,31 +49,6 @@ def preview_doc(
return _call(folder, path.preview, key, body.model_dump(exclude_none=True))
class MatchBody(BaseModel):
할일: str = "찾기" # 찾기 | 바꾸기 | 되돌리기(810-4)
판: str | None = None
@router.post("/layers/{layer}/sangeun/{key}/match")
def match_doc(
layer: Layer,
key: str,
body: MatchBody,
owner: str | None = None,
session: Session = Depends(verify_session),
) -> dict:
"""가져온 식 ↔ 우리 기초(810-4) — 찾기(연결후보 적음) · 바꾸기(확정만 · M01 사본 SK) · 되돌리기."""
from fastapi import HTTPException
from M02_MasterTemplete import M02_Doc_MatchSk as msk
jobs = {"찾기": msk.find, "바꾸기": msk.swap, "되돌리기": msk.back}
if body.할일 not in jobs:
raise HTTPException(status_code=400, detail="찾기 · 바꾸기 · 되돌리기")
folder = _root(session, layer, owner, write=True)
return _call(folder, jobs[body.할일], key, body.판 or "")
@router.post("/layers/{layer}/sangeun/{key}/doc/save")
def save_doc(
layer: Layer,
@@ -86,8 +58,4 @@ def save_doc(
session: Session = Depends(verify_session),
) -> dict:
folder = _root(session, layer, owner, write=True)
got = _call(folder, path.save, key, body.model_dump(exclude_none=True))
if body.문서 is not None: # 812-9 — 가져온 기계 줄에 고른 EQ = 이 층 별칭 장부 줄
who = {"id": session.get("user_id"), "이름": session.get("name")}
_call(folder, mfind.learn, body.문서, who)
return got
return _call(folder, path.save, key, body.model_dump(exclude_none=True))
+2 -23
View File
@@ -19,8 +19,8 @@
받아 `본뜬.전국자재 [{이름, 규격, 단위, 수량, 세 칸}]` 에 적고 빠진 줄에 까닭(금액 없음 ·
지역은 프로젝트에서) · 장부엔 안 남김.
같은 본뜬 사전조합이 있으면 확인한 내용으로 덮음(`다시씀`) · 없으면 같은 내용 SC 재사용(`재사용` · `M02_Hopyo_Kids.save`) ·
`하위` = 부르는 하위 호표 `[{번, 키, 새로}]`(부모 줄 = 하위 사본) · 확인한 줄은 별칭 장부에 남음
(`M02_Hopyo_Ledger` · 호표 참조 줄 뺌).
`하위` = 부르는 하위 호표 `[{번, 키, 새로}]`(부모 줄 = 하위 사본) · 별칭 장부엔 안 남김(815-2 걷음 ·
`M02_Hopyo_Ledger` 는 짝 검사 · 열쇠 도우미만).
빠짐 0(705-3) — 확인 안 한 줄 · 노임 · % · 「우리에 없음」 줄 = 고정 줄(`M02_Hopyo_Fixed` · 엑셀 숫자
그대로) · 확인 안 한 호표 줄 = 확인된 하위 사전조합 참조(단위 같음 · 아니면 고정) · 빠진 줄 = 「안 씀」 ·
통째 대신 · 로직 자재 겹침 · 전국만. `본뜬.품셈` = 품셈으로 넣은 엑셀 줄 차례([품셈으로 바꾸기] 첫 체크) ·
@@ -248,18 +248,14 @@ class Builder:
nation, mat_lines = body.nation, body.mat_lines
poomsem, kid_n = [], 0
book: dict[str, dict[str, Any]] = {}
if whole:
for one in whole:
put({"수량": 1, "단위": hopyo.get("단위"), "이름": hopyo.get("이름")}, one, None)
book[ledger.hopyo_key(hopyo)] = self._row(hopyo, "짝", whole)
for at, line in enumerate(rows):
got = checked.get(at)
if whole:
missing.append(_missing(line, WHOLE))
continue
if got and got.get("뺌"):
book[ledger.key(line)] = self._row(line, got["뺌"], [])
if got and got.get("뺌") == "안 씀":
missing.append(_missing(line, f"뺌 — {got['뺌']}"))
continue
@@ -277,9 +273,6 @@ class Builder:
alias = str(line.get("이름") or "")[: labor.ALIAS_MAX].strip() or None
for one in got["대상"]:
put(line, one, alias)
pairs = [p for p in got["대상"] if "호표" not in p and "전국" not in p]
if pairs:
book[ledger.key(line)] = self._row(line, "짝", pairs)
for code in self._inside(lines, mats): # 로직 자재와 같은 품목 = 세 칸만 로직 자재표시로
missing.append(_missing(mat_lines[code], TWICE))
name = " ".join(x for x in (hopyo["이름"], hopyo.get("규격")) if x)
@@ -306,14 +299,11 @@ class Builder:
count["단가산출"] = sum(1 for x in lines if _is_sk(x))
sub = [{"번": n, "키": self.done.get(mark(self.key, n))} for n in sorted(kids)]
if not save:
ledger.write(self.folder, book, self.who, file or None, self.doc_id)
head = {"문서": doc, "빠진줄": missing, "남은줄": left, "하위": sub}
return {**head, "줄수": {**count, "이음후보": linker.near}}
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}}
def _kid(self, line: dict[str, Any], kids: set[int]) -> dict[str, Any] | None:
@@ -351,17 +341,6 @@ class Builder:
mats.pop(code)
return moved
def _row(self, line: dict[str, Any], kind: str, pairs: list[dict[str, Any]]) -> dict[str, Any]:
"""장부 한 줄 — 엑셀조항 = 줄 근거 출처(있으면)."""
origin = {"품명": line.get("이름"), "규격": line.get("규격"), "단위": line.get("단위")}
clauses = (line.get("근거") or {}).get("출처") or []
return {
"원래": {k: str(v or "") for k, v in origin.items()},
"갈래": kind,
"짝": pairs,
"엑셀조항": clauses if isinstance(clauses, list) else [],
}
def _order(units: dict[int, dict[str, Any]], start: int | None = None) -> list[int]:
"""하위 호표 먼저(호표 줄 참조) · 고리는 끊음 · `start` = 그 호표와 하위만."""
+4 -36
View File
@@ -11,7 +11,8 @@
(줄 `불러오기` = `doc_id`)만 · 회사 · 개인 층 짝 = 「내 제안」(`제안` True · 자동 아님 ·
후보 맨 위 · M01 승인 대기).
읽는 차례 = 자동 정확 열쇠(같은 층 → 위 층) → 제안 정확 열쇠 → 품명|단위 느슨 열쇠(`규격다름`).
쓰는 때 = [확인하고 사전조합 만들기] · 장부 화면 [저장]만 · 쓰기 = 그 층만(권한은 길 몫) ·
⚠ 815-2 — 장부 화면 · M01 승인 · 불러오기 확인 때 쓰기는 걷음(별칭 = M01 요소 「별칭」 칸) ·
남은 것 = 짝 검사 · 열쇠 도우미 · 요소 잇기의 옛 장부 읽기(sub3 815-1 이 뗌) · 쓰기 = 그 층만 ·
짝은 서버가 다시 검사(돈 로직 · 그 로직 입력 이름 · M01 자재품목 · 배 0 넘음 · 몫 (0, 1]).
"""
@@ -137,39 +138,6 @@ def recall(
return None
def _states(rows_: list[dict[str, Any]]) -> None:
"""내 제안 승인 상태(739-1) — 마스터 줄 = 승인 · 아래 층 줄 = 마스터에 같은 짝(서명) 승인 ·
M01 반려 기억 반려 · 그 밖 대기(M01 별칭 장부 `sign` · 반려 기억 그대로)."""
from M01_MasterData import M01_MasterData_Alias as alias # M01 이 장부를 부름 — 늦게 읽음
master = read(store.FOLDER)
gone = alias.rejected()
for row in rows_:
if row["층"] == "system":
row["상태"] = "승인"
continue
sig, mine = alias.sign(alias.ALIAS, row), master.get(row["열쇠"])
if mine and alias.sign(alias.ALIAS, mine) == sig:
row["상태"] = "승인"
elif alias._reject_id(alias.ALIAS, row["열쇠"], sig) in gone:
row["상태"] = "반려"
else:
row["상태"] = "대기"
def rows(folder: str | Path, writable: bool) -> list[dict[str, Any]]:
"""장부 화면 — 이 층 + 위 층 줄 `{열쇠, …줄, 층, 주인, 고침, 상태}`(고침 = 이 층 · 쓰기 권한 ·
상태 = 승인 · 대기 · 반려)."""
out = []
for at, (layer, owner, book) in enumerate(books(folder)):
for full, row in sorted(book.items()):
out.append(
{"열쇠": full, **row, "층": layer, "주인": owner, "고침": writable and not at}
)
_states(out)
return out
# ── 검사 · 쓰기 ───────────────────────────────────────────────────────
def _bad(text: str) -> store.StoreError:
return store.StoreError(400, text)
@@ -350,9 +318,9 @@ def write(
def put(folder: str | Path, entries: Any, who: Any) -> dict[str, Any]:
"""장부 화면 [저장] — `{열쇠: 줄 | None}` 재검사 뒤 이 층에 씀 → `{씀, 줄}`."""
"""`{열쇠: 줄 | None}` 재검사 뒤 이 층에 씀 → `{씀}`(장부 화면 · M01 승인은 걷음 · 815-2)."""
if not isinstance(entries, dict) or not entries or len(entries) > 2000:
raise _bad("저장할 줄이 없음")
known = _known()
clean = {str(k): None if v is None else check(str(k), v, known) for k, v in entries.items()}
return {"씀": write(folder, clean, who), "줄": rows(folder, True)}
return {"씀": write(folder, clean, who)}
+1 -32
View File
@@ -4,7 +4,7 @@
엑셀 보기 머리 · 목록 = `View.listing`(줄 표지) · 옮기기 = [내 일위대가로] `hopyo-build-all {번}`(807-1).
옛 판정 · 검산 · 옮기기 · 근거 제안 길(785-5 `hopyo-judge` · `check` · `move` · `price` · 793-3a `hopyo-basis`)은
809-1 에서 지움(요구 703 — 기초로 바꾸기 없음).
그대로 쓰는 길(목록 · 올림 · 엑셀 보기 머리 · 격자 · 지우기 · 장부 · 모두 고정 · 붙여넣기 · 다시 잇기 ·
그대로 쓰는 길(목록 · 올림 · 엑셀 보기 머리 · 격자 · 지우기 · 모두 고정 · 붙여넣기 · 다시 잇기 ·
구조물 조합 · 칠 = B09 · M01 이 씀) · 옛 맞춤 길(호표 하나 맞춤 · 미리 셈 · 실무 호표 · 확인 만들기 · 안 이은 줄 ·
단가산출 만들기)은 785-7 에서 지움.
층 권한 = `Router_Items._root` · ⚠ 등록은 `main.py`(로그인만).
@@ -25,13 +25,11 @@ 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
from M02_MasterTemplete import M02_Hopyo_Marks as hopyo_marks
from M02_MasterTemplete import M02_Hopyo_View as hopyo_view_
from M02_MasterTemplete import M02_Hopyo_Paste as hopyo_paste
from M02_MasterTemplete import M02_Hopyo_Relink as hopyo_relink
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
from M02_MasterTemplete import M02_Layer_Access as access
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Sangeun_FromHopyo as from_hopyo
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root
@@ -207,35 +205,6 @@ def hopyo_make_all(
return _call(folder, hopyo_build.build_all, *args)
@router.get("/layers/{layer}/hopyo-ledger")
def hopyo_ledger_read(
layer: Layer, owner: str | None = None, session: Session = Depends(verify_session)
) -> dict:
"""별칭 장부 — 이 층 + 위 층 줄 `{열쇠, 원래, 갈래, 짝, 누가, 때, 처음파일, 층, 주인, 고침}`
(고침 = 이 층 · 쓰기 권한)."""
folder = _root(session, layer, owner)
writable = access.grant(session, layer, owner).write
return {"줄": _call(folder, hopyo_ledger.rows, folder, writable)}
class LedgerBody(_Strict):
"""`줄` = {열쇠: 장부 줄 | null(지움)} — 이 층만 · 서버가 짝을 다시 검사."""
줄: dict[str, Any]
@router.put("/layers/{layer}/hopyo-ledger")
def hopyo_ledger_write(
layer: Layer,
body: LedgerBody,
owner: str | None = None,
session: Session = Depends(verify_session),
) -> dict:
"""별칭 장부 [저장] — 고친 줄 · 지운 줄(층 쓰기) → `{씀, 줄}`."""
folder = _root(session, layer, owner, write=True)
return _call(folder, hopyo_ledger.put, folder, body.줄, _who(session))
@router.delete("/layers/{layer}/hopyo/{key}")
def hopyo_delete(
layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session)
@@ -34,14 +34,6 @@
overflow-wrap: break-word;
}
.m02-hopyo-ledger__table td:nth-child(1) {
min-width: 7em;
}
.m02-hopyo-ledger__table td:nth-child(2) {
min-width: 6em;
}
/* 우리 로직 칸 — 대상 여럿 = 칸 안 여러 줄(이름 · 키 · 몫 · 배 칸 · [옵션]) */
.m02-hopyo__parts {
min-width: 11em;
@@ -83,15 +75,6 @@
margin-left: var(--spacing-4);
}
/* 별칭 장부(338) — 위 층 줄 = 회색 읽기만 · 고친 줄 = 왼쪽 띠 */
.m02-hopyo-ledger__row.is-upper {
color: var(--color-text-muted);
}
.m02-hopyo-ledger__row.is-edited > td:first-child {
box-shadow: inset 3px 0 0 var(--color-accent);
}
/* 엑셀 보기(329) — 왼쪽 읽기 전용 시트 · 오른쪽 호표 목록 */
.m02-hopyo-sheet {
display: grid;
@@ -2,7 +2,7 @@
* M02_MasterTemplete_Hopyo_Api.ts
* 일위대가 실무자료(PLAN 324 · 338) 서버 길 — `/api/m02/layers/{층}/hopyo…` · 층 = 화면 층(`Api_Fetch` 지금 층) ·
* 올리기 = FormData(숨은 file 입력) · 그 밖 = 공용 `call`(서버 까닭 글 그대로) ·
* 목록 · 올림 · 엑셀 보기 머리(809-1 줄 표지 줄상태) · 격자 · 지우기 · 별칭 장부 · 붙여넣기 · 다시 잇기 —
* 목록 · 올림 · 엑셀 보기 머리(809-1 줄 표지 줄상태) · 격자 · 지우기 · 붙여넣기 · 다시 잇기 —
* [수정] 보기 = `Hopyo_View`(809-1) · 판정 · 검산 · 옮기기 길(785)은 809-1 에서 지움(요구 703).
* ========================================================================== */
@@ -97,39 +97,6 @@ export interface HopyoBuiltAll {
만듦?: { 번: number; 이름: string; 키: string }[];
}
/** 별칭 장부 짝 하나 */
export interface LedgerPair {
로직?: string;
자재?: string;
/** 고른 것 꼴(785-3 · 812-9 기계) — 갈래 + 키 */
갈래?: string;
키?: string;
입력값: Record<string, unknown>;
배: number;
몫: number | null;
}
/** 별칭 장부 한 줄(쓰기 꼴) */
export interface LedgerEntry {
원래: { 품명: string; 규격: string; 단위: string };
갈래: "짝" | "우리에 없음" | "안 씀";
짝: LedgerPair[];
엑셀조항?: unknown[];
}
/** 별칭 장부 보기 한 줄 — 층 · 고침(이 층 · 쓰기 권한) */
export interface LedgerRow extends LedgerEntry {
열쇠: string;
누가: { id: number; 이름: string | null } | null;
때: string | null;
처음파일: string | null;
층: Layer;
주인: string | null;
고침: boolean;
/** 739-1 M01 승인 상태 — 마스터 줄 = 승인 */
상태?: "승인" | "대기" | "반려";
}
const enc = encodeURIComponent;
/** 지금 화면 층(구조물 조합 층) */
export const pageScope = (): HopyoScope => ({ layer: getActiveLayer(), owner: getActiveOwner() });
@@ -149,21 +116,6 @@ export const readHopyo = (scope: HopyoScope, key: string): Promise<HopyoView> =>
export const readHopyoGrid = (scope: HopyoScope, key: string): Promise<HopyoGrid> =>
call(at(scope, `/hopyo-grid/${enc(key)}`));
export const readLedger = async (scope: HopyoScope): Promise<LedgerRow[]> =>
(await call<{ 줄: LedgerRow[] }>(at(scope, "/hopyo-ledger"))).줄;
/** 별칭 장부 [저장] — 고친 줄 · null(지움) · 이 층만 */
export const saveLedger = async (
scope: HopyoScope,
줄: Record<string, LedgerEntry | null>,
): Promise<LedgerRow[]> =>
(
await call<{ 줄: LedgerRow[] }>(at(scope, "/hopyo-ledger"), {
method: "PUT",
body: JSON.stringify({ 줄 }),
})
).줄;
/** [끝내기] · 화면 떠남(743-1) — `keepalive` = 페이지를 닫아도 요청은 감 */
export const deleteHopyo = (scope: HopyoScope, key: string, keepalive = false): Promise<unknown> =>
call(at(scope, `/hopyo/${enc(key)}`), { method: "DELETE", keepalive });
@@ -1,407 +0,0 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_Ledger.ts
* 일위대가 실무자료 별칭 장부 화면(PLAN 338-3 · 설계 4-4) — 본 칸 · 좌측 판 [별칭 장부] 로 엶.
* · 표 = 엑셀 품명 · 규격 · 단위 | 갈래 | 우리 로직(여럿) · 옵션 · 단위 환산 · 몫 | 누가 · 때 · 처음 파일 | 층(위 층 회색 읽기만)
* · 뜻(359-4 · 머리 한 줄 · 좌측 단추 title) = 엑셀 품명 ↔ 우리 로직 · 자재 짝을 기억해 두었다가 같은 품명이 나오면 먼저 제안
* · 할 일 = 찾기 · 갈래 거름 · 갈래 바꿈 · [로직 바꿈] · [항목 추가] · 단위 환산 · 몫 · [옵션] · [삭제] ·
* 위 층 줄 [내 층 복사] · 갈래 글 = 확인 창 드롭다운과 같음(짝 · 로직없음 · 사용안함 · 저장 값 그대로)
* · 고친 것 = sessionStorage `m02-hopyo-ledger:{층}:{주인}` → [저장] 때만 `PUT`(서버가 짝을 다시 검사)
* · 안 둠 = 발주처 칸 · 자동 올림 · 자동 합침 · 쓰인 횟수 · 회사로 올리기.
* · 739-1 중앙 관리 — 회사 · 개인 층 = 「내 제안 목록」(보기 · 고치기 · [거두기] · 승인 상태 대기 · 승인 · 반려) ·
* 자동 짝 = 마스터(M01 별칭 장부 승인)만 · 마스터 층(M01 화면) = 별칭 장부 그대로
* ========================================================================== */
import { createBadge, type BadgeTone } from "@ui/ui_template_badge";
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { loadingError, showLoading } from "@ui/ui_template_loading";
import { t as L } from "@ui/ui_template_locale";
import {
readLedger,
saveLedger,
type HopyoScope,
type LedgerEntry,
type LedgerPair,
type LedgerRow,
} from "./M02_MasterTemplete_Hopyo_Api";
import { fetchLogicInputs } from "./M02_MasterTemplete_Labor_Api";
import { dateTimeText } from "../common_util/common_util_time";
import { openLaborInput } from "./M02_MasterTemplete_Labor_Input";
import { openFindPicker } from "./M02_MasterTemplete_Find_Pick";
import { formatNumber } from "../common_util/common_util_money";
import "./M02_MasterTemplete_Hopyo.css";
export const LEDGER_TITLE = "별칭 장부";
/** 별칭 장부 뜻 한 줄(359-4) — 좌측 단추 title · 화면 머리 */
export const LEDGER_HINT =
"엑셀 품명 ↔ 우리 로직 · 자재 짝을 기억해 두었다가 같은 품명이 나오면 먼저 제안";
const KINDS = ["짝", "우리에 없음", "안 씀"] as const;
/** 갈래 화면 글 — 확인 창 드롭다운과 같음(저장 값 그대로) */
export const KIND_TEXT: Record<string, string> = {
짝: "짝",
"우리에 없음": "로직없음",
"안 씀": "사용안함",
};
const LAYER_TEXT: Record<string, string> = { system: "마스터", company: "회사", personal: "개인" };
/** 739-1 회사 · 개인 층 장부 화면 제목 */
export const MINE_TITLE = "내 제안 목록";
const STATE_TONE: Record<string, BadgeTone> = { 승인: "success", 대기: "warning", 반려: "danger" };
type Edits = Record<string, LedgerEntry | null>;
/** 화면 한 줄 — 서버 줄 위에 고친 것 */
export type LedgerView = LedgerRow & { 고친?: boolean };
export const ledgerKey = (scope: HopyoScope): string =>
`m02-hopyo-ledger:${scope.layer}:${scope.owner ?? ""}`;
function loadEdits(k: string): Edits {
try {
return JSON.parse(sessionStorage.getItem(k) ?? "{}") as Edits;
} catch {
return {};
}
}
function saveEdits(k: string, edits: Edits): void {
try {
if (Object.keys(edits).length) sessionStorage.setItem(k, JSON.stringify(edits));
else sessionStorage.removeItem(k);
} catch {
/* 캐시 못 씀 = 이 화면에만 남음 */
}
}
/** 쓰기 꼴 — 보기 칸(층 · 누가 · 때 …) 뺌 */
export const entryOf = (row: LedgerEntry): LedgerEntry => ({
원래: row.원래,
갈래: row.갈래,
짝:
row.갈래 === "짝"
? row.짝.map((p) => ({ ...p, 입력값: p.입력값 ?? {}, 몫: p.몫 ?? null }))
: [],
엑셀조항: row.엑셀조항 ?? [],
});
/** 서버 줄 + 고친 것 → 화면 줄 — 이 층 줄은 고친 값 · 지운 것 뺌 · 위 층에서 옮긴 줄은 이 층 줄로 더함 */
export function mergeRows(rows: LedgerRow[], edits: Edits, scope: HopyoScope): LedgerView[] {
const mine = rows.filter((r) => r.고침);
const out: LedgerView[] = [];
for (const r of mine) {
if (!(r.열쇠 in edits)) out.push(r);
else if (edits[r.열쇠]) out.push({ ...r, ...edits[r.열쇠]!, 고친: true });
}
for (const [key, entry] of Object.entries(edits)) {
if (!entry || mine.some((r) => r.열쇠 === key)) continue;
out.push({
...entry,
열쇠: key,
누가: null,
때: null,
처음파일: null,
층: scope.layer,
주인: scope.owner,
고침: true,
고친: true,
});
}
return [...out, ...rows.filter((r) => !r.고침)];
}
/** 짝 글 — 「GC000198 × 1 · 몫 0.1 · 소량감률=50」 */
export const pairText = (p: LedgerPair): string =>
[
`${p.로직 ?? p.자재 ?? [p.갈래, p.키].filter(Boolean).join(" ")} × ${p.배}`,
p.몫 != null ? `몫 ${p.몫}` : "",
Object.entries(p.입력값 ?? {})
.map(([k, v]) => `${k}=${formatNumber(v)}`)
.join(", "),
]
.filter(Boolean)
.join(" · ");
/** 찾기 · 갈래 거름 */
export const filterRows = (rows: LedgerView[], q: string, kind: string): LedgerView[] => {
const want = q.trim().toLowerCase();
return rows.filter(
(r) =>
(!kind || r.갈래 === kind) &&
(!want ||
`${r.원래.품명} ${r.원래.규격} ${r.짝.map(pairText).join(" ")}`
.toLowerCase()
.includes(want)),
);
};
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
const td = (text: string, cls = ""): HTMLElement =>
el("td", { text, className: cls, attrs: { title: text } });
const note = (text: string): HTMLElement =>
el("span", { className: "ui-text ui-text--muted", text, attrs: { "data-text": "14" } });
export interface LedgerOptions {
scope: HopyoScope;
/** 이 층 쓰기 권한 — 없으면 보기만 */
canWrite: boolean;
}
export function buildHopyoLedger(opt: LedgerOptions): HTMLElement {
const root = el("div", {
className: "m02-hopyo m02-hopyo-ledger",
attrs: { "data-hopyo-ledger": "" },
});
const k = ledgerKey(opt.scope);
const master = opt.scope.layer === "system";
let edits = loadEdits(k);
let rows: LedgerRow[] = [];
const search = el("input", {
className: "ui-input ui-input--sm m02-hopyo__search",
attrs: { type: "search", placeholder: "품명 · 규격 · 로직 찾기" },
}) as HTMLInputElement;
const kind = el("select", {
className: "ui-input ui-input--sm",
children: [
el("option", { text: "갈래 전체", attrs: { value: "" } }),
...KINDS.map((x) => el("option", { text: KIND_TEXT[x], attrs: { value: x } })),
],
}) as HTMLSelectElement;
const save = createButton({ label: "저장", variant: "filled", size: "sm" });
const drop = createButton({ label: "변경 취소", variant: "ghost", size: "sm" });
const body = el("tbody");
const keep = (): void => {
saveEdits(k, edits);
paint();
};
const edit = (r: LedgerView, next: Partial<LedgerEntry>): void => {
edits[r.열쇠] = entryOf({ ...entryOf(r), ...next });
keep();
};
function pairCell(r: LedgerView, mine: boolean): HTMLElement {
const pairs: HTMLElement[] = r.짝.map((p, at) => {
const parts: HTMLElement[] = [el("span", { text: pairText(p) })];
if (mine) {
const times = el("input", {
className: "ui-input ui-input--sm m02-hopyo__times",
attrs: { type: "number", step: "any", min: "0", value: String(p.배) },
}) as HTMLInputElement;
times.addEventListener("change", () =>
edit(r, { 짝: r.짝.map((x, i) => (i === at ? { ...x, 배: Number(times.value) } : x)) }),
);
parts.push(unitField(times, { from: r.원래.단위, to: "" }));
if (p.로직)
parts.push(
createButton({
label: "옵션",
variant: "ghost",
size: "sm",
onClick: () =>
void fetchLogicInputs([p.로직!]).then(
(got) =>
openLaborInput({
title: p.로직!,
logic: got.로직[0] ?? null,
values: p.입력값 as never,
memo: null,
readOnly: false,
done: (res) =>
edit(r, {
짝: r.짝.map((x, i) => (i === at ? { ...x, 입력값: res.입력값 } : x)),
}),
}),
(error) => showToast(why(error), "error"),
),
}),
createButton({
label: "×",
variant: "ghost",
size: "sm",
onClick: () => edit(r, { 짝: r.짝.filter((_, i) => i !== at) }),
}),
);
}
return el("div", { className: "m02-hopyo__part", children: parts });
});
if (mine && r.갈래 === "짝")
pairs.push(
createButton({
label: "항목 추가",
variant: "ghost",
size: "sm",
onClick: () =>
// 736-1 별칭 장부가 가리킬 수 있는 것 = 기초 로직 · 재료 둘(탭 막음)
openFindPicker({
layer: opt.scope.layer,
owner: opt.scope.owner,
kinds: ["logic", "material"],
logic: (x) => edit(r, { 짝: [...r.짝, { 로직: x.키, 입력값: {}, 배: 1, 몫: null }] }),
material: (e) =>
edit(r, { 짝: [...r.짝, { 자재: e.코드, 입력값: {}, 배: 1, 몫: null }] }),
}),
}),
);
return el("td", { className: "m02-hopyo__parts", children: pairs });
}
function rowOf(r: LedgerView): HTMLElement {
const mine = opt.canWrite && r.고침;
const kindCell = mine
? (() => {
const s = el("select", {
className: "ui-input ui-input--sm",
children: KINDS.map((x) => el("option", { text: KIND_TEXT[x], attrs: { value: x } })),
}) as HTMLSelectElement;
s.value = r.갈래;
s.addEventListener("change", () =>
edit(r, { 갈래: s.value as LedgerEntry["갈래"], 짝: s.value === "짝" ? r.짝 : [] }),
);
return el("td", { children: [s] });
})()
: td(KIND_TEXT[r.갈래] ?? r.갈래);
const acts: HTMLElement[] = [];
if (mine)
acts.push(
createButton({
label: master ? "삭제" : "거두기",
variant: "danger",
size: "sm",
onClick: () => {
edits[r.열쇠] = null;
keep();
},
}),
);
else if (opt.canWrite && !rows.some((x) => x.고침 && x.열쇠 === r.열쇠) && !edits[r.열쇠])
acts.push(
createButton({
label: "내 층 복사",
variant: "ghost",
size: "sm",
onClick: () => {
edits[r.열쇠] = entryOf(r);
keep();
},
}),
);
const when = [r.누가?.이름, dateTimeText(r.때, ""), r.처음파일].filter(Boolean).join(" · ");
return el("tr", {
className: `m02-hopyo-ledger__row${r.고침 ? "" : " is-upper"}${r.고친 ? " is-edited" : ""}`,
attrs: { "data-ledger": r.열쇠 },
children: [
td(r.원래.품명),
td(r.원래.규격),
td(r.원래.단위),
kindCell,
pairCell(r, mine),
td(when),
td(r.고침 ? "내 층" : (LAYER_TEXT[r.층] ?? r.층)),
el("td", {
attrs: { "data-state": r.상태 ?? "" },
children: r.고친 || !r.상태 ? [] : [createBadge(r.상태, STATE_TONE[r.상태])],
}),
el("td", { className: "m02-hopyo__acts", children: acts }),
],
});
}
function paint(): void {
const all = mergeRows(rows, edits, opt.scope);
const shown = filterRows(all, search.value, kind.value);
const n = Object.keys(edits).length;
save.querySelector(".ui-btn__label")!.textContent = n ? `저장 (${n})` : "저장";
save.disabled = drop.disabled = !n;
body.replaceChildren(
...(shown.length
? shown.map(rowOf)
: [el("tr", { children: [el("td", { text: "장부 줄 없음", attrs: { colspan: "9" } })] })]),
);
}
search.addEventListener("input", paint);
kind.addEventListener("change", paint);
drop.addEventListener("click", () => {
edits = {};
keep();
});
save.addEventListener("click", async () => {
save.disabled = true;
try {
rows = await saveLedger(opt.scope, edits);
edits = {};
showToast("장부 저장", "success");
keep();
} catch (error) {
showToast(`저장 못 함 — ${why(error)}`, "error");
paint();
}
});
async function load(): Promise<void> {
const stop = showLoading(root);
try {
rows = await readLedger(opt.scope);
} catch (error) {
root.replaceChildren(loadingError(why(error), () => void load()));
return;
} finally {
stop();
}
root.replaceChildren(
el("div", {
className: "ui-toolbar m02-hopyo__bar",
children: [
el("strong", { text: master ? LEDGER_TITLE : MINE_TITLE }),
note(
master
? `${LEDGER_HINT} · 여기 짝 = 모든 사용자 불러오기에 자동`
: `내 층 짝 = 내 제안(다음 불러오기에서 후보 맨 위 · 자동 아님) · M01 별칭 장부 승인 뒤 자동 · ${L("M02_PrecomboTitle")} 생성 때 남음`,
),
search,
kind,
...(opt.canWrite ? [save, drop] : []),
],
}),
el("div", {
className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head",
children: [
el("table", {
className: "ui-general-block__table m02-hopyo__table m02-hopyo-ledger__table",
children: [
el("thead", {
children: [
el("tr", {
children: [
"엑셀 품명",
"규격",
"단위",
"갈래",
"우리 로직 · 단위 환산 · 몫 · 옵션",
"누가 · 때 · 처음 파일",
"층",
"승인",
"",
].map((t) => el("th", { text: t })),
}),
],
}),
body,
],
}),
],
}),
);
paint();
}
void load();
return root;
}
/** 단위 환산 칸 — 「엑셀 1 {from} =」 입력 「{to}」(옛 확인 창 `CheckRows.unitField` 옮김 · 785-7) */
function unitField(input: HTMLInputElement, v: { from: string; to: string }): HTMLElement {
input.title = L("M02_HopyoUnitTip");
const word = (text: string): HTMLElement =>
el("span", { className: "m02-hopyo__unit-text", text });
return el("span", {
className: "m02-hopyo__unit",
children: [
word(L("M02_HopyoUnitLead").replace("{from}", v.from || "?")),
input,
...(v.to ? [word(v.to)] : []),
],
});
}
@@ -6,7 +6,7 @@
* · 결과 층 고름 = 작은 고름(쓸 수 있는 층 둘 이상일 때만 · 일반 = 개인 하나라 안 보임)
* · 「작업 중」 줄 = 층마다 서버 목록(같은 엑셀 작업 중 이어서) · 누르면 본 칸 불러오기 보기
* · 다른 엑셀 [불러오기] · 다른 화면으로 = 지금 보기 떠남(올린 파일 지움 · 743-1) — 고르고 안 만든 확인 캐시가 있으면 물음 한 번
* 아래 [별칭 장부](338-3) → 본 칸 장부 화면(`M02_MasterTemplete_Hopyo_Ledger` · 층 = 구조물 조합 층).
* 옛 [별칭 장부](338-3)는 걷음(815-2) — 별칭은 M01 요소 「별칭」 칸 · M01 별칭 모아 보기.
* ========================================================================== */
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
@@ -16,12 +16,10 @@ import { createSegment } from "@ui/ui_template_segment";
import { t as L } from "@ui/ui_template_locale";
import {
listHopyo,
pageScope,
uploadHopyo,
type HopyoFile,
type HopyoScope,
} from "./M02_MasterTemplete_Hopyo_Api";
import { buildHopyoLedger, LEDGER_HINT, LEDGER_TITLE } from "./M02_MasterTemplete_Hopyo_Ledger";
import {
buildHopyoImport,
LAYERS,
@@ -32,8 +30,6 @@ import {
import type { ManageLayer } from "./M02_MasterTemplete_Layer";
export interface HopyoSectionOptions {
/** 이 층 쓰기 권한 — 별칭 장부 고침(구조물 조합 층이 제자리에서 바뀌어 열 때 읽음 · 720-4) */
canWrite: () => boolean;
/** 저장 안 한 고침을 버릴지 확인 — 취소면 false */
confirmLeave: () => Promise<boolean>;
/** 메인 칸에 화면을 띄움 */
@@ -72,20 +68,9 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
const seg = el("div", { className: "m02-side__row" });
seg.hidden = true;
const jobs = el("div", { className: "m02-hopyo-side", attrs: { "data-hopyo-jobs": "" } });
const ledger = el("button", {
className: "m02-side__item ui-sidebar-row",
text: LEDGER_TITLE,
attrs: { type: "button", title: LEDGER_HINT, "data-hopyo-ledger-open": "" },
});
ledger.addEventListener("click", () => void showLedger());
const body = el("div", {
className: "m02-hopyo-side",
children: [
el("div", { className: "m02-side__row", children: [start, input] }),
seg,
jobs,
ledger,
],
children: [el("div", { className: "m02-side__row", children: [start, input] }), seg, jobs],
});
let open: { key: string; view: HTMLElement; imp?: HopyoImport } | null = null;
@@ -177,7 +162,6 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
file,
onEnded: () => {
open = null;
ledger.classList.remove("is-active");
opt.show(title, el("div", { className: "m02-hopyo" })); // 끝냄 — 빈 본 칸
void refresh();
},
@@ -185,19 +169,6 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
opt.show(`${title} — ${file.파일}`, imp.root);
opt.onOpen();
open = { key: file.키, view: imp.root, imp };
ledger.classList.remove("is-active");
void refresh();
}
async function showLedger(): Promise<void> {
if (open?.key === LEDGER_TITLE && open.view.isConnected) return;
if (!(await leaveOk())) return;
open?.imp?.leave();
const view = buildHopyoLedger({ scope: pageScope(), canWrite: opt.canWrite() });
opt.show(`${title} — ${LEDGER_TITLE}`, view);
opt.onOpen();
open = { key: LEDGER_TITLE, view };
ledger.classList.add("is-active");
void refresh();
}
@@ -205,7 +176,6 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
if (open && !open.view.isConnected) {
open.imp?.leave(); // 다른 화면으로 = 불러오기 떠남
open = null;
ledger.classList.remove("is-active");
void refresh();
}
};
@@ -69,7 +69,6 @@ export interface SangeunDetailOptions {
/** 수식 탭 저장 뒤 그 SK 새 판(페이지 [저장] 409 막음 · 802-4b-2) */
onVersion?: (version: string) => void;
slot?: HTMLElement; // 페이지 머리 줄 빈 자리(804-8) — 보기 고르기를 M01 처럼 머리 줄에
match?: Parameters<typeof createSangeunSide>[0]; // 원문 패널 판정 띠 단추(810-4)
}
const LIB = { read: "/m02/logic-lib", calc: "/m02/logic-lib" };
@@ -104,7 +103,7 @@ export function mountSangeunDetail(
const inputsOf = new Map<string, LogicInput[]>();
const infoOf = new Map<string, SourceTab>(); // 더한줄 로직 → 원문 탭(752-1)
let extraKeys: string[] = [];
const side = opt.source ? createSangeunSide(opt.match) : null;
const side = opt.source ? createSangeunSide() : null;
let seq = 0;
let alive = true;
@@ -1,85 +0,0 @@
/* =============================================================================
* M02_MasterTemplete_Sangeun_MatchBand.ts
* 가져온 식 ↔ 우리 기초 판정 띠(PLAN 810-4 · 요구 707 · 709 · 703) — 원문 패널 「어디서 왔나」 카드 맨 위.
* 글 · 단추 = `logic/M02_Sangeun_Match.ts` `matchBand` · 길 POST `/m02/layers/{층}/sangeun/{키}/match`
* {할일 찾기 | 바꾸기 | 되돌리기} — 서버가 다시 찾아 「확정」 일 때만 바꿈(화면 판정을 믿지 않음) ·
* 바꾸기 · 되돌리기 = 확인창 · 저장 안 한 고침이 있으면 막음(먼저 [저장]) · 끝나면 `done(판)` = 그 SK 를 다시 엶
* 층 쓰기 권한 없음(읽기만) = 단추 없음
* ========================================================================== */
import { createBanner } from "@ui/ui_template_banner";
import { createButton } from "@ui/ui_template_button";
import { el } from "@ui/ui_template_elements";
import { askConfirm } from "@ui/ui_template_modal";
import { createText } from "@ui/ui_template_text";
import { showToast } from "@ui/ui_template_toast";
import { call, type Layer } from "./M02_MasterTemplete_Api_Fetch";
import { matchBand, type MatchAction } from "./logic/M02_Sangeun_Match";
export interface MatchCtx {
layer: Layer;
owner: string | null;
key: string;
readOnly: boolean;
/** 저장 안 한 고침 있음 */
dirty: () => boolean;
/** 서버가 SK 를 고친 뒤(새 판) — 그 SK 다시 엶 */
done: (version: string) => void;
}
const LABEL: Record<Exclude<MatchAction, null>, string> = {
찾기: "우리 기초와 연결 찾기",
바꾸기: "우리 기초 사본으로 바꾸기",
되돌리기: "가져온 식으로 되돌리기",
};
const ASK: Partial<Record<Exclude<MatchAction, null>, string>> = {
바꾸기:
"이 단가산출을 우리 기초 사본(찾은 옵션 · 우리 값)으로 바꿈 — 규격만 다른 기계 줄은 가져온 규격의 우리 기계 · 키 그대로 · 일위대가 금액이 다시 셈됨 · 원래 가져온 식은 보관",
되돌리기: "우리 기초 사본을 원래 가져온 식으로 되돌림 — 가져온 식 금액 그대로",
};
/** 판정 띠 — 가져온 식 · 바꾼 SK 가 아니면 null */
export function createMatchBand(sk: unknown, ctx: MatchCtx | undefined): HTMLElement | null {
const band = matchBand(sk as Parameters<typeof matchBand>[0]);
if (!band) return null;
const act = band.action;
const actions =
act && ctx && !ctx.readOnly && ctx.key
? [
createButton({
label: LABEL[act],
variant: act === "바꾸기" ? "filled" : "ghost",
size: 24,
onClick: (ev) => void run(act, ctx, ev.currentTarget as HTMLButtonElement),
}),
]
: [];
const details = band.details.length
? el("div", { children: band.details.map((x) => createText(x, "muted", 12)) })
: undefined;
const root = createBanner({ tone: band.tone, text: band.text, details, actions });
root.setAttribute("data-sk-match", act ?? "");
return root;
}
async function run(act: Exclude<MatchAction, null>, ctx: MatchCtx, btn: HTMLButtonElement) {
if (ctx.dirty()) return void showToast("저장 안 한 고침이 있음 — 먼저 [저장]", "warning");
const ask = ASK[act];
if (ask && !(await askConfirm({ message: ask, okLabel: LABEL[act] }))) return;
const label = btn.textContent;
btn.disabled = true;
btn.textContent = act === "찾기" ? "찾는 중…" : "바꾸는 중…";
try {
const owner = ctx.owner ? `?owner=${encodeURIComponent(ctx.owner)}` : "";
const path = `/layers/${ctx.layer}/sangeun/${encodeURIComponent(ctx.key)}/match${owner}`;
const got = await call<{ 판?: string }>(path, {
method: "POST",
body: JSON.stringify({ 할일: act }),
});
ctx.done(got.판 ?? "");
} catch (e) {
showToast(e instanceof Error ? e.message : String(e), "error");
btn.disabled = false;
btn.textContent = label;
}
}
@@ -7,7 +7,7 @@
* 맨 위 「단가산출 기초 찾기」(807-1 · M01 원문 패널과 같은 `LogicLab_SourceFind`) — 고르면 그 기초 원문(보기만 · 기억 안 함) ·
* 지우면 처음 화면 · 처음 화면 = 우리 SK 는 기초 원문 · 가져온 식 SK 는 「어디서 왔나」 카드
* (프로그램 · 파일 · 블록 · 행 · 가져온 때 · 표지 요약 + 엑셀 글의 품셈 절 글만 — 찾기 · 잇기 없음)
* 카드 맨 위 = 우리 기초 판정 띠(810-4 · `Sangeun_MatchBand`) · 우리 기초 사본으로 바꾼 SK = 보관한 가져온 식 카드
* 옛 우리 기초 판정 띠(810-4)는 걷음(815-2) · 옛 바꾼 SK(`가져온보관`) = 보관한 가져온 식 카드(읽기만)
* 일위대가 줄 모달 안 상세는 안 만듦(부르는 쪽이 `source` 를 안 켬).
* ========================================================================== */
@@ -23,7 +23,6 @@ import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_S
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";
import { createMatchBand, type MatchCtx } from "./M02_MasterTemplete_Sangeun_MatchBand";
export interface SourceTab {
label: string;
@@ -63,7 +62,7 @@ const tx = (k: keyof typeof TEXT, n = 0): string =>
TEXT[k][currentLanguageIndex === 1 ? 1 : 0].replace("{n}", String(n));
/** 「어디서 왔나」 카드 — 표지는 셋까지(더 = 단추로 펼침) */
function originCard(o: Origin, band: HTMLElement | null): HTMLElement {
function originCard(o: Origin): HTMLElement {
const marks = el("div");
const showMarks = (all: boolean): void =>
marks.replaceChildren(
@@ -82,7 +81,6 @@ function originCard(o: Origin, band: HTMLElement | null): HTMLElement {
title: tx("Origin"),
pad: 12,
body: [
...(band ? [band] : []),
...(o.요약 ? [el("strong", { text: o.요약, attrs: { "data-origin-native": "" } })] : []),
createKv([
{ label: tx("Program"), value: o.프로그램 },
@@ -103,9 +101,9 @@ function originCard(o: Origin, band: HTMLElement | null): HTMLElement {
return card;
}
export function createSangeunSide(match?: MatchCtx): {
export function createSangeunSide(): {
wrap: (main: HTMLElement) => HTMLElement;
/** 원문 탭 · SK 문서(`가져온` · 바꾼 SK `가져온보관` — 있으면 첫 화면 = 카드 + 판정 띠) */
/** 원문 탭 · SK 문서(`가져온` · 옛 바꾼 SK `가져온보관` — 있으면 첫 화면 = 카드) */
set: (tabs: SourceTab[], sk?: unknown) => void;
} {
const view = createSourceView({
@@ -140,8 +138,7 @@ export function createSangeunSide(match?: MatchCtx): {
view.show(found);
return void content.replaceChildren(view.root);
}
if (origin)
return void content.replaceChildren(originCard(origin, createMatchBand(skDoc, match)));
if (origin) return void content.replaceChildren(originCard(origin));
if (!tabs.length)
return void content.replaceChildren(createText(L("M02_SangeunSourceNone"), "muted"));
at = Math.min(at, tabs.length - 1);
@@ -3,10 +3,8 @@
* 단가산출(SK) 상세 자리(717-2a) — 편집 칸 종류 sangeun 이 여는 곳(UI_Main 갈래 한 줄).
* 717-2b = 상세 · 고침(`Sangeun_Detail.ts` mountSangeunDetail) · [저장]은 그 손잡이 `getDoc` · 고침 알림 `opt.onChanged`.
* 804-8 = 페이지 [저장] 한 번에 수식도(`flush` 먼저 · 새 판) · 보기 고르기는 머리 줄 `slot`.
* 810-4 = 원문 패널 판정 띠 [바꾸기] · [되돌리기] · [연결 찾기] 뒤 그 SK 를 다시 읽어 상세를 다시 엶(새 판).
* ========================================================================== */
import { readTemplate } from "./M02_MasterTemplete_Api_Fetch";
import { mountSangeunDetail } from "./M02_MasterTemplete_Sangeun_Detail";
import type { Selection } from "./M02_MasterTemplete_UI_Main";
@@ -25,37 +23,9 @@ export function mountSangeunView(
doc: unknown,
opt: SangeunViewOptions,
): { getDoc: () => Promise<unknown>; destroy: () => void } {
let dirty = false;
const onChanged = (d: boolean): void => ((dirty = d), opt.onChanged(d));
/** 판정 띠 단추(810-4) — 서버가 SK 를 고친 뒤 새 판 · 그 SK 를 다시 읽어 상세를 다시 엶 */
const match = {
layer: opt.layer,
owner: opt.owner ?? null,
key: opt.name,
readOnly: opt.readOnly,
dirty: () => dirty,
done: (version: string): void => {
opt.onVersion?.(version);
void readTemplate(opt.layer, "sangeun", opt.name, null, opt.owner ?? null).then((got) => {
if (!alive) return;
one.destroy();
one = mount(got.문서);
onChanged(false);
});
},
};
const mount = (d: unknown) =>
mountSangeunDetail(host, d, {
...opt,
onChanged,
owner: opt.owner ?? null,
source: true,
match,
}); // 752-1
let alive = true;
let one = mount(doc);
const one = mountSangeunDetail(host, doc, { ...opt, owner: opt.owner ?? null, source: true }); // 752-1
return {
getDoc: () => one.flush().then(() => one.getDoc()),
destroy: () => ((alive = false), one.destroy()),
destroy: () => one.destroy(),
};
}
@@ -56,7 +56,7 @@ const STUB_DRAWING = {
export interface SideOptions {
/** 시스템 관리자 — 도면 양식(시스템 층에만 있음)을 늘 보임(720-4) */
isSystem: boolean;
/** 지금 구조물 조합 층을 고칠 수 있나(층이 제자리에서 바뀜) — 일위대가 불러오기 장부 */
/** 지금 구조물 조합 층을 고칠 수 있나(층이 제자리에서 바뀜) */
canWrite: () => boolean;
onOpen: (sel: Selection | null) => void;
getOpen: () => Selection | null;
@@ -164,7 +164,6 @@ export function buildSide(opt: SideOptions): SideHandle {
const drawing = section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing"));
// 일위대가 불러오기(324-4 · 제목은 718-1) · 층 = 구조물 조합 층 · 쓰기 권한 없으면 올리기 꺼짐
const hopyo = buildHopyoSection({
canWrite: opt.canWrite,
confirmLeave: opt.confirmLeave,
show: opt.showView,
onOpen: paint,
@@ -1,129 +0,0 @@
/* =============================================================================
* M02_Sangeun_Match.ts
* 가져온 식 ↔ 우리 M01 기초 연결 판정 띠 자료(PLAN 810-4 · 요구 707 · 709 · 703) — 원문 패널 카드 위.
* SK `가져온.원본.연결후보`(서버 `M02_Doc_Match` 가 적음) → 띠 글 · 다른 항 · 단추 하나:
* 확정 = [우리 기초 사본으로 바꾸기] · 근접 · 없음 = 단추 없음(703) · 안 찾음 = [연결 찾기] ·
* 다른 항 = 로직 차이(확정을 막음) · 참고 = 단가 · 끝수 · 율 값 %(값 차이 — 「우리 단가로 셈 ○○ 원」 · 812-2 · 812-4) ·
* 바꾼 SK(`가져온보관` 있음) = 「우리 기초 사본 ○○」 + [가져온 식으로 되돌리기] ·
* 같은 기종 규격 · 수량 차이 = 참고(812-11) · 기계 작업 = 기계 한 줄 단가산출(우리 기초에 안 붙임 · 단추 없음)
* 화면 · DOM · fetch 없음
* ========================================================================== */
export type MatchVerdict = "확정" | "근접" | "없음" | "기계 작업";
export interface MatchFound {
판정?: MatchVerdict;
키?: string;
이름?: string;
옵션?: Record<string, string>;
다른?: { 항: string; 글: string }[];
/** 값 차이(단가 · 끝수 · 율 값 · 규격 · 수량) — 확정을 안 막음 */
참고?: { 항: string; 글: string }[];
금액?: { 가져온?: Record<string, number | null>; 우리?: Record<string, number | null> };
후보수?: number;
가까움?: { 키?: string; 이름?: string; 다른?: { 항: string; 글: string }[] };
/** 우리 정보로 셈(814-1) — 자원 줄 이음 수 · 형제 단가산출 부름뿐 = 조합 */
우리정보?: { 됨?: number; 줄?: number; 조합?: boolean };
}
export type MatchAction = "찾기" | "바꾸기" | "되돌리기" | null;
export interface MatchBand {
tone: "success" | "info" | "warning";
text: string;
/** 다른 항 글(근접 · 없음의 가까운 후보) */
details: string[];
action: MatchAction;
}
interface SkLike {
바탕?: { 로직?: string } | null;
입력값?: Record<string, unknown>;
가져온?: { 로직?: unknown; 원본?: { 연결후보?: MatchFound } } | null;
가져온보관?: { 가져온?: { 원본?: { 연결후보?: MatchFound } } } | null;
}
const opts = (o?: Record<string, unknown>): string => {
const list = Object.entries(o ?? {}).map(([k, v]) => `${k} ${String(v)}`);
return list.length ? `(${list.join(" · ")})` : "(기본 옵션)";
};
const lines = (d?: { 항: string; 글: string }[]): string[] =>
(d ?? []).map((x) => `${x.항} — ${x.글}`);
const won = (n?: number | null): string =>
n == null ? "" : `${Math.round(n).toLocaleString("ko-KR")} 원`;
const QTY = ["수량", "수량 끝자리", "품 숫자"];
/** 참고 요약 — 「참고: 단가 다름 3 줄 · 율 값 다름 1 줄 · 우리 단가로 셈 53,218 원」 · 참고 없음 = "" */
const noteText = (f: MatchFound): string => {
const n = f.참고 ?? [];
if (!n.length) return "";
const price = n.filter((x) => x.항 === "단가 차이").length;
const rate = n.filter((x) => x.항 === "율 값").length;
const spec = n.filter((x) => x.항 === "규격").length;
const qty = n.filter((x) => QTY.includes(x.항)).length;
const bits = [
spec ? `규격 다름 ${spec} 줄` : "",
qty ? `수량 다름 ${qty} 줄` : "",
price ? `단가 다름 ${price} 줄` : "",
rate ? `율 값 다름 ${rate} 줄` : "",
n.some((x) => x.항 === "옵션 확인") ? "옵션 확인" : "",
n.some((x) => x.항 === "끝수") ? "끝수 다름" : "",
].filter(Boolean);
const ours = won(f.금액?.우리?.계);
return ` · 참고: ${bits.join(" · ")}${ours ? ` · 우리 단가로 셈 ${ours}` : ""}`;
};
/** SK → 띠 자료 · 가져온 식도 · 바꾼 SK 도 아니면 null */
export function matchBand(sk: SkLike | null | undefined): MatchBand | null {
const kept = sk?.가져온보관;
if (kept && sk?.바탕?.로직) {
return {
tone: "info",
text: `우리 기초 사본 ${sk.바탕.로직} ${opts(sk.입력값)} — 원래 가져온 식은 보관`,
details: [],
action: "되돌리기",
};
}
if (!sk?.가져온?.로직) return null;
const f = sk.가져온.원본?.연결후보;
if (!f?.판정) {
return { tone: "info", text: "우리 기초와 연결을 아직 안 찾음", details: [], action: "찾기" };
}
if (f.판정 === "기계 작업") {
return {
tone: "info",
text: "기계 한 줄 단가산출 — 그대로 씀 · 값은 [단가 갱신]으로 우리 값",
details: lines(f.참고),
action: null,
};
}
const base = `${f.키 ?? ""} ${f.이름 ?? ""} ${opts(f.옵션)}`.trim();
if (f.판정 === "확정") {
return {
tone: "success",
text: `확정 — 로직 같음 · 우리 기초 ${base}${noteText(f)}`,
details: lines(f.참고),
action: "바꾸기",
};
}
if (f.판정 === "근접") {
const kinds = [...new Set((f.다른 ?? []).map((x) => x.항))].join(" · ");
return {
tone: "warning",
text: `근접 — ${base} · 다른 항: ${kinds}${noteText(f)}`,
details: [...lines(f.다른), ...lines(f.참고).map((x) => `참고 ${x}`)],
action: null,
};
}
const near = f.가까움;
const mine = f.우리정보; // 「없음」 = 흠 아님(814-1 · 요구 745) — 우리 정보로 다시 만든 정도
const made = mine?.줄 ? ` — 우리 정보로 셈 ${mine.됨 ?? 0}/${mine.줄} 줄` : "";
return {
tone: "info",
text: mine?.조합
? `조합 — 다른 단가산출 ${mine.줄 ?? 0} 개 묶음(안의 단가산출로 셈)`
: `우리 기초 없음${made}`,
details: near ? [`가장 가까운 ${near.키 ?? ""} ${near.이름 ?? ""}`, ...lines(near.다른)] : [],
action: null,
};
}
+3 -4
View File
@@ -549,10 +549,9 @@ app.include_router(b09_estimation_router, dependencies=protected_with_company)
# 마스터 데이터 — 회사·프로젝트가 아니라 시스템 관리자만 본다.
system_admin_only = [Depends(verify_session), Depends(require_system_admin)]
app.include_router(m01_master_data_router, dependencies=system_admin_only)
app.include_router(
__import__("M01_MasterData.M01_MasterData_Alias", fromlist=["router"]).router,
dependencies=system_admin_only,
) # 별칭 장부 모으기(718-2)
_m01_alias = __import__("M01_MasterData.M01_MasterData_Alias", fromlist=["router"])
app.include_router(_m01_alias.router, dependencies=system_admin_only) # 별칭 모아 보기(815-2)
app.include_router(_m01_alias.suggest_router, dependencies=protected) # 별칭 제안 — 로그인만
# 단위 · 금액 표준 표 읽기(`GET /api/m02/df/standards` · 단위 고르기 `M02_DF_UnitPick`) — 읽기만 · 로그인만(M02 줄 쌓기 단위)
app.include_router(
__import__("M02_MasterTemplete.DataFlow.M02_DF_Router", fromlist=["router"]).router,
-144
View File
@@ -1,144 +0,0 @@
"""810-4 전수 도우미 — 원파일 산근 블록 전부(EST 6 · STC 7) × M01 수식 문서 1,518 연결 찾기.
파일마다 프로세스 · 안쪽 산근(형제)은 같은 파일 블록을 우리 단가로 먼저 셈해 묶음 값으로 넣음(저장 층의
형제 SK 읽기와 같은 값) · 확정은 M01 문서를 찾은 옵션(입력값 꼴)으로 다시 셈해 금액 대조.
마지막 줄 = JSON {블록, 판정, 근접항, 확정참고, 확정참고블록, 오류, 확정다시셈다름} · `--rows <pkl>` = 줄 목록 저장(목록 xlsx 용).
"""
from __future__ import annotations
import collections
import json
import os
import pickle
import sys
import time
from multiprocessing import Pool
from pathlib import Path
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
for p in (ROOT, HERE):
if str(p) not in sys.path:
sys.path.insert(0, str(p))
def _ctx():
import test_810_2_native_doc as t
from M02_MasterTemplete import M02_Doc_Import as dim
from M02_MasterTemplete import M02_Doc_Match as dm
whole = t.whole()
docs = dm.ml.load_all()
aims = {k: d for k, d in docs.items() if not d.get("부품")} # 화면과 같은 과녁(813-2)
return t, dim, dm, whole, docs, dm.Index(aims), dm.Memo(dm.ml.Links(whole, docs))
def file_docs(t, dim, dm, whole, kind: str, tag: str) -> dict[int, dict]:
"""파일 블록 → 가져온 식 문서(형제 = 우리 단가 묶음)."""
saved = t.native(kind, tag)
nums = t.fn.numbers(saved)
by_code = {str(b.get("코드")): n for n, b in nums.items()}
links = dim.Links(whole, "우리")
made: dict[int, dict] = {}
ours: dict[int, dict | None] = {}
def build(no: int, trail: tuple = ()) -> None:
if no in made:
return
out = t.fn.emit(saved, no)
doc = dim.derive(t.sk_of(out), None, whole)
for h in out["로직"]["호표"]:
res = h.get("자원") or {}
n = by_code.get(str(res.get("코드")))
if res.get("갈래") != "단가산출" or n is None or n in trail or n == no:
continue
build(n, (*trail, no))
got = ours.get(n)
line = next(
(x for x in doc["금액"] if x["이름"] == h["이름"] and x.get("비목") == "묶음"), None
) # noqa: E501
if not line or not got:
continue
for v in doc["변수"]:
if dm.md.var_id(v) == line["단가"] and (v.get("연결") or {}).get("갈래") == "숫자":
v["연결"] = {
"갈래": "숫자",
"값": {k: got[k] for k in dm.md.COSTS},
} # 수 그대로(814-1)
made[no] = doc
ours[no] = dm._calc(doc, links, {})
for no in nums:
build(no)
return made
PARTS = 10 # 파일마다 블록을 나눠 맡김(큰 파일 한 프로세스가 끝을 붙잡지 않게)
def sweep(arg: tuple[str, str, int]) -> list[dict]:
kind, tag, part = arg
t, dim, dm, whole, docs, index, links = _ctx()
rows = []
try:
made = file_docs(t, dim, dm, whole, kind, tag)
except Exception as e: # noqa: BLE001 — 원파일 없음(다른 PC) = 빈 줄
if part:
return []
return [{"파일": tag, "오류": repr(e)[:200], "판정": "오류", "없는파일": "없음" in repr(e)}]
for no, doc in made.items():
if no % PARTS != part:
continue
start = time.time()
try:
res = dm.match(doc, dim.Links(whole, "우리"), index, links)
except Exception as e: # noqa: BLE001
res = {"판정": "오류", "까닭": repr(e)[:200]}
row = {"파일": tag, "꼴": kind, "번": no, "블록": doc["이름"], "단위": doc["결과단위"]}
row |= {"규격": (doc.get("가져온") or {}).get("규격"), **res}
if res.get("판정") == dm.SURE:
again = dm._calc(docs[res["키"]], dm.ml.Links(whole, docs), dict(res["옵션"]))
money = res["금액"] # 다시 셈 = 우리 · 참고 없으면 가져온 = 우리(812-2)
row["다시셈같음"] = (
again is not None
and dm._plain(dm._totals(again)) == money["우리"]
and (bool(res.get("참고")) or money["우리"] == money["가져온"])
)
row["초"] = round(time.time() - start, 2)
rows.append(row)
return rows
def main() -> None:
import test_810_1_estx as e
import test_810_1_stc as c
tags = [("EST", k) for k in e.FILES] + [("STC", k) for k in c.FILES]
tags = [(kind, tag, part) for kind, tag in tags for part in range(PARTS)]
with Pool(min(len(tags), max(2, (os.cpu_count() or 4) - 2))) as pool:
rows = [r for part in pool.map(sweep, tags) for r in part]
if "--rows" in sys.argv:
pickle.dump(rows, open(sys.argv[sys.argv.index("--rows") + 1], "wb"))
real = [r for r in rows if not r.get("없는파일")]
out = {
"블록": sum(1 for r in real if r.get("판정") != "오류" or "번" in r),
"판정": dict(collections.Counter(r["판정"] for r in real)),
"근접항": dict(
collections.Counter(d["항"] for r in real if r["판정"] == "근접" for d in r["다른"])
), # noqa: E501
"확정참고": dict(
collections.Counter(
d["항"] for r in real if r["판정"] == "확정" for d in r.get("참고") or []
)
),
"확정참고블록": sum(1 for r in real if r["판정"] == "확정" and r.get("참고")),
"오류": sum(1 for r in real if r["판정"] == "오류"),
"확정다시셈다름": sum(1 for r in real if r.get("다시셈같음") is False),
}
print(json.dumps(out, ensure_ascii=False))
if __name__ == "__main__":
main()
+3 -51
View File
@@ -206,25 +206,9 @@ def test_못_잇는_짝(geo):
assert lk.link(user, rows, [], "take")[1]["이음"] == 0
@pytest.mark.parametrize(
"role, layer", [("USER", "personal"), ("ADMIN", "company")], ids=["일반", "회사관리자"]
)
def test_기억_층(tmp_path, monkeypatch, role, layer):
monkeypatch.setattr(route.access, "folder", lambda got, create=False: tmp_path / got.layer)
monkeypatch.setattr(route.m02_store, "FOLDER", tmp_path / "system")
master = {"a||": {"확정": "earth:9"}, "m||": {"확정": "earth:1"}} # 시스템 승인(718-2)
hopyo_store.remember(tmp_path / "system", master, lk.MEMORY)
session = {"company_id": 7, "user_id": 3, "role": role}
assert route.keep(session, {"a||": {"확정": "earth:4", "이름": "절토(토사)"}}) == layer
assert route.keep(session, {}) is None
hopyo_store.remember(
tmp_path / "company", {"a||": {"확정": "earth:5"}, "b||": {"확정": "x"}}, lk.MEMORY
)
got = route.recalled(session)
assert got["a||"]["확정"] == ("earth:4" if layer == "personal" else "earth:5")
assert got["b||"]["확정"] == "x"
assert got["m||"]["확정"] == "earth:1" # 회사 · 개인 줄이 마스터보다 앞섬(a = 아래 층)
assert route.recalled({"user_id": 3, "role": role}) == master # 회사 없음 = 마스터만
def test_기억_길_걷음():
"""공사 내역 맞춤 기억(층 `내역맞춤.json` 읽기 · 쓰기 · 잊기)은 걷음(815-2 · 요구 765-2)."""
assert not any(hasattr(route, x) for x in ("recalled", "keep", "forget", "ForgetBody"))
def _same(a, b):
@@ -275,38 +259,6 @@ def test_줄마다_갈래_판(geo):
assert not inner & left # 구조물 묶음에 딸린 줄 = 남은 줄 아님
def test_이_짝_잊기(tmp_path, monkeypatch):
monkeypatch.setattr(route.access, "folder", lambda got, create=False: tmp_path / got.layer)
user = {"company_id": 7, "user_id": 3, "role": "USER"}
gone = {lk.FORGOT: True}
route.keep(user, {"a||": {"확정": "earth:4"}, "b||": {"확정": "earth:5"}})
hopyo_store.remember(tmp_path / "company", {"c||": {"확정": "x"}}, lk.MEMORY)
assert route.keep(user, {"a||": gone}) == "personal"
assert route.recalled(user)["a||"] == gone and route.recalled(user)["b||"]["확정"] == "earth:5"
route.keep(user, {"c||": gone}) # 일반 = 회사 기억을 개인 층 표시로 덮음
assert route.recalled(user)["c||"] == gone
def test_잊은_짝_느슨한_기억에도_안_나옴(tmp_path, monkeypatch):
monkeypatch.setattr(route.access, "folder", lambda got, create=False: tmp_path / got.layer)
user = {"company_id": 7, "user_id": 3, "role": "USER"}
row = {"name": "토사절취", "spec": "A", "unit": "m3"}
other = {"name": "토사절취", "spec": "B", "unit": "m3"}
live = {"확정": "earth:4", "이름": "절토(토사)"}
route.keep(user, {lk.key_of(other): live})
assert (
lk.recall(route.recalled(user), row) == live
) # 규격 달라도 품명 · 단위 같음 = 느슨한 기억
route.keep(user, {lk.key_of(row): {lk.FORGOT: True}})
memory = route.recalled(user)
assert lk.recall(memory, row) is None # 잊음 = 느슨한 기억도 끊김
assert lk.recall(memory, other) == live # 다른 줄은 그대로
third = {"name": "토사절취", "spec": "C", "unit": "m3"}
assert lk.recall(memory, third) is None # 잊은 짝 곁에서는 느슨하게 안 좁힘
route.keep(user, {lk.key_of(row): live}) # 다시 이음 = 풀림
assert lk.recall(route.recalled(user), row) == live
def test_잊은_짝_짝_표_제안에도_안_나옴(geo):
_user, rows = _load(geo)
now = {s["key"]: s for s in lk.suggest(rows, {})}
-134
View File
@@ -1,134 +0,0 @@
"""별칭 장부 시스템 모으기(PLAN 718-2 · `M01_MasterData_Alias`) — 회사 · 개인 층 맞춤 +
내역맞춤 모음 · 같은 짝 묶기 · 회사 수 · 사람 수(익명) · 충돌 · 승인 → 마스터 줄 ·
반려 기억 · 다른 회사 새 사용자가 마스터 줄을 씀
· 시스템 관리자만 · master_data 원본 그대로. 층 · storage 는 임시 폴더.
"""
import json
from pathlib import Path
import pytest
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
from B09_Estimation import B09_Estimation_Bill_Link as lk
from B09_Estimation import B09_Estimation_Router_Link as route
from common_util.common_util_auth import require_system_admin, verify_session
from M01_MasterData import M01_MasterData_Alias as alias
from M02_MasterTemplete import M02_Hopyo_Ledger as ledger
from M02_MasterTemplete import M02_Hopyo_Store as hs
from M02_MasterTemplete import M02_Template_Layers as layers
# 올리기 길 시험의 `client` 마당(임시 storage · 마스터 층)을 같이 씀
# ruff: noqa: F811
from resources.tester.test_m02_hopyo_excel import client, master_hash # noqa: F401
DIG = {"이름": "구조물터파기(인력100%)", "규격": "토사", "단위": "㎥"}
DIG_KEY = "구조물터파기(인력100%)|토사|m3"
FORM_KEY = "합판거푸집|4회|m2"
CUT_KEY = "토사절취||m3"
ROOT = Path(__file__).resolve().parents[2]
def _pair(code):
return ledger.check(DIG_KEY, {"갈래": "짝", "짝": [{"로직": code, "배": 1}]})
def _seed():
"""회사 7(개인 42 · 43 = 같은 짝) · 회사 8(개인 50 = 다른 짝 · 회사 층 = 안 씀) · 내역맞춤."""
for user, who in ((42, "갑"), (43, "을")):
mine = layers.personal_dir(7, user)
ledger.write(mine, {DIG_KEY: _pair("GF000101")}, {"id": user, "이름": who}, "가공사.xls")
ledger.write(layers.personal_dir(8, 50), {DIG_KEY: _pair("GF000103")}, {"id": 50}, "나.xls")
skip = ledger.check(FORM_KEY, {"갈래": "안 씀"})
ledger.write(layers.company_dir(8), {FORM_KEY: skip}, {"id": 51, "이름": "병"}, "다.xls")
live = {"확정": "earth:4", "이름": "절토(토사)"}
hs.remember(layers.personal_dir(7, 42), {CUT_KEY: live, "a||": {lk.FORGOT: True}}, lk.MEMORY)
hs.remember(layers.company_dir(8), {CUT_KEY: live}, lk.MEMORY)
def _one(got, kind, key, code=None):
rows = [x for x in got["후보"] if (x["종류"], x["열쇠"]) == (kind, key)]
if code:
rows = [x for x in rows if x["짝"][0]["로직"] == code]
assert len(rows) == 1, rows
return rows[0]
def test_모음_묶기_익명_충돌(client):
before = master_hash()
_seed()
got = alias.gather()
assert got["모은곳"] == {"회사": 2, "사람": 5} and got["반려"] == 0
assert len(got["후보"]) == 4 # 터파기 둘(충돌) · 거푸집 안 씀 · 토사절취 내역맞춤(같은 짝 하나)
same = _one(got, alias.ALIAS, DIG_KEY, "GF000101")
assert (same["회사"], same["사람"], same["충돌"], same["마스터"]) == (1, 2, True, None)
assert _one(got, alias.ALIAS, DIG_KEY, "GF000103")["사람"] == 1
form = _one(got, alias.ALIAS, FORM_KEY)
assert (form["갈래"], form["충돌"], form["사람"]) == ("안 씀", False, 1)
cut = _one(got, alias.BILL, CUT_KEY)
assert (cut["회사"], cut["사람"], cut["확정"], cut["충돌"]) == (2, 2, "earth:4", False)
text = json.dumps(got, ensure_ascii=False)
for secret in ("갑", "을", "병", "가공사.xls", "누가", "처음파일", "주인"):
assert secret not in text # 익명 — 회사 수 · 사람 수만
assert master_hash() == before
def test_승인_마스터_줄_반려_기억(client, tmp_path, monkeypatch):
before = master_hash()
_seed()
newbie = layers.personal_dir(9, 99) # 처음 쓰는 다른 회사 사용자
assert ledger.recall(DIG, newbie) is None
sig = _one(alias.gather(), alias.ALIAS, DIG_KEY, "GF000101")["서명"]
got = alias.approve(alias.ALIAS, DIG_KEY, sig, {"id": 1, "이름": "관리자"})
hit = ledger.recall(DIG, newbie)
assert (hit["층"], hit["짝"][0]["로직"]) == ("system", "GF000101") # 승인 → 모두가 씀
master = ledger.read(client.system)
assert master[DIG_KEY]["처음파일"] is None # 마스터 줄에도 남의 파일 이름 없음
other = _one(got, alias.ALIAS, DIG_KEY) # 승인한 짝 = 마스터와 같음 → 후보에서 빠짐
assert other["짝"][0]["로직"] == "GF000103"
assert (other["마스터"], other["충돌"]) == ("다름", True)
got = alias.reject(alias.ALIAS, DIG_KEY, other["서명"], {"id": 1})
assert got["반려"] == 1 and not [x for x in got["후보"] if x["열쇠"] == DIG_KEY]
assert alias.gather()["반려"] == 1 # 반려 기억 = 다시 모아도 안 보임
with pytest.raises(alias.store.StoreError) as error:
alias.approve(alias.ALIAS, DIG_KEY, other["서명"], {"id": 1})
assert error.value.status == 404
with pytest.raises(alias.store.StoreError):
alias.approve("모름", DIG_KEY, sig, {"id": 1})
# 내역맞춤 승인 → 마스터 기억 → 다른 회사 새 사용자 B09 잇기 기억에 나옴
cut = _one(alias.gather(), alias.BILL, CUT_KEY)
alias.approve(alias.BILL, CUT_KEY, cut["서명"], {"id": 1})
assert [r["열쇠"] for r in alias.master_bill()] == [CUT_KEY]
monkeypatch.setattr(route.access, "folder", lambda got, create=False: tmp_path / got.layer)
user = {"company_id": 9, "user_id": 99, "role": "USER"}
assert route.recalled(user)[CUT_KEY]["확정"] == "earth:4"
assert alias.forget_bill(CUT_KEY) == [] and CUT_KEY not in route.recalled(user)
assert master_hash() == before
def test_길_시스템_관리자만(client):
_seed()
app = FastAPI()
app.include_router(alias.router, dependencies=[Depends(require_system_admin)])
who = {"user_id": 1, "company_id": None, "role": "USER", "name": "갑"}
app.dependency_overrides[verify_session] = lambda: who
web = TestClient(app)
for role in ("USER", "ADMIN"):
who["role"] = role
assert web.get("/api/m01/alias-ledger").status_code == 403
who["role"] = "SYSTEM_ADMIN"
got = web.get("/api/m01/alias-ledger").json()
assert len(got["후보"]) == 4 and got["마스터내역"] == []
form = _one(got, alias.ALIAS, FORM_KEY)
pick = {"종류": alias.ALIAS, "열쇠": FORM_KEY, "서명": form["서명"]}
res = web.post("/api/m01/alias-ledger/approve", json=pick)
assert res.status_code == 200 and len(res.json()["후보"]) == 3
assert ledger.read(client.system)[FORM_KEY]["갈래"] == "안 씀"
assert web.post("/api/m01/alias-ledger/approve", json=pick).status_code == 404
assert web.post("/api/m01/alias-ledger/reject", json={**pick, "서명": "x"}).status_code == 422
gone = web.post("/api/m01/alias-ledger/master-bill/forget", json={"열쇠": CUT_KEY})
assert gone.status_code == 404
main = (ROOT / "main.py").read_text(encoding="utf-8")
at = main.index("M01_MasterData.M01_MasterData_Alias")
assert "dependencies=system_admin_only" in main[at : at + 120]
-5
View File
@@ -52,9 +52,6 @@ def test_721_2_부르는_곳_셋만_새_창():
):
text = _text(f)
assert "openLogicPicker(" in text and "sangeun:" not in text, f.name
for f in ("Hopyo_Ledger",):
text = _text(M02 / f"M02_MasterTemplete_{f}.ts")
assert "openFindPicker({" in text and "openLogicPicker" not in text.split("*/", 1)[1], f
# 탭 셋 · 찾기 칸 하나 · 거름 고르기 없음 · M01 = 로직 · 인력 · 재료(기계 · 호표 없음)
for key in ('key: "m01"', 'key: "sk"', 'key: "pre"'):
assert key in find
@@ -125,8 +122,6 @@ def test_736_1_항목_추가_찾기_창_하나():
find = _text(M02 / "M02_MasterTemplete_Find_Pick.ts")
assert 'kind === "재료" && opt.material' in find and "? opt.material" in find
assert '"logic" | "labor" | "material" | "machine" | "oil" | "sk" | "pre"' in find # 785-3 oil
# 별칭 장부 = 기초 · 재료 둘(옛 확인 창 고르기는 785-7 에서 지움)
assert 'kinds: ["logic", "material"],' in _text(M02 / "M02_MasterTemplete_Hopyo_Ledger.ts")
def test_736_2_빠진_길_둘_신규_자재_기계_탭():
@@ -1,85 +0,0 @@
"""별칭 장부 중앙 관리(PLAN 739-1 · `M02_Hopyo_Ledger` ·
`M01_MasterData_Alias`) — 자동 짝 = 마스터(승인) · 이 불러오기에서 이은 짝만 · 회사 · 개인 층 짝 =
「내 제안」(자동 0 · 꼬리표 · 고르기 맨 위 `제안`) · 이은 짝 → 층 장부(불러오기 표시) → M01 후보 ·
승인 뒤 다음 불러오기 자동 · 장부 화면 승인 상태(대기 · 승인 · 반려) · master_data 원본 그대로.
층 · storage 는 임시 폴더.
"""
from M01_MasterData import M01_MasterData_Alias as alias
from M02_MasterTemplete import M02_Hopyo_Ledger as ledger
from M02_MasterTemplete import M02_Template_Layers as layers
# 올리기 길 시험의 `client` 마당(임시 storage · 마스터 층)을 같이 씀
# ruff: noqa: F811
from resources.tester.test_m02_hopyo_excel import URL, _post, client, master_hash # noqa: F401
DIG = {"이름": "구조물터파기(인력100%)", "규격": "토사", "단위": "㎥"}
DIG_KEY = "구조물터파기(인력100%)|토사|m3"
WHO = {"id": 42, "이름": "갑"}
ADMIN = {"id": 1, "이름": "관리자"}
LEDGER = "/api/m02/layers/{}/hopyo-ledger"
FROM_LEDGER = ("별칭", "뺌") # 장부가 자동으로 정한 줄 상태
def _pair(code):
return ledger.check(DIG_KEY, {"갈래": "짝", "짝": [{"로직": code, "배": 1}]})
def _candidate(key, code):
got = [
x
for x in alias.gather()["후보"]
if x["열쇠"] == key and x["종류"] == alias.ALIAS and x["짝"][0]["로직"] == code
]
assert len(got) == 1, got
return got[0]
def test_장부_층_짝은_제안_이_불러오기_짝은_자동_승인_뒤_모두_자동(client):
before = master_hash()
mine = layers.personal_dir(7, 42)
here = ledger.doc_id(mine, "HP-000001")
assert here == "personal/7/42/HP-000001"
ledger.write(mine, {DIG_KEY: _pair("GF000101")}, WHO, "가.xls", here)
got = ledger.recall(DIG, mine, doc=here)
assert (got["층"], got["제안"]) == ("personal", False) # 이 불러오기에서 이은 짝 = 바로 씀
later = ledger.doc_id(mine, "HP-000002") # 다음 불러오기
for doc in (later, None):
got = ledger.recall(DIG, mine, doc=doc)
assert (got["층"], got["짝"][0]["로직"], got["제안"]) == ("personal", "GF000101", True)
ledger.put(mine, {DIG_KEY: {**_pair("GF000101"), "엑셀조항": []}}, WHO) # 장부 화면 고침
assert ledger.read(mine)[DIG_KEY]["불러오기"] == here # 이은 불러오기 표시는 남음
assert [r["상태"] for r in ledger.rows(mine, True)] == ["대기"]
# M01 후보에 보임 → 승인 → 마스터 → 다음 불러오기 · 다른 회사 새 사용자도 자동
sig = _candidate(DIG_KEY, "GF000101")["서명"]
alias.approve(alias.ALIAS, DIG_KEY, sig, ADMIN)
for folder, doc in ((mine, later), (layers.personal_dir(9, 99), None)):
got = ledger.recall(DIG, folder, doc=doc)
assert (got["층"], got["짝"][0]["로직"], got["제안"]) == ("system", "GF000101", False)
assert [(r["층"], r["상태"]) for r in ledger.rows(mine, True)] == [
("personal", "승인"),
("system", "승인"),
]
# 다른 짝 제안 → 반려 = 상태 반려 · 여전히 내 제안(자동 아님)
other = layers.personal_dir(7, 43)
ledger.write(other, {DIG_KEY: _pair("GF000103")}, {"id": 43}, "나.xls")
alias.reject(alias.ALIAS, DIG_KEY, _candidate(DIG_KEY, "GF000103")["서명"], ADMIN)
assert ledger.rows(other, True)[0]["상태"] == "반려"
got = ledger.recall(DIG, other)
assert (got["층"], got["짝"][0]["로직"]) == ("system", "GF000101") # 자동 = 마스터
assert master_hash() == before
def _hopyo(client, no):
res = client.get(f"{URL.format('personal')}/HP-000001/{no}")
assert res.status_code == 200, res.text
return res.json()["호표"]
def _lines(client, no):
return {one["이름"]: one["맞춤"] for one in _hopyo(client, no)["줄"]}
def _auto(client, no):
"""장부가 자동으로 정한 줄 수."""
return sum(1 for m in _lines(client, no).values() if m["상태"] in FROM_LEDGER)
-225
View File
@@ -1,225 +0,0 @@
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
"""810-4 지킴 — 가져온 식 ↔ 우리 M01 기초 연결 찾기(`M02_Doc_Match`) · 확정일 때만 바꾸기(`M02_Doc_MatchSk`).
(a) 판정 — 거창 원파일 블록: 확정(콘크리트 표층 인력포설 = GC000297 형식 B-Type · 금액 원 단위 같음) ·
근접(합판거푸집 4회 = GC000211 보통 · 4회 — 단가 차이 · 비목 · 율) · 요구 704 파형강관 매설 Φ800 =
GF000179 관경 800 근접 · 다른 항 = 단위 환산(× 6) · 기계 규격 · 율 줄 · 없음(후보 0)
(b) 옵션 풀이 — 줄 수량이 의존하는 변수만 돌려 좁힘 · 글 근거 값과 다르면 「옵션 글」 다른 항(확정 아님)
(c) 줄 대조 — 같음 · 단위 환산 · 율 줄 = 다른 항 · 단가 차이 · 끝수 = 참고(812-2 · 확정을 안 막음)
(d) 바꾸기 — 임시 층 개인: 확정 SK = 같은 키에 M01 사본(바탕 · 입력값) · 금액 = M01 문서 셈 · 원래 몸 보관 →
되돌리기 = 원래 금액 · 근접 SK 바꾸기 = 409 · M01 문서 파일 그대로
(e) 전수(1,518 문서 × 원파일 산근 2,220 블록 · 파일마다 프로세스) — 확정은 모두 다시 셈해도 금액 같음 ·
판정 셋 · 근접 다른 항 갈래 표(S8104_ALL=1 일 때만 · 수십 분 · S8104_ROWS=<pkl> = 줄 목록 저장)
원파일 없으면 그 시험만 skip(다른 PC).
"""
from __future__ import annotations
import os
import sys
from decimal import Decimal as D
from functools import lru_cache
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
HERE = Path(__file__).resolve().parent
for p in (ROOT, HERE):
if str(p) not in sys.path:
sys.path.insert(0, str(p))
import test_810_2_native_doc as t2 # noqa: E402
from M02_MasterTemplete import M02_Doc_Import as dim # noqa: E402
from M02_MasterTemplete import M02_Doc_Match as dm # noqa: E402
from M02_MasterTemplete import M02_Doc_MatchSk as msk # noqa: E402
from M02_MasterTemplete import M02_MasterTemplete_Store as store # noqa: E402
from M02_MasterTemplete import M02_Sangeun_FromHopyo as fh # noqa: E402
from resources.tester.test_m02_labor_ref import world # noqa: E402, F401 — 같은 임시 층
@lru_cache(maxsize=1)
def ctx():
whole = t2.whole()
docs = dm.ml.load_all()
return whole, docs, dm.Index(docs), dm.Memo(dm.ml.Links(whole, docs))
def block(no: int, tag: str = "거창") -> dict:
"""원파일 블록 → 가져온 식 문서(형제 산근 없는 블록만 씀 — 형제는 저장 층에서 읽음)."""
out = t2.fn.emit(t2.native("EST", tag), no)
return dim.derive(t2.sk_of(out), None, t2.whole())
def judge(no: int) -> dict:
whole, _, index, links = ctx()
return dm.match(block(no), dim.Links(whole, "우리"), index, links)
# ── (a) 판정 ─────────────────────────────────────────────────────────────
def test_sure_one_block() -> None:
got = judge(33) # 콘크리트 포장포설 t=20cm이하
assert got["판정"] == dm.SURE and got["키"] == "GC000297"
assert got["옵션"] == {"형식": "B-Type"} and not got["다른"]
assert got["금액"]["가져온"] == got["금액"]["우리"]
def test_sure_formwork_tool_cost_is_value() -> None:
got = judge(
41
) # 합판거푸집 4회 — 공구손료 재료비 ↔ 경비 = 값(814-1 · 요구 744 C1 · 옛 812-2 = 로직)
assert got["판정"] == dm.SURE and got["키"] == "GC000211"
assert got["옵션"]["사용횟수"] == "4회" and got["옵션"]["유형"] == "보통"
assert not got["다른"]
assert [d["항"] for d in got["참고"]] == [
"단가 차이",
"공구손료 비목",
] # 대전 합판 단가 · 비목 = 값
def test_req704_pipe_is_near_with_unit_rate_machine() -> None:
got = judge(19) # 파형강관 매설 Φ800 ↔ GF000179(요구 704)
assert got["판정"] == dm.NEAR and got["키"] == "GF000179" and got["옵션"] == {"관경": 800}
kinds = [d["항"] for d in got["다른"]]
assert kinds.count("단위 환산") == 2 and "기계 규격" in kinds and "율 줄" in kinds
assert any("× 6" in d["글"] for d in got["다른"])
def test_none_without_candidates() -> None:
got = judge(7) # 지장목 제거 — 같은 단위 · 자원 묶음 기초 없음
assert got["판정"] == dm.NONE and not got.get("키")
# ── (b) 옵션 풀이 ────────────────────────────────────────────────────────
def test_option_narrowing_and_text_evidence() -> None:
whole, docs, index, links = ctx()
imported = block(41)
base = docs["GC000211"]
dom, ev = dm.domains(base, imported, index.regions)
assert dom["n_사용"][0] == "4회" and "4회" in ev["n_사용"] # 규격 글 「4회」 = 근거 먼저
got = dm._calc(imported, dim.Links(whole, "우리"), {})
mine = dm.facts(imported, got)
want = {"수량": {x["수량"] for x in mine if x["수량"] is not None}, "단가": set()}
small = dm.narrow(base, links, {k: list(v) for k, v in dom.items()}, want, ev)
assert (
small["S_유형"] == ["보통"] and D(0) in small["r_높이"]
) # 5 ÷ 45 · 2 ÷ 45 = 보통 · 감률 0
res = dm.try_doc(base, links, imported, got, mine, index.regions)
assert res["옵션"]["n_사용"] == "4회"
off = dm.verdict(mine, mine, got, got, [dm._off(base, "n_사용", "1회", {"4회"})])
assert off["판정"] != dm.SURE and off["다른"][0]["항"] == "옵션 글"
# ── (c) 줄 대조 ──────────────────────────────────────────────────────────
def _line(name, kind, qty, price, cost="노무비", code=""):
q, p = D(qty), D(price)
return {"번": 0, "이름": name, "갈래": kind, "코드": code, "수량": dm._key(q),
"단가": dm._key(p), "비목": {cost: dm._key(q * p)}, "금액": dm._key(q * p), "합밖": False} # fmt: skip
def test_compare_kinds_and_no_tolerance() -> None:
a = [_line("인부", "노임", "0.04", "100"), _line("공", "노임", "0.5", "10")]
tot = {"노무비": 9, "재료비": 0, "경비": 0, "계": 9}
assert dm.verdict(a, list(a), tot, tot)["판정"] == dm.SURE
off = dm.verdict(a, list(a), tot, {**tot, "노무비": 10, "계": 10})
assert off["판정"] == dm.SURE and off["참고"][0]["항"] == "끝수" # 끝수 = 참고(812-2)
b = [_line("인부", "노임", "0.24", "100"), _line("공", "노임", "3", "11")]
res = dm.verdict(a, b, tot, tot) # 같은 배(× 6)가 두 줄 = 단위 환산(812-11 · 한 줄만 = 수량 값)
assert [d["항"] for d in res["다른"]] == ["단위 환산"] * 2 and res["판정"] == dm.NEAR
assert [d["항"] for d in res["참고"]] == ["단가 차이"]
rate = {**_line("율", "율", "1", "3", "경비"), "수량": None, "단가": None}
res = dm.verdict([*a, rate], a, tot, tot)
assert res["다른"] == [{"항": "율 줄", "글": "가져온 쪽만 — 율"}] and res["판정"] == dm.NEAR
named = dm.verdict(
a, list(a), tot, tot, [dm._unnamed({"이름": "구조물되메우기"}, {"이름": "숲가꾸기"})]
)
assert named["판정"] == dm.NEAR # 값만 같은 남의 기초 = 확정 아님
assert not dm.same_name({"이름": "모르타르"}, {"이름": "기초다짐 및 뒷채움(인력)"})
assert dm.same_name({"이름": "유로폼 설치및해체"}, {"이름": "유로폼 설치 및 해체"})
c = [_line("딴", "노임", "3", "7", code="LB9")]
assert dm.verdict(a, c, tot, tot)["판정"] == dm.NONE
# ── (d) 바꾸기 · 되돌리기 ────────────────────────────────────────────────
COSTS = ("노무비", "재료비", "경비")
def _money(key: str) -> list:
from M02_MasterTemplete import M02_Doc_Sk_Path as path
got = path.read(key)["셈"]
return [got[k] for k in COSTS]
def test_swap_only_when_sure_and_back(world) -> None:
folder = world["personal"]
est = t2._file(t2.EST["거창"])
before = {p.name: p.read_bytes() for p in dm.ml.FOLDER.glob("수식_*.json")}
with store.at(folder):
hp = fh.upload_native(folder, est.name, est.read_bytes(), t2.WHO)["키"]
sure = fh.make(folder, hp, 33)["이름"]
near = fh.make(folder, hp, 19)["이름"] # 파형강관(근접 — 41 은 814-1 로 확정)
old = store.read("sangeun", sure)
money0 = _money(sure)
found = msk.find(sure, old["판"])
assert found[dm.KEY]["판정"] == dm.SURE
meta = store.read("sangeun", sure)["문서"]["가져온"]["원본"][dm.KEY]
assert meta["키"] == "GC000297" and meta["옵션"] == {"형식": "B-Type"}
assert _money(sure) == money0 # 찾기 = 기록만 · 금액 그대로
msk.swap(sure, found["판"])
now = store.read("sangeun", sure)
doc = now["문서"]
assert doc["바탕"] == {"로직": "GC000297"} and doc["입력값"] == {"형식": "B-Type"}
assert not doc.get("가져온") and doc[msk.KEEP]["가져온"]["로직"]
assert isinstance((doc.get("수식") or {}).get("문서"), dict) # 수식 사본(802-4a) 찍힘
want = dm._calc(dm.ml.load_all()["GC000297"], dm.ml.Links(t2.whole()), {"형식": "B-Type"})
assert _money(sure) == [want[k] for k in COSTS] # 바꾼 뒤 = M01 사본 셈
msk.back(sure, now["판"])
again = store.read("sangeun", sure)["문서"]
assert again["가져온"]["로직"] and not again.get("바탕")
assert _money(sure) == money0 # 되돌리기 = 원래 가져온 식 금액
with pytest.raises(store.StoreError) as e:
msk.swap(near, "")
assert e.value.status == 409
assert store.read("sangeun", near)["문서"]["가져온"]["로직"] # 근접 = 그대로
assert before == {p.name: p.read_bytes() for p in dm.ml.FOLDER.glob("수식_*.json")}
# ── (e) 전수 ─────────────────────────────────────────────────────────────
@pytest.mark.skipif(os.environ.get("S8104_ALL") != "1", reason="전수 = S8104_ALL=1")
def test_every_block_table() -> None:
import collections
import subprocess
rows = ["--rows", os.environ["S8104_ROWS"]] if os.environ.get("S8104_ROWS") else []
out = subprocess.run(
[sys.executable, str(HERE / "helper_810_4_sweep.py"), *rows], capture_output=True, text=True
)
assert out.returncode == 0, out.stderr[-2000:]
import json
got = json.loads(out.stdout.strip().splitlines()[-1])
assert got["블록"] == 2220 and got["오류"] == 0 and got["확정다시셈다름"] == 0
assert set(got["판정"]) <= {dm.SURE, dm.NEAR, dm.NONE}
assert sum(got["판정"].values()) == 2220 and got["판정"].get(dm.SURE, 0) >= 1
assert not {"단가 차이", "끝수"} & set(got["근접항"]) # 값 차이 = 참고(812-2)
assert collections.Counter(got["확정참고"]).get("단가 차이", 0) >= 1
def test_parent_reads_swapped_sibling(world) -> None:
"""형제 SK 를 우리 기초 사본으로 바꿔도 부르는 가져온 식은 그 사본 문서로 셈(연결 안 끊김)."""
folder = world["personal"]
est = t2._file(t2.EST["거창"])
with store.at(folder):
hp = fh.upload_native(folder, est.name, est.read_bytes(), t2.WHO)["키"]
parent = fh.make(folder, hp, 15)["이름"] # 전석 메쌓기 — 형제 산근 둘
sk = store.read("sangeun", parent)["문서"]
sib = next(x["참조"]["키"] for x in sk["가져온"]["로직"]["호표"] if x.get("참조"))
got = store.read("sangeun", sib)
body = msk.copy_body(got["문서"], {"키": "GC000211", "옵션": {}}) # 같은 ㎡
store.write("sangeun", sib, got["판"], body)
mid = _money(parent)
assert None not in mid and sum(mid) != 94215 # 사본 문서로 셈(끊기면 None)
msk.back(sib, store.read("sangeun", sib)["판"])
assert sum(_money(parent)) == 94215 # 되돌리면 원파일 금액
@@ -1,239 +0,0 @@
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
"""812-11 지킴 — 연결 찾기 값 기준 넓힘(요구 733 · 734 · 735 · `M02_Doc_Match_Value`).
(a) 줄 대조 — 같은 기종 규격만 다름 = 확정 + 참고 「규격」(바꿈 = 가져온 원문번호) · 무한궤도 ↔ 타이어 = 다른 항 ·
수량 다름 = 참고(끝자리 · 품 숫자 · 수량) · 같은 배가 두 줄 = 단위 환산(다른 항) · 줄 구성 다름 = 다른 항
(b) 견본 — main 230 되메우기(백호 0.2㎥ 70 % + 인력 30 %) ↔ GF000113 확정 + 규격 · 수량 · main 850 PC 슬래브(2ton 미만)
↔ GC000257 확정 + 수량 끝자리 · main 828 조립식 PC 맨홀(D1800) ↔ GC000252 확정 + 품 숫자(옵션 ㉠ 글 「D1800」 =
D1,800) · 거창 46 토사절취(백호 0.7㎥) = 기계 작업 + 작업량 틀 · 거창 19 파형강관 Φ800 ↔ GF000179 근접 그대로
(c) 옵션 확인(㉢) — 값으로 못 가른 선택 = 고른 값 + 참고 「옵션 확인」
(d) 바꾸기(임시 층) — main 230: 사본 기계 줄 = 가져온 규격(0201-0020) · 금액 = M01 문서에 그 기계만 바꾼 셈 ·
되돌리기 = 원래 금액 · 기계 작업 SK 바꾸기 = 409 · M01 문서 파일 그대로
(e) 전수(S81211_ALL=1) — 2,220 블록 · 오류 0 · 확정 다시 셈 다름 0 · 기계 작업 있음 · 근접 까닭에 값 항 없음
원파일 없으면 (b) · (d) · (e) skip(다른 PC).
"""
from __future__ import annotations
import copy
import json
import os
import subprocess
import sys
from decimal import Decimal as D
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
for p in (ROOT, HERE):
if str(p) not in sys.path:
sys.path.insert(0, str(p))
import test_810_2_native_doc as t2 # noqa: E402
import test_810_4_match as t4 # noqa: E402
from M02_MasterTemplete import M02_Doc_Match as dm # noqa: E402
from M02_MasterTemplete import M02_Doc_Match_Value as val # noqa: E402
from M02_MasterTemplete import M02_Doc_MatchSk as msk # noqa: E402
from M02_MasterTemplete import M02_MasterTemplete_Store as store # noqa: E402
from M02_MasterTemplete import M02_Sangeun_FromHopyo as fh # noqa: E402
from resources.tester.test_m02_labor_ref import world # noqa: E402, F401 — 같은 임시 층
TOT = {"노무비": 9, "재료비": 0, "경비": 0, "계": 9}
def _line(name, kind, qty, price, cost="노무비", code="", sym="", text=""):
q, p = D(qty), D(price)
return {"번": 0, "이름": name, "갈래": kind, "코드": code, "수량": dm._key(q), "단가": dm._key(p),
"비목": {cost: dm._key(q * p)}, "금액": dm._key(q * p), "합밖": False, "기호": sym,
"규격": "", "수량글": text or qty} # fmt: skip
MAN = _line("보통인부", "노임", "0.01", "100", code="LB000002")
# ── (a) 줄 대조 ──────────────────────────────────────────────────────────
def test_same_kind_spec_is_value_other_kind_is_logic() -> None:
a = [MAN, _line("굴착기", "기계", "0.03", "500", "경비", "0201-0020", "P_a", "1 ÷ Q")]
b = [MAN, _line("굴착기", "기계", "0.02", "600", "경비", "0201-0070", "P_b", "r ÷ Q")]
res = dm.verdict(a, b, TOT, TOT)
assert res["판정"] == dm.SURE and not res["다른"]
assert {x["항"] for x in res["참고"]} == {"규격", "수량", "단가 차이"}
spec = next(x for x in res["참고"] if x["항"] == "규격")
assert "0.2㎥ ↔ 0.7㎥" in spec["글"] and "바꾸면 가져온 규격" in spec["글"]
assert res["바꿈"] == [
{"기호": "P_b", "기계": "0201-0020", "이름": "굴착기(무한궤도)", "규격": "0.2㎥"}
]
same_qty = dm.verdict(a, [MAN, {**b[1], "수량": a[1]["수량"]}], TOT, TOT)
assert same_qty["판정"] == dm.SURE and len(same_qty["바꿈"]) == 1 # 수량 같아도 규격 = 값
tire = [MAN, _line("굴착기", "기계", "0.02", "600", "경비", "0211-0060", "P_b")]
crawler = [MAN, _line("굴착기", "기계", "0.02", "600", "경비", "0201-0060", "P_a")]
res = dm.verdict(crawler, tire, TOT, TOT) # 무한궤도 ↔ 타이어 = 다른 기종
assert res["판정"] != dm.SURE and [x["항"] for x in res["다른"]] == ["기계 규격"]
assert not res["바꿈"]
def test_combo_machine_is_its_own_kind() -> None:
one = _line("굴삭기(무한궤도)", "기계", "0.02", "5", "경비", "0201-0060", "P_a")
grab = _line("굴착기+부착용 집게 1.0㎥", "기계", "0.07", "5", "경비", "0201-0100", "P_b")
val.mark_combo({}, [one, grab]) # facts 가 붙이는 조합 표지
res = dm.verdict([MAN, one], [MAN, grab], TOT, TOT) # 조합 기계 ↔ 낱 기계 = 다른 기종
assert res["판정"] != dm.SURE and "기계 규격" in {x["항"] for x in res["다른"]}
pair = "로더(타이어)+소형노면파쇄기"
a = _line(pair, "기계", "0.1", "5", "경비", "0302-0095", "P_a")
val.mark_combo({}, [a])
res = dm.verdict([MAN, a], [MAN, {**a, "기호": "P_b"}], TOT, TOT) # 같은 조합 = 같음
assert res["판정"] == dm.SURE and not res["참고"]
def test_quantity_kinds_and_unit_conversion() -> None:
tail = dm.verdict([_line("크레인", "노임", "0.2959", "9", code="LB1")],
[_line("크레인", "노임", "0.2963", "9", code="LB1")], TOT, TOT) # fmt: skip
assert tail["판정"] == dm.SURE and [x["항"] for x in tail["참고"]] == ["수량 끝자리"]
poom = dm.verdict([_line("특별인부", "노임", "0.46", "9", code="LB3")],
[_line("특별인부", "노임", "0.48", "9", code="LB3", text="Q")], TOT, TOT) # fmt: skip
assert [x["항"] for x in poom["참고"]] == ["품 숫자"] and "0.46 ↔ 0.48" in poom["참고"][0]["글"]
assert "바꾸면 우리 값" in poom["참고"][0]["글"]
two = [
_line("배관공", "노임", "0.04", "9", code="LB1"),
_line("인부", "노임", "0.02", "9", code="LB2"),
]
six = [
_line("배관공", "노임", "0.24", "9", code="LB1"),
_line("인부", "노임", "0.12", "9", code="LB2"),
]
res = dm.verdict(two, six, TOT, TOT) # 같은 배(× 6)가 두 줄 = 결과 단위 기준 다름 = 로직
assert [x["항"] for x in res["다른"]] == ["단위 환산", "단위 환산"] and res["판정"] == dm.NEAR
one = dm.verdict(two[:1] + six[1:], six, TOT, TOT) # 한 줄만 배 = 수량 값
assert one["판정"] == dm.SURE and [x["항"] for x in one["참고"]] == ["품 숫자"]
kind = dm.verdict([MAN], [_line("특별인부", "노임", "0.02", "100", code="LB000003")], TOT, TOT)
assert kind["판정"] != dm.SURE and kind["다른"] # 직종 다름 = 로직
more = dm.verdict([MAN], [MAN, _line("모래", "재료", "1", "9", "재료비")], TOT, TOT)
assert more["판정"] != dm.SURE and more["다른"][0]["항"] == "줄 없음" # 줄 구성(㉡) = 로직
# ── (b) 견본 ─────────────────────────────────────────────────────────────
def _judge(tag: str, no: int) -> dict:
whole, _, index, links = t4.ctx()
try:
doc = t4.dim.derive(t2.sk_of(t2.fn.emit(t2.native("EST", tag), no)), None, t2.whole())
except FileNotFoundError:
pytest.skip("원파일 없음")
return dm.match(doc, t4.dim.Links(whole, "우리"), index, links)
def _kinds(got: dict) -> list[str]:
return [x["항"] for x in got.get("참고") or []]
def test_backfill_spec_and_quantity() -> None:
got = _judge("main", 230)
assert got["판정"] == dm.SURE and got["키"] == "GF000113" and not got["다른"]
assert {"규격", "수량"} <= set(_kinds(got))
assert got["바꿈"] == [
{"기호": "P_굴착기무한궤", "기계": "0201-0020", "이름": "굴착기(무한궤도)", "규격": "0.2㎥"}
]
def test_pc_slab_tail_and_manhole_poom() -> None:
slab = _judge("main", 850)
assert slab["판정"] == dm.SURE and slab["키"] == "GC000257" and _kinds(slab) == ["수량 끝자리"]
hole = _judge("main", 828)
assert hole["판정"] == dm.SURE and hole["키"] == "GC000252" and "품 숫자" in _kinds(hole)
assert hole["옵션"] == {"규격": "D1,800"} # ㉠ 이름 글 「D1800」 = 선택 「D1,800」
def test_cut_soil_is_machine_work_and_pipe_stays_near() -> None:
work = _judge("거창", 46)
assert work["판정"] == val.WORK and not work.get("키")
assert _kinds(work) == ["작업량 틀"] and "GC001147" in work["참고"][0]["글"]
pipe = _judge("거창", 19)
kinds = [d["항"] for d in pipe["다른"]]
assert pipe["판정"] == dm.NEAR and pipe["키"] == "GF000179"
assert kinds.count("단위 환산") == 2 and "율 줄" in kinds
def test_machine_work_needs_one_machine_group() -> None:
names = dm.note._names
head = {"가져온": {"기계": [{"기호": "P_a"}, {"기호": "P_b"}]}, "식": []}
one = [_line("굴착기", "기계", "0.03", "5", "경비", "0201-0070", "P_a", "1 ÷ Q")]
assert val.work(head, one, names)["판정"] == val.WORK
assert val.work(head, [{**one[0], "수량글": "0.03"}], names) is None # 수량 = 수뿐
assert val.work(head, [*one, {**MAN, "기호": "w"}], names) is None # 인력 섞임 = 보통 길
both = [*one, {**one[0], "기호": "P_b", "코드": "0240-0007"}]
assert val.work(head, both, names) is None # 기계 둘(조합 아님)
combo = {"가져온": {"기계": [{"기호": "P_a", "조합첫": True}, {"기호": "P_b", "조합": True}]}}
assert val.work(combo, both, names)["판정"] == val.WORK # 조합 기계 = 한 묶음
# ── (c) 옵션 확인 ────────────────────────────────────────────────────────
def test_option_check_when_values_tie() -> None:
doc = {"변수": [{"기호": "S", "아래": "규격", "이름": "규격", "열쇠": "규격",
"연결": {"갈래": "선택", "목록": ["A", "B"], "값": "A"}}]} # fmt: skip
vid = dm.md.var_id(doc["변수"][0])
got = val.option_check(doc, {vid: "A"}, [{vid: "A"}, {vid: "B"}], {vid})
assert len(got) == 1 and "규격 = A(기본값" in got[0]["글"]
assert not val.option_check(doc, {vid: "A"}, [{vid: "A"}], {vid}) # 하나로 갈림 = 없음
assert not val.option_check(doc, {vid: "A"}, [{vid: "A"}, {vid: "B"}], set()) # 수량 무관
# ── (d) 바꾸기 · 되돌리기 ────────────────────────────────────────────────
def test_swap_puts_imported_spec_machine_and_back(world) -> None:
folder = world["personal"]
try:
est = t2._file(t2.EST["main"])
except FileNotFoundError:
pytest.skip("원파일 없음")
before = {p.name: p.read_bytes() for p in dm.ml.FOLDER.glob("수식_*.json")}
with store.at(folder):
hp = fh.upload_native(folder, est.name, est.read_bytes(), t2.WHO)["키"]
name = fh.make(folder, hp, 230)["이름"]
money0 = t4._money(name)
found = msk.find(name, store.read("sangeun", name)["판"])
meta = found[dm.KEY]
assert meta["판정"] == dm.SURE and meta["바꿈"][0]["기계"] == "0201-0020"
out = msk.swap(name, found["판"])
assert [x["기계"] for x in out["바꾼기계"]] == ["0201-0020"]
from M02_MasterTemplete import M02_Doc_Sk_Path as path
doc = path.read(name)["문서"]
var = next(v for v in doc["변수"] if dm.md.var_id(v) == "P_굴착기무한궤")
assert var["연결"]["입력"]["기계"] == "'0201-0020'"
line = next(x for x in doc["금액"] if x["단가"] == "P_굴착기무한궤")
assert (line["이름"], line["규격"]) == ("굴착기(무한궤도)", "0.2㎥")
assert line["묶음"] == "굴착기(무한궤도) 0.2㎥" # 묶음 제목도 가져온 규격
base = copy.deepcopy(dm.ml.load_all()["GF000113"]) # M01 값 그대로 · 기계만 가져온 규격
for v in base["변수"]:
if dm.md.var_id(v) == "P_굴착기무한궤":
v["연결"]["입력"]["기계"] = "'0201-0020'"
want = dm._calc(base, dm.ml.Links(t2.whole()), dict(meta["옵션"]))
assert t4._money(name) == [want[k] for k in t4.COSTS] and t4._money(name) != money0
msk.back(name, store.read("sangeun", name)["판"])
again = store.read("sangeun", name)["문서"]
assert again["가져온"]["로직"] and not again.get("바탕")
assert t4._money(name) == money0 # 되돌리기 = 원래 가져온 식 금액
with store.at(folder):
gc = t2._file(t2.EST["거창"])
hp2 = fh.upload_native(folder, gc.name, gc.read_bytes(), t2.WHO)["키"]
cut = fh.make(folder, hp2, 46)["이름"]
found = msk.find(cut, store.read("sangeun", cut)["판"])
assert found[dm.KEY]["판정"] == val.WORK
with pytest.raises(store.StoreError) as e:
msk.swap(cut, found["판"])
assert e.value.status == 409
assert before == {p.name: p.read_bytes() for p in dm.ml.FOLDER.glob("수식_*.json")}
# ── (e) 전수 ─────────────────────────────────────────────────────────────
@pytest.mark.skipif(os.environ.get("S81211_ALL") != "1", reason="전수 = S81211_ALL=1")
def test_every_block_values() -> None:
out = subprocess.run(
[sys.executable, str(HERE / "helper_810_4_sweep.py")], capture_output=True, text=True
)
assert out.returncode == 0, out.stderr[-2000:]
got = json.loads(out.stdout.strip().splitlines()[-1])
assert got["블록"] == 2220 and got["오류"] == 0 and got["확정다시셈다름"] == 0
assert got["판정"].get(val.WORK, 0) >= 1 and got["판정"][dm.SURE] >= 184
assert not set(val.VALUE) & set(got["근접항"]) # 값 차이 = 참고
-183
View File
@@ -1,183 +0,0 @@
"""812-2 지킴 — 연결 찾기 확정 = 로직(요구 712) · 값 차이 = 참고(`M02_Doc_Match_Note`).
(a) 단가만 다름 = 확정 + 참고 「단가 차이」(원파일 기계경비 묶음 ↔ 우리 기계 = 이름 종류 같을 때) ·
끝수만 다름 = 확정 + 참고 「끝수」
(b) 로직 차이 = 다른 항 — 율 대상 · 기계 규격 · 노임 종류(「자원」) · 산출 단가 · 비목
(c) 율 = 비율 — 거창 합판거푸집(블록 41): 가져온 「주자재소계 × 9 %」 = M01 소모자재 9 % 짝 · 율_2 1 % 재료비 ↔
공구손료 1 % 경비 = 「비목」 · 대전 합판 단가 = 참고
(d) 요구 704 배수관부설 D=800 ↔ GF000179 = 여전히 근접(단위 환산 × 6 · 기계 규격 · 율 줄)
(e) 전수(S8122_ALL=1) — 2,220 블록 · 확정 다시 셈 같음 · 근접 까닭에 단가 차이 · 끝수 없음 · 확정 참고 있음
원파일 없으면 (c) · (d) · (e) skip(다른 PC).
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from decimal import Decimal as D
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
for p in (ROOT, HERE):
if str(p) not in sys.path:
sys.path.insert(0, str(p))
from M02_MasterTemplete import M02_Doc_Match as dm # noqa: E402
TOT = {"노무비": 9, "재료비": 0, "경비": 0, "계": 9}
def _line(name, kind, qty, price, cost="노무비", code=""):
q, p = D(qty), D(price)
return {"번": 0, "이름": name, "갈래": kind, "코드": code, "수량": dm._key(q),
"단가": dm._key(p), "비목": {cost: dm._key(q * p)}, "금액": dm._key(q * p), "합밖": False} # fmt: skip
def _rate(name, rate, target=(("노임", "노무비"),), cost="경비"):
return {"번": 0, "이름": name, "갈래": "율", "코드": "", "수량": None, "단가": None,
"비목": {cost: D(1)}, "금액": D(1), "합밖": False, "율": D(rate), "대상": frozenset(target)} # fmt: skip
A = [_line("인부", "노임", "0.04", "100", code="LB1"), _line("공", "노임", "0.5", "10", code="LB2")]
# ── (a) 값 차이 = 참고 ───────────────────────────────────────────────────
def test_price_only_is_sure_with_note() -> None:
b = [_line("인부", "노임", "0.04", "120", code="LB1"), A[1]]
res = dm.verdict(A, b, TOT, {**TOT, "노무비": 10, "계": 10})
assert res["판정"] == dm.SURE and not res["다른"] and res["같음"] == 2
assert [d["항"] for d in res["참고"]] == ["단가 차이"]
mach = [_line("굴삭기", "기계", "0.1", "500", "경비", "0201-0070")]
other = [_line("굴삭기", "기계", "0.1", "530", "경비", "0201-0070")]
assert dm.verdict(mach, other, TOT, TOT)["판정"] == dm.SURE # 기계 시간당 단가 = 참고
file = [
_line("머캐덤로울러(자주식)", "산출", "0.1", "530", "경비")
] # 원파일 기계경비 묶음(코드 없음)
ours = [_line("탠덤롤러", "기계", "0.1", "500", "경비", "1206-0008")]
ours.append(_line("머캐덤롤러 10∼12ton", "기계", "0.1", "500", "경비", "1106-0012"))
res = dm.verdict(file, ours, TOT, TOT) # 이름 종류 같은 기계와 짝 · 단가 = 참고
assert (
not [d for d in res["다른"] if d["항"] != "줄 없음"] and res["참고"][0]["항"] == "단가 차이"
)
run = [{**_line("드릴", "산출", "0.1", "530", "경비"), "비목": {"노무비": D(1), "경비": D(2)}}]
res = dm.verdict(run, [_line("드릴", "기계", "0.1", "500", "경비", "0901-0010")], TOT, TOT)
assert res["판정"] == dm.SURE # 기계경비 구성(운전 노무비 포함) 차이 = 시간당 단가 = 참고
none = [{**_line("모래", "재료", "1", "0", "재료비"), "비목": {}}]
assert (
dm.verdict([_line("모래", "재료", "1", "9", "재료비")], none, TOT, TOT)["판정"] == dm.SURE
)
res = dm.verdict(file, ours[:1], TOT, TOT)
assert [d["항"] for d in res["다른"]] == ["기계 규격"]
def test_rounding_only_is_sure_with_note() -> None:
res = dm.verdict(A, list(A), TOT, {**TOT, "노무비": 10, "계": 10})
assert res["판정"] == dm.SURE and [d["항"] for d in res["참고"]] == ["끝수"]
# ── (b) 로직 차이 = 다른 항 ──────────────────────────────────────────────
def test_rate_value_is_note_structure_is_logic() -> None:
res = dm.verdict([*A, _rate("공구손료", "0.02")], [*A, _rate("공구손료", "0.03")], TOT, TOT)
assert not res["다른"] and res["판정"] == dm.SURE
assert [d["항"] for d in res["참고"]] == ["율 값"] # 율 값(%) 다름 = 참고(812-4) · 로직 같음
assert "2 % ↔" in res["참고"][0]["글"] and "3 %" in res["참고"][0]["글"]
same = dm.verdict([*A, _rate("율", "0.02")], [*A, _rate("공구손료", "0.02")], TOT, TOT)
assert same["판정"] == dm.SURE # 율 · 대상 · 비목 같음 = 이름 달라도 같은 율 줄
tgt = dm.verdict(
[*A, _rate("율", "0.02", (("재료", "재료비"),))], [*A, _rate("율", "0.02")], TOT, TOT
)
assert [d["항"] for d in tgt["다른"]] == ["율 대상"]
cost = dm.verdict([*A, _rate("율", "0.02", cost="재료비")], [*A, _rate("율", "0.02")], TOT, TOT)
assert [d["항"] for d in cost["다른"]] == ["비목"]
def test_machine_labor_kind_differs_blocks() -> None:
a = [*A, _line("굴삭기", "기계", "0.1", "500", "경비", "0201-0070")]
b = [
*A,
_line("굴삭기", "기계", "0.1", "500", "경비", "0211-0080"),
] # 타이어 = 다른 기종(812-11)
res = dm.verdict(a, b, TOT, TOT)
assert [d["항"] for d in res["다른"]] == ["기계 규격"] and res["판정"] == dm.NEAR
c = [_line("특별인부", "노임", "0.04", "120", code="LB9"), A[1]]
res = dm.verdict(A, c, TOT, TOT)
assert [d["항"] for d in res["다른"]] == ["자원"] and res["판정"] != dm.SURE
sub = [_line("모르타르", "산출", "0.1", "500", "재료비")]
res = dm.verdict(sub, [_line("모르타르", "산출", "0.1", "520", "재료비")], TOT, TOT)
assert [d["항"] for d in res["다른"]] == ["산출 단가"] # 안쪽 산출 = 로직 결과
# ── (c) · (d) 원파일 블록 ────────────────────────────────────────────────
def _judge(no: int) -> dict:
t4 = pytest.importorskip("test_810_4_match")
try:
return t4.judge(no)
except FileNotFoundError:
pytest.skip("원파일 없음")
def test_formwork_rate_ratio_pairs() -> None:
got = _judge(41) # 합판거푸집 4회 ↔ GC000211 — 공구손료 비목 차이 = 값(814-1 · 요구 744 C1)
assert got["판정"] == dm.SURE and got["키"] == "GC000211" and not got["다른"]
assert [d["항"] for d in got["참고"]] == ["단가 차이", "공구손료 비목"]
assert "1 %" not in got["참고"][1]["글"] and "재료비" in got["참고"][1]["글"]
def test_req704_still_near() -> None:
got = _judge(19) # 배수관부설 D=800 ↔ GF000179
kinds = [d["항"] for d in got["다른"]]
assert got["판정"] == dm.NEAR and got["키"] == "GF000179"
assert kinds.count("단위 환산") == 2 and "기계 규격" in kinds and "율 줄" in kinds
# ── (e) 전수 ─────────────────────────────────────────────────────────────
@pytest.mark.skipif(os.environ.get("S8122_ALL") != "1", reason="전수 = S8122_ALL=1")
def test_every_block_logic() -> None:
out = subprocess.run(
[sys.executable, str(HERE / "helper_810_4_sweep.py")], capture_output=True, text=True
)
assert out.returncode == 0, out.stderr[-2000:]
got = json.loads(out.stdout.strip().splitlines()[-1])
assert got["블록"] == 2220 and got["오류"] == 0 and got["확정다시셈다름"] == 0
assert not {"단가 차이", "끝수"} & set(got["근접항"])
assert got["확정참고블록"] >= 1 and got["판정"][dm.SURE] >= 98
def test_rate_target_scope() -> None:
"""율 대상 — 줄 기호 = 그 줄 · 비목 이름 = 그 비목 몫(기계 운전 노무비 포함) · 번지 합 = 핵심 줄 전부."""
from M02_MasterTemplete import M02_Doc_Match_Note as note
def run(rate_text: str) -> dict:
money = [
{"이름": "인부", "수량": "1", "단가": "100", "기호": "C_인부"},
{"이름": "굴삭기", "수량": "1", "단가": "300"},
{"이름": "율", "단가": rate_text},
]
rows = [
{"수량": D(1), "단가": D(100), "금액": D(100)},
{"수량": D(1), "단가": D(300), "금액": D(300)},
{"수량": D(1), "단가": D(0), "금액": D(0)},
]
facts = [
{"번": 0, "갈래": "노임", "비목": {"노무비": D(100)}, "합밖": False},
{"번": 1, "갈래": "산출", "비목": {"노무비": D(100), "경비": D(200)}, "합밖": False},
{"번": 2, "갈래": "율", "비목": {"경비": D(4)}, "합밖": False},
]
doc = {"변수": [], "식": [], "금액": money}
rows[2]["단가"] = {"C_인부 × 4%": D(4), "노무비 × 2%": D(4), "번지.A01.계 × 1%": D(4)}[
rate_text
]
note.rates(doc, {"줄": rows}, facts)
return facts[2]
one = run("C_인부 × 4%")
assert one["율"] == D("0.04") and one["대상"] == {("노임", "노무비")}
labor = run("노무비 × 2%") # 노무비 200(인부 100 + 굴삭기 운전 100)
assert labor["율"] == D("0.02") and labor["대상"] == {("노임", "노무비"), ("기계", "노무비")}
whole = run("번지.A01.계 × 1%") # 400 × 1 %
assert whole["율"] == D("0.01") and ("기계", "경비") in whole["대상"]
-79
View File
@@ -1,79 +0,0 @@
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
"""812-4 지킴 — 율 값(%) 다름 = 참고(값은 M01 값으로 업데이트됨) · 율 대상 · 비목 다름 = 다른 항(로직).
(a) 원파일 main 블록 105(터파기 · 리퍼 2.42 % ↔ 우리 유압식 리퍼 2 %) · 275(절토면고르기 리퍼) =
확정 + 참고 「율 값」 (다른 항 0) · 글에 「바꾸면 우리 값」
(b) 확정 + 참고 블록 [바꾸기] → 금액 = M01 사본 셈(율도 M01 값) · [되돌리기] → 원래 금액 · M01 문서 파일 그대로
(c) 대상이 다른 율(교량받침 1.14 % ↔ 공구손료 3 % 노임 노무비) = 여전히 다른 항 「율 대상」
원파일 없으면 skip(다른 PC) — 합성 줄 시험은 test_812_2.
"""
from __future__ import annotations
import sys
from decimal import Decimal as D
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
for p in (ROOT, HERE):
if str(p) not in sys.path:
sys.path.insert(0, str(p))
import test_810_2_native_doc as t2 # noqa: E402
import test_810_4_match as t4 # noqa: E402
from M02_MasterTemplete import M02_Doc_Match as dm # noqa: E402
from M02_MasterTemplete import M02_Doc_MatchSk as msk # noqa: E402
from M02_MasterTemplete import M02_MasterTemplete_Store as store # noqa: E402
from M02_MasterTemplete import M02_Sangeun_FromHopyo as fh # noqa: E402
from resources.tester.test_m02_labor_ref import world # noqa: E402, F401 — 같은 임시 층
def _judge(no: int, tag: str = "main") -> dict:
whole, _, index, links = t4.ctx()
try:
out = t2.fn.emit(t2.native("EST", tag), no)
doc = t4.dim.derive(t2.sk_of(out), None, t2.whole())
except FileNotFoundError:
pytest.skip("원파일 없음")
return dm.match(doc, t4.dim.Links(whole, "우리"), index, links)
@pytest.mark.parametrize(("no", "key"), [(105, "GC000089"), (275, "GC000111")])
def test_rate_value_only_is_sure_with_note(no: int, key: str) -> None:
got = _judge(no)
assert got["판정"] == dm.SURE and got["키"] == key and not got["다른"]
rate = [d for d in got["참고"] if d["항"] == "율 값"]
assert len(rate) == 1 and "바꾸면 우리 값" in rate[0]["글"] and "2 %" in rate[0]["글"]
def test_rate_target_differs_stays_logic() -> None:
got = _judge(1533) # 교량받침설치 — 율 대상 기계 · 노임 ↔ 노임
assert "율 대상" in {d["항"] for d in got["다른"]} and got["판정"] != dm.SURE
def test_swap_rate_note_block_uses_m01_rate(world) -> None:
folder = world["personal"]
est = t2._file(t2.EST["main"])
before = {p.name: p.read_bytes() for p in dm.ml.FOLDER.glob("수식_*.json")}
with store.at(folder):
hp = fh.upload_native(folder, est.name, est.read_bytes(), t2.WHO)["키"]
name = fh.make(folder, hp, 105)["이름"]
old = store.read("sangeun", name)
money0 = t4._money(name)
found = msk.find(name, old["판"])
meta = found[dm.KEY]
assert meta["판정"] == dm.SURE and any(d["항"] == "율 값" for d in meta["참고"])
assert t4._money(name) == money0 # 찾기 = 기록만
msk.swap(name, found["판"])
doc = store.read("sangeun", name)["문서"]
want = dm._calc(dm.ml.load_all()[meta["키"]], dm.ml.Links(t2.whole()), dict(meta["옵션"]))
assert t4._money(name) == [want[k] for k in t4.COSTS] # 율도 M01 값
assert doc["바탕"] == {"로직": meta["키"]} and sum(t4._money(name)) != sum(money0)
msk.back(name, store.read("sangeun", name)["판"])
assert t4._money(name) == money0 # 되돌리기 = 원래 금액
assert D(sum(money0)) > 0
assert before == {p.name: p.read_bytes() for p in dm.ml.FOLDER.glob("수식_*.json")}
@@ -2,10 +2,11 @@
"""814-1 지킴 — 불러오기 결정 열 가지(방향 장부 `_방향_불러오기.md` E 표 · 요구 742 ~ 750).
① 파일 하나(같은 공사 붙이기 걷음) ② 확장자 파일 = 단가산출표 판(산출근거 머리) · 일위대가 없음 ·
③ 엑셀 이름 규격 · 단위 꼬리 뗀 맞대기 ④ 기계 세 줄 = 한 줄(STC · EST · 엑셀) · 조합 다음 줄 기계마다 한 항목 ·
⑤ 계산식 기계 목록(빠진 기계 더함 · 기본값 그대로) · 조합 = 우리 조합 식 ⑥ 공구손료 비목 = 참고 ·
⑦ 못 이은 줄 까닭 · 「없음」 띠 ⑧ 유가 = 전국평균 + 지역 옵션 · 전력 = 일반용(갑)Ⅰ 저압 + 계약종별 옵션 ·
④ 기계 세 줄 = 한 줄(STC · EST · 엑셀) · 조합 다음 줄 기계마다 한 항목 ·
⑤ 계산식 기계 목록(빠진 기계 더함 · 기본값 그대로) · 조합 = 우리 조합 식 ·
⑦ 못 이은 줄 까닭 ⑧ 유가 = 전국평균 + 지역 옵션 · 전력 = 일반용(갑)Ⅰ 저압 + 계약종별 옵션 ·
⑨ 재료 지역 꼬리 뗀 정확 일치 ⑩ 가벼움(조합 첫 줄 규격 · 소계 검산 · 기계 찾기 판정 · 409 · 확정 글 · 전수 도우미).
③ ⑥ · 「없음」 띠 · 전수 도우미 = 연결 찾기와 함께 걷음(815-2).
원파일 · 실무 엑셀 없으면 그 시험만 skip(다른 PC).
"""
@@ -29,8 +30,6 @@ import test_810_2_native_doc as t # noqa: E402
from M02_MasterTemplete import M02_Doc_FromNative as fn # noqa: E402
from M02_MasterTemplete import M02_Doc_Import as dim # noqa: E402
from M02_MasterTemplete import M02_Doc_MatchSk as msk # noqa: E402
from M02_MasterTemplete import M02_Doc_Match_Note as note # noqa: E402
from M02_MasterTemplete import M02_Doc_NativeLayout as lay # noqa: E402
from M02_MasterTemplete import M02_Hopyo_Store as hs # noqa: E402
from M02_MasterTemplete import M02_Machine_Hourly as mh # noqa: E402
@@ -84,18 +83,6 @@ def test_one_file_and_list_sheet(world) -> None:
assert est["호표"] == [] and len(est["단산"]) == len(fn.numbers(t.native("EST", "거창")))
# ── ③ 꼬리 뗀 맞대기 ─────────────────────────────────────────────────────
def test_bare_name_tail() -> None:
doc = {
"이름": "구조물터파기(인력) 경질토사 / M3",
"결과단위": "원/M3",
"가져온": {"규격": "경질토사"},
}
assert note.bare(doc)["이름"] == "구조물터파기(인력)" and doc["이름"].endswith("/ M3")
plain = {"이름": "측구터파기", "결과단위": "원/㎥", "가져온": {"규격": "토사"}}
assert note.bare(plain) is plain
# ── ④ 세 줄 묶기 · 조합 그룹 ─────────────────────────────────────────────
def _machine_split(doc: dict, out: dict) -> int:
"""문서에 남은 기계 비목 낱 줄(같은 기계 · 같은 수량 · 비목 셋이 따로) 묶음 수."""
@@ -164,28 +151,6 @@ def test_combo_uses_our_combo_formula() -> None:
assert main["규격"] != (main["자원"] or {}).get("규격") # 칩 글 = 그 기계 규격(조합 글 아님)
# ── ⑥ 공구손료 비목 = 참고 ───────────────────────────────────────────────
def test_tool_rate_cost_is_value() -> None:
a = {
"갈래": "율",
"이름": "공구손료",
"율": "3",
"대상": {("인력", "노무비")},
"비목": ["재료비"],
}
b = {**a, "비목": ["경비"]}
left, right = [a], [b]
def take(test):
hits = [(x, y) for x in list(left) for y in list(right) if test(x, y)][:1]
for x, y in hits:
left.remove(x), right.remove(y)
return hits
diffs, notes = note.rate_pairs(left, right, take)
assert not diffs and notes[0]["항"] == "공구손료 비목"
# ── ⑧ ⑨ 유가 · 전력 · 재료 ───────────────────────────────────────────────
def test_oil_power_material_links() -> None:
oil, _ = link.m01("재료", {"이름": "경 유", "규격": "", "단위": "ℓ"})
@@ -229,9 +194,6 @@ def test_unlinked_reasons_and_tally(monkeypatch) -> None:
assert link.why(gone) == "별칭 대기"
sib = {"호표": [{"이름": "a", "참조": {"키": "SK-1"}, "자원": {"갈래": "단가산출"}}]}
assert link.tally(sib) == {"됨": 1, "줄": 1, "조합": True}
src = (ROOT / "M02_MasterTemplete/logic/M02_Sangeun_Match.ts").read_text("utf-8")
assert "우리 기초 없음" in src and "우리 정보로 셈" in src
assert "우리정보" in Path(msk.__file__).read_text("utf-8")
# ── ⑩ 가벼움 ─────────────────────────────────────────────────────────────
@@ -254,7 +216,5 @@ def test_light_fixes(world) -> None:
assert seen == [False]
origin = (ROOT / "M02_MasterTemplete/logic/M02_Sangeun_Origin.ts").read_text("utf-8")
assert "엑셀 읽기 확정" in origin
helper = (HERE / "helper_810_4_sweep.py").read_text("utf-8")
assert "str(got[k])" not in helper
one = em.first_spec({"규격": "0.7+브레이카"}, [{"규격": "0.7㎥"}, {"규격": "0.7"}])
assert one["규격"] == "0.7㎥"
+132 -1
View File
@@ -8,7 +8,9 @@
from __future__ import annotations
import json
import re
import shutil
import subprocess
from pathlib import Path
import pytest
@@ -84,10 +86,12 @@ def test_별칭_저장_그_줄만_빈칸이면_칸_없음(copy: Path) -> None:
old, new = _around(before.decode("utf-8")), _around(after)
assert old[0] == new[0] and old[2] == new[2] # 그 줄 밖 글 · 서식 그대로
assert json.loads(new[1]) == {**json.loads(old[1]), "별칭": "시험별칭A, 시험별칭B"}
flat = lambda s: re.sub(r"\s", "", s) # noqa: E731
assert flat(new[1]) == flat(old[1])[:-1] + ',"별칭":"시험별칭A,시험별칭B"}' # 9.0 그대로
_save_alias(client, " ")
last = _around((copy / MACHINE).read_bytes().decode("utf-8"))
assert last[0] == old[0] and last[2] == old[2]
assert json.loads(last[1]) == json.loads(old[1]) # 빈 글 = 칸 없음 = 처음 값
assert last[1] == old[1] # 빈 글 = 칸 없음 = 처음 글 그대로
def test_M01_길은_시스템_관리자만() -> None:
@@ -102,3 +106,130 @@ def test_마스터_기계_별칭_다섯_옮김_장부_걷음() -> None:
rows = {r["키"]: r for r in json.loads((REAL / MACHINE).read_text(encoding="utf-8"))["줄"]}
assert {k: r.get("별칭") for k, r in rows.items() if r.get("별칭")} == FIVE
assert not (ROOT / "resources" / "master_template" / "실무자료" / "맞춤.json").exists()
# ── 모아 보기 · 제안 → 승인 ───────────────────────────────────────────
@pytest.fixture
def suggest_file(tmp_path: Path, monkeypatch) -> Path:
from M01_MasterData import M01_MasterData_Alias as alias
path = tmp_path / "_shared" / "별칭제안.json"
monkeypatch.setattr(alias, "suggest_path", lambda: path)
return path
def _alias_app(role: str = "SYSTEM_ADMIN") -> TestClient:
from M01_MasterData import M01_MasterData_Alias as alias
app = FastAPI()
app.dependency_overrides[auth.verify_session] = lambda: {
"user_id": 7,
"name": "시험",
"role": role,
"company_id": 1,
}
admin = [Depends(auth.verify_session), Depends(auth.require_system_admin)]
app.include_router(alias.router, dependencies=admin)
app.include_router(alias.suggest_router, dependencies=[Depends(auth.verify_session)])
return TestClient(app)
def test_모아_보기_다섯_수기_넣은_것_바로(copy: Path, suggest_file: Path) -> None:
client = _alias_app()
rows = client.get("/api/m01/alias").json()["줄"]
assert {r["키"]: r["별칭"] for r in rows if r["갈래"] == "기계"} == FIVE
assert all(r["들어옴"] == "수기" for r in rows)
one = next(r for r in rows if r["키"] == "EQ000248")
assert [p["글"] for p in one["조각"]] == ["바이브레이트 봉상후렉시블"]
res = client.post("/api/m01/alias/save", json={"키": "LB000001", "별칭": "시험A,시험B"})
assert res.status_code == 200, res.text
got = next(r for r in res.json()["줄"] if r["키"] == "LB000001")
assert got["갈래"] == "인력" and [p["글"] for p in got["조각"]] == ["시험A", "시험B"]
res = client.post("/api/m01/alias/save", json={"키": "LB000001", "별칭": " "})
assert "LB000001" not in [r["키"] for r in res.json()["줄"]]
assert (
client.post("/api/m01/alias/save", json={"키": "ZZ999999", "별칭": "x"}).status_code == 404
)
def test_제안_승인_별칭에_더함_반려_지움(copy: Path, suggest_file: Path) -> None:
user, admin = _alias_app("USER"), _alias_app()
assert user.get("/api/m01/alias").status_code == 403 # 모아 보기 · 승인 = 시스템 관리자만
body = {"키": "EQ000400", "이름": "워터 젯", "규격": "96kW, 고압", "단위": "hr"}
sid = user.post("/api/m01/alias/suggest", json=body).json()["id"]
again = user.post("/api/m01/alias/suggest", json={**body, "이름": "워터제트", "규격": ""})
assert again.json() == {"이미": True} # 이미 있는 별칭은 제안 안 남음
assert user.post("/api/m01/alias/approve", json={"id": sid}).status_code == 403
wait = admin.get("/api/m01/alias").json()["대기"]
assert [(w["id"], w["갈래"], w["별칭"], w["누가"]["id"]) for w in wait] == [
(sid, "기계", "워터 젯 96kW, 고압", 7)
]
got = admin.post("/api/m01/alias/approve", json={"id": sid}).json()
row = next(r for r in got["줄"] if r["키"] == "EQ000400")
assert row["별칭"] == '워터제트, "워터 젯 96kW, 고압"' # 속 콤마 = 큰따옴표 조각
assert [(p["글"], p["승인"]) for p in row["조각"]] == [
("워터제트", False),
("워터 젯 96kW, 고압", True),
]
assert row["들어옴"] == "승인" and got["대기"] == []
data = json.loads((copy / MACHINE).read_text(encoding="utf-8"))
assert next(r for r in data["줄"] if r["키"] == "EQ000400")["별칭"] == row["별칭"]
assert admin.post("/api/m01/alias/approve", json={"id": sid}).status_code == 404
sid2 = user.post("/api/m01/alias/suggest", json={**body, "키": "EQ000425"}).json()["id"]
got = admin.post("/api/m01/alias/reject", json={"id": sid2}).json()
assert got["대기"] == [] and sid2 not in json.loads(suggest_file.read_text("utf-8"))["제안"]
assert user.post("/api/m01/alias/suggest", json={**body, "키": "LT000001"}).status_code == 404
# ── 옛 별칭 장부 · 연결 찾기 걷음 ──────────────────────────────────────
def test_옛_장부_길_연결_찾기_길_404() -> None:
from M02_MasterTemplete import M02_Doc_Router as doc_router
from M02_MasterTemplete import M02_Hopyo_Router as hopyo_router
app = FastAPI()
app.dependency_overrides[auth.verify_session] = lambda: {"user_id": 1, "role": "SYSTEM_ADMIN"}
for r in (hopyo_router.router, doc_router.router, _alias_router()):
app.include_router(r)
client = TestClient(app)
for method, url in (
("get", "/api/m02/layers/personal/hopyo-ledger"),
("put", "/api/m02/layers/personal/hopyo-ledger"),
("post", "/api/m02/layers/personal/sangeun/SK-000001/match"),
("get", "/api/m01/alias-ledger"),
("post", "/api/m01/alias-ledger/approve"),
):
assert getattr(client, method)(url).status_code == 404, url
def _alias_router():
from M01_MasterData import M01_MasterData_Alias as alias
return alias.router
def test_걷은_파일_쓰는_곳_0() -> None:
gone = (
"M02_MasterTemplete/M02_Doc_Match.py",
"M02_MasterTemplete/M02_Doc_Match_Note.py",
"M02_MasterTemplete/M02_Doc_Match_Value.py",
"M02_MasterTemplete/M02_Doc_MatchSk.py",
"M02_MasterTemplete/M02_MasterTemplete_Sangeun_MatchBand.ts",
"M02_MasterTemplete/logic/M02_Sangeun_Match.ts",
"M02_MasterTemplete/M02_MasterTemplete_Hopyo_Ledger.ts",
)
assert not [g for g in gone if (ROOT / g).exists()]
names = [Path(g).stem for g in gone]
listed = subprocess.run(
["git", "ls-files", "*.py", "*.ts"], cwd=ROOT, capture_output=True, text=True
).stdout.split()
for one in listed:
path = ROOT / one
if "old_code" in path.parts or "tester" in path.parts or not path.is_file():
continue
text = path.read_text(encoding="utf-8", errors="ignore")
assert not [n for n in names if f"import {n}" in text or f'/{n}"' in text], path
build = (ROOT / "M02_MasterTemplete" / "M02_Hopyo_Build.py").read_text(encoding="utf-8")
assert "ledger.write(" not in build # 일위대가 불러오기 확인 = 장부에 안 적음
doc = (ROOT / "M02_MasterTemplete" / "M02_Doc_Router.py").read_text(encoding="utf-8")
assert "mfind.learn" not in doc # 가져온 식 저장 = 장부에 안 적음
@@ -72,3 +72,72 @@ def test_estx_export_refuses_reordered_sheets():
str(MAIN),
)
assert result.startswith("수량산출서 순서 변경은 ESTX")
MARK = r"""(()=>{
const sheet={Code:'N1',options:{digits:2,roundMode:2},items:[{Code:'r000000',Name:'가'}],
rows:[{LineData:' \x0e잔토\x0e \x130.15\x13 2×3 = 6 \x15㎥\x15 그리고 \x125\x12',markers:['r000000']}]};
const shown=EstText.display(sheet.rows[0].LineData);
const at=shown.indexOf('6 ');
const out=[];
const t=(fn)=>{try{fn();out.push('ok');}catch(e){out.push(e.message.slice(0,6));}};
t(()=>EstQuantitySheets.mark(sheet,0,at,at+1,'r000001'));
const item=sheet.items.find(i=>i.Code==='r000001');
const markers=[...sheet.rows[0].markers];
const calc=EstQuantity.calculate(sheet).items.map(i=>[i.Code,i.GN]);
t(()=>EstQuantitySheets.mark(sheet,0,at+4,at+5,'r000002'));
t(()=>EstQuantitySheets.mark(sheet,0,0,3,'r000002'));
const inside=EstText.display(sheet.rows[0].LineData).indexOf('⟦결과⟧6')+5;
t(()=>EstQuantitySheets.free(sheet,0,inside));
const after=EstText.display(sheet.rows[0].LineData);
sheet.items[0].TargetCode='D1';
const last=after.lastIndexOf('⟦결과⟧')-1;
t(()=>EstQuantitySheets.free(sheet,0,last));
return {out,markers,item:[item.Name,item.Size,item.Unite],calc,
back:after===shown,items:sheet.items.map(i=>i.Code)};
})()"""
def test_mark_and_free_quantity_in_existing_line():
files = ["native-math.js", "native-formula.js", "native-text.js", "native-quantity.cjs"]
result = node(
f"""
for (const f of {json.dumps(files)})
vm.runInContext(fs.readFileSync('tools/est-test/' + f, 'utf8'), ctx, {{ filename: f }});
process.stdout.write(JSON.stringify(run({json.dumps(MARK)})));
"""
)
assert result["markers"] == ["r000001", "r000000"]
assert result["item"] == ["잔토", "0.15", "㎥"]
assert ["r000001", 6] in result["calc"]
assert result["back"] is True
assert result["items"] == ["r000000"]
assert [m[:2] for m in result["out"]] == ["ok", "이미", "산출", "ok", "내역"]
def test_estx_export_refuses_marker_edit():
if not MAIN.exists():
pytest.skip("installed EST main sample unavailable")
files = ["native-math.js", "native-formula.js", "native-text.js", "native-quantity.cjs"]
result = node(
f"""
for (const f of {json.dumps(files)})
vm.runInContext(fs.readFileSync('tools/est-test/' + f, 'utf8'), ctx, {{ filename: f }});
const imp = require('./tools/est-test/import-estx.cjs'), ex = require('./tools/est-test/export-estx.cjs');
ctx.project = imp.importEstx(fs.readFileSync(process.argv[1]));
run(`(() => {{
for (const s of project.quantitySheets)
for (let i = 0; i < s.rows.length; i++) {{
const code = s.rows[i].markers[0], item = s.items.find((x) => x.Code === code);
if (!code || !item || item.TargetCode || item.TargetKey) continue;
const t = EstText.display(s.rows[i].LineData);
return EstQuantitySheets.free(s, i, t.indexOf('⟦결과⟧') + 4);
}}
}})()`);
let message = '';
try {{ ex.exportEstx(ctx.project); }} catch (e) {{ message = e.message; }}
process.stdout.write(JSON.stringify(message));
""",
str(MAIN),
)
assert result.startswith("산출 줄의 수량 지정·해제는 ESTX")
-52
View File
@@ -1,52 +0,0 @@
"""일위대가 별칭 장부 길(PLAN 324-3 · 338-3 · `M02_Hopyo_Ledger`) — 보기 · 고치기 · 권한 · master_data 원본 그대로.
옛 확인 만들기 길(`/hopyo-build` · `/hopyo-unlinked`)은 785-7 에서 지움 — 만들기 = 옮기기(`test_642_5_make`).
층은 임시 폴더.
"""
import json
from M02_MasterTemplete import M02_Hopyo_Store as hs
from M02_MasterTemplete import M02_Template_Layers as layers
# 올리기 길 시험의 `client` 마당을 같이 씀
# ruff: noqa: F811
from resources.tester.test_m02_hopyo_excel import _post, client, master_hash # noqa: F401
DIG = "구조물터파기(인력100%)|토사|m3"
SOIL = "잔토처리(인력100%)||m3"
LEDGER = "/api/m02/layers/{}/hopyo-ledger"
ORIGIN = {"품명": "구조물터파기(인력100%)", "규격": "토사", "단위": "m3"}
def _book(layer_dir):
return json.loads((layer_dir / hs.DIRNAME / f"{hs.MEMORY}.json").read_bytes())["줄"]
def test_장부_길_보기_고치기_권한(client):
before = master_hash()
assert _post(client).status_code == 200
seed = {"줄": {DIG: {"원래": ORIGIN, "갈래": "짝", "짝": [{"로직": "GF000101"}]}}}
assert client.put(LEDGER.format("personal"), json=seed).status_code == 200
rows = client.get(LEDGER.format("personal")).json()["줄"]
assert (DIG, "personal", True) in [(r["열쇠"], r["층"], r["고침"]) for r in rows]
put = {
"줄": {DIG: {"갈래": "짝", "짝": [{"로직": "GF000103", "배": 2}]}, SOIL: {"갈래": "안 씀"}}
}
res = client.put(LEDGER.format("personal"), json=put)
assert res.status_code == 200, res.text
assert res.json()["씀"] == 2
book = _book(layers.personal_dir(7, 42))
assert book[DIG]["짝"][0] == {"로직": "GF000103", "입력값": {}, "배": 2, "몫": None}
bad = {"줄": {DIG: {"갈래": "짝", "짝": [{"로직": "XX"}]}}}
assert client.put(LEDGER.format("personal"), json=bad).status_code == 400
assert client.put(LEDGER.format("personal"), json={"줄": {DIG: None}}).status_code == 200
assert DIG not in _book(layers.personal_dir(7, 42))
assert client.put(LEDGER.format("system"), json=put).status_code == 403 # 위 층 = 읽기만
client.who.update(role="SYSTEM_ADMIN")
assert client.put(LEDGER.format("system"), json=put).status_code == 200
client.who.update(role="USER")
rows = client.get(LEDGER.format("personal")).json()["줄"]
assert {(r["층"], r["고침"]) for r in rows} == {("personal", True), ("system", False)}
assert client.get(LEDGER.format("system")).json()["줄"][0]["고침"] is False
assert master_hash() == before
@@ -94,13 +94,6 @@ def test_층_차례_마스터만_자동_아래_층은_제안_느슨_찾기(clien
assert ledger.recall(DIG, mine, whole=True) is None # 호표 통째 열쇠는 따로
books = ledger.books(mine)
assert ledger.recall(DIG, mine, books)["짝"][0]["로직"] == "GF000101" # 한 번 읽어 넘김
rows = ledger.rows(mine, True)
assert [(r["층"], r["고침"], r["상태"]) for r in rows] == [
("personal", True, "대기"),
("company", False, "대기"),
("system", False, "승인"),
] # 위 층 = 읽기만 · 739-1 승인 상태
assert not any(r["고침"] for r in ledger.rows(mine, False))
def test_재검사(client):
+6 -5
View File
@@ -8,6 +8,8 @@ from pathlib import Path
HERE = os.path.dirname(os.path.abspath(__file__))
M02 = Path(HERE).parents[1] / "M02_MasterTemplete"
def _text(name: str) -> str:
return (M02 / name).read_text(encoding="utf-8")
@@ -20,7 +22,7 @@ def test_좌측_판_자리와_권한():
order = ("precomboSec,", "summary,", "createDivider(),", "section(hopyo.title, hopyo.body),")
assert [host.index(k) for k in order] == sorted(host.index(k) for k in order)
assert host.index("section(hopyo.title, hopyo.body),") < host.index("drawing,")
assert "canWrite: opt.canWrite," in side # 구조물 조합 층 쓰기 권한(720-4 함수)
assert "canWrite: opt.canWrite," not in side # 옛 [별칭 장부] 걷음(815-2)
assert "hopyo.paint();" in side.split("function paint(): void {")[1]
hopyo_side = _text("M02_MasterTemplete_Hopyo_Side.ts")
# 718-1 · 785-4 — 제목 = 화면 글 키 · [불러오기] = 구조물 조합 층 단추 꼴 → 바로 파일 고르기 → 본 칸 불러오기 보기
@@ -30,9 +32,8 @@ def test_좌측_판_자리와_권한():
assert "buildHopyoImport({" in hopyo_side and "Hopyo_Wizard" not in hopyo_side
assert not (M02 / "M02_MasterTemplete_Hopyo_Wizard.ts").exists() # 가운데 상세 페이지 없음
assert "uploadHopyo(" in hopyo_side and "openHopyoSheet" not in hopyo_side
assert (
"buildHopyoLedger({ scope: pageScope(), canWrite: opt.canWrite() })" in hopyo_side
) # [별칭 장부]
assert "buildHopyoLedger" not in hopyo_side and "data-hopyo-ledger" not in hopyo_side
assert not (M02 / "M02_MasterTemplete_Hopyo_Ledger.ts").exists() # 옛 [별칭 장부] 걷음(815-2)
imp = _text("M02_MasterTemplete_Hopyo_Import.ts")
assert 'accept: ".xls,.xlsx"' in hopyo_side and "input.hidden = true;" in hopyo_side
choose = hopyo_side.split("async function choose()")[1].split("input.addEventListener")[0]
@@ -54,4 +55,4 @@ def test_372_2_저장은_개인_층에_삭제는_화면_층_권한():
assert "a.onEnded();" in imp and "held = false;" in imp
assert 'addEventListener("pagehide", leave)' in imp and " leave,\n" in imp
panel = _text("M02_MasterTemplete_Hopyo_Side.ts")
assert panel.count("open?.imp?.leave()") == 2 and panel.count("open.imp?.leave()") == 1
assert panel.count("open?.imp?.leave()") == 1 and panel.count("open.imp?.leave()") == 1
+3 -1
View File
@@ -439,10 +439,12 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD
- 검증완료: `test_est_blank.py` 2개(세 방법·원본 불변·잘못된 방법 거부, 설치 sample.estx 공내역 ESTX 저장·재읽기에서 수량 유지·단가·금액 0·기초자료 유지), 관련 11개 통과. ORCA 브라우저에서 메뉴 → 수량만 남김 → JSON 내려받기(GN 133·TU 0·TC 0), 현재 작업 불변, 사본을 열어 합계 0 유지 확인 후 샘플 복구.
- [x] 수량산출서 복사·순서(원본 TFRMMULLANG 공종복사·붙여넣기) — 현재 산출서 아래에 복사(새 코드·내역 연결 비움), 위로·아래로. 순서 변경 ESTX 저장은 명확한 문구로 거부. 단추 지킴 시험이 `.cjs` 화면 파일도 보도록 넓힘.
- 검증완료: `test_est_quantity_sheets.py` 3개(복사 위치·코드·연결 비움·원본 불변, 끝에서 멈춤, 설치 main.estx 순서 변경 저장 거부), 수량 관련 23개 통과. ORCA 브라우저에서 토공 복사 → 「토공 복사본」 선택·연결 빈칸, 아래로 → 토공·포장·복사본 순서 확인 후 샘플 복구.
- [x] 산출 줄 수량 지정·해제(원본 TFRMMULWORD popSetMulLang·popSetFree) — 드래그한 숫자를 결과 표지로 감싸 새 항목(품명·규격·단위 표지 옮김), 커서 위치 결과 해제·미사용 항목 삭제, 내역 연결 항목 해제 거부. 이 편집은 ESTX 저장을 명확한 문구로 거부(이전에는 「행 삭제」로 잘못 안내).
- 검증완료: `test_est_quantity_sheets.py` 5개(지정 위치·항목·계산 6, 중복·숫자 아님 거부, 해제 원상, 연결 항목 거부, 설치 main.estx 해제 후 저장 거부 문구), 수량 관련 25개 통과. ORCA 브라우저에서 「= 3」 드래그 → 수량 지정 → r000001 잔토처리 ㎥ 3, 커서 두고 지정 해제 → 원래 줄·항목 복귀, 샘플 복구.
- 대조 합계(CAD 제외 882개): 구현 283 · 부분 193 · 없음 273 · 제외 133.
- [ ] 남은 핵심(실무 빈도순):
1. 통합검색 — 저장된 다른 작업에서 단가·호표 찾기.
3. 수량산출서 — 기존 줄 수량 지정·해제, 누계·4방향 합계, 제출용 전체 인쇄.
3. 수량산출서 — 누계·4방향 합계, 제출용 전체 인쇄.
4. Excel → EST 열 맞춤 가져오기(남의 공내역 Excel).
5. 다른 공사 불러오기 시 딸린 일위·단가산출 함께 복사, 프로젝트 추가(합치기).
6. 낙찰률 전체·범위·공종별 적용.
+2
View File
@@ -26,6 +26,8 @@
`편집 → 찾기·다음찾기`(Ctrl+F)는 원본 찾기 창처럼 검색어를 처음부터 또는 현재 위치부터 찾아 해당 행으로 이동합니다. 일위대가·단가산출·기계경비는 찾은 행의 호표로 넘어갑니다. 단가산출에서는 찾은 행의 문구를 한 건씩 바꾸고 다음 위치로 이동하며, 모두 바꾸기는 `산출근거 문구 바꾸기`를 씁니다. 표 위 검색창은 걸러 보기로 그대로 남습니다.
산출 줄 옆 `수량 지정`은 줄 글에서 드래그해 고른 숫자를 결과 표지로 감싸고 수량 항목을 새로 만듭니다. 품명·규격·단위 표지가 있으면 항목 이름에 옮깁니다. `지정 해제`는 커서가 놓인 결과 표지를 풀고, 다른 줄에서 쓰지 않는 항목을 지웁니다. 내역에 연결된 항목은 먼저 연결을 비워야 합니다. 이 변경이 있는 작업은 ESTX 수정 저장을 거부합니다.
수량산출 화면의 `산출서 복사`는 현재 산출서를 바로 아래에 복사하고, 내역에 두 번 반영되지 않도록 복사본의 내역 연결을 비웁니다. `산출서 위로`·`아래로`로 순서를 바꿉니다. 산출서 추가·순서 변경이 있는 작업은 ESTX 수정 저장을 거부하고 JSON 저장을 안내합니다.
수량산출 화면의 수량 항목마다 `끝수 처리`에서 AUTO(산출서 기본)·절사·올림·반올림 0~5자리를 고릅니다. 원본처럼 결과 코드 뒤에 방식과 자리수를 붙이며(`r000001` → `r00000131`), 산출 행의 결과 표지도 함께 바꿉니다. 이 변경이 있는 작업은 ESTX 수정 저장을 거부하고 JSON 저장을 안내합니다.
+2
View File
@@ -418,6 +418,8 @@ function updateQuantities(project, base, cp949, memory) {
const sheets = project.quantitySheets || [],
originals = base.quantitySheets || [];
if (sheets.length !== originals.length) throw Error("수량산출서 추가·삭제는 지원하지 않습니다.");
if (sheets.some((sheet) => sheet.markerEdited))
throw Error("산출 줄의 수량 지정·해제는 ESTX 저장을 지원하지 않습니다. JSON으로 저장하세요.");
if (sheets.some((sheet, i) => sheet.Code !== originals[i].Code))
throw Error("수량산출서 순서 변경은 ESTX 저장을 지원하지 않습니다. JSON으로 저장하세요.");
const rows = project.tables.MulMainDat.map((r) => ({ ...r }));
+1 -1
View File
@@ -602,7 +602,7 @@ const EstBusinessActions = (() => {
(typeof EstPick !== "undefined" && EstPick.handle(name, state)) ||
(typeof EstFind !== "undefined" && EstFind.handle(name, state)) ||
(typeof EstBlank !== "undefined" && EstBlank.handle(name, state)) ||
(typeof EstQuantitySheets !== "undefined" && EstQuantitySheets.handle(name, state))
(typeof EstQuantitySheets !== "undefined" && EstQuantitySheets.handle(name, state, node))
) {
EstApp.render();
} else if (name === "load-contract" || name === "load-bid") {
+91 -2
View File
@@ -1,5 +1,6 @@
// 원본 수량산출(TFRMMULLANG) 공종 단위 조작 — 공종복사하기·붙여넣기(mnuCopy·mnuPaste)와 순서 이동.
// 복사본은 현재 산출서 바로 아래에 넣고, 내역 이중 반영을 막기 위해 내역 연결을 비운다.
// 산출 줄의 수량으로 지정·지정 해제(TFRMMULWORD popSetMulLang·popSetFree)는 화면 표기(⟦결과⟧) 위치로 다룬다.
const EstQuantitySheets = (() => {
function copy(project, code, uid) {
const sheets = project.quantitySheets || [],
@@ -30,7 +31,82 @@ const EstQuantitySheets = (() => {
return true;
}
function handle(name, state) {
const RESULT = "⟦결과⟧";
function tagged(shown, label) {
const match = shown.match(new RegExp(`⟦${label}⟧([^⟦]*)⟦${label}⟧`));
return match ? match[1].trim() : "";
}
function count(text) {
return text.split(RESULT).length - 1;
}
function mark(sheet, rowIndex, start, end, code, text) {
const row = sheet.rows[rowIndex];
if (!row) throw Error("산출 줄을 고르세요.");
const shown = text ?? EstText.display(row.LineData);
let from = Math.min(start, end),
to = Math.max(start, end);
while (from < to && /\s/.test(shown[from])) from++;
while (to > from && /\s/.test(shown[to - 1])) to--;
const picked = shown.slice(from, to);
if (!/^-?(\d{1,3}(,\d{3})+|\d+)(\.\d+)?$/.test(picked))
throw Error("산출 줄에서 수량으로 지정할 숫자만 드래그해 고르세요.");
if (count(shown.slice(0, from)) % 2) throw Error("이미 수량으로 지정된 숫자입니다.");
const position = count(shown.slice(0, from)) / 2;
row.LineData = EstText.encode(
`${shown.slice(0, from)}${RESULT}${picked}${RESULT}${shown.slice(to)}`,
);
row.markers = [...(row.markers || [])];
row.markers.splice(position, 0, code);
sheet.markerEdited = true;
sheet.items.push({
Code: code,
Name: tagged(shown, "품명") || "새 항목",
Size: tagged(shown, "규격"),
Unite: tagged(shown, "단위"),
GN: 0,
TargetCode: "",
});
return code;
}
function free(sheet, rowIndex, caret, text) {
const row = sheet.rows[rowIndex];
if (!row) throw Error("산출 줄을 고르세요.");
const shown = text ?? EstText.display(row.LineData),
pairs = [];
for (let at = shown.indexOf(RESULT), open = -1; at >= 0; at = shown.indexOf(RESULT, at + 1))
if (open < 0) open = at;
else {
pairs.push([open, at + RESULT.length]);
open = -1;
}
const position = pairs.findIndex(([open, close]) => caret >= open && caret <= close);
if (position < 0) throw Error("해제할 수량(⟦결과⟧ 안쪽)에 커서를 두세요.");
const code = row.markers?.[position],
item = sheet.items.find((next) => next.Code === code);
if (item?.TargetCode || item?.TargetKey)
throw Error("내역에 연결된 수량 항목입니다. 연결 내역코드를 먼저 비우세요.");
const [open, close] = pairs[position];
row.LineData = EstText.encode(
shown.slice(0, open) +
shown.slice(open + RESULT.length, close - RESULT.length) +
shown.slice(close),
);
row.markers = row.markers.filter((_, i) => i !== position);
sheet.markerEdited = true;
if (!sheet.rows.some((other) => (other.markers || []).includes(code)))
sheet.items = sheet.items.filter((next) => next.Code !== code);
return code;
}
function lineEditor(index) {
const editor = document.querySelector(`[data-quantity-line="${index}"]`);
if (!editor) throw Error("산출 줄을 찾을 수 없습니다.");
return editor;
}
function handle(name, state, node) {
const code = state.quantitySheet;
if (name === "quantity-sheet-copy") {
EstApp.change((project) => {
@@ -44,9 +120,22 @@ const EstQuantitySheets = (() => {
if (index < 0 || index + step < 0 || index + step >= sheets.length)
throw Error("더 옮길 수 없습니다.");
EstApp.change((project) => move(project, code, step), false);
} else if (name === "quantity-mark" || name === "quantity-free") {
const sheet = state.project.quantitySheets?.find((next) => next.Code === code),
index = Number(node?.dataset.index),
editor = lineEditor(index);
if (!sheet) throw Error("수량산출서를 고르세요.");
const { selectionStart: start, selectionEnd: end } = editor;
if (name === "quantity-mark" && start === end)
throw Error("산출 줄에서 수량으로 지정할 숫자를 드래그해 고른 뒤 누르세요.");
EstApp.change(() => {
if (name === "quantity-mark")
mark(sheet, index, start, end, EstQuantity.nextItemCode(sheet), editor.value);
else free(sheet, index, start, editor.value);
}, false);
} else return false;
return true;
}
return { copy, move, handle };
return { copy, move, mark, free, handle };
})();
+1 -1
View File
@@ -315,7 +315,7 @@ const EstQuantity = (() => {
{ esc, number, byId } = EstApp;
byId("work-content").innerHTML =
selector(state) +
`<div class="business-tools"><label>산출서명 <input data-quantity-name value="${esc(sheet.Name)}"></label><button data-quantity-add-item>수량 항목 추가</button><button data-quantity-add>산출 행 추가</button><button data-quantity-apply>내역 수량 반영</button><button data-action="quantity-sheet-copy">산출서 복사</button><button data-action="quantity-sheet-up">산출서 위로</button><button data-action="quantity-sheet-down">산출서 아래로</button><button data-quantity-delete-sheet>산출서 삭제</button></div><table class="sheet"><thead><tr><th>산출코드</th><th>품명</th><th>규격</th><th>단위</th><th>저장 수량</th><th>재계산 수량</th><th>연결 내역코드</th><th>끝수 처리</th><th>작업</th></tr></thead><tbody>${output.items.map((item, index) => `<tr><td>${esc(item.Code)}</td><td><input data-quantity-item="Name" data-index="${index}" value="${esc(item.Name || "")}" aria-label="품명"></td><td><input data-quantity-item="Size" data-index="${index}" value="${esc(item.Size || "")}" aria-label="규격"></td><td><input data-quantity-item="Unite" data-index="${index}" value="${esc(item.Unite || "")}" aria-label="단위"></td><td>${number(sheet.items[index]?.GN ?? 0)}</td><td>${number(item.GN)}</td><td style="white-space:normal"><input data-quantity-target="${index}" value="${esc(item.TargetCode ?? item.LinkCode ?? "")}" aria-label="연결 내역코드">${targetSelect(state.project, item, "sheet", index)}</td><td>${formatSelect(sheet.items[index], index)}</td><td><button data-quantity-delete-item="${index}">삭제</button></td></tr>`).join("")}</tbody></table><p class="business-note">${esc(output.errors.join(" / "))} 산출 행의 결과 표지로 수량 항목을 연결합니다. 신규 산출서와 지원되지 않는 원본 구조 변경은 JSON으로 저장합니다.</p><table class="sheet"><thead><tr><th>줄</th><th>산출근거 편집</th><th>작업</th></tr></thead><tbody>${sheet.rows.map((row, index) => `<tr><td>${index + 1}</td><td><textarea data-quantity-line="${index}" rows="1" style="min-width:750px" aria-label="산출근거 ${index + 1}">${esc(EstText.display(output.rows[index].LineData))}</textarea></td><td><button data-quantity-add data-index="${index}">아래 추가</button><button data-quantity-move="-1" data-index="${index}" ${index === 0 ? "disabled" : ""}>↑</button><button data-quantity-move="1" data-index="${index}" ${index === sheet.rows.length - 1 ? "disabled" : ""}>↓</button><button data-quantity-delete-row="${index}">삭제</button></td></tr>`).join("")}</tbody></table>`;
`<div class="business-tools"><label>산출서명 <input data-quantity-name value="${esc(sheet.Name)}"></label><button data-quantity-add-item>수량 항목 추가</button><button data-quantity-add>산출 행 추가</button><button data-quantity-apply>내역 수량 반영</button><button data-action="quantity-sheet-copy">산출서 복사</button><button data-action="quantity-sheet-up">산출서 위로</button><button data-action="quantity-sheet-down">산출서 아래로</button><button data-quantity-delete-sheet>산출서 삭제</button></div><table class="sheet"><thead><tr><th>산출코드</th><th>품명</th><th>규격</th><th>단위</th><th>저장 수량</th><th>재계산 수량</th><th>연결 내역코드</th><th>끝수 처리</th><th>작업</th></tr></thead><tbody>${output.items.map((item, index) => `<tr><td>${esc(item.Code)}</td><td><input data-quantity-item="Name" data-index="${index}" value="${esc(item.Name || "")}" aria-label="품명"></td><td><input data-quantity-item="Size" data-index="${index}" value="${esc(item.Size || "")}" aria-label="규격"></td><td><input data-quantity-item="Unite" data-index="${index}" value="${esc(item.Unite || "")}" aria-label="단위"></td><td>${number(sheet.items[index]?.GN ?? 0)}</td><td>${number(item.GN)}</td><td style="white-space:normal"><input data-quantity-target="${index}" value="${esc(item.TargetCode ?? item.LinkCode ?? "")}" aria-label="연결 내역코드">${targetSelect(state.project, item, "sheet", index)}</td><td>${formatSelect(sheet.items[index], index)}</td><td><button data-quantity-delete-item="${index}">삭제</button></td></tr>`).join("")}</tbody></table><p class="business-note">${esc(output.errors.join(" / "))} 산출 행의 결과 표지로 수량 항목을 연결합니다. 신규 산출서와 지원되지 않는 원본 구조 변경은 JSON으로 저장합니다.</p><table class="sheet"><thead><tr><th>줄</th><th>산출근거 편집</th><th>작업</th></tr></thead><tbody>${sheet.rows.map((row, index) => `<tr><td>${index + 1}</td><td><textarea data-quantity-line="${index}" rows="1" style="min-width:750px" aria-label="산출근거 ${index + 1}">${esc(EstText.display(output.rows[index].LineData))}</textarea></td><td><button data-quantity-add data-index="${index}">아래 추가</button><button data-quantity-move="-1" data-index="${index}" ${index === 0 ? "disabled" : ""}>↑</button><button data-quantity-move="1" data-index="${index}" ${index === sheet.rows.length - 1 ? "disabled" : ""}>↓</button><button data-action="quantity-mark" data-index="${index}">수량 지정</button><button data-action="quantity-free" data-index="${index}">지정 해제</button><button data-quantity-delete-row="${index}">삭제</button></td></tr>`).join("")}</tbody></table>`;
}
// 원본 수량목록 자리수 메뉴(mnuAuto·mnuTrunc·mnuUp·mnuRound): 결과 코드 뒤 방식(1 반올림·2 절사·3 올림)+자리수.
function resultFormat(code) {
+1 -1
View File
@@ -173,7 +173,7 @@ export const ui_locales_m1 = {
B01_CalcSites_LoadFailed: ["계산 자리를 못 읽음", "Failed to load calc sites"],
B01_Dashboard_CardLogic: ["단가산출 기초", "Cost-logic library"],
B01_Dashboard_CardStructOptionDef: ["구조물 옵션 정의", "Structure option definition"],
B01_Dashboard_CardAliasLedger: ["별칭 장부", "Alias ledger"],
B01_Dashboard_CardAliasLedger: ["별칭 모아 보기", "Aliases"],
M01_RateSheetTitle: [
"조달청 제비율표 — 읽기만 · 오른쪽 클릭으로 시트 ↔ 리스트 이동",
"PPS cost-rate table — read only · right-click to jump between sheet and list",