Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_5
This commit is contained in:
@@ -23,6 +23,8 @@
|
||||
짝 잃은 덮개(그 블록 · 줄 없음) = `daega_orphans` [{cover, cid}] — 지우지 않고 알림만.
|
||||
복사본(703-2) = 덮개 `K:…` 의 `@copy` {from · name · spec} — 원본 cover 블록을 본뜬 새 호표
|
||||
(id `DG:K:…` · 번호 = 끝 · 줄 · 기본값 = 원본 · 덮개 = 제 것) · 원본 없음 = 짝 잃음 {cover, cid: @copy}.
|
||||
붙인 줄(705-2 · 엑셀 붙여넣기) = 덮개 `@add` [블록 줄 `fixed` · `pct` · cid `@add|…`] — 그 cover 블록 끝에 더함
|
||||
(복사본 앞 · 뒤 두 번 · 같은 cid 는 한 번 · 복사본이 원본 붙인 줄을 이어받음) · 고친 칸 덮개는 다른 줄과 같음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -35,6 +37,7 @@ PARTS = ("labor", "material", "expense")
|
||||
COVER_KEYS = ("qty", *PARTS)
|
||||
TAB = "dg"
|
||||
COPY = "@copy"
|
||||
ADD = "@add"
|
||||
WHY = {
|
||||
"labor": "노임 단가 없음",
|
||||
"material": "자재 적용 단가 없음",
|
||||
@@ -182,6 +185,26 @@ def with_copies(blocks: list[dict[str, Any]], cover: dict[str, Any]) -> list[dic
|
||||
return out
|
||||
|
||||
|
||||
def with_added(blocks: list[dict[str, Any]], cover: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""붙인 줄(705-2) — 블록 cover 의 `@add` 줄을 끝에 더함(이미 있는 cid 는 뺌)."""
|
||||
out = []
|
||||
for b in blocks:
|
||||
add = (cover.get(b.get("cover")) or {}).get(ADD)
|
||||
have = {ln.get("cid") for ln in b["lines"]}
|
||||
more = [ln for ln in add if ln.get("cid") not in have] if isinstance(add, list) else []
|
||||
out.append({**b, "lines": [*b["lines"], *more]} if more else b)
|
||||
return out
|
||||
|
||||
|
||||
def added_keys(cover: dict[str, Any] | None) -> list[str]:
|
||||
"""붙인 줄이 잇는 단가 책 키(705-2 · 수요에 더함)."""
|
||||
out: list[str] = []
|
||||
for lines in (cover or {}).values():
|
||||
for ln in (lines or {}).get(ADD) or []:
|
||||
out += [v for v in (ln.get("link") or {}).values() if v and v not in out]
|
||||
return out
|
||||
|
||||
|
||||
def compute_labor(
|
||||
daega: dict[str, Any] | None,
|
||||
sheets: dict[str, Any],
|
||||
@@ -201,7 +224,7 @@ def compute_labor(
|
||||
"sangeun": sangeun or {},
|
||||
}
|
||||
cover = cover or {}
|
||||
blocks = with_copies(blocks, cover)
|
||||
blocks = with_added(with_copies(with_added(blocks, cover), cover), cover)
|
||||
by_id = {b["id"]: b for b in blocks}
|
||||
no = {b["id"]: n for n, b in enumerate(blocks, 1)}
|
||||
done: dict[str, dict[str, Any]] = {}
|
||||
@@ -252,7 +275,9 @@ def compute_labor(
|
||||
{"cover": key, "cid": cid}
|
||||
for key, lines in cover.items()
|
||||
for cid in lines
|
||||
if cid not in cids.get(key, ()) and not (cid == COPY and f"DG:{key}" in by_id)
|
||||
if cid not in cids.get(key, ())
|
||||
and not (cid == COPY and f"DG:{key}" in by_id)
|
||||
and cid != ADD
|
||||
]
|
||||
return {
|
||||
"daega_blocks": out_blocks,
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
* 있는 칸만 셈 값 대신 · 줄 base(품셈 값) · covered(고친 칸) · 짝 잃은 덮개 = daega_orphans(알림만)
|
||||
* 복사본(703-2) = 덮개 K:… 의 @copy {from · name · spec} — 원본 cover 블록을 본뜬 새 호표
|
||||
* (id DG:K:… · 번호 = 끝 · 줄 · 기본값 = 원본 · 덮개 = 제 것) · 원본 없음 = 짝 잃음 {cover, cid: @copy}
|
||||
* 붙인 줄(705-2) = 덮개 @add [블록 줄 fixed · pct · cid @add|…] — 그 cover 블록 끝에 더함(복사본 앞 · 뒤 · 같은 cid 한 번)
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
@@ -36,6 +37,8 @@ const PARTS = ["labor", "material", "expense"] as const;
|
||||
const COVER_KEYS = ["qty", ...PARTS] as const;
|
||||
const TAB = "dg";
|
||||
export const COPY = "@copy";
|
||||
/** 붙인 줄 덮개 열쇠(705-2) */
|
||||
export const ADD = "@add";
|
||||
const WHY: Record<string, string> = {
|
||||
labor: "노임 단가 없음",
|
||||
material: "자재 적용 단가 없음",
|
||||
@@ -183,6 +186,16 @@ function withCopies(blocks: Row[], covers: Row): Row[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 붙인 줄(705-2) — 블록 cover 의 @add 줄을 끝에 더함(이미 있는 cid 는 뺌) */
|
||||
function withAdded(blocks: Row[], covers: Row): Row[] {
|
||||
return blocks.map((b) => {
|
||||
const add: unknown = (covers[b.cover] || {})[ADD];
|
||||
const have = new Set((b.lines as Row[]).map((ln) => ln.cid));
|
||||
const more = Array.isArray(add) ? (add as Row[]).filter((ln) => !have.has(ln.cid)) : [];
|
||||
return more.length ? { ...b, lines: [...b.lines, ...more] } : b;
|
||||
});
|
||||
}
|
||||
|
||||
/** 대가 탭 둘 — daega_blocks(일위대가표) · daega(목록표) · daega_owners + 바탕 gaps 이어 붙임 */
|
||||
export function computeLabor(
|
||||
daega: Row | null | undefined,
|
||||
@@ -191,7 +204,10 @@ export function computeLabor(
|
||||
covers: Row = {},
|
||||
): Row {
|
||||
covers = covers || {};
|
||||
const blocks: Row[] = withCopies((daega || {}).blocks || [], covers);
|
||||
const blocks: Row[] = withAdded(
|
||||
withCopies(withAdded((daega || {}).blocks || [], covers), covers),
|
||||
covers,
|
||||
);
|
||||
const index = (rows: Row[] | undefined): Row =>
|
||||
Object.fromEntries((rows || []).map((r) => [r.key, r]));
|
||||
const books: Row = {
|
||||
@@ -244,7 +260,7 @@ export function computeLabor(
|
||||
const orphans: Row[] = [];
|
||||
for (const [key, lines] of Object.entries(covers))
|
||||
for (const cid of Object.keys(lines || {}))
|
||||
if (!cids.get(key)?.has(cid) && !(cid === COPY && `DG:${key}` in byId))
|
||||
if (!cids.get(key)?.has(cid) && !(cid === COPY && `DG:${key}` in byId) && cid !== ADD)
|
||||
orphans.push({ cover: key, cid });
|
||||
return {
|
||||
daega_blocks: outBlocks,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""B09 펼친 일위대가 엑셀 붙여넣기(PLAN 705-2) — 붙인 글 → 지금 호표 끝 고정 줄(덮개 `@add`).
|
||||
|
||||
글 해석 · 고정 줄 = M02 와 한 벌(`M02_Hopyo_Paste.read_text` · `fixed_lines` · 705-3 이음) · 블록 줄 꼴 =
|
||||
`Labor_Source.fixed_line`(fixed · 비율 = pct) · cid `@add|고정|이름|규격|단위`(같은 이름 = 「 (2)」 가름) ·
|
||||
여러 호표를 붙여도 지금 호표에 줄로만 · 사용자 값(화면 캐시)에 더해 돌려줌 — 저장은 B09 [저장](서버가 다시 셈).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from B09_Estimation import B09_Estimation_Store as store
|
||||
from B09_Estimation.B09_Estimation_Calc_Labor import ADD
|
||||
from B09_Estimation.B09_Estimation_Labor_Source import fixed_line
|
||||
from M02_MasterTemplete import M02_Hopyo_Paste as hp
|
||||
from M02_MasterTemplete import M02_Labor_Fixed as fixed
|
||||
|
||||
|
||||
def paste(user: dict[str, Any], cover: str, text: str, cols: Any) -> dict[str, Any]:
|
||||
"""→ `{갈래: 물음, 열, 보기}` · `{갈래: 줄, 줄, 엑셀, 이음, 이음후보, user}` — 틀린 글 = ValueError."""
|
||||
parsed = hp.read_text(text, cols)
|
||||
if parsed["물음"]:
|
||||
look = [[c[:24] for c in row[:16]] for row in hp.cells(text)[:8]]
|
||||
return {"갈래": "물음", "열": parsed["열"], "짐작": True, "보기": look}
|
||||
user = store.clean_user(user)
|
||||
lines = dict((user.get("daega_cover") or {}).get(cover) or {})
|
||||
have = list(lines.get(ADD) or [])
|
||||
taken = {str(x["cid"]).removeprefix(f"{ADD}|") for x in have}
|
||||
got = hp.fixed_lines(parsed, taken)
|
||||
new = [
|
||||
{**fixed_line(x["고정"], x["수량"]["손"]), "cid": f"{ADD}|{fixed.fixed_id(x['고정'])}"}
|
||||
for x in got["줄"]
|
||||
]
|
||||
lines[ADD] = [*have, *new]
|
||||
user = store.clean_user(
|
||||
{**user, "daega_cover": {**(user.get("daega_cover") or {}), cover: lines}}
|
||||
)
|
||||
head = {k: got[k] for k in ("엑셀", "이음", "이음후보")}
|
||||
return {"갈래": "줄", "열": parsed["열"], "줄": len(new), **head, "user": user}
|
||||
@@ -72,6 +72,7 @@ from B08_Quantity.B08_Quantity_Router_Material import material_sheets
|
||||
from B08_Quantity.B08_Quantity_Router_Milestone import read_data as read_milestone
|
||||
from B08_Quantity.B08_Quantity_Router_Milestone import site_info
|
||||
from B09_Estimation import B09_Estimation_Bill_Library as library
|
||||
from B09_Estimation import B09_Estimation_Daega_Paste as daega_paste
|
||||
from B09_Estimation import B09_Estimation_Daega_Personal as personal
|
||||
from B09_Estimation import B09_Estimation_Past as past
|
||||
from B09_Estimation import B09_Estimation_Prices as prices
|
||||
@@ -81,7 +82,7 @@ from B09_Estimation import B09_Estimation_Store as store
|
||||
from B09_Estimation.B09_Estimation_Bill_Items import bill_items, hopyo_prices
|
||||
from B09_Estimation.B09_Estimation_Calc_Base import compute_base
|
||||
from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation
|
||||
from B09_Estimation.B09_Estimation_Calc_Labor import compute_labor, daega_price
|
||||
from B09_Estimation.B09_Estimation_Calc_Labor import added_keys, compute_labor, daega_price
|
||||
from B09_Estimation.B09_Estimation_Calc_Total import compute_total
|
||||
from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs
|
||||
from B09_Estimation.B09_Estimation_Linked import linked_resource_keys
|
||||
@@ -211,7 +212,8 @@ async def _demand(root: Path, draft: dict[str, Any] | None = None) -> list[str]:
|
||||
keep = None if draft is None else library.used_folders(user)
|
||||
keys = await asyncio.to_thread(linked_resource_keys, root, keep)
|
||||
hand = await asyncio.to_thread(sangeun.hand_resource_keys, user)
|
||||
return [*keys, *(k for k in hand if k not in keys)]
|
||||
hand = [*hand, *added_keys(user.get("daega_cover"))] # 705-2 붙인 줄 이음
|
||||
return [*keys, *dict.fromkeys(k for k in hand if k not in keys)]
|
||||
|
||||
|
||||
async def payload(
|
||||
@@ -499,6 +501,34 @@ async def save_personal(
|
||||
}
|
||||
|
||||
|
||||
class DaegaPasteBody(BaseModel):
|
||||
"""705-2 — `cover` = 펼친 호표 덮개 자리 · `글` = 붙인 글 그대로 · `열` = 기억한 열 짝 · `user` = 화면 캐시."""
|
||||
|
||||
cover: str = Field(min_length=1, max_length=200)
|
||||
글: str = Field(max_length=400_000)
|
||||
열: dict[str, int] | None = None
|
||||
user: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@router.post("/{project_id}/estimation/daega/paste")
|
||||
async def daega_paste_run(
|
||||
project_id: UUID, body: DaegaPasteBody, session: dict[str, Any] = Depends(verify_session)
|
||||
) -> dict[str, Any]:
|
||||
"""[Ctrl+V] 펼친 일위대가 — 지금 호표 끝에 고정 줄(저장 안 함) · 답 = 새 사용자 값 + 그 값으로 셈한 원가
|
||||
자료(붙인 줄 이음 키가 단가 책 수요에 듦) · 물음 = 열 짝."""
|
||||
root = await _project_root(project_id)
|
||||
raw = (
|
||||
body.user if isinstance(body.user, dict) else await asyncio.to_thread(store.read_user, root)
|
||||
)
|
||||
try:
|
||||
got = await asyncio.to_thread(daega_paste.paste, raw, body.cover, body.글, body.열)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=400, detail=str(error)) from error
|
||||
if got["갈래"] == "줄":
|
||||
got["payload"] = await payload(project_id, root, got["user"])
|
||||
return got
|
||||
|
||||
|
||||
def _past_rows(projects: list[dict[str, Any]], here: str) -> list[dict[str, Any]]:
|
||||
out = []
|
||||
for other in projects:
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"daega_cover": {블록 cover: {줄 cid: {qty?, labor?, material?, expense?}}}
|
||||
(702-2 일위대가 고친 칸 덮개 — 수만 · 셈 = `B09_Estimation_Calc_Labor`)
|
||||
+ 복사본 `K:…` 의 `@copy` {from · name · spec}(703-2 · 글만)
|
||||
+ 붙인 줄 `@add` [블록 줄 fixed · pct](705-2 · `clean_added`)
|
||||
|
||||
quotes/<자재 키>/ 견적서 파일(166-4 · 여러 개) — 폴더가 목록(키의 못 쓰는 글자는 %XX) ·
|
||||
읽을 때 materials[키].files = 이름들 · 사용자 값으로는 저장 안 함
|
||||
@@ -32,7 +33,7 @@ from urllib.parse import unquote
|
||||
|
||||
from B09_Estimation.B09_Estimation_Calc_Base import SOURCES, SUPPLY
|
||||
from B09_Estimation.B09_Estimation_Calc_Bill import SECTIONS
|
||||
from B09_Estimation.B09_Estimation_Calc_Labor import COVER_KEYS
|
||||
from B09_Estimation.B09_Estimation_Calc_Labor import ADD, COVER_KEYS, PARTS
|
||||
|
||||
try: # 252 공기 초안(draft 폴더 · 지우면 공기 값 없이)
|
||||
from B09_Estimation.draft.B09_Draft_Schedule_Source import clean_schedule
|
||||
@@ -253,6 +254,10 @@ def clean_cover(raw: Any) -> dict[str, Any]:
|
||||
kept = {}
|
||||
for cid, cell in (lines if isinstance(lines, dict) else {}).items():
|
||||
cell = cell if isinstance(cell, dict) else {}
|
||||
if cid == ADD:
|
||||
if got := clean_added(lines.get(ADD)):
|
||||
kept[cid] = got
|
||||
continue
|
||||
if cid == "@copy":
|
||||
if _text(cell.get("from")):
|
||||
kept[cid] = {k: _text(cell[k]) for k in ("from", "name", "spec") if k in cell}
|
||||
@@ -265,6 +270,33 @@ def clean_cover(raw: Any) -> dict[str, Any]:
|
||||
return out
|
||||
|
||||
|
||||
ADD_MAX = 500
|
||||
|
||||
|
||||
def clean_added(raw: Any) -> list[dict[str, Any]]:
|
||||
"""붙인 줄(705-2) — fixed = 이름 · 규격 · 단위 · 수량 · 붙인 단가(own) · 이음 키(link) · pct = 비목 · 밑 ·
|
||||
율 · cid `@add|…` 만 · 모르는 칸 버림 · 숫자 아닌 단가 = 0."""
|
||||
out: list[dict[str, Any]] = []
|
||||
for ln in raw[:ADD_MAX] if isinstance(raw, list) else []:
|
||||
if not isinstance(ln, dict) or not str(ln.get("cid") or "").startswith(f"{ADD}|"):
|
||||
continue
|
||||
head = {k: _text(ln.get(k))[:200] for k in ("cid", "name", "spec", "unit")}
|
||||
if ln.get("kind") == "pct" and ln.get("part") in PARTS:
|
||||
base = str(ln.get("base") or "")
|
||||
if base.removeprefix("part:") in PARTS:
|
||||
out.append({**head, "kind": "pct", "part": ln["part"], "base": base,
|
||||
"qty": _num(ln.get("qty"))}) # fmt: skip
|
||||
continue
|
||||
if ln.get("kind") != "fixed":
|
||||
continue
|
||||
own, link = ln.get("own") or {}, ln.get("link") or {}
|
||||
out.append({**head, "kind": "fixed", "qty": _num(ln.get("qty")),
|
||||
"own": {p: _num(own.get(p)) or 0 for p in PARTS if isinstance(own, dict)},
|
||||
"link": {k: _text(link.get(k))[:40] or None for k in ("labor", "material", "machine")
|
||||
if isinstance(link, dict)}}) # fmt: skip
|
||||
return out
|
||||
|
||||
|
||||
def clean_user(raw: Any) -> dict[str, Any]:
|
||||
"""사용자 값 다듬기 — 모르는 칸 버림 · 수 칸은 수 · null(빈칸)만."""
|
||||
raw = raw if isinstance(raw, dict) else {}
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
* 고친 칸 = 칸 색(`is-edited`) · 「품셈 값」 칸 = 셈 값 · 우클릭 [품셈 값으로] · 빈칸 입력 = 되돌림
|
||||
* 일위대가 연결(dg) 없는 줄 = 펼치지 않음 · 고른 줄 = 이 파일이 기억(다시 그려도 그대로)
|
||||
* 우클릭 덧메뉴(703-2) = 복사해 새 일위대가 · 내 자료집에 저장 · 어디 쓰였나(`…_Bill_Fold_Menu`)
|
||||
* 엑셀 붙여넣기(705-2) = 표를 누르고 Ctrl+V → 서버가 붙인 글을 읽어 지금 호표 끝 고정 줄(덮개 `@add` · 캐시 ·
|
||||
* [저장] 때 서버가 다시 셈) · 열 짝 = M02 와 같은 물음 · 기억 · 알림 누름 = 「이 공사 단가로 다시 잇기」(이 공사 단가 책)
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
@@ -22,6 +24,16 @@ import type { Row } from "./B09_Estimation_Calc_Base";
|
||||
import { money, parseNumber, qty, text, type TabContext } from "./B09_Estimation_UI_Tabs";
|
||||
import { attachRowMenu } from "./B09_Estimation_UI_Tabs_Bill_Edit";
|
||||
import { foldMenu } from "./B09_Estimation_UI_Tabs_Bill_Fold_Menu";
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_toast";
|
||||
import { currentProjectId } from "../A00_Common/b_project_switch";
|
||||
import { ADD } from "./B09_Estimation_Calc_Labor";
|
||||
import { withCols } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Paste";
|
||||
import {
|
||||
openRelink,
|
||||
type RelinkSource,
|
||||
} from "../M02_MasterTemplete/M02_MasterTemplete_Hopyo_Relink";
|
||||
import { relinkRows, type PasteCols } from "../M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api";
|
||||
|
||||
/** 고칠 칸 → 덮개 칸 */
|
||||
const FIELD: Record<string, string> = {
|
||||
@@ -210,9 +222,126 @@ function foldCard(
|
||||
],
|
||||
});
|
||||
card.classList.add("b09-fold");
|
||||
if (block.cover) {
|
||||
table.root.tabIndex = 0; // 705-2 — 눌러 고르면 Ctrl+V 받음
|
||||
card.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();
|
||||
void pasteFold(ctx, block.cover, text);
|
||||
});
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
async function post(path: string, body: Row): Promise<Row> {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${currentProjectId()}/estimation/${path}`,
|
||||
{
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
const got = ((await response.json().catch(() => null)) as Row | null) ?? {};
|
||||
if (!response.ok)
|
||||
throw new Error(typeof got.detail === "string" ? got.detail : `HTTP ${response.status}`);
|
||||
return got;
|
||||
}
|
||||
|
||||
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] || [];
|
||||
const book = (kind: "노임" | "자재", key: string): number | null | undefined =>
|
||||
(ctx.payload.inputs?.[kind === "노임" ? "labor" : "materials"] || []).find(
|
||||
(r: Row) => r.key === key,
|
||||
)?.price;
|
||||
return {
|
||||
title: "이 공사 단가로 다시 잇기 — 붙인 줄",
|
||||
priceLabel: "이 공사 단가",
|
||||
price: book,
|
||||
rows: () =>
|
||||
relinkRows(
|
||||
{ layer: "personal", owner: null },
|
||||
{
|
||||
문서: {
|
||||
담은로직: added()
|
||||
.filter((ln) => ln.kind === "fixed")
|
||||
.map((ln) => ({
|
||||
고정: {
|
||||
이름: ln.name,
|
||||
규격: ln.spec,
|
||||
단위: ln.unit,
|
||||
단가: Object.fromEntries(
|
||||
Object.entries(COST_PART).map(([k, p]) => [k, ln.own?.[p] ?? 0]),
|
||||
),
|
||||
이음: { 노임: ln.link?.labor ?? null, 자재: ln.link?.material ?? null },
|
||||
},
|
||||
수량: { 손: ln.qty },
|
||||
})),
|
||||
},
|
||||
},
|
||||
),
|
||||
apply: async (changes) => {
|
||||
const lines: Row[] = JSON.parse(JSON.stringify(added()));
|
||||
const back = [];
|
||||
for (const c of changes) {
|
||||
const ln = lines.find((x) => x.cid === `${ADD}|${c.줄}`);
|
||||
if (!ln) continue;
|
||||
back.push({
|
||||
키: "",
|
||||
줄: c.줄,
|
||||
노임: ln.link?.labor ?? null,
|
||||
자재: ln.link?.material ?? null,
|
||||
});
|
||||
ln.link = { ...(ln.link || {}) };
|
||||
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 || {}));
|
||||
all[cover] = { ...(all[cover] || {}), [ADD]: lines };
|
||||
const user = { ...ctx.user, daega_cover: all };
|
||||
Object.assign(ctx.payload, await post("calc", { user })); // 새 이음 키 = 이 공사 단가 책 수요
|
||||
ctx.setUser(user);
|
||||
return { back, note: `바뀐 줄 ${back.length} — [저장]으로 남김` };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 붙여넣기(705-2) — 붙인 글 그대로 서버로 · 답 = 새 캐시 + 그 캐시로 셈한 원가 자료 */
|
||||
async function pasteFold(ctx: TabContext, cover: string, text: string): Promise<void> {
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
const got = await withCols(
|
||||
(cols: PasteCols | null) =>
|
||||
post("daega/paste", {
|
||||
cover,
|
||||
글: text,
|
||||
...(cols ? { 열: cols } : {}),
|
||||
user: ctx.user,
|
||||
}) as Promise<Row & { 갈래: "줄" }>,
|
||||
);
|
||||
if (!got) return;
|
||||
Object.assign(ctx.payload, got.payload);
|
||||
ctx.setUser(got.user);
|
||||
const won = Math.round(Number(got.엑셀) || 0).toLocaleString("ko-KR");
|
||||
showToast(
|
||||
`고정 줄 ${got.줄} 붙임 · 엑셀 합계 ${won} · 이음 ${got.이음} · 후보 ${got.이음후보} — [저장]으로 남김 · 이 알림을 누르면 이 공사 단가로 다시 잇기`,
|
||||
"success",
|
||||
9000,
|
||||
() => void openRelink(relinkFold(ctx, cover)),
|
||||
);
|
||||
} catch (error) {
|
||||
showToast(`붙여넣기 실패 — ${error instanceof Error ? error.message : String(error)}`, "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
/** 펼침 고리 — 설계내역서 표 root 줄 누름 = 그 줄 펼침 · 돌려준 자리 = 표 아래 */
|
||||
export function attachBillFold(ctx: TabContext, raw: DataRow[], table: HTMLElement): HTMLElement {
|
||||
const host = document.createElement("div");
|
||||
|
||||
@@ -221,7 +221,10 @@ class Builder:
|
||||
"확인": sure,
|
||||
}
|
||||
|
||||
def build(self, checked: dict[int, dict[str, Any]], whole: list[dict[str, Any]]) -> dict:
|
||||
def build(
|
||||
self, checked: dict[int, dict[str, Any]], whole: list[dict[str, Any]], save: bool = True
|
||||
) -> dict:
|
||||
"""`save` 거짓 = 사전조합 안 씀 · `{문서, 빠진줄, 줄수}`(704-4 [구조물 조합으로])."""
|
||||
stamp = utc_now_text()
|
||||
hopyo, file = self.hopyo, self.doc.get("파일") or ""
|
||||
lines, mats, missing, used, mat_lines = [], {}, [], set(), {}
|
||||
@@ -329,12 +332,15 @@ class Builder:
|
||||
if nation:
|
||||
doc["본뜬"]["전국자재"] = list(nation.values())
|
||||
doc["본뜬"]["품셈"] = poomsem
|
||||
ref, again = _save(doc)
|
||||
wrote = ledger.write(self.folder, book, self.who, file or None)
|
||||
fixed_n = sum(1 for x in lines if "고정" in x)
|
||||
links = sum(1 for x in lines if any(((x.get("고정") or {}).get("이음") or {}).values()))
|
||||
count = {"품셈": len(poomsem), "고정": fixed_n, "호표": kid_n}
|
||||
count |= {"빠짐": sum(1 for x in missing if x["까닭"] != TWICE), "이음": links}
|
||||
if not save:
|
||||
ledger.write(self.folder, book, self.who, file or None)
|
||||
return {"문서": doc, "빠진줄": missing, "줄수": {**count, "이음후보": linker.near}}
|
||||
ref, again = _save(doc)
|
||||
wrote = ledger.write(self.folder, book, self.who, file or None)
|
||||
out = {"참조": ref, "다시씀": again, "빠진줄": missing, "장부": {"씀": wrote}}
|
||||
return {**out, "줄수": {**count, "이음후보": linker.near}}
|
||||
|
||||
@@ -462,7 +468,7 @@ def _order(units: dict[int, dict[str, Any]]) -> list[int]:
|
||||
def build_all(folder: Path, source: Path, key: str, who: Any = None) -> dict[str, Any]:
|
||||
"""[모두 고정으로 들여오기](705-3) — 시트 호표 모두 · 하위 먼저 · 줄 = 고정(하위 = 참조) ·
|
||||
이미 확인된 호표는 덮지 않음(`그대로`) → `{호표, 줄, 품셈, 고정, 호표참조, 빠짐, 이음, 이음후보,
|
||||
같음, 그대로, 다름 [{번, 이름, 엑셀, 우리, 차이}], 실패 [{번, 까닭}]}`."""
|
||||
같음, 그대로, 다름 [{번, 이름, 엑셀, 우리, 차이}], 실패 [{번, 까닭}], 만듦 [{번, 이름, 키}]}`."""
|
||||
units = {int(h["번"]): h for h in hopyo_store.read(source, key).get("호표") or []}
|
||||
sums = dict.fromkeys(("품셈", "고정", "호표", "빠짐", "이음", "이음후보"), 0)
|
||||
out: dict[str, Any] = {
|
||||
@@ -472,6 +478,7 @@ def build_all(folder: Path, source: Path, key: str, who: Any = None) -> dict[str
|
||||
"그대로": 0,
|
||||
"다름": [],
|
||||
"실패": [],
|
||||
"만듦": [],
|
||||
}
|
||||
for no in _order(units):
|
||||
out["줄"] += len(units[no].get("줄") or [])
|
||||
@@ -487,6 +494,7 @@ def build_all(folder: Path, source: Path, key: str, who: Any = None) -> dict[str
|
||||
for k in sums:
|
||||
sums[k] += made["줄수"][k]
|
||||
got = compare(ref, maker.hopyo)
|
||||
out["만듦"].append({"번": no, "이름": units[no].get("이름"), "키": ref["키"]})
|
||||
except store.StoreError as e:
|
||||
out["실패"].append({"번": no, "까닭": str(e.detail)})
|
||||
continue
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"""실무 호표 → 구조물 조합 항목 한 단계(PLAN 704-4 뒷부분 · `_제안_704-4_규격표.md` 6장 끝) — 사전조합 안 거침.
|
||||
|
||||
[구조물 조합으로] = 확인 창 몸(체크한 줄 · 통째 · `M02_Hopyo_Build.check` 그대로)으로 줄을 만들어(`build(save=False)` ·
|
||||
사전조합 SC 문서 안 씀 · 옛 [사전조합 생성] 길은 그대로) 이 층 구조물집계표에 새 항목(SU · `M02_Item_Personal.create_item`
|
||||
· 경로 「실무자료」) + 그 일위대가 문서에 줄을 담음(수량 = 1 단위 몫 `{손}`).
|
||||
규격 묶음 = 같은 파일에서 이름 · 단위 같고 줄 구성(줄 이름 · 단위 차례) 같은 · 규격만 다른 호표(5-A 「구성 같은 묶음」) →
|
||||
한 항목의 규격 열(704-4) — 열 이름 = 규격 글에서 달라지는 칸(「H=1.5」 → 이름 H · 열 1.5) · 아니면 이름 「규격」 ·
|
||||
열마다 같은 몸으로 만든 줄 수량(줄 차례 같음 · 다르면 400) · 기본 열 = 연 호표.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Build as hb
|
||||
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
|
||||
from M02_MasterTemplete import M02_Item_Personal as personal
|
||||
from M02_MasterTemplete import M02_Labor_Spec as spec
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
|
||||
PATH = ["실무자료"]
|
||||
_TOKEN = re.compile(r"[\s,]+")
|
||||
_PAIR = re.compile(r"^([A-Za-z가-힣Φφ]+)\s*[=:]?\s*([\d.]+)\D{0,3}$")
|
||||
|
||||
|
||||
def _flat(text: Any) -> str:
|
||||
return re.sub(r"\s+", "", str(text or ""))
|
||||
|
||||
|
||||
def _shape(h: dict[str, Any]) -> tuple:
|
||||
return tuple((_flat(x.get("이름")), _flat(x.get("단위"))) for x in h.get("줄") or [])
|
||||
|
||||
|
||||
def columns(specs: list[str]) -> tuple[str, list[str]]:
|
||||
"""규격 글들 → (규격 이름, 열 이름) — 처음 달라지는 칸이 모두 「H=1.5」 꼴이면 (H, [1.5 …]) ·
|
||||
아니면 (「규격」, 그 칸 글) · 20 자 자름 · 겹치면 뒤에 차례."""
|
||||
parts = [[t for t in _TOKEN.split(s.strip()) if t] for s in specs]
|
||||
width = max((len(p) for p in parts), default=0)
|
||||
at = next((i for i in range(width) if len({p[i] if i < len(p) else "" for p in parts}) > 1), 0)
|
||||
cells = [p[at] if at < len(p) else "" for p in parts]
|
||||
hits = [_PAIR.match(c) for c in cells]
|
||||
if all(hits) and len({h.group(1) for h in hits}) == 1:
|
||||
name, cols = hits[0].group(1), [h.group(2) for h in hits]
|
||||
else:
|
||||
name, cols = "규격", [c or s for c, s in zip(cells, specs, strict=True)]
|
||||
out: list[str] = []
|
||||
for c in cols:
|
||||
c = (c.strip() or "열")[: spec.NAME_MAX]
|
||||
while c in out:
|
||||
c = f"{c[: spec.NAME_MAX - 2]}·{len(out) + 1}"
|
||||
out.append(c)
|
||||
return name[: spec.NAME_MAX], out
|
||||
|
||||
|
||||
def siblings(source: Path, key: str, no: int) -> dict[str, Any]:
|
||||
"""규격 묶음 제안 — 같은 이름 · 단위 · 줄 구성 · 규격 다른 호표(연 호표 먼저) →
|
||||
`{묶음 [{번, 규격, 열}], 이름}` · 없으면 묶음 빈 것."""
|
||||
units = hopyo_store.read(source, key).get("호표") or []
|
||||
me = next((h for h in units if int(h["번"]) == int(no)), None)
|
||||
if me is None:
|
||||
raise store.StoreError(404, f"없는 호표 「제{no}호표」")
|
||||
same = [
|
||||
h
|
||||
for h in units
|
||||
if _flat(h.get("이름")) == _flat(me.get("이름"))
|
||||
and _flat(h.get("단위")) == _flat(me.get("단위"))
|
||||
and _shape(h) == _shape(me)
|
||||
]
|
||||
same.sort(key=lambda h: (int(h["번"]) != int(no), int(h["번"])))
|
||||
specs = list(dict.fromkeys(_flat(h.get("규격")) for h in same))
|
||||
if len(same) < 2 or len(specs) != len(same):
|
||||
return {"묶음": [], "이름": None}
|
||||
name, cols = columns([str(h.get("규격") or "") for h in same])
|
||||
rows = [
|
||||
{"번": int(h["번"]), "규격": h.get("규격") or "", "열": c}
|
||||
for h, c in zip(same, cols, strict=True)
|
||||
]
|
||||
return {"묶음": rows, "이름": name}
|
||||
|
||||
|
||||
def _doc(folder: Path, source: Path, key: str, no: int, lines: list, whole: Any, who: Any) -> dict:
|
||||
maker = hb.Builder(folder, source, key, no, who)
|
||||
checked, pairs = maker.check(list(lines or []), whole)
|
||||
return {**maker.build(checked, pairs, save=False), "호표": maker.hopyo}
|
||||
|
||||
|
||||
def make_item(
|
||||
folder: Path,
|
||||
source: Path,
|
||||
key: str,
|
||||
no: int,
|
||||
lines: list[dict[str, Any]] | None = None,
|
||||
whole: dict[str, Any] | None = None,
|
||||
bundle: list[int] | None = None,
|
||||
name: str | None = None,
|
||||
who: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
"""[구조물 조합으로] → `{key, 열, 이름, 줄수, 열수, 빠진줄}` — `bundle` = 규격 열로 묶을 호표 번(연 호표 빼고)."""
|
||||
main = _doc(folder, source, key, no, lines, whole, who)
|
||||
doc, hopyo = main["문서"], main["호표"]
|
||||
title = (name or "").strip() or doc["이름"]
|
||||
cols_doc = None
|
||||
if bundle:
|
||||
found = {r["번"]: r for r in siblings(source, key, no)["묶음"]}
|
||||
want = [int(no), *(int(b) for b in bundle if int(b) != int(no))]
|
||||
if any(b not in found for b in want):
|
||||
raise store.StoreError(400, "규격 묶음은 이름 · 단위 · 줄 구성이 같은 호표만")
|
||||
title = (name or "").strip() or str(hopyo.get("이름") or doc["이름"])
|
||||
qty: dict[str, dict[str, Any]] = {}
|
||||
for b in want:
|
||||
got = (
|
||||
main["문서"]
|
||||
if b == int(no)
|
||||
else _doc(folder, source, key, b, lines, whole, who)["문서"]
|
||||
)
|
||||
col = found[b]["열"]
|
||||
for part in ("담은로직", "자재"):
|
||||
mine, theirs = doc[part], got[part]
|
||||
if len(mine) != len(theirs):
|
||||
raise store.StoreError(400, f"제{b}호표 줄 구성이 다름 — 묶지 않고 따로 만듦")
|
||||
for one, other in zip(mine, theirs, strict=True):
|
||||
hand = (other.get("수량") or {}).get("손")
|
||||
if hand is not None:
|
||||
qty.setdefault(str(spec.line_key(one)), {})[col] = hand
|
||||
cols_doc = {
|
||||
"이름": siblings(source, key, no)["이름"],
|
||||
"열": [found[b]["열"] for b in want],
|
||||
"기본": found[int(no)]["열"],
|
||||
"수량": qty,
|
||||
}
|
||||
with store.at(folder):
|
||||
made = personal.create_item(folder, PATH, title, doc.get("단위"))
|
||||
have = store.read("labor", made["열"])
|
||||
body = {**have["문서"], "담은로직": doc["담은로직"], "자재": doc["자재"]}
|
||||
body["단위"] = doc.get("단위") or body.get("단위")
|
||||
if cols_doc:
|
||||
body["규격"] = cols_doc
|
||||
store.write("labor", made["열"], have["판"], body)
|
||||
count = {
|
||||
"줄수": len(doc["담은로직"]) + len(doc["자재"]),
|
||||
"열수": len(cols_doc["열"]) if cols_doc else 1,
|
||||
}
|
||||
return {**made, **count, "빠진줄": main["빠진줄"]}
|
||||
@@ -0,0 +1,295 @@
|
||||
"""M02 일위대가 엑셀 붙여넣기(PLAN 705-2 · `_분석_내역5-A` ⑤-2) — 붙인 글(탭 · 줄 나뉨)을 서버가 읽어
|
||||
실무자료 꼴(`M02_Hopyo_Excel.read_book` 과 같은 `{꼴, 시트, 호표, 단산, 검산}`)로 · 브라우저 숫자 안 받음.
|
||||
|
||||
열 짝 `열` = {품명, 규격, 수량, 단위, 금액, 노, 재, 경} → 칸 차례(0부터) — 노 · 재 · 경 = 단가 칸(다음 칸 = 금액) ·
|
||||
금액 = 합계 금액 칸(호표 1 단위 값 · 금액만 줄) · 합계 단가 = 금액 앞 칸 · 비고 = 경 + 2.
|
||||
머리 글 줄(「품명|명칭」 · 「수량」 · 「단위」)이 붙어 오면 그 줄로(물음 없음) · 없으면 받은 `열` ·
|
||||
둘 다 없으면 짐작(엑셀꼴 · STmate 꼴 차례) + `물음`(화면이 한 번 묻고 기억).
|
||||
호표 = 「제N호표」 칸이 있는 줄 — 같은 줄 품명 = 머리(엑셀꼴) · 품명 칸이 그 글이면 다음 줄이 머리(STmate 꼴) ·
|
||||
「제N호표」 없는 글 = 이름 없는 호표 하나(번 0 · 줄만).
|
||||
거름 = 머리 글 줄 · 「합계 · 계 · 소계 · 계약단가」 줄(STmate 합계 줄 금액 = 호표 1 단위 값) ·
|
||||
근거 줄(단위 · 단가 · 금액 빔 — 「건설표준품셈 | 6-7-2 | 0」) · 빈 줄.
|
||||
수 = 쉼표 · 빈칸 · 「-」(`M02_Hopyo_Excel._num`) · 갈래 = `M02_Hopyo_Excel.kind_of`(비고 「단산N참조」 · 「일위N참조」) ·
|
||||
단위 섞임(㎥ · M3)은 이음 쪽 `factor` 가 맞춤 · % 줄 = 단가 계 = 밑수(705-3 `M02_Hopyo_Fixed._ratio`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections import Counter
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Build as hb
|
||||
from M02_MasterTemplete import M02_Hopyo_Excel as hx
|
||||
from M02_MasterTemplete import M02_Hopyo_Fixed as hfix
|
||||
from M02_MasterTemplete import M02_Hopyo_Store as hstore
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
|
||||
FIELDS = ("품명", "규격", "수량", "단위", "금액", "노", "재", "경")
|
||||
TEXT_MAX = 400_000
|
||||
ROWS_MAX = 5000
|
||||
SHEET = "붙여넣기"
|
||||
_SKIP = ("합계", "계", "소계", "계약단가")
|
||||
_HEAD_NAMES = {"품명": ("품명", "명칭"), "규격": ("규격",), "수량": ("수량",), "단위": ("단위",)}
|
||||
_HEAD_COST = {"노": "노무비", "재": "재료비", "경": "경비"}
|
||||
|
||||
|
||||
class PasteError(ValueError):
|
||||
"""붙인 글이 비거나 꼴이 틀림 — 길이 400."""
|
||||
|
||||
|
||||
def cells(text: str) -> list[list[str]]:
|
||||
"""붙인 글 → 줄마다 칸(탭) — 엑셀 따옴표 칸(줄바꿈 든 칸)은 따옴표만 벗김 · 끝 빈 줄 뺌."""
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise PasteError("붙인 글이 빔")
|
||||
if len(text) > TEXT_MAX:
|
||||
raise PasteError("붙인 글이 너무 김")
|
||||
rows = [
|
||||
[c.strip().strip('"').strip() for c in line.split("\t")]
|
||||
for line in text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
||||
]
|
||||
while rows and not any(rows[-1]):
|
||||
rows.pop()
|
||||
if len(rows) > ROWS_MAX:
|
||||
raise PasteError(f"줄은 {ROWS_MAX} 개 안")
|
||||
return rows
|
||||
|
||||
|
||||
def _flat(value: Any) -> str:
|
||||
return re.sub(r"\s+", "", str(value or ""))
|
||||
|
||||
|
||||
def _header(rows: list[list[str]]) -> tuple[int, dict[str, int]] | None:
|
||||
"""머리 글 줄 → (줄 차례, 열 짝) — 품명 · 수량 · 단위 다 있는 줄 · 비목 = 그 줄(또는 다음 줄) 글."""
|
||||
for at, row in enumerate(rows[:20]):
|
||||
flat = [_flat(v) for v in row]
|
||||
cols = {k: next((i for i, v in enumerate(flat) if v in names), -1)
|
||||
for k, names in _HEAD_NAMES.items()} # fmt: skip
|
||||
if min(cols["품명"], cols["수량"], cols["단위"]) < 0:
|
||||
continue
|
||||
for k, word in _HEAD_COST.items():
|
||||
cols[k] = next((i for i, v in enumerate(flat) if v.startswith(word)), -1)
|
||||
total = next((i for i, v in enumerate(flat) if v == "합계"), -1)
|
||||
cols["금액"] = total + 1 if total >= 0 else -1
|
||||
return at, {k: v for k, v in cols.items() if v >= 0}
|
||||
return None
|
||||
|
||||
|
||||
def guess(rows: list[list[str]]) -> dict[str, int]:
|
||||
"""머리 없는 글 열 짐작 — 「제N호표」 칸이 따로면(엑셀꼴 B 열부터) 품명 = 그 다음 칸 · 아니면 첫 칸 ·
|
||||
차례 = 품명 · 규격 · 수량 · 단위 · 합계 단가 / 금액 · 노 · 재 · 경(단가 / 금액 쌍)."""
|
||||
marks = Counter(i for row in rows for i, v in enumerate(row) if hx._HOPYO.fullmatch(_flat(v)))
|
||||
first = marks.most_common(1)[0][0] if marks else 0
|
||||
beside = any(
|
||||
hx._HOPYO.fullmatch(_flat(r[first])) and len(r) > first + 1 and r[first + 1].strip()
|
||||
for r in rows
|
||||
if len(r) > first
|
||||
)
|
||||
name = first + 1 if beside else first
|
||||
if not marks and rows and not any(r[0].strip() for r in rows if r) and len(rows[0]) > 1:
|
||||
name = 1 # 첫 칸이 모두 빔(호표 칸만 끼어 옴)
|
||||
return {"품명": name, "규격": name + 1, "수량": name + 2, "단위": name + 3, "금액": name + 5,
|
||||
"노": name + 6, "재": name + 8, "경": name + 10} # fmt: skip
|
||||
|
||||
|
||||
def check_cols(cols: Any) -> dict[str, int]:
|
||||
"""받은 열 짝 정리 — 품명 · 수량 · 단위 있어야 · 칸 차례 0~60 · 같은 칸 둘 = 400."""
|
||||
if not isinstance(cols, dict):
|
||||
raise PasteError("열 짝은 {품명, 규격, …: 칸 차례}")
|
||||
out = {}
|
||||
for k in FIELDS:
|
||||
v = cols.get(k)
|
||||
if v is None or v == "":
|
||||
continue
|
||||
if isinstance(v, bool) or not isinstance(v, int) or not 0 <= v <= 60:
|
||||
raise PasteError(f"열 짝 「{k}」 칸 차례가 틀림")
|
||||
out[k] = v
|
||||
if any(k not in out for k in ("품명", "수량", "단위")):
|
||||
raise PasteError("열 짝에 품명 · 수량 · 단위는 꼭")
|
||||
if len(set(out.values())) != len(out):
|
||||
raise PasteError("열 짝에 같은 칸이 둘")
|
||||
return out
|
||||
|
||||
|
||||
def _money_cols(cols: dict[str, int]) -> dict[str, int]:
|
||||
out = {"노무비": cols.get("노"), "재료비": cols.get("재"), "경비": cols.get("경")}
|
||||
if cols.get("금액") is not None:
|
||||
out["합계"] = cols["금액"] - 1
|
||||
return {k: v for k, v in out.items() if v is not None and v >= 0}
|
||||
|
||||
|
||||
def _cell(row: list[str], col: int | None) -> str:
|
||||
return row[col] if col is not None and 0 <= col < len(row) else ""
|
||||
|
||||
|
||||
def _hopyo_at(row: list[str]) -> tuple[int, int] | None:
|
||||
"""(번, 칸) — 줄에 「제N호표」 칸."""
|
||||
for i, v in enumerate(row):
|
||||
hit = hx._HOPYO.fullmatch(_flat(v))
|
||||
if hit:
|
||||
return int(hit.group(1)), i
|
||||
return None
|
||||
|
||||
|
||||
def _money(row: list[str], money: dict[str, int], offset: int) -> dict[str, Any]:
|
||||
return hx._money(row, money, offset)
|
||||
|
||||
|
||||
def read_text(text: str, cols: Any = None) -> dict[str, Any]:
|
||||
"""붙인 글 → `{꼴, 시트, 호표, 단산, 검산, 열, 짐작, 물음}` — `물음` = 머리 없음 · 열 짝 안 받음
|
||||
(짐작으로 읽은 결과 · 화면이 열 짝을 묻고 다시 부름)."""
|
||||
rows = cells(text)
|
||||
head = _header(rows)
|
||||
start, asked = 0, False
|
||||
if head is not None:
|
||||
start, used = head[0] + 1, head[1]
|
||||
below = [_flat(v) for v in rows[start]] if start < len(rows) else []
|
||||
start += "단가" in below or "금액" in below
|
||||
elif cols is not None:
|
||||
used = check_cols(cols)
|
||||
else:
|
||||
used, asked = guess(rows), True
|
||||
money = _money_cols(used)
|
||||
note = (used["경"] + 2) if used.get("경") is not None else None
|
||||
hopyos: list[dict[str, Any]] = []
|
||||
lines: list[list[dict[str, Any]]] = []
|
||||
want_head = False
|
||||
|
||||
def open_hopyo(no: int, row: list[str] | None) -> None:
|
||||
hopyos.append({"번": no, "이름": "", "규격": "", "단위": "", "금액": {}, "행": [0, 0]})
|
||||
lines.append([])
|
||||
if row is not None:
|
||||
fill(row)
|
||||
|
||||
def fill(row: list[str]) -> None:
|
||||
hopyos[-1].update(
|
||||
이름=_cell(row, used["품명"]),
|
||||
규격=_cell(row, used.get("규격")),
|
||||
단위=_cell(row, used["단위"]),
|
||||
금액=_money(row, money, 1),
|
||||
)
|
||||
|
||||
for at, row in enumerate(rows[start:], start + 1):
|
||||
if not any(row):
|
||||
continue
|
||||
hit = _hopyo_at(row)
|
||||
name = _cell(row, used["품명"])
|
||||
if hit:
|
||||
same = hit[1] != used["품명"] and name
|
||||
open_hopyo(hit[0], row if same else None)
|
||||
hopyos[-1]["행"] = [at, at]
|
||||
want_head = not same
|
||||
continue
|
||||
if want_head:
|
||||
if name:
|
||||
fill(row)
|
||||
want_head = False
|
||||
continue
|
||||
flat = _flat(name)
|
||||
if (
|
||||
not flat
|
||||
or flat in ("품명", "명칭")
|
||||
or any(flat == w for w in _SKIP)
|
||||
or flat.startswith("합계")
|
||||
):
|
||||
if (
|
||||
hopyos
|
||||
and (flat.startswith("합계") or flat == "계")
|
||||
and not hopyos[-1]["금액"].get("계")
|
||||
):
|
||||
hopyos[-1]["금액"] = _money(row, money, 1) # STmate 합계 줄 = 1 단위 값
|
||||
continue
|
||||
price, amount = _money(row, money, 0), _money(row, money, 1)
|
||||
unit = _cell(row, used["단위"])
|
||||
qty = hx._num(_cell(row, used["수량"]))
|
||||
if not unit and not any(v for v in (*price.values(), *amount.values())):
|
||||
continue # 근거 줄 「건설표준품셈 | 6-7-2 | 0」
|
||||
if not hopyos: # 첫 줄 수량 없음 = 호표 머리(「제N호표」 칸 없이 복사)
|
||||
open_hopyo(0, row if qty is None else None)
|
||||
if qty is None:
|
||||
continue
|
||||
remark = _cell(row, note)
|
||||
ref = hx._REF.match(_flat(remark))
|
||||
line = {
|
||||
"차례": len(lines[-1]),
|
||||
"이름": name,
|
||||
"규격": _cell(row, used.get("규격")),
|
||||
"수량": qty,
|
||||
"단위": unit,
|
||||
"단가": price,
|
||||
"금액": amount,
|
||||
"비고": remark,
|
||||
"참조": {"종류": "단산" if ref.group(1) else "일위", "번": int(ref.group(3))}
|
||||
if ref
|
||||
else None,
|
||||
"행": at,
|
||||
}
|
||||
line["갈래"] = hx.kind_of(line)
|
||||
hopyos[-1]["행"][1] = at
|
||||
lines[-1].append(line)
|
||||
if not any(lines):
|
||||
raise PasteError("붙인 글에 일위대가 줄이 없음 — 품명 · 수량 · 단위 열을 확인")
|
||||
for hopyo, mine in zip(hopyos, lines, strict=True):
|
||||
if not hopyo["이름"]:
|
||||
hopyo["이름"] = "붙여넣기" if hopyo["번"] == 0 else f"제{hopyo['번']}호표"
|
||||
if not hopyo["금액"].get(
|
||||
"계"
|
||||
): # 머리 · 합계 줄 금액 없음 = 줄 금액 합(엑셀 끝수 · 원 내림)
|
||||
sums = {
|
||||
k: sum((x["금액"].get(k) or Decimal(0) for x in mine), Decimal(0)) for k in hx.COSTS
|
||||
}
|
||||
hopyo["금액"] = {**{k: hx._floor(v, 0) for k, v in sums.items()},
|
||||
"계": sum(hx._floor(v, 0) for v in sums.values())} # fmt: skip
|
||||
out = [
|
||||
{**hx._json(h), "줄": [hx._json(x) for x in mine]}
|
||||
for h, mine in zip(hopyos, lines, strict=True)
|
||||
if mine
|
||||
]
|
||||
return {
|
||||
"꼴": SHEET,
|
||||
"시트": SHEET,
|
||||
"호표": out,
|
||||
"단산": [],
|
||||
"검산": {"어긋남": [], "총괄표": 0},
|
||||
"열": used,
|
||||
"짐작": head is None and cols is None,
|
||||
"물음": asked,
|
||||
}
|
||||
|
||||
|
||||
def fixed_lines(parsed: dict[str, Any], taken: Any = ()) -> dict[str, Any]:
|
||||
"""줄만(M02 열린 문서 · B09 지금 호표) — 호표 모두의 줄 → 고정 줄(705-3 `line_of` · 하위 호표 줄도
|
||||
금액 그대로) · `taken` = 문서에 이미 있는 고정 줄 가름(이름 끝 「 (2)」) →
|
||||
`{줄, 엑셀, 이음, 이음후보}`(엑셀 = 붙인 호표 1 단위 값 합)."""
|
||||
linker, have, out = hfix.Linker(), {str(x) for x in taken}, []
|
||||
for h in parsed["호표"]:
|
||||
where = {"파일": SHEET, "호표": h["번"]}
|
||||
for line in h["줄"]:
|
||||
others = [x for x in h["줄"] if x is not line]
|
||||
out.append(hfix.line_of(line, others, where, have, linker))
|
||||
excel = sum((h["금액"].get("계") or 0) for h in parsed["호표"])
|
||||
links = sum(1 for x in out if any(x["고정"]["이음"].values()))
|
||||
return {"줄": out, "엑셀": excel, "이음": links, "이음후보": linker.near}
|
||||
|
||||
|
||||
def paste(
|
||||
folder: Any, text: str, cols: Any, who: Any, lines_only: bool = False, taken: Any = ()
|
||||
) -> dict[str, Any]:
|
||||
"""[Ctrl+V] — `갈래` 「물음」(열 짝 짐작 + 보기 · 안 씀) · 「줄」(호표 머리 없음 · `lines_only`) ·
|
||||
「만듦」(호표 머리 있음 = 실무자료 「붙여넣기 …」 + 호표마다 사전조합 · 705-3 `build_all` 그대로)."""
|
||||
try:
|
||||
parsed = read_text(text, cols)
|
||||
except PasteError as error:
|
||||
raise store.StoreError(400, str(error)) from error
|
||||
head = {"열": parsed["열"], "짐작": parsed["짐작"]}
|
||||
if parsed["물음"]:
|
||||
look = [[c[:24] for c in row[:16]] for row in cells(text)[:8]]
|
||||
return {**head, "갈래": "물음", "보기": look}
|
||||
named = any(h["번"] or h["이름"] != SHEET for h in parsed["호표"])
|
||||
if lines_only or not named:
|
||||
return {**head, "갈래": "줄", **fixed_lines(parsed, taken)}
|
||||
keep = {k: parsed[k] for k in ("꼴", "시트", "호표", "단산", "검산")}
|
||||
row = hstore.save(folder, f"{SHEET} {datetime.now():%m-%d %H:%M}", keep, who)
|
||||
return {**head, "갈래": "만듦", "키": row["키"], **hb.build_all(folder, folder, row["키"], who)}
|
||||
@@ -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}
|
||||
@@ -25,8 +25,11 @@ from common_util.common_util_auth import verify_session
|
||||
from M02_MasterTemplete import M02_Hopyo_Build as hopyo_build
|
||||
from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel
|
||||
from M02_MasterTemplete import M02_Hopyo_Grid as hopyo_grid
|
||||
from M02_MasterTemplete import M02_Hopyo_Item as hopyo_item
|
||||
from M02_MasterTemplete import M02_Hopyo_Ledger as hopyo_ledger
|
||||
from M02_MasterTemplete import M02_Hopyo_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
|
||||
@@ -442,3 +445,101 @@ def hopyo_delete(
|
||||
folder = _root(session, layer, owner, write=True)
|
||||
_call(folder, hopyo_store.delete, folder, key)
|
||||
return {"지움": key}
|
||||
|
||||
|
||||
class PasteBody(_Strict):
|
||||
"""엑셀 붙여넣기(705-2) — `글` = 붙인 글 그대로 · `열` = 기억한 열 짝(없으면 머리 글 · 짐작 + 물음) ·
|
||||
`줄만` = 열린 문서에 줄로 · `있음` = 문서의 고정 줄 가름(이름 겹침 피함)."""
|
||||
|
||||
글: str = Field(max_length=hopyo_paste.TEXT_MAX)
|
||||
열: dict[str, int] | None = None
|
||||
줄만: bool = False
|
||||
있음: list[str] = Field(default_factory=list, max_length=500)
|
||||
|
||||
|
||||
@router.post("/layers/{layer}/hopyo-paste")
|
||||
def hopyo_paste_run(
|
||||
layer: Layer,
|
||||
body: PasteBody,
|
||||
owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
) -> dict:
|
||||
"""[Ctrl+V] — `갈래` 물음(열 짝) · 줄(고정 줄 · 저장은 문서 [저장]) · 만듦(실무자료 + 호표마다 사전조합 ·
|
||||
층 쓰기 · `build_all` 답 + 키)."""
|
||||
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.바꿈)
|
||||
|
||||
|
||||
@router.get("/layers/{layer}/hopyo/{key}/{no}/siblings")
|
||||
def hopyo_siblings(
|
||||
layer: Layer,
|
||||
key: str,
|
||||
no: int,
|
||||
owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
) -> dict:
|
||||
"""규격 묶음 제안(704-4) — 이름 · 단위 · 줄 구성 같고 규격만 다른 호표 `{묶음 [{번, 규격, 열}], 이름}`."""
|
||||
folder = _root(session, layer, owner)
|
||||
return _call(folder, hopyo_item.siblings, folder, key, no)
|
||||
|
||||
|
||||
class ItemBody(BuildBody):
|
||||
"""[구조물 조합으로](704-4) — 확인 창 몸 그대로 + `묶음` = 규격 열로 묶을 호표 번 · `이름` = 항목 이름."""
|
||||
|
||||
묶음: list[int] = Field(default_factory=list, max_length=20)
|
||||
이름: str | None = Field(None, max_length=120)
|
||||
|
||||
|
||||
@router.post("/layers/{layer}/hopyo-item")
|
||||
def hopyo_to_item(
|
||||
layer: Layer,
|
||||
body: ItemBody,
|
||||
owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
) -> dict:
|
||||
"""실무 호표 → 이 층 구조물 조합 항목(사전조합 안 만듦 · 이 층 쓰기 · 파일 층 읽기) →
|
||||
`{key, 열, 이름, 줄수, 열수, 빠진줄}`."""
|
||||
folder = _root(session, layer, owner, write=True)
|
||||
source = _root(session, body.층, body.주인)
|
||||
lines = [one.model_dump(exclude_none=True) for one in body.줄]
|
||||
whole = body.통째.model_dump(exclude_none=True) if body.통째 else None
|
||||
args = (folder, source, body.키, body.번, lines, whole, body.묶음, body.이름, _who(session))
|
||||
return _call(folder, hopyo_item.make_item, *args)
|
||||
|
||||
@@ -16,6 +16,7 @@ from decimal import Decimal, InvalidOperation
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Labor_Fixed as fixed
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
|
||||
NAME_MAX = 20 # 규격 이름 · 열 이름 글자 수 상한 — 화면 칸과 같음
|
||||
@@ -46,7 +47,10 @@ def _text(raw: Any, what: str) -> str:
|
||||
|
||||
|
||||
def line_key(one: dict) -> Any:
|
||||
"""줄 id — 참조 줄 = `ref_id` · 로직 줄 = 로직 키 · 자재 줄 = 코드."""
|
||||
"""줄 id — 참조 줄 = `ref_id` · 고정 줄 = `fixed_id`(705 · 짝 TS `lineId`) · 로직 줄 = 로직 키 ·
|
||||
자재 줄 = 코드."""
|
||||
if fixed.is_fixed(one):
|
||||
return fixed.fixed_id(one[fixed.FIXED])
|
||||
if isinstance(one.get("참조"), dict):
|
||||
return refs.ref_id(one["참조"])
|
||||
return one.get("로직") or one.get("코드")
|
||||
|
||||
@@ -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,104 @@ 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({ 바꿈 }) });
|
||||
|
||||
/** 규격 묶음 제안(704-4) — 이름 · 단위 · 줄 구성 같고 규격만 다른 호표(연 호표 먼저) */
|
||||
export const hopyoSiblings = (
|
||||
scope: HopyoScope,
|
||||
key: string,
|
||||
no: number,
|
||||
): Promise<{ 묶음: { 번: number; 규격: string; 열: string }[]; 이름: string | null }> =>
|
||||
call(at(scope, `/hopyo/${enc(key)}/${no}/siblings`));
|
||||
|
||||
/** [구조물 조합으로](704-4) — 확인 창 몸 그대로 → 이 층 구조물 조합 항목(사전조합 안 만듦) */
|
||||
export const buildHopyoItem = (
|
||||
target: HopyoScope,
|
||||
from: { 층: Layer; 주인: string | null; 키: string; 번: number },
|
||||
줄: BuildLine[],
|
||||
통째: { 대상: BuildTarget[] } | null,
|
||||
묶음: number[],
|
||||
이름: string,
|
||||
): Promise<{ key: string; 열: string; 이름: string; 줄수: number; 열수: number }> =>
|
||||
call(at(target, "/hopyo-item"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ...from, ...(통째 ? { 통째 } : { 줄 }), 묶음, 이름 }),
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
* · 705-3 — 체크 안 한 줄 = 고정 줄(엑셀 숫자 그대로) · 만든 사전조합이 있으면 첫 체크 = 그 품셈 줄(`만든.품셈`) ·
|
||||
* [품셈으로 바꾸기 (n)] = 맞음 · 별칭 줄 체크 + 생성 · [모두 고정으로] = 체크 모두 끔 + 생성(되돌리기) ·
|
||||
* 생성 물음에 전후 금액(품셈 = 우리 미리 셈 · 고정 = 엑셀) · 품 다른 줄 수.
|
||||
* · 704-4 — [구조물 조합으로] = 같은 몸으로 구조물 조합 항목 바로(`Hopyo_ToItem` · 규격 묶음 제안)
|
||||
* 계산은 서버(`M02_Hopyo_Match` · `M02_Hopyo_Build` · `/labor/{열}/calc`) — 화면은 보이기 · 체크 · 고름만.
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -49,6 +50,7 @@ import type { HopyoSheetOptions } from "./M02_MasterTemplete_Hopyo_Sheet";
|
||||
import { calcLabor, fetchLogicInputs } from "./M02_MasterTemplete_Labor_Api";
|
||||
import { openLaborInput } from "./M02_MasterTemplete_Labor_Input";
|
||||
import { draftDoc } from "./logic/M02_MasterTemplete_Hopyo_Draft";
|
||||
import { toItem } from "./M02_MasterTemplete_Hopyo_ToItem";
|
||||
import "./M02_MasterTemplete_Hopyo.css";
|
||||
|
||||
export const POLL_MS = 3000;
|
||||
@@ -500,6 +502,15 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement {
|
||||
});
|
||||
}
|
||||
|
||||
const toItemButton = (h: HopyoOne["호표"], whole: boolean): HTMLElement =>
|
||||
createButton({
|
||||
label: "구조물 조합으로",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
void toItem({ scope: opt.scope, target, key: opt.key, no: opt.no, hopyo: h,
|
||||
lines: whole ? [] : bodyOf(h.줄, d), whole: whole ? { 대상: h.통째!.대상.map(toTarget) } : null }), // prettier-ignore
|
||||
});
|
||||
|
||||
function draw(): void {
|
||||
if (!got) return;
|
||||
const h = got.호표;
|
||||
@@ -545,7 +556,7 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement {
|
||||
});
|
||||
tools.push(poom, back);
|
||||
}
|
||||
tools.push(go);
|
||||
tools.push(go, toItemButton(h, whole)); // 704-4 — 사전조합 안 거치고 구조물 조합
|
||||
}
|
||||
const head = el("div", {
|
||||
className: "ui-toolbar m02-hopyo__bar",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_ToItem.ts
|
||||
* [구조물 조합으로](PLAN 704-4 뒷부분) — 실무자료 확인 창 몸 그대로 구조물 조합 항목(SU)을 바로 만듦(사전조합 안 거침).
|
||||
* 창 = 항목 이름(기본 = 호표 이름 · 규격) · 규격 묶음 제안(같은 이름 · 줄 구성 · 규격만 다른 호표 → 한 항목의 규격 열 ·
|
||||
* 기본 켬 · 끄면 이 호표 하나) · [만들기] → 서버 `/hopyo-item`(이 층 쓰기 · 「실무자료」 폴더) → 나무 다시
|
||||
* 숫자는 서버가 파일에서 — 화면은 체크 · 고름만
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
createButton,
|
||||
el,
|
||||
hideLoadingOverlay,
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { createCheckbox } from "@ui/ui_template_checkbox";
|
||||
import { createInputField } from "@ui/ui_template_input";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import {
|
||||
buildHopyoItem,
|
||||
hopyoSiblings,
|
||||
type BuildLine,
|
||||
type BuildTarget,
|
||||
type HopyoOne,
|
||||
type HopyoScope,
|
||||
} from "./M02_MasterTemplete_Hopyo_Api";
|
||||
|
||||
/** 항목을 만들었음(document 알림) — 구조물 조합 나무 · 옆 목록 다시 */
|
||||
export const ITEM_BUILT = "m02-hopyo-item-built";
|
||||
|
||||
export interface ToItemOptions {
|
||||
scope: HopyoScope;
|
||||
target: HopyoScope;
|
||||
key: string;
|
||||
no: number;
|
||||
hopyo: HopyoOne["호표"];
|
||||
lines: BuildLine[];
|
||||
whole: { 대상: BuildTarget[] } | null;
|
||||
}
|
||||
|
||||
export async function toItem(o: ToItemOptions): Promise<void> {
|
||||
let sib: Awaited<ReturnType<typeof hopyoSiblings>>;
|
||||
try {
|
||||
sib = await hopyoSiblings(o.scope, o.key, o.no);
|
||||
} catch (error) {
|
||||
showToast(
|
||||
`묶음 제안 실패 — ${error instanceof Error ? error.message : String(error)}`,
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const h = o.hopyo;
|
||||
const one = [h.이름, h.규격].filter(Boolean).join(" ");
|
||||
const bundle = sib.묶음.length > 1;
|
||||
const name = createInputField({ label: "구조물 조합 이름", value: bundle ? h.이름 : one });
|
||||
const pack = bundle
|
||||
? createCheckbox(
|
||||
`규격 열로 묶음 — ${sib.이름}: ${sib.묶음.map((r) => `${r.열}(제${r.번}호표)`).join(" · ")}`,
|
||||
{
|
||||
checked: true,
|
||||
onChange: (on) => (name.input.value = on ? h.이름 : one),
|
||||
},
|
||||
)
|
||||
: null;
|
||||
openModal({
|
||||
title: `구조물 조합으로 — 제${o.no}호표`,
|
||||
closeLabel: "취소",
|
||||
width: "40rem",
|
||||
mount: (body, close, actions) => {
|
||||
const go = createButton({
|
||||
label: "만들기",
|
||||
onClick: async () => {
|
||||
const label = name.input.value.trim();
|
||||
if (!label) return name.setError("이름이 빔");
|
||||
const from = { 층: o.scope.layer, 주인: o.scope.owner, 키: o.key, 번: o.no };
|
||||
const with_ = pack?.input.checked
|
||||
? sib.묶음.map((r) => r.번).filter((n) => n !== o.no)
|
||||
: [];
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
const got = await buildHopyoItem(o.target, from, o.lines, o.whole, with_, label);
|
||||
close();
|
||||
document.dispatchEvent(new Event(ITEM_BUILT));
|
||||
showToast(
|
||||
`구조물 조합 「${got.이름}」 만듦 — 「실무자료」 폴더 · 줄 ${got.줄수}` +
|
||||
(got.열수 > 1 ? ` · 규격 열 ${got.열수}` : ""),
|
||||
"success",
|
||||
6000,
|
||||
);
|
||||
} catch (error) {
|
||||
showToast(
|
||||
`못 만듦 — ${error instanceof Error ? error.message : String(error)}`,
|
||||
"error",
|
||||
);
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
},
|
||||
});
|
||||
actions.append(go);
|
||||
body.append(
|
||||
createText(
|
||||
"체크한 줄 = 품셈 줄 · 나머지 = 고정 줄(엑셀 숫자 그대로) — 사전조합을 거치지 않고 구조물 조합 항목을 바로 만듦",
|
||||
"muted",
|
||||
),
|
||||
name.root,
|
||||
...(pack ? [el("div", { children: [pack.root] })] : []),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
} from "./M02_MasterTemplete_TreePanel_Roots";
|
||||
import { canEdit, createMain, newItemButton, type Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
import { buildSide } from "./M02_MasterTemplete_UI_Side";
|
||||
import { ITEM_BUILT } from "./M02_MasterTemplete_Hopyo_ToItem";
|
||||
import { precomboList, type PrecomboScope } from "./M02_MasterTemplete_UI_Side_Precombo";
|
||||
import { buildTwoWay, pickedName } from "./M02_MasterTemplete_ItemPick";
|
||||
import "./M02_MasterTemplete_UI_Style.css";
|
||||
@@ -156,6 +157,12 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
|
||||
go: (next) => void go("personal", next, open),
|
||||
})
|
||||
: panel;
|
||||
// 704-4 [구조물 조합으로] 만든 항목 → 나무 · 옆 목록 다시(페이지가 내려가면 듣기 끝)
|
||||
const onItem = (): void =>
|
||||
tree.root.isConnected
|
||||
? void Promise.all([side.refresh(), tree.refresh()])
|
||||
: document.removeEventListener(ITEM_BUILT, onItem);
|
||||
document.addEventListener(ITEM_BUILT, onItem);
|
||||
const main = createMain(
|
||||
isAdmin,
|
||||
() => void Promise.all([side.refresh(), tree.refresh()]),
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
"""705-2 엑셀 붙여넣기 — 붙인 글(탭 · 줄)을 서버가 읽어 고정 줄 · 호표마다 사전조합(요구서 3장 10 · 5-A ⑤-2).
|
||||
|
||||
- 실무 xlsx 넷(거창 · 영월 · 울진 대흥 · 소광) 일위대가 시트를 엑셀 보이는 글(쉼표 · 0 빈칸)로 복사한 꼴
|
||||
→ 시트 통째 = 파일 올리기와 호표 · 줄 · 합계 같음 · 만듦 = 엑셀 합계 = 우리 합계
|
||||
- 호표 하나 · 머리 글 없음(열 짝 짐작 + 물음) · 기억한 열 짝 · 줄만(열린 문서) · 합계 · 근거 줄 거름 · % 줄
|
||||
층은 임시 폴더(공용 storage 안 씀).
|
||||
"""
|
||||
|
||||
import json
|
||||
import warnings
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import openpyxl
|
||||
import pytest
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Excel as hx
|
||||
from M02_MasterTemplete import M02_Hopyo_Paste as hp
|
||||
|
||||
# ruff: noqa: F811
|
||||
from resources.tester.test_m02_hopyo_excel import client, master_hash # noqa: F401
|
||||
|
||||
URL = "/api/m02/layers/personal/hopyo-paste"
|
||||
DOCS = Path(__file__).resolve().parents[2] / "resources" / "knowledge" / "original" / "실무문서"
|
||||
FILES = { # 이름 · 엑셀 합계 = 우리 합계 수(영월 23 — 붙인 글엔 하위 호표 코드가 없어 엑셀 값 그대로 · 파일은 22)
|
||||
"거창": (
|
||||
"01.산불거창(거창북상병곡)/01.내역서/00.25년_산불_거창(거창,북상,병곡,산8외)내역+수량.xlsx",
|
||||
14,
|
||||
),
|
||||
"영월": ("05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.xlsx", 23),
|
||||
"대흥": (
|
||||
"2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/01 내역자료/2024년 간선임도사업(기번3).xlsx",
|
||||
34,
|
||||
),
|
||||
"소광": (
|
||||
"25년 산불진화임도(기번8)(울진소광)/"
|
||||
"2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외).xlsx",
|
||||
25,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _shown(v):
|
||||
"""엑셀이 클립보드에 내는 글 — 천 단위 쉼표 · 0 = 빈칸(STmate 표시꼴) · 줄바꿈 = 띄움."""
|
||||
if v is None or v == 0:
|
||||
return ""
|
||||
if isinstance(v, (int, float)):
|
||||
return f"{int(v):,}" if float(v).is_integer() else f"{v:,.6f}".rstrip("0").rstrip(".")
|
||||
return str(v).replace("\n", " ")
|
||||
|
||||
|
||||
def _rows(tag):
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore")
|
||||
book = openpyxl.load_workbook(DOCS / FILES[tag][0], read_only=True, data_only=True)
|
||||
sheet = "일위대가" if "일위대가" in book.sheetnames else "일위대가표"
|
||||
first = 1 if sheet == "일위대가" else 0 # 엑셀꼴 = B 열(호표)부터 · STmate = A 열부터
|
||||
width = 14 if first else 13
|
||||
return [
|
||||
[_shown(v) for v in list(r)[first : first + width]]
|
||||
for r in book[sheet].iter_rows(values_only=True)
|
||||
]
|
||||
|
||||
|
||||
def _text(rows):
|
||||
return "\n".join("\t".join(r) for r in rows)
|
||||
|
||||
|
||||
def _paste(client, text, **body):
|
||||
res = client.post(URL, json={"글": text, **body})
|
||||
assert res.status_code == 200, res.text
|
||||
return res.json()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tag", list(FILES))
|
||||
def test_시트_통째_붙여넣기_파일과_같음(tag):
|
||||
got = hp.read_text(_text(_rows(tag)))
|
||||
book = hx.read_book(FILES[tag][0], (DOCS / FILES[tag][0]).read_bytes())
|
||||
assert got["물음"] is False # 머리 글이 붙어 옴 = 열 짝 저절로
|
||||
key = lambda h: (h["번"], h["이름"], h["규격"], h["단위"], h["금액"].get("계"), len(h["줄"])) # noqa: E731
|
||||
assert [key(h) for h in got["호표"]] == [key(h) for h in book["호표"]]
|
||||
zero = lambda m: {k: round(v or 0, 4) for k, v in m.items()} # noqa: E731 · 엑셀 0 = 빈칸으로 옴
|
||||
line = lambda x: (x["이름"], x["수량"] or 0, x["단위"], zero(x["단가"]), zero(x["금액"])) # noqa: E731
|
||||
assert [line(x) for h in got["호표"] for x in h["줄"]] == [
|
||||
line(x) for h in book["호표"] for x in h["줄"]
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tag", list(FILES))
|
||||
def test_실무_xlsx_넷_붙여넣기_만듦_합계_같음(client, tag):
|
||||
before = master_hash()
|
||||
got = _paste(client, _text(_rows(tag)))
|
||||
assert got["갈래"] == "만듦" and got["빠짐"] == 0 and got["실패"] == []
|
||||
assert got["같음"] == FILES[tag][1] and all(abs(x["차이"]) <= 1 for x in got["다름"])
|
||||
assert len(got["만듦"]) == got["호표"] and got["고정"] + got["호표참조"] == got["줄"]
|
||||
assert master_hash() == before
|
||||
rows = client.get("/api/m02/layers/personal/hopyo").json()["줄"]
|
||||
assert rows[0]["파일"].startswith("붙여넣기") and rows[0]["시트"] == "붙여넣기"
|
||||
|
||||
|
||||
def _block(rows, no):
|
||||
"""호표 하나 줄 — 「제N호표」 줄부터 다음 호표 앞까지(머리 글 없이)."""
|
||||
at = next(i for i, r in enumerate(rows) if any(hx._HOPYO.fullmatch(c.replace(" ", "")) and int(hx._HOPYO.fullmatch(c.replace(" ", "")).group(1)) == no for c in r)) # fmt: skip
|
||||
end = next((i for i in range(at + 1, len(rows)) if any(hx._HOPYO.fullmatch(c.replace(" ", "")) for c in rows[i])), len(rows)) # fmt: skip
|
||||
return rows[at:end]
|
||||
|
||||
|
||||
def test_호표_하나_머리_없음_물음_뒤_기억한_열_짝(client):
|
||||
rows = _block(_rows("대흥"), 17) # 돌기슭막이(찰쌓기) H=1.5
|
||||
ask = _paste(client, _text(rows))
|
||||
assert ask["갈래"] == "물음" and ask["짐작"] is True and ask["보기"]
|
||||
cols = ask["열"]
|
||||
assert (cols["품명"], cols["수량"], cols["단위"], cols["노"]) == (0, 2, 3, 6)
|
||||
got = _paste(client, _text(rows), 열=cols) # 화면이 한 번 묻고 기억한 열 짝
|
||||
assert got["갈래"] == "만듦" and got["호표"] == 1 and got["같음"] == 1
|
||||
assert got["만듦"][0]["이름"].startswith("돌기슭막이")
|
||||
|
||||
|
||||
def test_엑셀꼴_호표_여럿_머리_없음(client):
|
||||
rows = _rows("거창")
|
||||
some = _block(rows, 2) + _block(rows, 3)
|
||||
cols = hp.read_text(_text(some))["열"]
|
||||
assert cols["품명"] == 1 # 「제N호표」 칸이 따로 = 그 다음 칸이 품명
|
||||
got = _paste(client, _text(some), 열=cols)
|
||||
assert (got["호표"], got["같음"]) == (2, 2)
|
||||
|
||||
|
||||
def test_줄만_고정_줄_합계_근거_거름():
|
||||
text = "\n".join(
|
||||
[
|
||||
"석공\t\t0.08\t인\t268,908\t21,512.6\t268,908\t21,512.6\t\t\t\t\t",
|
||||
"보통인부\t\t0.04\t인\t169,804\t6,792.1\t169,804\t6,792.1\t\t\t\t\t",
|
||||
"공구손료\t노무비의\t3\t%\t28,304.7\t849.1\t\t\t\t\t849.1\t849.1\t",
|
||||
"건설표준품셈\t6-7-2\t\t\t\t\t\t\t\t\t\t\t",
|
||||
"합 계\t\t\t\t\t29,153\t\t28,304\t\t\t\t849\t",
|
||||
]
|
||||
)
|
||||
cols = {"품명": 0, "규격": 1, "수량": 2, "단위": 3, "금액": 5, "노": 6, "재": 8, "경": 10}
|
||||
parsed = hp.read_text(text, cols)
|
||||
(one,) = parsed["호표"]
|
||||
assert [x["이름"] for x in one["줄"]] == ["석공", "보통인부", "공구손료"] # 근거 · 합계 줄 뺌
|
||||
assert one["금액"]["계"] == 29153 and one["줄"][0]["수량"] == 0.08
|
||||
out = hp.fixed_lines(parsed, {"고정|석공||인"})
|
||||
names = [x["고정"]["이름"] for x in out["줄"]]
|
||||
assert names == ["석공 (2)", "보통인부", "공구손료"] # 있는 줄 가름 피함
|
||||
assert out["줄"][2]["고정"]["비율"] == {"기준": "노무비", "비목": "경비"}
|
||||
assert out["줄"][0]["고정"]["단가"]["노무비"] == 268908 and out["엑셀"] == 29153
|
||||
|
||||
|
||||
def test_줄만_길_저장_없음(client):
|
||||
text = "석공\t\t0.08\t인\t268,908\t21,512.6\t268,908\t21,512.6"
|
||||
cols = {"품명": 0, "규격": 1, "수량": 2, "단위": 3, "금액": 5, "노": 6, "재": 8, "경": 10}
|
||||
got = _paste(client, text, 열=cols, 줄만=True)
|
||||
assert got["갈래"] == "줄" and len(got["줄"]) == 1 and got["줄"][0]["수량"] == {"손": 0.08}
|
||||
assert client.get("/api/m02/layers/personal/hopyo").json()["줄"] == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("text", "cols", "word"),
|
||||
[
|
||||
("", None, "빔"),
|
||||
("가\t나", {"품명": 0, "수량": 0, "단위": 1}, "같은 칸"),
|
||||
("가\t나", {"품명": 0, "규격": 1}, "꼭"),
|
||||
("합계\t\t\t\t1,000", {"품명": 0, "수량": 2, "단위": 3, "금액": 4}, "줄이 없음"),
|
||||
],
|
||||
)
|
||||
def test_틀린_글_400(client, text, cols, word):
|
||||
res = client.post(URL, json={"글": text, **({"열": cols} if cols else {})})
|
||||
assert res.status_code == 400 and word in res.json()["detail"]
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ── B09 펼친 일위대가 붙여넣기(덮개 @add) ─────────────────────────────────────
|
||||
def _b09():
|
||||
from resources.tester import test_145_2_b09_labor_calc as b09
|
||||
|
||||
return b09
|
||||
|
||||
|
||||
def _added(user):
|
||||
from B09_Estimation import B09_Estimation_Daega_Paste as dp
|
||||
|
||||
text = "\n".join(
|
||||
[
|
||||
"석공\t\t0.08\t인\t258,935\t20,714.8\t258,935\t20,714.8\t\t\t\t\t",
|
||||
"잡재료\t\t1\t식\t1,000\t1,000\t\t\t1,000\t1,000\t\t\t",
|
||||
"공구손료\t노무비의\t3\t%\t20,714.8\t621.4\t\t\t\t\t621.4\t621.4\t",
|
||||
]
|
||||
)
|
||||
cols = {"품명": 0, "규격": 1, "수량": 2, "단위": 3, "금액": 5, "노": 6, "재": 8, "경": 10}
|
||||
return dp.paste(user, "C:DG9", text, cols)
|
||||
|
||||
|
||||
def test_B09_붙인_줄_거울_정리_수요():
|
||||
from B09_Estimation.B09_Estimation_Calc_Labor import ADD, added_keys, compute_labor
|
||||
from B09_Estimation.B09_Estimation_Store import clean_user
|
||||
|
||||
b09 = _b09()
|
||||
got = _added({})
|
||||
assert got["갈래"] == "줄" and got["줄"] == 3 and got["엑셀"] == 22335
|
||||
cover = got["user"]["daega_cover"]
|
||||
lines = cover["C:DG9"][ADD]
|
||||
assert [ln["kind"] for ln in lines] == ["fixed", "fixed", "pct"]
|
||||
assert all(ln["cid"].startswith("@add|고정|") for ln in lines)
|
||||
again = _added(got["user"]) # 같은 글 두 번 = 이름 「 (2)」 가름 · 줄 더함
|
||||
names = [ln["name"] for ln in again["user"]["daega_cover"]["C:DG9"][ADD]]
|
||||
assert names[3:] == ["석공 (2)", "잡재료 (2)", "공구손료 (2)"]
|
||||
assert clean_user(again["user"]) == again["user"] # 저장 정리 그대로
|
||||
bad = clean_user(
|
||||
{"daega_cover": {"C:x": {ADD: [{"cid": "x", "kind": "fixed"}, {"kind": "?"}]}}}
|
||||
)
|
||||
assert "daega_cover" not in bad # cid 머리 틀림 · 모르는 갈래 버림
|
||||
linked = {**lines[0], "link": {"labor": "L00005", "material": None, "machine": None}}
|
||||
cover = {"C:DG9": {ADD: [linked, *lines[1:]], linked["cid"]: {"qty": 0.1}}}
|
||||
assert added_keys(cover) == ["L00005"]
|
||||
blocks = json.loads(json.dumps(b09.EXCEL))
|
||||
for b in blocks["blocks"]:
|
||||
b["cover"] = f"C:{b['id']}"
|
||||
for n, ln in enumerate(b["lines"]):
|
||||
ln["cid"] = f"{ln['kind']}:{ln.get('key')}#{n}"
|
||||
py = json.loads(json.dumps(compute_labor(blocks, b09.SHEETS, b09.SANGEUN, cover)))
|
||||
ts = b09._ts(blocks, b09.SHEETS, b09.SANGEUN, cover)
|
||||
assert b09._same(py, ts["out"])
|
||||
dg9 = b09._block(py, "DG9")
|
||||
stone = next(ln for ln in dg9["lines"] if ln["cid"] == linked["cid"])
|
||||
assert stone["prices"]["labor"] == 258935 and stone["qty"] == 0.1 # 이은 단가 · 덮개
|
||||
pct = next(ln for ln in dg9["lines"] if ln["kind"] == "pct" and ln["cid"].startswith(ADD))
|
||||
assert pct["amounts"]["expense"] > 0 and py["daega_orphans"] == []
|
||||
|
||||
|
||||
# ── 704-4 뒷부분 — [구조물 조합으로] · 규격 묶음 ─────────────────────────────────
|
||||
def _upload(client, tag):
|
||||
from resources.tester.test_m02_hopyo_excel import _post
|
||||
|
||||
path = DOCS / FILES[tag][0]
|
||||
res = _post(client, data=path.read_bytes(), name=path.name)
|
||||
assert res.status_code == 200, res.text
|
||||
return res.json()["키"]
|
||||
|
||||
|
||||
def _item_doc(column):
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
|
||||
with store.at(layers.personal_dir(7, 42)):
|
||||
table = store.read("table", store.STRUCTURE_TABLE)["문서"]
|
||||
col = next(c for c in table["열"] if c.get("id") == column)
|
||||
return store.read("labor", column)["문서"], col
|
||||
|
||||
|
||||
def _total(doc, col=None):
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
from M02_MasterTemplete import M02_Labor_Spec as spec
|
||||
|
||||
doc = {**doc, "규격": {**doc["규격"], "기본": col}} if col else doc # 셈 = 기본 열
|
||||
return labor.calc(labor.clean(spec.at(doc, col)), {})["반영합"]["계"]
|
||||
|
||||
|
||||
def layers_dir():
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
|
||||
return layers.personal_dir(7, 42)
|
||||
|
||||
|
||||
def test_구조물_조합으로_규격_묶음_울진_돌기슭막이(client):
|
||||
before = master_hash()
|
||||
key = _upload(client, "대흥")
|
||||
sib = client.get(f"/api/m02/layers/personal/hopyo/{key}/17/siblings").json()
|
||||
assert sib["이름"] == "H" and [(r["번"], r["열"]) for r in sib["묶음"]] == [
|
||||
(17, "1.5"), (24, "2.0"), (25, "2.5")
|
||||
] # fmt: skip
|
||||
body = {"층": "personal", "키": key, "번": 17, "묶음": [24, 25]}
|
||||
res = client.post("/api/m02/layers/personal/hopyo-item", json=body)
|
||||
assert res.status_code == 200, res.text
|
||||
got = res.json()
|
||||
assert got["열수"] == 3 and got["이름"].endswith("돌기슭막이(찰쌓기,기초무,구입)")
|
||||
doc, col = _item_doc(got["열"])
|
||||
assert col["경로"] == ["실무자료", "돌기슭막이(찰쌓기,기초무,구입)"] and doc["규격"]["열"] == [
|
||||
"1.5",
|
||||
"2.0",
|
||||
"2.5",
|
||||
]
|
||||
assert doc["규격"]["기본"] == "1.5" and all("고정" in x for x in doc["담은로직"])
|
||||
excel = {
|
||||
h["번"]: h["금액"]["계"]
|
||||
for h in client.get(f"/api/m02/layers/personal/hopyo/{key}").json()["호표"]
|
||||
if h["번"] in (17, 24, 25)
|
||||
}
|
||||
for no, c in ((17, "1.5"), (24, "2.0"), (25, "2.5")):
|
||||
assert abs(_total(doc, c) - excel[no]) <= excel[no] * 1e-4 # 열마다 = 그 호표 엑셀 합계(끝수 차)
|
||||
one = client.post("/api/m02/layers/personal/hopyo-item", json={**body, "묶음": []}).json()
|
||||
single, _ = _item_doc(one["열"])
|
||||
assert not single.get("규격") and one["이름"].endswith("H=1.5 L3=45cm")
|
||||
bad = client.post("/api/m02/layers/personal/hopyo-item", json={**body, "묶음": [3]})
|
||||
assert bad.status_code == 400 # 줄 구성 다른 호표
|
||||
precombo = layers_dir() / "precombo"
|
||||
assert not list(precombo.glob("SC-*.json")) # 사전조합 문서 안 만듦
|
||||
assert master_hash() == before
|
||||
|
||||
|
||||
def test_구조물_조합으로_남의_층_403(client):
|
||||
key = _upload(client, "대흥")
|
||||
body = {"층": "personal", "키": key, "번": 17}
|
||||
assert client.post("/api/m02/layers/company/hopyo-item", json=body).status_code == 403
|
||||
|
||||
|
||||
def test_규격_열_이름():
|
||||
from M02_MasterTemplete import M02_Hopyo_Item as item
|
||||
|
||||
assert item.columns(["H=1.5 L3=45cm", "H=2.0 L3=45cm"]) == ("H", ["1.5", "2.0"])
|
||||
assert item.columns(["일반형", "ㄷ형"]) == ("규격", ["일반형", "ㄷ형"])
|
||||
assert item.columns(["D800", "D1000"]) == ("D", ["800", "1000"])
|
||||
@@ -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