diff --git a/B09_Estimation/B09_Estimation_UI_Store.ts b/B09_Estimation/B09_Estimation_UI_Store.ts index 81c62c5c6..893fc0ffc 100644 --- a/B09_Estimation/B09_Estimation_UI_Store.ts +++ b/B09_Estimation/B09_Estimation_UI_Store.ts @@ -1,6 +1,7 @@ /* ============================================================================= * B09_Estimation_UI_Store.ts - * B09 설계내역서 오른쪽 재료 창고(PLAN 703-1) — 찾기 하나 + 출처 탭(이 공사 · 내 것 · 회사 · 품셈 · 지난 공사). + * B09 설계내역서 오른쪽 재료 창고(PLAN 703-1) — 찾기 하나 + 출처 탭(이 공사 · 내 것 · 회사 · 단가산출 · 품셈 · 지난 공사). + * 단가산출(717-4) = 위 층 M02 SK 목록(`Sangeun_Pick` · 마스터 › 회사 › 개인) · 공사 연결은 아직 없음(목록만) * * 두 번 누름 · Enter = 고른 내역 줄(`…_Bill_Level` 고름)에 단가연결 · 끌어 놓기 = 놓은 줄에 단가연결 · * 우클릭 = [고른 줄에 연결] · 품셈 = [계산서 보기](704-2 `Labor_Sheet` · [담기] = 연결) @@ -31,17 +32,19 @@ import { } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api"; import { openStandardSheet } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Sheet"; import { ownOwner } from "../M02_MasterTemplete/M02_MasterTemplete_Layer"; +import { listSangeun } from "../M02_MasterTemplete/M02_MasterTemplete_Sangeun_Pick"; import type { Row } from "./B09_Estimation_Calc_Base"; import type { TabContext } from "./B09_Estimation_UI_Tabs"; import { applyAt, openMenuAt } from "./B09_Estimation_UI_Tabs_Bill_Edit"; import { askInputs, importLib, type ApplyRef } from "./B09_Estimation_UI_Tabs_Bill_Link"; import { pickedKey } from "./B09_Estimation_UI_Tabs_Bill_Level"; -type Source = "here" | "mine" | "company" | "poomsem" | "past"; +type Source = "here" | "mine" | "company" | "sangeun" | "poomsem" | "past"; const SOURCES: Array<[Source, string]> = [ ["here", "이 공사"], ["mine", "내 것"], ["company", "회사"], + ["sangeun", "단가산출"], ["poomsem", "품셈"], ["past", "지난 공사"], ]; @@ -115,6 +118,28 @@ async function layerItems(layer: "personal" | "company"): Promise { ]; } +/** 단가산출(717-4) — 위 층 SK(마스터 › 회사 › 개인) · 공사 연결 길 없음 = 알림만 */ +async function sangeunItems(): Promise { + const user = await fetchSessionUser().catch(() => null); + const own = (["company", "personal"] as const) + .map((층) => ({ 층, 주인: ownOwner(층, user) })) + .filter((x) => x.주인); + const layerText: Record = { system: "마스터", company: "회사", personal: "개인" }; + return (await listSangeun([{ 층: "system", 주인: null }, ...own])).map((r) => { + const name = r.제목 || r.이름; + const sub = [r.구분, r.상세구분].filter(Boolean).join(" "); + const money = r.기준금액 === null ? "" : `${Math.round(r.기준금액).toLocaleString("ko-KR")}원`; + return { + tag: `단가산출 · ${layerText[r.ref.층] ?? r.ref.층}`, + name, + sub: [sub, money].filter(Boolean).join(" · "), + unit: r.단위 ?? "", + hit: has(`단가산출 ${name} ${sub}`), + go: () => showToast("단가산출은 아직 공사에 바로 연결하지 않음 — 목록 보기만", "info"), + }; + }); +} + /** 넣을 값 창 — 대표 규격 = 계산서(704-2)가 셈에 쓴 입력 전부(목록 `바꿈` 은 기본과 다른 것만 · * 기본을 다르게 보는 칸이 빠짐 — 석축헐기 뒷길이 60 · 0) · 못 받으면 `바꿈` */ function askStandard(apply: ApplyRef, r: StandardRow): void { @@ -200,6 +225,7 @@ async function pastItems(): Promise { const LOAD: Record, () => Promise> = { mine: () => layerItems("personal"), company: () => layerItems("company"), + sangeun: sangeunItems, poomsem: poomsemItems, past: pastItems, }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts index a24be99b8..8d82f7339 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts @@ -22,6 +22,7 @@ export function withHopyo(lib: LibraryPick): LibraryPick { const scope = { layer: lib.layer, owner: lib.owner }; return { ...lib, + sangeun: true, // 717-4 — 일위대가 [로직 추가] 「단가산출」 탭 kindFilter: lib.kindFilter ?? [ { value: "logic", text: tc("Kind_Logic") }, { value: "precombo", text: tc("Kind_Combo") }, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts index 2b675b661..36381ec7c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts @@ -9,6 +9,8 @@ * 704-1 단가산출 줄 = 기준 목록 표(품명 · 규격 · 단위 · 금액 · 출처 · 대표 규격 펼침 · 공종 거름 · * 실무 낱말 = 서버가 별칭에 덧붙임) · 금액 = 서버 M01 엔진(`/logic-lib/standard`) · 오기 전엔 로직 줄만 · * 줄 누름 = 계산서 보기(704-2 · `Labor_Sheet`) → [담기] = 지금 담기 동작(`done(로직)`) + * 717-4 `lib.sangeun` = 탭 [기준 목록 | 단가산출] · 단가산출 = 위 층 SK(마스터 › 회사 › 개인 · `Sangeun_Pick`)를 + * 같은 표 꼴(출처 = 단가산출 · 층) · 줄 누름 = `lib.pick` 참조(SK 키) * ========================================================================== */ import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements"; @@ -37,6 +39,8 @@ import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css"; // 창 겉틀 import "./M02_MasterTemplete_Labor.css"; import { openModal } from "@ui/ui_template_modal"; import { createText } from "@ui/ui_template_text"; +import { createTabs } from "@ui/ui_template_tabs"; +import { listSangeun, type SangeunRow } from "./M02_MasterTemplete_Sangeun_Pick"; export { withHopyo } from "./M02_MasterTemplete_Hopyo_Pick"; @@ -71,6 +75,8 @@ export interface LibraryPick { more?: () => Promise; /** 종류 고르기 칸(251 · 값 logic · precombo · 덧붙인 줄 kind) */ kindFilter?: Array<{ value: string; text: string }>; + /** 717-4 「단가산출」 탭(M02 SK · 위 층 모두) — 고르면 `pick` 참조 */ + sangeun?: boolean; } /** 고를 수 있는 층 — 위 층만(마스터 → 마스터 · 회사 → 그 회사 · 마스터 · 개인 → 그 개인 · 그 회사 · 마스터) */ @@ -107,6 +113,9 @@ export function openLogicPicker( const detail = createSelectField({ options: opts([]), compact: true, label: tc("Detail") }); detail.select.disabled = true; let close = (): void => {}; + let rowPick: Array<() => void> = []; // 표 줄 누름 — 탭마다 다시 셈 + let tab: "std" | "sk" = "std"; + let skRows: SangeunRow[] | null = null; // 717-4 — 오기 전 = null /* 한 목록 — 구분(단가산출 · 일위조합) 칸 · 층 단추는 서고 줄(사전조합)의 층 */ const layerBar = el("div", { className: "ui-btn-group", attrs: { "data-pick-layers": "" } }); @@ -141,15 +150,7 @@ export function openLogicPicker( { key: "출처", label: tc("Std_Source"), width: 150 }, ], rows: [], - onRowClick: (i) => { - const row = listed[i]; - const logic = all.find((x) => x.키 === row?.키); - if (row && logic) - openStandardSheet(row, () => { - close(); - done(logic); - }); - }, + onRowClick: (i) => rowPick[i]?.(), }); /** 기준 목록이 오기 전 · 못 받으면 — 로직 하나 = 줄 하나(금액 빈칸) */ const plain = (x: LogicSummary): StandardRow => ({ @@ -170,6 +171,7 @@ export function openLogicPicker( const find = (): void => { const q = finder.search.value.trim().toLowerCase(); + if (tab === "sk") return findSangeun(q); const rows = std ?? all.map(plain); const found = rows.filter( (x) => @@ -200,6 +202,14 @@ export function openLogicPicker( ? `${shown} / ${total}` : tc("Pick_None"); listed = found; + rowPick = listed.map((row) => () => { + const logic = all.find((x) => x.키 === row.키); + if (logic) + openStandardSheet(row, () => { + close(); + done(logic); + }); + }); table.root.hidden = !!lib?.libOnly; table.setRows( found.map((x) => ({ @@ -240,6 +250,32 @@ export function openLogicPicker( })), ]); }; + /** 717-4 단가산출 탭 — 같은 표 꼴 · 찾기 = 이름 · 제목 · 구분 */ + const findSangeun = (q: string): void => { + const rows = (skRows ?? []).filter( + (x) => !q || [x.이름, x.제목, x.구분, x.상세구분].join(" ").toLowerCase().includes(q), + ); + finder.hint.textContent = !skRows + ? tc("Loading") + : rows.length + ? `${rows.length} / ${skRows.length}` + : tc("Pick_None"); + rowPick = rows.map((x) => () => { + close(); + lib?.pick(x.ref, x.제목 || x.이름); + }); + table.root.hidden = false; + table.setRows( + rows.map((x) => ({ + 품명: x.제목 || x.이름, + 규격: [x.구분, x.상세구분].filter(Boolean).join(" "), + 단위: x.단위 ?? "", + 금액: x.기준금액, + 출처: `${L("M02_PickSangeun")} · ${tc(LAYER_TEXT[x.ref.층 as keyof typeof LAYER_TEXT])}`, + })), + ); + finder.setRows([]); + }; const loadLib = async (): Promise => { if (!lib || !scope) return; const mine = ++seq; @@ -303,12 +339,46 @@ export function openLogicPicker( finder.search.addEventListener("input", find); layerBar.style.display = "none"; + const hint = createText(tc("Std_Hint")); + /** 탭 바꿈 — 단가산출 탭은 찾기 칸만(거름 · 층 단추 · 계산서 안내 숨김) */ + const tabs = lib?.sangeun + ? createTabs({ + tabs: [ + { key: "std", label: L("M02_PickStd") }, + { key: "sk", label: L("M02_PickSangeun") }, + ], + value: tab, + kind: "line", + onChange: (key) => { + tab = key === "sk" ? "sk" : "std"; + const std = tab === "std"; + for (const f of [kindSel?.root, work.root, sub.root, detail.root]) + if (f) f.style.display = std ? "" : "none"; + layerBar.style.display = std && scopes.length ? "" : "none"; + hint.hidden = !std; + find(); + }, + }) + : null; if (lib) void upper(lib.layer, lib.owner).then((got) => { scopes = got; + if (tabs) + listSangeun(got).then( + (rows) => { + skRows = rows; + tabs.setBadge("sk", rows.length); + find(); + }, + (error) => { + skRows = []; + find(); + showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"); + }, + ); scope = got[got.length - 1] ?? null; // 기본 = 가장 가까운 층 if (!got.length) return; - layerBar.style.display = ""; + layerBar.style.display = tab === "std" ? "" : "none"; drawLayers(); void loadLib(); void fetchStructureDef().then( @@ -331,7 +401,7 @@ export function openLogicPicker( body.append( el("div", { className: "m02-std-pick", - children: [finder.root, createText(tc("Std_Hint")), table.root], + children: [...(tabs ? [tabs.root] : []), finder.root, hint, table.root], }), ), })); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Pick.ts new file mode 100644 index 000000000..39d004145 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Pick.ts @@ -0,0 +1,104 @@ +/* ============================================================================= + * M02_MasterTemplete_Sangeun_Pick.ts + * M02 단가산출(SK) 고르기(PLAN 717-4) — 위 층 SK 목록 한 벌 · 단가산출 더한줄 고르기. + * · `listSangeun` = 층 목록 길(`/layers/{층}/templates` · 종류 `sangeun`)을 마스터 › 회사 › 개인 차례로 이음 · + * 금액 = 서버가 붙인 1 단위 `기준금액` · 일위대가 [로직 추가] 「단가산출」 탭 · B09 재료 창고 출처가 씀 + * · 고른 SK = 참조 `{층, 주인, 키: SK-…}` — 일위대가 줄은 옛 참조 줄 꼴(서버 `M02_Labor_Expand` 가 펼침) + * · `openSangeunLinePicker` = 단가산출 더한줄 고르기 — 같은 [로직 추가] 창(M01 로직 · 일위대가 · 단가산출 · + * M01 재료) → SK 문서 `더한줄` 한 줄(PLAN 715 이음 꼴) + * ========================================================================== */ + +import { t as L } from "@ui/ui_template_locale"; +import { call, type Layer } from "./M02_MasterTemplete_Api_Fetch"; +import type { LaborRef } from "./M02_MasterTemplete_Labor_Calc"; +import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; +import { pickMaterial } from "./M02_Material_Pick"; + +export const SANGEUN_KIND = "sangeun"; +/** 층 차례 — 위에서 아래 */ +export const LAYER_ORDER = ["system", "company", "personal"] as const; + +/** SK 목록 한 줄 */ +export interface SangeunRow { + ref: LaborRef; + 이름: string; + 제목: string | null; + 단위: string | null; + 구분: string | null; + 상세구분: string | null; + 기준금액: number | null; +} + +interface ListRow { + 종류: string; + 이름: string; + 제목?: string | null; + 단위?: string | null; + 구분?: string | null; + 상세구분?: string | null; + 기준금액?: number | null; +} + +/** 위 층 SK — 층 차례(마스터 › 회사 › 개인) · 층 안은 서버 차례 */ +export async function listSangeun(scopes: Omit[]): Promise { + const rank = (x: Omit): number => + LAYER_ORDER.indexOf(x.층 as (typeof LAYER_ORDER)[number]); + const sorted = [...scopes].sort((a, b) => rank(a) - rank(b)); + const lists = await Promise.all( + sorted.map(async (at) => { + const q = at.주인 && at.층 !== "system" ? `?owner=${encodeURIComponent(at.주인)}` : ""; + const got = await call<{ 양식: ListRow[] }>(`/layers/${at.층}/templates${q}`); + return got.양식 + .filter((r) => r.종류 === SANGEUN_KIND) + .map((r) => ({ + ref: { 층: at.층, 주인: at.층 === "system" ? null : at.주인, 키: r.이름 }, + 이름: r.이름, + 제목: r.제목 ?? null, + 단위: r.단위 ?? null, + 구분: r.구분 ?? null, + 상세구분: r.상세구분 ?? null, + 기준금액: r.기준금액 ?? null, + })); + }), + ); + return lists.flat(); +} + +/** 단가산출 더한줄 한 줄(PLAN 715 이음 꼴) */ +export type SangeunLine = + | { 종류: "로직"; 로직: string; 입력값: Record; 수량: number } + | { 참조: LaborRef; 수량: number } + | { 종류: "재료"; 요소: string; 이름: string | null; 단위: string | null; 수량: number }; + +/** 단가산출 더한줄 고르기 — `scope` = 그 SK 문서 층(고를 수 있는 서고 = 위 층만) */ +export function openSangeunLinePicker( + scope: { layer: Layer; owner: string | null }, + done: (line: SangeunLine) => void, +): void { + const element = L("M02_PickElement"); + openLogicPicker((x) => done({ 종류: "로직", 로직: x.키, 입력값: {}, 수량: 1 }), { + ...scope, + sangeun: true, + pick: (ref) => done({ 참조: ref, 수량: 1 }), + kindFilter: [ + { value: "logic", text: tc("Kind_Logic") }, + { value: "precombo", text: L("M02_PrecomboTitle") }, + { value: "element", text: element }, + ], + extra: [ + { + kind: "element", + key: "element:material", + tag: element, + name: L("M02_PickMaterial"), + sub: "", + unit: "", + onPick: () => + pickMaterial((m) => + done({ 종류: "재료", 요소: m.코드, 이름: m.이름, 단위: m.단위, 수량: 1 }), + ), + }, + ], + }); +} diff --git a/resources/tester/test_717_4_sangeun_pick.py b/resources/tester/test_717_4_sangeun_pick.py new file mode 100644 index 000000000..858eb0d6b --- /dev/null +++ b/resources/tester/test_717_4_sangeun_pick.py @@ -0,0 +1,72 @@ +"""717-4 단가산출(SK) 고르기 — SK 목록 층 차례 · 고른 SK 참조 꼴 · 일위대가 줄 금액.""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Sangeun_Calc as calc +from resources.tester.test_m02_labor_ref import COMPANY, MINE, world # noqa: F401 — 같은 임시 층 + +ROOT = Path(__file__).resolve().parents[2] +M02 = ROOT / "M02_MasterTemplete" +PCT = "GC001109" # 원/㎡ · 인력 둘 + 덧줄 % + + +def _text(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def test_sk_list_layer_order_and_ref_shape(): + """목록 = 마스터 › 회사 › 개인 차례 · 종류 sangeun 만 · 고른 줄 = 참조 {층, 주인, 키}.""" + pick = _text(M02 / "M02_MasterTemplete_Sangeun_Pick.ts") + order = re.search(r"LAYER_ORDER = \[([^\]]+)\]", pick).group(1) + assert [x.strip().strip('"') for x in order.split(",")] == ["system", "company", "personal"] + assert "[...scopes].sort((a, b) => rank(a) - rank(b))" in pick + assert "r.종류 === SANGEUN_KIND" in pick and 'SANGEUN_KIND = "sangeun"' in pick + assert 'ref: { 층: at.층, 주인: at.층 === "system" ? null : at.주인, 키: r.이름 }' in pick + labor = _text(M02 / "M02_MasterTemplete_Labor_Pick.ts") + assert "lib?.pick(x.ref, x.제목 || x.이름);" in labor # SK 탭 줄 누름 = 참조 + assert 'if (tab === "sk") return findSangeun(q);' in labor + assert "sangeun: true," in _text( + M02 / "M02_MasterTemplete_Hopyo_Pick.ts" + ) # 일위대가 [로직 추가] + store_ts = _text(ROOT / "B09_Estimation" / "B09_Estimation_UI_Store.ts") + assert '["sangeun", "단가산출"],' in store_ts and "sangeun: sangeunItems," in store_ts + # 더한줄 고르기 — 715 이음 꼴 셋 + assert 'done({ 종류: "로직", 로직: x.키, 입력값: {}, 수량: 1 })' in pick + assert "done({ 참조: ref, 수량: 1 })" in pick + assert 'done({ 종류: "재료", 요소: m.코드, 이름: m.이름, 단위: m.단위, 수량: 1 })' in pick + + +def _sk(folder, name): + with store.at(folder): + return refs.create( + "sangeun", {"이름": name, "단위": "㎡", "바탕": {"로직": PCT}, "더한줄": []} + )["이름"] + + +def test_list_amount_and_labor_line_holds_upper_sk(world): # noqa: F811 + """층 목록 SK 줄에 1 단위 `기준금액` · 개인 일위대가 줄이 회사 SK 를 참조 = SK 1 단위 × 수량.""" + from M02_MasterTemplete import M02_Labor_Doc as labor + + sk = _sk(world["company"], "회사 단가산출") + with store.at(world["company"]): + rows = [r for r in store.list_all() if r["종류"] == "sangeun"] + refs.summaries(world["company"], rows) + one = calc.calc_doc(store.read("sangeun", sk)["문서"])["1단위"]["계"] + assert [r["이름"] for r in rows] == [sk] + assert rows[0]["제목"] == "회사 단가산출" and rows[0]["기준금액"] == pytest.approx(one, abs=1) + with store.at(world["personal"]): + sc = refs.create( + "precombo", + {"이름": "일위대가", "담은로직": [{"참조": {**COMPANY, "키": sk}, "수량": {"손": 3}}]}, + )["이름"] + saved = store.read("precombo", sc)["문서"]["담은로직"][0]["참조"] + got = labor.calc({"담은로직": [{"참조": {**MINE, "키": sc}, "수량": {"손": 1}}]}, {}) + assert saved == {**COMPANY, "키": sk} + assert got["반영합"]["계"] == pytest.approx(one * 3, abs=1) diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index cfc15a584..e90310eac 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -191,6 +191,7 @@ export const ui_locales_m2 = { M02_HopyoImport: ["일위대가 불러오기", "Import unit-price sheets"], B01_Dashboard_CardPrecombo: ["일위대가", "Unit-cost items"], M02_PrecomboTitle: ["일위대가", "Unit-cost items"], +<<<<<<< HEAD B01_Dashboard_CardSangeun: ["단가산출", "Unit-price analyses"], M02_SangeunTitle: ["단가산출", "Unit-price analyses"], M02_SangeunNone: ["단가산출 없음", "No unit-price analyses"], @@ -207,4 +208,10 @@ export const ui_locales_m2 = { "단가산출 상세 표 · 고침 — 준비 중(717-2b)", "Unit-price detail table and editing — coming next (717-2b)", ], +======= + M02_PickStd: ["기준 목록", "Standard list"], + M02_PickSangeun: ["단가산출", "Unit-cost calcs"], + M02_PickElement: ["M01 요소", "M01 elements"], + M02_PickMaterial: ["재료 고르기", "Pick a material"], +>>>>>>> origin/sub_laptop_7 } as const;