Merge remote-tracking branch 'origin/dev' into main_laptop_1
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
"""B09 원가계산 — **사용자가 고친 값** 한 벌 (PLAN 12장 2차 · 2026-09-14 브레인 판정).
|
||||
|
||||
전부 **프로젝트 단위**다 — 라이브러리 층을 만들지 않는다(B09 는 그 프로젝트의 내역·단가라 다른
|
||||
프로젝트로 가져갈 것이 없음. 양식·라이브러리는 B08 구조물도에만 있음).
|
||||
|
||||
저장 자리 산출 조건 `estimation.edits` — 한 파일 한 구획
|
||||
얹는 자리 서버가 기본 조립(`cached_build`) 뒤 **복사본에** 고친 값을 얹고 다시 계산
|
||||
(캐시 키에 고친 값이 들어감 — 브라우저 값을 받아 적지 않음)
|
||||
되돌리기 고친 값을 지우면 계산값으로 돌아감(B08 집계표 ↺ 와 같은 꼴)
|
||||
|
||||
⚠ 기본 조립은 **안 바꾼다** — 고친 값이 없으면 `cached_build` 그 벌 그대로라 골든셋이 그대로 돈다.
|
||||
⚠ 얹지 못한 고친 값(단가표에서 사라진 코드·값 없는 슬롯)은 조용히 버리지 않고 `edit_skipped` 에 남긴다.
|
||||
|
||||
구획
|
||||
adopted_slots {자재 코드: 슬롯 번호 1~6} 자재단가대비표 채택 바꾸기(`wM_Boxa`)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
from B09_Estimation.B09_Estimation_PriceBook import PRICE_SLOT_COUNT, PriceKind
|
||||
from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, cached_build
|
||||
|
||||
EDITS_KEY = "edits"
|
||||
#: 화면·근거 표기 — B08 구조물도 집계표와 같은 말.
|
||||
USER_SOURCE = "user"
|
||||
SECTIONS = ("adopted_slots",)
|
||||
|
||||
|
||||
class EditError(ValueError):
|
||||
"""고친 값을 받을 수 없음 — 저장하지 않고 까닭을 돌려줌."""
|
||||
|
||||
|
||||
def normalize(raw: Any) -> dict[str, dict[str, Any]]:
|
||||
"""저장본을 아는 구획만 남긴 모양으로 — 모르는 구획·빈 구획은 버림."""
|
||||
edits: dict[str, dict[str, Any]] = {}
|
||||
if not isinstance(raw, dict):
|
||||
return edits
|
||||
for section in SECTIONS:
|
||||
values = raw.get(section)
|
||||
if isinstance(values, dict) and values:
|
||||
edits[section] = {str(key): value for key, value in values.items()}
|
||||
return edits
|
||||
|
||||
|
||||
def edits_key(raw: Any) -> str:
|
||||
"""캐시 키 — 같은 고친 값이면 같은 글(차례 무관)."""
|
||||
edits = normalize(raw)
|
||||
return json.dumps(edits, sort_keys=True, ensure_ascii=False) if edits else ""
|
||||
|
||||
|
||||
def _apply_adopted_slots(build: UnitPriceBuild, values: dict[str, Any]) -> None:
|
||||
for code, slot in values.items():
|
||||
title = build.book.titles.get(code)
|
||||
number = int(slot) if str(slot).isdigit() else 0
|
||||
if title is None or title.kind is not PriceKind.MATERIAL:
|
||||
build.edit_skipped.append(f"채택 슬롯 {code} — 단가표에 없는 자재")
|
||||
continue
|
||||
if not 1 <= number <= PRICE_SLOT_COUNT or title.slots[number - 1] is None:
|
||||
build.edit_skipped.append(f"채택 슬롯 {code} — {slot}번 슬롯에 값이 없음")
|
||||
continue
|
||||
title.adopted_slot = number
|
||||
|
||||
|
||||
def apply_edits(build: UnitPriceBuild, edits: dict[str, dict[str, Any]]) -> UnitPriceBuild:
|
||||
"""고친 값을 얹은 **새 벌** — 기본 벌(캐시)은 건드리지 않음."""
|
||||
edited = copy.deepcopy(build)
|
||||
edited.edit_skipped = []
|
||||
_apply_adopted_slots(edited, edits.get("adopted_slots", {}))
|
||||
return edited
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def edited_build(args: tuple, key: str) -> UnitPriceBuild:
|
||||
"""프로젝트 조립 한 벌 — `args` 는 `cached_build` 인자 그대로, `key` 는 `edits_key`."""
|
||||
base = cached_build(*args)
|
||||
if not key:
|
||||
return base
|
||||
return apply_edits(base, json.loads(key))
|
||||
|
||||
|
||||
def validate_change(build: UnitPriceBuild, section: str, key: str, value: Any) -> Any:
|
||||
"""고친 값 하나를 받기 전 검사 — 받을 수 있는 모양으로 돌려주거나 `EditError`."""
|
||||
if section not in SECTIONS:
|
||||
raise EditError(f"모르는 구획: {section}")
|
||||
if section == "adopted_slots":
|
||||
title = build.book.titles.get(key)
|
||||
if title is None or title.kind is not PriceKind.MATERIAL:
|
||||
raise EditError(f"단가표에 없는 자재입니다: {key}")
|
||||
number = int(value) if str(value).isdigit() else 0
|
||||
if not 1 <= number <= PRICE_SLOT_COUNT or title.slots[number - 1] is None:
|
||||
raise EditError(f"{title.name}: {value}번 원천에 값이 없어 채택할 수 없습니다")
|
||||
return number
|
||||
return value
|
||||
|
||||
|
||||
def merge_changes(
|
||||
stored: Any, build: UnitPriceBuild, changes: list[dict[str, Any]]
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""바꿀 것 여럿을 한 번에 — `value` 가 `None` 이면 그 칸을 지움(↺ 계산값으로)."""
|
||||
edits = normalize(stored)
|
||||
for change in changes:
|
||||
section, key = str(change.get("section") or ""), str(change.get("key") or "")
|
||||
if not key:
|
||||
raise EditError("고칠 칸이 비었습니다")
|
||||
if change.get("value") is None:
|
||||
edits.get(section, {}).pop(key, None)
|
||||
continue
|
||||
edits.setdefault(section, {})[key] = validate_change(build, section, key, change["value"])
|
||||
return normalize(edits)
|
||||
@@ -0,0 +1,84 @@
|
||||
"""B09 — 사용자가 글로 넣은 **식**을 안전하게 셈 (PLAN 12장 2차 ③ · STmate `wM_Edit_San` 함수 칸).
|
||||
|
||||
받는 것 — 수 · `+ - * /` · 괄호 · 함수 넷 `ROUND(x, n)` · `ROUNDDOWN(x, n)` · `INT(x)` · `SQRT(x)`
|
||||
(STmate 식 편집기 함수 목록과 같은 이름 · 명세 13장 식 언어). 이름·속성·호출 모양이 그 밖이면 거절.
|
||||
|
||||
⚠ `eval` 을 쓰지 않음 — 파이썬 문법 나무를 한 마디씩 보고 아는 것만 셈.
|
||||
⚠ 값은 `Decimal` — 소수를 이진수로 접지 않음(`1/1.175` 는 식 그대로 나눔).
|
||||
⚠ 「좌→우 순차」 — 같은 우선순위의 곱·나눗셈은 파이썬 문법 나무가 이미 왼쪽부터 묶음(명세 7장 노임 식과 같음).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from decimal import ROUND_FLOOR, ROUND_HALF_UP, Decimal, InvalidOperation
|
||||
|
||||
_MAX_LENGTH = 400
|
||||
|
||||
|
||||
class ExpressionError(ValueError):
|
||||
"""식을 셀 수 없음 — 까닭을 사람 말로."""
|
||||
|
||||
|
||||
def _places(value: Decimal) -> Decimal:
|
||||
digits = int(value)
|
||||
if digits < 0 or digits > 10:
|
||||
raise ExpressionError("자릿수는 0~10 사이여야 합니다")
|
||||
return Decimal(1).scaleb(-digits)
|
||||
|
||||
|
||||
def _call(name: str, args: list[Decimal]) -> Decimal:
|
||||
if name == "ROUND" and len(args) == 2:
|
||||
return args[0].quantize(_places(args[1]), rounding=ROUND_HALF_UP)
|
||||
if name == "ROUNDDOWN" and len(args) == 2:
|
||||
return args[0].quantize(_places(args[1]), rounding=ROUND_FLOOR)
|
||||
if name == "INT" and len(args) == 1:
|
||||
return args[0].to_integral_value(rounding=ROUND_FLOOR)
|
||||
if name == "SQRT" and len(args) == 1:
|
||||
if args[0] < 0:
|
||||
raise ExpressionError("음수의 제곱근은 셀 수 없습니다")
|
||||
return args[0].sqrt()
|
||||
raise ExpressionError(f"모르는 함수이거나 인자 수가 맞지 않습니다: {name}")
|
||||
|
||||
|
||||
def _eval(node: ast.AST) -> Decimal:
|
||||
if isinstance(node, ast.Expression):
|
||||
return _eval(node.body)
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
|
||||
return Decimal(str(node.value))
|
||||
if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.USub, ast.UAdd)):
|
||||
value = _eval(node.operand)
|
||||
return -value if isinstance(node.op, ast.USub) else value
|
||||
if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Sub, ast.Mult, ast.Div)):
|
||||
left, right = _eval(node.left), _eval(node.right)
|
||||
if isinstance(node.op, ast.Add):
|
||||
return left + right
|
||||
if isinstance(node.op, ast.Sub):
|
||||
return left - right
|
||||
if isinstance(node.op, ast.Mult):
|
||||
return left * right
|
||||
if right == 0:
|
||||
raise ExpressionError("0 으로 나눌 수 없습니다")
|
||||
return left / right
|
||||
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and not node.keywords:
|
||||
return _call(node.func.id.upper(), [_eval(arg) for arg in node.args])
|
||||
raise ExpressionError(
|
||||
"식에 쓸 수 없는 글자가 있습니다 — 수·+ - * /·괄호·ROUND·ROUNDDOWN·INT·SQRT 만"
|
||||
)
|
||||
|
||||
|
||||
def evaluate(text: str) -> Decimal:
|
||||
"""식 글 → 값. 셀 수 없으면 `ExpressionError`."""
|
||||
source = str(text or "").strip().replace("×", "*").replace("÷", "/")
|
||||
if not source:
|
||||
raise ExpressionError("식이 비었습니다")
|
||||
if len(source) > _MAX_LENGTH:
|
||||
raise ExpressionError("식이 너무 깁니다")
|
||||
try:
|
||||
tree = ast.parse(source, mode="eval")
|
||||
except SyntaxError as error:
|
||||
raise ExpressionError(f"식 문법이 맞지 않습니다: {error.msg}") from error
|
||||
try:
|
||||
return _eval(tree)
|
||||
except (InvalidOperation, OverflowError) as error:
|
||||
raise ExpressionError("셀 수 없는 값입니다") from error
|
||||
@@ -269,7 +269,9 @@ async def _build_for(project_id: UUID):
|
||||
# 공구손료·잡재료 — **비어 있는 것이 기본**이라 안 넣으면 줄이 안 선다(확정 5차 작은 것 1).
|
||||
# 유가 지역 — 안 고르면 전국평균(품셈 8-1-7 5호 「해당지역의 가격」).
|
||||
# 기계 수송비 — 거리·도로 구분이 있어야 선다(산림품셈 10-4 [주]).
|
||||
return cached_build(
|
||||
from B09_Estimation.B09_Estimation_Edits import edited_build, edits_key
|
||||
|
||||
args = (
|
||||
ranges,
|
||||
machines,
|
||||
str(settings.get("misc_material_percent") or ""),
|
||||
@@ -281,6 +283,8 @@ async def _build_for(project_id: UUID):
|
||||
# 조종원 시간당 노임 자르는 자리 — 실무마다 다름(명세 7장 정정). 안 정하면 원 미만.
|
||||
str(settings.get("operator_wage_digits") or ""),
|
||||
)
|
||||
# 사용자가 고친 값(PLAN 12장 2차) — 없으면 기본 조립 그 벌 그대로.
|
||||
return edited_build(args, edits_key(settings.get("edits")))
|
||||
|
||||
|
||||
@router.get("/{project_id}/estimation/base-data")
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""B09 원가계산 라우터 — **사용자가 고친 값** 읽기·저장 (PLAN 12장 2차 · `B09_Estimation_Edits`).
|
||||
|
||||
⚠ `B09_Estimation_Router.py` 는 쪼개기 차례를 기다리는 중이라(브레인 판정) 새 문을 여기 둠.
|
||||
⚠ 저장은 고친 값만 — 금액은 다음 조회 때 서버가 조립 뒤 고친 값을 얹어 **다시 계산**함.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from B09_Estimation.B09_Estimation_Edits import (
|
||||
EDITS_KEY,
|
||||
EditError,
|
||||
merge_changes,
|
||||
normalize,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["B09 Estimation Edits"])
|
||||
|
||||
|
||||
class EditChange(BaseModel):
|
||||
"""고칠 칸 하나 — `value` 가 `null` 이면 그 칸을 지움(↺ 계산값으로 돌아감)."""
|
||||
|
||||
section: str
|
||||
key: str
|
||||
value: Any = None
|
||||
|
||||
|
||||
class EditRequest(BaseModel):
|
||||
changes: list[EditChange] = Field(default_factory=list)
|
||||
|
||||
|
||||
@router.get("/{project_id}/estimation/edits")
|
||||
async def get_edits(project_id: UUID) -> JSONResponse:
|
||||
"""고친 값 한 벌 — 화면이 「사용자」 표시·↺ 를 붙이는 데 씀."""
|
||||
from B09_Estimation.B09_Estimation_Router import _build_for, _project_root_of
|
||||
from common_util.common_util_project_settings import estimation_settings
|
||||
|
||||
root = await _project_root_of(project_id)
|
||||
settings = estimation_settings(root) if root else {}
|
||||
build = await _build_for(project_id)
|
||||
return JSONResponse(
|
||||
content={
|
||||
"status": "success",
|
||||
EDITS_KEY: normalize(settings.get(EDITS_KEY)),
|
||||
"skipped": list(getattr(build, "edit_skipped", [])),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{project_id}/estimation/edits")
|
||||
async def put_edits(project_id: UUID, payload: EditRequest) -> JSONResponse:
|
||||
"""고친 값 저장 — 칸마다 검사하고(값 없는 슬롯 등은 받지 않음) 구획째 갈아 끼움."""
|
||||
from B09_Estimation.B09_Estimation_Router import _build_for, _project_root_of
|
||||
from common_util.common_util_project_settings import estimation_settings, save_section
|
||||
|
||||
root = await _project_root_of(project_id)
|
||||
if root is None:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."},
|
||||
)
|
||||
settings = estimation_settings(root)
|
||||
try:
|
||||
build = await _build_for(project_id)
|
||||
edits = merge_changes(
|
||||
settings.get(EDITS_KEY), build, [change.model_dump() for change in payload.changes]
|
||||
)
|
||||
except EditError as error:
|
||||
return JSONResponse(status_code=422, content={"status": "error", "message": str(error)})
|
||||
try:
|
||||
save_section(root, "estimation", {EDITS_KEY: edits}, replace_keys=(EDITS_KEY,))
|
||||
except Exception:
|
||||
logger.exception("B09 고친 값 저장 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "고친 값을 저장하지 못했습니다."},
|
||||
)
|
||||
return JSONResponse(content={"status": "success", EDITS_KEY: edits})
|
||||
@@ -21,6 +21,12 @@ import {
|
||||
sheetTable,
|
||||
} from "./B09_Estimation_UI_Sheet";
|
||||
import { loadDetail, type DetailDto, type DetailRowDto } from "./B09_Estimation_UI_Store";
|
||||
import {
|
||||
addRowForm,
|
||||
qFormulaInput,
|
||||
quantityInput,
|
||||
rowControls,
|
||||
} from "./B09_Estimation_UI_DetailEdit";
|
||||
|
||||
interface Crumb {
|
||||
tab: string;
|
||||
@@ -65,8 +71,22 @@ function targetTab(row: DetailRowDto): string | null {
|
||||
return row.kind === "machine_hourly" ? "machine" : "unit_price";
|
||||
}
|
||||
|
||||
function detailRow(row: DetailRowDto, ctx: B09TabContext): HTMLElement {
|
||||
const tr = el("tr");
|
||||
/** 편집 칸을 켰는가 — 탭을 오가도 남음(본표 한 장 단위가 아니라 화면 단위). */
|
||||
let editing = false;
|
||||
|
||||
interface EditHooks {
|
||||
projectId: string;
|
||||
reload: () => void;
|
||||
}
|
||||
|
||||
function detailRow(row: DetailRowDto, ctx: B09TabContext, hooks: EditHooks | null): HTMLElement {
|
||||
const tr = el("tr", row.edit?.removed ? "is-removed" : row.edit?.user ? "is-user" : "");
|
||||
const edit = row.edit;
|
||||
const quantityCell =
|
||||
hooks && edit && editing && row.unit !== "%" ? el("td") : numberCell(quantity(row.quantity));
|
||||
if (hooks && edit && editing && row.unit !== "%") {
|
||||
quantityCell.append(quantityInput(hooks.projectId, edit, row.quantity, hooks.reload));
|
||||
}
|
||||
// 비율 줄(잡품·공구손료·제잡비)은 단가 칸에 **밑수**가 옴 — 「21 % × 주연료비 6,365」 꼴(실무 표).
|
||||
const unit: [string, string, string, string] = [
|
||||
row.unit_total ?? "",
|
||||
@@ -77,11 +97,17 @@ function detailRow(row: DetailRowDto, ctx: B09TabContext): HTMLElement {
|
||||
tr.append(
|
||||
el("td", "", row.name),
|
||||
el("td", "", row.spec),
|
||||
numberCell(quantity(row.quantity)),
|
||||
quantityCell,
|
||||
el("td", "", row.unit),
|
||||
...moneyCells(unit, [row.total, row.labor, row.material, row.expense]),
|
||||
);
|
||||
const note = el("td", "b09s-note");
|
||||
if (hooks && edit && (editing || edit.user)) {
|
||||
note.append(rowControls(hooks.projectId, edit, hooks.reload, editing));
|
||||
}
|
||||
if (hooks && edit?.output && (editing || edit.q_formula)) {
|
||||
note.append(qFormulaInput(hooks.projectId, edit, hooks.reload));
|
||||
}
|
||||
const source = row.source_label || row.source || "";
|
||||
if (source) note.append(el("span", "b09s-hint", `[${source}] `));
|
||||
if (row.note) note.append(el("span", "b09s-formula", row.note));
|
||||
@@ -96,13 +122,34 @@ function detailRow(row: DetailRowDto, ctx: B09TabContext): HTMLElement {
|
||||
return tr;
|
||||
}
|
||||
|
||||
function drawDetail(ctx: B09TabContext, box: HTMLElement, detail: DetailDto, label: string): void {
|
||||
function drawDetail(
|
||||
ctx: B09TabContext,
|
||||
box: HTMLElement,
|
||||
detail: DetailDto,
|
||||
label: string,
|
||||
reload: () => void,
|
||||
): void {
|
||||
const title = el(
|
||||
"div",
|
||||
"b09s-title",
|
||||
"b09s-title b09s-inline",
|
||||
`${label} ${detail.name}${detail.spec ? ` · ${detail.spec}` : ""}`,
|
||||
);
|
||||
if (detail.unit) title.append(el("span", "b09s-hint", ` (${detail.unit})`));
|
||||
const hooks = detail.editable && ctx.projectId ? { projectId: ctx.projectId, reload } : null;
|
||||
if (hooks) {
|
||||
// 2차 편집(PLAN 12장) — 구성행 수정 · Q 식 편집. 프로젝트 단위로 저장, 금액은 서버가 다시 셈.
|
||||
const toggle = el(
|
||||
"button",
|
||||
"b09s-undo",
|
||||
editing ? L("B09_Sheet_EditDone") : L("B09_Sheet_Edit"),
|
||||
);
|
||||
toggle.type = "button";
|
||||
toggle.addEventListener("click", () => {
|
||||
editing = !editing;
|
||||
reload();
|
||||
});
|
||||
title.append(toggle);
|
||||
}
|
||||
const { wrap, tbody } = sheetTable(
|
||||
sheetHead([
|
||||
L("B09_Sheet_Col_Name"),
|
||||
@@ -111,7 +158,7 @@ function drawDetail(ctx: B09TabContext, box: HTMLElement, detail: DetailDto, lab
|
||||
L("B09_Sheet_Col_Unit"),
|
||||
]),
|
||||
);
|
||||
for (const row of detail.rows) tbody.append(detailRow(row, ctx));
|
||||
for (const row of detail.rows) tbody.append(detailRow(row, ctx, hooks));
|
||||
const sum = el("tr", "is-sum");
|
||||
sum.append(el("td", "", L("B09_Sheet_Sum")), el("td"), el("td"), el("td"));
|
||||
sum.append(
|
||||
@@ -120,6 +167,9 @@ function drawDetail(ctx: B09TabContext, box: HTMLElement, detail: DetailDto, lab
|
||||
);
|
||||
tbody.append(sum);
|
||||
box.append(title, wrap);
|
||||
if (hooks && editing && detail.next_add_key) {
|
||||
box.append(addRowForm(hooks.projectId, detail.code, detail.next_add_key, reload));
|
||||
}
|
||||
if (detail.unattached_note) box.append(hint(detail.unattached_note.replace(/\*\*/g, ""), true));
|
||||
if (detail.known_gap_note) box.append(hint(detail.known_gap_note, true));
|
||||
}
|
||||
@@ -134,13 +184,20 @@ export function renderDetail(
|
||||
): void {
|
||||
if (!ctx.projectId) return;
|
||||
visit(tab, code, label);
|
||||
box.replaceChildren(trail(ctx), hint(L("B09_Sheet_Loading")));
|
||||
loadDetail(ctx.projectId, code)
|
||||
.then((detail) => {
|
||||
box.replaceChildren(trail(ctx));
|
||||
drawDetail(ctx, box, detail, label);
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
box.replaceChildren(trail(ctx), hint(`${L("B09_Sheet_LoadFailed")} ${error.message}`, true));
|
||||
});
|
||||
const projectId = ctx.projectId;
|
||||
const load = (): void => {
|
||||
box.replaceChildren(trail(ctx), hint(L("B09_Sheet_Loading")));
|
||||
loadDetail(projectId, code)
|
||||
.then((detail) => {
|
||||
box.replaceChildren(trail(ctx));
|
||||
drawDetail(ctx, box, detail, label, load);
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
box.replaceChildren(
|
||||
trail(ctx),
|
||||
hint(`${L("B09_Sheet_LoadFailed")} ${error.message}`, true),
|
||||
);
|
||||
});
|
||||
};
|
||||
load();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_UI_DetailEdit.ts
|
||||
* 호표 본표 **편집** — 일위대가 구성행 수정(`wM_Edit_iLWi`) · 단가산출 Q 식 편집(`wM_Edit_San`) (PLAN 12장 2차)
|
||||
*
|
||||
* - 전부 프로젝트 단위(브레인 판정) — 칸 하나 고칠 때마다 서버에 고친 값만 보내고, 금액은 서버가 다시 셈.
|
||||
* - 줄마다: 수량 칸 · ✕(줄 빼기) · D 의 기계 줄은 Q 식 칸 · 고친 줄 = 「사용자」 + ↺(계산값으로).
|
||||
* - 아래: 줄 더하기 — 단가표 고르개(이름으로 찾기) + 수량.
|
||||
* - ⚠ 화면이 값을 만들지 않음 — 입력한 글을 그대로 보내고, 받을 수 없는 값은 서버가 까닭과 함께 거절.
|
||||
* ========================================================================== */
|
||||
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { L, el, userMark } from "./B09_Estimation_UI_Sheet";
|
||||
import { saveEdits, type EditChange } from "./B09_Estimation_UI_Store";
|
||||
|
||||
/** 서버가 줄마다 붙이는 고친 값 표시. */
|
||||
export interface RowEditDto {
|
||||
key: string;
|
||||
user: boolean;
|
||||
removed?: boolean;
|
||||
added?: boolean;
|
||||
was_quantity?: string;
|
||||
output?: string;
|
||||
q_formula?: string;
|
||||
}
|
||||
|
||||
interface SearchRowDto {
|
||||
code: string;
|
||||
name: string;
|
||||
spec: string;
|
||||
unit: string;
|
||||
kind_label: string;
|
||||
}
|
||||
|
||||
export const ROWS_SECTION = "sheet_rows";
|
||||
export const Q_SECTION = "price_basis_q";
|
||||
|
||||
function save(projectId: string, changes: EditChange[], reload: () => void): void {
|
||||
void saveEdits(projectId, changes)
|
||||
.then(() => {
|
||||
showToast(L("B09_Sheet_Saved"), "success");
|
||||
reload();
|
||||
})
|
||||
.catch((error: Error) => showToast(`${L("B09_Sheet_SaveFailed")} ${error.message}`, "error"));
|
||||
}
|
||||
|
||||
/** 수량 칸 — Enter·칸 나가기에 보냄(바뀐 때만). */
|
||||
export function quantityInput(
|
||||
projectId: string,
|
||||
edit: RowEditDto,
|
||||
quantity: string,
|
||||
reload: () => void,
|
||||
): HTMLElement {
|
||||
const input = el("input", "b09s-edit-input");
|
||||
input.type = "text";
|
||||
input.inputMode = "decimal";
|
||||
input.value = quantity;
|
||||
const send = (): void => {
|
||||
const text = input.value.trim();
|
||||
if (text === quantity) return;
|
||||
save(projectId, [{ section: ROWS_SECTION, key: edit.key, value: { quantity: text } }], reload);
|
||||
};
|
||||
input.addEventListener("click", (event) => event.stopPropagation());
|
||||
input.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") input.blur();
|
||||
});
|
||||
input.addEventListener("change", send);
|
||||
return input;
|
||||
}
|
||||
|
||||
/** 줄 끝 조작 — ✕(빼기) · 고친 줄이면 「사용자」 + ↺. */
|
||||
export function rowControls(
|
||||
projectId: string,
|
||||
edit: RowEditDto,
|
||||
reload: () => void,
|
||||
editing: boolean,
|
||||
): HTMLElement {
|
||||
const box = el("span", "b09s-inline");
|
||||
if (editing && !edit.removed && !edit.added) {
|
||||
const remove = el("button", "b09s-undo", "✕");
|
||||
remove.type = "button";
|
||||
remove.title = L("B09_Sheet_RemoveRow");
|
||||
remove.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
save(projectId, [{ section: ROWS_SECTION, key: edit.key, value: { removed: true } }], reload);
|
||||
});
|
||||
box.append(remove);
|
||||
}
|
||||
if (edit.user) {
|
||||
box.append(
|
||||
userMark(() =>
|
||||
save(projectId, [{ section: ROWS_SECTION, key: edit.key, value: null }], reload),
|
||||
),
|
||||
);
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
/** D 기계 줄의 Q 식 칸 — 식을 글로 넣으면 서버가 셈 → 소수 2자리 확정 → 줄 수량 = 몫 ÷ Q. */
|
||||
export function qFormulaInput(
|
||||
projectId: string,
|
||||
edit: RowEditDto,
|
||||
reload: () => void,
|
||||
): HTMLElement {
|
||||
const box = el("div", "b09s-inline");
|
||||
const input = el("input", "b09s-edit-input b09s-edit-formula");
|
||||
input.type = "text";
|
||||
input.placeholder = `Q = ${edit.output ?? ""}`;
|
||||
input.value = edit.q_formula ?? "";
|
||||
input.addEventListener("click", (event) => event.stopPropagation());
|
||||
input.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") input.blur();
|
||||
});
|
||||
input.addEventListener("change", () => {
|
||||
const text = input.value.trim();
|
||||
const value = text ? { q_formula: text } : null;
|
||||
save(projectId, [{ section: Q_SECTION, key: edit.key, value }], reload);
|
||||
});
|
||||
box.append(el("span", "b09s-head", "Q"), input);
|
||||
if (edit.q_formula) {
|
||||
box.append(
|
||||
userMark(() => save(projectId, [{ section: Q_SECTION, key: edit.key, value: null }], reload)),
|
||||
);
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
/** 줄 더하기 — 단가표에서 이름으로 찾아 고르고 수량을 넣음. */
|
||||
export function addRowForm(
|
||||
projectId: string,
|
||||
code: string,
|
||||
nextKey: string,
|
||||
reload: () => void,
|
||||
): HTMLElement {
|
||||
const box = el("div", "b09s-bar");
|
||||
const search = el("input", "b09s-edit-input b09s-edit-formula");
|
||||
search.type = "text";
|
||||
search.placeholder = L("B09_Sheet_SearchTitle");
|
||||
const pick = el("select");
|
||||
const amount = el("input", "b09s-edit-input");
|
||||
amount.type = "text";
|
||||
amount.inputMode = "decimal";
|
||||
amount.placeholder = L("B09_Sheet_Col_Quantity");
|
||||
const add = el("button", "b09s-undo", L("B09_Sheet_AddRow"));
|
||||
add.type = "button";
|
||||
let timer = 0;
|
||||
search.addEventListener("input", () => {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => {
|
||||
const query = search.value.trim();
|
||||
if (!query) return;
|
||||
void fetch(
|
||||
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/edits/search?q=${encodeURIComponent(query)}&parent=${encodeURIComponent(code)}`,
|
||||
{ credentials: "include" },
|
||||
)
|
||||
.then((response) => response.json() as Promise<{ rows: SearchRowDto[] }>)
|
||||
.then((data) => {
|
||||
pick.replaceChildren(
|
||||
...data.rows.map((row) => {
|
||||
const option = el(
|
||||
"option",
|
||||
"",
|
||||
`[${row.kind_label}] ${row.name} ${row.spec} (${row.unit})`.trim(),
|
||||
);
|
||||
option.value = row.code;
|
||||
return option;
|
||||
}),
|
||||
);
|
||||
})
|
||||
.catch(() => pick.replaceChildren());
|
||||
}, 250);
|
||||
});
|
||||
add.addEventListener("click", () => {
|
||||
if (!pick.value || !amount.value.trim()) return;
|
||||
save(
|
||||
projectId,
|
||||
[
|
||||
{
|
||||
section: ROWS_SECTION,
|
||||
key: nextKey,
|
||||
value: { ref: pick.value, quantity: amount.value.trim() },
|
||||
},
|
||||
],
|
||||
reload,
|
||||
);
|
||||
});
|
||||
box.append(el("span", "b09s-head", L("B09_Sheet_AddRow")), search, pick, amount, add);
|
||||
return box;
|
||||
}
|
||||
@@ -158,6 +158,21 @@ export function unconfirmedBadge(count: number): HTMLElement {
|
||||
return el("span", "b09s-badge", `${L("B09_Sheet_Unconfirmed")} ${count}${L("B09_Sheet_Count")}`);
|
||||
}
|
||||
|
||||
/** 사용자가 고친 칸 표시 + ↺(지우면 계산값으로) — B08 집계표와 같은 말·같은 꼴. */
|
||||
export function userMark(onUndo: () => void): HTMLElement {
|
||||
const box = el("span", "b09s-user");
|
||||
box.append(el("span", "b09s-badge b09s-badge--user", L("B09_Sheet_User")));
|
||||
const undo = el("button", "b09s-undo", "↺");
|
||||
undo.type = "button";
|
||||
undo.title = L("B09_Sheet_Undo");
|
||||
undo.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
onUndo();
|
||||
});
|
||||
box.append(undo);
|
||||
return box;
|
||||
}
|
||||
|
||||
export function hint(text: string, warn = false): HTMLElement {
|
||||
return el("div", warn ? "b09s-hint b09s-hint--warn" : "b09s-hint", text);
|
||||
}
|
||||
@@ -195,6 +210,12 @@ export function injectSheetStyles(): void {
|
||||
.b09s-title { font-weight:700; font-size:14px; }
|
||||
.b09s-formula { white-space:pre-wrap; font-size:12px; color:var(--ui-text, #1f2430); }
|
||||
.b09s-head { font-weight:600; margin-top:6px; }
|
||||
.b09s-table tr.is-removed td { color:var(--ui-text-muted, #5f6673); text-decoration:line-through; }
|
||||
.b09s-edit-input { width:72px; font-size:12px; padding:1px 4px; }
|
||||
.b09s-edit-formula { width:220px; }
|
||||
.b09s-table tr.is-user td:first-child { box-shadow:inset 3px 0 0 var(--color-accent, #6c8ebf); }
|
||||
.b09s-badge--user { border-color:var(--color-accent, #6c8ebf); color:var(--color-accent, #6c8ebf); }
|
||||
.b09s-undo { border:1px solid var(--ui-border, #d0d4dc); background:none; cursor:pointer; padding:0 4px; margin-left:4px; font-size:12px; }
|
||||
.b09s-info td { text-align:right; font-variant-numeric:tabular-nums; }
|
||||
.b09s-info td.b09s-left, .b09s-info th.b09s-left { text-align:left; white-space:normal; }
|
||||
.b09s-group { display:flex; flex-direction:column; gap:4px; border-top:1px solid var(--ui-border, #d0d4dc); padding-top:6px; }
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import type { ProvenancePayload } from "@ui/ui_template_provenance";
|
||||
import type { RowEditDto } from "./B09_Estimation_UI_DetailEdit";
|
||||
|
||||
export interface BillNoteDto {
|
||||
column: string;
|
||||
@@ -152,6 +153,8 @@ export interface DetailRowDto {
|
||||
expense: string;
|
||||
total: string;
|
||||
note: string;
|
||||
/** 고친 값 표시(편집 문으로 받을 때만) — 2차 편집. */
|
||||
edit?: RowEditDto;
|
||||
}
|
||||
|
||||
export interface DetailDto {
|
||||
@@ -167,6 +170,9 @@ export interface DetailDto {
|
||||
rows: DetailRowDto[];
|
||||
unattached_note: string;
|
||||
known_gap_note: string;
|
||||
/** 구성행·Q 식을 고칠 수 있는 본표(B·D)인가 · 줄 더하기에 쓸 다음 칸 이름. */
|
||||
editable?: boolean;
|
||||
next_add_key?: string;
|
||||
}
|
||||
|
||||
const bills = new Map<string, Promise<BillDto>>();
|
||||
@@ -180,6 +186,39 @@ async function getJson<T>(path: string): Promise<T> {
|
||||
return body as T;
|
||||
}
|
||||
|
||||
/** 사용자가 고친 값 한 벌(프로젝트 단위 · 서버 `estimation.edits`). 구획 → {칸: 값}. */
|
||||
export type EditsDto = Record<string, Record<string, unknown>>;
|
||||
|
||||
export interface EditChange {
|
||||
section: string;
|
||||
key: string;
|
||||
/** `null` = 그 칸을 지움(↺ 계산값으로 돌아감). */
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
export async function loadEdits(
|
||||
projectId: string,
|
||||
): Promise<{ edits: EditsDto; skipped: string[] }> {
|
||||
return getJson(`/projects/${encodeURIComponent(projectId)}/estimation/edits`);
|
||||
}
|
||||
|
||||
/** 고친 값 저장 — 금액은 서버가 다시 계산하므로 내역 한 벌도 비움. 받지 못하면 까닭을 던짐. */
|
||||
export async function saveEdits(projectId: string, changes: EditChange[]): Promise<EditsDto> {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/edits`,
|
||||
{
|
||||
method: "PUT",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ changes }),
|
||||
},
|
||||
);
|
||||
const body = (await response.json().catch(() => ({}))) as { message?: string; edits?: EditsDto };
|
||||
if (!response.ok) throw new Error(body.message || String(response.status));
|
||||
forgetBill(projectId);
|
||||
return body.edits ?? {};
|
||||
}
|
||||
|
||||
/** 산출 조건을 저장한 뒤 — 단가가 다시 서므로 다음에 고르는 탭이 새로 받게 비움. */
|
||||
export function forgetBill(projectId: string): void {
|
||||
bills.delete(projectId);
|
||||
@@ -227,7 +266,8 @@ export function loadPriceCompare(projectId: string): Promise<PriceCompareDto> {
|
||||
);
|
||||
}
|
||||
|
||||
/** 호표 본표 — 일위대가(B)·시간당 중기(X)는 `unit-prices`, 단가산출(D)은 `price-basis`. */
|
||||
/** 호표 본표 — 일위대가(B)·시간당 중기(X)는 `unit-prices`, 단가산출(D)은 `price-basis`.
|
||||
* ⚠ 2차 편집 문(`edits/sheet`)이 서면 B·D 는 그리로 바꿈 — 그때까지 편집 칸은 잠자 있음(`editable` 없음). */
|
||||
export function loadDetail(projectId: string, code: string): Promise<DetailDto> {
|
||||
const kind = code.startsWith("D-") ? "price-basis" : "unit-prices";
|
||||
return getJson<DetailDto>(
|
||||
|
||||
@@ -1,26 +1,39 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_UI_Tab_PriceCompare.ts
|
||||
* B09 자재단가대비표 탭 — 원천 슬롯 여섯 · 채택 · 최소단가 (실무 `자재단가대비표` · STmate `wM_Boxa` 보기)
|
||||
* B09 자재단가대비표 탭 — 원천 슬롯 여섯 · 채택 · 최소단가 (실무 `자재단가대비표` · STmate `wM_Boxa`)
|
||||
*
|
||||
* - 칸: 호표 · 명칭 · 규격 · 단위 · 슬롯 1~5(단가 · 페이지) · 적용 단가(6번) · 비고.
|
||||
* - 칸: 호표 · 명칭 · 규격 · 단위 · 슬롯 1~6(단가 · 페이지) · 채택 · 비고.
|
||||
* - 채택 슬롯 = 굵게·색 · 최소단가 = 「최소」 표시(서버 `min_slot`). 값 없는 슬롯은 빈칸(0 아님).
|
||||
* - 채택 바꾸기(「변동없음 / 1 단가~5 단가 / 최소단가」 일괄)는 2차 — 저장 모양 판정 뒤.
|
||||
* - 2차(PLAN 12장 · 프로젝트 단위) — 줄마다 채택 슬롯을 고르거나 「변동없음 / 1~5 단가 / 최소단가」로 일괄.
|
||||
* 고친 줄 = 「사용자」 표시 + ↺(지우면 계산값으로). 값 없는 슬롯은 서버가 받지 않음.
|
||||
* - ⚠ 저장은 고른 슬롯 번호만 — 금액은 서버가 다시 셈(다른 탭은 내역 한 벌을 새로 받음).
|
||||
* ========================================================================== */
|
||||
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types";
|
||||
import { L, el, hint, numberCell, sheetTable, won } from "./B09_Estimation_UI_Sheet";
|
||||
import { loadPriceCompare, type PriceCompareDto } from "./B09_Estimation_UI_Store";
|
||||
import { L, el, hint, numberCell, sheetTable, userMark, won } from "./B09_Estimation_UI_Sheet";
|
||||
import {
|
||||
loadEdits,
|
||||
loadPriceCompare,
|
||||
saveEdits,
|
||||
type EditChange,
|
||||
type EditsDto,
|
||||
type PriceCompareDto,
|
||||
} from "./B09_Estimation_UI_Store";
|
||||
|
||||
const SECTION = "adopted_slots";
|
||||
|
||||
function head(slotNames: string[]): HTMLElement {
|
||||
const thead = el("thead");
|
||||
const top = el("tr");
|
||||
const bottom = el("tr");
|
||||
for (const label of [
|
||||
const fixed = [
|
||||
L("B09_Sheet_Col_Sheet"),
|
||||
L("B09_Sheet_Col_Name"),
|
||||
L("B09_Sheet_Col_Spec"),
|
||||
L("B09_Sheet_Col_Unit"),
|
||||
]) {
|
||||
];
|
||||
for (const label of fixed) {
|
||||
const th = el("th", "", label);
|
||||
th.rowSpan = 2;
|
||||
top.append(th);
|
||||
@@ -34,18 +47,76 @@ function head(slotNames: string[]): HTMLElement {
|
||||
el("th", "", L("B09_Sheet_Col_Page")),
|
||||
);
|
||||
});
|
||||
const note = el("th", "", L("B09_Sheet_Col_Note"));
|
||||
note.rowSpan = 2;
|
||||
top.append(note);
|
||||
for (const label of [L("B09_Sheet_Adopt"), L("B09_Sheet_Col_Note")]) {
|
||||
const th = el("th", "", label);
|
||||
th.rowSpan = 2;
|
||||
top.append(th);
|
||||
}
|
||||
thead.append(top, bottom);
|
||||
return thead;
|
||||
}
|
||||
|
||||
function draw(ctx: B09TabContext, data: PriceCompareDto): void {
|
||||
type Row = PriceCompareDto["material_comparison"]["rows"][number];
|
||||
|
||||
function adoptPicker(row: Row, onPick: (slot: number) => void): HTMLSelectElement {
|
||||
const select = el("select");
|
||||
row.slots.forEach((slot, index) => {
|
||||
if (slot.price_krw === null) return; // 값 없는 원천은 고를 수 없음
|
||||
const option = el("option", "", `${index + 1} ${won(slot.price_krw)}`);
|
||||
option.value = String(index + 1);
|
||||
option.selected = index + 1 === row.adopted_slot;
|
||||
select.append(option);
|
||||
});
|
||||
select.addEventListener("change", () => onPick(Number(select.value)));
|
||||
return select;
|
||||
}
|
||||
|
||||
/** 일괄 — 「변동없음 / 1~5 단가 / 최소단가」. 그 슬롯에 값이 있는 줄만 고침(STmate `wM_Boxa`). */
|
||||
function bulkChanges(rows: Row[], choice: string): EditChange[] {
|
||||
if (choice === "none") return [];
|
||||
return rows.flatMap((row) => {
|
||||
const slot = choice === "min" ? row.min_slot : Number(choice);
|
||||
if (!slot || row.slots[slot - 1]?.price_krw === null) return [];
|
||||
return [{ section: SECTION, key: row.code, value: slot }];
|
||||
});
|
||||
}
|
||||
|
||||
function draw(ctx: B09TabContext, projectId: string, data: PriceCompareDto, edits: EditsDto): void {
|
||||
const table = data.material_comparison;
|
||||
const userSlots = edits[SECTION] ?? {};
|
||||
const save = (changes: EditChange[]): void => {
|
||||
if (changes.length === 0) return;
|
||||
void saveEdits(projectId, changes)
|
||||
.then(() => {
|
||||
showToast(L("B09_Sheet_Saved"), "success");
|
||||
ctx.open("price_compare");
|
||||
})
|
||||
.catch((error: Error) => showToast(`${L("B09_Sheet_SaveFailed")} ${error.message}`, "error"));
|
||||
};
|
||||
|
||||
const bar = el("div", "b09s-bar");
|
||||
const bulk = el("select");
|
||||
const choices: Array<[string, string]> = [
|
||||
["none", L("B09_Sheet_Bulk_None")],
|
||||
...[1, 2, 3, 4, 5].map((n): [string, string] => [
|
||||
String(n),
|
||||
`${n} ${table.slot_names[n - 1] ?? ""}`,
|
||||
]),
|
||||
["min", L("B09_Sheet_Bulk_Min")],
|
||||
];
|
||||
for (const [value, label] of choices) {
|
||||
const option = el("option", "", label);
|
||||
option.value = value;
|
||||
bulk.append(option);
|
||||
}
|
||||
const apply = el("button", "b09s-undo", L("B09_Sheet_Apply"));
|
||||
apply.type = "button";
|
||||
apply.addEventListener("click", () => save(bulkChanges(table.rows, bulk.value)));
|
||||
bar.append(el("span", "b09s-head", L("B09_Sheet_Bulk")), bulk, apply);
|
||||
|
||||
const { wrap, tbody } = sheetTable(head(table.slot_names));
|
||||
table.rows.forEach((row, index) => {
|
||||
const tr = el("tr");
|
||||
const tr = el("tr", row.code in userSlots ? "is-user" : "");
|
||||
tr.append(
|
||||
el("td", "", String(index + 1)),
|
||||
el("td", "", row.name),
|
||||
@@ -58,10 +129,17 @@ function draw(ctx: B09TabContext, data: PriceCompareDto): void {
|
||||
if (row.min_slot === slotIndex + 1) price.append(el("span", "b09s-min", L("B09_Sheet_Min")));
|
||||
tr.append(price, el("td", "", slot.source_note));
|
||||
});
|
||||
tr.append(el("td", "b09s-note", row.note));
|
||||
const adopt = el("td");
|
||||
adopt.append(
|
||||
adoptPicker(row, (slot) => save([{ section: SECTION, key: row.code, value: slot }])),
|
||||
);
|
||||
if (row.code in userSlots) {
|
||||
adopt.append(userMark(() => save([{ section: SECTION, key: row.code, value: null }])));
|
||||
}
|
||||
tr.append(adopt, el("td", "b09s-note", row.note));
|
||||
tbody.append(tr);
|
||||
});
|
||||
ctx.body.append(el("div", "b09s-title", L("B09_Sheet_Tab_PriceCompare")), wrap);
|
||||
ctx.body.append(el("div", "b09s-title", L("B09_Sheet_Tab_PriceCompare")), bar, wrap);
|
||||
if (table.rows.length === 0) ctx.body.append(hint(L("B09_Sheet_EmptyGroup")));
|
||||
for (const note of table.notes) ctx.body.append(hint(note));
|
||||
}
|
||||
@@ -74,11 +152,15 @@ export const priceCompareTab: B09Tab = {
|
||||
ctx.body.append(hint(L("B09_Sheet_NoProject")));
|
||||
return;
|
||||
}
|
||||
const projectId = ctx.projectId;
|
||||
ctx.body.append(hint(L("B09_Sheet_Loading")));
|
||||
loadPriceCompare(ctx.projectId)
|
||||
.then((data) => {
|
||||
Promise.all([loadPriceCompare(projectId), loadEdits(projectId)])
|
||||
.then(([data, stored]) => {
|
||||
ctx.body.replaceChildren();
|
||||
draw(ctx, data);
|
||||
draw(ctx, projectId, data, stored.edits);
|
||||
for (const line of stored.skipped) {
|
||||
ctx.body.append(hint(`${L("B09_Sheet_EditSkipped")} ${line}`, true));
|
||||
}
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
ctx.body.replaceChildren(hint(`${L("B09_Sheet_LoadFailed")} ${error.message}`, true));
|
||||
|
||||
@@ -66,6 +66,7 @@ from B08_Quantity.B08_Quantity_Router_Material import router as b08_material_rou
|
||||
from B08_Quantity.B08_Quantity_Router_StructureSheet import router as b08_structure_sheet_router
|
||||
from B09_Estimation.B09_Estimation_Router import router as b09_estimation_router
|
||||
from B09_Estimation.B09_Estimation_Router_CostSheet import router as b09_cost_sheet_router
|
||||
from B09_Estimation.B09_Estimation_Router_Edits import router as b09_edits_router
|
||||
from common_util.common_util_audit import note_api_call, record_call_burst
|
||||
from common_util.common_util_auth import (
|
||||
require_company,
|
||||
@@ -638,6 +639,7 @@ app.include_router(b08_material_router, dependencies=protected_with_company)
|
||||
app.include_router(b08_structure_sheet_router, dependencies=protected_with_company)
|
||||
app.include_router(b09_estimation_router, dependencies=protected_with_company)
|
||||
app.include_router(b09_cost_sheet_router, dependencies=protected_with_company)
|
||||
app.include_router(b09_edits_router, dependencies=protected_with_company)
|
||||
# 개발 전용 잠금 해제 — 다른 라우터와 **같은 보호**를 받는다(로그인·회사·프로젝트 접근).
|
||||
# 그 위에 서버가 환경까지 한 번 더 본다.
|
||||
app.include_router(dev_unlock_router, dependencies=protected_with_company)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""B09 사용자가 고친 값 — 프로젝트 단위 저장·얹기·되돌리기 (PLAN 12장 2차 · 2026-09-14 브레인 판정).
|
||||
|
||||
겨누는 것
|
||||
① 고친 값은 **복사본에** 얹힘 — 기본 벌(캐시)은 그대로(골든셋·다른 프로젝트가 안 흔들림)
|
||||
② 자재단가대비표 채택 슬롯 — 값 있는 슬롯만 받음 · 채택하면 금액이 그 슬롯 값으로 섬
|
||||
③ 지우면(↺) 계산값으로 돌아감 · 고친 값이 없으면 캐시 키가 빈 글
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from B09_Estimation.B09_Estimation_Edits import (
|
||||
EditError,
|
||||
apply_edits,
|
||||
edits_key,
|
||||
merge_changes,
|
||||
)
|
||||
from B09_Estimation.B09_Estimation_PriceBook import PriceBook, PriceDetail, PriceKind, PriceTitle
|
||||
from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild
|
||||
|
||||
|
||||
def _build() -> UnitPriceBuild:
|
||||
book = PriceBook()
|
||||
slots = [Decimal(100), None, Decimal(90), None, None, Decimal(120)]
|
||||
book.add_title(PriceTitle("M-1", PriceKind.MATERIAL, "시멘트", slots=slots))
|
||||
book.add_title(PriceTitle("B-1", PriceKind.UNIT_PRICE, "타설"))
|
||||
book.add_detail(PriceDetail("B-1", "M-1", Decimal(2)))
|
||||
return UnitPriceBuild(book=book)
|
||||
|
||||
|
||||
def test_채택_슬롯을_바꾸면_복사본에서만_금액이_바뀐다() -> None:
|
||||
base = _build()
|
||||
edits = merge_changes({}, base, [{"section": "adopted_slots", "key": "M-1", "value": "3"}])
|
||||
assert edits == {"adopted_slots": {"M-1": 3}}
|
||||
edited = apply_edits(base, edits)
|
||||
assert edited.book.resolve("B-1").material == Decimal(180) # 90 × 2
|
||||
assert base.book.resolve("B-1").material == Decimal(240) # 기본 벌은 6번 120 그대로
|
||||
assert edited.edit_skipped == []
|
||||
|
||||
|
||||
def test_값_없는_슬롯은_받지_않는다() -> None:
|
||||
with pytest.raises(EditError):
|
||||
merge_changes({}, _build(), [{"section": "adopted_slots", "key": "M-1", "value": 2}])
|
||||
with pytest.raises(EditError):
|
||||
merge_changes({}, _build(), [{"section": "adopted_slots", "key": "B-1", "value": 1}])
|
||||
|
||||
|
||||
def test_지우면_계산값으로_돌아가고_캐시_키가_빈다() -> None:
|
||||
base = _build()
|
||||
edits = merge_changes(
|
||||
{"adopted_slots": {"M-1": 1}},
|
||||
base,
|
||||
[{"section": "adopted_slots", "key": "M-1", "value": None}],
|
||||
)
|
||||
assert edits == {} and edits_key(edits) == ""
|
||||
assert edits_key({"adopted_slots": {"M-1": 1}}) != ""
|
||||
|
||||
|
||||
def test_단가표에서_사라진_코드는_버리지_않고_남긴다() -> None:
|
||||
edited = apply_edits(_build(), {"adopted_slots": {"M-없음": 1, "M-1": 2}})
|
||||
assert len(edited.edit_skipped) == 2
|
||||
assert edited.book.titles["M-1"].adopted_slot == 6
|
||||
@@ -0,0 +1,24 @@
|
||||
"""B09 사용자 식 셈 — Q 식 편집 칸이 받는 식(PLAN 12장 2차 ③ · STmate `wM_Edit_San` 함수)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from B09_Estimation.B09_Estimation_Expression import ExpressionError, evaluate
|
||||
|
||||
|
||||
def test_실무_Q_식을_그대로_센다() -> None:
|
||||
# 18번 §2.2 — 3600 × q 0.2 × K 0.7 × f 0.85 × E 0.55 ÷ Cm 15 = 15.708
|
||||
assert evaluate("3600*0.2*0.7*ROUND(1/1.175,2)*0.55/15") == Decimal("15.708")
|
||||
# 울진 대흥 제15호표 되메우기 Q 68.04
|
||||
assert evaluate("ROUND(3600*0.7*0.9*(1/1.25)*0.75/20, 2)") == Decimal("68.04")
|
||||
assert evaluate("INT(7.9) + SQRT(16) - ROUNDDOWN(1.239, 2)") == Decimal("9.77")
|
||||
assert evaluate("60 ÷ 4 × 0.45 × 0.8") == Decimal("5.40")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", ["__import__('os')", "a+1", "1/0", "2**3", "", "ROUND(1)"])
|
||||
def test_모르는_글·0_나누기·빈_식은_거절한다(text: str) -> None:
|
||||
with pytest.raises(ExpressionError):
|
||||
evaluate(text)
|
||||
@@ -73,4 +73,25 @@ export const ui_locales_b3 = {
|
||||
B09_Sheet_Tab_PriceCompare: ["자재단가대비표", "Material price comparison"],
|
||||
B09_Sheet_Col_Page: ["페이지", "Page"],
|
||||
B09_Sheet_Min: ["최소", "min"],
|
||||
B09_Sheet_User: ["사용자", "User"],
|
||||
B09_Sheet_Undo: [
|
||||
"고친 값을 지우고 계산값으로 되돌림",
|
||||
"Clear the edit and return to the computed value",
|
||||
],
|
||||
B09_Sheet_Adopt: ["채택", "Adopt"],
|
||||
B09_Sheet_Bulk: ["일괄 채택", "Adopt for all"],
|
||||
B09_Sheet_Bulk_None: ["변동없음", "No change"],
|
||||
B09_Sheet_Bulk_Min: ["최소단가", "Lowest price"],
|
||||
B09_Sheet_Apply: ["적용", "Apply"],
|
||||
B09_Sheet_Saved: ["저장했습니다 — 금액을 다시 셈했습니다.", "Saved — prices recalculated."],
|
||||
B09_Sheet_SaveFailed: ["저장하지 못했습니다:", "Could not save:"],
|
||||
B09_Sheet_EditSkipped: ["얹지 못한 고친 값:", "Edits not applied:"],
|
||||
B09_Sheet_Edit: ["편집", "Edit"],
|
||||
B09_Sheet_EditDone: ["편집 끝", "Done"],
|
||||
B09_Sheet_RemoveRow: ["이 줄을 뺌(↺ 로 되돌림)", "Remove this row (undo with ↺)"],
|
||||
B09_Sheet_AddRow: ["줄 더하기", "Add row"],
|
||||
B09_Sheet_SearchTitle: [
|
||||
"노임·자재·중기·일위대가 이름으로 찾기",
|
||||
"Search labor, material, machine or unit price",
|
||||
],
|
||||
} as const;
|
||||
|
||||
Reference in New Issue
Block a user