Merge remote-tracking branches 'origin/main_laptop_1', 'origin/sub_laptop_2' and 'origin/sub_laptop_4' into sub_desktop_1
This commit is contained in:
@@ -16,6 +16,7 @@ from M01_MasterData import M01_MasterData_Store as store
|
||||
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
|
||||
from M01_MasterData import M01_MasterData_Store_Flow as flow
|
||||
from M01_MasterData import M01_MasterData_Store_Make as make
|
||||
from M01_MasterData import M01_MasterData_Store_Revise as revise
|
||||
|
||||
import master_basis as mbs # noqa: E402 (`store` 가 scripts 를 길에 둠)
|
||||
|
||||
@@ -49,6 +50,7 @@ class FileChanges(BaseModel):
|
||||
|
||||
class SaveBody(BaseModel):
|
||||
files: list[FileChanges]
|
||||
개정확인: bool = False # 로직 정본 줄 고침 = 「원문 개정 반영」 확인(713-1) · 없으면 409
|
||||
|
||||
|
||||
class NewLogic(BaseModel):
|
||||
@@ -196,7 +198,9 @@ def get_logics(
|
||||
|
||||
@router.get("/logic")
|
||||
def get_logic(key: str) -> dict:
|
||||
return _call(store.logic, key)
|
||||
"""`지문` = 줄 바탕 지문(계산 칸 · `Store_Revise.row_print`) — M02 덮개 「M01 개정」 비교 값."""
|
||||
out = _call(store.logic, key)
|
||||
return {**out, "지문": revise.row_print(out["logic"])}
|
||||
|
||||
|
||||
@router.get("/logic/source")
|
||||
@@ -275,7 +279,7 @@ def post_text(body: CalcBody) -> dict:
|
||||
@router.post("/save")
|
||||
def post_save(body: SaveBody) -> dict:
|
||||
"""「흐름」 줄은 검사 · 서버 다시 셈 · 새 줄(키 없음)은 자체 GX — {files, added, warnings}."""
|
||||
return _call(flow.save, [f.model_dump() for f in body.files])
|
||||
return _call(flow.save, [f.model_dump() for f in body.files], body.개정확인)
|
||||
|
||||
|
||||
@router.get("/logic/flow")
|
||||
|
||||
@@ -21,6 +21,7 @@ from typing import Any
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as store
|
||||
from M01_MasterData import M01_MasterData_Store_Make as make
|
||||
from M01_MasterData import M01_MasterData_Store_Revise as revise
|
||||
from M02_MasterTemplete.DataFlow import M02_DF_Compute as dc
|
||||
from M02_MasterTemplete.DataFlow import M02_DF_Values as dv
|
||||
|
||||
@@ -375,8 +376,10 @@ def _bare(row: dict) -> dict:
|
||||
return {"끝수": None, **kept, "입력": inputs, "출처": row.get("출처") or "자체"}
|
||||
|
||||
|
||||
def save(batch: list[dict]) -> dict:
|
||||
"""`/save` — 흐름 줄은 검사 · 다시 셈 · 새 줄은 자체 GX · 그 밖은 `Store.save` 그대로."""
|
||||
def save(batch: list[dict], confirmed: bool = False) -> dict:
|
||||
"""`/save` — 흐름 줄은 검사 · 다시 셈 · 새 줄은 자체 GX · 그 밖은 `Store.save` 그대로 ·
|
||||
로직 정본 줄 고침은 `confirmed`(「원문 개정 반영」) 없으면 409(`Store_Revise` · 713-1)."""
|
||||
revise.guard(batch, confirmed)
|
||||
warns, fresh = [], False
|
||||
for part in batch:
|
||||
for ch in part.get("changes") or []:
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""M01 로직 정본 개정 확인 (PLAN 713-1 · 요구서 내역12 4장 10 · 17).
|
||||
|
||||
- 로직 정본 파일(`로직_*.json` · 자체 `로직_자체.json` 뺌) 줄을 실제로 바꾸는 `/save`(흐름 저장 ·
|
||||
지우기 포함)는 「원문 개정 반영」 확인 값(`개정확인`)이 있어야 함 — 없으면 409 + 까닭.
|
||||
- 요소 파일(인력 · 재료 · 표 …) · 자체 로직 · 같은 줄 그대로 다시 보냄은 대상 밖.
|
||||
- 줄 바탕 지문(`row_print`) = 계산 칸만 정렬 JSON(수는 Decimal 정규 글) sha256 앞 16 자 —
|
||||
M02 덮개가 담아 두고 「M01 개정 — 확인」 배지를 띄울 값.
|
||||
- 판 칸 · 옛 줄 사본은 안 둠 — 정본 이력은 git.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from decimal import Decimal
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as store
|
||||
|
||||
OWN = "로직_자체.json"
|
||||
PRINT_FIELDS = ("결과단위", "입력", "중간", "호표", "덧줄", "결과", "끝수", "흐름")
|
||||
WHY = "로직 정본(품셈 원문) 줄을 바꿈 — 「원문 개정 반영」 확인 뒤 저장(개정확인)"
|
||||
|
||||
|
||||
def is_canon(file: str) -> bool:
|
||||
return file.startswith("로직_") and file.endswith(".json") and file != OWN
|
||||
|
||||
|
||||
def _norm(v):
|
||||
"""수는 int · float · Decimal 어느 꼴로 와도 같은 글(「10」 · 「10.0」 → 「10」)."""
|
||||
if isinstance(v, bool) or v is None or isinstance(v, str):
|
||||
return v
|
||||
if isinstance(v, (int, float, Decimal)):
|
||||
return format(Decimal(str(v)).normalize(), "f")
|
||||
if isinstance(v, list):
|
||||
return [_norm(x) for x in v]
|
||||
if isinstance(v, dict):
|
||||
return {k: _norm(x) for k, x in v.items()}
|
||||
return str(v)
|
||||
|
||||
|
||||
def row_print(row: dict) -> str:
|
||||
"""줄 바탕 지문 — 계산 칸(`PRINT_FIELDS`)만 · 이름 · 별칭 · 비고 고침은 지문을 안 바꿈."""
|
||||
calc = {k: _norm(row[k]) for k in PRINT_FIELDS if row.get(k) is not None}
|
||||
raw = json.dumps(calc, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def changed(batch: list[dict]) -> list[dict]:
|
||||
"""정본 줄을 바꾸는 고침 — `[{file, key, op, 이름}]` · 같은 줄을 그대로 다시 보낸 edit 은 뺌."""
|
||||
out = []
|
||||
for part in batch:
|
||||
file = str(part.get("file") or "")
|
||||
if not is_canon(file):
|
||||
continue
|
||||
try:
|
||||
rows = {str(r.get("키")): r for r in store.read(file)[0].get("줄") or []}
|
||||
except store.StoreError:
|
||||
continue # 없는 파일 — `store.save` 가 404
|
||||
for ch in part.get("changes") or []:
|
||||
op, key, row = ch.get("op"), str(ch.get("key") or ""), ch.get("row")
|
||||
old = rows.get(key)
|
||||
if op == "edit" and old is not None and store._dec(row) == old:
|
||||
continue
|
||||
name = (row or {}).get("이름") if isinstance(row, dict) else None
|
||||
out.append(
|
||||
{
|
||||
"file": file,
|
||||
"key": key or None,
|
||||
"op": op,
|
||||
"이름": name or (old or {}).get("이름"),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def guard(batch: list[dict], confirmed: bool) -> None:
|
||||
if confirmed:
|
||||
return
|
||||
hits = changed(batch)
|
||||
if hits:
|
||||
raise store.StoreError(409, {"개정": hits, "까닭": WHY})
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
} from "./M01_MasterData_UI_LogicLab_Flow";
|
||||
import { askBasicInfo } from "./M01_MasterData_UI_LogicLab_Basic";
|
||||
import { buildLogicSide } from "./M01_MasterData_UI_LogicLab_Side";
|
||||
import { createViewPicker } from "./M01_MasterData_UI_LogicLab_View";
|
||||
import { buildHeadBar } from "./M01_MasterData_UI_Head";
|
||||
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
|
||||
import type { CfLogic } from "../M02_MasterTemplete/DataFlow/M02_DF_Model";
|
||||
@@ -130,6 +131,7 @@ export async function mountM01LogicLab(
|
||||
const editor = el("div", { className: "m01-logic__editor" });
|
||||
// 로직 상세 — 왼쪽 지금 내용 · 오른쪽 접는 패널 둘(흐름도 · 원문 · 하나만 열림 · 폭 공유)
|
||||
const panes = buildLogicSide(editor);
|
||||
const views = createViewPicker(editor); // 보기 고르기 — 머리줄 [신규 로직] 왼쪽
|
||||
const errors = el("div", {
|
||||
className: "ui-banner ui-banner--danger m01-logic__reasons",
|
||||
attrs: { hidden: "" },
|
||||
@@ -236,7 +238,7 @@ export async function mountM01LogicLab(
|
||||
list.root.hidden = !listing || !loaded;
|
||||
waiting.hidden = loaded;
|
||||
panes.root.hidden = !opened;
|
||||
back.hidden = listing;
|
||||
back.hidden = views.root.hidden = listing;
|
||||
if (!opened) return;
|
||||
if (!opened.row) {
|
||||
editor.replaceChildren(createEmpty({ text: `● ${tx("List_Deleted")}`, small: true }));
|
||||
@@ -582,6 +584,7 @@ export async function mountM01LogicLab(
|
||||
title: tx("Title"),
|
||||
status: [pending],
|
||||
buttons: [
|
||||
views.root,
|
||||
createButton({ label: tf("New"), variant: "filled", onClick: () => void onNew() }),
|
||||
copyButton,
|
||||
createButton({ label: tx("Bar_Delete"), variant: "danger", onClick: () => void onDelete() }),
|
||||
|
||||
@@ -37,6 +37,8 @@ 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` 줄의 `조각`) → 수량 칸 조각 — 요소·표·로직은 알약(글 = 조각 글) · 나머지는 글자 */
|
||||
function toFragments(pieces: TextPiece[], ctx: DetailContext): Fragment[] {
|
||||
@@ -142,7 +144,7 @@ const section = (title: string, body: HTMLElement[], hint = ""): HTMLElement =>
|
||||
],
|
||||
});
|
||||
|
||||
function infoBox(ctx: DetailContext): HTMLElement {
|
||||
function infoBox(ctx: DetailContext): HTMLElement[] {
|
||||
const row = ctx.row;
|
||||
const own = isOwnLogic(row);
|
||||
const field = (label: string, key: "이름" | "원문번호" | "결과단위" | "출처"): HTMLElement => {
|
||||
@@ -189,32 +191,46 @@ function infoBox(ctx: DetailContext): HTMLElement {
|
||||
}),
|
||||
]
|
||||
: [];
|
||||
return section(tl("Info_Title"), [
|
||||
el("p", {
|
||||
className: "ui-text ui-text--muted",
|
||||
text: tl(own ? "Info_Own" : ctx.admin ? "Info_Base" : "Info_ReadOnly"),
|
||||
}),
|
||||
...reasons,
|
||||
el("div", {
|
||||
className: "m01lab__info",
|
||||
children: [
|
||||
cell(tx("Head_File"), ctx.file),
|
||||
cell(tx("Head_Key"), row.키),
|
||||
field(tx("Head_Number"), "원문번호"),
|
||||
field(tx("Head_Name"), "이름"),
|
||||
field(tx("Head_Unit"), "결과단위"),
|
||||
field(tx("Head_Source"), "출처"),
|
||||
cell(tx("Head_Owner"), row.소유 ?? ""),
|
||||
el("label", {
|
||||
className: "m01lab__cell m01lab__cell--wide",
|
||||
children: [
|
||||
el("span", { className: "ui-text ui-text--muted", text: tx("Head_Note") }),
|
||||
note,
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
const grid = el("div", {
|
||||
className: "m01lab__info",
|
||||
children: [
|
||||
cell(tx("Head_File"), ctx.file),
|
||||
cell(tx("Head_Key"), row.키),
|
||||
field(tx("Head_Number"), "원문번호"),
|
||||
field(tx("Head_Name"), "이름"),
|
||||
field(tx("Head_Unit"), "결과단위"),
|
||||
field(tx("Head_Source"), "출처"),
|
||||
cell(tx("Head_Owner"), row.소유 ?? ""),
|
||||
el("label", {
|
||||
className: "m01lab__cell m01lab__cell--wide",
|
||||
children: [
|
||||
el("span", { className: "ui-text ui-text--muted", text: tx("Head_Note") }),
|
||||
note,
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
// 늘 맨 위 접힌 한 줄 — 펼침은 이 로직 안에서 기억(다시 그려도) · 막힘 까닭은 접어도 보임
|
||||
const head = [row.키, row.원문번호, row.이름, row.결과단위].filter(Boolean).join(" · ");
|
||||
const fold = el("details", {
|
||||
className: "m01lab__box m01lab__infofold",
|
||||
attrs: ctx.ownHost.dataset.info ? { open: "" } : {},
|
||||
children: [
|
||||
el("summary", {
|
||||
children: [
|
||||
el("strong", { text: tl("Info_Title") }),
|
||||
el("span", { className: "ui-text ui-text--muted", text: head }),
|
||||
],
|
||||
}),
|
||||
el("p", {
|
||||
className: "ui-text ui-text--muted",
|
||||
text: tl(own ? "Info_Own" : ctx.admin ? "Info_Base" : "Info_ReadOnly"),
|
||||
}),
|
||||
grid,
|
||||
],
|
||||
});
|
||||
fold.addEventListener("toggle", () => (ctx.ownHost.dataset.info = fold.open ? "1" : ""));
|
||||
return [fold, ...reasons];
|
||||
}
|
||||
|
||||
/** 수량 칸 = 계산 답의 고른 수량(% 덧줄은 율) · 계산 전 「—」 */
|
||||
@@ -453,10 +469,13 @@ function ownEditor(ctx: DetailContext): HTMLElement {
|
||||
function hoBox(ctx: DetailContext): HTMLElement {
|
||||
const money = !("결과" in ctx.row) && (ctx.row.결과단위 ?? "").startsWith("원");
|
||||
if (!money) {
|
||||
return section(tl("Ho_Title"), [
|
||||
el("p", { className: "ui-text ui-text--muted", text: tl("Not_Money") }),
|
||||
el("pre", { className: "m01lab__expr", text: ctx.row.결과?.식 ?? "" }),
|
||||
]);
|
||||
return block(
|
||||
section(tl("Ho_Title"), [
|
||||
el("p", { className: "ui-text ui-text--muted", text: tl("Not_Money") }),
|
||||
el("pre", { className: "m01lab__expr", text: ctx.row.결과?.식 ?? "" }),
|
||||
]),
|
||||
"ho",
|
||||
);
|
||||
}
|
||||
const all = entries(ctx);
|
||||
const calculated = ctx.lines !== null;
|
||||
@@ -479,7 +498,7 @@ function hoBox(ctx: DetailContext): HTMLElement {
|
||||
fold.addEventListener("toggle", () => (ctx.ownHost.dataset.fold = fold.open ? "1" : ""));
|
||||
const blocks = isOwnLogic(ctx.row) ? [ownEditor(ctx), fold, totalLine] : [...tables, totalLine];
|
||||
const hint = calculated ? tl("Click_Hint") : `${tl("Click_Hint")} · ${tl("Calc_First")}`;
|
||||
return section(tl("Ho_Title"), blocks, hint);
|
||||
return block(section(tl("Ho_Title"), [nowInputs(ctx.values), ...blocks], hint), "ho");
|
||||
}
|
||||
|
||||
/** 상세 화면 넷을 `host` 에 쌓음 */
|
||||
@@ -492,10 +511,14 @@ export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void {
|
||||
active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement
|
||||
? { node: active, from: active.selectionStart, to: active.selectionEnd }
|
||||
: null;
|
||||
// 보기 고르기(`_View.ts`) — 덩이마다 이름표 · 고른 덩이만 보임 · 시험 계산은 숨어도 그려 둠
|
||||
host.replaceChildren(
|
||||
infoBox(ctx),
|
||||
section(tl("Calc_Title"), [ctx.calcHost]),
|
||||
...(ctx.flowHost ? [ctx.flowHost] : [hoBox(ctx), section(tl("Formula_Title"), [formula])]),
|
||||
...infoBox(ctx),
|
||||
block(section(tl("Calc_Title"), [ctx.calcHost]), "calc"),
|
||||
block(section(tl("View_Est"), [createEmpty({ text: tl("View_Soon"), small: true })]), "est"),
|
||||
...(ctx.flowHost
|
||||
? [block(nowInputs(ctx.values), "ho"), ctx.flowHost]
|
||||
: [hoBox(ctx), block(section(tl("Formula_Title"), [formula]), "formula")]),
|
||||
);
|
||||
const own = kept?.node.dataset.own; // 기본정보 칸은 새로 만들어지므로 이름표로 다시 찾음
|
||||
const back = own ? host.querySelector<HTMLInputElement>(`[data-own="${own}"]`) : kept?.node;
|
||||
|
||||
@@ -159,9 +159,11 @@ export function buildFlowView(
|
||||
try {
|
||||
ev = opts.result ?? evalLogic(lg, values, { logics: opts.logics });
|
||||
} catch (e) {
|
||||
host.replaceChildren(
|
||||
section(tf("Detail"), [el("p", { className: "ui-text ui-text--muted", text: String(e) })]),
|
||||
);
|
||||
const failed = section(tf("Detail"), [
|
||||
el("p", { className: "ui-text ui-text--muted", text: String(e) }),
|
||||
]);
|
||||
failed.dataset.block = "ho";
|
||||
host.replaceChildren(failed);
|
||||
return;
|
||||
}
|
||||
const totalRound = totalRoundOf(lg, totalRoundNow());
|
||||
@@ -196,5 +198,8 @@ export function buildFlowView(
|
||||
tf("Formula"),
|
||||
bodies.length ? bodies : [el("p", { className: "ui-text ui-text--muted", text: tf("NoFunc") })],
|
||||
);
|
||||
// 보기 고르기 이름표(`LogicLab_View.ts`) — 고른 덩이만 보임
|
||||
detail.dataset.block = "ho";
|
||||
text.dataset.block = "formula";
|
||||
host.replaceChildren(detail, text);
|
||||
}
|
||||
|
||||
@@ -190,3 +190,28 @@
|
||||
.m01lab__pickrow.is-picked {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 보기 고르기(710-2 · `_View.ts`) — 그릇 data-view 와 다른 이름표 덩이는 숨김(시험 계산은 숨어도 돎) */
|
||||
.m01-logic__editor[data-view="ho"] [data-block]:not([data-block="ho"]),
|
||||
.m01-logic__editor[data-view="est"] [data-block]:not([data-block="est"]),
|
||||
.m01-logic__editor[data-view="calc"] [data-block]:not([data-block="calc"]),
|
||||
.m01-logic__editor[data-view="formula"] [data-block]:not([data-block="formula"]) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 기본정보 — 늘 맨 위 접힌 한 줄 */
|
||||
.m01lab__infofold > summary {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing-8);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m01lab__infofold[open] > summary {
|
||||
margin-bottom: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 상세 머리 「지금 입력」 한 줄 */
|
||||
.m01lab__now {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,14 @@ const TEXT = {
|
||||
Formula_Title: ["텍스트 수식", "Text formula"],
|
||||
Formula_Slot: ["시험 계산을 누르면 식이 글로 나옴", "Run the trial calc to see the formula"],
|
||||
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"],
|
||||
G_material: ["재료", "Material"],
|
||||
G_cost: ["경비", "Expense"],
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab_View.ts
|
||||
* 「단가산출 로직」 상세 보기 고르기 — 머리줄 [신규 로직] 왼쪽 「분할 고르기 | 세로 나눔선」
|
||||
* 보기 넷 = 단가산출 상세(기본) · 인력·재료·경비 · 시험 계산 · 텍스트 수식 — 고른 덩이만 보임
|
||||
* 덩이 = `data-block` 이름표 · 숨김 = 그릇 `data-view` + CSS(`LogicLab_Style.css`) — 다시 그려도 고름 유지
|
||||
* 시험 계산은 숨겨도 그려 둠(자동 계산이 숨은 칸에서도 돎) · 기본정보는 이름표 없음(늘 맨 위)
|
||||
* 기억 = 이 화면 안에서만(로직을 바꿔도 유지 · 새로 들어오면 단가산출 상세)
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { createSegment } from "@ui/ui_template_segment";
|
||||
import { createToolbar } from "@ui/ui_template_toolbar";
|
||||
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
|
||||
|
||||
export type LabView = "ho" | "est" | "calc" | "formula";
|
||||
|
||||
/** 덩이에 보기 이름표를 붙여 돌려줌 */
|
||||
export const block = <T extends HTMLElement>(node: T, view: LabView): T => {
|
||||
node.dataset.block = view;
|
||||
return node;
|
||||
};
|
||||
|
||||
/** 보기 고르기 — `host`(상세 그릇)에 `data-view` 를 둠 · `root` 는 머리줄에 놓음 */
|
||||
export function createViewPicker(host: HTMLElement): { root: HTMLElement } {
|
||||
host.dataset.view = "ho";
|
||||
const segment = createSegment({
|
||||
options: [
|
||||
{ value: "ho", label: tl("Ho_Title") },
|
||||
{ value: "est", label: tl("View_Est") },
|
||||
{ value: "calc", label: tl("Calc_Title") },
|
||||
{ value: "formula", label: tl("Formula_Title") },
|
||||
],
|
||||
value: "ho",
|
||||
size: 32,
|
||||
ariaLabel: tl("View_Label"),
|
||||
onChange: (v) => (host.dataset.view = v),
|
||||
});
|
||||
return { root: createToolbar({ items: [segment.root, "divider"], label: tl("View_Label") }) };
|
||||
}
|
||||
|
||||
/** 상세 머리 「지금 입력」 한 줄(읽기만) — 숨은 시험 계산 칸에 든 값 */
|
||||
export function nowInputs(values: Record<string, string>): HTMLElement {
|
||||
const filled = Object.entries(values).filter(([, v]) => String(v ?? "").trim() !== "");
|
||||
const text = filled.length ? filled.map(([k, v]) => `${k} ${v}`).join(" · ") : tl("Now_None");
|
||||
return el("p", {
|
||||
className: "ui-text ui-text--muted m01lab__now",
|
||||
attrs: { "data-now": "" },
|
||||
text: `${tl("Now_Inputs")} · ${text}`,
|
||||
});
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
|
||||
import { formatMoney } from "./M01_MasterData_UI_Logic_Money";
|
||||
import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text";
|
||||
import { createBadge } from "@ui/ui_template_badge";
|
||||
import { createDivider } from "@ui/ui_template_divider";
|
||||
|
||||
/** 고르기 값이 마스터 키(자재·기계·직종)면 이름으로 바꿔 보임 — 값 칸은 키 그대로 보냄 */
|
||||
const KEY_RE = /^[A-Z]{2}\d{6}$/;
|
||||
@@ -67,6 +68,9 @@ export interface CalcContext {
|
||||
onText?: (answer: TextAnswer) => void;
|
||||
}
|
||||
|
||||
const grid = (children: HTMLElement[]): HTMLElement =>
|
||||
el("div", { className: "m01-logic__head", children });
|
||||
|
||||
const SUMS = ["노무비", "재료비", "경비", "계"];
|
||||
|
||||
/** 로직마다 자동값을 한 번만 받음(다시 그려도 안 부름) */
|
||||
@@ -122,7 +126,7 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
|
||||
control = box;
|
||||
}
|
||||
const label = spec.단위 ? `${name} (${spec.단위})` : name;
|
||||
// 긴 이름 줄만 한 칸 다 씀 — 고르기 줄은 짧으면 그대로 2열에 맞춰 나란히
|
||||
// 긴 이름 줄만 두 칸 차지 — 짧으면 4 열에 맞춰 나란히
|
||||
const wide = label.length > 10;
|
||||
return el("label", {
|
||||
className: `m01-logic__field${wide ? " m01-logic__field--wide" : ""}`,
|
||||
@@ -196,8 +200,13 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
|
||||
runButton.setAttribute("data-calc-run", "");
|
||||
host.replaceChildren(
|
||||
el("h3", { text: tx("Calc_Title") }),
|
||||
// 옵션 고르기 · 자재 고르기 두 격자(기본 4 열) — 사이 수평 구분선(709-1 부품)
|
||||
...(fields.length || materials.length
|
||||
? [el("div", { className: "m01-logic__head", children: [...fields, ...materials] })] // 요소 표가 쓰는 auto-fill 그리드 재사용 — 2열
|
||||
? [
|
||||
...(fields.length ? [grid(fields)] : []),
|
||||
...(fields.length && materials.length ? [createDivider()] : []),
|
||||
...(materials.length ? [grid(materials)] : []),
|
||||
]
|
||||
: [el("p", { className: "ui-text ui-text--muted", text: tx("Calc_NoInputs") })]),
|
||||
runButton,
|
||||
out,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_Logic_CalcPick.ts
|
||||
* 시험 계산의 재료 · 기계 고르기 — 재료 줄마다 「잡힌 품목 + 품목 고르기」 · 기계 입력은 단추 하나
|
||||
* 시험 계산의 재료 · 기계 고르기 — 재료 칸마다 잡힌 품목 이름(누르면 고르기) · 기계 입력은 단추 하나
|
||||
* 누르면 마스터 표 모달(`PickTable`) · 고른 재료는 `고름`(호표 줄 차례 → 키)에 실어 다시 계산 · 저장 안 함
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -49,10 +49,11 @@ const isMachineInput = (spec: LogicInput): boolean =>
|
||||
const pickButton = (label: string, onClick: () => void): HTMLButtonElement =>
|
||||
createButton({ label, variant: "ghost", onClick });
|
||||
|
||||
const line = (label: string, current: HTMLElement, button: HTMLElement): HTMLElement =>
|
||||
/** 자재 칸 — 이름 + 잡힌 품목(누르면 고르기 모달) · 긴 이름은 두 칸 */
|
||||
const line = (label: string, current: HTMLElement): HTMLElement =>
|
||||
el("div", {
|
||||
className: "m01-logic__field m01-logic__field--wide",
|
||||
children: [el("span", { text: label }), el("div", { children: [current, button] })],
|
||||
className: `m01-logic__field${label.length > 10 ? " m01-logic__field--wide" : ""}`,
|
||||
children: [el("span", { text: label }), current],
|
||||
});
|
||||
|
||||
/** 재료 줄마다 한 칸 — 지금 잡힌 품목 + 품목 고르기 */
|
||||
@@ -80,12 +81,16 @@ export function materialRows(
|
||||
const seeds = [cond?.["검색어"], cond?.이름, cond?.상세구분, cond?.구분, label]
|
||||
.map(headWord)
|
||||
.filter((w, k, all) => w && all.indexOf(w) === k);
|
||||
const current = el("span", {
|
||||
className: "ui-text ui-text--muted",
|
||||
text: `${nowName || tx("Mat_NoPrice")} `,
|
||||
attrs: { "data-mat-idx": String(i), "data-ref": brief?.ref ?? "" },
|
||||
// 잡힌 품목 이름 = 링크 단추 — 누르면 고르기 모달([품목 고르기] 단추 걷음)
|
||||
const current = createButton({
|
||||
label: nowName || tx("Mat_NoPrice"),
|
||||
variant: "link",
|
||||
onClick: () => pick(),
|
||||
});
|
||||
const button = pickButton(tx("Pick_Item"), () =>
|
||||
current.dataset.matIdx = String(i);
|
||||
current.dataset.ref = brief?.ref ?? "";
|
||||
current.title = tx("Pick_Item");
|
||||
const pick = (): void =>
|
||||
openPickTable({
|
||||
kind: "재료",
|
||||
title: `${tx("Pick_MatTitle")} · ${label}`,
|
||||
@@ -94,12 +99,11 @@ export function materialRows(
|
||||
isCurrent: (r) => String(r["키"]) === (mine.get(i)?.ref ?? current.dataset.ref),
|
||||
onPick: (r) => {
|
||||
mine.set(i, { ref: String(r["키"]), label: nameOf(r) });
|
||||
current.textContent = `${nameOf(r)} `;
|
||||
current.textContent = nameOf(r);
|
||||
again();
|
||||
},
|
||||
}),
|
||||
);
|
||||
return line(`${label}${item.규격 ? ` ${item.규격}` : ""}`, current, button);
|
||||
});
|
||||
return line(`${label}${item.규격 ? ` ${item.규격}` : ""}`, current);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -115,7 +119,7 @@ export function showItems(
|
||||
const item = lines[i]?.품목;
|
||||
if (!item || mine.has(i)) return;
|
||||
span.dataset.ref = item.키;
|
||||
span.textContent = `${`${item.이름 ?? ""} ${item.규격 ?? ""}`.trim() || item.키} `;
|
||||
span.textContent = `${item.이름 ?? ""} ${item.규격 ?? ""}`.trim() || item.키;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -73,11 +73,24 @@
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 시험 계산 입력만 2열 고정 — 요소 표 쪽(auto-fill)은 그대로 둠
|
||||
/* 시험 계산 입력만 4열 고정(옵션 · 자재 격자 둘) — 요소 표 쪽(auto-fill)은 그대로 둠 · 긴 이름 칸은 두 칸
|
||||
실제 그릇은 LogicLab 이 씌우는 `.m01lab__calc`(`.m01-logic__calc` 는 옛 테스트 컨테이너 몫이라 안 걸림) */
|
||||
.m01lab__calc .m01-logic__head,
|
||||
.m01-logic__calc .m01-logic__head {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.m01lab__calc .m01-logic__field--wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
/* 자재 칸 잡힌 품목 링크 — 칸 폭 안에서 왼쪽 맞춤 · 넘치면 말줄임 */
|
||||
.m01lab__calc .m01-logic__field > .ui-btn--link {
|
||||
justify-content: flex-start;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m01-logic__field {
|
||||
|
||||
@@ -118,7 +118,8 @@ def _save(client: TestClient, row: dict) -> None:
|
||||
"version": one["version"],
|
||||
"changes": [{"op": "edit", "key": LOGIC_KEY, "row": row}],
|
||||
}
|
||||
res = client.post("/api/m01/save", json={"files": [part]})
|
||||
body = {"files": [part], "개정확인": True} # 정본 줄 고침 = 개정 확인(713-1)
|
||||
res = client.post("/api/m01/save", json=body)
|
||||
assert res.status_code == 200, res.text
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""713-1 지킴 — 로직 정본 줄 고침 · 지우기 · 더하기는 「원문 개정 반영」 확인(`개정확인`) 없이는 409 ·
|
||||
요소 · 같은 줄 다시 보냄은 그대로 · 줄 바탕 지문(`Store_Revise.row_print`). 임시 사본 폴더만 씀."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from M01_MasterData import M01_MasterData_Router as router_module
|
||||
from M01_MasterData import M01_MasterData_Store as store
|
||||
from M01_MasterData import M01_MasterData_Store_Revise as revise
|
||||
|
||||
REAL = store.FOLDER
|
||||
KEY = "GC000091" # 공통 3-3-1 암발파 — 로직_건설품셈_03장_토공사.json
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client(tmp_path_factory) -> TestClient:
|
||||
folder = tmp_path_factory.mktemp("m01_revise")
|
||||
for path in REAL.glob("*.json"):
|
||||
if not path.name.startswith("_") or path.name == store.mk.BOOK.name:
|
||||
shutil.copy(path, folder / path.name)
|
||||
patch = pytest.MonkeyPatch()
|
||||
patch.setattr(store, "FOLDER", folder)
|
||||
app = FastAPI()
|
||||
app.include_router(router_module.router)
|
||||
yield TestClient(app)
|
||||
patch.undo()
|
||||
|
||||
|
||||
def _part(client: TestClient, op: str, row: dict | None = None, key: str | None = KEY) -> dict:
|
||||
one = client.get("/api/m01/logic", params={"key": KEY}).json()
|
||||
return {
|
||||
"file": one["file"],
|
||||
"version": one["version"],
|
||||
"changes": [{"op": op, "key": key, "row": row}],
|
||||
}
|
||||
|
||||
|
||||
def _bytes(client: TestClient) -> bytes:
|
||||
return (
|
||||
store.FOLDER / client.get("/api/m01/logic", params={"key": KEY}).json()["file"]
|
||||
).read_bytes()
|
||||
|
||||
|
||||
def test_정본_고침_지움_더함_확인_없으면_409(client: TestClient) -> None:
|
||||
base = client.get("/api/m01/logic", params={"key": KEY}).json()["logic"]
|
||||
before = _bytes(client)
|
||||
for op, row, key in (
|
||||
("edit", {**base, "비고": "개정 시험"}, KEY),
|
||||
("delete", None, KEY),
|
||||
("add", {k: v for k, v in base.items() if k != "키"}, None),
|
||||
):
|
||||
res = client.post("/api/m01/save", json={"files": [_part(client, op, row, key)]})
|
||||
assert res.status_code == 409, (op, res.text)
|
||||
got = res.json()["detail"]
|
||||
assert got["개정"][0]["op"] == op and "원문 개정 반영" in got["까닭"]
|
||||
assert _bytes(client) == before # 확인 없이는 한 바이트도 안 바뀜
|
||||
|
||||
|
||||
def test_같은_줄_다시_보냄은_그대로(client: TestClient) -> None:
|
||||
base = client.get("/api/m01/logic", params={"key": KEY}).json()["logic"]
|
||||
res = client.post("/api/m01/save", json={"files": [_part(client, "edit", base)]})
|
||||
assert res.status_code == 200, res.text
|
||||
|
||||
|
||||
def test_확인하면_저장_지문은_계산_칸만(client: TestClient) -> None:
|
||||
one = client.get("/api/m01/logic", params={"key": KEY}).json()
|
||||
base, print0 = one["logic"], one["지문"]
|
||||
assert print0 == revise.row_print(base)
|
||||
body = {"files": [_part(client, "edit", {**base, "비고": "개정 시험"})], "개정확인": True}
|
||||
assert client.post("/api/m01/save", json=body).status_code == 200
|
||||
again = client.get("/api/m01/logic", params={"key": KEY}).json()
|
||||
assert again["logic"]["비고"] == "개정 시험" and again["지문"] == print0 # 비고 = 지문 밖
|
||||
hop = [dict(x) for x in base["호표"]]
|
||||
hop[0]["수량"] = f"({hop[0]['수량']}) * 2"
|
||||
body = {"files": [_part(client, "edit", {**again["logic"], "호표": hop})], "개정확인": True}
|
||||
assert client.post("/api/m01/save", json=body).status_code == 200, "계산 칸 고침"
|
||||
assert client.get("/api/m01/logic", params={"key": KEY}).json()["지문"] != print0
|
||||
|
||||
|
||||
def test_요소_파일은_대상_밖(client: TestClient) -> None:
|
||||
rows = client.get("/api/m01/rows", params={"file": "인력.json", "size": 1}).json()
|
||||
row = {**rows["rows"][0], "비고": "요소 시험"}
|
||||
part = {
|
||||
"file": "인력.json",
|
||||
"version": rows["version"],
|
||||
"changes": [{"op": "edit", "key": row["키"], "row": row}],
|
||||
}
|
||||
assert client.post("/api/m01/save", json={"files": [part]}).status_code == 200
|
||||
|
||||
|
||||
def test_지문_수_꼴_같음() -> None:
|
||||
a = {"이름": "가", "호표": [{"수량": Decimal("10.0")}], "끝수": 1}
|
||||
b = {"이름": "나", "호표": [{"수량": 10}], "끝수": 1.0, "별칭": "x"}
|
||||
assert revise.row_print(a) == revise.row_print(b)
|
||||
assert revise.is_canon("로직_산림품셈_09장_토공.json")
|
||||
assert not revise.is_canon("로직_자체.json") and not revise.is_canon("인력.json")
|
||||
@@ -231,7 +231,7 @@ def test_더함_지움_로직_파일도_같은_길(client: TestClient) -> None:
|
||||
one = _get(client, "/api/m01/logic", key=LOGIC_KEY)
|
||||
copy = {**one["logic"], "키": "", "원문번호": LOGIC_NUM}
|
||||
add = {"file": one["file"], "version": one["version"], "changes": [{"op": "add", "row": copy}]}
|
||||
res = client.post("/api/m01/save", json={"files": [add]})
|
||||
res = client.post("/api/m01/save", json={"files": [add], "개정확인": True}) # 713-1
|
||||
assert res.status_code == 200, res.text
|
||||
book = json.loads((store.FOLDER / store.mk.BOOK.name).read_text(encoding="utf-8"))
|
||||
new = f"GC{book['다음']['GC'] - 1:06d}" # 대장의 다음 번호를 받음
|
||||
@@ -242,7 +242,8 @@ def test_더함_지움_로직_파일도_같은_길(client: TestClient) -> None:
|
||||
"version": res.json()["files"][0]["version"],
|
||||
"changes": [{"op": "delete", "key": new}],
|
||||
}
|
||||
assert client.post("/api/m01/save", json={"files": [drop]}).status_code == 200
|
||||
body = {"files": [drop], "개정확인": True}
|
||||
assert client.post("/api/m01/save", json=body).status_code == 200
|
||||
assert client.get("/api/m01/logic", params={"key": new}).status_code == 404
|
||||
|
||||
|
||||
@@ -322,6 +323,7 @@ def test_저장은_고친_줄만_바꿈(client: TestClient) -> None:
|
||||
edited = {**one["logic"], "비고": "고침"}
|
||||
change = {"op": "edit", "key": LOGIC_KEY, "row": edited}
|
||||
body = {"files": [{"file": one["file"], "version": one["version"], "changes": [change]}]}
|
||||
body["개정확인"] = True # 정본 줄 고침 = 개정 확인(713-1)
|
||||
assert client.post("/api/m01/save", json=body).status_code == 200
|
||||
new = path.read_text(encoding="utf-8")
|
||||
at = old.index(f'"키": "{LOGIC_KEY}"')
|
||||
|
||||
@@ -94,7 +94,8 @@ def test_로직을_저장하면_목록에_바로_비친다(client: TestClient) -
|
||||
"version": one["version"],
|
||||
"changes": [{"op": "edit", "key": CALLER, "row": row}],
|
||||
}
|
||||
]
|
||||
],
|
||||
"개정확인": True, # 정본 줄 고침 = 개정 확인(713-1)
|
||||
},
|
||||
)
|
||||
assert res.status_code == 200, res.text
|
||||
@@ -128,7 +129,8 @@ def _save(client: TestClient, key: str, spoil) -> object:
|
||||
"version": one["version"],
|
||||
"changes": [{"op": "edit", "key": key, "row": row}],
|
||||
}
|
||||
]
|
||||
],
|
||||
"개정확인": True, # 정본 줄 고침 = 개정 확인(713-1)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -200,7 +202,10 @@ def _save_file(client: TestClient, file: str, changes: list[dict]):
|
||||
"""정본 파일을 저장 길로 고침 — 요소·표 줄도 같은 길."""
|
||||
return client.post(
|
||||
"/api/m01/save",
|
||||
json={"files": [{"file": file, "version": store.read(file)[1], "changes": changes}]},
|
||||
json={
|
||||
"files": [{"file": file, "version": store.read(file)[1], "changes": changes}],
|
||||
"개정확인": True, # 로직 정본 파일이면 개정 확인(713-1) · 요소 파일엔 상관없음
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -75,7 +75,8 @@ def test_조합에_담은_로직을_흐름으로_바꿔도_같음(tmp_path: Path
|
||||
flow.pop("key", None)
|
||||
change = {"op": "edit", "key": key, "row": {**got["logic"], "흐름": flow}}
|
||||
sflow.save(
|
||||
[store._dec({"file": got["file"], "version": got["version"], "changes": [change]})]
|
||||
[store._dec({"file": got["file"], "version": got["version"], "changes": [change]})],
|
||||
True, # 정본 줄 고침 = 개정 확인(713-1)
|
||||
)
|
||||
for key in KEYS:
|
||||
row = store.logic(key)["logic"]
|
||||
|
||||
@@ -100,7 +100,8 @@ def _save_flow(client: TestClient, key: str, flow: dict, want: int = 200) -> dic
|
||||
row = {**got["logic"], "흐름": flow}
|
||||
part = {"file": got["file"], "version": got["version"],
|
||||
"changes": [{"op": "edit", "key": key, "row": row}]} # fmt: skip
|
||||
return _post(client, "/api/m01/save", {"files": [part]}, want)
|
||||
body = {"files": [part], "개정확인": True} # 정본 줄 고침 = 개정 확인(713-1)
|
||||
return _post(client, "/api/m01/save", body, want)
|
||||
|
||||
|
||||
def test_흐름_저장_다시_셈_다시_읽기(client: TestClient, folder: Path) -> None:
|
||||
|
||||
Reference in New Issue
Block a user