@@ -0,0 +1,249 @@
""" 704-4 규격 열 — 설계 고르기 = 열(summary_item `spec`) · 사본이 고른 열을 굳힘 ·
열 바꿈 뒤 B09 덮개 남음 · B07 한 장 판정 = 항목 + 열 · 열 지움 막음(쓰는 사본).
임시 저장소(실제 마스터 · storage 안 씀 · M01 읽기만).
"""
from __future__ import annotations
import json
import shutil
from pathlib import Path
from typing import Any
import pytest
from B07_DesignDetail . B07_DesignDetail_Router_Support_Structure import structure_sheets
from B09_Estimation . B09_Estimation_Calc_Labor import compute_labor
from B09_Estimation . B09_Estimation_Labor_Source import daega_inputs
from common_util . common_util_summary_item import parse_summary_item
from config import config_system
from M02_MasterTemplete import M02_Item_Snapshot as snap
from M02_MasterTemplete import M02_Labor_Spec as spec
from M02_MasterTemplete import M02_Template_Layers as layers
from resources . tester . helper_m02_master_sample import SAMPLE
MS = { " layer " : " system " , " owner " : None , " key " : " SS000022 " , " name " : " 돌쌓기 " }
SPEC = {
" 이름 " : " H " ,
" 열 " : [ " 1.5 " , " 2.5 " ] ,
" 기본 " : " 1.5 " ,
" 수량 " : { " GF000219 " : { " 1.5 " : 2 , " 2.5 " : 3 } } ,
}
LABOR = {
" 종류 " : " 일위대가 " ,
" 열 " : " ms " ,
" 단위 " : " m " ,
" 담은로직 " : [
{ " 로직 " : " GF000219 " , " 수량 " : { " 손 " : 2 } } , # 메쌓기 — 열마다 수량
{ " 로직 " : " GF000119 " , " 수량 " : { " 손 " : 0.5 } } , # 노체포설 — 공통
] ,
" 자재 " : [ ] ,
" 규격 " : SPEC ,
}
# ── summary_item spec ──────────────────────────────────────────────────────
def test_summary_item_spec_있음_없음 ( ) :
assert parse_summary_item ( { * * MS , " spec " : " 2.5 " } ) == { * * MS , " spec " : " 2.5 " }
assert parse_summary_item ( MS ) == MS # 옛 기록 = 칸 없음 그대로
with pytest . raises ( ValueError , match = " 모르는 칸 " ) :
parse_summary_item ( { * * MS , " 열 " : " 2.5 " } )
for bad in ( " " , " x " * 21 , 2.5 ) :
with pytest . raises ( ValueError , match = " spec " ) :
parse_summary_item ( { * * MS , " spec " : bad } )
# ── 굳히기 ─────────────────────────────────────────────────────────────────
def test_굳히기_고른_열_지운_열_옛_문서 ( ) :
got = spec . freeze ( { * * LABOR , " 계산값 " : { " x " : 1 } } , " 2.5 " )
assert got [ " 담은로직 " ] [ 0 ] [ " 수량 " ] == { " 손 " : 3 } and got [ " 담은로직 " ] [ 1 ] [ " 수량 " ] == { " 손 " : 0.5 }
assert got [ " 규격 " ] == { " 이름 " : " H " , " 열 " : [ " 2.5 " ] , " 기본 " : " 2.5 " , " 수량 " : { } }
assert " 계산값 " not in got
assert spec . freeze ( LABOR , " 지운열 " ) [ " 담은로직 " ] [ 0 ] [ " 수량 " ] == { " 손 " : 2 } # = 기본
old = { k : v for k , v in LABOR . items ( ) if k != " 규격 " }
assert spec . freeze ( old , " 2.5 " ) is old
# ── 임시 저장소 사본 ───────────────────────────────────────────────────────
@pytest.fixture
def world ( tmp_path : Path , monkeypatch ) - > dict [ str , Any ] :
system = tmp_path / " master "
system . mkdir ( )
shutil . copy2 ( SAMPLE / layers . COMBO_FILE , system / layers . COMBO_FILE )
for kind in ( " table " , " structure " , " area " ) :
shutil . copytree ( SAMPLE / kind , system / kind )
( system / " labor " ) . mkdir ( )
( system / " labor " / " ms.json " ) . write_text ( json . dumps ( LABOR ) , encoding = " utf-8 " )
monkeypatch . setattr ( layers , " SYSTEM_ROOT " , system )
storage = tmp_path / " storage "
monkeypatch . setattr ( config_system , " STORAGE_BASE_DIR " , str ( storage ) )
root = storage / " 3 " / " 12 " / " proj "
( root / " B05_Profile/route " ) . mkdir ( parents = True )
( root / " B04_PreProcess/drainage/edits " ) . mkdir ( parents = True )
return { " system " : system , " root " : root }
def _save ( root : Path , specs : list [ str | None ] ) - > dict [ str , list [ str ] ] :
rows = [
{
" structure_id " : f " st { n } " ,
" type_id " : " masonry_dry " ,
" placement " : " interval " ,
" chainage_m " : 85 + n * 100 ,
" start_m " : 80 + n * 100 ,
" end_m " : 90 + n * 100 ,
" options " : { " height_m " : 2.0 , " length_m " : 10 } ,
" summary_item " : { * * MS , * * ( { " spec " : s } if s else { } ) } ,
}
for n , s in enumerate ( specs )
]
( root / " B05_Profile/route/structures.json " ) . write_text (
json . dumps ( { " revision " : 1 , " structures " : rows } , ensure_ascii = False ) , encoding = " utf-8 "
)
( root / " B04_PreProcess/drainage/edits/pipe_points.json " ) . write_text (
' { " points " : []} ' , encoding = " utf-8 "
)
return snap . sync_snapshots ( root )
def _block ( root : Path , owner : str ) - > tuple [ dict , dict ] :
got = daega_inputs ( root )
return got , next ( b for b in got [ " blocks " ] if b [ " id " ] == got [ " owners " ] [ owner ] )
def test_사본이_고른_열을_굳힘_manifest_규격 ( world ) :
root = world [ " root " ]
assert _save ( root , [ " 2.5 " ] ) [ " 만듦 " ] == [ " st0 " ]
folder = snap . structures_dir ( root ) / " st0 "
doc = json . loads ( ( folder / " 일위대가.json " ) . read_text ( encoding = " utf-8 " ) ) [ " 줄 " ]
assert [ x [ " 수량 " ] for x in doc [ " 담은로직 " ] ] == [ { " 손 " : 3 } , { " 손 " : 0.5 } ]
manifest = json . loads ( ( folder / snap . MANIFEST_NAME ) . read_text ( encoding = " utf-8 " ) )
assert manifest [ " 규격 " ] == { " 이름 " : " H " , " 열 " : " 2.5 " } and manifest [ " 원본 " ] [ " spec " ] == " 2.5 "
assert _save ( root , [ " 2.5 " ] ) [ " 만듦 " ] == [ ] # 같은 항목 · 같은 열 = 그대로
def test_열_바꿈_뒤_덮개_남음 ( world ) :
root = world [ " root " ]
_save ( root , [ " 1.5 " ] )
_ , before = _block ( root , " st0 " )
cids = [ ln [ " cid " ] for ln in before [ " lines " ] ]
assert before [ " cover " ] == " S:st0 " and [ ln [ " qty " ] for ln in before [ " lines " ] ] == [ 2 , 0.5 ]
cover = { before [ " cover " ] : { cids [ 1 ] : { " qty " : 0.7 } } } # 공통 줄 품을 고침
got = _save ( root , [ " 2.5 " ] )
assert got [ " 지움 " ] == [ " st0 " ] and got [ " 만듦 " ] == [ " st0 " ] # 열 바꿈 = 새 사본
after_all , after = _block ( root , " st0 " )
assert after [ " cover " ] == before [ " cover " ] and [ ln [ " cid " ] for ln in after [ " lines " ] ] == cids
sheets = { " labor " : [ ] , " material " : [ ] , " machine " : [ ] , " gaps " : [ ] }
out = compute_labor ( after_all , sheets , { } , cover )
lines = next ( b for b in out [ " daega_blocks " ] if b [ " id " ] == after [ " id " ] ) [ " lines " ]
assert lines [ 0 ] [ " qty " ] == 3 # 새 열 수량
assert lines [ 1 ] [ " qty " ] == 0.7 and lines [ 1 ] [ " covered " ] == [ " qty " ] # 덮개 그대로
assert out [ " daega_orphans " ] == [ ]
def test_B07_한_장_판정_항목_더하기_열 ( world ) :
root = world [ " root " ]
_save ( root , [ " 1.5 " , " 1.5 " , " 2.5 " ] )
ids = [ s [ " id " ] for s in structure_sheets ( root ) if s [ " id " ] . startswith ( " structure_system " ) ]
assert len ( set ( ids ) ) == 2 # 같은 열 둘 = 한 장 · 다른 열 = 따로
def test_열_지움_쓰는_사본_찾기 ( world ) :
root = world [ " root " ]
_save ( root , [ " 2.5 " , None ] )
assert spec . users ( world [ " system " ] , { " SS000022 " } , { " 2.5 " } ) == [ " proj/st0 " ]
assert spec . users ( world [ " system " ] , { " SS000022 " } , { " 1.5 " } ) == [ ]
assert spec . users ( world [ " system " ] , { " SS000001 " } , { " 2.5 " } ) == [ ]
def test_쓰는_열_지움_409_안_쓰는_열_됨 ( world ) :
from M02_MasterTemplete import M02_MasterTemplete_Store as store
root = world [ " root " ]
_save ( root , [ " 2.5 " ] )
path = world [ " system " ] / " labor " / " ms.json "
with store . at ( world [ " system " ] ) :
with pytest . raises ( store . StoreError ) as got :
spec . guard ( path , { " 규격 " : { * * SPEC , " 열 " : [ " 1.5 " ] } } , " ms " )
assert got . value . status == 409 and " proj/st0 " in str ( got . value . detail )
spec . guard ( path , { " 규격 " : { * * SPEC , " 열 " : [ " 2.5 " , " 3.0 " ] } } , " ms " ) # 1.5 = 안 씀
spec . guard ( path , { " 규격 " : SPEC } , " ms " )
# ── 화면 자료(TS · Node) — 격자 규격 열 · 고르기 창 spec ─────────────────────
def test_격자_규격_열_수량_칸_열마다 ( ) :
from resources . tester import test_704_3_hopyo_grid as g
files = [ g . GRID , " M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts " ,
" M02_MasterTemplete/M02_MasterTemplete_Labor_Spec.ts " ,
" M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts " ] # fmt: skip
doc = { * * g . DOC , " 규격 " : { " 이름 " : " H " , " 열 " : [ " 1.5 " , " 2.0 " ] , " 기본 " : " 2.0 " ,
" 수량 " : { " GF1 " : { " 1.5 " : 1.5 , " 2.0 " : 2.09 } , " MT1 " : { " 1.5 " : 4 } } } } # fmt: skip
doc [ " 자재 " ] = [ { * * doc [ " 자재 " ] [ 0 ] , " 수량 " : { " 손 " : 5 } } , { * * doc [ " 자재 " ] [ 1 ] , " 수량 " : { " 손 " : 2 } } ]
out = g . _node ( files , (
f " \n const b = laborBlock( { json . dumps ( doc , ensure_ascii = False ) } , { json . dumps ( g . CALC , ensure_ascii = False ) } , new Map()); "
" \n const grid = hopyoGrid([b]); "
" \n console.log(JSON.stringify( { cols: grid.columns.map((c) => [c.key, c.label, c.group]), "
" rows: grid.rows, specs: b.specs })); "
) , g . STUBS ) # fmt: skip
cols = [ c for c in out [ " cols " ] if c [ 0 ] . startswith ( " qty " ) ]
assert cols == [ [ " qty_0 " , " 1.5 " , [ " H 열 " ] ] , [ " qty_1 " , " 2.0 (기본) " , [ " H 열 " ] ] ]
line = out [ " rows " ] [ 1 ] # 첫 구성 줄
assert line [ " qty_0 " ] == 1.5 and line [ " qty_1 " ] == 2.09
mt1 = next ( r for r in out [ " rows " ] if r [ " name " ] == " ㄴ 큰돌 " )
assert mt1 [ " qty_0 " ] == 4 and mt1 [ " qty_1 " ] == 5 # 2.0 빈 열 = 공통 수량
t15 , t20 = out [ " specs " ] [ " totals " ]
assert t20 == pytest . approx ( 112559.55 ) # 기본 열 = 금액 칸 합
assert t15 == pytest . approx ( 529950 * 1.5 / 10 + 1800 ) # 열 수량 · 별도계상 MT1 = 합 밖
def test_고르기_창_spec_돌려줌 ( ) :
import subprocess
data = Path ( __file__ ) . resolve ( ) . parents [ 2 ] / " ui_template/ui_template_item_picker_data.ts "
body = (
data . read_text ( " utf-8 " )
+ """
const row = { key: " SS000022 " , 열: " ms " , 경로: [ " 석축 " , " 메쌓기 " ],
labor: { 규격: { 이름: " H " , 기본: " 2.0 " , 열: [ { 이름: " 1.5 " , 합계: 1 }, { 이름: " 2.0 " , 합계: 2 }] } } };
const one = { ...row, labor: { 규격: { 이름: " H " , 기본: " 1.5 " , 열: [ { 이름: " 1.5 " , 합계: 1 }] } } };
const plain = { key: " SS000001 " , 열: " rv " };
const cur = { layer: " system " , owner: null, key: " SS000022 " , name: " x " , spec: " 1.5 " };
console.log(JSON.stringify([toSummary(row, " 1.5 " ), toSummary(one, " 1.5 " ), toSummary(plain, " 1.5 " ),
startSpec(row, cur), startSpec(row, null), startSpec(row, { ...cur, spec: " 지운 " }), startSpec(plain, cur)]));
"""
)
got = subprocess . run (
[ " node " , " --experimental-strip-types " , " --no-warnings " , " --input-type=module-typescript " ] ,
input = body , capture_output = True , text = True , encoding = " utf-8 " , timeout = 60 ,
) # fmt: skip
assert got . returncode == 0 , got . stderr
two , single , plain , * starts = json . loads ( got . stdout )
assert two == { " layer " : " system " , " owner " : None , " key " : " SS000022 " ,
" name " : " 석축 메쌓기 (H=1.5) " , " spec " : " 1.5 " } # fmt: skip
assert single [ " name " ] == " 석축 메쌓기 " and single [ " spec " ] == " 1.5 " # 열 하나 = 이름 그대로
assert " spec " not in plain # 규격 없는 옛 항목
assert starts == [ " 1.5 " , " 2.0 " , " 2.0 " , None ]
def test_B08_B09_금액_열_수량_곱하기_단가 ( world ) :
""" B08 = 사본 1 단위 값(사본 셈 반영합) = 층 문서 그 열 셈 · B09 = 대가 줄 금액 = 열 수량 × 단가. """
from M02_MasterTemplete import M02_Item_Prices as item_prices
from M02_MasterTemplete import M02_Labor_Doc as labor
root = world [ " root " ]
_save ( root , [ " 2.5 " ] )
folder = snap . structures_dir ( root ) / " st0 "
read = lambda name : json . loads ( ( folder / name ) . read_text ( encoding = " utf-8 " ) ) # noqa: E731
copy = item_prices . calc ( read ( " 일위대가.json " ) , read ( " 단가.json " ) , read ( " 입력값.json " ) , { } , None )
layer = labor . clean ( LABOR )
want = labor . settle ( labor . calc ( layer , { } ) , layer , { } , " 2.5 " ) [ " 반영합 " ]
assert copy [ " 반영합 " ] [ " 계 " ] == pytest . approx ( want [ " 계 " ] ) # B08 1 단위 값 = 2.5 열
first = labor . settle ( labor . calc ( layer , { } ) , layer , { } , " 1.5 " ) [ " 반영합 " ]
assert want [ " 계 " ] != pytest . approx ( first [ " 계 " ] )
got , block = _block ( root , " st0 " )
price = { ln [ " key " ] : { " labor " : 1000.0 , " material " : 0.0 , " expense " : 0.0 } for ln in block [ " lines " ] }
sheets = { " labor " : [ ] , " material " : [ ] , " machine " : [ ] , " gaps " : [ ] }
out = compute_labor ( got , sheets , price , { } )
lines = next ( b for b in out [ " daega_blocks " ] if b [ " id " ] == block [ " id " ] ) [ " lines " ]
assert [ ln [ " amounts " ] [ " labor " ] for ln in lines ] == [ 3000 , 500 ] # 3 × 1000 · 공통 0.5 × 1000