feat(M02 붙여넣기 화면 · 다시 잇기): 격자 Ctrl+V · 열 짝 한 번 묻고 기억 · 이 공사 단가로 다시 잇기 (PLAN 705-2)
- 일위대가 격자(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 <noreply@anthropic.com>
This commit is contained in:
@@ -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}
|
||||
@@ -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.바꿈)
|
||||
|
||||
@@ -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<HopyoF
|
||||
throw new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`);
|
||||
return body;
|
||||
}
|
||||
|
||||
/** 엑셀 붙여넣기 열 짝(705-2) — 칸 차례(0부터) · 노 · 재 · 경 = 단가 칸 · 금액 = 합계 금액 칸 */
|
||||
export type PasteCols = Partial<
|
||||
Record<"품명" | "규격" | "수량" | "단위" | "금액" | "노" | "재" | "경", number>
|
||||
>;
|
||||
|
||||
/** 붙여넣기 답 — 물음(열 짝 짐작 + 보기) · 줄(고정 줄 · 문서에 넣음) · 만듦(호표마다 사전조합) */
|
||||
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<PasteResult> =>
|
||||
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<Record<"노임" | "자재", number | null>>;
|
||||
후보: {
|
||||
비목: "노임" | "자재";
|
||||
키: 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<RelinkRow[]> =>
|
||||
(
|
||||
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({ 바꿈 }) });
|
||||
|
||||
@@ -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<Kind, "노무비" | "재료비"> = { 노임: "노무비", 자재: "재료비" };
|
||||
|
||||
export interface RelinkSource {
|
||||
title: string;
|
||||
/** 이은 단가 칸 머리 — 「최신 판 단가」 · 「이 공사 단가」 */
|
||||
priceLabel: string;
|
||||
rows: () => Promise<RelinkRow[]>;
|
||||
/** 바꿈 → 되돌릴 바꿈 · 알림 글 */
|
||||
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<void> {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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<string, MatMarks>;
|
||||
/** 고정 줄(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.코드,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<string, ItemLogic>,
|
||||
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;
|
||||
|
||||
@@ -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<PasteCols | null> {
|
||||
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<T extends { 갈래: string }>(
|
||||
run: (cols: PasteCols | null) => Promise<T | { 갈래: "물음"; 열: PasteCols; 보기: string[][] }>,
|
||||
): Promise<T | null> {
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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<LaborRow, "로직" | "참조" | "승격">): string => {
|
||||
export const kindOf = (row: Pick<LaborRow, "로직" | "참조" | "승격" | "고정">): 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")] : []),
|
||||
|
||||
@@ -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<string, readonly [string, string]>;
|
||||
|
||||
export type ComboTextKey = keyof typeof TEXT;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<DataTableOptions> = {},
|
||||
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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user