Merge remote-tracking branch 'origin/dev' into sub_laptop_8
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
/* =============================================================================
|
||||
* calc_sheet.ts
|
||||
* 공용 「단가산출 계산서」(PLAN 714-2 · 요구서 내역12 4장 5) — 704-2 M02 계산서 보기를 올린 것.
|
||||
* 쓰는 곳 = M01 「인력·재료·경비」 보기 · M02 기준 목록 계산서 보기(704-2) · (뒤에) 일위대가 모달 · B09 단가산출근거
|
||||
* 꼴 = 묶음마다 [머리(변수 칸이 있을 때만) · 쓰는 입력 값 줄] · 식 줄(이름 | 단가 × 품 | 금액) · 소계 · 맨 아래 계
|
||||
* 값 = 서버 셈 그대로 찍음(화면에서 다시 셈 안 함) · 변수 칸 바꾸면 `onInput(이름, 값)` — 부르는 쪽이 다시 셈
|
||||
* 부품 = `ui_template_calc_row`(값 줄 · 식 줄) · 입력 · 고르기 · 글 — 날 요소 · 페이지 CSS 없음
|
||||
* ========================================================================== */
|
||||
|
||||
import { createCalcFormula, createCalcFormulaRow, createCalcRow } from "@ui/ui_template_calc_row";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { createInputField, createSelectField } from "@ui/ui_template_input";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
|
||||
export interface SheetLine {
|
||||
이름: string;
|
||||
/** 서버 값 식 「단가 * 품 = 금액」 — 「= 금액」 은 떼고 「*」 → 「×」 */
|
||||
글: string;
|
||||
금액: number | null;
|
||||
}
|
||||
export interface SheetGroup {
|
||||
비목: string;
|
||||
소계: number | null;
|
||||
끝수?: string;
|
||||
줄: SheetLine[];
|
||||
}
|
||||
export interface SheetInput {
|
||||
이름: string;
|
||||
단위?: string;
|
||||
고르기?: (string | number)[];
|
||||
}
|
||||
export interface SheetOptions {
|
||||
묶음: SheetGroup[];
|
||||
계: number | null;
|
||||
/** 맨 아래 계 줄 이름(「계 (원/㎡)」) */
|
||||
sumLabel: string;
|
||||
/** 변수 칸 — 입력 꼴 · 지금 값 · 묶음이 쓰는 입력 이름 · 바꾼 뒤 할 일(없으면 변수 칸 없음) */
|
||||
inputs?: SheetInput[];
|
||||
values?: Record<string, string>;
|
||||
uses?: (group: SheetGroup, index: number) => string[];
|
||||
onInput?: (name: string, value: string) => void;
|
||||
}
|
||||
|
||||
const won = (v: number | null, digits = 0): string =>
|
||||
v === null ? "-" : v.toLocaleString("ko-KR", { maximumFractionDigits: digits });
|
||||
|
||||
const out = (v: number | null, key = false, digits = 0): HTMLElement =>
|
||||
el("span", { className: `ui-calc-row__out${key ? " is-key" : ""}`, text: won(v, digits) });
|
||||
|
||||
/** 값 식 → 식 칸 글 — 「268,908 * 0.12 = 32,269」 → 「268,908 × 0.12」 */
|
||||
export const sheetFormula = (text: string): string =>
|
||||
text.replace(/ = [^=]*$/, "").replaceAll(" * ", " × ");
|
||||
|
||||
/** 변수 칸 하나 — 고르기 있으면 고르기 · 아니면 글 칸(Enter · 떠남 때 넘김) */
|
||||
function varRow(spec: SheetInput, value: string, onInput: (v: string) => void): HTMLElement {
|
||||
let control: HTMLElement;
|
||||
if (spec.고르기?.length) {
|
||||
control = createSelectField({
|
||||
options: spec.고르기.map((o) => ({ value: String(o), text: String(o) })),
|
||||
value,
|
||||
compact: true,
|
||||
ariaLabel: spec.이름,
|
||||
onChange: onInput,
|
||||
}).root;
|
||||
} else {
|
||||
const box = createInputField({ value, compact: true });
|
||||
box.input.setAttribute("aria-label", spec.이름);
|
||||
box.input.addEventListener("change", () => {
|
||||
if (box.input.value.trim() && box.input.value !== value) onInput(box.input.value.trim());
|
||||
});
|
||||
control = box.root;
|
||||
}
|
||||
const row = createCalcRow(spec.단위 ? `${spec.이름} (${spec.단위})` : spec.이름, control);
|
||||
row.dataset.sheetVar = spec.이름;
|
||||
return row;
|
||||
}
|
||||
|
||||
/** 계산서 줄들 — 부르는 쪽 세로 칸(flex column)에 그대로 붙임 */
|
||||
export function calcSheetRows(o: SheetOptions): HTMLElement[] {
|
||||
const rows: HTMLElement[] = [];
|
||||
const specs = new Map((o.onInput ? (o.inputs ?? []) : []).map((s) => [s.이름, s]));
|
||||
o.묶음.forEach((g, gi) => {
|
||||
const vars = (o.uses?.(g, gi) ?? []).filter((n) => specs.has(n));
|
||||
if (vars.length && o.onInput) {
|
||||
const onInput = o.onInput;
|
||||
const head = createText(g.비목, "muted", 14);
|
||||
head.dataset.sheetHead = g.비목;
|
||||
rows.push(head);
|
||||
for (const n of vars)
|
||||
rows.push(varRow(specs.get(n)!, o.values?.[n] ?? "", (v) => onInput(n, v)));
|
||||
}
|
||||
for (const line of g.줄)
|
||||
rows.push(
|
||||
createCalcFormulaRow(
|
||||
line.이름,
|
||||
createCalcFormula(sheetFormula(line.글)),
|
||||
out(line.금액, false, 1),
|
||||
),
|
||||
);
|
||||
rows.push(
|
||||
createCalcFormulaRow(`${g.비목}${g.끝수 ? ` (${g.끝수})` : ""}`, null, out(g.소계, true)),
|
||||
);
|
||||
});
|
||||
rows.push(createCalcFormulaRow(o.sumLabel, null, out(o.계, true)));
|
||||
return rows;
|
||||
}
|
||||
@@ -65,6 +65,7 @@ export function settingLinks(user: SettingUser, companyId: number | string | nul
|
||||
m01("로직", L("B01_Dashboard_CardLogic"), read, "로직"),
|
||||
m01("옵션", L("B01_Dashboard_CardStructOptionDef"), desc, "옵션"),
|
||||
m01("별칭", L("B01_Dashboard_CardAliasLedger"), desc, "별칭"),
|
||||
m02(L("B01_Dashboard_CardSangeun"), "단가산출", comboRoot("master"), desc),
|
||||
m02(L("B01_Dashboard_CardPrecombo"), "일위대가조합", comboRoot("master"), desc),
|
||||
m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", comboRoot("master"), desc),
|
||||
m02(L("B01_Dashboard_CardDrawing"), "도면", "master", desc),
|
||||
|
||||
@@ -261,7 +261,7 @@ def call_of(whole: Any, item: dict, env: dict) -> dict[str, Any] | None:
|
||||
target = None
|
||||
if not target:
|
||||
return None
|
||||
if any(s.get("이름") == "기계" for s in target.get("입력", [])):
|
||||
if mf.mb.is_machine(target): # 기계경비 판별 한 곳(712-1)
|
||||
keys = _captured(whole, node, env)
|
||||
return {"machine": keys[0] if keys else None}
|
||||
given = {name: mf.evaluate(expr, env, whole, 1) for name, expr in node[2]}
|
||||
|
||||
@@ -276,7 +276,7 @@ def _logic_lines(whole, item, env, qty_text, q, price_of) -> list[tuple[str, dic
|
||||
except mf.FormulaError:
|
||||
target = None
|
||||
name = item.get("이름", "")
|
||||
if target and any(s.get("이름") == "기계" for s in target.get("입력", [])):
|
||||
if mf.mb.is_machine(target): # 기계경비 판별 한 곳(712-1)
|
||||
keys = sg._captured(whole, node, env)
|
||||
got = price_of(keys[0]) if keys else None
|
||||
if not got:
|
||||
|
||||
@@ -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", "지난 공사"],
|
||||
];
|
||||
@@ -120,6 +123,28 @@ async function layerItems(layer: "personal" | "company"): Promise<Item[]> {
|
||||
];
|
||||
}
|
||||
|
||||
/** 단가산출(717-4) — 위 층 SK(마스터 › 회사 › 개인) · 공사 연결 길 없음 = 알림만 */
|
||||
async function sangeunItems(): Promise<Item[]> {
|
||||
const user = await fetchSessionUser().catch(() => null);
|
||||
const own = (["company", "personal"] as const)
|
||||
.map((층) => ({ 층, 주인: ownOwner(층, user) }))
|
||||
.filter((x) => x.주인);
|
||||
const layerText: Record<string, string> = { 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 {
|
||||
@@ -205,6 +230,7 @@ async function pastItems(): Promise<Item[]> {
|
||||
const LOAD: Record<Exclude<Source, "here">, () => Promise<Item[]>> = {
|
||||
mine: () => layerItems("personal"),
|
||||
company: () => layerItems("company"),
|
||||
sangeun: sangeunItems,
|
||||
poomsem: poomsemItems,
|
||||
past: pastItems,
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { logicApiPaths } from "./M01_MasterData_UI_Logic_Api";
|
||||
|
||||
/** 줄·표 한 개 — 파일 그대로(한글 칸). */
|
||||
export type Row = Record<string, unknown>;
|
||||
@@ -89,8 +90,12 @@ export type SaveResult =
|
||||
| { status: 422; errors: string[] }
|
||||
| { status: number; detail: string };
|
||||
|
||||
async function call<T>(path: string, init: RequestInit = {}): Promise<{ status: number; body: T }> {
|
||||
const response = await fetch(`${API_BASE_URL}/m01${path}`, {
|
||||
async function call<T>(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
base = "/m01",
|
||||
): Promise<{ status: number; body: T }> {
|
||||
const response = await fetch(`${API_BASE_URL}${base}${path}`, {
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...init,
|
||||
@@ -98,9 +103,13 @@ async function call<T>(path: string, init: RequestInit = {}): Promise<{ status:
|
||||
return { status: response.status, body: (await response.json()) as T };
|
||||
}
|
||||
|
||||
async function get<T>(path: string, params: Record<string, string | number>): Promise<T> {
|
||||
async function get<T>(
|
||||
path: string,
|
||||
params: Record<string, string | number>,
|
||||
base = "/m01",
|
||||
): Promise<T> {
|
||||
const query = new URLSearchParams(Object.entries(params).map(([k, v]) => [k, String(v)]));
|
||||
const { status, body } = await call<T & { detail?: unknown }>(`${path}?${query}`);
|
||||
const { status, body } = await call<T & { detail?: unknown }>(`${path}?${query}`, {}, base);
|
||||
if (status !== 200) throw new Error(String(body.detail ?? `HTTP ${status}`));
|
||||
return body;
|
||||
}
|
||||
@@ -121,7 +130,11 @@ export const fetchRows = (
|
||||
detail = "",
|
||||
state = "",
|
||||
): Promise<RowsPage> =>
|
||||
get<RowsPage>("/rows", { file, page, size, q, unlinked: unlinked ? 1 : 0, sub, detail, state });
|
||||
get<RowsPage>(
|
||||
"/rows",
|
||||
{ file, page, size, q, unlinked: unlinked ? 1 : 0, sub, detail, state },
|
||||
logicApiPaths().read, // 고르기 표 · 품목 찾기 — 상세 부품 길(717-1)
|
||||
);
|
||||
|
||||
/** 한 테이블 파일은 `file` · 장 그룹(소요량 · 계수 · 로직)은 `group` 으로 부름. */
|
||||
export const fetchSubsAll = (where: {
|
||||
@@ -174,7 +187,8 @@ export interface PickItem {
|
||||
export const fetchPick = (
|
||||
kind: PickKind,
|
||||
q: string,
|
||||
): Promise<{ total: number; items: PickItem[] }> => get("/pick", { kind, q, limit: 50 });
|
||||
): Promise<{ total: number; items: PickItem[] }> =>
|
||||
get("/pick", { kind, q, limit: 50 }, logicApiPaths().read);
|
||||
|
||||
/** 자재 찾기 — `/pick?kind=price` 감쌈 · 이름·규격 낱말이 모두 든 줄 */
|
||||
export const searchMaterials = async (
|
||||
|
||||
@@ -18,6 +18,7 @@ from M01_MasterData import M01_MasterData_Store_Flow as flow
|
||||
from M01_MasterData import M01_MasterData_Store_List as store_list
|
||||
from M01_MasterData import M01_MasterData_Store_Make as make
|
||||
from M01_MasterData import M01_MasterData_Store_Revise as revise
|
||||
from M01_MasterData import M01_MasterData_Store_Sheet as sheet
|
||||
|
||||
import master_basis as mbs # noqa: E402 (`store` 가 scripts 를 길에 둠)
|
||||
|
||||
@@ -275,7 +276,7 @@ def post_text(body: CalcBody) -> dict:
|
||||
if got is not None:
|
||||
return got
|
||||
with store.cm.mf.mp.choosing(body.고름):
|
||||
return _call(store.text, body.key, body.inputs, body.row, body.file)
|
||||
return _call(sheet.text, body.key, body.inputs, body.row, body.file) # 줄 덧칸(714-1)
|
||||
|
||||
|
||||
@router.post("/save")
|
||||
|
||||
@@ -17,6 +17,7 @@ from config import config_system
|
||||
from M01_MasterData import M01_MasterData_Store as store
|
||||
|
||||
cm, mk, mcb = store.cm, store.mk, store.mcb
|
||||
import master_unit as mu # noqa: E402 — `store` 가 scripts 를 길에 둔 뒤(712-2)
|
||||
|
||||
FILE = mcb.FILE
|
||||
OWNERS = mcb.OWNERS
|
||||
@@ -150,9 +151,6 @@ def combo(key: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
_PER = re.compile(
|
||||
r"원\s*/\s*(\d[\d,]*\.?\d*|\.\d+)?\s*([^\s·(\d,.][^\s·(]*)"
|
||||
) # 「원/1,000본」 = 배 1000
|
||||
_TAIL = re.compile(r"\s*\([^()]*\)\s*$")
|
||||
|
||||
|
||||
@@ -177,7 +175,7 @@ def combo_pills(row: dict) -> dict:
|
||||
got = whole.logic(code)
|
||||
except cm.mf.FormulaError:
|
||||
continue
|
||||
per = _PER.findall(str(got.get("결과단위") or ""))
|
||||
per = mu.per(got.get("결과단위")) # 결과단위 배 읽기 한 벌(712-2)
|
||||
if not per:
|
||||
continue
|
||||
# ponytail: 분모가 여럿(「원/㎡ · 원/㎥(단위당 따름)」)이면 「㎡ · ㎥」 한 글 — 입력이 고름
|
||||
@@ -187,9 +185,9 @@ def combo_pills(row: dict) -> dict:
|
||||
(name, unit), {"키": f"{name}|{unit}", "이름": name, "단위": unit, "로직": []}
|
||||
)
|
||||
pill["로직"].append(code)
|
||||
times = per[0][0].replace(",", "")
|
||||
if len(per) == 1 and times and float(times) != 1:
|
||||
pill["배"] = float(times) if "." in times else int(times)
|
||||
times = per[0][0]
|
||||
if len(per) == 1 and times != 1:
|
||||
pill["배"] = int(times) if times == times.to_integral_value() else float(times)
|
||||
return {"키": row.get("키"), "이름": row.get("이름"), "물량": list(pills.values()), "조건": []}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""M01 `/text` 계산서 줄 덧칸(PLAN 714-1) — `store.text` 답에 줄마다 쓰는 입력 · 묶음 · 기준단위 ·
|
||||
환산 근거(`master_sheet_line.annotate` — M02 `/logic-lib/sheet` 와 한 벌) · 서버 단독(③) · 저장 안 함.
|
||||
`M01_MasterData_Store.py` 는 700 줄 근처라 여기서 감쌈."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as store
|
||||
|
||||
mf = store.cm.mf
|
||||
import master_sheet_line as msl # noqa: E402 — `store` 가 scripts 를 길에 둔 뒤
|
||||
|
||||
|
||||
def text(key: str, inputs: dict, row: dict | None = None, file: str | None = None) -> dict:
|
||||
got = store.text(key, inputs, row, file)
|
||||
if not isinstance(got, dict) or not got.get("ok"):
|
||||
return got
|
||||
given = {k: store._dec(v) for k, v in inputs.items()}
|
||||
try:
|
||||
if row is None:
|
||||
master = store.loaded()[1]
|
||||
else: # 저장 전 시험 — `store.text` 와 같은 메모리 사본
|
||||
files = store.cm.load(folder=store.FOLDER)
|
||||
key = store._swap(files, str(row.get("키") or key), row, file)
|
||||
master = mf.Master(files)
|
||||
return msl.annotate(master, key, mf.fill_empty(master.logic(key), given), got)
|
||||
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError):
|
||||
return got
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
type LogicFlow,
|
||||
} from "./M01_MasterData_UI_LogicLab_Flow";
|
||||
import { askBasicInfo } from "./M01_MasterData_UI_LogicLab_Basic";
|
||||
import { saveRevised } from "./M01_MasterData_UI_LogicLab_Revise";
|
||||
import { buildLogicSide } from "./M01_MasterData_UI_LogicLab_Side";
|
||||
import { createViewPicker } from "./M01_MasterData_UI_LogicLab_View";
|
||||
import { buildHeadBar } from "./M01_MasterData_UI_Head";
|
||||
@@ -98,7 +99,6 @@ const calcRow = (row: LogicRow | null): LogicRow | null => {
|
||||
|
||||
const cacheKey = "m01_logic_drafts_lab";
|
||||
const ownHost = (): HTMLElement => el("div", { className: "m01lab__own" });
|
||||
|
||||
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
|
||||
|
||||
function loadDrafts(): Record<string, Draft> {
|
||||
@@ -256,6 +256,7 @@ export async function mountM01LogicLab(
|
||||
values: current.values,
|
||||
ownHost: current.ownHost,
|
||||
onChange: touch,
|
||||
onInput: (name, v) => syncLeft(current, { [name]: v }, true),
|
||||
calcHost: calc,
|
||||
flowHost: flowOf(current.row) ? current.flowHost : undefined,
|
||||
admin,
|
||||
@@ -331,11 +332,11 @@ export async function mountM01LogicLab(
|
||||
};
|
||||
|
||||
/** 흐름 예시값 → 왼쪽 시험 계산 입력(이름이 있는 칸만) · 바뀌면 왼쪽을 다시 그리고 셈 */
|
||||
const syncLeft = (o: Opened, ex: Record<string, string>): void => {
|
||||
const syncLeft = (o: Opened, ex: Record<string, string>, loud = false): void => {
|
||||
const names = new Set((calcRow(o.row)?.입력 ?? []).map((x) => x.이름));
|
||||
const changed = Object.entries(ex).filter(([k, v]) => names.has(k) && o.values[k] !== v);
|
||||
if (!changed.length) return;
|
||||
quiet = true;
|
||||
quiet = !loud; // 텍스트 수식 고침(loud)은 흐름 예시값에도 — 시험 계산 칸 고침과 같은 길
|
||||
for (const [k, v] of changed) o.values[k] = v;
|
||||
quiet = false;
|
||||
drawCalc();
|
||||
@@ -501,9 +502,8 @@ export async function mountM01LogicLab(
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
// 키 없는 새 줄은 서버가 새 키를 줌(added) — 저장 뒤 그 로직을 엶
|
||||
const saved = (await saveFiles([...byFile.values()])) as {
|
||||
added?: { file: string; key: string }[];
|
||||
};
|
||||
const saved = await saveRevised((ok) => saveFiles([...byFile.values()], ok));
|
||||
if (!saved) return; // 개정 확인 취소 = 캐시 그대로
|
||||
const was = opened;
|
||||
drafts = {};
|
||||
await reload();
|
||||
|
||||
@@ -24,6 +24,7 @@ import { openPicker } from "./M01_MasterData_UI_Logic_Pick";
|
||||
import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text";
|
||||
import { pickMaterial } from "./M01_MasterData_UI_Logic_CalcPick";
|
||||
import { buildFormula } from "./M01_MasterData_UI_LogicLab_Formula";
|
||||
import { buildEst } from "./M01_MasterData_UI_LogicLab_Est";
|
||||
import {
|
||||
condText,
|
||||
openMaterialModal,
|
||||
@@ -38,7 +39,6 @@ import { tl, type LabTextKey } from "./M01_MasterData_UI_LogicLab_Text";
|
||||
import "./M01_MasterData_UI_LogicLab_Style.css";
|
||||
import "./M01_MasterData_UI_LogicLab_Pill.css";
|
||||
import { createBadge } from "@ui/ui_template_badge";
|
||||
import { createEmpty } from "@ui/ui_template_empty";
|
||||
import { block, nowInputs } from "./M01_MasterData_UI_LogicLab_View";
|
||||
|
||||
/** 서버 조각(`/text` 줄의 `조각`) → 수량 칸 조각 — 요소·표·로직은 알약(글 = 조각 글) · 나머지는 글자 */
|
||||
@@ -82,6 +82,8 @@ export interface DetailContext {
|
||||
flowHost?: HTMLElement;
|
||||
/** 시스템 관리자 — 기본 로직 안내 글이 다름(흐름도에서 고침 / 보기만) */
|
||||
admin?: boolean;
|
||||
/** 714-3 텍스트 수식 입력 단추 — 시험 계산 입력 고침 + 다시 셈 */
|
||||
onInput?: (name: string, value: string) => void;
|
||||
}
|
||||
|
||||
type Group = "labor" | "material" | "cost" | "other";
|
||||
@@ -511,7 +513,8 @@ function hoBox(ctx: DetailContext): HTMLElement {
|
||||
/** 상세 화면 넷을 `host` 에 쌓음 */
|
||||
export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void {
|
||||
const formula = el("div", { className: "m01lab__formula" });
|
||||
buildFormula(formula, { text: ctx.text });
|
||||
const fx = { inputs: ctx.row.입력, values: ctx.values, onInput: ctx.onInput };
|
||||
buildFormula(formula, { text: ctx.text, ...fx });
|
||||
// 다시 그리면 포커스가 빠짐 — 고치던 칸(편집기 · 기본정보)을 기억해 되돌림
|
||||
const active = document.activeElement;
|
||||
const kept =
|
||||
@@ -522,7 +525,10 @@ export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void {
|
||||
host.replaceChildren(
|
||||
...infoBox(ctx),
|
||||
block(section(tl("Calc_Title"), [ctx.calcHost]), "calc"),
|
||||
block(section(tl("View_Est"), [createEmpty({ text: tl("View_Soon"), small: true })]), "est"),
|
||||
block(
|
||||
section(tl("View_Est"), [buildEst({ text: ctx.text, unit: ctx.row.결과단위, ...fx })]),
|
||||
"est",
|
||||
),
|
||||
...(ctx.flowHost
|
||||
? [block(nowInputs(ctx.values), "ho"), ctx.flowHost]
|
||||
: [hoBox(ctx), block(section(tl("Formula_Title"), [formula]), "formula")]),
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab_Est.ts
|
||||
* 「인력·재료·경비」 보기(PLAN 714-2) — 공용 단가산출 계산서(`A00_Common/calc_sheet.ts`)에 시험 계산 읽는 식(`/text`)을 그대로.
|
||||
* 묶음 머리 변수 칸 = 그 묶음 줄들이 쓰는 시험 입력(714-3 `usedInputs`) · 바꾸면 시험 계산 입력 고침 + 다시 셈(정본 안 바뀜)
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { calcSheetRows } from "../A00_Common/calc_sheet";
|
||||
import type { LogicInput } from "./M01_MasterData_UI_Logic_Api";
|
||||
import { safeReason } from "./M01_MasterData_UI_Logic_Text";
|
||||
import { usedInputs, type PreviewTextAnswer } from "./M01_MasterData_UI_LogicLab_Formula";
|
||||
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
|
||||
|
||||
export interface EstContext {
|
||||
text: PreviewTextAnswer | null;
|
||||
unit: string;
|
||||
inputs: LogicInput[];
|
||||
values: Record<string, string>;
|
||||
onInput?: (name: string, value: string) => void;
|
||||
}
|
||||
|
||||
export function buildEst(ctx: EstContext): HTMLElement {
|
||||
const host = el("div", { className: "m01lab-est", attrs: { "data-est": "" } });
|
||||
const text = ctx.text;
|
||||
if (!text || (text.ok && text.계산됨 === false)) {
|
||||
host.append(createText(tl("Formula_Slot"), "muted"));
|
||||
return host;
|
||||
}
|
||||
if (!text.ok) {
|
||||
host.append(createText(safeReason(text.reason), "error"));
|
||||
return host;
|
||||
}
|
||||
const names = ctx.inputs.map((x) => x.이름);
|
||||
host.append(
|
||||
...calcSheetRows({
|
||||
묶음: text.groups,
|
||||
계: text.계,
|
||||
sumLabel: `${tl("Total")} (${ctx.unit || "원"})`,
|
||||
inputs: ctx.inputs,
|
||||
values: ctx.values,
|
||||
onInput: ctx.onInput,
|
||||
uses: (_, gi) => [
|
||||
...new Set(text.groups[gi].줄.flatMap((line) => usedInputs(line, names).map(([n]) => n))),
|
||||
],
|
||||
}),
|
||||
);
|
||||
return host;
|
||||
}
|
||||
@@ -37,6 +37,18 @@
|
||||
padding: var(--spacing-4);
|
||||
}
|
||||
|
||||
/* 714-3 줄이 쓰는 입력 — 누르면 그 자리 고르기/글 칸 */
|
||||
.m01lab-fx__vars {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-4);
|
||||
margin-top: var(--spacing-4);
|
||||
}
|
||||
|
||||
.m01lab-fx__var .ui-field {
|
||||
min-width: 6rem;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.m01lab-fx,
|
||||
.m01lab-fx__head,
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
* 「텍스트 수식」 컨테이너 — 서버(`POST /text`)가 준 줄을 그대로 찍음(화면에서 다시 계산하지 않음)
|
||||
* 두 칸 — 왼쪽 = 변수 이름 식(`이름글`) · 오른쪽 = 값이 들어간 식(`글`) · 줄은 `짝` 번호로 같은 높이
|
||||
* 한쪽에 올리거나 누르면 같은 줄이 밝아짐 · 비목 제목 · 소계 · 끝수 한 마디 · 까닭 있는 줄은 옅게
|
||||
* 714-3 — 줄이 쓰는 입력(표 찾기 행 값 · 이름 조각)을 오른쪽 칸 아래 작은 단추로 · 누르면 그 자리에 고르기/글 칸 →
|
||||
* 바꾸면 `onInput`(시험 계산 입력 고침 + 다시 셈) · 정본은 안 바뀜(요구서 4-1 확정 8)
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import type { TextAnswer } from "./M01_MasterData_UI_Logic_Api";
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { createInputField, createSelectField } from "@ui/ui_template_input";
|
||||
import type { LogicInput, TextAnswer, TextLine } from "./M01_MasterData_UI_Logic_Api";
|
||||
import { formatMoney } from "./M01_MasterData_UI_Logic_Money";
|
||||
import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text";
|
||||
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
|
||||
@@ -17,6 +20,77 @@ export type PreviewTextAnswer = TextAnswer & { 계산됨?: boolean };
|
||||
|
||||
export interface FormulaContext {
|
||||
text: PreviewTextAnswer | null;
|
||||
/** 714-3 — 시험 계산 입력 · 지금 값 · 고친 뒤 할 일(값 넣고 다시 셈) · 없으면 단추 없음 */
|
||||
inputs?: LogicInput[];
|
||||
values?: Record<string, string>;
|
||||
onInput?: (name: string, value: string) => void;
|
||||
}
|
||||
|
||||
/* ── 순수 셈: 줄이 쓰는 입력 ── */
|
||||
/** 줄의 조각에서 시험 입력 이름 → 그 줄이 쓴 값 — 표 찾기 행 열쇠 · 이름 조각(`값` 이 붙은 이름) · 차례 = 처음 나온 차례 */
|
||||
export function usedInputs(
|
||||
line: Pick<TextLine, "조각" | "요소조각">,
|
||||
names: string[],
|
||||
): [string, string][] {
|
||||
const out = new Map<string, string>();
|
||||
for (const p of [...(line.조각 ?? []), ...(line.요소조각 ?? [])]) {
|
||||
const row = p.kind === "표" ? p.참조?.행 : null;
|
||||
if (row && typeof row === "object")
|
||||
for (const [k, v] of Object.entries(row))
|
||||
if (names.includes(k) && !out.has(k)) out.set(k, String(v));
|
||||
if (p.kind === "값" && p.값 !== undefined && names.includes(p.글) && !out.has(p.글))
|
||||
out.set(p.글, String(p.값));
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
/* ── 순수 셈 끝 ── */
|
||||
|
||||
/** 입력 단추 하나 — 누르면 그 자리에 고르기(고르기 있는 입력) · 글 칸 · 바꾸면 `onInput` */
|
||||
function inputChip(spec: LogicInput, shown: string, onInput: (v: string) => void): HTMLElement {
|
||||
const wrap = el("span", { className: "m01lab-fx__var", attrs: { "data-fx-var": spec.이름 } });
|
||||
const chip = createButton({
|
||||
label: `${spec.이름} ${shown}${spec.단위 ? ` ${spec.단위}` : ""}`,
|
||||
variant: "ghost",
|
||||
size: 24,
|
||||
onClick: (ev) => {
|
||||
ev.stopPropagation(); // 줄 누름(밝히기 고정)과 안 겹치게
|
||||
let over = false; // Enter 뒤 blur · 고름 뒤 blur 가 두 번 부르지 않게
|
||||
const done = (v: string | null): void => {
|
||||
if (over) return;
|
||||
over = true;
|
||||
if (v !== null && v.trim() !== "" && v !== shown) onInput(v.trim());
|
||||
else wrap.replaceChildren(chip);
|
||||
};
|
||||
let field: HTMLElement;
|
||||
if (spec.고르기?.length) {
|
||||
const pick = createSelectField({
|
||||
options: spec.고르기.map((o) => ({ value: String(o), text: String(o) })),
|
||||
value: shown,
|
||||
size: 24,
|
||||
ariaLabel: spec.이름,
|
||||
onChange: (v) => done(v),
|
||||
});
|
||||
pick.select.addEventListener("blur", () => done(null));
|
||||
field = pick.root;
|
||||
} else {
|
||||
const box = createInputField({ value: shown, size: 24 });
|
||||
box.input.setAttribute("aria-label", spec.이름);
|
||||
box.input.addEventListener("keydown", (k) => {
|
||||
if (k.isComposing) return;
|
||||
if (k.key === "Enter") done(box.input.value);
|
||||
if (k.key === "Escape") (k.preventDefault(), done(null));
|
||||
});
|
||||
box.input.addEventListener("blur", () => done(box.input.value));
|
||||
field = box.root;
|
||||
}
|
||||
field.addEventListener("click", (e) => e.stopPropagation());
|
||||
wrap.replaceChildren(field);
|
||||
field.querySelector<HTMLElement>("select, input")?.focus();
|
||||
},
|
||||
});
|
||||
chip.title = tl("Formula_Var");
|
||||
wrap.append(chip);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
const muted = (text: string): HTMLElement => el("p", { className: "ui-text ui-text--muted", text });
|
||||
@@ -47,6 +121,27 @@ export function buildFormula(host: HTMLElement, ctx: FormulaContext): void {
|
||||
],
|
||||
});
|
||||
const rows: HTMLElement[] = [];
|
||||
const specs = ctx.onInput ? (ctx.inputs ?? []) : [];
|
||||
const vars = (line: TextLine): HTMLElement[] => {
|
||||
const used = usedInputs(
|
||||
line,
|
||||
specs.map((x) => x.이름),
|
||||
);
|
||||
if (!used.length || !ctx.onInput) return [];
|
||||
const onInput = ctx.onInput;
|
||||
return [
|
||||
el("div", {
|
||||
className: "m01lab-fx__vars",
|
||||
children: used.map(([name, was]) =>
|
||||
inputChip(
|
||||
specs.find((x) => x.이름 === name)!,
|
||||
ctx.values?.[name] || was,
|
||||
(v) => onInput(name, v),
|
||||
),
|
||||
),
|
||||
}),
|
||||
];
|
||||
};
|
||||
const calculated = text.계산됨 !== false; // [계산] 뒤 응답은 표식이 안 붙음 — 미리보기만 false
|
||||
const light = (pair: string, on: boolean): void =>
|
||||
rows.forEach((r) => r.dataset.pair === pair && r.classList.toggle("is-on", on));
|
||||
@@ -64,6 +159,7 @@ export function buildFormula(host: HTMLElement, ctx: FormulaContext): void {
|
||||
children: calculated
|
||||
? [
|
||||
el("div", { text: `${line.이름} : ${line.글}` }),
|
||||
...vars(line),
|
||||
...(line.까닭
|
||||
? [
|
||||
el("div", {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab_Revise.ts
|
||||
* 로직 정본 개정 확인 창(PLAN 713-2 · 요구서 내역12 4장 10) — `/save` 409 `{개정, 까닭}` 를 받으면
|
||||
* 바뀌는 정본 줄(파일 · 로직 · 바뀜)을 보이고 「원문 개정 반영」 확인을 받음.
|
||||
* 확인 = 부르는 쪽이 같은 몸에 `개정확인: true` 로 다시 보냄 · 취소 = 아무것도 안 바뀜(캐시 그대로)
|
||||
* 옛 409(`stale` — 그 사이 파일 바뀜)는 이 창 대상 밖 · `reviseHits` 가 null
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, el, hideLoadingOverlay, showLoadingOverlay } from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { createListRow } from "@ui/ui_template_list_row";
|
||||
import { createBadge, type BadgeTone } from "@ui/ui_template_badge";
|
||||
import { ApiError } from "./M01_MasterData_UI_Logic_Api";
|
||||
|
||||
/** 서버 `Store_Revise.changed` 한 줄 */
|
||||
export interface ReviseHit {
|
||||
file: string;
|
||||
key: string | null;
|
||||
op: "edit" | "add" | "delete";
|
||||
이름?: string | null;
|
||||
}
|
||||
|
||||
const TEXT = {
|
||||
Title: ["원문 개정 반영", "Apply source revision"],
|
||||
Lead: [
|
||||
"로직 정본(품셈 원문) 줄을 바꿈 — 원문이 개정된 때만 저장 · 아래 {n} 줄",
|
||||
"This changes canonical logic rows (source text) — save only for a source revision · {n} rows below",
|
||||
],
|
||||
Ok: ["원문 개정 반영", "Apply revision"],
|
||||
Cancel: ["취소", "Cancel"],
|
||||
New: ["새 줄", "New row"],
|
||||
edit: ["고침", "Edit"],
|
||||
add: ["더함", "Add"],
|
||||
delete: ["지움", "Delete"],
|
||||
} as const satisfies Record<string, readonly [string, string]>;
|
||||
|
||||
const t = (key: keyof typeof TEXT, n = 0): string =>
|
||||
(TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]).replace("{n}", String(n));
|
||||
|
||||
const TONE: Record<ReviseHit["op"], BadgeTone> = { edit: "warning", add: "info", delete: "danger" };
|
||||
|
||||
/** 개정 409 면 바뀌는 줄 · 아니면 null */
|
||||
export function reviseHits(error: unknown): ReviseHit[] | null {
|
||||
if (!(error instanceof ApiError) || error.status !== 409) return null;
|
||||
const hits = (error.detail as { 개정?: ReviseHit[] } | null)?.개정;
|
||||
return Array.isArray(hits) && hits.length ? hits : null;
|
||||
}
|
||||
|
||||
/** 확인 창 — 「원문 개정 반영」이면 true · 취소 · 닫기 · Esc · 바깥 누름이면 false */
|
||||
export function askRevise(hits: ReviseHit[]): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
openModal({
|
||||
title: t("Title"),
|
||||
closeLabel: t("Cancel"),
|
||||
width: "40rem",
|
||||
// [취소] · Esc · 바깥 누름 = 닫기 길(묻는 길) · 확인은 `shut` 이라 이 길을 안 거침
|
||||
beforeClose: () => {
|
||||
resolve(false);
|
||||
return true;
|
||||
},
|
||||
mount: (body, shut, actions) => {
|
||||
body.append(
|
||||
el("p", { text: t("Lead", hits.length) }),
|
||||
...hits.map((h) =>
|
||||
createListRow({
|
||||
label: `${h.key ?? t("New")} ${h.이름 ?? ""}`.trim(),
|
||||
sub: h.file,
|
||||
trailing: createBadge(t(h.op), TONE[h.op]),
|
||||
size: 36,
|
||||
}),
|
||||
),
|
||||
);
|
||||
actions.append(
|
||||
createButton({
|
||||
label: t("Ok"),
|
||||
variant: "danger",
|
||||
onClick: () => {
|
||||
shut();
|
||||
resolve(true);
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** 저장 한 번 — 개정 409 면 창을 띄워 확인 뒤 `개정확인: true` 로 다시 보냄 · 취소면 null(캐시 그대로) */
|
||||
export async function saveRevised(
|
||||
save: (confirmed: boolean) => Promise<unknown>,
|
||||
): Promise<{ added?: { file: string; key: string }[] } | null> {
|
||||
try {
|
||||
return (await save(false)) as { added?: { file: string; key: string }[] };
|
||||
} catch (error) {
|
||||
const hits = reviseHits(error);
|
||||
if (!hits) throw error;
|
||||
hideLoadingOverlay(); // 창이 가림막 아래로 숨지 않게
|
||||
const ok = await askRevise(hits);
|
||||
showLoadingOverlay();
|
||||
return ok ? ((await save(true)) as { added?: { file: string; key: string }[] }) : null;
|
||||
}
|
||||
}
|
||||
@@ -225,3 +225,11 @@
|
||||
max-height: 40vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* 714-2 「인력·재료·경비」 — 공용 계산서 줄을 세로로 */
|
||||
.m01lab-est {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -30,10 +30,6 @@ const TEXT = {
|
||||
Calc_Title: ["시험 계산", "Trial calculation"],
|
||||
View_Est: ["인력·재료·경비", "Labor·material·expense"],
|
||||
View_Label: ["보기", "View"],
|
||||
View_Soon: [
|
||||
"준비 중 — 인력 · 재료 · 경비 묶음 보기",
|
||||
"Coming soon — labor · material · expense view",
|
||||
],
|
||||
Now_Inputs: ["지금 입력", "Current inputs"],
|
||||
Now_None: ["없음", "none"],
|
||||
G_labor: ["인력", "Labor"],
|
||||
@@ -61,6 +57,10 @@ const TEXT = {
|
||||
Modal_Close: ["닫기", "Close"],
|
||||
Formula_Left: ["변수 이름 식", "Formula with names"],
|
||||
Formula_Right: ["값이 들어간 식", "Formula with values"],
|
||||
Formula_Var: [
|
||||
"누르면 시험 계산 입력을 고침 · 다시 셈(정본 안 바뀜)",
|
||||
"Click to change the trial input and recalculate (source unchanged)",
|
||||
],
|
||||
Modal_Sum: ["한 줄 풀이", "In one line"],
|
||||
Pill_Hint: ["쓰인 자료 — 누르면 펼침", "Data used — tap to open"],
|
||||
Modal_UnitPrice: ["단가", "Unit price"],
|
||||
|
||||
@@ -124,6 +124,8 @@ export type CalcAnswer =
|
||||
export interface TextPiece {
|
||||
kind: "요소" | "표" | "로직" | "값" | "기호" | "글";
|
||||
글: string;
|
||||
/** 이름 조각(`kind: "값"` · 글 = 이름)이 가진 그 값 — 714-3 입력 단추 */
|
||||
값?: string | number;
|
||||
참조?: {
|
||||
테이블?: string;
|
||||
키: string;
|
||||
@@ -190,8 +192,22 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, body?: unknown): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}/m01${path}`, {
|
||||
/** 상세 부품이 부르는 길 앞머리(717-1) — 읽기(로직 · 요소 · 재료 · 표 · 줄) · 셈(`/calc` · `/text`)
|
||||
* · 기본 = M01(`/m01` · 시스템 관리자) · M02 = `{ read: "/m02/logic-lib", calc: "/m02/logic-lib" }`(로그인) */
|
||||
export interface LogicApiPaths {
|
||||
read: string;
|
||||
calc: string;
|
||||
}
|
||||
const M01_PATHS: LogicApiPaths = { read: "/m01", calc: "/m01" };
|
||||
let paths = M01_PATHS;
|
||||
/** 상세를 그리기 전에 한 번 — 안 넘기면 M01 길로 되돌림 · 저장 · 만들기 길은 늘 `/m01` */
|
||||
export const setLogicApiPaths = (next?: LogicApiPaths): void => {
|
||||
paths = next ?? M01_PATHS;
|
||||
};
|
||||
export const logicApiPaths = (): LogicApiPaths => paths;
|
||||
|
||||
async function request<T>(path: string, body?: unknown, base = "/m01"): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}${base}${path}`, {
|
||||
method: body === undefined ? "GET" : "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -205,13 +221,16 @@ async function request<T>(path: string, body?: unknown): Promise<T> {
|
||||
const query = (params: Record<string, string>): string => new URLSearchParams(params).toString();
|
||||
|
||||
export const fetchLogics = (): Promise<LogicSummary[]> =>
|
||||
request<{ logics: LogicSummary[] }>("/logics").then((d) => d.logics);
|
||||
request<{ logics: LogicSummary[] }>("/logics", undefined, paths.read).then((d) => d.logics);
|
||||
|
||||
/** 왼쪽 트리 목록 — 서버가 준 구분·상세구분(장 차례대로) */
|
||||
export const fetchLogicSubs = (): Promise<SubBrief[]> =>
|
||||
request<{ subs: SubBrief[] }>(`/subs?${query({ group: "로직" })}`).then((d) => d.subs);
|
||||
request<{ subs: SubBrief[] }>(`/subs?${query({ group: "로직" })}`, undefined, paths.read).then(
|
||||
(d) => d.subs,
|
||||
);
|
||||
|
||||
export const fetchLogic = (key: string): Promise<LogicOne> => request(`/logic?${query({ key })}`);
|
||||
export const fetchLogic = (key: string): Promise<LogicOne> =>
|
||||
request(`/logic?${query({ key })}`, undefined, paths.read);
|
||||
|
||||
export const fetchLogicFiles = (): Promise<LogicFile[]> =>
|
||||
request<{ files: LogicFile[] }>(`/groups/${encodeURIComponent("로직")}/files`).then(
|
||||
@@ -222,10 +241,10 @@ export const searchElements = (
|
||||
group: string,
|
||||
q: string,
|
||||
): Promise<{ total: number; items: ElementBrief[] }> =>
|
||||
request(`/elements?${query({ group, q, limit: "100" })}`);
|
||||
request(`/elements?${query({ group, q, limit: "100" })}`, undefined, paths.read);
|
||||
|
||||
export const searchPrice = (q: string): Promise<{ total: number; items: ElementBrief[] }> =>
|
||||
request(`/pick?${query({ kind: "price", q, limit: "100" })}`);
|
||||
request(`/pick?${query({ kind: "price", q, limit: "100" })}`, undefined, paths.read);
|
||||
|
||||
export const fetchMaterials = (cond: {
|
||||
sub: string;
|
||||
@@ -235,11 +254,11 @@ export const fetchMaterials = (cond: {
|
||||
/** 호표 줄 단위 — 주면 그 단위 줄만 */
|
||||
unit?: string;
|
||||
}): Promise<{ total: number; 기본: string | null; items: ElementBrief[] }> =>
|
||||
request(`/materials?${query({ ...cond, limit: "100" })}`);
|
||||
request(`/materials?${query({ ...cond, limit: "100" })}`, undefined, paths.read);
|
||||
|
||||
/** 자동값 길(`GET /logic/auto`) — 입력 이름 → 견본값 · 서버에 아직 없거나 값이 없으면 빈 채로(화면은 그냥 안 채움) */
|
||||
export const fetchAuto = (key: string): Promise<Record<string, unknown>> =>
|
||||
request<{ 값?: Record<string, unknown> }>(`/logic/auto?${query({ key })}`)
|
||||
request<{ 값?: Record<string, unknown> }>(`/logic/auto?${query({ key })}`, undefined, paths.read)
|
||||
.then((d) => d.값 ?? {})
|
||||
.catch(() => ({}));
|
||||
|
||||
@@ -250,7 +269,7 @@ export const runCalc = (body: {
|
||||
file?: string;
|
||||
/** 시험 계산에서 고른 재료 — 호표 줄 차례 → 자재품목 키 · 저장 안 함 */
|
||||
고름?: Record<string, string>;
|
||||
}): Promise<CalcAnswer> => request("/calc", body);
|
||||
}): Promise<CalcAnswer> => request("/calc", body, paths.calc);
|
||||
|
||||
export const runText = (body: {
|
||||
key: string;
|
||||
@@ -259,11 +278,12 @@ export const runText = (body: {
|
||||
file?: string;
|
||||
/** 시험 계산에서 고른 재료 — 호표 줄 차례 → 자재품목 키 · 저장 안 함 */
|
||||
고름?: Record<string, string>;
|
||||
}): Promise<TextAnswer> => request("/text", body);
|
||||
}): Promise<TextAnswer> => request("/text", body, paths.calc);
|
||||
|
||||
export const saveFiles = (
|
||||
files: SaveFile[],
|
||||
): Promise<{ files: { file: string; version: string }[] }> => request("/save", { files });
|
||||
개정확인 = false,
|
||||
): Promise<{ files: { file: string; version: string }[] }> => request("/save", { files, 개정확인 });
|
||||
|
||||
/** 본떠 만들기 — 정본·자체 둘 다 본뜸 · 새 키(`GX…`)는 서버가 줌(계약 5장) */
|
||||
export const copyLogic = (
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import type { ElementBrief, NamedFormula } from "./M01_MasterData_UI_Logic_Api";
|
||||
import { logicApiPaths, type ElementBrief, type NamedFormula } from "./M01_MasterData_UI_Logic_Api";
|
||||
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
|
||||
import { tx } from "./M01_MasterData_UI_Logic_Text";
|
||||
|
||||
@@ -31,7 +31,7 @@ const tableCache = new Map<string, Promise<TableRow | null>>();
|
||||
|
||||
async function getJson<T>(path: string): Promise<T | null> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/m01${path}`);
|
||||
const res = await fetch(`${API_BASE_URL}${logicApiPaths().read}${path}`);
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as T;
|
||||
} catch {
|
||||
|
||||
@@ -251,7 +251,7 @@ def match_line(line: dict[str, Any], ctx: Ctx, full: bool = True) -> dict[str, A
|
||||
target = {"호표": no, "이름": h.get("이름", ""), "단위": h.get("단위"), "입력값": {}}
|
||||
target.update(못읽음=[], 출처={}, 환산=conv, 몫=None, 배=conv["배"])
|
||||
ok = bool(made) and conv["배"] is not None and conv["갈래"] != "추정"
|
||||
text = f"확인된 사전조합 {made}" if made else f"제{no}호표 먼저 확인"
|
||||
text = f"확인된 일위대가 {made}" if made else f"제{no}호표 먼저 확인"
|
||||
return {**out, "상태": "호표", "체크": ok, "대상": [target], "글": text}
|
||||
if kind == "노임":
|
||||
return {**out, "상태": "노임", "글": "노임 · 그릇 없음"}
|
||||
|
||||
@@ -32,9 +32,9 @@ from M01_MasterData import M01_MasterData_Store_Cache as m01_cache
|
||||
from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match
|
||||
from M02_MasterTemplete.M02_Hopyo_Words import conds, mtypes, specnums
|
||||
|
||||
mb, mu = m01.cm.mf.mb, m01.cm.mf.mp.mm.mu # 묶음(712-1) · 단위(712-2) 한 벌
|
||||
FP_VERSION = 1 # 지문 꼴이 바뀌면 올림 — 옛 캐시 버림
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
_PER = re.compile(r"원\s*/\s*(\d[\d,]*\.?\d*|\.\d+)?")
|
||||
_SUB = re.compile(r"^\s*로직\(\s*(\w+)\s*(?:,(.*))?\)\s*$")
|
||||
EQ_CODE = re.compile(r"^\d{4}-\d{4}$")
|
||||
_PCT = re.compile(r"\*\s*([\d.]+)\s*/\s*100")
|
||||
@@ -84,7 +84,7 @@ def whole() -> Any:
|
||||
|
||||
|
||||
def is_mach_logic(row: dict[str, Any]) -> bool:
|
||||
return "시간당" in str(row.get("이름") or "")
|
||||
return mb.is_machine(row) # 기계경비 판별 한 곳(712-1 · 결과단위 원/시간)
|
||||
|
||||
|
||||
def eq_of(code: Any) -> tuple[set[str], set[float]]:
|
||||
@@ -95,8 +95,8 @@ def eq_of(code: Any) -> tuple[set[str], set[float]]:
|
||||
|
||||
|
||||
def times_of(row: dict[str, Any]) -> float:
|
||||
per = _PER.findall(str(row.get("결과단위") or ""))
|
||||
return float(per[0].replace(",", "")) if per and per[0] else 1.0
|
||||
per = mu.per(row.get("결과단위")) # 결과단위 배 읽기 한 벌(712-2)
|
||||
return float(per[0][0]) if per else 1.0
|
||||
|
||||
|
||||
def _args(text: str | None) -> dict[str, str]:
|
||||
|
||||
@@ -42,6 +42,7 @@ from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
|
||||
from M02_MasterTemplete import M02_Labor_Fixed as fixed
|
||||
from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
|
||||
from M02_MasterTemplete import M02_Labor_Patch as patch
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_Labor_Spec as spec
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
@@ -127,6 +128,7 @@ def clean(doc: Any) -> dict[str, Any]:
|
||||
"별칭": alias,
|
||||
"메모": str(memo) if memo else None,
|
||||
"수량": _qty(one.get("수량"), where),
|
||||
**patch.ref_clean(one, ref, where), # 그 줄 덮개(715-3)
|
||||
}
|
||||
)
|
||||
continue
|
||||
@@ -155,6 +157,7 @@ def clean(doc: Any) -> dict[str, Any]:
|
||||
**({"켜쌓기율": rate} if rate is not None else {}),
|
||||
**({"고름": picks} if picks else {}),
|
||||
**({"승격": True} if one.get("승격") is True else {}),
|
||||
**patch.clean(one, where, whole, logic), # 그 줄 덮개(715-3)
|
||||
**(
|
||||
{"자재표시": shown} if (shown := _mat_marks(one.get("자재표시"), logic)) else {}
|
||||
),
|
||||
@@ -512,7 +515,7 @@ def calc(
|
||||
lines = []
|
||||
for one in doc.get("담은로직") or []:
|
||||
if _is_ref(one):
|
||||
lines.append(ref_line(one["참조"], expand(one["참조"])))
|
||||
lines.append(ref_line(one["참조"], patch.ref(one, expand)))
|
||||
continue
|
||||
if fixed.is_fixed(one):
|
||||
lines.append(fixed.line(whole, one[fixed.FIXED]))
|
||||
@@ -525,6 +528,9 @@ def calc(
|
||||
"분모": pill.get("단위"),
|
||||
"배": pill.get("배") or 1,
|
||||
}
|
||||
if one.get(patch.FIELD): # 줄 덮개(715-3) = 이름 없는 단가산출 셈 · 별도계상까지
|
||||
lines.append({**line, **patch.run(whole, one, read)})
|
||||
continue
|
||||
got = run_line(whole, code, one.get("입력값"), one.get("켜쌓기율"), one.get("고름"))
|
||||
lines.append({**line, **drop_apart(got, one.get("자재표시"))})
|
||||
mats = [
|
||||
|
||||
@@ -11,6 +11,7 @@ from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Labor_Fixed as fixed
|
||||
from M02_MasterTemplete import M02_Labor_Patch as patch
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_Sangeun_Calc as calc
|
||||
|
||||
@@ -81,10 +82,13 @@ class Expand:
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
|
||||
pill = pill or {}
|
||||
got = labor.run_line(
|
||||
self.whole, one["로직"], one.get("입력값"), one.get("켜쌓기율"), one.get("고름")
|
||||
)
|
||||
got = labor.drop_apart(got, one.get("자재표시")) # 별도계상 로직 자재 뺌(222-1)
|
||||
if one.get(patch.FIELD): # 줄 덮개(715-3) = 이름 없는 단가산출 · 별도계상까지
|
||||
got = patch.run(self.whole, one, self.read)
|
||||
else:
|
||||
got = labor.run_line(
|
||||
self.whole, one["로직"], one.get("입력값"), one.get("켜쌓기율"), one.get("고름")
|
||||
)
|
||||
got = labor.drop_apart(got, one.get("자재표시")) # 별도계상 로직 자재 뺌(222-1)
|
||||
times = labor._number(pill.get("배")) or Decimal(1)
|
||||
base = got.get("기준")
|
||||
return {
|
||||
@@ -109,9 +113,10 @@ class Expand:
|
||||
inner, rows, why = [], [], None
|
||||
for one in lines:
|
||||
ref = one.get("참조")
|
||||
if isinstance(ref, dict):
|
||||
head, sub = {"참조": ref}, self(ref, trail)
|
||||
elif one.get("로직"): # 로직 줄(51-11) = 1 단위 값(기준 ÷ 배 · 승격과 같음)
|
||||
if isinstance(ref, dict): # 줄 덮개(715-3) 있는 SK 참조 = 그 줄만 다시 셈
|
||||
head = {"참조": ref}
|
||||
sub = self(ref, trail) if not one.get(patch.FIELD) else patch.ref(one, self)
|
||||
elif one.get("로직"): # 로직 줄(51-11) = 1 단위 값(기준 ÷ 배 · 승격과 같음) · 덮개 있음
|
||||
head, sub = {"로직": one["로직"]}, self._logic(one, by_logic.get(one["로직"]))
|
||||
elif fixed.is_fixed(one): # 고정 줄(705-3) = 줄 단가 · 비율 줄 = 끝에(`ratio_row`)
|
||||
head, sub = {"고정": one["고정"]}, fixed.line(self.whole, one["고정"])
|
||||
@@ -156,7 +161,14 @@ class Expand:
|
||||
return {
|
||||
"이름": doc.get("이름"),
|
||||
"단위": doc.get("단위"),
|
||||
"머리": head_of(doc),
|
||||
"기준": None if why else labor._sum(rows),
|
||||
"까닭": why,
|
||||
"안": inner,
|
||||
}
|
||||
|
||||
|
||||
def head_of(doc: dict) -> str | None:
|
||||
"""상세 머리 「1 ㎡ 당」(715-3 · 일위대가 = 기준단위 1 당) — 단위 빈 옛 문서는 None."""
|
||||
unit = str(doc.get("단위") or "").strip()
|
||||
return f"1 {unit} 당" if unit else None
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""일위대가 · 사전조합 줄 덮개(PLAN 715-3 · 요구서 내역12 4장 16 · 4-1 확정 5).
|
||||
|
||||
줄 칸 `패치` = 단가산출(SK) 문서 꼴 `{덮개, 뺀줄, 더한줄}`(한 벌 · `M02_Sangeun_Doc`) — 그 줄에만 ·
|
||||
로직 줄(`{로직, 입력값, 고름, 자재표시, 패치}`) · 단가산출 참조 줄(`{참조: SK, 패치}`)만 받음 ·
|
||||
줄 `고름` · `자재표시`(호표 차례 열쇠)는 바탕 줄 차례 그대로 — 셈 때 cid 로 바꿔 뺀줄 뒤에도 제자리.
|
||||
더한줄은 인력 · 재료 · 기계 · 로직 · 고정만(참조는 일위대가에 참조 줄로).
|
||||
셈 = 줄을 이름 없는 SK 문서로(`as_doc`) → `M02_Sangeun_Calc` 한 벌 · 로직 줄 기준 = 결과단위 값(배는
|
||||
알약 · `settle` 그대로) · 참조 줄 = 1 단위 값. [단가산출로 저장] = `as_doc` + 이름 · 단위 → 층 SK.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Sangeun_Doc as sd
|
||||
|
||||
FIELD = "패치"
|
||||
KEEP = ("덮개", "뺀줄", "더한줄")
|
||||
TEMP = "SK-000000" # 이름 없는 줄 단가산출 — 저장 안 함
|
||||
|
||||
|
||||
def _bad(text: str):
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
|
||||
return store.StoreError(400, text)
|
||||
|
||||
|
||||
def clean(one: dict, where: str, whole: Any, row: dict | None, opened: list | None = None) -> dict:
|
||||
"""줄 칸 `패치` 정리 — 없거나 비면 `{}`(칸 안 둠) · 틀리면 400."""
|
||||
raw = one.get(FIELD)
|
||||
if not raw:
|
||||
return {}
|
||||
if not isinstance(raw, dict):
|
||||
raise _bad(f"{where} · 패치는 묶음")
|
||||
if any("참조" in x for x in raw.get("더한줄") or [] if isinstance(x, dict)):
|
||||
raise _bad(f"{where} · 줄 더한줄엔 참조 못 넣음 — 일위대가에 참조 줄로")
|
||||
body = {k: raw.get(k) for k in KEEP}
|
||||
try:
|
||||
got = sd.clean(
|
||||
{"키": TEMP, "이름": "줄", "단위": "식", **body, "열린입력": opened or []}, row, whole
|
||||
)
|
||||
except sd.DocError as e:
|
||||
raise _bad(f"{where} · {e}") from e
|
||||
out = {k: got[k] for k in KEEP if got[k]}
|
||||
return {FIELD: out} if out else {}
|
||||
|
||||
|
||||
def ref_clean(one: dict, ref: dict, where: str) -> dict:
|
||||
"""단가산출 참조 줄 패치 — 그 SK 의 열린입력이면 품 배율만."""
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
|
||||
if not one.get(FIELD):
|
||||
return {}
|
||||
if refs.kind_of(ref["키"]) != sd.KIND:
|
||||
raise _bad(f"{where} · 줄 덮개는 로직 줄 · 단가산출 참조 줄만")
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
|
||||
doc = refs.read_ref(ref) or {}
|
||||
return clean(one, where, m01.loaded()[1], None, doc.get("열린입력"))
|
||||
|
||||
|
||||
def _keyed(value: Any, ids: list[str]) -> dict:
|
||||
"""차례 열쇠 → cid 열쇠(바탕 줄 차례)."""
|
||||
out = {}
|
||||
for n, v in (value or {}).items() if isinstance(value, dict) else ():
|
||||
at = int(n) if str(n).isdigit() else -1
|
||||
if 0 <= at < len(ids):
|
||||
out[ids[at]] = v
|
||||
return out
|
||||
|
||||
|
||||
def as_doc(one: dict, whole: Any, name: str = "줄", unit: str = "식") -> dict[str, Any]:
|
||||
"""일위대가 줄 → SK 문서(바탕 = 그 로직 · 그 SK) — 고름 · 자재표시는 cid 로."""
|
||||
body = {k: (one.get(FIELD) or {}).get(k) or ([] if k != "덮개" else {}) for k in KEEP}
|
||||
head = {"키": TEMP, "이름": name, "단위": unit, **body}
|
||||
if "참조" in one:
|
||||
return {**head, "바탕": {"참조": one["참조"]}}
|
||||
try:
|
||||
ids = sd.line_ids(whole.logic(one["로직"]))
|
||||
except Exception: # noqa: BLE001 — 없는 로직 = 셈이 까닭
|
||||
ids = []
|
||||
extra = {"켜쌓기율": one["켜쌓기율"]} if one.get("켜쌓기율") is not None else {}
|
||||
return {
|
||||
**head,
|
||||
**extra,
|
||||
"바탕": {"로직": one["로직"]},
|
||||
"입력값": dict(one.get("입력값") or {}),
|
||||
"고름": _keyed(one.get("고름"), ids),
|
||||
"자재표시": _keyed(one.get("자재표시"), ids),
|
||||
}
|
||||
|
||||
|
||||
def run(whole: Any, one: dict, read: Any = None) -> dict[str, Any]:
|
||||
"""패치 있는 로직 줄 기준(`run_line` 꼴 · 별도계상 뺀 뒤 · 기준 = 결과단위 값)."""
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_Sangeun_Calc as calc
|
||||
|
||||
try:
|
||||
doc = sd.clean(as_doc(one, whole), None, whole)
|
||||
return calc.run(doc, whole, None, read or refs.read_ref)
|
||||
except sd.DocError as e:
|
||||
return {"기준": None, "까닭": str(e)}
|
||||
|
||||
|
||||
def ref(one: dict, expand: Any) -> dict[str, Any]:
|
||||
"""참조 줄 펼침 — 패치 있는 SK 참조는 그 줄만 다시 셈(캐시 안 씀) · 그 밖 `expand` 그대로."""
|
||||
from M02_MasterTemplete import M02_Sangeun_Calc as calc
|
||||
|
||||
if not one.get(FIELD):
|
||||
return expand(one["참조"])
|
||||
doc = as_doc(one, expand.whole)
|
||||
base = expand.read(one["참조"]) or {}
|
||||
doc = {**doc, "이름": base.get("이름") or doc["이름"], "단위": base.get("단위") or doc["단위"]}
|
||||
return calc.unit(doc, expand.whole, expand, ())
|
||||
|
||||
|
||||
def to_sangeun(one: dict, name: str, unit: str, whole: Any) -> dict[str, Any]:
|
||||
"""[단가산출로 저장] 문서 — 줄의 바탕 · 입력값 · 고름 · 덮개 → 이름 붙인 SK(키는 서버)."""
|
||||
if "로직" not in one and "참조" not in one:
|
||||
raise _bad("로직 줄 · 단가산출 참조 줄만 단가산출로 저장")
|
||||
doc = as_doc(one, whole, name, unit)
|
||||
doc.pop("키")
|
||||
return doc
|
||||
@@ -163,7 +163,10 @@ def clean_doc(kind: str, doc: Any) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {"종류": TITLE[kind], "키": key}
|
||||
if not name:
|
||||
raise _bad("사전조합 이름이 빔")
|
||||
out.update(이름=name, 단위=unit or None, 담은로직=_combo_lines(doc, key))
|
||||
lines = _combo_lines(doc, key)
|
||||
if not unit and (lines or doc.get("자재")): # 일위대가 = 기준단위 1 당(요구 27 · 715-3) ·
|
||||
raise _bad("일위대가 단위가 빔 — 1 단위 값의 단위") # 빈 새 문서(이름만)는 받음
|
||||
out.update(이름=name, 단위=unit or None, 담은로직=lines)
|
||||
out["자재"] = _combo_mats(doc, out["담은로직"])
|
||||
out.update({k: str(doc.get(k) or "").strip() for k in ("구분", "상세구분")})
|
||||
out["본뜬"] = doc["본뜬"] if isinstance(doc.get("본뜬"), dict) else None
|
||||
@@ -226,9 +229,9 @@ def _combo_lines(doc: dict, key: str) -> list[dict[str, Any]]:
|
||||
|
||||
lines = []
|
||||
for one in labor.clean({"담은로직": doc.get("담은로직") or []})["담은로직"]:
|
||||
head = {k: one[k] for k in ("참조", "고정") if k in one} or {"로직": one["로직"]}
|
||||
head = {k: one[k] for k in ("참조", "고정", "패치") if k in one} # 패치 = 줄 덮개(715-3)
|
||||
if "로직" in one:
|
||||
head["입력값"] = one["입력값"]
|
||||
head.update(로직=one["로직"], 입력값=one["입력값"])
|
||||
head.update({k: one[k] for k in ("켜쌓기율", "고름", "자재표시") if k in one})
|
||||
head.update({"별칭": one["별칭"]} if one["별칭"] else {}) # 실무 품명(324-3)
|
||||
lines.append({**head, "수량": one["수량"] or {"손": 1}, "메모": one["메모"]})
|
||||
@@ -299,6 +302,19 @@ def summary(folder: Path, kind: str, key: str, expand: Expand | None = None) ->
|
||||
layer, owner = where(folder)
|
||||
base = expand({"층": layer, "주인": owner, "키": key}).get("기준") or {}
|
||||
out["기준금액"] = float(base["계"]) if "계" in base else None
|
||||
# 717-2a 목록 열 — 바탕(로직 「키 이름」 | 참조 키 | None) · 고친 줄 수 · 쓰는 곳 수
|
||||
if kind == "sangeun":
|
||||
top, (layer, owner) = doc.get("바탕") or {}, where(folder)
|
||||
logic = top.get("로직")
|
||||
out["바탕"] = logic or (top.get("참조") or {}).get("키")
|
||||
if logic and expand is not None:
|
||||
try:
|
||||
out["바탕"] = f"{logic} {expand.whole.logic(logic).get('이름') or ''}".strip()
|
||||
except Exception: # noqa: BLE001 — 없는 로직 = 키만(셈 때 까닭)
|
||||
pass
|
||||
out["고친줄수"] = sum(len(doc.get(k) or ()) for k in ("덮개", "더한줄", "뺀줄"))
|
||||
# ponytail: SK 마다 모든 층 훑음(users) — 목록이 느려지면 층 훑기 한 번 셈으로
|
||||
out["쓰는곳수"] = len(users({"층": layer, "주인": owner, "키": key}))
|
||||
return out
|
||||
|
||||
|
||||
@@ -318,7 +334,8 @@ def users(ref: Ref) -> list[str]:
|
||||
"""`ref` 를 담은 일위대가 · 사전조합 · 단가산출(바탕 · 더한줄) — 「어디 이름」 목록(모든 층 훑음 ·
|
||||
프로젝트 뺌)."""
|
||||
rid, out = ref_id(ref), []
|
||||
for kind, what in (("labor", "일위대가"), ("precombo", "사전조합"), ("sangeun", "단가산출")):
|
||||
# 「일위대가」 = 사전조합 새 이름(내역 14 확정 1) · 구조물 조합 일위대가 = 「구조물 조합」
|
||||
for kind, what in (("labor", "구조물 조합"), ("precombo", "일위대가"), ("sangeun", "단가산출")):
|
||||
for at, path in layers.layer_files(kind):
|
||||
try:
|
||||
doc = json.loads(path.read_bytes())
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""단가산출 로직 상세 읽기 · 셈 길(PLAN 717-1) — `/api/m02/logic-lib/…` · 로그인만(`main.py`).
|
||||
|
||||
M01 상세 부품(`M01_MasterData_UI_Logic_Api.ts` · `Api_Fetch.ts` · `Logic_Note.ts`)이 길 앞머리만
|
||||
`/m02/logic-lib` 로 바꿔 부름 — 길 · 몸 · 답 = M01 `/api/m01/…` 와 같음(M01 길 함수를 부름).
|
||||
쓰기 없음 — `/calc` · `/text` 의 `row`(저장 안 한 고친 줄)는 메모리 사본에서만 셈(`Store._swap`).
|
||||
`/logics` · `/subs` · `/logic` · 원문은 `M02_MasterTemplete_Router_Combos.py` 에 있음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from M01_MasterData import M01_MasterData_Router as m01
|
||||
|
||||
router = APIRouter(prefix="/api/m02/logic-lib", tags=["M02 Logic Lib Read"])
|
||||
|
||||
#: `/rows` 로 줄을 내줄 요소 파일 앞머리 — 인력 · 재료 · 기계(고르기 표 · 품목 찾기)
|
||||
ROW_FILES = ("인력", "재료", "기계")
|
||||
#: `/table` 로 내줄 표 파일 앞머리 — 소요량 · 계수(설명 카드 원문 표)
|
||||
TABLE_FILES = ("소요량_", "계수_")
|
||||
|
||||
|
||||
@router.get("/logic/auto")
|
||||
def get_auto(key: str) -> dict:
|
||||
return m01.get_logic_auto(key)
|
||||
|
||||
|
||||
@router.get("/logic/flow")
|
||||
def get_flow(key: str, file: str = "") -> dict:
|
||||
"""흐름 보기(읽기 · 변환해도 파일 안 바뀜)."""
|
||||
return m01.get_logic_flow(key, file)
|
||||
|
||||
|
||||
@router.get("/elements")
|
||||
def get_elements(group: str, q: str = "", limit: int = 50) -> dict:
|
||||
return m01.get_elements(group, q, min(limit, 200))
|
||||
|
||||
|
||||
@router.get("/pick")
|
||||
def get_pick(kind: str, q: str = "", limit: int = 50) -> dict:
|
||||
return m01.get_pick(kind, q, min(limit, 200))
|
||||
|
||||
|
||||
@router.get("/materials")
|
||||
def get_materials(
|
||||
sub: str, detail: str = "", spec: str = "", region: str = "", unit: str = "", limit: int = 50
|
||||
) -> dict:
|
||||
return m01.get_materials(sub, detail, spec, region, unit, min(limit, 200))
|
||||
|
||||
|
||||
@router.get("/rows")
|
||||
def get_rows(file: str, page: int = 1, size: int = 50, q: str = "", sub: str = "") -> dict:
|
||||
if not file.startswith(ROW_FILES):
|
||||
raise HTTPException(status_code=403, detail="요소 파일(인력 · 재료 · 기계)만 읽음")
|
||||
return m01.get_rows(file, page, size, q, False, sub, "", "")
|
||||
|
||||
|
||||
@router.get("/table")
|
||||
def get_table(file: str, key: str) -> dict:
|
||||
if not file.startswith(TABLE_FILES):
|
||||
raise HTTPException(status_code=403, detail="소요량 · 계수 표만 읽음")
|
||||
return m01.get_table(file, key)
|
||||
|
||||
|
||||
@router.post("/calc")
|
||||
def post_calc(body: m01.CalcBody) -> dict:
|
||||
"""M01 `/calc` 와 같음 — `row` = 저장 안 한 고친 줄(셈만 · 쓰기 없음)."""
|
||||
return m01.post_calc(body)
|
||||
|
||||
|
||||
@router.post("/text")
|
||||
def post_text(body: m01.CalcBody) -> dict:
|
||||
return m01.post_text(body)
|
||||
@@ -31,6 +31,9 @@ from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
|
||||
from M02_MasterTemplete import M02_Logic_Settle as settle
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
|
||||
mf = m01.cm.mf
|
||||
import master_sheet_line as msl # noqa: E402 — 계산서 줄 덧칸(714-1) · scripts 길은 `m01` 이 둠
|
||||
|
||||
SHAPE = 3 # 줄 꼴이 바뀌면 올림 — 옛 저장본을 안 씀
|
||||
KIND = "m02_logic_standard"
|
||||
REGION = "지역" # 지역 = 자동값 한 벌(정함 8)
|
||||
@@ -331,7 +334,7 @@ def sheet(key: str, change: dict) -> dict:
|
||||
raise m01.StoreError(404, str(e)) from e
|
||||
given, ax = _given(whole, logic, change)
|
||||
filled = m01.cm.mf.fill_empty(logic, given)
|
||||
got = m01.mtx.lines(files, key, filled)
|
||||
got = msl.annotate(whole, key, filled, m01.mtx.lines(files, key, filled)) # 줄 덧칸(714-1)
|
||||
done = settle.settle(whole, key, filled)
|
||||
if "결과" not in done: # 돈 로직 — 줄마다 버림(B09 산근 규칙)
|
||||
settle.apply(got.get("groups") or [], m01.cm.mf.run(whole, key, filled), done)
|
||||
@@ -341,7 +344,7 @@ def sheet(key: str, change: dict) -> dict:
|
||||
"비목": g["비목"],
|
||||
"소계": g.get("소계"),
|
||||
"끝수": g.get("끝수") or "",
|
||||
"줄": [{k: line.get(k) for k in ("이름", "글", "금액")} for line in g["줄"]],
|
||||
"줄": [{k: line.get(k) for k in ("이름", "글", "금액", *msl.KEYS)} for line in g["줄"]],
|
||||
}
|
||||
for g in got.get("groups") or []
|
||||
]
|
||||
@@ -356,5 +359,6 @@ def sheet(key: str, change: dict) -> dict:
|
||||
"입력": given,
|
||||
"묶음": groups,
|
||||
"계": got.get("계"),
|
||||
"기준단위": got.get("기준단위"),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -10,8 +10,9 @@ export type Layer = "system" | "company" | "personal" | "project";
|
||||
/** 서버 종류 — 화면의 「표 양식」 = table · 「도면 양식」 = drawing · 「구조물 도면」 = structure ·
|
||||
* 「면적산출」 = area(구조물집계표 열마다 한 통합문서) ·
|
||||
* 「일위대가」 = labor(열마다 문서 · 39장) ·
|
||||
* 「사전조합」 = precombo(일위대가 사전조합 서고 · 50-3 · 키가 이름 · 옛 단가승격 promote 는 134-2 에서 뺌) */
|
||||
export type Kind = "table" | "drawing" | "structure" | "area" | "labor" | "precombo";
|
||||
* 「사전조합」 = precombo(일위대가 사전조합 서고 · 50-3 · 키가 이름 · 옛 단가승격 promote 는 134-2 에서 뺌) ·
|
||||
* 「단가산출」 = sangeun(SK- 서고 · 715-2 · 717-2a) */
|
||||
export type Kind = "table" | "drawing" | "structure" | "area" | "labor" | "precombo" | "sangeun";
|
||||
|
||||
export interface TemplateInfo {
|
||||
종류: Kind;
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from "@ui/ui_template_elements";
|
||||
import { loadingError, showLoading } from "@ui/ui_template_loading";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source";
|
||||
import {
|
||||
buildHopyo,
|
||||
@@ -429,7 +430,7 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement {
|
||||
const ask = whole
|
||||
? `호표 통째로 만듦 — ${h.통째!.대상.map(targetText).join(" · ")}`
|
||||
: `품셈 줄 ${put.length}${diff ? `(품 다름 ${diff})` : ""} · 고정 줄 ${h.줄.length - put.length - outs}` +
|
||||
` · 뺌 ${outs} — ${money}(미리 셈 · 끝수 차 있음) — 사전조합 생성`;
|
||||
` · 뺌 ${outs} — ${money}(미리 셈 · 끝수 차 있음) — ${L("M02_PrecomboTitle")} 생성`;
|
||||
if (!(await showConfirmDialog(ask, "생성"))) return;
|
||||
const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: opt.key, 번: opt.no };
|
||||
showLoadingOverlay();
|
||||
@@ -530,7 +531,11 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement {
|
||||
draw();
|
||||
});
|
||||
const whole = !!h.통째 && (d.통째 ?? h.통째.체크);
|
||||
const go = createButton({ label: "사전조합 생성", variant: "filled", size: "sm" });
|
||||
const go = createButton({
|
||||
label: `${L("M02_PrecomboTitle")} 생성`,
|
||||
variant: "filled",
|
||||
size: "sm",
|
||||
});
|
||||
go.setAttribute("data-hopyo-make", "");
|
||||
go.addEventListener("click", () => void make(h));
|
||||
tools.push(all);
|
||||
@@ -573,7 +578,7 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement {
|
||||
...(h.만든
|
||||
? [
|
||||
note(
|
||||
`만든 사전조합 ${h.만든.키}${h.만든.확인 ? " · 확인함" : ""}` +
|
||||
`만든 ${L("M02_PrecomboTitle")} ${h.만든.키}${h.만든.확인 ? " · 확인함" : ""}` +
|
||||
(h.만든.품셈
|
||||
? ` · 품셈 줄 ${h.만든.품셈.length} · 고정 줄 ${h.만든.고정 ?? 0}`
|
||||
: ""),
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import type { HopyoLine, HopyoPart } from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import {
|
||||
checkable,
|
||||
@@ -170,7 +171,10 @@ function actsCell(line: HopyoLine, parts: HopyoPart[], ctx: RowsContext): HTMLEl
|
||||
createButton({ label, variant: "ghost", size: "sm", onClick: run });
|
||||
const out = el("select", {
|
||||
className: "ui-input ui-input--sm",
|
||||
attrs: { title: "로직없음 · 사용안함 — 사전조합에 안 넣고 장부에 남김", "data-out": "" },
|
||||
attrs: {
|
||||
title: `로직없음 · 사용안함 — ${L("M02_PrecomboTitle")}에 안 넣고 장부에 남김`,
|
||||
"data-out": "",
|
||||
},
|
||||
children: OUT_TEXT.map(([value, text]) => el("option", { text, attrs: { value } })),
|
||||
}) as HTMLSelectElement;
|
||||
out.value = ctx.d.뺌[line.차례] ?? "";
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import { loadingError, showLoading } from "@ui/ui_template_loading";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import {
|
||||
readLedger,
|
||||
saveLedger,
|
||||
@@ -326,7 +327,7 @@ export function buildHopyoLedger(opt: LedgerOptions): HTMLElement {
|
||||
className: "ui-toolbar m02-hopyo__bar",
|
||||
children: [
|
||||
el("strong", { text: LEDGER_TITLE }),
|
||||
note(`${LEDGER_HINT} · 사전조합 생성 때 남음 · 아래 층이 이김`),
|
||||
note(`${LEDGER_HINT} · ${L("M02_PrecomboTitle")} 생성 때 남음 · 아래 층이 이김`),
|
||||
search,
|
||||
kind,
|
||||
...(opt.canWrite ? [save, drop] : []),
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import { listHopyoPicks, type HopyoPickRow } from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import type { LibraryPick, PickExtra } from "./M02_MasterTemplete_Labor_Pick";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
@@ -21,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") },
|
||||
@@ -82,7 +84,7 @@ export function hopyoExtra(row: HopyoPickRow, lib: LibraryPick): PickExtra {
|
||||
export async function pickHopyo(row: HopyoPickRow, lib: LibraryPick): Promise<void> {
|
||||
const alias = row.이름.slice(0, ALIAS_MAX).trim() || null;
|
||||
if (row.확인) {
|
||||
showToast(`「${row.이름}」 → 확인한 사전조합 ${row.확인.키}`, "success");
|
||||
showToast(`「${row.이름}」 → 확인한 ${L("M02_PrecomboTitle")} ${row.확인.키}`, "success");
|
||||
return lib.pick(row.확인, row.이름, alias);
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -12,6 +12,7 @@ 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 { t as L } from "@ui/ui_template_locale";
|
||||
import {
|
||||
pageScope,
|
||||
relinkApply,
|
||||
@@ -43,7 +44,7 @@ const won = (v: number | null | undefined): string =>
|
||||
export function relinkKeys(keys: string[]): RelinkSource {
|
||||
const scope = pageScope();
|
||||
return {
|
||||
title: `이 공사 단가로 다시 잇기 — 사전조합 ${keys.length}`,
|
||||
title: `이 공사 단가로 다시 잇기 — ${L("M02_PrecomboTitle")} ${keys.length}`,
|
||||
priceLabel: "최신 판 단가",
|
||||
rows: () => relinkRows(scope, { 키: keys }),
|
||||
apply: async (changes) => {
|
||||
@@ -51,7 +52,7 @@ export function relinkKeys(keys: string[]): RelinkSource {
|
||||
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("후"))}`,
|
||||
note: `바뀐 줄 ${got.바뀜} · ${L("M02_PrecomboTitle")} ${got.문서.length} · 1 단위 값 합 ${won(sum("전"))} → ${won(sum("후"))}`,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import type { Sheet } from "../A00_Common/spreadsheet/spreadsheet_types";
|
||||
import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet";
|
||||
import { StaleError } from "./M02_MasterTemplete_Api_Fetch";
|
||||
@@ -239,7 +240,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
variant: "danger",
|
||||
size: "sm",
|
||||
onClick: async () => {
|
||||
const ask = `실무자료 「${opt.파일}」을 지움 — 만든 사전조합 · 별칭 장부는 그대로`;
|
||||
const ask = `실무자료 「${opt.파일}」을 지움 — 만든 ${L("M02_PrecomboTitle")} · 별칭 장부는 그대로`;
|
||||
if (!(await showConfirmDialog(ask, "삭제"))) return;
|
||||
try {
|
||||
await deleteHopyo(opt.scope, opt.key);
|
||||
@@ -427,7 +428,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
const nos = [...checked].sort((a, b) => a - b);
|
||||
const fresh = nos.filter((no) => !hasDraft(draftKey(opt.scope, opt.key, no))).length;
|
||||
const ask =
|
||||
`고른 호표 ${nos.length}개를 체크한 줄로 사전조합 생성` +
|
||||
`고른 호표 ${nos.length}개를 체크한 줄로 ${L("M02_PrecomboTitle")} 생성` +
|
||||
(fresh ? ` — 확인 창에서 안 고친 ${fresh}개는 서버 제안 체크 줄` : "");
|
||||
if (!(await showConfirmDialog(ask, "저장"))) return;
|
||||
go.disabled = true;
|
||||
@@ -510,7 +511,10 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
}
|
||||
if (ok) document.dispatchEvent(new Event(HOPYO_BUILT));
|
||||
result.replaceChildren(
|
||||
table(["호표", "사전조합", "빠진 줄", "값 차이(참고)"], [...rows, ...skipped]),
|
||||
table(
|
||||
["호표", `${L("M02_PrecomboTitle")}`, "빠진 줄", "값 차이(참고)"],
|
||||
[...rows, ...skipped],
|
||||
),
|
||||
);
|
||||
const all = order.length + skipped.length;
|
||||
showToast(`만듦 ${ok} / ${all}`, ok === all ? "success" : "warning");
|
||||
@@ -526,7 +530,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
/** [모두 고정으로 들여오기](705-3) — 물음 한 번 · 서버가 호표 모두를 하위부터(숫자 = 서버가 파일에서) */
|
||||
async function importAll(): Promise<void> {
|
||||
const ask =
|
||||
`호표 ${briefs.length}개를 엑셀 숫자 그대로(고정 줄) 사전조합으로 들여옴 — ` +
|
||||
`호표 ${briefs.length}개를 엑셀 숫자 그대로(고정 줄) ${L("M02_PrecomboTitle")}으로 들여옴 — ` +
|
||||
"이미 만든 호표는 그대로 · 품셈 맞춤은 뒤에 호표 [수정]에서 [품셈으로 바꾸기]";
|
||||
if (!(await showConfirmDialog(ask, "들여오기"))) return;
|
||||
fixAll.disabled = true;
|
||||
|
||||
@@ -17,6 +17,7 @@ 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 { t as L } from "@ui/ui_template_locale";
|
||||
import {
|
||||
buildHopyoItem,
|
||||
hopyoSiblings,
|
||||
@@ -101,7 +102,7 @@ export async function toItem(o: ToItemOptions): Promise<void> {
|
||||
actions.append(go);
|
||||
body.append(
|
||||
createText(
|
||||
"체크한 줄 = 품셈 줄 · 나머지 = 고정 줄(엑셀 숫자 그대로) — 사전조합을 거치지 않고 구조물 조합 항목을 바로 만듦",
|
||||
`체크한 줄 = 품셈 줄 · 나머지 = 고정 줄(엑셀 숫자 그대로) — ${L("M02_PrecomboTitle")}을 거치지 않고 구조물 조합 항목을 바로 만듦`,
|
||||
"muted",
|
||||
),
|
||||
name.root,
|
||||
|
||||
@@ -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<PickExtra[]>;
|
||||
/** 종류 고르기 칸(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<void> => {
|
||||
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],
|
||||
}),
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -2,23 +2,17 @@
|
||||
* M02_MasterTemplete_Labor_Sheet.ts
|
||||
* 계산서 보기(704-2) — 기준 목록 줄을 누르면 담기 전에 한 장: 맨 위 「출처 · 판」 · 비목마다
|
||||
* 「이름 | 노임 × 품 | 금액」 줄 · 소계 · 계(서버 M01 엔진 값 · `/logic-lib/sheet`) · 곁에 원문 띠 ·
|
||||
* [담기] = 부르는 쪽 담기 동작 그대로(`put`).
|
||||
* [담기] = 부르는 쪽 담기 동작 그대로(`put`). 줄 · 소계 · 계 = 공용 계산서(`A00_Common/calc_sheet.ts` · 714-2).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import { createCalcFormula, createCalcFormulaRow } from "@ui/ui_template_calc_row";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { calcSheetRows } from "../A00_Common/calc_sheet";
|
||||
import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source";
|
||||
import { fetchStandardSheet, type StandardRow } from "./M02_MasterTemplete_Labor_Api";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
|
||||
const won = (v: number | null, digits = 0): string =>
|
||||
v === null ? "-" : v.toLocaleString("ko-KR", { maximumFractionDigits: digits });
|
||||
|
||||
const out = (v: number | null, key = false, digits = 0): HTMLElement =>
|
||||
el("span", { className: `ui-calc-row__out${key ? " is-key" : ""}`, text: won(v, digits) });
|
||||
|
||||
export function openStandardSheet(row: StandardRow, put: () => void): void {
|
||||
const sheet = el("div", { className: "m02-std-sheet__body", attrs: { "data-std-sheet": "" } });
|
||||
sheet.append(createText(tc("Loading")));
|
||||
@@ -51,19 +45,12 @@ export function openStandardSheet(row: StandardRow, put: () => void): void {
|
||||
createText(head, "muted", 14),
|
||||
createText([got.출처, got.판].filter(Boolean).join(" · "), "hint"),
|
||||
];
|
||||
for (const g of got.묶음) {
|
||||
for (const line of g.줄) {
|
||||
const text = line.글.replace(/ = [^=]*$/, "").replaceAll(" * ", " × ");
|
||||
lines.push(
|
||||
createCalcFormulaRow(line.이름, createCalcFormula(text), out(line.금액, false, 1)),
|
||||
);
|
||||
}
|
||||
lines.push(
|
||||
createCalcFormulaRow(`${g.비목}${g.끝수 ? ` (${g.끝수})` : ""}`, null, out(g.소계, true)),
|
||||
);
|
||||
}
|
||||
lines.push(
|
||||
createCalcFormulaRow(`${tc("Std_Sum")} (원/${got.단위 || "식"})`, null, out(got.계, true)),
|
||||
...calcSheetRows({
|
||||
묶음: got.묶음,
|
||||
계: got.계,
|
||||
sumLabel: `${tc("Std_Sum")} (원/${got.단위 || "식"})`,
|
||||
}),
|
||||
);
|
||||
sheet.replaceChildren(...lines);
|
||||
source.show(got.출처);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Layer.ts
|
||||
* M02 한 화면의 뿌리 셋(마스터 · 회사 · 개인 · PLAN 27-1) — 진입 해시
|
||||
* `#/m02-master-template?open=구조물조합|옵션|도면&root=master|company|personal&owner=…&company=…&pre=…`
|
||||
* `#/m02-master-template?open=구조물조합|옵션|도면|일위대가조합|단가산출&root=master|company|personal&owner=…&company=…&pre=…`
|
||||
* (옛 `?layer=` 도 root 로 읽음 · `pre` = 일위대가 사전조합 층 · 134-3) · 고칠 권한(`accessFor` — 서버 `M02_Layer_Access` 와 짝 · 27-1c) ·
|
||||
* 회사 없음 안내 · 회사용 읽기만일 때 [개인용 복사] 막대.
|
||||
* ========================================================================== */
|
||||
@@ -16,7 +16,7 @@ import { copyItemToPersonal, fetchStructureCombos } from "./M02_MasterTemplete_A
|
||||
export type ManageLayer = "system" | "company" | "personal";
|
||||
/** 해시 · 화면의 뿌리 이름 — 서버 층 이름은 `layerOfRoot` */
|
||||
export type Root = "master" | "company" | "personal";
|
||||
export type OpenTarget = "구조물조합" | "옵션" | "도면" | "일위대가조합";
|
||||
export type OpenTarget = "구조물조합" | "옵션" | "도면" | "일위대가조합" | "단가산출";
|
||||
|
||||
export interface Entry {
|
||||
root: Root;
|
||||
@@ -29,7 +29,7 @@ export interface Entry {
|
||||
pre: Root | null;
|
||||
}
|
||||
|
||||
const OPENS: OpenTarget[] = ["구조물조합", "옵션", "도면", "일위대가조합"];
|
||||
const OPENS: OpenTarget[] = ["구조물조합", "옵션", "도면", "일위대가조합", "단가산출"];
|
||||
const ROOTS: Root[] = ["master", "company", "personal"];
|
||||
|
||||
export const layerOfRoot = (root: Root): ManageLayer => (root === "master" ? "system" : root);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Library_List.ts
|
||||
* 일위대가 사전조합 서고 목록표(51-4) — 좌측 사전조합 층 단추를 누르면 상세 칸에 뜸.
|
||||
* 표 = 고름 · 키 · 구분 · 상세구분(51-12) · 이름 · 단위 · 기준 금액(서버 1 단위 계) · 고친 날 · 줄 누름 = 그 문서 상세.
|
||||
* 서고 목록표(51-4 · 717-2a) — 좌측 일위대가 · 단가산출 층 단추를 누르면 상세 칸에 뜸.
|
||||
* 표 = 고름 · 키 · 구분 · 상세구분(51-12) · 이름 · 단위 · 기준 금액(서버 1 단위 계) · 고친 날 · 줄 누름 = 그 문서 상세 ·
|
||||
* 단가산출 = 구분 둘 대신 바탕(M01 로직 · 없으면 「바탕 없음」) · 고친 줄 수 · 쓰는 곳 수(서버 `summary`).
|
||||
* 머리 [생성] · [복사](고른 줄) · [삭제](고른 줄 · 쓰는 곳 있으면 서버 409 까닭 그대로).
|
||||
* 꼴 = M01 단가산출 로직 목록 — 찾기 줄(`buildHeadFilters`) · 쪽(`buildPager`) · 표(`m01-master__grid`) 를 M01 에서 import.
|
||||
* ========================================================================== */
|
||||
@@ -28,10 +29,10 @@ import {
|
||||
saveTemplate,
|
||||
type Layer,
|
||||
type LibraryBody,
|
||||
type LibraryKind,
|
||||
type TemplateInfo,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import type { PrecomboKind } from "./M02_MasterTemplete_UI_Side_Precombo";
|
||||
|
||||
/** 목록 줄 — 서버가 문서 `이름`(제목) · 단위 · 1 단위 기준 계를 실어 보냄 */
|
||||
type Row = TemplateInfo & {
|
||||
@@ -40,13 +41,16 @@ type Row = TemplateInfo & {
|
||||
기준금액?: number | null;
|
||||
구분?: string | null;
|
||||
상세구분?: string | null;
|
||||
바탕?: string | null;
|
||||
고친줄수?: number;
|
||||
쓰는곳수?: number;
|
||||
};
|
||||
|
||||
export interface LibraryListOptions {
|
||||
layer: Layer;
|
||||
/** 그 층 주인(134-3 · 사전조합 층 · null = 자기 층) */
|
||||
owner: string | null;
|
||||
kind: PrecomboKind;
|
||||
kind: LibraryKind;
|
||||
canEdit: boolean;
|
||||
/** 줄 누름 · 생성 · 복사 뒤 — 그 문서 상세 */
|
||||
open: (key: string, label: string) => void;
|
||||
@@ -60,6 +64,36 @@ const money = (v: number | null | undefined): string =>
|
||||
v == null ? "" : v.toLocaleString("ko-KR", { maximumFractionDigits: 2 });
|
||||
export const libraryLabel = (row: Row): string => (row.제목 ? `${row.이름} ${row.제목}` : row.이름);
|
||||
|
||||
/** 새 단가산출 몸(717-2a) — 바탕 없음 · 빈 더한줄 · 단위 「식」(서버 필수 · 상세에서 고침 · 717-2b) */
|
||||
export const sangeunBlank = (name: string): Record<string, unknown> => ({
|
||||
이름: name,
|
||||
단위: "식",
|
||||
바탕: null,
|
||||
더한줄: [],
|
||||
});
|
||||
/** 표 열 — 머리 글 · 줄 값(일위대가 = 구분 둘 · 단가산출 = 바탕 · 고친 줄 · 쓰는 곳) */
|
||||
const COLUMNS: Record<LibraryKind, () => [string, (row: Row) => string][]> = {
|
||||
precombo: () => [
|
||||
[tc("Lib_Key"), (r) => r.이름],
|
||||
[tc("Sub"), (r) => r.구분 || tc("Lib_Unsorted")], // 빈 구분 = 미분류(149-2 · 나무와 같은 말)
|
||||
[tc("Detail"), (r) => r.상세구분 ?? ""],
|
||||
[tc("Lib_Name"), (r) => r.제목 ?? ""],
|
||||
[tc("Lib_Unit"), (r) => r.단위 ?? ""],
|
||||
[tc("Lib_Base"), (r) => money(r.기준금액)],
|
||||
[tc("Lib_Date"), (r) => r.수정일],
|
||||
],
|
||||
sangeun: () => [
|
||||
[tc("Lib_Key"), (r) => r.이름],
|
||||
[tc("Lib_Name"), (r) => r.제목 ?? ""],
|
||||
[tc("Lib_Unit"), (r) => r.단위 ?? ""],
|
||||
[L("M02_SangeunBase"), (r) => r.바탕 || L("M02_SangeunNoBase")],
|
||||
[L("M02_SangeunEdited"), (r) => String(r.고친줄수 ?? 0)],
|
||||
[L("M02_SangeunUsers"), (r) => String(r.쓰는곳수 ?? 0)],
|
||||
[tc("Lib_Base"), (r) => money(r.기준금액)],
|
||||
[tc("Lib_Date"), (r) => r.수정일],
|
||||
],
|
||||
};
|
||||
|
||||
/** 이름 창(생성 · 복사 · 사전조합 나무 폴더 메뉴 149-1) — [생성] = `onOk` 성공하면 닫힘 */
|
||||
export const nameDialog = (
|
||||
title: string,
|
||||
@@ -101,6 +135,7 @@ export function mountLibraryList(
|
||||
tools: HTMLElement,
|
||||
opt: LibraryListOptions,
|
||||
): { getDoc: () => null; destroy: () => void } {
|
||||
const columns = COLUMNS[opt.kind]();
|
||||
let rows: Row[] = [];
|
||||
let picked: string | null = null;
|
||||
let page = 1;
|
||||
@@ -144,15 +179,9 @@ export function mountLibraryList(
|
||||
attrs: { "data-lib-key": row.이름 },
|
||||
children: [
|
||||
pick,
|
||||
...[
|
||||
row.이름,
|
||||
row.구분 || tc("Lib_Unsorted"), // 빈 구분 = 미분류(149-2 · 나무와 같은 말)
|
||||
row.상세구분 ?? "",
|
||||
row.제목 ?? "",
|
||||
row.단위 ?? "",
|
||||
money(row.기준금액),
|
||||
row.수정일,
|
||||
].map((v) => el("td", { text: v, attrs: { title: v } })),
|
||||
...columns
|
||||
.map(([, value]) => value(row))
|
||||
.map((v) => el("td", { text: v, attrs: { title: v } })),
|
||||
],
|
||||
});
|
||||
tr.addEventListener("click", () => opt.open(row.이름, libraryLabel(row)));
|
||||
@@ -160,7 +189,12 @@ export function mountLibraryList(
|
||||
})
|
||||
: [
|
||||
el("tr", {
|
||||
children: [el("td", { text: L("M02_NoTemplates"), attrs: { colspan: "8" } })],
|
||||
children: [
|
||||
el("td", {
|
||||
text: L("M02_NoTemplates"),
|
||||
attrs: { colspan: String(columns.length + 1) },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]),
|
||||
);
|
||||
@@ -191,7 +225,10 @@ export function mountLibraryList(
|
||||
/** 사전조합 = 이름 · 구분 · 상세구분 빈 값(미분류 · 134-5 · 상세에서 고름) · 빈 묶음 */
|
||||
btnNew.addEventListener("click", () => {
|
||||
nameDialog(L("M02_NewTitle"), "", async (name) => {
|
||||
const 문서 = { 이름: name, 구분: "", 상세구분: "", 단위: null, 담은로직: [] };
|
||||
const 문서 =
|
||||
opt.kind === "sangeun"
|
||||
? sangeunBlank(name)
|
||||
: { 이름: name, 구분: "", 상세구분: "", 단위: null, 담은로직: [] };
|
||||
await create({ 문서 }, name);
|
||||
});
|
||||
});
|
||||
@@ -244,16 +281,7 @@ export function mountLibraryList(
|
||||
page = 1;
|
||||
draw();
|
||||
});
|
||||
const heads = [
|
||||
"",
|
||||
"Lib_Key",
|
||||
"Sub",
|
||||
"Detail",
|
||||
"Lib_Name",
|
||||
"Lib_Unit",
|
||||
"Lib_Base",
|
||||
"Lib_Date",
|
||||
] as const;
|
||||
const heads = ["", ...columns.map(([head]) => head)];
|
||||
const gridBox = el("div", {
|
||||
className: "ui-table-wrap m01-master__grid-wrap",
|
||||
attrs: { "data-lib-list": opt.kind },
|
||||
@@ -262,9 +290,7 @@ export function mountLibraryList(
|
||||
className: "ui-table m01-master__grid",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [
|
||||
el("tr", { children: heads.map((h) => el("th", { text: h ? tc(h) : "" })) }),
|
||||
],
|
||||
children: [el("tr", { children: heads.map((h) => el("th", { text: h })) })],
|
||||
}),
|
||||
body,
|
||||
],
|
||||
|
||||
@@ -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<LaborRef, "키">[]): Promise<SangeunRow[]> {
|
||||
const rank = (x: Omit<LaborRef, "키">): 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<SangeunRow>((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<string, never>; 수량: 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 }),
|
||||
),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Sangeun_View.ts
|
||||
* 단가산출(SK) 상세 자리(717-2a) — 편집 칸 종류 sangeun 이 여는 곳(UI_Main 갈래 한 줄).
|
||||
* 지금 = 이름 · 단위 · 바탕 · 고친 줄 요약(덮개 · 더한 줄 · 뺀 줄 · 열린 입력) + 상세 자리표.
|
||||
* 717-2b(sub2) = 자리표 줄을 `mountSangeunDetail(slot, doc, opt)`(`Sangeun_Detail.ts`)로 갈음 ·
|
||||
* [저장]은 그 손잡이 `getDoc` · 고침 알림은 `opt.onChanged`. 지금 [저장] = 읽은 문서 그대로.
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { createEmpty } from "@ui/ui_template_empty";
|
||||
import { createKv } from "@ui/ui_template_kv";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import type { Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
|
||||
/** SK 문서 중 이 자리가 읽는 칸(꼴 = 서버 `M02_Sangeun_Doc`) */
|
||||
export interface SangeunDoc {
|
||||
키?: string;
|
||||
이름?: string;
|
||||
단위?: string;
|
||||
바탕?: { 로직?: string; 참조?: { 키?: string } } | null;
|
||||
덮개?: Record<string, unknown>;
|
||||
더한줄?: unknown[];
|
||||
뺀줄?: unknown[];
|
||||
열린입력?: string[];
|
||||
}
|
||||
|
||||
/** 고름(층 · 주인 · SK 키 = name) + 읽기만 · 고침 알림 */
|
||||
export type SangeunViewOptions = Pick<Selection, "layer" | "owner" | "name"> & {
|
||||
readOnly: boolean;
|
||||
onChanged: (dirty: boolean) => void;
|
||||
};
|
||||
|
||||
/** 바탕 한 줄 — 로직 키 · 참조 SK 키 · 없으면 「바탕 없음」 */
|
||||
export const baseText = (doc: SangeunDoc): string =>
|
||||
doc.바탕?.로직 ?? doc.바탕?.참조?.키 ?? L("M02_SangeunNoBase");
|
||||
|
||||
export function mountSangeunView(
|
||||
host: HTMLElement,
|
||||
doc: unknown,
|
||||
_opt: SangeunViewOptions,
|
||||
): { getDoc: () => unknown; destroy: () => void } {
|
||||
const sk = (doc ?? {}) as SangeunDoc;
|
||||
const count = (v: unknown[] | Record<string, unknown> | undefined): number =>
|
||||
Array.isArray(v) ? v.length : Object.keys(v ?? {}).length;
|
||||
const head = createKv([
|
||||
{ label: L("M02_Name"), value: sk.이름 ?? "" },
|
||||
{ label: tc("Lib_Unit"), value: sk.단위 ?? "" },
|
||||
{ label: L("M02_SangeunBase"), value: baseText(sk) },
|
||||
{ label: L("M02_SangeunCover"), value: count(sk.덮개) },
|
||||
{ label: L("M02_SangeunAdded"), value: count(sk.더한줄) },
|
||||
{ label: L("M02_SangeunRemoved"), value: count(sk.뺀줄) },
|
||||
{ label: L("M02_SangeunOpened"), value: (sk.열린입력 ?? []).join(" · ") || null },
|
||||
]);
|
||||
const slot = el("div", {
|
||||
attrs: { "data-sangeun-detail": sk.키 ?? "" },
|
||||
children: [createEmpty({ text: L("M02_SangeunDetailSoon") })],
|
||||
});
|
||||
host.replaceChildren(el("div", { attrs: { "data-sangeun-view": "" }, children: [head, slot] }));
|
||||
return { getDoc: () => doc, destroy: () => host.replaceChildren() };
|
||||
}
|
||||
@@ -357,6 +357,12 @@ export function createMain(
|
||||
const readOnly = !writable(at);
|
||||
const mine = seq; // 모듈 · 집계표 번호를 기다리는 사이 다른 화면(옵션 정의 등)이 열렸으면 그만둠
|
||||
host.replaceChildren();
|
||||
if (at.kind === "sangeun") {
|
||||
const { mountSangeunView } = await import("./M02_MasterTemplete_Sangeun_View"); // 717-2a
|
||||
const onChanged = (d: boolean): boolean => (dirty = d && !readOnly);
|
||||
if (mine === seq) editor = mountSangeunView(host, doc, { ...at, readOnly, onChanged });
|
||||
return;
|
||||
}
|
||||
if (at.kind === "table") {
|
||||
const create = await findExport<(h: HTMLElement, d: unknown, o: object) => EditorHandle>(
|
||||
SHEET_MODS,
|
||||
@@ -503,7 +509,7 @@ export function createMain(
|
||||
return empty("");
|
||||
}
|
||||
title.textContent = isCombo(next) ? L("M02_KindTable") : (next.label ?? next.name); // 26-7a
|
||||
if (isPrecombo(next.kind) && !next.name) {
|
||||
if ((isPrecombo(next.kind) || next.kind === "sangeun") && !next.name) {
|
||||
// 서고 목록표(51-4) — 이름 "" · 저장할 것 없음 · 줄 누름 = 그 문서 상세
|
||||
save.hidden = true;
|
||||
const { mountLibraryList } = await import("./M02_MasterTemplete_Library_List");
|
||||
|
||||
@@ -53,6 +53,7 @@ import { canEdit, createMain, newItemButton, type Selection } from "./M02_Master
|
||||
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 { sangeunList } from "./M02_MasterTemplete_UI_Side_Sangeun";
|
||||
import { buildTwoWay, pickedName } from "./M02_MasterTemplete_ItemPick";
|
||||
import "./M02_MasterTemplete_UI_Style.css";
|
||||
|
||||
@@ -248,6 +249,16 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
|
||||
reselect: () =>
|
||||
void main.confirmLeave().then((ok) => ok && void main.open(precomboList(pre))),
|
||||
});
|
||||
// 단가산출 층(717-2a) — 일위대가 층과 따로 · 기본 개인(못 가면 마스터) · 주소에 안 실음
|
||||
let sk = preScope("personal");
|
||||
const skBar = (): HTMLElement =>
|
||||
createLayerBar({
|
||||
user,
|
||||
active: { root: sk.root, owner: sk.owner },
|
||||
company,
|
||||
go: (next) => ((sk = preScope(next)), void side.refreshSangeun()),
|
||||
reselect: () => void main.confirmLeave().then((ok) => ok && void main.open(sangeunList(sk))),
|
||||
});
|
||||
const side = buildSide({
|
||||
layer,
|
||||
isAdmin,
|
||||
@@ -264,6 +275,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
|
||||
bar: preBar,
|
||||
more: (key, name) => share.precomboMenu(pre.root === "personal" && !pre.owner, key, name),
|
||||
},
|
||||
sangeun: { scope: () => sk, bar: skBar },
|
||||
});
|
||||
|
||||
const layout = el("div", { className: "ui-workflow-layout m02-master" });
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_UI_Side.ts
|
||||
* 마스터 템플릿 좌측 패널 차례(716-3) — 「일위대가」(층 단추 따로 · 2단 폴더 나무 · `UI_Side_Precombo` ·
|
||||
* 마스터 템플릿 좌측 패널 차례(716-3 · 717-2a) — 「단가산출」(층 단추 따로 · 기본 개인 · `UI_Side_Sangeun`) › 「일위대가」(층 단추 따로 · 2단 폴더 나무 · `UI_Side_Precombo` ·
|
||||
* 50-3 · 134-3) › 「구조물 조합」(층 단추 · 나무) › 수평 구분선 › 「일위대가 불러오기」(구조물 조합 층 ·
|
||||
* `Hopyo_Side` · 324-4) › 「도면 양식」(새로 · 본떠 · 지우기) › 실무문서. 「단가산출」 칸은 맨 위(717-2a).
|
||||
* `Hopyo_Side` · 324-4) › 「도면 양식」(새로 · 본떠 · 지우기) › 실무문서.
|
||||
* 구조물 옵션 정의는 M01 로 옮김(716-1).
|
||||
* 서버 길은 계약 `6_계약.md`.
|
||||
* 구조물 도면 · 일위대가 · 면적산출 상세는 좌측 줄이 아니라 항목 카드 [수정]으로(PLAN 45-2).
|
||||
@@ -36,6 +36,7 @@ import { HOPYO_BUILT } from "./M02_MasterTemplete_Hopyo_Check";
|
||||
import { buildHopyoSection } from "./M02_MasterTemplete_Hopyo_Side";
|
||||
import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
import { buildPrecomboSection, type PrecomboOptions } from "./M02_MasterTemplete_UI_Side_Precombo";
|
||||
import { buildSangeunSection, type SangeunOptions } from "./M02_MasterTemplete_UI_Side_Sangeun";
|
||||
import { createEmpty } from "@ui/ui_template_empty";
|
||||
import { createDivider } from "@ui/ui_template_divider";
|
||||
|
||||
@@ -67,6 +68,8 @@ export interface SideOptions {
|
||||
tree?: HTMLElement[];
|
||||
/** 「일위대가」 층(134-3 · 페이지 층과 따로 · 층 단추는 페이지가 만듦) */
|
||||
precombo: Pick<PrecomboOptions, "scope" | "bar" | "more">;
|
||||
/** 「단가산출」 층(717-2a · 일위대가 층과 따로 · 층 단추는 페이지가 만듦) */
|
||||
sangeun: Pick<SangeunOptions, "scope" | "bar">;
|
||||
}
|
||||
|
||||
export interface SideHandle {
|
||||
@@ -75,9 +78,11 @@ export interface SideHandle {
|
||||
/** 고름 표시를 지금 열린 것 한 줄로 — 편집 칸이 무엇을 열든(카드 [도면 수정] 등) 부름 */
|
||||
paint: () => void;
|
||||
/** 진입 해시 `open`(PLAN 27-1d) — 그 컨테이너를 펼침 · 도면은 시스템 층에만 · 옵션은 M01 로 옮겨 아무 일 없음 */
|
||||
reveal: (target: "옵션" | "도면" | "일위대가조합") => void;
|
||||
reveal: (target: "옵션" | "도면" | "일위대가조합" | "단가산출") => void;
|
||||
/** 사전조합 층이 바뀜(134-3) — 그 컨테이너만 다시 그림 */
|
||||
refreshPrecombo: () => Promise<void>;
|
||||
/** 단가산출 층이 바뀜(717-2a) — 그 컨테이너만 다시 그림 */
|
||||
refreshSangeun: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** 서버 이름 규칙과 같게 — 화면에서 먼저 막음 */
|
||||
@@ -133,7 +138,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
openOnExpand(summary, async () => {
|
||||
if (!isPicked("table", STRUCTURE_TABLE) && (await opt.confirmLeave())) opt.onOpen(null);
|
||||
});
|
||||
// 일위대가 서고(50-3 · 134-3) — 맨 위 칸 · 층은 페이지 층과 따로
|
||||
// 일위대가 서고(50-3 · 134-3) — 단가산출 다음 칸 · 층은 페이지 층과 따로
|
||||
const precombo = buildPrecomboSection({
|
||||
...opt.precombo,
|
||||
confirmLeave: opt.confirmLeave,
|
||||
@@ -141,10 +146,18 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
getOpen: opt.getOpen,
|
||||
});
|
||||
const precomboSec = section(L("M02_PrecomboTitle"), precombo.body);
|
||||
// 단가산출 서고(717-2a) — 맨 위 칸 · 층은 일위대가 층과 따로
|
||||
const sangeun = buildSangeunSection({
|
||||
...opt.sangeun,
|
||||
confirmLeave: opt.confirmLeave,
|
||||
onOpen: opt.onOpen,
|
||||
getOpen: opt.getOpen,
|
||||
});
|
||||
const sangeunSec = section(L("M02_SangeunTitle"), sangeun.body);
|
||||
// 실무자료로 일위대가를 만들었음(확인 창 · 엑셀 보기 창) → 목록 다시 · 이 패널이 내려갔으면 듣기 끝
|
||||
const onBuilt = (): void =>
|
||||
precombo.body.isConnected
|
||||
? void precombo.refresh()
|
||||
? void Promise.all([precombo.refresh(), sangeun.refresh()])
|
||||
: document.removeEventListener(HOPYO_BUILT, onBuilt);
|
||||
document.addEventListener(HOPYO_BUILT, onBuilt);
|
||||
// 도면 양식은 시스템 층(마스터 템플릿)에만
|
||||
@@ -171,6 +184,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
const listHost = el("div", {
|
||||
className: "m02-side__lists ui-sidebar-panel",
|
||||
children: [
|
||||
sangeunSec,
|
||||
precomboSec,
|
||||
summary,
|
||||
createDivider(),
|
||||
@@ -229,6 +243,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
* (양식이 열려 있으면 실무문서 고름은 지움) */
|
||||
function paint(): void {
|
||||
paintList();
|
||||
sangeun.paint();
|
||||
precombo.paint();
|
||||
hopyo.paint();
|
||||
paintButtons();
|
||||
@@ -253,7 +268,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
if (mine !== seq) return;
|
||||
paintList();
|
||||
paintButtons();
|
||||
await precombo.refresh();
|
||||
await Promise.all([precombo.refresh(), sangeun.refresh()]);
|
||||
}
|
||||
|
||||
/* --- 시스템 층 새로 · 본떠 · 지우기(도면 양식만 — 구조물 도면은 집계표 열이 정함) --- */
|
||||
@@ -333,11 +348,25 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
}
|
||||
}
|
||||
|
||||
const reveal = (target: "옵션" | "도면" | "일위대가조합"): void => {
|
||||
const sec = target === "일위대가조합" ? precomboSec : target === "도면" ? drawing : null;
|
||||
const reveal = (target: "옵션" | "도면" | "일위대가조합" | "단가산출"): void => {
|
||||
const sec =
|
||||
target === "단가산출"
|
||||
? sangeunSec
|
||||
: target === "일위대가조합"
|
||||
? precomboSec
|
||||
: target === "도면"
|
||||
? drawing
|
||||
: null;
|
||||
sec?.classList.remove("is-collapsed");
|
||||
};
|
||||
|
||||
void refresh();
|
||||
return { root, refresh, paint, reveal, refreshPrecombo: precombo.refresh };
|
||||
return {
|
||||
root,
|
||||
refresh,
|
||||
paint,
|
||||
reveal,
|
||||
refreshPrecombo: precombo.refresh,
|
||||
refreshSangeun: sangeun.refresh,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_UI_Side_Sangeun.ts
|
||||
* 좌측 맨 위 「단가산출」 칸(717-2a · 요구서 4장 12) — 서고 칸(`UI_Side_Library` · 716-4)에 단가산출 몫만 줌:
|
||||
* 종류 sangeun(SK-) · 그 층 목록표 고름 · 새 문서 몸 · 글. 층 단추는 페이지가 만듦(칸마다 따로 · 기본 개인).
|
||||
* 문서 누름 = 편집 칸(UI_Main 종류 sangeun = `Sangeun_View`) · 켜진 층 단추 다시 누름 = 목록표(`Library_List`).
|
||||
* ========================================================================== */
|
||||
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import { sangeunBlank } from "./M02_MasterTemplete_Library_List";
|
||||
import type { Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
import {
|
||||
buildLibrarySection,
|
||||
type LibraryHandle,
|
||||
type LibraryOptions,
|
||||
type LibraryScope,
|
||||
} from "./M02_MasterTemplete_UI_Side_Library";
|
||||
|
||||
/** 그 층 목록표 고름(켜진 층 단추 다시 누름) */
|
||||
export const sangeunList = (scope: LibraryScope): Selection => ({
|
||||
layer: scope.layer,
|
||||
owner: scope.owner,
|
||||
canWrite: scope.canWrite,
|
||||
projectId: null,
|
||||
kind: "sangeun",
|
||||
name: "",
|
||||
label: L("M02_SangeunTitle"),
|
||||
});
|
||||
|
||||
export type SangeunOptions = Pick<
|
||||
LibraryOptions,
|
||||
"scope" | "bar" | "confirmLeave" | "onOpen" | "getOpen"
|
||||
>;
|
||||
|
||||
export const buildSangeunSection = (opt: SangeunOptions): LibraryHandle =>
|
||||
buildLibrarySection({
|
||||
...opt,
|
||||
kind: "sangeun",
|
||||
list: sangeunList,
|
||||
blank: sangeunBlank,
|
||||
text: { none: L("M02_SangeunNone"), newHere: L("M02_SangeunNewHere") },
|
||||
});
|
||||
@@ -47,9 +47,8 @@ def times_of(row: dict) -> Decimal:
|
||||
"""결과단위 분모 앞 수(원/10㎡ → 10) — 없으면 1."""
|
||||
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
|
||||
|
||||
per = combo_store._PER.findall(str(row.get("결과단위") or ""))
|
||||
head = per[0][0].replace(",", "") if len(per) == 1 else ""
|
||||
return Decimal(head) if head else Decimal(1)
|
||||
per = combo_store.mu.per(row.get("결과단위")) # 712-2 단위 한 벌
|
||||
return per[0][0] if len(per) == 1 else Decimal(1)
|
||||
|
||||
|
||||
def fingerprint(row: dict) -> str:
|
||||
@@ -170,6 +169,10 @@ def unit(doc: dict, whole: Any, expand: Any, trail: tuple) -> dict[str, Any]:
|
||||
return {**head, "기준": got["1단위"], "까닭": why, "줄": lines, "짝잃음": got["짝잃음"]}
|
||||
|
||||
|
||||
def head_of(doc: dict) -> str:
|
||||
return f"1 {doc.get('단위')} 당"
|
||||
|
||||
|
||||
# ── 저장 ──────────────────────────────────────────────────────────────
|
||||
def _check_refs(doc: dict, here_: refs.Here) -> None:
|
||||
me = {"층": here_[0], "주인": here_[1], "키": doc["키"]}
|
||||
@@ -227,4 +230,22 @@ def calc_doc(doc: Any, given: dict | None = None) -> dict[str, Any]:
|
||||
got = run(clean, whole, given, trail=(me,))
|
||||
except sd.DocError as e:
|
||||
raise _fail(e) from e
|
||||
return labor._json(got)
|
||||
return labor._json({**got, "머리": head_of(clean)})
|
||||
|
||||
|
||||
def from_line(line: Any, name: str, unit: str) -> dict[str, Any]:
|
||||
"""[단가산출로 저장](715-3) — 일위대가 줄(바탕 · 입력값 · 고름 · 줄 덮개) → 지금 층 새 SK ·
|
||||
답 = `{이름: 새 키, 줄: 그 SK 참조 줄}`(일위대가 문서는 화면 [저장] 때 바뀜)."""
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M02_MasterTemplete import M02_Labor_Patch as patch
|
||||
|
||||
if not isinstance(line, dict):
|
||||
raise store.StoreError(400, "줄이 묶음 아님")
|
||||
doc = patch.to_sangeun(line, name, unit, m01.loaded()[1])
|
||||
made = refs.create(sd.KIND, doc)
|
||||
at = refs.here()
|
||||
keep = {k: line[k] for k in ("별칭", "메모", "수량") if k in line}
|
||||
return {
|
||||
"이름": made["이름"],
|
||||
"줄": {"참조": {"층": at[0], "주인": at[1], "키": made["이름"]}, **keep},
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""M02 단가산출(SK) 셈 길(PLAN 715-1b) — `POST /api/m02/layers/{층}/sangeun/calc`.
|
||||
"""M02 단가산출(SK) 셈 길(PLAN 715-1b) — `POST /api/m02/layers/{층}/sangeun/calc` ·
|
||||
[단가산출로 저장](715-3) `POST /api/m02/layers/{층}/sangeun/from-line`(층 쓰기 권한).
|
||||
|
||||
저장 안 함 · 로그인 사용자 · 층 읽기 권한(`Router_Items._root`) · 화면은 최신 응답만.
|
||||
몸 = `문서`(저장 전 화면 문서) 또는 `키`(그 층 저장본) + `입력값`(열린입력 · 시험 값).
|
||||
@@ -37,6 +38,24 @@ def _calc(body: SangeunCalcBody) -> dict[str, Any]:
|
||||
return calc.calc_doc(doc, body.입력값)
|
||||
|
||||
|
||||
class FromLineBody(BaseModel):
|
||||
줄: dict[str, Any]
|
||||
이름: str
|
||||
단위: str
|
||||
|
||||
|
||||
@router.post("/layers/{layer}/sangeun/from-line")
|
||||
def sangeun_from_line(
|
||||
layer: Layer,
|
||||
body: FromLineBody,
|
||||
owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
) -> dict:
|
||||
"""[단가산출로 저장](715-3) — 일위대가 줄 → 그 층 새 단가산출 · 답 = 새 키 + 바꿀 참조 줄."""
|
||||
folder = _root(session, layer, owner, write=True)
|
||||
return _call(folder, calc.from_line, body.줄, body.이름, body.단위)
|
||||
|
||||
|
||||
@router.post("/layers/{layer}/sangeun/calc")
|
||||
def calc_sangeun(
|
||||
layer: Layer,
|
||||
|
||||
@@ -563,6 +563,11 @@ app.include_router(m02_items_router, dependencies=protected)
|
||||
app.include_router(m02_combos_router, dependencies=protected)
|
||||
app.include_router(m02_drawing_router, dependencies=protected)
|
||||
app.include_router(m02_labor_router, dependencies=protected)
|
||||
# 단가산출 로직 상세 읽기 · 셈(PLAN 717-1) — 로그인만 · 쓰기 없음
|
||||
app.include_router(
|
||||
__import__("M02_MasterTemplete.M02_Logic_Lib_Read", fromlist=["router"]).router,
|
||||
dependencies=protected,
|
||||
)
|
||||
# 단가산출(SK) 셈 길(PLAN 715-1b) — 로그인 · 층 읽기 권한은 길 안에서
|
||||
app.include_router(
|
||||
__import__("M02_MasterTemplete.M02_Sangeun_Router", fromlist=["router"]).router,
|
||||
|
||||
@@ -15,10 +15,9 @@ import re
|
||||
from decimal import Decimal
|
||||
from functools import lru_cache
|
||||
|
||||
import master_unit as mu
|
||||
from check_master_body import section_of
|
||||
|
||||
# ponytail: 단위 별칭은 여기 짧은 표 — 712-2 단위 모듈이 서면 그쪽 별칭으로 갈아 끼움
|
||||
_ALIAS = {"ea": "개", "톤": "ton", "t": "ton", "㎞": "km", "hr": "시간", "min": "분", "㎏": "kg"}
|
||||
_UNITS = sorted(
|
||||
(
|
||||
"㎡ ㎥ ㎢ ㎞ km ㎜ mm ㎝ cm m ha ton 톤 t kg ㎏ 개소 개 본 대 회 조 기 틀 "
|
||||
@@ -33,11 +32,10 @@ _UNIT_AT_END = re.compile(
|
||||
)
|
||||
_HANGUL = re.compile(r"[가-힣]")
|
||||
_HEAD = re.compile(r"^\(\s*(?:단위\s*:\s*)?([^()]*당[^()]*)\)$") # 원문 머리 줄 「(단위: ㎥당)」
|
||||
_RESULT = re.compile(r"원\s*/\s*(\d[\d,]*\.?\d*)?\s*([^\s·(\d,.][^\s·(]*)") # 「원/10㎡」
|
||||
|
||||
|
||||
def unit(text: str) -> str:
|
||||
return _ALIAS.get(text, text)
|
||||
return mu.shown(text) # 보이는 꼴 별칭 한 벌(712-2 `master_unit`)
|
||||
|
||||
|
||||
def _times(text: str | None) -> Decimal:
|
||||
@@ -97,7 +95,7 @@ def basis(table: dict) -> dict:
|
||||
|
||||
def result_per(result_unit) -> list[tuple[Decimal, str]]:
|
||||
"""로직 결과단위 분모 — 「원/10㎡」 → [(10, ㎡)] · 여럿이면 여럿."""
|
||||
return [(_times(n), unit(u)) for n, u in _RESULT.findall(str(result_unit or ""))]
|
||||
return [(n, unit(u)) for n, u in mu.per(result_unit)]
|
||||
|
||||
|
||||
def _formula_text(row: dict) -> str:
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""인력 · 재료 · 경비 묶음 — **셈 때 파생**(정본에 칸 없음 · PLAN 712-1 · 요구서 원칙 5).
|
||||
|
||||
규칙 한 줄:
|
||||
- 호표 · 덧줄 = `비목` 따라(노무비 → 인력 · 재료비 → 재료 · 경비 → 경비).
|
||||
- 비목 없는 로직 줄 = 부르는 로직이 기계경비(`is_machine`)면 경비 + `장비` · 아니면 하위.
|
||||
두 줄 모두 엔진 줄 `비목` 에 노무비 · 재료비 · 경비 세 몫이 그대로 남음(한 줄 · 흩어 넣기 둘 다).
|
||||
|
||||
기계경비 판별 = `is_machine` 한 곳 — M01 엔진 · M02 엑셀 맞춤(`M02_Hopyo_Ours`) ·
|
||||
B09 산근(`B09_Estimation_Sangeun` · `_Text`)이 같이 씀.
|
||||
엔진 줄 덧칸 = `묶음`(출력 꼬리표만 · 입력 값 덧칸 `값묶음` 은 715-1b 몫) · 기계경비 줄 `장비` true.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import master_formula as mf
|
||||
|
||||
BUNDLES = ("인력", "재료", "경비", "하위")
|
||||
BY_ITEM = {"노무비": "인력", "재료비": "재료", "경비": "경비"}
|
||||
BY_KIND = {"인력": "인력", "재료": "재료", "기계": "경비"} # 비목 빈 옛 줄 받침
|
||||
|
||||
|
||||
def is_machine(row: dict | None) -> bool:
|
||||
"""기계경비 로직 — 돈 로직이고 결과단위 「원/시간」(작업량 「㎥/시간」 · 이름 「시간당」 아님)."""
|
||||
if not row or "결과" in row or isinstance(row.get("흐름"), dict):
|
||||
return False
|
||||
return re.sub(r"\s", "", str(row.get("결과단위") or "")) == "원/시간"
|
||||
|
||||
|
||||
def called(master, item: dict, env: dict | None = None) -> dict | None:
|
||||
"""로직 줄이 부르는 로직 줄(정본) — 못 찾으면 None."""
|
||||
try:
|
||||
node = mf.parse(item.get("요소") or "")
|
||||
if not node or node[0] != "logic":
|
||||
return None
|
||||
key = mf.fill(node[1], env) if env is not None and "{" in node[1] else node[1]
|
||||
return master.logic(key)
|
||||
except (mf.FormulaError, mf.EmptyInput, KeyError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def bundle(master, item: dict, env: dict | None = None) -> tuple[str, bool]:
|
||||
"""줄 하나(호표 · 덧줄 정본 줄) → (묶음, 장비인가)."""
|
||||
if item.get("비목") in BY_ITEM:
|
||||
return BY_ITEM[item["비목"]], False
|
||||
if item.get("종류") == "로직":
|
||||
return ("경비", True) if is_machine(called(master, item, env)) else ("하위", False)
|
||||
return BY_KIND.get(str(item.get("종류")), "하위"), False
|
||||
|
||||
|
||||
def tag(master, row: dict, lines: list[dict], env: dict | None = None) -> list[dict]:
|
||||
"""엔진 `run` 줄에 `묶음`(+ 기계경비 줄 `장비`) — 줄 차례 = 호표 줄 하나씩 뒤 덧줄 하나씩."""
|
||||
for item, line in zip([*row.get("호표", []), *row.get("덧줄", [])], lines):
|
||||
name, machine = bundle(master, item, env)
|
||||
line["묶음"] = name
|
||||
if machine:
|
||||
line["장비"] = True
|
||||
return lines
|
||||
@@ -657,6 +657,7 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict:
|
||||
}
|
||||
)
|
||||
sums = {k: cut(row, v, "계") for k, v in sums.items()} # 비목 합마다 끊고 계는 그 합
|
||||
mb.tag(master, row, lines, env) # 줄마다 인력 · 재료 · 경비 · 하위 묶음(712-1)
|
||||
return {"줄": lines, **sums, "계": sum(sums.values()), "중간": _shown(env)}
|
||||
|
||||
|
||||
@@ -676,6 +677,7 @@ from master_material import ( # noqa: E402, F401
|
||||
pick,
|
||||
)
|
||||
import master_pick as mp # noqa: E402 — 검색어 · 고름 · 단위
|
||||
import master_bundle as mb # noqa: E402 — 인력 · 재료 · 경비 묶음
|
||||
from master_formula_check import ( # noqa: E402, F401 — 검사는 나눈 파일 · 이름은 여기서 그대로
|
||||
_check_cut,
|
||||
_check_node,
|
||||
|
||||
@@ -12,6 +12,8 @@ import re
|
||||
import unicodedata
|
||||
from decimal import Decimal
|
||||
|
||||
import master_unit as mu
|
||||
|
||||
LINKED = "MP" # 품셈재료 테이블ID
|
||||
MARKET = "MT" # 자재품목 테이블ID
|
||||
PRICED = "MO" # 유가전력 테이블ID — 값 한 칸
|
||||
@@ -27,7 +29,6 @@ PICK_KEYS = (
|
||||
) # 재료 고르기 조건 칸 (`_틀.md` 7장)
|
||||
REGION = "자재지역" # 로직 입력 — 「지역」 은 유가 고르기가 씀
|
||||
TARIFF = "전력 계약종별" # 로직 입력 — 품셈·계약예규에 계약종별 규정 없음
|
||||
_UNITS = {"ea": "개", "ton": "톤", "t": "톤", "l": "ℓ", "리터": "ℓ", "set": "조", "세트": "조"}
|
||||
|
||||
|
||||
def plain(s) -> str:
|
||||
@@ -41,8 +42,7 @@ def plain_spec(s) -> str:
|
||||
|
||||
|
||||
def plain_unit(s) -> str:
|
||||
s = plain(s).replace("ℓ", "l")
|
||||
return _UNITS.get(s, s)
|
||||
return mu.plain(s) # 별칭 · 맞대기 꼴 한 벌(712-2)
|
||||
|
||||
|
||||
def _offers(master: mf.Master, key: str) -> list[tuple[Decimal, str]]:
|
||||
|
||||
@@ -9,18 +9,10 @@ from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from decimal import Decimal
|
||||
|
||||
import master_material as mm
|
||||
from master_unit import UNITS, convert, kind_of, same_kind # noqa: F401 — 단위 한 벌(712-2)
|
||||
|
||||
# 「같은 성격」 단위 — {성격: {정규 단위: 기준 단위 몇 배}} (키는 `plain_unit` 을 거친 꼴)
|
||||
UNITS = {
|
||||
"무게": {"g": 1, "kg": 1000, "톤": 1000000},
|
||||
"길이": {"mm": 1, "cm": 10, "m": 1000, "km": 1000000},
|
||||
"넓이": {"cm2": 1, "m2": 10000},
|
||||
"부피": {"cm3": 1, "ℓ": 1000, "m3": 1000000},
|
||||
}
|
||||
_SCALE = {u: (kind, n) for kind, group in UNITS.items() for u, n in group.items()}
|
||||
REGIONS = (
|
||||
"전국평균 전국 서울 경기 강원 충북 충남 전북 전남 경북 경남 부산 대구 인천 대전 울산 세종 제주 광주"
|
||||
" 전남광주"
|
||||
@@ -60,27 +52,6 @@ def machine(spec: dict, value) -> bool:
|
||||
return bool(picks) and str(value).strip() in known and all(str(o) in known for o in picks)
|
||||
|
||||
|
||||
# ── 단위 ───────────────────────────────────────────────────────────────
|
||||
def kind_of(unit) -> str | None:
|
||||
got = _SCALE.get(mm.plain_unit(unit))
|
||||
return got[0] if got else None
|
||||
|
||||
|
||||
def same_kind(a, b) -> bool:
|
||||
"""단위가 같거나 같은 성격(무게 · 길이 · 넓이 · 부피)."""
|
||||
pa, pb = mm.plain_unit(a), mm.plain_unit(b)
|
||||
return pa == pb or (kind_of(a) is not None and kind_of(a) == kind_of(b))
|
||||
|
||||
|
||||
def convert(price, have, want):
|
||||
"""`have` 단위 단가 → `want` 단위 단가 — 같은 성격이 아니면 그대로."""
|
||||
if price is None or not want or mm.plain_unit(have) == mm.plain_unit(want):
|
||||
return price
|
||||
if not same_kind(have, want) or kind_of(have) is None:
|
||||
return price
|
||||
return price * Decimal(_SCALE[mm.plain_unit(want)][1]) / Decimal(_SCALE[mm.plain_unit(have)][1])
|
||||
|
||||
|
||||
# ── 찾기 ───────────────────────────────────────────────────────────────
|
||||
def _words(term) -> tuple[list[str], list[str]]:
|
||||
"""(이름·규격에 든 낱말, 지역 낱말) — 지역 낱말은 고르는 차례에만 쓰임."""
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""계산서 줄 덧칸 — 「쓰는 입력」 · 묶음 · 기준단위 · 환산 근거(PLAN 714-1 · 요구서 4장 5 · 8).
|
||||
|
||||
`master_text.lines` 답(비목 묶음 줄)에 줄마다 덧칸을 얹음 — M01 `/text` 와 M02 `/logic-lib/sheet`
|
||||
두 길이 같은 함수(`annotate`)를 거쳐 같은 줄 꼴(`KEYS`)을 냄. 셈은 `master_formula.run` 한 벌.
|
||||
- `쓰는입력` = 그 줄 값(수량 식 · 요소 · 덧줄 식)에 쓰인 로직 입력 이름 — 중간값을 거슬러 감 ·
|
||||
덧줄이 부르는 비목 합 · 줄 금액은 거스르지 않음(율 식에 직접 쓴 입력만).
|
||||
- `묶음` · `장비` = 712-1 `master_bundle`(엔진 줄 덧칸 그대로).
|
||||
- `기준단위` = 그 줄 수량 식이 찾는 표의 기준(「10㎡」 · 712-3 `master_basis.basis`) · 답 머리
|
||||
`기준단위` = 로직 결과단위 분모(「10㎡」 · 여럿이면 「㎡ · ㎥」).
|
||||
- `환산근거` = 표 기준배 ↔ 결과단위 배(「10㎡당 → 1㎡당 ÷10」) · 재료 품목 단위 ↔ 줄 단위
|
||||
(712-2 `master_unit.note`) · 없으면 「」.
|
||||
줄 차례 맞춤 = `master_text.lines` 와 같은 차례(호표 줄마다 `_shares` 비목 · 뒤 덧줄).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from decimal import Decimal
|
||||
|
||||
import master_basis as mbs
|
||||
import master_bundle as mb
|
||||
import master_formula as mf
|
||||
import master_text as mtx
|
||||
import master_unit as mu
|
||||
|
||||
KEYS = ("쓰는입력", "묶음", "장비", "기준단위", "환산근거")
|
||||
_HOLE = re.compile(r"\{([^{}]+)\}")
|
||||
_ERRORS = (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError)
|
||||
|
||||
|
||||
def _names(expr) -> tuple[set[str], set[str]]:
|
||||
"""식 하나의 (이름 · 찾는 표 키) — `{입력}` 구멍 이름도 셈."""
|
||||
if isinstance(expr, dict): # 재료 고르기 조건 묶음 — 값 글의 구멍만
|
||||
return {h for v in expr.values() for h in _HOLE.findall(str(v))}, set()
|
||||
if expr in (None, ""):
|
||||
return set(), set()
|
||||
try:
|
||||
tree = mf.parse(expr)
|
||||
except mf.FormulaError:
|
||||
return set(_HOLE.findall(str(expr))), set()
|
||||
names, tables = set(), set()
|
||||
for node in mf.walk(tree):
|
||||
if node[0] == "name":
|
||||
names.add(node[1])
|
||||
elif node[0] in ("find", "logic", "ref"):
|
||||
names |= set(_HOLE.findall(node[1]))
|
||||
if node[0] == "find":
|
||||
tables.add(node[1])
|
||||
return names, tables
|
||||
|
||||
|
||||
def _closure(row: dict, exprs: list) -> tuple[list[str], list[str]]:
|
||||
"""식들이 쓰는 (입력 이름 · 표 키) — 중간값은 그 식으로 거슬러 감 · 입력 차례대로."""
|
||||
steps = {s["이름"]: s["식"] for s in row.get("중간", [])}
|
||||
inputs = [s["이름"] for s in row.get("입력", [])]
|
||||
seen, tables, todo = set(), [], list(exprs)
|
||||
while todo:
|
||||
names, found = _names(todo.pop())
|
||||
tables += [t for t in sorted(found) if t not in tables]
|
||||
for name in names - seen:
|
||||
seen.add(name)
|
||||
if name in steps:
|
||||
todo.append(steps[name])
|
||||
return [n for n in inputs if n in seen], tables
|
||||
|
||||
|
||||
def _times(x: Decimal) -> str:
|
||||
return format(x.normalize(), "f")
|
||||
|
||||
|
||||
def _per_text(per: list[tuple[Decimal, str]]) -> str:
|
||||
return " · ".join(dict.fromkeys(f"{_times(n) if n != 1 else ''}{u}" for n, u in per))
|
||||
|
||||
|
||||
def _basis(master, tables: list[str], env: dict) -> dict | None:
|
||||
for ref in tables:
|
||||
try:
|
||||
got = mbs.basis(master.get(mf.fill(ref, env) if "{" in ref else ref))
|
||||
except (*_ERRORS, mf.EmptyInput):
|
||||
continue
|
||||
if got["갈래"] == "단위":
|
||||
return got
|
||||
return None
|
||||
|
||||
|
||||
def _note(basis: dict | None, per: list, line: dict) -> str:
|
||||
notes = []
|
||||
if basis and len(per) == 1:
|
||||
times, one = per[0]
|
||||
if mu.plain(basis["기준단위"]) == mu.plain(mbs.unit(one)) and basis["기준배"] != times:
|
||||
ratio = basis["기준배"] / times
|
||||
how = f"÷{_times(ratio)}" if ratio > 1 else f"×{_times(1 / ratio)}"
|
||||
notes.append(
|
||||
f"{_times(basis['기준배'])}{basis['기준단위']}당 → {_times(times)}{one}당 {how}"
|
||||
)
|
||||
goods = line.get("품목") or {}
|
||||
if goods.get("단위") and line.get("단위"):
|
||||
notes.append(mu.note(goods["단위"], line["단위"]))
|
||||
return " · ".join(n for n in notes if n)
|
||||
|
||||
|
||||
def _fields(master, row, exprs, env, per, line: dict, bundle: tuple[str, bool] | None) -> dict:
|
||||
uses, tables = _closure(row, exprs)
|
||||
basis = _basis(master, tables, env)
|
||||
out = {
|
||||
"쓰는입력": uses,
|
||||
"기준단위": f"{_times(basis['기준배']) if basis['기준배'] != 1 else ''}{basis['기준단위']}"
|
||||
if basis
|
||||
else None,
|
||||
"환산근거": _note(basis, per, line),
|
||||
}
|
||||
if bundle:
|
||||
out["묶음"] = bundle[0]
|
||||
if bundle[1]:
|
||||
out["장비"] = True
|
||||
return out
|
||||
|
||||
|
||||
def annotate(master, key: str, given: dict, answer: dict) -> dict:
|
||||
"""`master_text.lines` 답에 줄마다 `KEYS` 덧칸 · 머리 `기준단위` — 못 셈이면 답 그대로."""
|
||||
if not answer.get("ok") or "groups" not in answer:
|
||||
return answer
|
||||
row = master.logic(key)
|
||||
per = mbs.result_per(row.get("결과단위"))
|
||||
answer["기준단위"] = _per_text(per) or None
|
||||
try:
|
||||
env = mtx._env_of(master, row, given)
|
||||
except _ERRORS:
|
||||
env = {}
|
||||
if "결과" in row:
|
||||
for line in answer["groups"][0]["줄"]:
|
||||
line.update(_fields(master, row, [row["결과"]["식"]], env, per, {}, None))
|
||||
return answer
|
||||
try:
|
||||
result = mf.run(master, key, given)
|
||||
except _ERRORS:
|
||||
result = None
|
||||
slots = {g["비목"]: iter(g["줄"]) for g in answer["groups"]}
|
||||
items = row.get("호표") or []
|
||||
for n, item in enumerate(items):
|
||||
line = result["줄"][n] if result else {}
|
||||
if result:
|
||||
parts = [p for p, _ in mtx._shares(line)]
|
||||
elif item.get("종류") == "로직" and "비목" not in item:
|
||||
parts = list(mf.COST_ITEMS)
|
||||
else:
|
||||
parts = [item.get("비목")]
|
||||
exprs = [item.get("수량"), item.get("요소")]
|
||||
got = _fields(master, row, exprs, env, per, line, mb.bundle(master, item, env))
|
||||
for part in parts:
|
||||
target = next(slots.get(part, iter(())), None)
|
||||
if target is not None:
|
||||
target.update(got)
|
||||
for extra in row.get("덧줄") or []:
|
||||
target = next(slots.get(extra["비목"], iter(())), None)
|
||||
if target is not None:
|
||||
target.update(
|
||||
_fields(master, row, [extra["식"]], env, per, {}, mb.bundle(master, extra))
|
||||
)
|
||||
return answer
|
||||
@@ -0,0 +1,121 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""단위 한 벌 — 별칭 · 개수 묶음 · 결과단위 배 읽기 · 같은 성격 환산 · 환산 근거 글(PLAN 712-2).
|
||||
|
||||
쓰는 곳: 재료 단가 → 줄 단위(`master_pick`) · 자재 맞대기 별칭(`master_material.plain_unit`) ·
|
||||
조합 알약 배(`M01_MasterData_Store_Combo`) · 엑셀 맞춤 배(`M02_Hopyo_Ours` · `_Match`) ·
|
||||
표 기준배(`master_basis`) · 계산서 줄 환산 근거(714-1). 서버 단독(③) — TS 는 서버가 준 글만 씀.
|
||||
- 개수 묶음(개 · EA · 본 · 매) = 1 : 1 같은 성격 · 조 · 세트는 묶음 밖(한 조 = 여럿).
|
||||
- 부피 ↔ 무게 = 단위중량 표 CC000337 에서 고른 줄(`unit_weight`)을 받을 때만 · 기본 줄 없음
|
||||
(사용자 확인 거리) · 밀도(kg ↔ ℓ) · ㎾h 등은 환산 없이 경고 글만.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
from decimal import Decimal
|
||||
|
||||
# 맞대기 꼴(`plain`) 별칭 — NFKC · 소문자 · 빈칸 없앤 뒤
|
||||
ALIAS = {"ea": "개", "ton": "톤", "t": "톤", "l": "ℓ", "리터": "ℓ", "set": "조", "세트": "조"}
|
||||
# 보이는 꼴(`shown`) 별칭 — 표 기준 · 결과단위 분모(원문 기호 ㎡ · ㎥ 그대로)
|
||||
SHOWN = {"ea": "개", "톤": "ton", "t": "ton", "㎞": "km", "hr": "시간", "min": "분", "㎏": "kg"}
|
||||
# 같은 성격 — {성격: {맞대기 단위: 기준 단위 몇 배}}
|
||||
UNITS = {
|
||||
"무게": {"g": 1, "kg": 1000, "톤": 1000000},
|
||||
"길이": {"mm": 1, "cm": 10, "m": 1000, "km": 1000000},
|
||||
"넓이": {"cm2": 1, "m2": 10000},
|
||||
"부피": {"cm3": 1, "ℓ": 1000, "m3": 1000000},
|
||||
"개수": {"개": 1, "본": 1, "매": 1},
|
||||
}
|
||||
_SCALE = {u: (kind, n) for kind, group in UNITS.items() for u, n in group.items()}
|
||||
WEIGHT_TABLE = "CC000337" # 건설품셈 공통 1-2-4 재료의 단위중량(㎏/㎥)
|
||||
# 결과단위 분모 — 「원/10㎡」 → (10, ㎡) · 「원/1,000본」 → (1000, 본)
|
||||
_PER = re.compile(r"원\s*/\s*(\d[\d,]*\.?\d*|\.\d+)?\s*([^\s·(\d,.][^\s·(]*)")
|
||||
|
||||
|
||||
def plain(unit) -> str:
|
||||
"""맞대기 꼴 — 「㎥」 = 「m3」 · 「EA」 = 「개」 · 「Ton」 = 「톤」 · 「리터」 = 「ℓ」."""
|
||||
s = re.sub(r"\s+", "", unicodedata.normalize("NFKC", str(unit or "")).lower())
|
||||
s = s.replace("ℓ", "l")
|
||||
return ALIAS.get(s, s)
|
||||
|
||||
|
||||
def shown(unit) -> str:
|
||||
return SHOWN.get(unit, unit)
|
||||
|
||||
|
||||
def per(result_unit) -> list[tuple[Decimal, str]]:
|
||||
"""결과단위 분모마다 (배, 단위 원문 글) — 배 없으면 1 · 분모가 여럿이면 여럿."""
|
||||
return [
|
||||
(Decimal(n.replace(",", "")) if n else Decimal(1), u)
|
||||
for n, u in _PER.findall(str(result_unit or ""))
|
||||
]
|
||||
|
||||
|
||||
def kind_of(unit) -> str | None:
|
||||
got = _SCALE.get(plain(unit))
|
||||
return got[0] if got else None
|
||||
|
||||
|
||||
def same_kind(a, b) -> bool:
|
||||
"""단위가 같거나 같은 성격(무게 · 길이 · 넓이 · 부피 · 개수)."""
|
||||
return plain(a) == plain(b) or (kind_of(a) is not None and kind_of(a) == kind_of(b))
|
||||
|
||||
|
||||
def _weighs(a, b) -> bool:
|
||||
return {kind_of(a), kind_of(b)} == {"무게", "부피"}
|
||||
|
||||
|
||||
def factor(have, want, density=None) -> Decimal | None:
|
||||
"""`have` 1 단위가 `want` 몇 단위인가 — 단가는 ÷, 수량은 ×.
|
||||
부피 ↔ 무게는 단위중량(㎏/㎥) `density` 가 있을 때만 · 못 하면 None."""
|
||||
a, b = plain(have), plain(want)
|
||||
if a == b:
|
||||
return Decimal(1)
|
||||
if same_kind(a, b):
|
||||
return Decimal(_SCALE[a][1]) / Decimal(_SCALE[b][1])
|
||||
if density and _weighs(a, b):
|
||||
grams_per_cm3 = Decimal(str(density)) / Decimal(1000)
|
||||
base = Decimal(_SCALE[a][1]) * (
|
||||
grams_per_cm3 if kind_of(a) == "부피" else 1 / grams_per_cm3
|
||||
)
|
||||
return base / Decimal(_SCALE[b][1])
|
||||
return None
|
||||
|
||||
|
||||
def convert(price, have, want, density=None):
|
||||
"""`have` 단위 단가 → `want` 단위 단가 — 못 바꾸면 그대로(경고는 부르는 쪽)."""
|
||||
if price is None or not want or not have or plain(have) == plain(want):
|
||||
return price
|
||||
if same_kind(have, want): # × 뒤 ÷ — 옛 셈과 같은 Decimal 꼴(자릿수)
|
||||
return price * Decimal(_SCALE[plain(want)][1]) / Decimal(_SCALE[plain(have)][1])
|
||||
got = factor(want, have, density) # want 1 단위 = have 몇 단위
|
||||
return price if got is None or got == 1 else price * got
|
||||
|
||||
|
||||
def note(have, want, density=None) -> str:
|
||||
"""환산 근거 글 — 「kg → 톤 ×1000」 · 「본 = 개(개수 1:1)」 · 못 바꾸면 까닭."""
|
||||
if not have or not want or plain(have) == plain(want):
|
||||
return ""
|
||||
got = factor(want, have, density)
|
||||
if got is not None:
|
||||
if kind_of(have) == "개수" and got == 1:
|
||||
return f"{have} = {want}(개수 1:1)"
|
||||
how = f" · 단위중량 {density}㎏/㎥" if _weighs(have, want) else ""
|
||||
return f"{have} → {want} ×{_num(got)}{how}"
|
||||
if _weighs(have, want):
|
||||
return f"{have} ↔ {want} — 단위중량({WEIGHT_TABLE}) 골라야 환산 · 지금 그대로"
|
||||
return f"{have} ↔ {want} — 성격 다름 · 환산 없음(경고)"
|
||||
|
||||
|
||||
def _num(x: Decimal) -> str:
|
||||
return format(x.normalize(), "f") if x == x.to_integral_value() else format(x, "f")[:10]
|
||||
|
||||
|
||||
def unit_weight(master, kind: str, shape: str, end: str = "위") -> Decimal | None:
|
||||
"""단위중량 표에서 고른 줄의 ㎏/㎥ — 기본 줄 없음(고르지 않으면 None)."""
|
||||
for row in master.get(WEIGHT_TABLE).get("줄") or []:
|
||||
if row.get("종별") == kind and row.get("형상") == shape:
|
||||
got = row.get(end)
|
||||
return None if got is None else Decimal(str(got))
|
||||
return None
|
||||
@@ -0,0 +1,47 @@
|
||||
/* 717-1 헬퍼 — 상세 부품 길 주입 · 가짜 fetch 로 부른 주소만 모음. 짝 = `test_717_1_logic_lib.py`. */
|
||||
const path = require("path");
|
||||
const { makeLoader } = require("./helper_ts_stub_loader.cjs");
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const { load } = makeLoader({
|
||||
root: ROOT,
|
||||
stubs: { M01_MasterData_UI_Logic_Edit: {}, M01_MasterData_UI_Logic_Text: {} },
|
||||
});
|
||||
const urls = [];
|
||||
global.fetch = async (url) => {
|
||||
urls.push(String(url).replace("http://fake", ""));
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "",
|
||||
json: async () => ({ logics: [], subs: [], items: [], rows: [] }),
|
||||
};
|
||||
};
|
||||
|
||||
const api = load("M01_MasterData", "M01_MasterData_UI_Logic_Api.ts");
|
||||
const rows = load("M01_MasterData", "M01_MasterData_Api_Fetch.ts");
|
||||
const note = load("M01_MasterData", "M01_MasterData_UI_Logic_Note.ts");
|
||||
|
||||
async function round(tag) {
|
||||
await api.fetchLogic("K");
|
||||
await api.fetchAuto("K");
|
||||
await api.searchElements("재료", "q");
|
||||
await api.fetchMaterials({ sub: "s" });
|
||||
await api.runCalc({ key: "K", inputs: {} });
|
||||
await api.runText({ key: "K", inputs: {} });
|
||||
await rows.fetchRows("기계.json", 1, 5, "q");
|
||||
await rows.fetchPick("price", "q");
|
||||
await note.loadTable(`T-${tag}`);
|
||||
await rows.fetchGroups(); // 관리자 길 — 늘 /m01
|
||||
await api.saveFiles([]); // 저장 — 늘 /m01
|
||||
return urls.splice(0).map((u) => u.split("?")[0]);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const out = { before: await round("a") };
|
||||
api.setLogicApiPaths({ read: "/m02/logic-lib", calc: "/m02/calc-x" });
|
||||
out.injected = await round("b");
|
||||
api.setLogicApiPaths();
|
||||
out.after = await round("c");
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
})();
|
||||
@@ -30,7 +30,7 @@ SYS = {"층": "system", "주인": None}
|
||||
|
||||
def _save(folder, doc):
|
||||
with store.at(folder):
|
||||
key = refs.create("precombo", {"이름": "자재 조합", **doc})["이름"]
|
||||
key = refs.create("precombo", {"이름": "자재 조합", "단위": "㎡", **doc})["이름"]
|
||||
return key, json.loads((folder / "precombo" / f"{key}.json").read_bytes())
|
||||
|
||||
|
||||
@@ -82,9 +82,9 @@ def _world_combo(world): # noqa: F811
|
||||
# MAT = 직접계산(222-1 · 기본 별도계상이면 셈에서 빠짐)
|
||||
mats = [{"코드": MAT, "수량": {"손": 2}, "별도계상": "직접계산"}, {"코드": WIRE, "수량": None}]
|
||||
with store.at(world["system"]):
|
||||
key = refs.create("precombo", {"이름": "자재 조합", "담은로직": lines, "자재": mats})[
|
||||
"이름"
|
||||
]
|
||||
key = refs.create(
|
||||
"precombo", {"이름": "자재 조합", "단위": "㎡", "담은로직": lines, "자재": mats}
|
||||
)["이름"]
|
||||
stone = Decimal(str(labor.run_line(m01.loaded()[1], STONE, {})["기준"]["계"]))
|
||||
return key, stone + _price(MAT) * 2
|
||||
|
||||
@@ -114,7 +114,11 @@ def test_B08_참조_줄_재료에_사전조합_자재_손_곱(world): # noqa: F
|
||||
with store.at(world["system"]):
|
||||
outer = refs.create(
|
||||
"precombo",
|
||||
{"이름": "겹", "담은로직": [{"참조": {**SYS, "키": key}, "수량": {"손": 3}}]},
|
||||
{
|
||||
"이름": "겹",
|
||||
"단위": "㎡",
|
||||
"담은로직": [{"참조": {**SYS, "키": key}, "수량": {"손": 3}}],
|
||||
},
|
||||
)["이름"]
|
||||
got = _ref_mats(m01.loaded()[1], refs.read_ref, {**SYS, "키": outer})
|
||||
mats = [(m["품목"]["키"], q) for m, q in got if m.get("자재")]
|
||||
|
||||
@@ -49,16 +49,17 @@ def test_줄_해시_옵션은_M01():
|
||||
from test_b01_settings_cards import _cards
|
||||
|
||||
cards = _cards({"role": "SYSTEM_ADMIN", "is_master": False}, 3)
|
||||
heads = [c[2].split("?")[0] + "?" + c[2].split("?")[1].split("&")[0] for c in cards[1:7]]
|
||||
heads = [c[2].split("?")[0] + "?" + c[2].split("?")[1].split("&")[0] for c in cards[1:8]]
|
||||
assert heads == [
|
||||
"m01-master-data?open=로직",
|
||||
"m01-master-data?open=옵션",
|
||||
"m01-master-data?open=별칭", # 718-3
|
||||
"m02-master-template?open=단가산출", # 717-2a
|
||||
"m02-master-template?open=일위대가조합",
|
||||
"m02-master-template?open=구조물조합",
|
||||
"m02-master-template?open=도면",
|
||||
]
|
||||
assert all("&root=master" in c[2] for c in cards[4:7])
|
||||
assert all("&root=master" in c[2] for c in cards[4:8])
|
||||
|
||||
|
||||
@needs_node
|
||||
@@ -77,7 +78,7 @@ def test_해시_진입_셋():
|
||||
+ fn.group(0).replace("export function", "function")
|
||||
+ "\nconsole.log(JSON.stringify(entryFromHash(null)));"
|
||||
)
|
||||
for want in ("구조물조합", "옵션", "도면", "일위대가조합"):
|
||||
for want in ("구조물조합", "옵션", "도면", "일위대가조합", "단가산출"):
|
||||
got = _run(script, f"open={want}&root=master&company=3")
|
||||
assert got["open"] == want and got["root"] == "master" and got["company"] == "3"
|
||||
|
||||
@@ -85,8 +86,12 @@ def test_해시_진입_셋():
|
||||
def test_reveal_옵션은_M01_로():
|
||||
side = _text(M02 / "M02_MasterTemplete_UI_Side.ts")
|
||||
body = side.split("const reveal = ")[1].split("\n };")[0]
|
||||
# 716-1 · 716-3 — 옵션 정의는 M01 로 옮김 · M02 reveal 은 일위대가 · 도면만 펼침
|
||||
assert 'target === "일위대가조합" ? precomboSec : target === "도면" ? drawing : null' in body
|
||||
# 716-1 · 716-3 · 717-2a — 옵션 정의는 M01 로 옮김 · M02 reveal 은 단가산출 · 일위대가 · 도면만 펼침
|
||||
flat = " ".join(body.split())
|
||||
assert (
|
||||
'target === "단가산출" ? sangeunSec : target === "일위대가조합" ? precomboSec'
|
||||
' : target === "도면" ? drawing : null'
|
||||
) in flat
|
||||
assert 'sec?.classList.remove("is-collapsed");' in body
|
||||
assert "options" not in body and "옵션: section(" not in side
|
||||
page = _text(M02 / "M02_MasterTemplete_UI_Page.ts")
|
||||
|
||||
@@ -62,7 +62,7 @@ def world(tmp_path, monkeypatch):
|
||||
company = layers.company_dir(7)
|
||||
company.mkdir(parents=True)
|
||||
with store.at(company):
|
||||
other = refs.create("precombo", {"이름": "회사 조합", "담은로직": lines[:1]})["이름"]
|
||||
other = refs.create("precombo", {"이름": "회사 조합", "단위": "㎡", "담은로직": lines[:1]})["이름"]
|
||||
project = tmp_path / "storage" / "7" / "1" / "proj"
|
||||
project.mkdir(parents=True)
|
||||
return {"system": system, "key": key, "company": other, "root": project}
|
||||
|
||||
@@ -120,7 +120,8 @@ def test_화면_기준_목록_계산서_담기() -> None:
|
||||
assert "openStandardSheet(row, () => {\n close();\n done(logic);" in pick
|
||||
assert "(!work.select.value || x.공종 === work.select.value)" in pick
|
||||
sheet = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor_Sheet.ts").read_text("utf-8")
|
||||
assert 'from "@ui/ui_template_calc_row"' in sheet and "createSourceView(" in sheet
|
||||
# 줄 · 소계 · 계 = 공용 단가산출 계산서(714-2 · 계산 줄 부품을 그 안에서 씀)
|
||||
assert 'from "../A00_Common/calc_sheet"' in sheet and "createSourceView(" in sheet
|
||||
for name in ("Labor_Pick", "Labor_Sheet"):
|
||||
text = (ROOT / f"M02_MasterTemplete/M02_MasterTemplete_{name}.ts").read_text("utf-8")
|
||||
assert len(text.splitlines()) <= 700
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""712-1 지킴 — 인력 · 재료 · 경비 묶음 셈 때 파생 · 기계경비 판별 한 곳(`master_bundle`)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "resources/master_data/scripts"))
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import check_master as cm # noqa: E402
|
||||
import master_auto as mau # noqa: E402
|
||||
import master_bundle as mb # noqa: E402
|
||||
import master_formula as mf # noqa: E402
|
||||
|
||||
FILES = cm.load()
|
||||
MASTER = mf.Master(FILES)
|
||||
LOGICS = {r["키"]: r for d in FILES.values() if d.get("그룹") == "로직" for r in d.get("줄") or []}
|
||||
|
||||
|
||||
def test_every_line_bundled():
|
||||
"""호표 · 덧줄 전수 — 하위(못 가름) ≤ 31 · 나머지는 인력 · 재료 · 경비 · 기계경비 줄 = 경비."""
|
||||
count = dict.fromkeys(mb.BUNDLES, 0)
|
||||
machine = 0
|
||||
for row in LOGICS.values():
|
||||
for item in [*row.get("호표", []), *row.get("덧줄", [])]:
|
||||
name, is_mach = mb.bundle(MASTER, item)
|
||||
count[name] += 1
|
||||
machine += is_mach
|
||||
if is_mach:
|
||||
assert name == "경비"
|
||||
assert sum(count.values()) > 5_000
|
||||
assert count["하위"] <= 31, count
|
||||
assert machine >= 1_000, machine
|
||||
|
||||
|
||||
def test_machine_rule_one_place():
|
||||
"""기계경비 = 결과단위 원/시간 돈 로직 — 작업량(㎥/시간) · 이름 「시간당」 은 아님."""
|
||||
machines = {k for k, r in LOGICS.items() if mb.is_machine(r)}
|
||||
assert {"GC000267", "GC000268", "GF000145"} <= machines
|
||||
assert not machines & {"GF000102", "GC000422", "GC000260"} # 작업량 · ㎡/시간 · 원/㎡
|
||||
sys.path.insert(0, str(ROOT))
|
||||
from M02_MasterTemplete import M02_Hopyo_Ours as ours
|
||||
|
||||
assert all(ours.is_mach_logic(r) == mb.is_machine(r) for r in LOGICS.values())
|
||||
|
||||
|
||||
def test_run_lines_tagged():
|
||||
"""엔진 `run` 줄마다 `묶음` · 기계경비 줄은 `장비` + 비목 노 · 재 · 경 세 몫."""
|
||||
seen = set()
|
||||
for key in ("GF000169", "GF000103"):
|
||||
row = LOGICS[key]
|
||||
given = {k: v for k, v in mau.values(MASTER, row).items() if v is not None}
|
||||
got = mf.run(MASTER, key, given)
|
||||
for line in got["줄"]:
|
||||
assert line["묶음"] in mb.BUNDLES
|
||||
seen.add(line["묶음"])
|
||||
if line.get("장비"):
|
||||
assert set(line["비목"]) == set(mf.COST_ITEMS)
|
||||
total = sum((line["비목"].get(k) or Decimal(0)) for line in got["줄"] for k in line["비목"])
|
||||
assert abs(total - got["계"]) < 1
|
||||
assert {"인력", "재료", "하위"} <= seen
|
||||
@@ -0,0 +1,59 @@
|
||||
"""712-2 지킴 — 단위 한 벌(`master_unit`) · 다섯 자리가 같은 모듈을 씀 · 알약 배 전수."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "resources/master_data/scripts"))
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import check_master as cm # noqa: E402
|
||||
import master_basis as mbs # noqa: E402
|
||||
import master_formula as mf # noqa: E402
|
||||
import master_material as mm # noqa: E402
|
||||
import master_pick as mp # noqa: E402
|
||||
import master_unit as mu # noqa: E402
|
||||
|
||||
|
||||
def test_alias_and_count_group():
|
||||
assert mu.plain("EA") == mu.plain("ea") == "개"
|
||||
assert mu.plain("Ton") == mu.plain("t") == mu.plain("톤") == "톤"
|
||||
assert mu.plain("㎥") == "m3" and mu.plain("리터") == mu.plain("ℓ") == "ℓ"
|
||||
assert mu.same_kind("본", "개") and mu.same_kind("매", "EA")
|
||||
assert not mu.same_kind("조", "개") # 한 조 = 여럿 — 묶음 밖
|
||||
assert mu.note("본", "개") == "본 = 개(개수 1:1)"
|
||||
assert mm.plain_unit("EA") == mu.plain("EA")
|
||||
assert mp.convert is mu.convert and mp.UNITS is mu.UNITS
|
||||
|
||||
|
||||
def test_same_kind_convert():
|
||||
assert mu.convert(Decimal(1000), "톤", "kg") == Decimal(1) # 톤 당 1000 원 = kg 당 1 원
|
||||
assert mu.convert(Decimal(5), "kg", "톤") == Decimal(5000)
|
||||
assert mu.convert(Decimal(7), "m", "km") == Decimal(7000)
|
||||
assert mu.factor("kg", "ton") == Decimal("0.001") # B08 「/1000」(kg → ton) 손 식과 같은 값
|
||||
assert mu.note("kg", "톤") == "kg → 톤 ×1000"
|
||||
assert mu.convert(Decimal(3), "kg", "ℓ") == Decimal(3) # 밀도 = 경고만 · 그대로
|
||||
assert "성격 다름" in mu.note("ℓ", "㎾h")
|
||||
|
||||
|
||||
def test_volume_weight_only_when_picked():
|
||||
"""부피 ↔ 무게 = 단위중량 표 고른 줄이 있을 때만 · 기본 줄 없음."""
|
||||
master = mf.Master(cm.load())
|
||||
assert mu.factor("㎥", "톤") is None
|
||||
assert "단위중량(CC000337)" in mu.note("㎥", "톤")
|
||||
rho = mu.unit_weight(master, "모래", "건조")
|
||||
assert rho is not None and rho > 1000
|
||||
assert mu.factor("㎥", "톤", rho) == rho / 1000 # 1 ㎥ = ρ/1000 톤
|
||||
assert mu.convert(Decimal(100), "㎥", "톤", rho) == Decimal(100) * 1000 / rho
|
||||
assert mu.unit_weight(master, "없는 종별", "건조") is None
|
||||
|
||||
|
||||
def test_result_per_one_parser():
|
||||
assert mu.per("원/10㎡") == [(Decimal(10), "㎡")]
|
||||
assert mu.per("원/1,000본") == [(Decimal(1000), "본")]
|
||||
assert mu.per("원/㎡ · 원/㎥(단위 따름)") == [(Decimal(1), "㎡"), (Decimal(1), "㎥")]
|
||||
assert mu.per("㎥/시간") == []
|
||||
assert mbs.result_per("원/ton") == [(Decimal(1), "ton")]
|
||||
@@ -0,0 +1,34 @@
|
||||
"""713-2 정본 개정 확인 창 지킴 시험 — 글로 잡음(화면 · 서버 없음).
|
||||
|
||||
- `/save` 몸에 `개정확인` 이 실림 · 로직 화면 [저장]은 `saveRevised` 한 길(개정 409 → 창 → 다시 보냄)
|
||||
- 창은 부품 상자(모달 · 목록 줄 · 배지)만 · 옛 `stale` 409 는 이 창 대상 밖
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
M01 = Path(__file__).resolve().parents[2] / "M01_MasterData"
|
||||
|
||||
|
||||
def _read(name: str) -> str:
|
||||
return (M01 / f"M01_MasterData_UI_{name}.ts").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_save_body_carries_confirm():
|
||||
api = _read("Logic_Api")
|
||||
assert 'request("/save", { files, 개정확인 })' in api
|
||||
|
||||
|
||||
def test_lab_save_goes_through_revise():
|
||||
lab = _read("LogicLab")
|
||||
assert "saveRevised((ok) => saveFiles(" in lab
|
||||
assert "await saveFiles(" not in lab.replace("saveRevised((ok) => saveFiles(", "")
|
||||
|
||||
|
||||
def test_revise_window_parts_only():
|
||||
rev = _read("LogicLab_Revise")
|
||||
for part in ("ui_template_modal", "ui_template_list_row", "ui_template_badge"):
|
||||
assert f"@ui/{part}" in rev
|
||||
assert "<button" not in rev and "document.createElement" not in rev
|
||||
# 개정 409 만 창 — 옛 stale 409 는 부르는 쪽 토스트 그대로
|
||||
assert "개정?: ReviseHit[]" in rev and "if (!hits) throw error" in rev
|
||||
assert "save(true)" in rev
|
||||
@@ -0,0 +1,62 @@
|
||||
"""714-1 지킴 — 계산서 줄 덧칸(쓰는 입력 · 묶음 · 기준단위 · 환산 근거) · M01 `/text` 와
|
||||
M02 `/logic-lib/sheet` 가 같은 줄 꼴(`master_sheet_line`)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as store # noqa: E402
|
||||
from M01_MasterData import M01_MasterData_Store_Sheet as sheet # noqa: E402
|
||||
from M02_MasterTemplete import M02_Logic_Standard as standard # noqa: E402
|
||||
|
||||
msl = sheet.msl
|
||||
|
||||
|
||||
def _text(key: str) -> dict:
|
||||
auto = {k: v for k, v in store.auto(key)["값"].items() if v is not None}
|
||||
return sheet.text(key, auto)
|
||||
|
||||
|
||||
def _lines(got: dict, groups: str = "groups") -> list[dict]:
|
||||
return [line for g in got[groups] for line in g["줄"]]
|
||||
|
||||
|
||||
def test_line_keys_and_bundle():
|
||||
got = _text("GF000103") # 구조물터파기 — 인력 + 굴착기 기계경비 줄
|
||||
assert got["ok"] and got["기준단위"] == "㎥"
|
||||
lines = _lines(got)
|
||||
assert all({"쓰는입력", "묶음", "기준단위", "환산근거"} <= set(line) for line in lines)
|
||||
machine = [line for line in lines if line.get("장비")]
|
||||
assert machine and all(line["묶음"] == "경비" for line in machine)
|
||||
assert {"지역", "보정작업"} <= set(machine[0]["쓰는입력"])
|
||||
assert any(line["묶음"] == "인력" for line in lines)
|
||||
|
||||
|
||||
def test_uses_follow_middle_values():
|
||||
"""중간값을 거슬러 입력까지 — 돌쌓기 석공 품은 높이 · 쌓기 · 돌 입력을 씀."""
|
||||
lines = _lines(_text("GF000219"))
|
||||
assert {"높이", "쌓기", "돌"} <= set(lines[0]["쓰는입력"])
|
||||
|
||||
|
||||
def test_basis_note_tenfold():
|
||||
"""표 10㎡당 → 결과 원/㎡ 「÷10」 · 결과도 원/10㎡ 면 근거 없음."""
|
||||
one = _lines(_text("GC000037"))
|
||||
assert one[0]["기준단위"] == "10㎡" and one[0]["환산근거"] == "10㎡당 → 1㎡당 ÷10"
|
||||
ten = _text("GF000124")
|
||||
assert ten["기준단위"] == "10㎡" and all(not x["환산근거"] for x in _lines(ten))
|
||||
|
||||
|
||||
def test_two_routes_same_shape():
|
||||
"""M01 `/text` 와 M02 계산서 — 줄 차례 · 덧칸 값이 같음(금액 끝수만 다를 수 있음)."""
|
||||
for key in ("GF000169", "GF000103", "GF000219"):
|
||||
m01 = _lines(_text(key))
|
||||
m02 = _lines(standard.sheet(key, {}), "묶음")
|
||||
assert len(m01) == len(m02)
|
||||
for a, b in zip(m01, m02):
|
||||
assert set(b) == {"이름", "글", "금액", *msl.KEYS}
|
||||
assert a["이름"] == b["이름"]
|
||||
assert [a.get(k) for k in msl.KEYS] == [b.get(k) for k in msl.KEYS]
|
||||
@@ -0,0 +1,47 @@
|
||||
"""714-2 공용 단가산출 계산서 지킴 시험 — 글 · 순수 함수(화면 · 서버 없음).
|
||||
|
||||
- 704-2 M02 계산서 보기 · M01 「인력·재료·경비」 보기가 한 벌(`A00_Common/calc_sheet.ts`)을 씀
|
||||
- 계산서는 부품 상자만(날 단추 · 입력 · 표 없음) · 화면에서 다시 셈 안 함(값 = 서버)
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SHEET = ROOT / "A00_Common" / "calc_sheet.ts"
|
||||
|
||||
|
||||
def _text(p: Path) -> str:
|
||||
return p.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_한_벌을_씀():
|
||||
m02 = _text(ROOT / "M02_MasterTemplete" / "M02_MasterTemplete_Labor_Sheet.ts")
|
||||
est = _text(ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_Est.ts")
|
||||
detail = _text(ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_Detail.ts")
|
||||
assert "calcSheetRows(" in m02 and "createCalcFormulaRow" not in m02
|
||||
assert "calcSheetRows(" in est and "buildEst(" in detail
|
||||
|
||||
|
||||
def test_부품_상자만():
|
||||
s = _text(SHEET)
|
||||
assert "<button" not in s and "document.createElement" not in s and "<table" not in s
|
||||
assert "@ui/ui_template_calc_row" in s and "@ui/ui_template_input" in s
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
def test_식_글():
|
||||
s = _text(SHEET)
|
||||
pure = s[s.index("export const sheetFormula") : s.index("/** 변수 칸 하나")]
|
||||
body = 'console.log(JSON.stringify([sheetFormula("268,908 * 0.12 = 32,269"), sheetFormula("59,020 * 1 * 8 / 165 = 2,862"), sheetFormula("")]))'
|
||||
out = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", pure + "\n" + body],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert out.returncode == 0, out.stderr
|
||||
assert json.loads(out.stdout) == ["268,908 × 0.12", "59,020 × 1 × 8 / 165", ""]
|
||||
@@ -0,0 +1,62 @@
|
||||
"""714-3 텍스트 수식 입력 단추 지킴 시험 — 줄이 쓰는 입력 고르기(순수 함수 · node) + 이음 글.
|
||||
|
||||
`M01_MasterData_UI_LogicLab_Formula.ts` 「순수 셈」 구간만 떼어 돌림(화면 · 서버 없음).
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
M01 = Path(__file__).resolve().parents[2] / "M01_MasterData"
|
||||
FX = M01 / "M01_MasterData_UI_LogicLab_Formula.ts"
|
||||
|
||||
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
|
||||
|
||||
def _run(body: str) -> object:
|
||||
text = FX.read_text(encoding="utf-8")
|
||||
pure = text[text.index("/* ── 순수 셈: 줄이 쓰는 입력 ── */") : text.index("/* ── 순수 셈 끝 ── */")]
|
||||
out = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", pure + "\n" + body],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert out.returncode == 0, out.stderr
|
||||
return json.loads(out.stdout)
|
||||
|
||||
|
||||
def test_표_행_열쇠와_이름_조각():
|
||||
got = _run(
|
||||
"""
|
||||
const line = {
|
||||
조각: [
|
||||
{ kind: "표", 글: "25 · 깬잡석", 참조: { 키: "QF1", 행: { 뒷길이: 25, 돌: "깬잡석", 줄번호: 3 } } },
|
||||
{ kind: "기호", 글: "*" },
|
||||
{ kind: "값", 글: "두께", 값: "20" },
|
||||
{ kind: "값", 글: "0.009" },
|
||||
{ kind: "값", 글: "뒷길이", 값: "99" },
|
||||
],
|
||||
요소조각: [{ kind: "표", 글: "x", 참조: { 키: "QF2", 행: 4 } }],
|
||||
};
|
||||
console.log(JSON.stringify(usedInputs(line, ["뒷길이", "돌", "두께", "쌓기"])));
|
||||
"""
|
||||
)
|
||||
# 처음 나온 값 · 입력 아닌 열쇠(줄번호) · 이름 없는 수(0.009) · 행 번호(4) 는 뺌
|
||||
assert got == [["뒷길이", "25"], ["돌", "깬잡석"], ["두께", "20"]]
|
||||
|
||||
|
||||
def test_빈_줄():
|
||||
assert _run('console.log(JSON.stringify(usedInputs({}, ["a"])))') == []
|
||||
|
||||
|
||||
def test_이음_시험_입력만_고침():
|
||||
lab = (M01 / "M01_MasterData_UI_LogicLab.ts").read_text(encoding="utf-8")
|
||||
detail = (M01 / "M01_MasterData_UI_LogicLab_Detail.ts").read_text(encoding="utf-8")
|
||||
# 텍스트 수식 고침 = 시험 계산 입력 + 다시 셈(흐름 예시값과 같은 길) · 캐시(drafts)에 안 들어감
|
||||
assert "onInput: (name, v) => syncLeft(current, { [name]: v }, true)" in lab
|
||||
assert "onInput: ctx.onInput" in detail
|
||||
fx = FX.read_text(encoding="utf-8")
|
||||
assert "touch" not in fx and "drafts" not in fx
|
||||
@@ -0,0 +1,135 @@
|
||||
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
|
||||
"""일위대가 · 사전조합 줄 덮개 · [단가산출로 저장] · 단위 빈칸 막기(PLAN 715-3) — 임시 storage.
|
||||
|
||||
줄 덮개 = 그 줄에만(단가산출 원본 그대로) · 값 = 같은 덮개 SK 값 = 손셈 ·
|
||||
줄 고름(차례)이 뺀줄 뒤 제자리 ·
|
||||
[단가산출로 저장] = 새 SK + 참조 줄 · 같은 값 · 단위 빈칸 400 · 덮개 없는 줄 = 옛 값.
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
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 MINE, world # noqa: F401 — 같은 임시 층
|
||||
|
||||
PCT = "GC001109" # 원/㎡ · 인력 둘(LB000036 · LB000002) + 「노무비 * 3 / 100」
|
||||
TWO_MATS = "GC000565" # 원/m · 재료 줄 둘
|
||||
COVER = {"덮개": {"labor:LB000036#0": {"qty": 0.5}}}
|
||||
|
||||
|
||||
def _whole():
|
||||
return m01.loaded()[1]
|
||||
|
||||
|
||||
def _sc(folder, lines, unit="㎡"):
|
||||
with store.at(folder):
|
||||
return refs.create("precombo", {"이름": "조합", "단위": unit, "담은로직": lines})["이름"]
|
||||
|
||||
|
||||
def _value(folder, key):
|
||||
with store.at(folder):
|
||||
return refs.Expand(_whole())({**MINE, "키": key})
|
||||
|
||||
|
||||
def test_logic_line_cover_in_precombo(world):
|
||||
"""사전조합 로직 줄 덮개 = 같은 덮개 SK 값 · 손셈(품 0.5 × 단가 + 둘째 줄 · 3 % 덧줄)."""
|
||||
bare = labor.run_line(_whole(), PCT, {})
|
||||
sc = _sc(world["personal"], [{"로직": PCT, "패치": COVER}])
|
||||
with store.at(world["personal"]):
|
||||
kept = store.read("precombo", sc)["문서"]["담은로직"][0]
|
||||
assert kept["패치"] == COVER
|
||||
got = _value(world["personal"], sc)["기준"]
|
||||
labor_sum = Decimal("0.5") * Decimal(str(bare["줄"][0]["단가"])) + Decimal(
|
||||
str(bare["줄"][1]["금액"])
|
||||
)
|
||||
assert got["노무비"] == pytest.approx(labor_sum)
|
||||
assert got["경비"] == pytest.approx(labor_sum * 3 / 100, abs=1)
|
||||
|
||||
|
||||
def test_line_pick_by_order_stays_after_drop(world):
|
||||
"""줄 고름 = 바탕 호표 차례(2) · 패치 뺀줄로 첫 재료 줄을 빼도 그 자재가 그 줄에."""
|
||||
import master_pick as mp
|
||||
|
||||
whole = _whole()
|
||||
row = whole.logic(TWO_MATS)
|
||||
item = row["호표"][2]
|
||||
want = mp.search(whole, item["요소"], {}, item.get("단위"))[1]
|
||||
one = {
|
||||
"로직": TWO_MATS,
|
||||
"고름": {"2": want},
|
||||
"자재표시": {"2": {"별도계상": "직접계산"}},
|
||||
"패치": {"뺀줄": ["material:" + row["호표"][1]["이름"] + "#0"]},
|
||||
}
|
||||
with store.at(world["personal"]):
|
||||
doc = labor.clean({"담은로직": [one]})
|
||||
got = labor.calc(doc, {})["줄"][0]
|
||||
picked = [x for x in got["줄"] if x.get("품목")]
|
||||
assert [x["품목"]["키"] for x in picked] == [want]
|
||||
|
||||
|
||||
def test_ref_line_cover_only_that_line(world):
|
||||
"""SK 참조 줄 덮개 = 그 줄만 · SK 원본 · 다른 줄 값 그대로."""
|
||||
with store.at(world["personal"]):
|
||||
sk = refs.create(
|
||||
"sangeun", {"이름": "밑", "단위": "㎡", "바탕": {"로직": PCT}, "더한줄": []}
|
||||
)["이름"]
|
||||
before = store.read("sangeun", sk)["판"]
|
||||
ref = {**MINE, "키": sk}
|
||||
sc = _sc(
|
||||
world["personal"],
|
||||
[{"참조": ref, "수량": {"손": 1}, "패치": COVER}, {"로직": "GC001111", "수량": {"손": 1}}],
|
||||
)
|
||||
inner = _value(world["personal"], sc)["안"]
|
||||
plain = _value(world["personal"], _sc(world["personal"], [{"참조": ref}]))["기준"]
|
||||
covered = calc.calc_doc(
|
||||
{"키": "SK-000999", "이름": "x", "단위": "㎡", "바탕": {"로직": PCT}, **COVER}
|
||||
)
|
||||
assert inner[0]["기준"]["계"] == pytest.approx(Decimal(str(covered["1단위"]["계"])))
|
||||
assert inner[0]["기준"]["계"] != plain["계"]
|
||||
with store.at(world["personal"]):
|
||||
assert store.read("sangeun", sk)["판"] == before
|
||||
|
||||
|
||||
def test_save_as_sangeun(world):
|
||||
"""[단가산출로 저장] — 새 SK(바탕 · 입력값 · 고름 cid · 덮개) · 참조 줄 · 같은 1 단위 값."""
|
||||
one = {"로직": PCT, "패치": COVER, "수량": {"손": 2}, "별칭": "내 줄"}
|
||||
with store.at(world["personal"]):
|
||||
line_value = labor.calc(labor.clean({"담은로직": [one]}), {})["줄"][0]["기준"]
|
||||
got = calc.from_line(one, "줄로 만든", "㎡")
|
||||
doc = store.read("sangeun", got["이름"])["문서"]
|
||||
value = calc.calc_doc(doc)["기준"]
|
||||
assert got["줄"] == {"참조": {**MINE, "키": got["이름"]}, "수량": {"손": 2}, "별칭": "내 줄"}
|
||||
assert doc["바탕"] == {"로직": PCT} and doc["덮개"]["labor:LB000036#0"]["qty"] == 0.5
|
||||
assert value["계"] == pytest.approx(line_value["계"])
|
||||
|
||||
|
||||
def test_refusals(world):
|
||||
with store.at(world["personal"]):
|
||||
with pytest.raises(store.StoreError, match="단위가 빔"):
|
||||
refs.create("precombo", {"이름": "x", "단위": " ", "담은로직": [{"로직": PCT}]})
|
||||
assert refs.create("precombo", {"이름": "빈 새 문서", "단위": None, "담은로직": []})["이름"]
|
||||
bad = {"로직": PCT, "패치": {"더한줄": [{"참조": {**MINE, "키": "SK-000001"}, "수량": 1}]}}
|
||||
with pytest.raises(store.StoreError, match="참조 못 넣음"):
|
||||
labor.clean({"담은로직": [bad]})
|
||||
with pytest.raises(store.StoreError, match="수가 아님"):
|
||||
labor.clean(
|
||||
{"담은로직": [{"로직": PCT, "패치": {"덮개": {"labor:LB000036#0": {"qty": "식"}}}}]}
|
||||
)
|
||||
sc = _sc(world["personal"], [{"로직": PCT}])
|
||||
with pytest.raises(store.StoreError, match="줄 덮개는"):
|
||||
labor.clean({"담은로직": [{"참조": {**MINE, "키": sc}, "패치": COVER}]})
|
||||
|
||||
|
||||
def test_no_patch_same_value(world):
|
||||
"""덮개 없는 줄 = run_line 값 그대로(옛 셈 길)."""
|
||||
sc = _sc(world["personal"], [{"로직": PCT, "패치": {}}])
|
||||
with store.at(world["personal"]):
|
||||
assert "패치" not in store.read("precombo", sc)["문서"]["담은로직"][0]
|
||||
assert _value(world["personal"], sc)["기준"]["계"] == pytest.approx(
|
||||
labor.run_line(_whole(), PCT, {})["기준"]["계"]
|
||||
)
|
||||
@@ -1,4 +1,5 @@
|
||||
"""M02 단가산출(SK) 서고 · 셈(PLAN 715-1b · 715-2 · `M02_Sangeun_Calc` · `M02_Labor_Ref`) — 임시 storage.
|
||||
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
|
||||
"""M02 단가산출(SK) 서고 · 셈(PLAN 715-1b · 715-2) — 임시 storage.
|
||||
|
||||
고친 줄 값 = 손셈 · 덧줄 % 엔진이 다시 셈 · 뺀줄 뒤 고름 제자리 · 짝 잃음 남김 · 원값 서버 ·
|
||||
순환 422 · 쓰는 곳 지우기 409 · 사전조합이 SK 를 담음 · 층 복사 왕복 같은 값.
|
||||
@@ -46,7 +47,7 @@ def _bare(code):
|
||||
|
||||
|
||||
def test_qty_cover_hand_and_pct_recomputed(world):
|
||||
"""품 덮개 = 손셈 · 「노무비 * 3 / 100」 덧줄이 덮인 노무비에서 다시 셈 · 원값 = 서버가 그때 M01 값."""
|
||||
"""품 덮개 = 손셈 · 「노무비 * 3 / 100」 덧줄 다시 셈 · 원값 = 서버가 셈."""
|
||||
bare = _bare(PCT)
|
||||
first, second = bare["줄"][0], bare["줄"][1]
|
||||
key = _sk(
|
||||
@@ -134,14 +135,18 @@ def test_precombo_holds_sangeun(world):
|
||||
with store.at(world["personal"]):
|
||||
sc = refs.create(
|
||||
"precombo",
|
||||
{"이름": "조합", "담은로직": [{"참조": {**MINE, "키": sk}, "수량": {"손": 2}}]},
|
||||
{
|
||||
"이름": "조합",
|
||||
"단위": "㎡",
|
||||
"담은로직": [{"참조": {**MINE, "키": sk}, "수량": {"손": 2}}],
|
||||
},
|
||||
)["이름"]
|
||||
got = labor.calc({"담은로직": [{"참조": {**MINE, "키": sc}, "수량": {"손": 1}}]}, {})
|
||||
with pytest.raises(store.StoreError) as e:
|
||||
store.delete("sangeun", sk)
|
||||
one = _calc(world["personal"], sk)["1단위"]["계"]
|
||||
assert got["반영합"]["계"] == pytest.approx(one * 2, abs=1)
|
||||
assert e.value.status == 409 and "사전조합 개인 7/42" in str(e.value.detail)
|
||||
assert e.value.status == 409 and "일위대가 개인 7/42" in str(e.value.detail)
|
||||
|
||||
|
||||
def test_layer_copy_round_trip(world):
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""717-1 상세 부품 공용 — 길 주입(TS) · M02 `logic-lib` 읽기 · 셈 길(로그인 · 쓰기 없음).
|
||||
|
||||
TS = `helper_717_1_logic_api_paths.cjs`(가짜 fetch 주소) · 서버 = 정본 읽기만(파일 지문 같음).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import Depends, FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from common_util.common_util_auth import verify_session
|
||||
from M01_MasterData import M01_MasterData_Store as store
|
||||
from M02_MasterTemplete import M02_Logic_Lib_Read as lib
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
HELPER = Path(__file__).parent / "helper_717_1_logic_api_paths.cjs"
|
||||
KEY = "GF000001" # 경계표시 — 보통인부 한 줄
|
||||
BASE = "/api/m02/logic-lib"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (ROOT / "config" / "node_modules").is_dir(), reason="의존성 미설치")
|
||||
def test_길_주입_없으면_m01_있으면_넘긴_길() -> None:
|
||||
done = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(HELPER)], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8"
|
||||
)
|
||||
assert done.returncode == 0, done.stderr
|
||||
out = json.loads(done.stdout)
|
||||
assert out["before"] == out["after"] # 안 넘기면 지금 그대로
|
||||
assert all(u.startswith("/m01/") for u in out["before"])
|
||||
shared, admin = out["injected"][:-2], out["injected"][-2:]
|
||||
assert all(u.startswith(("/m02/logic-lib/", "/m02/calc-x/")) for u in shared)
|
||||
assert [u for u in shared if u.startswith("/m02/calc-x/")] == [
|
||||
"/m02/calc-x/calc",
|
||||
"/m02/calc-x/text",
|
||||
]
|
||||
assert admin == ["/m01/groups", "/m01/save"] # 관리자 · 저장 길은 늘 M01
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def user() -> TestClient:
|
||||
"""main.py 와 같은 등록(로그인만) · 일반 사용자."""
|
||||
app = FastAPI()
|
||||
app.include_router(lib.router, dependencies=[Depends(verify_session)])
|
||||
app.dependency_overrides[verify_session] = lambda: {
|
||||
"user_id": 42, "company_id": 7, "role": "USER", "is_master": False,
|
||||
} # fmt: skip
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _prints() -> dict[str, str]:
|
||||
return {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in store.FOLDER.glob("*.json")}
|
||||
|
||||
|
||||
def test_일반_계정_읽기(user: TestClient) -> None:
|
||||
assert user.get(f"{BASE}/logic/auto", params={"key": KEY}).status_code == 200
|
||||
assert user.get(f"{BASE}/elements", params={"group": "인력", "q": "LB000002"}).json()["items"]
|
||||
assert user.get(f"{BASE}/pick", params={"kind": "price", "q": "시멘트"}).status_code == 200
|
||||
assert user.get(f"{BASE}/rows", params={"file": "기계.json", "size": 3}).json()["rows"]
|
||||
assert user.get(f"{BASE}/materials", params={"sub": "시멘트"}).status_code == 200
|
||||
flow = user.get(f"{BASE}/logic/flow", params={"key": KEY})
|
||||
assert flow.status_code == 200 and "flow" in flow.json()
|
||||
found = user.get(f"{BASE}/elements", params={"group": "소요량", "q": "QF000025"}).json()
|
||||
file = next(i["file"] for i in found["items"] if i["ref"] == "QF000025")
|
||||
assert user.get(f"{BASE}/table", params={"file": file, "key": "QF000025"}).json()["table"]
|
||||
|
||||
|
||||
def test_요소_표_밖_파일은_막음(user: TestClient) -> None:
|
||||
logic_file = store.logics("", "", "", None, "")[0]["file"]
|
||||
assert user.get(f"{BASE}/rows", params={"file": logic_file}).status_code == 403
|
||||
assert user.get(f"{BASE}/table", params={"file": "기계.json", "key": "x"}).status_code == 403
|
||||
assert user.get(f"{BASE}/rows", params={"file": "재료_../x.json"}).status_code == 404
|
||||
|
||||
|
||||
def test_저장_안_한_줄_셈_쓰기_없음(user: TestClient) -> None:
|
||||
before = _prints()
|
||||
saved = user.post(f"{BASE}/calc", json={"key": KEY, "inputs": {}}).json()
|
||||
row = store.logic(KEY)["logic"]
|
||||
same = user.post(f"{BASE}/calc", json={"key": KEY, "inputs": {}, "row": row}).json()
|
||||
line = row["호표"][0]
|
||||
row2 = {**row, "호표": [{**line, "수량": f"({line['수량']}) * 2"}, *row["호표"][1:]]}
|
||||
double = user.post(f"{BASE}/calc", json={"key": KEY, "inputs": {}, "row": row2}).json()
|
||||
text = user.post(f"{BASE}/text", json={"key": KEY, "inputs": {}, "row": row2}).json()
|
||||
assert saved["ok"] and same["sums"] == saved["sums"]
|
||||
assert double["sums"]["노무비"] == pytest.approx(saved["sums"]["노무비"] * 2)
|
||||
assert text["ok"] and text["계"] == pytest.approx(double["sums"]["계"], rel=1e-6)
|
||||
assert store.logic(KEY)["logic"]["호표"][0]["수량"] == line["수량"] # 정본 그대로
|
||||
assert _prints() == before
|
||||
@@ -0,0 +1,54 @@
|
||||
"""M02 단가산출 칸 목록 줄(PLAN 717-2a · `M02_Labor_Ref.summary` sangeun 갈래) — 임시 storage.
|
||||
|
||||
바탕 = 로직 「키 이름」 · 참조 = SK 키 · 없음 = None · 고친 줄 수 = 덮개 + 더한줄 + 뺀줄 ·
|
||||
쓰는 곳 수 = 그 SK 를 바탕 · 더한줄로 담은 문서 수 · 일위대가(precombo) 줄엔 안 붙음.
|
||||
"""
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete.M02_Labor_Expand import Expand
|
||||
from resources.tester.test_m02_labor_ref import MINE, world # noqa: F401 — 같은 임시 층
|
||||
|
||||
PCT = "GC001109"
|
||||
|
||||
|
||||
def _sk(folder, **doc):
|
||||
with store.at(folder):
|
||||
return refs.create("sangeun", {"이름": "목록 시험", "단위": "㎡", "더한줄": [], **doc})[
|
||||
"이름"
|
||||
]
|
||||
|
||||
|
||||
def _row(folder, kind, key):
|
||||
return refs.summary(folder, kind, key, Expand(m01.loaded()[1]))
|
||||
|
||||
|
||||
def test_sangeun_row_base_edits_users(world):
|
||||
mine = world["personal"]
|
||||
a = _sk(mine, 바탕={"로직": PCT}, 덮개={"labor:LB000036#0": {"qty": 0.5}}, 뺀줄=["pct:x#0"])
|
||||
name = m01.loaded()[1].logic(PCT)["이름"]
|
||||
row = _row(mine, "sangeun", a)
|
||||
assert row["바탕"] == f"{PCT} {name}".strip()
|
||||
assert row["고친줄수"] == 2 and row["쓰는곳수"] == 0
|
||||
assert row["제목"] == "목록 시험" and row["단위"] == "㎡"
|
||||
# 바탕 참조 SK · 더한줄 하나 → 그 SK 쓰는 곳 1
|
||||
b = _sk(
|
||||
mine,
|
||||
바탕={"참조": {**MINE, "키": a}},
|
||||
더한줄=[{"종류": "인력", "요소": "LB000036", "이름": "보통인부", "단위": "인", "수량": 1}],
|
||||
)
|
||||
row_b = _row(mine, "sangeun", b)
|
||||
assert row_b["바탕"] == a and row_b["고친줄수"] == 1
|
||||
assert _row(mine, "sangeun", a)["쓰는곳수"] == 1
|
||||
# 바탕 없음
|
||||
c = _sk(mine)
|
||||
row_c = _row(mine, "sangeun", c)
|
||||
assert row_c["바탕"] is None and row_c["고친줄수"] == 0
|
||||
|
||||
|
||||
def test_precombo_row_has_no_sangeun_fields(world):
|
||||
with store.at(world["personal"]):
|
||||
sc = refs.create("precombo", {"이름": "조합", "담은로직": []})["이름"]
|
||||
row = _row(world["personal"], "precombo", sc)
|
||||
assert not {"바탕", "고친줄수", "쓰는곳수"} & set(row)
|
||||
@@ -0,0 +1,76 @@
|
||||
"""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)
|
||||
@@ -97,22 +97,23 @@ WRITE_CO = READ_CO.replace("CompanyRead", "CompanyWrite")
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
def test_시스템_관리자_줄_칸별_여덟_고른_회사_해시():
|
||||
# 716-2 · 718-3 — 대시보드 설계 기본값 = 칸별 줄: M01 4(마스터 요소 · 단가산출 로직 · 구조물 옵션 정의 ·
|
||||
# 별칭 장부) +
|
||||
# M02 3(일위대가 · 구조물 조합 · 도면 양식) + 계산 자리
|
||||
def test_시스템_관리자_줄_칸별_아홉_고른_회사_해시():
|
||||
# 716-2 · 718-3 · 717-2a — 대시보드 설계 기본값 = 칸별 줄: M01 4(마스터 요소 · 단가산출 로직 ·
|
||||
# 구조물 옵션 정의 · 별칭 장부) +
|
||||
# M02 4(단가산출 · 일위대가 · 구조물 조합 · 도면 양식) + 계산 자리
|
||||
assert _cards({"role": "SYSTEM_ADMIN", "is_master": False}, 3) == [
|
||||
["M01_Title", READ_SRC, M01],
|
||||
["B01_Dashboard_CardLogic", READ_SRC, f"{M01}?open=로직"],
|
||||
["B01_Dashboard_CardStructOptionDef", WRITE_M, f"{M01}?open=옵션"],
|
||||
["B01_Dashboard_CardAliasLedger", WRITE_M, f"{M01}?open=별칭"],
|
||||
["B01_Dashboard_CardSangeun", WRITE_M, f"{M02}?open=단가산출&root=master&company=3"],
|
||||
["B01_Dashboard_CardPrecombo", WRITE_M, f"{M02}?open=일위대가조합&root=master&company=3"],
|
||||
["B01_Dashboard_CardStructCombo", WRITE_M, f"{M02}?open=구조물조합&root=master&company=3"],
|
||||
["B01_Dashboard_CardDrawing", WRITE_M, f"{M02}?open=도면&root=master&company=3"],
|
||||
CALC_SITES,
|
||||
]
|
||||
# 회사가 없으면 해시에 안 실음
|
||||
assert _cards({"role": "SYSTEM_ADMIN", "is_master": False})[5][2] == (
|
||||
assert _cards({"role": "SYSTEM_ADMIN", "is_master": False})[6][2] == (
|
||||
f"{M02}?open=구조물조합&root=master"
|
||||
)
|
||||
|
||||
@@ -243,7 +244,8 @@ def test_구조물_조합_기본_층_개인():
|
||||
134-4 — 층 없는 주소도 개인(회사 없으면 마스터) · 사전조합 기본 층도 같은 규칙."""
|
||||
combo = f"{M02}?open=구조물조합&root=personal"
|
||||
rows = _cards({"role": "SYSTEM_ADMIN", "is_master": False, "company_id": 1}, 3)
|
||||
assert rows[4:7] == [
|
||||
assert rows[4:8] == [
|
||||
["B01_Dashboard_CardSangeun", WRITE_M, f"{M02}?open=단가산출&root=personal&company=3"],
|
||||
["B01_Dashboard_CardPrecombo", WRITE_M, f"{M02}?open=일위대가조합&root=personal&company=3"],
|
||||
["B01_Dashboard_CardStructCombo", WRITE_M, f"{combo}&company=3"],
|
||||
["B01_Dashboard_CardDrawing", WRITE_M, f"{M02}?open=도면&root=master&company=3"],
|
||||
@@ -254,7 +256,7 @@ def test_구조물_조합_기본_층_개인():
|
||||
assert _cards({"role": "USER", "is_master": True, "company_id": 1})[0][2] == combo
|
||||
assert _cards({"role": "USER", "is_master": False, "company_id": 1})[0][2] == combo
|
||||
# 개인 층 없음(회사 없음) = 지금 규칙
|
||||
assert _cards({"role": "SYSTEM_ADMIN", "is_master": False, "company_id": None})[5][2] == (
|
||||
assert _cards({"role": "SYSTEM_ADMIN", "is_master": False, "company_id": None})[6][2] == (
|
||||
f"{M02}?open=구조물조합&root=master"
|
||||
)
|
||||
assert _cards({"role": "ADMIN", "is_master": False, "company_id": None})[0][2] == (
|
||||
|
||||
@@ -308,7 +308,7 @@ def test_옛_즉시_확정과_바로_만들기_없음():
|
||||
users = sorted(name for name, text in texts.items() if "buildHopyo(" in text)
|
||||
assert users == ["Hopyo_Check", "Hopyo_Sheet"] # 확인 창 · 엑셀 보기 [저장] 물음 뒤
|
||||
check = texts["Hopyo_Check"]
|
||||
assert '"사전조합 생성"' in check and "맞음 · 별칭 일괄 체크" in check
|
||||
assert '`${L("M02_PrecomboTitle")} 생성`' in check and "맞음 · 별칭 일괄 체크" in check
|
||||
assert "확인하고" not in check # 359-4 명사형
|
||||
assert "showLoadingOverlay()" in check and "dropDraft(k)" in check
|
||||
assert "m02-hopyo-check:" in check and "POLL_MS" in check
|
||||
@@ -443,7 +443,8 @@ def test_확인_창_손질_339():
|
||||
assert make.index("document.dispatchEvent(new Event(HOPYO_BUILT));") < make.index("opt.onBuilt")
|
||||
side = _text("M02_MasterTemplete_UI_Side.ts")
|
||||
assert "document.addEventListener(HOPYO_BUILT, onBuilt);" in side
|
||||
assert "void precombo.refresh()" in side.split("const onBuilt")[1].split(";")[0]
|
||||
onb = side.split("const onBuilt")[1].split(";")[0]
|
||||
assert "precombo.refresh()" in onb and "sangeun.refresh()" in onb # 717-2a — 둘 다 새로고침
|
||||
sheet = _text("M02_MasterTemplete_Hopyo_Sheet.ts")
|
||||
assert "poll(file);" in sheet and "poll(next);" in sheet and "POLL_MS" in sheet
|
||||
assert "if (ok) document.dispatchEvent(new Event(HOPYO_BUILT));" in sheet
|
||||
|
||||
@@ -70,7 +70,7 @@ def _promote(folder, code=TEN, values=None, **extra):
|
||||
|
||||
def _combo(folder, lines, name="조합"):
|
||||
with store.at(folder):
|
||||
return refs.create("precombo", {"이름": name, "담은로직": lines})["이름"]
|
||||
return refs.create("precombo", {"이름": name, "단위": "㎡", "담은로직": lines})["이름"]
|
||||
|
||||
|
||||
def _read(folder, kind, key):
|
||||
@@ -225,11 +225,15 @@ def test_calc_는_참조를_펼침_승격은_기준_나누기_배_사전조합
|
||||
one = got["줄"][0]
|
||||
want = Decimal(str(_base(TEN)["계"])) / 10 * 2
|
||||
assert one["기준"]["계"] == pytest.approx(float(want))
|
||||
assert (one["분모"], one["배"], one["알약"]) == (None, 1, refs.ref_id(ref))
|
||||
assert (one["분모"], one["배"], one["알약"]) == (
|
||||
"㎡",
|
||||
1,
|
||||
refs.ref_id(ref),
|
||||
) # 단위 = 그 사전조합 것
|
||||
assert one["반영"]["계"] == pytest.approx(float(want * 5))
|
||||
assert one["안"][0]["수량"] == 2 and one["안"][0]["참조"]["키"] == sp
|
||||
pills = labor.pills(doc)
|
||||
assert pills == [{"키": refs.ref_id(ref), "이름": "둘", "단위": "", "로직": [], "참조": ref}]
|
||||
assert pills == [{"키": refs.ref_id(ref), "이름": "둘", "단위": "㎡", "로직": [], "참조": ref}]
|
||||
|
||||
|
||||
def test_승격_줄_상세는_원_로직_키_표_사전조합은_안_줄만(world):
|
||||
@@ -364,7 +368,7 @@ def test_길_새로_복사_권한(world):
|
||||
}
|
||||
client = TestClient(app)
|
||||
url = "/api/m02/layers/{}/library/{}"
|
||||
body = {"문서": {"이름": "가", "담은로직": [{"로직": TEN}]}}
|
||||
body = {"문서": {"이름": "가", "단위": "㎡", "담은로직": [{"로직": TEN}]}}
|
||||
got = client.post(url.format("personal", "precombo"), json=body).json()
|
||||
assert got["이름"] == "SC-000001"
|
||||
assert client.post(url.format("system", "precombo"), json=body).status_code == 403
|
||||
|
||||
@@ -89,7 +89,8 @@ def test_주소_인자를_라우트에서_뗌():
|
||||
assert (
|
||||
'raw === "master" || raw === "company" || raw === "personal"' in layer
|
||||
) # 없음 = 기본(134-4)
|
||||
assert '["구조물조합", "옵션", "도면", "일위대가조합"]' in layer # 일위대가 조합 서고 = 50-3
|
||||
# 일위대가 조합 서고 = 50-3 · 단가산출 = 717-2a
|
||||
assert '["구조물조합", "옵션", "도면", "일위대가조합", "단가산출"]' in layer
|
||||
picker = _text(ROOT / "ui_template" / "ui_template_item_picker.ts")
|
||||
assert 'open: "구조물조합", root: "personal"' in picker and "layer=personal" not in picker
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ def test_분류_고정_폴더_지움_자유_폴더_메뉴_끌어_옮기기():
|
||||
def test_목록표_빈_구분_미분류_고르기_창_새_분류_거름():
|
||||
"""149-2 — 목록표 빈 구분 = 미분류 · 고르기 창 거름 = 품셈 구분 + 사전조합 새 분류"""
|
||||
lib = _text("M02_MasterTemplete_Library_List.ts")
|
||||
assert 'row.구분 || tc("Lib_Unsorted"),' in lib
|
||||
assert '(r) => r.구분 || tc("Lib_Unsorted")],' in lib # 717-2a 열 표(COLUMNS)
|
||||
pick = _text("M02_MasterTemplete_Labor_Pick.ts")
|
||||
assert "new Map([...subs, ...classes].map((s) => [s.name, new Set(s.details)]))" in pick
|
||||
assert "classes = precomboClasses(def);" in pick
|
||||
@@ -143,6 +143,7 @@ def test_왼쪽_칸_차례_일위대가_구조물조합_구분선_불러오기_7
|
||||
side = _text("M02_MasterTemplete_UI_Side.ts")
|
||||
order = side[side.index("m02-side__lists") :]
|
||||
marks = [
|
||||
"sangeunSec,", # 717-2a 맨 위
|
||||
"precomboSec,",
|
||||
"summary,",
|
||||
"createDivider()",
|
||||
|
||||
@@ -191,4 +191,24 @@ export const ui_locales_m2 = {
|
||||
M02_HopyoImport: ["일위대가 불러오기", "Import unit-price sheets"],
|
||||
B01_Dashboard_CardPrecombo: ["일위대가", "Unit-cost items"],
|
||||
M02_PrecomboTitle: ["일위대가", "Unit-cost items"],
|
||||
B01_Dashboard_CardSangeun: ["단가산출", "Unit-price analyses"],
|
||||
M02_SangeunTitle: ["단가산출", "Unit-price analyses"],
|
||||
M02_SangeunNone: ["단가산출 없음", "No unit-price analyses"],
|
||||
M02_SangeunNewHere: ["여기에 새 단가산출", "New unit-price analysis here"],
|
||||
M02_SangeunBase: ["바탕", "Base"],
|
||||
M02_SangeunNoBase: ["바탕 없음", "No base"],
|
||||
M02_SangeunEdited: ["고친 줄", "Edited rows"],
|
||||
M02_SangeunUsers: ["쓰는 곳", "Used by"],
|
||||
M02_SangeunCover: ["덮개", "Overrides"],
|
||||
M02_SangeunAdded: ["더한 줄", "Added rows"],
|
||||
M02_SangeunRemoved: ["뺀 줄", "Removed rows"],
|
||||
M02_SangeunOpened: ["열린 입력", "Open inputs"],
|
||||
M02_SangeunDetailSoon: [
|
||||
"단가산출 상세 표 · 고침 — 준비 중(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"],
|
||||
} as const;
|
||||
|
||||
Reference in New Issue
Block a user