Merge remote-tracking branch 'origin/dev' into main_laptop_1

This commit is contained in:
2026-09-14 16:49:33 +09:00
4 changed files with 150 additions and 0 deletions
@@ -176,6 +176,21 @@ def recipe_item(
"reason": "가산 행 — 수량 계산에 안 씀" if is_percent else NOTE_SOURCE_QTY, "reason": "가산 행 — 수량 계산에 안 씀" if is_percent else NOTE_SOURCE_QTY,
} }
) )
# ㉮ 일위대가 구성 — 일위대가로 가는 줄마다 한 줄(수량 = 그 원단위 줄). **공종 코드는 비움**:
# 원문 코드는 파일 카운터라 못 쓰고 이름으로 확정도 안 함(명세 2장) → 사용자가 고르개로 고름.
# 고르기 전엔 그 장 금액이 「미완 — 공종 코드 미정」(조용한 0 아님 · 2026-09-14 판정).
unit_rows = [
{
"seq": index,
"name": row["name"],
"spec": row["spec"],
"unit": row["unit"],
"from_row": row["seq"],
}
for index, row in enumerate(
(row for row in rows if row["destination"] == "unit_price"), start=1
)
]
notes = [NOTE_SOURCE_QTY] notes = [NOTE_SOURCE_QTY]
if percent: if percent:
notes.append(NOTE_PERCENT.format(count=len(percent), names=" · ".join(percent))) notes.append(NOTE_PERCENT.format(count=len(percent), names=" · ".join(percent)))
@@ -191,6 +206,11 @@ def recipe_item(
"vars": {}, "vars": {},
"tables": {}, "tables": {},
"rows": rows, "rows": rows,
"unit_price": {
"unit": hopyo["unit"],
"note": "STmate 호표 구성 — 줄마다 공종 코드를 고르개로 골라야 금액이 섬(원문 코드는 안 씀)",
"rows": unit_rows,
},
# 출처 — 원문 공사명·파일명이 들어감. 공유 만들 때 뺄지·가릴지 판정(PLAN 4장). # 출처 — 원문 공사명·파일명이 들어감. 공유 만들 때 뺄지·가릴지 판정(PLAN 4장).
"origin": { "origin": {
"kind": "stmate_xlsx", "kind": "stmate_xlsx",
@@ -136,6 +136,11 @@ export function unitPriceEditor(options: EditorOptions): HTMLElement {
find.addEventListener("click", () => { find.addEventListener("click", () => {
picking = index; picking = index;
render(); render();
// 코드가 아직 없는 줄은 그 줄 이름으로 **후보만** 띄움 — 고르는 것은 사용자(명세 2장 · 이름 자동 확정 금지).
if (!row.ref_code && !row.work_item_code && row.name) {
query.value = row.name;
void search();
}
picker.querySelector("input")?.focus(); picker.querySelector("input")?.focus();
}); });
code.append(" ", find); code.append(" ", find);
@@ -0,0 +1,121 @@
"""고정형 항목 사슬 ㉮ — 인계·내역으로 가는 문(2026-09-14 브레인 판정 「공종 코드가 없으면 어디에도 안 감」).
실측(936be972): 뽑은 고정형엔 일위대가 구성(`unit_price.rows`)이 없어 `priced_sheets` 가 그 장을 안 셈 →
구조물 줄 수량 0 · 내역 줄 사라짐 · 본체 −4,062,480. 고친 길: 일위대가로 가는 줄마다 구성 줄을 두되
**공종 코드는 비움** → 그 장은 호표 줄로 인계되고 금액은 「미완 — 공종 코드 미정」(조용한 0 아님) →
사용자가 고르개로 코드를 고르면 금액이 섬. 자원 축은 후보만(이름 자동 확정 금지 · 명세 2장).
"""
from __future__ import annotations
import sys
from decimal import Decimal
from pathlib import Path
from types import SimpleNamespace
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Engine_StmateRecipe import recipe_item # noqa: E402
from B08_Quantity.B08_Quantity_Engine_StructurePriceLink import ( # noqa: E402
price_sheets,
priced_sheets,
)
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import with_rows # noqa: E402
from B09_Estimation.B09_Estimation_PriceBook import Money3, PriceBookError # noqa: E402
HOPYO = {
"no": 6,
"source_code": "B00010",
"name": "기슭막이",
"spec": "H=2.0m",
"unit": "M",
"basis": [],
"contract_rows": 0,
"rows": [
{
"name": "깬잡석채집",
"spec": "L=45cm",
"amount": 2.09,
"unit": "M2",
"remark": "",
"source_code": "",
},
{
"name": "고임돌채집",
"spec": "기계",
"amount": 0.31,
"unit": "M3",
"remark": "",
"source_code": "",
},
],
}
class Book:
titles = {"B-FP-X#채집": ("깬잡석 채집", "", Money3(Decimal(0), Decimal(1000), Decimal(0)))}
def title(self, code: str) -> SimpleNamespace:
if code not in self.titles:
raise PriceBookError(f"단가표에 없는 코드입니다: {code}")
name, unit, _money = self.titles[code]
return SimpleNamespace(name=name, spec="", unit=unit)
def resolve(self, code: str) -> Money3:
return self.titles[code][2] if code in self.titles else self.title(code)
def _sheet(item: dict) -> dict:
"""`apply_templates` 가 낸 장 모양 — 고정형 줄 = 박힌 단위량."""
return {
"type_id": item["type_id"],
"title": "돌쌓기(찰) H=2.5",
"formula_sheet": {"vars": {}},
"billing_unit": "m",
"members": [{"structure_id": "s1", "billing_quantity": 10}],
"rows": [
{
"no": row["seq"],
"unit_amount": float(row["amount"]),
"unit": row["unit"],
"destination": row["destination"],
}
for row in item["rows"]
],
}
def test_고정형_장이_호표_줄로_인계되고_코드_없으면_미완() -> None:
item = {
**recipe_item(HOPYO, type_id="masonry_wet", file_name="a.xlsx", project="p"),
"code": "AX-ST-0000abcd",
}
entries = priced_sheets([_sheet(item)], {"masonry_wet": item})
assert [e["structure_ids"] for e in entries] == [["s1"]] # 전엔 빈 목록 — 인계에서 끊겼음
prices = price_sheets(entries, Book(), lambda code, value: None, [item])
(entry,) = prices.values()
assert entry["blocked"] == 2 and entry["total"] == 0
assert all("공종 코드 미정" in reason for reason in entry["reasons"])
def test_찾기는_코드_없는_줄_이름으로_후보만_띄운다() -> None:
ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet_UnitPriceEdit.ts").read_text(
encoding="utf-8"
)
assert "query.value = row.name" in ui and "!row.ref_code && !row.work_item_code" in ui
def test_사용자가_코드를_고르면_금액이_선다() -> None:
item = {
**recipe_item(HOPYO, type_id="masonry_wet", file_name="a.xlsx", project="p"),
"code": "AX-ST-0000abcd",
}
picked = [
dict(row, ref_code="B-FP-X#채집", unit="") for row in item["unit_price"]["rows"][:1]
]
template = with_rows(item, picked)
entries = priced_sheets([_sheet(item)], {"masonry_wet": template})
(entry,) = price_sheets(entries, Book(), lambda code, value: None, [template]).values()
assert entry["blocked"] == 0 and entry["total"] == Decimal("2090") # 2.09㎡ × 1,000원
@@ -122,6 +122,10 @@ def test_착공판_계약단가_줄은_안_뽑고_항목에_적는다(tmp_path:
assert "가산 행 1줄" in item["note"] and "공구손료" in item["note"] assert "가산 행 1줄" in item["note"] and "공구손료" in item["note"]
assert "계약단가·낙찰율 줄 1줄" in item["note"] and "현행 품셈 대조 전" in item["note"] assert "계약단가·낙찰율 줄 1줄" in item["note"] and "현행 품셈 대조 전" in item["note"]
assert item["item_kind"] == "fixed" and item["origin"]["project"] == "시험" assert item["item_kind"] == "fixed" and item["origin"]["project"] == "시험"
# ㉮ 일위대가 구성 — 일위대가로 가는 줄만(별산·가산 행 뺌) · 공종 코드는 비워 둠(사용자가 고름)
unit_rows = item["unit_price"]["rows"]
assert [(r["name"], r["from_row"]) for r in unit_rows] == [("메쌓기", 2)]
assert not any(r.get("work_item_code") or r.get("ref_code") for r in unit_rows)
def test_시트가_없으면_사유(tmp_path: Path) -> None: def test_시트가_없으면_사유(tmp_path: Path) -> None: