From 32de4884013af8d9c67e57ffc6ba9038c9aa7bee Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 15:57:39 +0900 Subject: [PATCH] =?UTF-8?q?feat(M02=20=EB=B6=99=EC=97=AC=EB=84=A3=EA=B8=B0?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=20=C2=B7=20=EB=8B=A4=EC=8B=9C=20=EC=9E=87?= =?UTF-8?q?=EA=B8=B0):=20=EA=B2=A9=EC=9E=90=20Ctrl+V=20=C2=B7=20=EC=97=B4?= =?UTF-8?q?=20=EC=A7=9D=20=ED=95=9C=20=EB=B2=88=20=EB=AC=BB=EA=B3=A0=20?= =?UTF-8?q?=EA=B8=B0=EC=96=B5=20=C2=B7=20=EC=9D=B4=20=EA=B3=B5=EC=82=AC=20?= =?UTF-8?q?=EB=8B=A8=EA=B0=80=EB=A1=9C=20=EB=8B=A4=EC=8B=9C=20=EC=9E=87?= =?UTF-8?q?=EA=B8=B0=20(PLAN=20705-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 일위대가 격자(ui_template_hopyo_grid onPaste) 누르고 Ctrl+V → 서버 /hopyo-paste · 열 짝 = 처음 한 번 묻고 이 브라우저에 기억 · 엑셀 머리 글이면 묻지 않음 - 호표 머리 없음 = 열린 문서에 고정 줄(알림 누름 = 다시 잇기) · 머리 있음 = 호표마다 사전조합 + 결과 창 [다음 — 이 공사 단가로 다시 잇기] - 다시 잇기 창(EST 신 · 구 단가 비교 꼴) — 붙인 단가 · 최신 판 단가 · 후보(705-3 이음 짝) · 금액 차이 · 줄마다 · 모두 고름 · [바꿈] · [되돌리기] - 서버 M02_Hopyo_Relink — 후보 표 · 이음 칸만 고쳐 씀(숫자 안 받음) · 전후 1 단위 값 · 되돌림 - 실무자료 [모두 고정으로 들여오기] 결과에도 다음 걸음 단추 - M02 상세가 고정 줄을 읽을 때 떨구던 흠 — normalizeLabor 가 고정 칸 지킴 · 로직 표 · 격자에 고정 줄 이름 · 규격 · 단위 · 「고정」 꼬리표 · 이음 / 손 단가 Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Hopyo_Relink.py | 148 +++++++++++ M02_MasterTemplete/M02_Labor_Router.py | 38 +++ .../M02_MasterTemplete_Hopyo_Api.ts | 83 ++++++- .../M02_MasterTemplete_Hopyo_Relink.ts | 234 ++++++++++++++++++ .../M02_MasterTemplete_Hopyo_Sheet.ts | 9 +- .../M02_MasterTemplete_Labor_Api.ts | 7 +- .../M02_MasterTemplete_Labor_Detail.ts | 2 +- .../M02_MasterTemplete_Labor_Grid.ts | 22 +- .../M02_MasterTemplete_Labor_Paste.ts | 213 ++++++++++++++++ .../M02_MasterTemplete_Labor_Rows.ts | 20 +- .../M02_MasterTemplete_Labor_Text.ts | 2 + resources/tester/test_705_2_hopyo_paste.py | 44 ++++ ui_template/ui_template_hopyo_grid.css | 7 + ui_template/ui_template_hopyo_grid.ts | 16 +- 14 files changed, 831 insertions(+), 14 deletions(-) create mode 100644 M02_MasterTemplete/M02_Hopyo_Relink.py create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Hopyo_Relink.ts create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Labor_Paste.ts diff --git a/M02_MasterTemplete/M02_Hopyo_Relink.py b/M02_MasterTemplete/M02_Hopyo_Relink.py new file mode 100644 index 000000000..bf2054d2c --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_Relink.py @@ -0,0 +1,148 @@ +"""「이 공사 단가로 다시 잇기」(PLAN 705-2 · 요구서 4장 2 · EST 「신 · 구 단가 비교」 꼴) — 고정 줄마다 붙인 단가 ↔ +단가 책(M02 = M01 최신 판 노임 「값」 · 자재 기준단가) · 이음 후보 · 고른 줄만 이음 바꿈 · 되돌리기. + +후보 = 705-3 이음과 같은 짝(`M02_Hopyo_Fixed.Linker`) — 노임 = 이름이 우리 직종(단위 인) · + 자재 = 이름 · 규격 같은 품목 + 단위 배 1 · 단가가 달라도 후보(바꿀지는 사람이 고름) · 기계 = 없음(B09 중기 몫). +줄 = 비율 줄 뺀 고정 줄 · 줄 가름 = `fixed_id` · 붙인 이름 끝 「 (2)」(줄 가름)은 떼고 찾음. +바꿈 = `{키, 줄, 노임?, 자재?}` — 키 = 이 층 사전조합(SC) · 값 null = 이음 풂(붙인 단가로) · + 서버가 문서를 읽어 이음 칸만 고쳐 씀(숫자 안 받음) · 전후 1 단위 값(`Labor_Ref.Expand`) · `되돌림` = 옛 이음. +열린 문서(저장 전) = `doc` 로 줄 · 후보만 — 바꿈은 화면이 문서에 넣고 [저장]. +""" + +from __future__ import annotations + +import re +from typing import Any + +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Hopyo_Fixed as hfix +from M02_MasterTemplete import M02_Hopyo_Shares as shares +from M02_MasterTemplete import M02_Labor_Fixed as fixed +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_MasterTemplete_Store as store + +KINDS = ("노임", "자재") +CHANGES_MAX = 2000 +_SUFFIX = re.compile(r"\s*\(\d+\)$") + + +def _num(raw: Any) -> float | None: + got = fixed._num(raw) + return float(got) if got is not None else None + + +class Finder: + """후보 찾기 — 단가 책(M01 최신 판) 한 번 읽음.""" + + def __init__(self) -> None: + self.linker = hfix.Linker() + self.whole = self.linker.whole + + def _name(self, code: str) -> dict[str, str]: + try: + row = self.whole.get(code) or {} + except fixed.mf.FormulaError: + row = {} + return {k: str(row.get(k) or "") for k in ("이름", "규격", "단위")} + + def price(self, kind: str, code: str | None, unit: str | None) -> float | None: + if not code: + return None + try: + if kind == "노임": + return _num((self.whole.get(code) or {}).get("값")) + return _num(fixed.mm.element(self.whole, code, {}, unit or None)[0]) + except fixed.mf.FormulaError: + return None + + def candidates(self, fx: dict[str, Any]) -> list[dict[str, Any]]: + name = _SUFFIX.sub("", str(fx.get("이름") or "")) + line = {"이름": name, "규격": fx.get("규격") or "", "단위": fx.get("단위") or ""} + out = [] + trade = shares.trade(name) if line["단위"].strip() == "인" else None + for code, value in self.linker.labor.get(trade or "", []): + out.append({"비목": "노임", "키": code, **self._name(code), "단가": _num(value)}) + price = fx.get("단가") or {} + if not trade and (_num(price.get("재료비")) or not any(_num(v) for v in price.values())): + for code, value in self.linker._mats({**line, "갈래": "자재"}): + out.append({"비목": "자재", "키": code, **self._name(code), "단가": _num(value)}) + return out + + def row(self, key: str | None, one: dict[str, Any]) -> dict[str, Any]: + fx = one[fixed.FIXED] + link = fx.get("이음") or {} + qty = _num((one.get("수량") or {}).get("손")) + now = { + k: self.price(k, link.get(k), fx.get("단위")) + for k in KINDS + if link.get(k) # 이은 비목 지금 단가 + } + return { + "키": key, + "줄": fixed.fixed_id(fx), + **{k: fx.get(k) or "" for k in ("이름", "규격", "단위")}, + "수량": qty, + "붙인": {k: _num((fx.get("단가") or {}).get(k)) or 0 for k in fixed.COSTS}, + "이음": {k: link.get(k) for k in KINDS}, + "지금": now, + "후보": self.candidates(fx), + } + + +def _lines(doc: dict[str, Any]) -> list[dict[str, Any]]: + return [ + one + for one in doc.get("담은로직") or [] + if fixed.is_fixed(one) and not one[fixed.FIXED].get("비율") + ] + + +def rows(keys: list[str] | None = None, doc: dict[str, Any] | None = None) -> dict[str, Any]: + """고정 줄 표 — `keys` = 이 층 사전조합들 · `doc` = 열린 문서 → `{줄 [...], 문서 [{키, 이름}]}`.""" + finder, out, docs = Finder(), [], [] + if doc is not None: + out = [finder.row(None, one) for one in _lines(doc)] + for key in keys or []: + refs.check_key("precombo", key) + got = store.read("precombo", key)["문서"] + docs.append({"키": key, "이름": got.get("이름") or key}) + out += [finder.row(key, one) for one in _lines(got)] + return {"줄": out, "문서": docs} + + +def _total(key: str) -> float | None: + at = refs.here() + got = refs.Expand(m01.loaded()[1])({"층": at[0], "주인": at[1], "키": key}) + return _num((got.get("기준") or {}).get("계")) + + +def apply(changes: list[dict[str, Any]]) -> dict[str, Any]: + """바꿈 → 이 층 사전조합 이음 칸만 고쳐 씀 · M01 에 없는 키 400(`Labor_Fixed.clean`) → + `{바뀜, 문서 [{키, 이름, 전, 후}], 되돌림 [{키, 줄, 노임, 자재}]}`.""" + if len(changes) > CHANGES_MAX: + raise store.StoreError(400, f"바꿈은 {CHANGES_MAX} 줄 안") + by_key: dict[str, list[dict[str, Any]]] = {} + for one in changes: + if not isinstance(one, dict) or not isinstance(one.get("줄"), str): + raise store.StoreError(400, "바꿈 줄은 {키, 줄, 노임?, 자재?}") + refs.check_key("precombo", str(one.get("키") or "")) + by_key.setdefault(str(one["키"]), []).append(one) + whole, done, back, docs = m01.loaded()[1], 0, [], [] + for key, wants in by_key.items(): + got = store.read("precombo", key) + doc, before = got["문서"], _total(key) + lines = {fixed.fixed_id(x[fixed.FIXED]): x for x in _lines(doc)} + for want in wants: + one = lines.get(want["줄"]) + if one is None: + raise store.StoreError(404, f"없는 고정 줄 「{want['줄']}」 ({key})") + link = dict(one[fixed.FIXED].get("이음") or {}) + back.append({"키": key, "줄": want["줄"], **{k: link.get(k) for k in KINDS}}) + for k in KINDS: + if k in want: + link[k] = want[k] or None + one[fixed.FIXED] = fixed.clean({**one[fixed.FIXED], "이음": link}, want["줄"], whole) + done += 1 + store.write("precombo", key, got["판"], doc) + docs.append({"키": key, "이름": doc.get("이름") or key, "전": before, "후": _total(key)}) + return {"바뀜": done, "문서": docs, "되돌림": back} diff --git a/M02_MasterTemplete/M02_Labor_Router.py b/M02_MasterTemplete/M02_Labor_Router.py index fbc2efadb..67b805f96 100644 --- a/M02_MasterTemplete/M02_Labor_Router.py +++ b/M02_MasterTemplete/M02_Labor_Router.py @@ -28,6 +28,7 @@ from M02_MasterTemplete import M02_Hopyo_Grid as hopyo_grid from M02_MasterTemplete import M02_Hopyo_Ledger as hopyo_ledger from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match 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_Labor_Doc as labor from M02_MasterTemplete import M02_Labor_Ref as refs @@ -467,3 +468,40 @@ def hopyo_paste_run( folder = _root(session, layer, owner, write=True) args = (folder, body.글, body.열, _who(session), body.줄만, body.있음) return _call(folder, hopyo_paste.paste, *args) + + +class RelinkRowsBody(BaseModel): + """다시 잇기 표(705-2) — `키` = 이 층 사전조합들 · `문서` = 열린 문서(저장 전 · 줄 · 후보만).""" + + 키: list[str] = Field(default_factory=list, max_length=500) + 문서: dict[str, Any] | None = None + + +@router.post("/layers/{layer}/hopyo-relink/rows") +def hopyo_relink_rows( + layer: Layer, + body: RelinkRowsBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """「이 공사 단가로 다시 잇기」 표 — 고정 줄마다 붙인 단가 · 이은 단가 · 후보(층 읽기).""" + folder = _root(session, layer, owner) + return _call(folder, hopyo_relink.rows, body.키, body.문서) + + +class RelinkBody(_Strict): + """바꿈 `[{키, 줄, 노임?, 자재?}]` — 이음 키만(숫자 칸 없음 · 값 null = 풂).""" + + 바꿈: list[dict[str, Any]] = Field(max_length=hopyo_relink.CHANGES_MAX) + + +@router.post("/layers/{layer}/hopyo-relink") +def hopyo_relink_apply( + layer: Layer, + body: RelinkBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """[바꿈] · [되돌리기] — 이 층 사전조합 이음 칸 고쳐 씀(층 쓰기) → `{바뀜, 문서, 되돌림}`.""" + folder = _root(session, layer, owner, write=True) + return _call(folder, hopyo_relink.apply, body.바꿈) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts index 27f12f6bc..558be0c42 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts @@ -8,7 +8,7 @@ import { API_BASE_URL } from "@config/config_frontend"; import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet"; import { call, getActiveLayer, getActiveOwner, type Layer } from "./M02_MasterTemplete_Api_Fetch"; -import type { LaborRef } from "./M02_MasterTemplete_Labor_Calc"; +import type { LaborFixed, LaborRef } from "./M02_MasterTemplete_Labor_Calc"; import type { MatMarks } from "./M02_MasterTemplete_Labor_Api"; /** 층 · 주인(null = 자기 층) */ @@ -275,6 +275,8 @@ export interface HopyoBuiltAll { 차이: number | null; }[]; 실패: { 번: number; 까닭: string }[]; + /** 만든 · 그대로 둔 사전조합(705-2 · 다시 잇기) */ + 만듦?: { 번: number; 이름: string; 키: string }[]; } /** [로직 추가] 「실무 호표」 줄 하나 — 확인 = 문서 층에 확인된 사전조합 */ @@ -411,3 +413,82 @@ export async function uploadHopyo(scope: HopyoScope, file: File): Promise +>; + +/** 붙여넣기 답 — 물음(열 짝 짐작 + 보기) · 줄(고정 줄 · 문서에 넣음) · 만듦(호표마다 사전조합) */ +export type PasteResult = + | { 갈래: "물음"; 열: PasteCols; 짐작: boolean; 보기: string[][] } + | { + 갈래: "줄"; + 열: PasteCols; + 줄: { 고정: LaborFixed; 별칭: string | null; 메모: string; 수량: { 손: number | null } }[]; + 엑셀: number; + 이음: number; + 이음후보: number; + } + | ({ 갈래: "만듦"; 열: PasteCols; 키: string } & HopyoBuiltAll); + +/** [Ctrl+V](705-2) — 붙인 글 그대로(숫자는 서버가 글에서) */ +export const pasteHopyo = ( + scope: HopyoScope, + 글: string, + 열: PasteCols | null, + opt: { 줄만?: boolean; 있음?: string[] } = {}, +): Promise => + call(at(scope, "/hopyo-paste"), { + method: "POST", + body: JSON.stringify({ 글, ...(열 ? { 열 } : {}), ...opt }), + }); + +/** 다시 잇기 표 한 줄(705-2) — 붙인 단가 · 이은 단가(지금) · 후보 */ +export interface RelinkRow { + 키: string | null; + 줄: string; + 이름: string; + 규격: string; + 단위: string; + 수량: number | null; + 붙인: Record<"노무비" | "재료비" | "경비", number>; + 이음: Record<"노임" | "자재", string | null>; + 지금: Partial>; + 후보: { + 비목: "노임" | "자재"; + 키: string; + 이름: string; + 규격: string; + 단위: string; + 단가: number | null; + }[]; +} + +/** 바꿈 한 줄 — 값 null = 이음 풂 */ +export interface RelinkChange { + 키: string; + 줄: string; + 노임?: string | null; + 자재?: string | null; +} + +export const relinkRows = async ( + scope: HopyoScope, + body: { 키?: string[]; 문서?: unknown }, +): Promise => + ( + await call<{ 줄: RelinkRow[] }>(at(scope, "/hopyo-relink/rows"), { + method: "POST", + body: JSON.stringify(body), + }) + ).줄; + +export const relinkApply = ( + scope: HopyoScope, + 바꿈: RelinkChange[], +): Promise<{ + 바뀜: number; + 문서: { 키: string; 이름: string; 전: number | null; 후: number | null }[]; + 되돌림: RelinkChange[]; +}> => call(at(scope, "/hopyo-relink"), { method: "POST", body: JSON.stringify({ 바꿈 }) }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Relink.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Relink.ts new file mode 100644 index 000000000..5b4fd7eb5 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Relink.ts @@ -0,0 +1,234 @@ +/* ============================================================================= + * M02_MasterTemplete_Hopyo_Relink.ts + * 「이 공사 단가로 다시 잇기」(PLAN 705-2 · EST 「신 · 구 단가 비교」 꼴) — 고정 줄마다 붙인 단가 · 지금 이은 단가 · + * 이음 후보(이름 · 규격 · 단위 맞춤 · 705-3 이음과 같은 짝) · 차이 → 줄마다 · 모두 골라 [바꿈] · [되돌리기]. + * 후보 칸 누름 = 다음 후보(끝 = 그대로 · 이은 줄은 「풂」) · 고름 칸 누름 = 켬 · 끔 + * 출처 셋 — 사전조합 여럿(`relinkKeys` · 서버가 이음 칸만 고쳐 씀 · 전후 1 단위 값) · + * 열린 문서(`relinkDoc` · 문서에 넣고 [저장]) · B09 지금 호표(부르는 쪽 · 이 공사 단가 책) + * 숫자는 서버 단가 책 값 — 화면은 이음 키만 고름 + * ========================================================================== */ + +import { createButton, showToast } from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import { createDataTable, type DataRow } from "@ui/ui_template_data_table"; +import { createText } from "@ui/ui_template_text"; +import { + pageScope, + relinkApply, + relinkRows, + type RelinkChange, + type RelinkRow, +} from "./M02_MasterTemplete_Hopyo_Api"; +import type { LaborDoc } from "./M02_MasterTemplete_Labor_Api"; +import { lineId } from "./M02_MasterTemplete_Labor_Calc"; + +type Kind = "노임" | "자재"; +const COST: Record = { 노임: "노무비", 자재: "재료비" }; + +export interface RelinkSource { + title: string; + /** 이은 단가 칸 머리 — 「최신 판 단가」 · 「이 공사 단가」 */ + priceLabel: string; + rows: () => Promise; + /** 바꿈 → 되돌릴 바꿈 · 알림 글 */ + apply: (changes: RelinkChange[]) => Promise<{ back: RelinkChange[]; note: string }>; + /** 단가 덮기(B09 = 이 공사 단가 책) — 없으면 서버 값 */ + price?: (kind: Kind, key: string) => number | null | undefined; +} + +const won = (v: number | null | undefined): string => + v == null ? "-" : Math.round(v).toLocaleString("ko-KR"); + +/** 사전조합 여럿(붙여넣기 · 실무자료 들여오기 뒤) — 서버가 쓰고 전후 1 단위 값 */ +export function relinkKeys(keys: string[]): RelinkSource { + const scope = pageScope(); + return { + title: `이 공사 단가로 다시 잇기 — 사전조합 ${keys.length}`, + priceLabel: "최신 판 단가", + rows: () => relinkRows(scope, { 키: keys }), + apply: async (changes) => { + const got = await relinkApply(scope, changes); + const sum = (k: "전" | "후"): number => got.문서.reduce((a, d) => a + (d[k] ?? 0), 0); + return { + back: got.되돌림, + note: `바뀐 줄 ${got.바뀜} · 사전조합 ${got.문서.length} · 1 단위 값 합 ${won(sum("전"))} → ${won(sum("후"))}`, + }; + }, + }; +} + +/** 열린 문서(저장 전) — 이음을 문서에 넣고 `changed`(서버 셈 · [저장]) */ +export function relinkDoc(doc: LaborDoc, changed: () => void): RelinkSource { + const scope = pageScope(); + return { + title: "이 공사 단가로 다시 잇기 — 이 일위대가", + priceLabel: "최신 판 단가", + rows: () => relinkRows(scope, { 문서: doc }), + apply: async (changes) => { + const back: RelinkChange[] = []; + for (const c of changes) { + const row = doc.담은로직.find((r) => r.고정 && lineId(r) === c.줄); + if (!row?.고정) continue; + const link = { 노임: null, 자재: null, 기계: null, ...(row.고정.이음 ?? {}) }; + back.push({ 키: c.키, 줄: c.줄, 노임: link.노임, 자재: link.자재 }); + if ("노임" in c) link.노임 = c.노임 ?? null; + if ("자재" in c) link.자재 = c.자재 ?? null; + row.고정 = { ...row.고정, 이음: link }; + } + changed(); + return { back, note: `바뀐 줄 ${back.length} — [저장]으로 남김` }; + }, + }; +} + +/** 다시 잇기 창 */ +export async function openRelink(src: RelinkSource): Promise { + let rows: RelinkRow[] = []; + /** 줄마다 고른 후보 차례(후보 수 = 그대로 · 이은 줄의 +1 = 풂) · 켬 */ + let pick: number[] = []; + let on: boolean[] = []; + let back: RelinkChange[] = []; + const host = document.createElement("div"); + const info = createText("", "muted"); + + const linked = (r: RelinkRow): boolean => !!(r.이음.노임 || r.이음.자재); + const priceOf = (kind: Kind, key: string, given: number | null | undefined): number | null => + src.price?.(kind, key) ?? given ?? null; + const load = async (): Promise => { + rows = await src.rows(); + pick = rows.map((r) => (r.후보.length && !linked(r) ? 0 : r.후보.length)); + on = rows.map((r) => !!r.후보.length && !linked(r)); + }; + const choice = (i: number): RelinkRow["후보"][number] | "keep" | "free" => { + const r = rows[i]; + if (pick[i] < r.후보.length) return r.후보[pick[i]]; + return pick[i] === r.후보.length ? "keep" : "free"; + }; + const changes = (): RelinkChange[] => + rows.flatMap((r, i): RelinkChange[] => { + const c = choice(i); + if (!on[i] || c === "keep") return []; + if (c === "free") return [{ 키: r.키 ?? "", 줄: r.줄, 노임: null, 자재: null }]; + return [{ 키: r.키 ?? "", 줄: r.줄, [c.비목]: c.키 }]; + }); + const nowPrice = (r: RelinkRow): string => + (["노임", "자재"] as Kind[]) + .filter((k) => r.이음[k]) + .map((k) => won(priceOf(k, r.이음[k] as string, r.지금[k]))) + .join(" · ") || "손 단가"; + const draw = (): void => { + let gapSum = 0; + const data: DataRow[] = rows.map((r, i) => { + const c = choice(i); + const cand = typeof c === "object" ? c : null; + const price = cand ? priceOf(cand.비목, cand.키, cand.단가) : null; + const gap = + cand && price != null && r.수량 != null ? (price - r.붙인[COST[cand.비목]]) * r.수량 : null; + if (on[i] && gap != null) gapSum += gap; + return { + key: `${r.키 ?? ""}|${r.줄}`, + name: [r.이름, r.규격].filter(Boolean).join(" · "), + unit: r.단위, + qty: r.수량, + pasted: r.붙인.노무비 + r.붙인.재료비 + r.붙인.경비, + now: nowPrice(r), + cand: cand + ? `${cand.비목} ${cand.이름} ${cand.규격} (${pick[i] + 1}/${r.후보.length})` + : c === "free" + ? "이음 풂 — 붙인 단가로" + : r.후보.length + ? `그대로 (후보 ${r.후보.length})` + : "후보 없음", + price, + gap, + pick: on[i] ? "✓" : "", + }; + }); + const table = createDataTable({ + columns: [ + { key: "name", label: "품명 · 규격", width: 220 }, + { key: "unit", label: "단위", width: 48 }, + { key: "qty", label: "수량", kind: "number", decimals: 4, trim: true, width: 70 }, + { key: "pasted", label: "붙인 단가", kind: "number", decimals: 1, width: 90 }, + { key: "now", label: src.priceLabel, width: 100 }, + { key: "cand", label: "이음 후보 (누름 = 다음)", width: 260 }, + { key: "price", label: "후보 단가", kind: "number", decimals: 1, width: 90 }, + { key: "gap", label: "금액 차이", kind: "number", decimals: 1, width: 90 }, + { key: "pick", label: "고름", width: 48 }, + ], + rows: data, + frozen: 1, + empty: "고정 줄 없음", + onCellClick: (i, key) => { + const r = rows[i]; + if (key === "cand") { + pick[i] = (pick[i] + 1) % (r.후보.length + (linked(r) ? 2 : 1)); + on[i] = choice(i) !== "keep"; + } else if (key === "pick") on[i] = !on[i]; + else return; + draw(); + }, + cellClass: (i, key) => (key === "pick" && on[i] ? "is-edited" : null), + }); + const n = changes().length; + info.textContent = + `고정 줄 ${rows.length} · 후보 있는 줄 ${rows.filter((r) => r.후보.length).length} · ` + + `이은 줄 ${rows.filter(linked).length} · 고른 줄 ${n} · 금액 차이 합 ${won(gapSum)}`; + apply.disabled = !n; + undo.disabled = !back.length; + host.replaceChildren(table.root); + }; + const run = async (list: RelinkChange[], undoing: boolean): Promise => { + try { + const got = await src.apply(list); + back = undoing ? [] : got.back; + showToast(undoing ? `되돌림 — ${got.note}` : got.note, "success", 6000); + await load(); + draw(); + } catch (error) { + showToast(`실패 — ${error instanceof Error ? error.message : String(error)}`, "error"); + } + }; + const all = createButton({ + label: "모두 고름", + variant: "ghost", + onClick: () => { + const want = !rows.every((r, i) => !r.후보.length || on[i]); + on = rows.map((r, i) => (want ? !!r.후보.length && choice(i) !== "keep" : false)); + draw(); + }, + }); + const apply = createButton({ label: "바꿈", onClick: () => void run(changes(), false) }); + const undo = createButton({ + label: "되돌리기", + variant: "ghost", + onClick: () => void run(back, true), + }); + try { + await load(); + } catch (error) { + showToast( + `다시 잇기 표 실패 — ${error instanceof Error ? error.message : String(error)}`, + "error", + ); + return; + } + openModal({ + title: src.title, + closeLabel: "닫기", + wide: true, + width: "1100px", + actions: [all, undo, apply], + mount: (body) => { + body.append( + createText( + "붙인 단가 ↔ 단가 책 — 후보 칸을 눌러 고르고 [바꿈] · 품(수량)은 그대로 · 단가만 이음 · [되돌리기] = 바로 앞 바꿈", + "muted", + ), + info, + host, + ); + draw(); + }, + }); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts index 95e4c47d0..ddb3f3de5 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts @@ -55,6 +55,7 @@ import { toTarget, } from "./M02_MasterTemplete_Hopyo_Check"; import { builtWord } from "./M02_MasterTemplete_Hopyo_Choose"; +import { openRelink, relinkKeys } from "./M02_MasterTemplete_Hopyo_Relink"; import "./M02_MasterTemplete_Hopyo.css"; export interface HopyoSheetOptions { @@ -537,7 +538,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { const next = "다음 — 호표 [수정]에서 [품셈으로 바꾸기]" + (got.이음후보 - ? ` · 이음 후보 ${got.이음후보} 줄(이름은 같고 단가가 다름)은 엑셀 단가 그대로 — 「이 공사 단가로 다시 잇기」에서 바꿈` + ? ` · 이음 후보 ${got.이음후보} 줄(이름은 같고 단가가 다름)은 엑셀 단가 그대로 — 아래 [다음 — 이 공사 단가로 다시 잇기]에서 바꿈` : ""); const rows = [ ...got.다름.map((x) => @@ -556,9 +557,15 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { el("tr", { children: [el("td", { text: `제${x.번}호표` }), el("td", { text: x.까닭 })] }), ), ]; + const relink = createButton({ + label: "다음 — 이 공사 단가로 다시 잇기", + size: "sm", + onClick: () => void openRelink(relinkKeys((got.만듦 ?? []).map((x) => x.키))), + }); // 705-2 result.replaceChildren( note(allText(got)), note(next), + relink, ...(rows.length ? [table(["호표", "합계 다름 · 실패", "차이"], rows)] : []), ); showToast(allText(got), got.빠짐 || got.실패.length ? "warning" : "success"); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts index 6005b80f2..e18842fb5 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts @@ -11,7 +11,7 @@ import { type LogicSummary, type SubBrief, } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; -import type { LaborCalc, LaborRef, LaborSpec } from "./M02_MasterTemplete_Labor_Calc"; +import type { LaborCalc, LaborFixed, LaborRef, LaborSpec } from "./M02_MasterTemplete_Labor_Calc"; import { call, getActiveLayer, @@ -182,6 +182,8 @@ export interface LaborRow { 승격?: boolean; /** 로직 자재 세 칸(148-4) — {호표 차례: 세 칸} · 금액 무관 */ 자재표시?: Record; + /** 고정 줄(705-1 · 705-2 붙여넣기) — 숫자 그대로 · 로직 · 참조 없음 */ + 고정?: LaborFixed; } /** 자재 세 칸(148-4 · 서버 `M02_Labor_Doc.MARKS` 와 같음) — 빈 칸 = 읽을 때 기본값 · 금액 무관 */ export const MAT_MARKS = { @@ -260,7 +262,8 @@ export function normalizeLabor(doc: unknown, column: string): LaborDoc { ...(r.켜쌓기율 != null ? { 켜쌓기율: r.켜쌓기율 } : {}), ...(r.고름 && Object.keys(r.고름).length ? { 고름: { ...r.고름 } } : {}), ...(r.승격 === true && !r.참조 ? { 승격: true } : {}), - ...(r.참조 ? {} : cleanShown(r.자재표시)), + ...(r.참조 || r.고정 ? {} : cleanShown(r.자재표시)), + ...(r.고정 ? { 고정: r.고정 } : {}), // 705-1 — 읽을 때 떨구면 [저장]에서 줄이 사라짐 })), 자재: (d.자재 ?? []).map((m) => ({ 코드: m.코드, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index 5c7b65d42..280c3b2d6 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -218,7 +218,7 @@ export function mountLaborDetail( ), ); const draw = (): void => { - gridHost.replaceChildren(laborGrid(cur, calc ?? last, logics, edit && !lib && changed)); + gridHost.replaceChildren(laborGrid(cur, calc ?? last, logics, edit && changed, !lib)); drawLogic(); matHost.replaceChildren( matTable( diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts index b2addf083..b4f6bd348 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts @@ -22,6 +22,7 @@ import { lineId } from "./M02_MasterTemplete_Labor_Calc"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import { valueLine } from "./M02_Labor_Values_Model"; import { specBar, specCells, withSpecs, type SpecEdit } from "./M02_MasterTemplete_Labor_Spec"; +import { pasteInto } from "./M02_MasterTemplete_Labor_Paste"; const money = (m: LaborCost | null | undefined, times = 1): HopyoMoney => m @@ -46,6 +47,20 @@ export function laborBlock( const layer = row.켜쌓기율 != null ? tc("Labor_Layer").replace("{n}", String(row.켜쌓기율)) : ""; const qty = line?.수량 ?? null; + const fx = row.고정; // 705-1 고정 줄 — 이름 · 규격 · 단위 = 줄 것 · 비고 = 이음 · 손 단가 + if (fx) + return { + name: fx.이름, + spec: fx.규격 ?? "", + qty, + unit: fx.비율 ? "%" : (fx.단위 ?? ""), + prices: money(line?.기준, line?.배 || 1), + amounts: money(line?.반영), + note: + line?.까닭 ?? + (Object.values(fx.이음 ?? {}).some(Boolean) ? tc("Grid_Linked") : tc("Grid_Hand")), + tag: HOPYO_TAG.fix, + }; return { name: row.별칭 || ((row.참조 ? line?.이름 : logic?.이름) ?? row.참조?.키 ?? row.로직), spec: [valueLine(logic?.입력 ?? [], row.입력값).text, layer].filter(Boolean).join(" · "), @@ -90,16 +105,19 @@ export function laborBlock( return withSpecs(block, doc, calc); } -/** 격자 하나 — `edit` 있으면 위에 규격 줄 · 규격 수량 칸 고침(704-4) */ +/** 격자 하나 — `edit` 있으면 Ctrl+V 붙여넣기(705-2 · `Labor_Paste`) · `spec` 이면 위에 규격 줄 · 규격 수량 칸 고침(704-4) */ export const laborGrid = ( doc: LaborDoc, calc: LaborCalc | null, logics: ReadonlyMap, edit?: SpecEdit | false, + spec = true, ): HTMLElement => { const block = laborBlock(doc, calc, logics); if (!edit) return createHopyoGrid([block]).root; - const grid = createHopyoGrid([block], specCells(block, doc, edit)).root; + const paste = (text: string): void => void pasteInto(doc, text, () => edit(false)); + if (!spec) return createHopyoGrid([block], {}, paste).root; + const grid = createHopyoGrid([block], specCells(block, doc, edit), paste).root; const box = document.createElement("div"); box.append(specBar(doc, calc, edit), grid); return box; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Paste.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Paste.ts new file mode 100644 index 000000000..cf00010b3 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Paste.ts @@ -0,0 +1,213 @@ +/* ============================================================================= + * M02_MasterTemplete_Labor_Paste.ts + * 엑셀 붙여넣기(PLAN 705-2) — 일위대가 격자 Ctrl+V → 붙인 글 그대로 서버(`/hopyo-paste` · 숫자는 서버가 글에서 읽음). + * 열 짝 = 처음 한 번 묻고 기억(이 브라우저 `localStorage` · 사용자 설정) · 엑셀 머리 글이 붙어 오면 묻지 않음 + * 답 「줄」(호표 머리 없음) = 열린 문서에 고정 줄로 더함 → [저장] · 알림을 누르면 「이 공사 단가로 다시 잇기」 + * 답 「만듦」(호표 머리 있음) = 호표마다 사전조합(실무자료 「붙여넣기 …」) · 결과 창 [이 공사 단가로 다시 잇기] + * `askCols` · `withCols` = B09 펼친 일위대가 붙여넣기도 씀 + * ========================================================================== */ + +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { createSelectField } from "@ui/ui_template_input"; +import { openModal } from "@ui/ui_template_modal"; +import { createDataTable } from "@ui/ui_template_data_table"; +import { createText } from "@ui/ui_template_text"; +import { hideLoadingOverlay, showLoadingOverlay } from "@ui/ui_template_toast"; +import { HOPYO_BUILT } from "./M02_MasterTemplete_Hopyo_Check"; +import { + pageScope, + pasteHopyo, + type HopyoBuiltAll, + type PasteCols, +} from "./M02_MasterTemplete_Hopyo_Api"; +import { openRelink, relinkDoc, relinkKeys } from "./M02_MasterTemplete_Hopyo_Relink"; +import type { LaborDoc } from "./M02_MasterTemplete_Labor_Api"; +import { lineId } from "./M02_MasterTemplete_Labor_Calc"; + +const COLS_KEY = "aislo:hopyo-paste-cols"; +const FIELDS = ["품명", "규격", "수량", "단위", "금액", "노", "재", "경"] as const; +const LABEL: Record<(typeof FIELDS)[number], string> = { + 품명: "품명", + 규격: "규격", + 수량: "수량", + 단위: "단위", + 금액: "합계 금액", + 노: "노무비 단가", + 재: "재료비 단가", + 경: "경비 단가", +}; +const NEED = new Set(["품명", "수량", "단위"]); + +const letter = (n: number): string => + n < 26 ? String.fromCharCode(65 + n) : `${letter(Math.floor(n / 26) - 1)}${letter(n % 26)}`; +const won = (v: number | null | undefined): string => + v == null ? "-" : Math.round(v).toLocaleString("ko-KR"); + +export function rememberedCols(): PasteCols | null { + try { + const got = JSON.parse(localStorage.getItem(COLS_KEY) ?? "null") as PasteCols | null; + return got && typeof got === "object" ? got : null; + } catch { + return null; + } +} + +/** 열 짝 묻기 — 짐작 값이 미리 골라짐 · 보기 = 붙인 첫 줄들(칸 머리 A · B · C …) · 취소 = null */ +export function askCols(guess: PasteCols, look: string[][]): Promise { + return new Promise((done) => { + const width = Math.max(0, ...look.map((r) => r.length)); + const options = [ + { value: "", text: "없음" }, + ...Array.from({ length: width }, (_, n) => ({ + value: String(n), + text: `${letter(n)} 칸 · ${look.find((r) => r[n])?.[n] ?? ""}`.slice(0, 40), + })), + ]; + const picks = FIELDS.map((f) => + createSelectField({ + label: `${LABEL[f]}${NEED.has(f) ? " *" : ""}`, + options, + value: guess[f] != null ? String(guess[f]) : "", + compact: true, + }), + ); + const table = createDataTable({ + columns: Array.from({ length: width }, (_, n) => ({ key: `c${n}`, label: letter(n) })), + rows: look.map((r, i) => ({ + key: String(i), + ...Object.fromEntries(r.map((c, n) => [`c${n}`, c])), + })), + empty: "", + }); + let answered = false; + const { close } = openModal({ + title: "붙인 엑셀 열 짝 — 한 번만 묻고 기억", + closeLabel: "취소", + width: "960px", + beforeClose: () => { + if (!answered) done(null); + return true; + }, + mount: (body, _close, actions) => { + const ok = createButton({ + label: "확인", + onClick: () => { + const cols: PasteCols = {}; + FIELDS.forEach((f, i) => { + const v = picks[i].select.value; + if (v !== "") cols[f] = Number(v); + }); + if ([...NEED].some((f) => cols[f as keyof PasteCols] == null)) + return showToast("품명 · 수량 · 단위 칸은 꼭 고름", "warning"); + localStorage.setItem(COLS_KEY, JSON.stringify(cols)); + answered = true; + done(cols); + close(); + }, + }); + actions.append(ok); + body.append( + createText( + "엑셀 머리 글 없이 붙여 열 짝을 모름 — 칸마다 무엇인지 고름(짐작 값이 골라져 있음) · 다음부터 묻지 않음", + "muted", + ), + el("div", { className: "ui-toolbar", children: picks.map((p) => p.root) }), + table.root, + ); + }, + }); + }); +} + +/** 열 짝 고리 — 기억한 짝으로 부름 · 「물음」이면 묻고 다시 부름 · 취소 = null */ +export async function withCols( + run: (cols: PasteCols | null) => Promise, +): Promise { + let got = await run(rememberedCols()); + if (got.갈래 === "물음") { + const ask = got as { 열: PasteCols; 보기: string[][] }; + hideLoadingOverlay(); + const cols = await askCols(ask.열, ask.보기); + if (!cols) return null; + showLoadingOverlay(); + got = await run(cols); + } + return got as T; +} + +/** 만든 결과 창 — 엑셀 합계 = 우리 합계 · 다음 걸음 [이 공사 단가로 다시 잇기] */ +export function builtNotice(got: HopyoBuiltAll, keys: string[]): void { + const text = + `사전조합 ${got.만듦?.length ?? 0} 만듦(호표 ${got.호표} · 줄 ${got.줄} · 고정 줄 ${got.고정} · ` + + `하위 호표 참조 ${got.호표참조} · 빠짐 ${got.빠짐}) · 엑셀 합계 = 우리 합계 ${got.같음}/${got.호표}` + + ` · 저절로 이음 ${got.이음} · 이음 후보 ${got.이음후보}`; + const rows = got.다름.map((x) => ({ + key: String(x.번), + name: `제${x.번}호표 ${x.이름}`, + excel: x.엑셀, + ours: x.우리, + gap: x.차이, + })); + openModal({ + title: "엑셀 붙여넣기 — 만듦", + closeLabel: "닫기", + mount: (body, close, actions) => { + actions.append( + createButton({ + label: "다음 — 이 공사 단가로 다시 잇기", + onClick: () => { + close(); + void openRelink(relinkKeys(keys)); + }, + }), + ); + body.append(createText(text, got.빠짐 || got.실패.length ? "error" : "muted")); + if (rows.length) + body.append( + createDataTable({ + columns: [ + { key: "name", label: "합계 다른 호표", width: 260 }, + { key: "excel", label: "엑셀", kind: "number" }, + { key: "ours", label: "우리", kind: "number" }, + { key: "gap", label: "차이", kind: "number", decimals: 1 }, + ], + rows, + }).root, + ); + }, + }); +} + +/** 격자 Ctrl+V(M02 일위대가 상세 · 사전조합) — 줄 = 문서에 더함(`changed`) · 만듦 = 사전조합 여럿 */ +export async function pasteInto(doc: LaborDoc, text: string, changed: () => void): Promise { + const scope = pageScope(); + const have = doc.담은로직.map((r) => lineId(r) ?? "").filter((id) => id.startsWith("고정|")); + showLoadingOverlay(); + try { + const got = await withCols((cols) => pasteHopyo(scope, text, cols, { 있음: have })); + if (!got || got.갈래 === "물음") return; + if (got.갈래 === "만듦") { + document.dispatchEvent(new Event(HOPYO_BUILT)); + builtNotice( + got, + (got.만듦 ?? []).map((x) => x.키), + ); + return; + } + for (const one of got.줄) + doc.담은로직.push({ 로직: "", 고정: one.고정, 별칭: one.별칭, 메모: one.메모, 입력값: {}, + 수량: one.수량.손 == null ? null : { 손: one.수량.손 } }); // prettier-ignore + changed(); + showToast( + `고정 줄 ${got.줄.length} 붙임 · 엑셀 합계 ${won(got.엑셀)} · 이음 ${got.이음} · 후보 ${got.이음후보}` + + " — [저장]으로 남김 · 이 알림을 누르면 이 공사 단가로 다시 잇기", + "success", + 9000, + () => void openRelink(relinkDoc(doc, changed)), + ); + } catch (error) { + showToast(`붙여넣기 실패 — ${error instanceof Error ? error.message : String(error)}`, "error"); + } finally { + hideLoadingOverlay(); + } +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts index a48088c51..84f9ca0fd 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts @@ -13,6 +13,7 @@ * ========================================================================== */ import { createButton, createSelectField, el } from "@ui/ui_template_elements"; +import { HOPYO_TAG } from "@ui/ui_template_hopyo_grid"; import { pctView } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money"; import type { ItemLogic } from "./M02_MasterTemplete_Api_Fetch"; @@ -46,7 +47,8 @@ const SPAN = 3; /** 구분(50-4) — 로직 줄 = `승격`(134-1 · 단가승격 · 없음 = 단가산출) · 참조 줄 = 키 머리(SC- = 일위조합 · * 옛 SP- = 단가승격 글 고정 · 134-2) */ -export const kindOf = (row: Pick): string => { +export const kindOf = (row: Pick): string => { + if (row.고정) return HOPYO_TAG.fix; // 705-1 고정 줄 const key = row.참조?.키 ?? row.로직; return tc( row.승격 || key.startsWith("SP-") @@ -343,7 +345,8 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement const logic = data.logics.get(row.로직); const line = lineOf(id); // 참조 줄 — 키 · 이름 · 단위 = 가리킨 문서 것(calc 답) · 넣을 값 없음(승격 때 고정) - const unit = row.참조 ? (line?.분모 ?? "") : (logic?.결과단위 ?? ""); + const fx = row.고정; // 705-1 고정 줄 — 이름 · 단위 = 줄 것 · [옵션] 없음 + const unit = fx ? (fx.단위 ?? "") : row.참조 ? (line?.분모 ?? "") : (logic?.결과단위 ?? ""); const values = valueLine(logic?.입력 ?? [], row.입력값); const layer = row.켜쌓기율 != null ? tc("Labor_Layer").replace("{n}", String(row.켜쌓기율)) : ""; @@ -393,7 +396,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement el("td", { className: "m02-labrow__kind", children: [ - on?.promote && !row.참조 + on?.promote && !row.참조 && !fx ? kindPick(!!row.승격, (v) => on.promote?.(i, v)) : kindOf(row), // 102-3 — 단가산출 줄만 품셈 책(출처 첫 낱말) @@ -404,9 +407,14 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement el("td", { className: "m02-labrow__name", children: [ - el("span", { className: "m02-labrow__key", text: row.참조?.키 ?? row.로직 }), + el("span", { + className: "m02-labrow__key", + text: fx + ? [fx.규격, fx.이음?.노임 ?? fx.이음?.자재].filter(Boolean).join(" · ") + : (row.참조?.키 ?? row.로직), + }), el("strong", { - text: (row.참조 ? line?.이름 : logic?.이름) ?? row.참조?.키 ?? row.로직, + text: fx?.이름 ?? (row.참조 ? line?.이름 : logic?.이름) ?? row.참조?.키 ?? row.로직, }), ...(values.empty ? [ @@ -456,7 +464,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement ? ref && !row.참조.키.startsWith("SP-") ? [small(tc("Labor_Edit"), () => ref(i))] : [] - : option + : option && !fx ? [small(tc("Labor_Option"), () => option(i))] : []), ...(remove ? [small(tc("Remove"), () => remove(i), "danger")] : []), diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index 17a29f798..c453025c4 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -177,6 +177,8 @@ const TEXT = { Grid_Title: ["일위대가표", "Unit-price sheet"], Grid_NoQty: ["수량 없음", "No quantity"], Grid_Apart: ["별도계상 · 합계 밖", "Separate · not in total"], + Grid_Linked: ["이 공사 단가에 이음", "Linked to project price"], + Grid_Hand: ["손 단가(붙인 값)", "Hand price (pasted)"], } as const satisfies Record; export type ComboTextKey = keyof typeof TEXT; diff --git a/resources/tester/test_705_2_hopyo_paste.py b/resources/tester/test_705_2_hopyo_paste.py index c28a053b6..b70d299e6 100644 --- a/resources/tester/test_705_2_hopyo_paste.py +++ b/resources/tester/test_705_2_hopyo_paste.py @@ -170,3 +170,47 @@ def test_틀린_글_400(client, text, cols, word): def test_쉼표_빈칸_수(): assert hx._num("1,415,169") == Decimal("1415169") and hx._num("") is None + + +# ── 이 공사 단가로 다시 잇기 ────────────────────────────────────────────────── +RELINK = "/api/m02/layers/personal/hopyo-relink" + + +def test_다시_잇기_후보_바꿈_되돌리기(client): + from resources.tester.test_m02_hopyo_excel import _post + + assert _post(client).status_code == 200 # 북부청 — 이음 13 · 이음 후보 31(705-3) + made = client.post( + "/api/m02/layers/personal/hopyo-build-all", json={"층": "personal", "키": "HP-000001"} + ).json() + keys = [x["키"] for x in made["만듦"]] + assert len(keys) == 68 + rows = client.post(f"{RELINK}/rows", json={"키": keys}).json()["줄"] + near = [r for r in rows if r["후보"] and not any(r["이음"].values())] + assert near, "단가 다른 이음 후보 줄" + linked = [r for r in rows if any(r["이음"].values())] + assert len(linked) >= 13 and all(r["지금"] for r in linked) + one = near[0] + pick = one["후보"][0] + change = {"키": one["키"], "줄": one["줄"], pick["비목"]: pick["키"]} + got = client.post(RELINK, json={"바꿈": [change]}) + assert got.status_code == 200, got.text + done = got.json() + (doc,) = done["문서"] + assert done["바뀜"] == 1 and doc["전"] != doc["후"] # 붙인 단가 → 단가 책 값 + again = client.post(f"{RELINK}/rows", json={"키": [one["키"]]}).json()["줄"] + assert next(r for r in again if r["줄"] == one["줄"])["이음"][pick["비목"]] == pick["키"] + back = client.post(RELINK, json={"바꿈": done["되돌림"]}).json() # 되돌리기 + assert back["문서"][0]["후"] == doc["전"] + bad = {"키": one["키"], "줄": one["줄"], "노임": "LB999999"} + assert client.post(RELINK, json={"바꿈": [bad]}).status_code == 400 + assert client.post(RELINK, json={"바꿈": [{**change, "줄": "고정|없음||"}]}).status_code == 404 + num = client.post(RELINK, json={"바꿈": [change], "금액": 1}) + assert num.status_code == 422 # 숫자 칸 안 받음 + + +def test_다시_잇기_열린_문서_후보만(client): + fx = {"이름": "보통인부", "규격": "", "단위": "인", "단가": {"노무비": 1}, "이음": {}} + doc = {"담은로직": [{"고정": fx, "수량": {"손": 0.5}}]} + (row,) = client.post(f"{RELINK}/rows", json={"문서": doc}).json()["줄"] + assert row["키"] is None and row["후보"][0]["비목"] == "노임" and row["후보"][0]["단가"] > 1 diff --git a/ui_template/ui_template_hopyo_grid.css b/ui_template/ui_template_hopyo_grid.css index f31819c12..0dda4188d 100644 --- a/ui_template/ui_template_hopyo_grid.css +++ b/ui_template/ui_template_hopyo_grid.css @@ -19,3 +19,10 @@ color: var(--color-danger); font-weight: var(--font-weight-bold); } + +/* 붙여넣기 받는 격자(705-2) — 눌러 고르면 테두리 · Ctrl+V 자리 */ +.ui-hopyo-grid[data-paste]:focus { + outline: 2px solid var(--color-focus-ring); + outline-offset: 2px; + border-radius: var(--radius-sm); +} diff --git a/ui_template/ui_template_hopyo_grid.ts b/ui_template/ui_template_hopyo_grid.ts index 8d5eedc4a..7a2b4a5dd 100644 --- a/ui_template/ui_template_hopyo_grid.ts +++ b/ui_template/ui_template_hopyo_grid.ts @@ -13,6 +13,7 @@ * 값은 부르는 쪽이 준 것 그대로(서버 계산 값) — 여기서 셈하지 않음 * * const g = createHopyoGrid([block]); host.append(g.root); g.set([block2]); + * 붙여넣기(705-2) = `createHopyoGrid(blocks, {}, (text) => …)` — 격자 누른 뒤 Ctrl+V 글을 넘김 * B09 처럼 탭 표가 따로 그리면 `hopyoGrid(blocks)` 의 열 · 줄 · 제목 줄만 씀 * ========================================================================== */ @@ -248,13 +249,26 @@ export interface HopyoGridHandle { set: (blocks: HopyoBlock[]) => void; } -/** 격자 하나(읽기 전용) — 빈 호표 목록 = 빈 표 */ +/** 격자 하나(읽기 전용) — 빈 호표 목록 = 빈 표 · `onPaste`(705-2) = 격자를 누르고 Ctrl+V → 붙인 글 그대로 + * (엑셀 칸 = 탭 · 줄 = 줄바꿈 · 해석은 부르는 쪽 · 서버) */ export function createHopyoGrid( blocks: HopyoBlock[], extra: Partial = {}, + onPaste?: (text: string) => void, ): HopyoGridHandle { const root = document.createElement("div"); root.className = "ui-hopyo-grid"; + if (onPaste) { + root.tabIndex = 0; // 눌러 고르면 붙여넣기를 받음 + root.dataset.paste = ""; + root.addEventListener("paste", (ev) => { + if ((ev.target as Element | null)?.closest("input, textarea")) return; // 칸 고침 중은 그 칸 몫 + const text = ev.clipboardData?.getData("text/plain") ?? ""; + if (!text.trim()) return; + ev.preventDefault(); + onPaste(text); + }); + } const set = (next: HopyoBlock[]): void => { root.replaceChildren(createDataTable(hopyoTableOptions(next, extra)).root); };