diff --git a/B09_Estimation/B09_Estimation_Bill_Link.py b/B09_Estimation/B09_Estimation_Bill_Link.py index 5fee2e7a1..0732de6f8 100644 --- a/B09_Estimation/B09_Estimation_Bill_Link.py +++ b/B09_Estimation/B09_Estimation_Bill_Link.py @@ -15,10 +15,15 @@ 안 줄이 그 아래(저절로 묶음 · 금액 = 안 줄 합) / price = 안 줄 금액 합 ÷ 개수(기본 = B08 수량) = 1 단위 고정 단가 · 안 줄 지움 (B08 수량 × 단가 · 끝수 비목마다 1 원 안). + 이은 자동 줄 `was` = 되돌릴 것 {rows: [불러온 줄 · price 안 줄], old: 자동 줄 덮개, + prev · next: 그때 같은 부모 앞 · 뒤 줄 키} · 수량 갈래 = 줄마다(`modes`) · 없으면 `mode` +풀기(`unlink`) — `was` 대로 불러온 줄 · 자동 줄 제자리(맞춤 기억은 그대로 · 잊기 = 부르는 쪽). +판(`board`) — 남은 불러온 줄 · 빈 자리(이을 수 있는 자동 줄) · 이은 줄. """ from __future__ import annotations +import copy import re from typing import Any @@ -198,11 +203,14 @@ def link( pairs: list[tuple[str, str]], mode: str = "take", counts: dict[str, float] | None = None, + modes: dict[str, str] | None = None, ) -> tuple[dict[str, Any], dict[str, Any]]: """(새 사용자 값, 알림 {이음, 묶음, 지움, 못함 [{key, 까닭}], 기억 {열쇠: 확정}}) — `rows` = 지금 셈 줄 · - `counts` = 묶음 개수 {불러온 묶음 키: 수}(price 갈래 · 없으면 B08 수량).""" - if mode not in MODES: + `counts` = 묶음 개수 {불러온 묶음 키: 수}(price 갈래 · 없으면 B08 수량) · + `modes` = 줄마다 수량 갈래 {불러온 줄 키: take | price}.""" + modes = modes or {} + if any(m not in MODES for m in (mode, *modes.values())): raise ValueError(f"수량 갈래는 {', '.join(MODES)} 중 하나") counts = counts or {} by_key = {r["key"]: r for r in rows} @@ -211,6 +219,7 @@ def link( bill = freeze(user, rows) report: dict[str, Any] = {"이음": 0, "묶음": 0, "지움": 0, "못함": [], "기억": {}} for line, target in pairs: + mode_ = modes.get(line, mode) entries = bill["rows"] at = next((n for n, e in enumerate(entries) if e["key"] == line), -1) row, goal = by_key.get(line), targets.get(target) @@ -221,32 +230,37 @@ def link( else "묶음 ↔ 구조물 줄만" if (row["kind"] != "item") != target.startswith("st:") else "" ) # fmt: skip - if not why and row["kind"] != "item" and mode == "price": + if not why and row["kind"] != "item" and mode_ == "price": count = counts.get(line, goal.get("auto_qty", goal.get("qty"))) why = "" if count else "개수 없음(B08 수량 없음) — 넘겨받기로" if why: report["못함"].append({"key": line, "까닭": why}) continue - old = next((e for e in entries if e["key"] == target), None) - if old is not None: - entries.remove(old) + old_at = next((n for n, e in enumerate(entries) if e["key"] == target), -1) + old = entries.pop(old_at) if old_at >= 0 else None + sibs = [e["key"] for e in entries if old and e.get("parent") == old.get("parent")] + cut = sum(1 for e in entries[:old_at] if old and e.get("parent") == old.get("parent")) + was = {"old": copy.deepcopy(old), "prev": sibs[:cut], "next": sibs[cut:]} at = next(n for n, e in enumerate(entries) if e["key"] == line) hand = entries.pop(at) - auto = {k: v for k, v in (old or {"key": target}).items() if k not in ("price", "src_qty")} + was["rows"] = [copy.deepcopy(hand)] + skip = ("price", "src_qty", "was") + auto = {k: v for k, v in (old or {"key": target}).items() if k not in skip} if row["kind"] == "item": auto.update({"ref": hand.get("ref"), "src_qty": hand.get("src_qty", hand.get("qty"))}) if hand.get("price"): auto["price"] = hand["price"] - if mode == "take" or goal.get("auto_qty", goal.get("qty")) is None: + if mode_ == "take" or goal.get("auto_qty", goal.get("qty")) is None: auto["qty"] = hand.get("qty") report["이음"] += 1 - elif mode == "take": + elif mode_ == "take": for e in entries: # 안 줄 = 구조물 줄 아래 · 금액 = 안 줄 합 if e.get("parent") == line: e["parent"] = target report["묶음"] += 1 else: inner = _descendants(entries, line) + was["rows"] += [copy.deepcopy(e) for e in entries if e["key"] in inner] items = [by_key[k] for k in inner if by_key.get(k, {}).get("kind") == "item"] auto.update( { @@ -258,7 +272,73 @@ def link( bill["rows"] = entries = [e for e in entries if e["key"] not in inner] at = min(at, len(entries)) report.update(묶음=report["묶음"] + 1, 지움=report["지움"] + len(inner)) + auto["was"] = was _place(bill, at, auto, hand.get("parent")) used.add(target) report["기억"][key_of(row)] = {"확정": target, "이름": goal["name"]} return {**user, "bill": bill}, report + + +def unlink( + user: dict[str, Any], rows: list[dict[str, Any]], target: str +) -> tuple[dict[str, Any], str]: + """(새 사용자 값, 못한 까닭 · 됨 = "") — 이은 자동 줄 `was` 대로 불러온 줄 · 자동 줄 되돌림.""" + bill = freeze(user, rows) + entries = bill["rows"] + at = next((n for n, e in enumerate(entries) if e["key"] == target), -1) + was = entries[at].get("was") if at >= 0 else None + back = was.get("rows") if isinstance(was, dict) else None + if not back: + return user, "이은 줄이 아님" + entries.pop(at) + head = back[0]["key"] + for e in entries: # take 묶음 안 줄 = 불러온 묶음 아래로 + if e.get("parent") == target: + e["parent"] = head + entries[at:at] = copy.deepcopy(back) + old = was.get("old") + if old: # 자동 줄 = 그때 뒤 줄 앞 · 없으면 앞 줄 뒤 · 둘 다 없음 = 셈이 기본 부모 끝 + where = {e["key"]: n for n, e in enumerate(entries)} + after = [where[k] + 1 for k in was.get("prev") or [] if k in where] + spot = next((where[k] for k in was.get("next") or [] if k in where), None) + spot = spot if spot is not None else (after[-1] if after else None) + if spot is not None: + entries.insert(spot, old) + return {**user, "bill": bill}, "" + + +def _under(rows: list[dict[str, Any]], key: str) -> bool: + """불러온 줄이 이은 자동 줄(구조물 take 묶음) 아래인지.""" + by_key = {r["key"]: r for r in rows} + up = by_key.get(key, {}).get("parent") + while up: + if not str(up).startswith("hand:"): + return up in by_key and str(up).split(":")[0] in HEADS + up = by_key.get(up, {}).get("parent") + return False + + +def board(user: dict[str, Any], rows: list[dict[str, Any]]) -> dict[str, Any]: + """잇기 창 판 — 남은 불러온 줄(구조물 묶음에 딸린 줄 뺌) · 빈 자리 · 이은 줄.""" + + def pick(r: dict[str, Any]) -> dict[str, Any]: + return {f: r.get(f) for f in ("key", "kind", "name", "spec", "unit")} + + used = _linked(rows) + entries = {e["key"]: e for e in ((user.get("bill") or {}).get("rows") or [])} + by_key = {r["key"]: r for r in rows} + linked = [] + for key, e in entries.items(): + was = e.get("was") if isinstance(e.get("was"), dict) else None + if was and was.get("rows") and key in by_key: + hand = was["rows"][0] + linked.append({**pick(by_key[key]), "from": pick(hand), "memo": key_of(hand)}) + return { + "남은": [ + pick(r) for r in rows if r["key"].startswith("hand:x") and not _under(rows, r["key"]) + ], + "자리": [ + {**pick(r), "head": k.split(":")[0]} for k, r in _targets(rows).items() if k not in used + ], + "이은": linked, + } diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 058f9fffb..2e6424a3a 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -88,6 +88,7 @@ from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs from B09_Estimation.B09_Estimation_Linked import linked_resource_keys from B09_Estimation.B09_Estimation_Rates import rate_candidates, rate_table from B09_Estimation.B09_Estimation_Router_Import import router as import_router +from B09_Estimation.B09_Estimation_Router_Link import router as link_router from B09_Estimation.B09_Estimation_Router_Quotes import router as quotes_router from B09_Estimation.B09_Estimation_Summary import compute_summary, hopyo_lines, used_materials from common_util.common_util_auth import verify_session @@ -589,6 +590,7 @@ async def past_import( # 166-4 견적서 파일 길 · 707-1~3 경쟁사 결과물 불러오기 길 — 같은 머리 · 같은 권한 router.include_router(quotes_router) router.include_router(import_router) +router.include_router(link_router) # 707-4 내역 줄 ↔ 우리 공종 잇기 # 302 초안 길(draft/*_Router.py 의 router · 폴더째 지우면 길도 없음) — 같은 머리 · 같은 권한 diff --git a/B09_Estimation/B09_Estimation_Router_Link.py b/B09_Estimation/B09_Estimation_Router_Link.py index a5cb113f1..d7f27a368 100644 --- a/B09_Estimation/B09_Estimation_Router_Link.py +++ b/B09_Estimation/B09_Estimation_Router_Link.py @@ -1,14 +1,18 @@ """B09 내역 줄 ↔ 우리 공종 잇기 길(PLAN 707-4) — 저장 안 함(화면 캐시로 → [저장] 때 서버 정본). POST /api/projects/{id}/estimation/link/suggest {user?} - → {줄: 제안(`B09_Estimation_Bill_Link.suggest`) · 저절로: 지난번 이음 · 하나 수} + → {줄: 제안(`B09_Estimation_Bill_Link.suggest` · 구조물 묶음에 딸린 줄 뺌) · + 저절로: 지난번 이음 · 하나 수 · 남은 · 자리 · 이은(`board`)} POST /api/projects/{id}/estimation/link/apply - {pairs [{line, target}], auto, mode, counts?, user?} + {pairs [{line, target, mode?}], auto, mode, counts?, user?} · 알림 남은 = 안 이은 항목 수 잇기(`link`) — `auto` = 지난번 이음 · 하나도 더함 → {user(새 캐시), 알림, payload(그 캐시로 셈)} · 이은 짝 = 맞춤 기억에 씀(회사 층 = 회사 관리자 · 아니면 개인 층) + POST …/link/undo {target, user?} → {user, 알림, payload} — 잇기 풀기(맞춤 기억 그대로) + POST …/link/forget {key, user?} → {잊음, 기억층, 남음} — 이 짝 잊기(불러온 줄 · 이은 줄 키 → + 맞춤 열쇠 · 쓸 수 있는 층에서 지움 · 남음 = 못 지운 위 층 기억) 맞춤 기억 = 층 `실무자료/내역맞춤.json`(실무자료 맞춤 꼴) · 읽기 = 회사 + 개인(개인이 이김). -끼워 넣기 = `B09_Estimation_Router`(같은 머리 · 같은 권한) — 화면 고리 일감 몫. +끼워 넣기 = `B09_Estimation_Router`(같은 머리 · 같은 권한) · 화면 = `B09_Estimation_UI_Link`. """ from __future__ import annotations @@ -73,6 +77,17 @@ class SuggestBody(BaseModel): class Pair(BaseModel): line: str = Field(min_length=1, max_length=80) target: str = Field(min_length=1, max_length=200) + mode: str | None = None + + +class UndoBody(BaseModel): + target: str = Field(min_length=1, max_length=200) + 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): @@ -96,8 +111,11 @@ async def suggest( 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) - lines = linker.suggest(rows, await asyncio.to_thread(recalled, session)) - return {"줄": lines, "저절로": len(linker.auto_pairs(lines))} + 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] + return {"줄": lines, "저절로": len(linker.auto_pairs(lines)), **board} @router.post(PATH + "/apply") @@ -106,7 +124,8 @@ async def apply( ) -> dict[str, Any]: from B09_Estimation.B09_Estimation_Router import payload # 서로 부름 — 부를 때만 - if body.mode not in linker.MODES: + modes = {p.line: p.mode for p in body.pairs if p.mode} + if any(m not in linker.MODES for m in (body.mode, *modes.values())): raise HTTPException(status_code=400, detail="수량 갈래는 take · price 중 하나") root = await _project_root(project_id) draft = _draft(body.user) or await asyncio.to_thread(store.read_user, root) @@ -115,11 +134,47 @@ async def apply( 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] - made, report = linker.link(draft, rows, pairs, body.mode, body.counts) + 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("기억")) + 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} + + +@router.post(PATH + "/undo") +async def undo( + project_id: UUID, body: UndoBody, session: dict[str, Any] = Depends(verify_session) +) -> dict[str, Any]: + from B09_Estimation.B09_Estimation_Router import payload # 서로 부름 — 부를 때만 + + root = await _project_root(project_id) + draft = _draft(body.user) or await asyncio.to_thread(store.read_user, root) + made, why = linker.unlink(draft, await _rows(project_id, root, draft), body.target) + if why: + raise HTTPException(status_code=400, detail=why) + made = store.clean_user(made) return { "user": made, - "알림": {**report, "기억층": layer}, + "알림": {"풀음": 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: None}) + left = memo in await asyncio.to_thread(recalled, session) + return {"잊음": memo, "기억층": layer, "남음": left} diff --git a/B09_Estimation/B09_Estimation_Store.py b/B09_Estimation/B09_Estimation_Store.py index 3ba1c68b3..12026ad0b 100644 --- a/B09_Estimation/B09_Estimation_Store.py +++ b/B09_Estimation/B09_Estimation_Store.py @@ -168,6 +168,19 @@ def _fixed_cells(row: dict[str, Any]) -> dict[str, Any]: return out +def _clean_was(raw: Any) -> dict[str, Any] | None: + """707-4 이은 자동 줄 되돌릴 것 — 불러온 줄(hand 먼저) · 자동 줄 덮개 · 앞 · 뒤 줄 키.""" + if not isinstance(raw, dict) or not isinstance(raw.get("rows"), list): + return None + rows = _clean_v2({"rows": raw["rows"]}).get("rows") or [] + if not rows or not rows[0]["key"].startswith("hand:"): + return None + old = raw.get("old") + old = (_clean_v2({"rows": [old]}).get("rows") or [None])[0] if isinstance(old, dict) else None + near = {k: [x for x in raw.get(k) or [] if isinstance(x, str)][:500] for k in ("prev", "next")} + return {"rows": rows, "old": old, **near} + + def _clean_v2(raw: dict[str, Any]) -> dict[str, Any]: """184 덮개 v2 — 표 차례(겹친 키 · `fee` 버림) · 새 줄(`hand:`) = 모든 칸 · 자동 줄 = 고친 칸만(값 · null = 일부러 빈칸 · 못 쓰는 값 = 칸 버림) · `gone` = 자동 줄 키 · @@ -204,6 +217,8 @@ def _clean_v2(raw: dict[str, Any]) -> dict[str, Any]: if "ref" in row and (row["ref"] is None or _clean_ref(row["ref"]) is not None): out["ref"] = _clean_ref(row["ref"]) out.update(_fixed_cells(row)) + if was := _clean_was(row.get("was")): + out["was"] = was rows.append(out) given = raw.get("gone") if isinstance(raw.get("gone"), list) else [] gone = list( diff --git a/B09_Estimation/B09_Estimation_UI_Import.ts b/B09_Estimation/B09_Estimation_UI_Import.ts index 24b46fcaa..889de4691 100644 --- a/B09_Estimation/B09_Estimation_UI_Import.ts +++ b/B09_Estimation/B09_Estimation_UI_Import.ts @@ -34,6 +34,7 @@ import { openModal } from "@ui/ui_template_modal"; import { createText } from "@ui/ui_template_text"; import { currentProjectId } from "../A00_Common/b_project_switch"; import type { Row } from "./B09_Estimation_Calc_Base"; +import { openBillLink } from "./B09_Estimation_UI_Link"; import { money, qty, text, type TabContext, type TabId } from "./B09_Estimation_UI_Tabs"; const won = (v: unknown): string => Number(v || 0).toLocaleString("ko-KR"); @@ -160,6 +161,7 @@ export function createBillImport( // 창 닫고 다시 그림 — 표 높이(fillViewport)가 열린 창 가림막을 아래 자리로 셈 close(); done(got.payload, made); + void openBillLink(user(), done, mode.value); // 707-4 다음: 우리 공종으로 잇기 const n = got.알림 || {}; const head = `새 줄 ${n.실음} · 붙임 ${n.붙임} · 지움 ${n.지움} · 이음 ${n.이음}${tail} — [저장]해야 남음`; const off: Row[] = got.어긋남 || []; diff --git a/B09_Estimation/B09_Estimation_UI_Link.ts b/B09_Estimation/B09_Estimation_UI_Link.ts new file mode 100644 index 000000000..bd87dca85 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Link.ts @@ -0,0 +1,325 @@ +/* ============================================================================= + * B09_Estimation_UI_Link.ts + * B09 설계내역서 내역 줄 ↔ 우리 공종 잇기 창(PLAN 707-4) — 불러온 경쟁사 줄(707-1 `hand:x…`)을 + * 우리 자동 줄(토공 · 운반 · 구조물)에 이어 B08 수량이 붙게. 저장 안 함(받은 캐시 → [저장] 때 서버 정본). + * + * 길 = `B09_Estimation_Router_Link.py` link/suggest(제안 · 남은 줄 · 빈 자리 · 이은 줄) · + * link/apply(잇기 · 맞춤 기억 씀) · link/undo(잇기 풀기) · link/forget(이 짝 잊기) — 셈 = 서버 단독 + * 창 = 제안 줄(꼬리표 지난번 이음 · 짝 표 · 이름 같음 · 후보 고름 · 수량 갈래 · 묶음 개수 · [잇기]) · + * 제안 없는 줄([우리 공종으로 잇기] = 단가연결 고르기 창 재사용 · 우리 줄만) · 이은 줄([잇기 풀기] · + * [이 짝 잊기]) · 머리 [모두 제안대로](짧은 길 = 불러오기 → 모두 제안대로 → [저장]) + * 수량 갈래 기본 = 불러올 때 고른 것(가 넘겨받기 · 나 단가만) · 줄에서 바꿈 · + * 묶음 나 = 개수 칸(빈칸 = B08 수량 · 다르면 수량 칸 붉게) + * 고리 = 불러오기 끝(`…_UI_Import`) · 설계내역서 우클릭(`…_UI_Tabs_Bill_Edit`) + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import type { MapContextMenuItem } from "@ui/ui_template_context_menu"; +import { createBadge, type BadgeTone } from "@ui/ui_template_badge"; +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { createInputField, createSelectField } from "@ui/ui_template_input"; +import { createListRow } from "@ui/ui_template_list_row"; +import { openModal } from "@ui/ui_template_modal"; +import { createText } from "@ui/ui_template_text"; +import { createToolbar } from "@ui/ui_template_toolbar"; +import { + openLogicPicker, + type PickExtra, +} from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Pick"; +import { currentProjectId } from "../A00_Common/b_project_switch"; +import type { Row } from "./B09_Estimation_Calc_Base"; +import type { TabContext } from "./B09_Estimation_UI_Tabs"; + +/** 받은 원가 자료 · 새 캐시를 화면에 — 불러오기 고리 = 페이지 `done` · 우클릭 = 탭 ctx */ +export type LinkDone = (payload: Row, user: Row) => void; +type Mode = "take" | "price"; + +const MODES = [ + { value: "take", text: "가 넘겨받기" }, + { value: "price", text: "나 단가만" }, +]; +const HEAD = { earth: "토공", haul: "운반", st: "구조물" } as Record; +const TONE: Record = { "지난번 이음": "success", "짝 표": "info" }; +/** 마지막에 고른 수량 갈래(불러올 때 · 창에서) — 우클릭 길 기본 */ +let lastMode: Mode = "take"; + +const flat = (t: unknown): string => String(t ?? "").replace(/\s+/g, ""); +const label = (r: Row): string => + [r.name, r.spec].filter(Boolean).join(" ") + (r.unit ? ` (${r.unit})` : ""); + +async function post(path: string, body: Row): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${currentProjectId()}/estimation/link/${path}`, + { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); + const got = (await response.json().catch(() => null)) as Row | null; + if (!response.ok || !got) { + const detail = got?.detail; + throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`); + } + return got; +} + +const fail = (head: string) => (error: unknown) => + showToast(`${head} — ${error instanceof Error ? error.message : error}`, "error"); + +/** 잇기 한 번 — 받은 것 화면에 → 새 캐시 · 알림 글 */ +async function linkPairs(user: Row, done: LinkDone, pairs: Row[], counts: Row): Promise { + const got = await post("apply", { pairs, mode: lastMode, counts, user }); + done(got.payload, got.user); + const n = got.알림 || {}; + const miss: Row[] = n.못함 || []; + const head = `이음 ${n.이음} · 묶음 ${n.묶음}${n.지움 ? ` · 안 줄 지움 ${n.지움}` : ""}`; + const tail = + (miss.length ? ` · 못함 ${miss.length}(${miss[0].까닭})` : "") + + (n.남은 != null ? ` · 안 이은 불러온 항목 ${n.남은}` : ""); + showToast(`${head}${tail} — [저장]해야 남음`, miss.length ? "warning" : "success", 6000); + return got.user; +} + +/** 우리 줄 고르기 — 단가연결 고르기 창(덧줄만 · 종류 = 토공 · 운반 · 구조물) */ +function pickOurs(spots: Row[], line: Row, take: (key: string) => void): void { + const group = line.kind !== "item"; + const extra: PickExtra[] = spots + .filter((s) => (s.head === "st") === group) + .map((s) => ({ + kind: String(s.head), + key: String(s.key), + tag: HEAD[s.head] ?? s.head, + name: String(s.name ?? ""), + sub: String(s.spec ?? ""), + unit: String(s.unit ?? ""), + onPick: () => take(String(s.key)), + })); + if (!extra.length) + return void showToast(group ? "빈 구조물 줄 없음" : "빈 토공 · 운반 줄 없음", "warning"); + const kinds = [...new Set(extra.map((x) => x.kind))]; + openLogicPicker(() => undefined, { + layer: "project", + owner: null, + libOnly: true, + title: `우리 공종으로 잇기 — ${label(line)}`, + extra, + kindFilter: kinds.map((k) => ({ value: k, text: HEAD[k] ?? k })), + pick: () => undefined, + }); +} + +/** 잇기 창 — user = 지금 캐시 · mode = 불러올 때 고른 갈래(없으면 마지막) */ +export async function openBillLink(user: Row, done: LinkDone, mode?: string): Promise { + if (mode === "take" || mode === "price") lastMode = mode; + let cur = user; + let seen: Row; + try { + seen = await post("suggest", { user: cur }); + } catch (error) { + return fail("제안을 못 받음")(error); + } + if (!seen.남은.length && !seen.이은.length) return; // 불러온 줄 없음 + let busy = false; + const run = (job: () => Promise): void => { + if (busy) return; + busy = true; + job() + .then((next) => { + if (next) cur = next; + return post("suggest", { user: cur }); + }) + .then((next) => ((seen = next), draw()), fail("잇기 못 함")) + .finally(() => (busy = false)); + }; + const all = createButton({ label: "모두 제안대로", variant: "filled" }); + const modeSel = createSelectField({ + options: MODES, + value: lastMode, + compact: true, + label: "수량 갈래(줄에서 바꿈)", + onChange: (v) => (lastMode = v as Mode), + }); + const body = el("div", { className: "b09-import__choices" }); + // 줄마다 고른 것(후보 · 갈래 · 개수) — 다시 그려도 남음 + const chosen = new Map(); + const pickOf = (s: Row) => { + // 기본 = 앞 줄이 안 고른 첫 후보(암석절취 연암 · 보통암 → 절토(연암) · 암발파) + const taken = new Set([...chosen.values()].map((c) => c.target)); + const first = (s.후보 as Row[]).find((c) => !taken.has(c.key)) ?? s.후보[0]; + const got = chosen.get(s.key) ?? { target: first.key, mode: null, count: "" }; + chosen.set(s.key, got); + return got; + }; + const pairOf = (s: Row): Row => { + const p = pickOf(s); + return { line: s.key, target: p.target, ...(p.mode && { mode: p.mode }) }; + }; + const countsOf = (lines: Row[]): Row => + Object.fromEntries( + lines + .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); + const tags = [createBadge(s.꼬리표, TONE[s.꼬리표] ?? "neutral")]; + if (s.후보.some((c: Row) => flat(c.name) === flat(s.name))) + tags.push(createBadge("이름 같음", "accent")); + if (!s.하나) tags.push(createBadge("고름", "warning")); + const target = createSelectField({ + options: s.후보.map((c: Row) => ({ value: c.key, text: `→ ${c.name}` })), + value: p.target, + compact: true, + ariaLabel: "이을 우리 줄", + onChange: (v) => (p.target = v), + }); + const lineMode = createSelectField({ + options: [{ value: "", text: "창 갈래" }, ...MODES], + value: p.mode ?? "", + compact: true, + ariaLabel: "이 줄 수량 갈래", + onChange: (v) => (p.mode = (v || null) as Mode | null), + }); + const items: HTMLElement[] = [...tags, target.root, lineMode.root]; + if (s.kind !== "item") { + const count = createInputField({ + type: "number", + compact: true, + min: 0, + value: p.count, + placeholder: "개수(B08)", + onInput: (v) => (p.count = v), + }); + items.push(count.root); + } + items.push( + createButton({ + label: "잇기", + variant: "ghost", + 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" ? "" : "묶음 → 구조물 줄 통째"), + trailing: createToolbar({ items, size: 24 }), + }); + }; + const freeRow = (r: Row): HTMLElement => + createListRow({ + label: label(r), + sub: r.kind === "item" ? "" : "묶음", + trailing: createButton({ + label: "우리 공종으로 잇기", + variant: "ghost", + size: 24, + onClick: () => + pickOurs(seen.자리, r, (target) => + run(() => linkPairs(cur, done, [{ line: r.key, target }], {})), + ), + }), + }); + const linkedRow = (r: Row): HTMLElement => + createListRow({ + label: `${label(r.from)} → ${r.name}`, + sub: HEAD[String(r.key).split(":")[0]] ?? "", + trailing: createToolbar({ + size: 24, + items: [ + createButton({ + label: "잇기 풀기", + variant: "ghost", + onClick: () => run(() => undoLink(cur, done, r.key)), + }), + createButton({ label: "이 짝 잊기", variant: "ghost", onClick: () => forget(r.key) }), + ], + }), + }); + + const draw = (): void => { + const lines: Row[] = seen.줄; + const has = new Set(lines.map((s) => s.key)); + const free = (seen.남은 as Row[]).filter((r) => !has.has(r.key)); + const items = (seen.남은 as Row[]).filter((r) => r.kind === "item").length; + const recall = lines.filter((s) => s.꼬리표 === "지난번 이음").length; + all.disabled = !lines.length; + body.replaceChildren( + createText( + `안 이은 불러온 줄 — 항목 ${items} · 묶음 ${seen.남은.length - items} · 제안 ${lines.length}` + + `(지난번 이음 ${recall} · 짝 표 ${lines.length - recall}) · 이은 줄 ${seen.이은.length}`, + "muted", + 14, + ), + createToolbar({ items: [modeSel.root] }), + ...(lines.length ? [createText("제안", "muted", 14), ...lines.map(suggestRow)] : []), + ...(free.length ? [createText("제안 없음", "muted", 14), ...free.map(freeRow)] : []), + ...(seen.이은.length + ? [createText("이은 줄", "muted", 14), ...seen.이은.map(linkedRow)] + : []), + ); + }; + openModal({ + title: mode ? "다음: 우리 공종으로 잇기" : "우리 공종으로 잇기", + closeLabel: "닫기", + width: "960px", + mount: (box, close, actions) => { + all.dataset.b09LinkAll = ""; + all.addEventListener("click", () => { + const lines: Row[] = seen.줄; + run(async () => { + const used = new Set(); // 같은 우리 줄 둘째부터 = 남김(고름) + const pairs = lines.map(pairOf).filter((p) => !used.has(p.target) && used.add(p.target)); + const next = await linkPairs(cur, done, pairs, countsOf(lines)); + close(); // 짧은 길 — 남은 줄은 알림 · 우클릭으로 + return next; + }); + }); + actions.append(all); + draw(); + box.append(body); + }, + }); +} + +async function undoLink(user: Row, done: LinkDone, target: string): Promise { + const got = await post("undo", { target, user }); + done(got.payload, got.user); + showToast("잇기 풂 — 불러온 줄로 · [저장]해야 남음", "success"); + return got.user; +} + +/** 설계내역서 우클릭 — 불러온 줄 = 잇기 · 이은 줄(`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], + ]; + if (!key.startsWith("hand:x")) return []; + return [["우리 공종으로 잇기", () => void openBillLink(ctx.user, done)]]; +} diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts index 1f102dad6..d802bdf02 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts @@ -26,6 +26,7 @@ import { import type { Row } from "./B09_Estimation_Calc_Base"; import type { TabContext, TabTable } from "./B09_Estimation_UI_Tabs"; import { linkItems, type ApplyRef } from "./B09_Estimation_UI_Tabs_Bill_Link"; +import { linkMenu } from "./B09_Estimation_UI_Link"; /** 설계내역서 표 = 탭 표 + 줄 우클릭 메뉴 */ export type BillTable = TabTable & { @@ -269,6 +270,7 @@ export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContex const [picks, more] = linkFor(ctx, rows, i); // 251-6 단가연결 = 모달 하나(옛 하위 메뉴 다섯) parts[1].push(...picks, ...more); } + parts[1].push(...linkMenu(ctx, key)); // 707-4 내역 줄 ↔ 우리 공종 잇기 · 풀기 · 잊기 // 옮김 · 레벨 — 같은 부모 형제와 자손째 if (!fixed(key)) { add(2, "위로 ↑", (w, e) => { diff --git a/docs/raw/요구_1025/사용자요구_1025.md b/docs/raw/요구_1025/사용자요구_1025.md index d64f62f57..63996aa66 100644 --- a/docs/raw/요구_1025/사용자요구_1025.md +++ b/docs/raw/요구_1025/사용자요구_1025.md @@ -1025,3 +1025,7 @@ CPU는 더늘리기는 힘들어보이고 GPU 사용을 위해 부품사용에 ## 요구 514 (10-31 · 사용자) 해상도가 높은건 나중에 공공기관과 협업을 하면서 정식으로 요청을 해야할것같아. 일단은 되는 범위에서 진행하는게 좋을것같은데 지금 우리프로그램에서 상당히 중요하게 보고 있는 요소가 속도야. 그래서 등고선이나 미리 계산해야하는것들을 미리 받아 계산하고 있는데 현재 위 데이터를 받으면 용량이 어느정도 되는거야? / 지형 · 0차곡 이웃 목록 밖이라는게 무슨 말이야? / 현재 위데이터를 순차적으로 받으면 랩탑에 무리가 갈까? / 리소스 폴더는 시놀로지 문제 없음. PC 스토리지 문제 없음.. 공공데이터포털의 API키를 env파일에 넣어둔걸로 기억함. 확인 + +## 요구 515 (10-31 · 사용자) + +1. api키는 env파일로 옮겨둬. 2. 공공데이터 포털에서 데이터를 다운받는데 위치는 리소스 폴더가 맞을것같아. 적당히 폴더를 만들어서 넣어줘. 3. 지금 랩탑의 CPU 사용율이 89%로 웹앱에는 표기되는데 작업관리자로 확인해보니까 이용률이 19%밖에 안됨.... 왜 그런지 이유를 찾아봐. diff --git a/resources/tester/test_707_4_bill_link.py b/resources/tester/test_707_4_bill_link.py index 24b03ff01..13b342a39 100644 --- a/resources/tester/test_707_4_bill_link.py +++ b/resources/tester/test_707_4_bill_link.py @@ -221,3 +221,62 @@ def test_기억_층(tmp_path, monkeypatch, role, layer): assert got["a||"]["확정"] == ("earth:4" if layer == "personal" else "earth:5") assert got["b||"]["확정"] == "x" assert route.recalled({"user_id": 3, "role": role}) == {} # 회사 없음 = 층 없음 + + +def _same(a, b): + """두 셈 = 같은 줄 · 같은 직접비.""" + assert [r["key"] for r in a["rows"]] == [r["key"] for r in b["rows"]] + assert a["totals"]["direct"]["sum"] == b["totals"]["direct"]["sum"] + + +@pytest.mark.parametrize("mode", ["take", "price"]) +def test_잇기_풀기_제자리(geo, mode): + user, rows = _load(geo) + before = _bill(user) + group = next(r for r in rows if r["name"] == "콘크리트구조물") + pairs = [*_pick(lk.suggest(rows, {})), (group["key"], ST)] + made, report, bill = _link(user, rows, pairs, mode) + assert report["이음"] == 12 and report["묶음"] == 1 + linked = [e for e in made["bill"]["rows"] if e.get("was")] + assert len(linked) == 13 # 저장 다듬기 뒤에도 되돌릴 것 남음 + for entry in linked[::2] + linked[1::2]: # 이은 차례와 다르게 + made, why = lk.unlink(made, bill["rows"], entry["key"]) + assert why == "" + made = store.clean_user(made) + bill = _bill(made) + _same(bill, before) + assert lk.unlink(made, bill["rows"], "earth:4")[1] == "이은 줄이 아님" + + +def test_줄마다_갈래_판(geo): + user, rows = _load(geo) + cut = next(r for r in rows if r["name"] == "토사절취") + side = next(r for r in rows if r["name"] == "측구터파기" and r["spec"] == "토사") + pairs = [(cut["key"], "earth:4"), (side["key"], "earth:11")] + made, report, bill = _link(user, rows, pairs, "take", modes={side["key"]: "price"}) + got = {r["key"]: r for r in bill["rows"]} + assert got["earth:4"]["qty"] == cut["qty"] and got["earth:11"]["qty"] == 100 + with pytest.raises(ValueError): + lk.link(user, rows, pairs, "take", modes={cut["key"]: "both"}) + board = lk.board(made, bill["rows"]) + left = {r["key"] for r in board["남은"]} + assert cut["key"] not in left and side["key"] not in left + assert {r["key"] for r in board["이은"]} == {"earth:4", "earth:11"} + assert board["이은"][0]["memo"] in report["기억"] + assert not {"earth:4", "earth:11"} & {r["key"] for r in board["자리"]} + group = next(r for r in rows if r["name"] == "콘크리트구조물") + inner = {r["key"] for r in rows if r["parent"] == group["key"]} + made, _r, bill = _link(made, bill["rows"], [(group["key"], ST)], "take") + left = {r["key"] for r in lk.board(made, bill["rows"])["남은"]} + 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"} + route.keep(user, {"a||": {"확정": "earth:4"}, "b||": {"확정": "earth:5"}}) + hopyo_store.remember(tmp_path / "company", {"c||": {"확정": "x"}}, lk.MEMORY) + assert route.keep(user, {"a||": None}) == "personal" + assert set(route.recalled(user)) == {"b||", "c||"} + route.keep(user, {"c||": None}) + assert "c||" in route.recalled(user) # 일반 = 회사 기억 못 지움(남음)