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

This commit is contained in:
2026-10-31 17:18:26 +09:00
16 changed files with 918 additions and 30 deletions
+344
View File
@@ -0,0 +1,344 @@
"""B09 내역 줄 ↔ 우리 공종 잇기(PLAN 707-4) — 불러온 줄(707-1 `hand:x…`)을
자동 줄(earth · st · haul)에 이어 B08 수량이 붙게. 파일 · 저장 안 만짐(맞춤 기억 읽기 · 쓰기 =
부르는 쪽이 층 폴더로).
제안(`suggest`) — 아직 안 이은 불러온 줄마다 후보 · 꼬리표:
「지난번 이음」 = 맞춤 기억 `{품명|규격|단위: {확정: 줄 키, 이름}}`(실무자료 맞춤 꼴 ·
규격이 달라도 품명 · 단위가 같고 짝이 하나면) · 키가 없는 공사 = 같은 이름 자동 줄
「짝 표」 = 토공 이름 짝 표(`PAIRS` · 코드 상수) · 우리 이름(`Bill_Items.EARTH`) 그대로도
`하나` = 한 줄 ↔ 한 줄 — 아니면(암 갈래 · 여러 줄 → 한 줄) 사용자 고름 ·
`auto_pairs` = 기억 · 하나만
잇기(`link`) — 항목 줄 ↔ 자동 항목 줄 = 707-1 붙임과 같은 꼴(불러온 줄 자리에 자동 줄 ·
고정 단가 칸 · 연결 · 수량 갈래 take = 엑셀 수량 덮개 / price = B08(없으면 엑셀) ·
불러온 줄 지움 = 두 번 셈 0)
묶음 줄 ↔ 구조물 줄(`st:`) 하나(1 : N) — take = 구조물 줄이 묶음 자리 ·
안 줄이 그 아래(저절로 묶음 · 금액 = 안 줄 합) / 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
from B09_Estimation.B09_Estimation_Bill_Import import MODES, PARTS, _flat, freeze
from B09_Estimation.B09_Estimation_Bill_Items import EARTH
from M02_MasterTemplete.M02_Hopyo_Match import line_key
MEMORY = "내역맞춤"
TABLE, RECALL = "짝 표", "지난번 이음"
HEADS = ("earth", "st", "haul")
_TAIL = re.compile(r"^(.+?)\((.*)\)$")
ROCK = ("절토(연암)", "암발파")
#: 토공 이름 짝 표 — (경쟁사 이름, 규격에 든 글 | "", 우리 이름) · 우리 이름 둘 = 한 줄 ↔ 한 줄 아님
#: (EST 암 = 연암 90 % · 보통암 10 % ↔ 우리 연암 · 경암) · 이름 괄호 글 = 규격으로도 봄
PAIRS: tuple[tuple[str, str, tuple[str, ...]], ...] = (
("수평규준틀", "", ("규준틀설치 종단규준틀",)),
("비탈규준틀", "", ("규준틀설치 횡단규준틀",)),
("뿌리뽑기", "", ("제근",)),
("토사절취", "", ("절토(토사)",)),
("암석절취", "", ROCK),
("암절취", "", ROCK),
("구조물터파기", "토사", ("구조물터파기(토사)",)),
("구조물터파기", "암", ("구조물터파기(연암)",)),
("터파기", "암", ("구조물터파기(연암)",)),
("구조물되메우기", "", ("구조물 되메우기",)),
("되메우기", "", ("구조물 되메우기",)),
("측구터파기", "토사", ("측구파기(토사)",)),
("측구터파기", "암", ("측구파기(연암)",)),
("도자운반-토사", "", ("도자운반",)),
("도자운반-암", "", ("도자운반",)),
("흙운반D/Z19Ton", "", ("도자운반",)),
("덤프운반-토사", "", ("덤프운반",)),
("덤프운반-암", "", ("덤프운반",)),
("흙운반D/P+B/H", "", ("덤프운반",)),
("토사운반", "", ("덤프운반",)),
("잔토처리", "", ("사토처리",)),
("사토운반", "", ("사토처리",)),
("사토무대처리", "", ("사토처리",)),
("노면고르기및다짐", "", ("노면정리",)),
)
EARTH_KEY = {name: f"earth:{n}" for n, (_s, name, _u, _w) in enumerate(EARTH, 1)}
def _texts(name: Any, spec: Any) -> list[tuple[str, str]]:
"""(이름, 규격) 보기 — 그대로 · 이름 끝 괄호 글을 규격 앞에."""
n, s = _flat(name), _flat(spec)
hit = _TAIL.match(n)
return [(n, s), *([(hit[1], hit[2] + s)] if hit else [])]
def table(name: Any, spec: Any) -> tuple[str, ...]:
"""짝 표 → 우리 토공 이름들(없음 = ())."""
n, s = _texts(name, spec)[0]
for ours in EARTH_KEY:
if _flat(ours) in (n, n + s):
return (ours,)
for alias, part, targets in PAIRS:
if any(t == _flat(alias) and _flat(part) in u for t, u in _texts(name, spec)):
return targets
return ()
def key_of(row: dict[str, Any]) -> str:
"""맞춤 기억 열쇠 = 품명 | 규격 | 단위(실무자료 맞춤과 같은 정규화)."""
return line_key({"이름": row.get("name"), "규격": row.get("spec"), "단위": row.get("unit")})
def _loose(key: str) -> str:
name, _spec, unit = (key.split("|") + ["", ""])[:3]
return f"{name}|{unit}"
def recall(memory: dict[str, dict[str, Any]], row: dict[str, Any]) -> dict[str, Any] | None:
"""기억 한 칸 — 같은 열쇠 · 없으면 품명 · 단위 같고 짝이 하나인 것."""
key = key_of(row)
if isinstance(memory.get(key), dict):
return memory[key]
loose = [v for k, v in memory.items() if isinstance(v, dict) and _loose(k) == _loose(key)]
return loose[0] if len({v.get("확정") for v in loose}) == 1 else None
def _targets(rows: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
"""이을 수 있는 자동 줄 {키: 줄} — earth · st · haul(항목이든 저절로 묶음이든)."""
return {r["key"]: r for r in rows if r.get("auto") and r["key"].split(":")[0] in HEADS}
def _linked(rows: list[dict[str, Any]]) -> set[str]:
"""이미 이은 자동 줄 — 불러온 수량(비교 칸) 있음 · 불러온 묶음 아래 · 안 줄을 거느림."""
parents = {r.get("parent") for r in rows}
return {
k
for k, r in _targets(rows).items()
if r.get("src_qty") is not None
or str(r.get("parent") or "").startswith("hand:x")
or (k.startswith("st:") and k in parents)
}
def _resolve(got: dict[str, Any], targets: dict[str, dict[str, Any]]) -> str | None:
key = got.get("확정")
if key in targets:
return key
head = str(key or "").split(":")[0]
same = [
k for k, r in targets.items() if k.startswith(f"{head}:") and r["name"] == got.get("이름")
]
return same[0] if len(same) == 1 else None
def suggest(rows: list[dict[str, Any]], memory: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
"""[{key, kind, name, spec, unit, 후보 [{key, name}], 꼬리표, 하나, 까닭}] —
후보 있는 불러온 줄만."""
targets = _targets(rows)
free = {k: r for k, r in targets.items() if k not in _linked(rows)}
out = []
for row in rows:
if not row["key"].startswith("hand:x"):
continue
got = recall(memory, row)
hit = _resolve(got, free) if got else None
if hit:
keys, tag = [hit], RECALL
elif row["kind"] == "item":
keys, tag = [EARTH_KEY[n] for n in table(row["name"], row["spec"])], TABLE
keys = [k for k in keys if k in free]
else:
keys = []
if not keys:
continue
one = len(keys) == 1
out.append(
{
**{f: row.get(f) for f in ("key", "kind", "name", "spec", "unit")},
"후보": [{"key": k, "name": free[k]["name"]} for k in keys],
"꼬리표": tag,
"하나": one,
"까닭": "" if one else "한 줄 ↔ 한 줄 아님 — 고름",
}
)
seen: dict[str, int] = {}
for s in out:
if s["하나"]:
seen[s["후보"][0]["key"]] = seen.get(s["후보"][0]["key"], 0) + 1
for s in out: # 여러 줄 → 한 자동 줄(도자운반-토사 · -암) = 고름
if s["하나"] and seen[s["후보"][0]["key"]] > 1:
s.update({"하나": False, "까닭": "여러 줄 → 한 줄 — 고름"})
return out
def auto_pairs(suggestions: list[dict[str, Any]]) -> list[tuple[str, str]]:
"""저절로 이을 짝 [(불러온 줄 키, 자동 줄 키)] — 지난번 이음 · 하나만."""
return [
(s["key"], s["후보"][0]["key"]) for s in suggestions if s["꼬리표"] == RECALL and s["하나"]
]
def _descendants(entries: list[dict[str, Any]], key: str) -> set[str]:
out, grow = set(), {key}
while grow:
grow = {e["key"] for e in entries if e.get("parent") in grow} - out
out |= grow
return out
def _place(bill: dict[str, Any], at: int, entry: dict[str, Any], parent: Any) -> None:
entry.pop("parent", None)
if parent:
entry["parent"] = parent
bill["rows"].insert(at, entry)
if entry["key"] in bill["gone"]:
bill["gone"].remove(entry["key"])
def link(
user: dict[str, Any],
rows: list[dict[str, Any]],
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 수량) ·
`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}
targets = _targets(rows)
used = _linked(rows)
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)
why = (
"불러온 줄이 아님" if at < 0 or not line.startswith("hand:x") or row is None
else "이을 수 없는 줄" if goal is None
else "이미 이은 줄" if target in used
else "묶음 ↔ 구조물 줄만" if (row["kind"] != "item") != target.startswith("st:")
else ""
) # fmt: skip
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_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)
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:
auto["qty"] = hand.get("qty")
report["이음"] += 1
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(
{
"ref": None,
"price": {p: sum(r.get(p) or 0 for r in items) / count for p in PARTS},
"src_qty": count,
}
)
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,
}
+2
View File
@@ -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 · 폴더째 지우면 길도 없음) — 같은 머리 · 같은 권한
@@ -0,0 +1,181 @@
"""B09 내역 줄 ↔ 우리 공종 잇기 길(PLAN 707-4) — 저장 안 함(화면 캐시로 → [저장] 때 서버 정본).
POST /api/projects/{id}/estimation/link/suggest {user?}
→ {줄: 제안(`B09_Estimation_Bill_Link.suggest` · 구조물 묶음에 딸린 줄 뺌) ·
저절로: 지난번 이음 · 하나 수 · 남은 · 자리 · 이은(`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?} → {잊음, 기억층, 남음} — 이 짝 잊기(불러온 줄 · 이은 줄 키 →
맞춤 열쇠 · 쓸 수 있는 층에서 지움 · 남음 = 못 지운 위 층 기억)
맞춤 기억 = 층 `실무자료/내역맞춤.json`(실무자료 맞춤 꼴) · 읽기 = 회사 + 개인(개인이 이김).
끼워 넣기 = `B09_Estimation_Router`(같은 머리 · 같은 권한) · 화면 = `B09_Estimation_UI_Link`.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
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
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]]:
"""맞춤 기억 — 회사 위에 개인을 덮음."""
out: dict[str, dict[str, Any]] = {}
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:
return store.clean_user(raw) if isinstance(raw, dict) else None
class SuggestBody(BaseModel):
user: dict[str, Any] | None = None
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):
pairs: list[Pair] = Field(default_factory=list, max_length=2000)
auto: bool = False
mode: str = "take"
counts: dict[str, float] | None = None
user: dict[str, Any] | None = None
async def _rows(project_id: UUID, root: Path, user: dict[str, Any]) -> list[dict[str, Any]]:
from B09_Estimation.B09_Estimation_Router import payload # 서로 부름 — 부를 때만
return (await payload(project_id, root, user, True))["sheets"]["bill"]["rows"]
@router.post(PATH + "/suggest")
async def suggest(
project_id: UUID, body: SuggestBody, 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)
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")
async def apply(
project_id: UUID, body: ApplyBody, session: dict[str, Any] = Depends(verify_session)
) -> dict[str, Any]:
from B09_Estimation.B09_Estimation_Router import payload # 서로 부름 — 부를 때만
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)
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]
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("기억"))
return {
"user": made,
"알림": {**report, "기억층": layer},
"payload": await payload(project_id, root, made, True),
}
@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,
"알림": {"풀음": 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}
+15
View File
@@ -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(
@@ -254,8 +254,9 @@ async function post(path: string, body: Row): Promise<Row> {
const COST_PART = { 노무비: "labor", 재료비: "material", 경비: "expense" } as const;
/** 붙인 줄 다시 잇기(705-2) — 후보 = 서버(M02 이음 짝) · 단가 = 이 공사 단가 책 · 바꿈 = 캐시 + 서버 셈 */
function relinkFold(ctx: TabContext, cover: string): RelinkSource {
const added = (): Row[] => ((ctx.user.daega_cover || {})[cover] || {})[ADD] || [];
function relinkFold(ctx: TabContext, cover: string, from: Row): RelinkSource {
let now = from; // 붙인 뒤 캐시(ctx.user 는 그리기 전 값) — 바꿀 때마다 새 값
const added = (): Row[] => ((now.daega_cover || {})[cover] || {})[ADD] || [];
const book = (kind: "노임" | "자재", key: string): number | null | undefined =>
(ctx.payload.inputs?.[kind === "노임" ? "labor" : "materials"] || []).find(
(r: Row) => r.key === key,
@@ -302,9 +303,10 @@ function relinkFold(ctx: TabContext, cover: string): RelinkSource {
if ("노임" in c) ln.link.labor = c.노임 ?? null;
if ("자재" in c) ln.link.material = c.자재 ?? null;
}
const all: Row = JSON.parse(JSON.stringify(ctx.user.daega_cover || {}));
const all: Row = JSON.parse(JSON.stringify(now.daega_cover || {}));
all[cover] = { ...(all[cover] || {}), [ADD]: lines };
const user = { ...ctx.user, daega_cover: all };
const user = { ...now, daega_cover: all };
now = user;
Object.assign(ctx.payload, await post("calc", { user })); // 새 이음 키 = 이 공사 단가 책 수요
ctx.setUser(user);
return { back, note: `바뀐 줄 ${back.length} — [저장]으로 남김` };
@@ -333,7 +335,7 @@ async function pasteFold(ctx: TabContext, cover: string, text: string): Promise<
`고정 줄 ${got.줄} 붙임 · 엑셀 합계 ${won} · 이음 ${got.이음} · 후보 ${got.이음후보} — [저장]으로 남김 · 이 알림을 누르면 이 공사 단가로 다시 잇기`,
"success",
9000,
() => void openRelink(relinkFold(ctx, cover)),
() => void openRelink(relinkFold(ctx, cover, got.user)),
);
} catch (error) {
showToast(`붙여넣기 실패 — ${error instanceof Error ? error.message : String(error)}`, "error");