Merge remote-tracking branch 'origin/sub_desktop_1' into sub_laptop_6
This commit is contained in:
@@ -178,6 +178,8 @@ export const STATE_REGISTRY = {
|
||||
"b08-hydro": { bucket: "draft", scope: "project", version: 3 },
|
||||
/** B08 좌측 패널 옵션 `{one_pipe_length_m, soft_rock_pct}` — [저장] 때 M02 표 변수 · project_settings 로(122). */
|
||||
"b08-options": { bucket: "draft", scope: "project" },
|
||||
/** B09 원가 사용자 값 `{materials, estimate}`(고친 칸만) — [저장] 때 프로젝트 B09 폴더로(144). */
|
||||
"b09-estimation": { bucket: "draft", scope: "project" },
|
||||
/** 미저장 계획고 편집(▲▼ · 곡선반경) — 설계 가게 `alignmentDraft` 의 칸(계획서 24-2c).
|
||||
* 옛 키는 노선 번호만 붙은 `b05-profile-alignment-draft:{노선}` 이었다. */
|
||||
"alignment-draft": {
|
||||
|
||||
@@ -12,7 +12,9 @@ Q · L 출처 = 저장된 유토곡선 배분(`mass_haul.haul_plan` — B07 토
|
||||
(`free_haul` = 무대 · `dozer` = 불도우저 · `dump_truck` = 덤프트럭).
|
||||
|
||||
암 갈래 — 「연암」 · 「경암」 줄 = 암 전체 × 비율(좌측 패널 · 기본 90 : 10 · PLAN 122).
|
||||
풍화암 · 보통암 · 구조물 쪽 줄(su2 시트)은 값이 없어 비움(None).
|
||||
풍화암 · 보통암 · 표토제거 · 법면보호공 · 운반성토 줄은 값이 없어 비움(None) · 구조물 쪽 줄(su2 시트
|
||||
구조물터파기 · 토사다짐 · 되메우기 · 잔토)은 뺌 · 거창 계수(성토면다짐 0.8 · 초류종자 성토 0.5 ·
|
||||
지장목 0.8)는 근거가 없어 값 빈칸 + 「비율 미정」(PLAN 143-2).
|
||||
|
||||
짝 — `rock_split` · `split_rock_columns` ↔ `B08_Quantity_Options_Calc.ts` `rockSplit` ·
|
||||
`splitRockColumns` · `splitRockSummary`(거울 시험 `test_122_b08_options_mirror`).
|
||||
@@ -99,8 +101,8 @@ def summary_rows(
|
||||
r0 = excel_round
|
||||
free_q, dump_q, dozer_q = haul["free_haul"]["q"], haul["dump_truck"]["q"], haul["dozer"]["q"]
|
||||
diverted = t["diverted_m3"]
|
||||
tree = r0(t["tree_removal_fill_area_m2"] * 0.5 + t["tree_removal_cut_area_m2"], 2)
|
||||
m3 = "㎥"
|
||||
unsure = "비율 미정" # 거창 계수 자리 — 값을 확인한 뒤 다시 잇기(143-2)
|
||||
return [
|
||||
_row("흙깍기", "토사", "기계(굴삭기)", m3, r0(t["cut_soil_volume_m3"])), # 토적표!E137
|
||||
_row("흙깍기", "풍화암", "굴삭기 + 브레카", m3, None),
|
||||
@@ -121,26 +123,10 @@ def summary_rows(
|
||||
_row("토사운반", "", "19 TON B/D", m3, dozer_q), # 평균운반거리 불도우저 ΣQ
|
||||
_row("잔토처리", "", "백호우", m3, r0(t["balance_m3"])), # 토적표!T137
|
||||
_row("층따기", "", "백호우", m3, t["benching_area_m2"]), # 토적표!W137
|
||||
_row("성토면다짐", "", "", "㎡", r0(t["grading_fill_area_m2"] * 0.8), "성토면80%반영"),
|
||||
_row("성토면다짐", "", "", "㎡", None, unsure), # 거창 = 성토면 × 0.8
|
||||
_row("표토제거", "", "", "㎡", None),
|
||||
_row(
|
||||
"초류종자살포",
|
||||
"",
|
||||
"씨드스프레이",
|
||||
"㎡",
|
||||
r0(t["grading_fill_area_m2"] * 0.5 + t["grading_cut_area_m2"]),
|
||||
"성토면50%반영+절토면",
|
||||
),
|
||||
_row("초류종자살포", "", "씨드스프레이", "㎡", None, unsure), # 거창 = 성토 × 0.5 + 절토
|
||||
_row("법면보호공", "", "", "㎡", None),
|
||||
# 토적표!AI138 = ROUND(성토 × 0.5 + 절토, 2) → × 0.8
|
||||
_row("지장목제거", "", "", "㎡", r0(tree * 0.8), "80%반영"),
|
||||
_row("구조물터파기", "토사", "기계(굴삭기)", m3, None, "구조물 수량"),
|
||||
_row("구조물터파기", "풍화암", "굴삭기 + 브레카", m3, None),
|
||||
_row("구조물터파기", "연암", "굴삭기 + 브레카", m3, None, "구조물 수량"),
|
||||
_row("구조물터파기", "보통암", "굴삭기 + 브레카", m3, None),
|
||||
_row("구조물터파기", "경암", "발파 + 굴삭기", m3, None),
|
||||
_row("토사다짐", "", "", m3, None, "구조물 수량"),
|
||||
_row("되메우기", "", "", m3, None, "구조물 수량"),
|
||||
_row("잔토", "", "", m3, None, "현장내 성토(유용토)"),
|
||||
_row("지장목제거", "", "", "㎡", None, unsure), # 거창 = (성토 × 0.5 + 절토) × 0.8
|
||||
_row("운반성토", "", "BH+DT+BH", m3, None),
|
||||
]
|
||||
|
||||
@@ -102,8 +102,8 @@ def compute(inputs: dict[str, Any]) -> dict[str, Any]:
|
||||
props = section_props(inputs["section"], diameter_mm / 1000.0) if diameter_mm else {}
|
||||
elif sheet == "box":
|
||||
width, height = _num(inputs, "box_width_m"), _num(inputs, "box_height_m")
|
||||
ratio = _num(inputs, "depth_ratio") or SHEET_DEFAULTS["box"]["depth_ratio"]
|
||||
if width and height:
|
||||
ratio = _num(inputs, "depth_ratio") # 빈칸 = 수심 · 단면 · 유출량 멈춤(143-2)
|
||||
if width and height and ratio:
|
||||
depth = ratio * height
|
||||
props = rect_props(width, depth)
|
||||
else:
|
||||
|
||||
@@ -56,10 +56,11 @@ SHEETS = {
|
||||
"ford_pavement": ("ford", "물넘이포장"),
|
||||
}
|
||||
|
||||
#: 장별 처음 값 — 박스 수심비 0.8H · n 0.013(울진 산출서 · 조사 69-7) · 내공 · 경사는 빈칸
|
||||
#: 장별 처음 값 — 박스 n 0.013(KDS 콘크리트관) · 수심비 · 내공 · 경사는 빈칸(수심비 0.8 = 울진 산출서 —
|
||||
#: 근거 없어 안 채움 · 빈칸이면 단면 계산 멈춤 · 143-2)
|
||||
#: 세월교 · 물넘이 월류 폭 10 m(사용자 10-7) · n · 경사 = B05 세월교 높이 계산과 같은 값
|
||||
SHEET_DEFAULTS: dict[str, dict[str, Any]] = {
|
||||
"box": {"n": 0.013, "depth_ratio": 0.8, "box_width_m": None, "box_height_m": None},
|
||||
"box": {"n": 0.013, "depth_ratio": None, "box_width_m": None, "box_height_m": None},
|
||||
"ford": {
|
||||
"n": FORD_MANNING_N,
|
||||
"overflow_width_m": 10.0,
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
· 길이 = 전 + 후 · 빈 높이 = 횡단도가 그리는 관경 최소 높이
|
||||
돌붙임 › 찰붙임 · 메붙임 ㎡ 도수로/돌붙임 붙임 형식 · 면적(빈칸이면 안 셈 · 98-3)
|
||||
관 공 › Φ{관경}m/m › 관매설 m B06 관 길이(1 m 올림 그대로)
|
||||
› 커플링 ea 올림(L ÷ 1본) − 1 · 1본 = 프로젝트 층 M02 구조물집계표 변수
|
||||
› 커플링 ea 올림(L ÷ 1본) − 1 · 1본 = 프로젝트 층 M02 구조물집계표 변수만
|
||||
관경 · 련 빈칸 = 「미입력」 열(임의 값으로 안 채움 · 143-2)
|
||||
› 관보호공 모양 실제로 선 집수정 모양마다 1 개소(98-2 `inlet_basin`)
|
||||
BOX암거 › {가로×세로} › 본체 m B06 새 키 `box_length_m`(두 끝 offset 차 · 120)
|
||||
› 날개벽 › 유입 · 유출 개소
|
||||
@@ -46,6 +47,7 @@ from B08_Quantity.B08_Quantity_Router_Hydro import _project_root
|
||||
from common_util.common_util_drainage_pipes import pipe_points_path_in, read_pipe_points_file
|
||||
from common_util.common_util_summary_item import GROUP_JOIN
|
||||
from config.config_db import get_db_pool
|
||||
from M02_MasterTemplete import M02_Item_Snapshot as snapshots
|
||||
from M02_MasterTemplete import M02_Table_Fill as fill
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
from M02_MasterTemplete.M02_Table_Fill_Spec import soil_tiers_from_designs
|
||||
@@ -85,6 +87,23 @@ def _num(value: Any) -> float | None:
|
||||
return None
|
||||
|
||||
|
||||
def _count(options: dict[str, Any], key: str) -> int | None:
|
||||
"""관경 · 련 수 — 빈칸이면 None(「미입력」 열 · 임의 값으로 안 채움 · 143-2)."""
|
||||
value = _num(options.get(key))
|
||||
return int(value) if value else None
|
||||
|
||||
|
||||
def _pipe_head(names: dict[str, str], diameter: int | None) -> list[str]:
|
||||
"""「관 공」 머리 — 관경 빈칸이면 「미입력」."""
|
||||
if diameter is None:
|
||||
return ["관 공", fill.EMPTY_TEXT]
|
||||
return ["관 공", f"Φ{names.get(f'pipe.diameter={diameter}', diameter)}m/m"]
|
||||
|
||||
|
||||
def _key(value: int | None) -> str:
|
||||
return fill.EMPTY_LABEL if value is None else str(value)
|
||||
|
||||
|
||||
def _combo(item: Any) -> str | None:
|
||||
"""집계 항목 → 조합 id(화면 `comboId` 와 같은 꼴) · 없으면 None."""
|
||||
if not isinstance(item, dict) or not item.get("key"):
|
||||
@@ -93,13 +112,10 @@ def _combo(item: Any) -> str | None:
|
||||
|
||||
|
||||
def one_pipe_length(project_root: str | Path) -> float | None:
|
||||
"""1본 길이(m) — 프로젝트 층 구조물집계표 변수 · 없으면 시스템 층 · 빈칸이면 None."""
|
||||
for folder in (layers.project_dir(project_root), layers.system_dir()):
|
||||
found = layers.read_template(folder, "table", TABLE_NAME)
|
||||
if found is not None:
|
||||
value = _num((found["문서"].get("변수") or {}).get(ONE_LENGTH_VAR))
|
||||
return value if value and value > 0 else None
|
||||
return None
|
||||
"""1본 길이(m) — 프로젝트 층 구조물집계표 변수만(시스템 층으로 안 채움 · 143-2) · 빈칸 None."""
|
||||
found = layers.read_template(layers.project_dir(project_root), "table", TABLE_NAME)
|
||||
value = _num((found["문서"].get("변수") or {}).get(ONE_LENGTH_VAR)) if found else None
|
||||
return value if value and value > 0 else None
|
||||
|
||||
|
||||
def couplings(length_m: float | None, one_m: float | None) -> int | None:
|
||||
@@ -159,6 +175,8 @@ class _Sheet:
|
||||
def __init__(self) -> None:
|
||||
self.columns: dict[str, dict[str, Any]] = {}
|
||||
self.rows: dict[int, dict[str, Any]] = {}
|
||||
self.owner = "" # 지금 셈하는 시설 — 사본 없어 안 센 칸의 시설을 모음(143-1)
|
||||
self.unlinked: set[str] = set()
|
||||
|
||||
def add(
|
||||
self,
|
||||
@@ -170,6 +188,9 @@ class _Sheet:
|
||||
value: float | None,
|
||||
combos: list[str | None],
|
||||
) -> None:
|
||||
if not any(combos): # 그 칸을 낸 조합 사본이 없음 — 안 셈(143-1)
|
||||
self.unlinked.add(self.owner)
|
||||
return
|
||||
cid = f"pipe:{key}"
|
||||
column = self.columns.setdefault(
|
||||
cid, {"id": cid, "group": group, "label": label, "combos": [], "_sort": sort}
|
||||
@@ -338,10 +359,11 @@ def _ford(
|
||||
combos: dict[str, str | None],
|
||||
names: dict[str, str],
|
||||
) -> None:
|
||||
"""세월교 — Φ관경×n련마다 월류 폭 m · 본체 개소."""
|
||||
diameter = int(_num(base["options"].get("pipe_diameter_mm")) or 1000)
|
||||
count = int(_num(base["options"].get("pipe_count")) or 1)
|
||||
spec = f"Φ{names.get(f'ford.diameter={diameter}', diameter)}×{count}련"
|
||||
"""세월교 — Φ관경×n련마다 월류 폭 m · 본체 개소(관경 · 련 빈칸 = 「미입력」)."""
|
||||
diameter = _count(base["options"], "pipe_diameter_mm")
|
||||
count = _count(base["options"], "pipe_count")
|
||||
size = fill.EMPTY_TEXT if diameter is None else names.get(f"ford.diameter={diameter}", diameter)
|
||||
spec = f"Φ{size}×{fill.EMPTY_TEXT if count is None else count}련"
|
||||
width_name = names.get("ford.width", "월류 폭").split(" (")[0]
|
||||
cells = (
|
||||
("width", width_name, "m", _num(base["options"].get("ford_width_m"))),
|
||||
@@ -350,8 +372,8 @@ def _ford(
|
||||
for index, (key, label, unit, value) in enumerate(cells):
|
||||
sheet.add(
|
||||
at,
|
||||
f"ford|{diameter}|{count}|{key}",
|
||||
(4, 0, diameter, count, index),
|
||||
f"ford|{_key(diameter)}|{_key(count)}|{key}",
|
||||
(4, 0, diameter or math.inf, count or math.inf, index),
|
||||
[names["종류"], spec, label],
|
||||
unit,
|
||||
value,
|
||||
@@ -367,35 +389,39 @@ def _ford_pipes(
|
||||
one_m: float | None,
|
||||
names: dict[str, str],
|
||||
pipes: list[dict[str, Any]],
|
||||
body: str | None,
|
||||
) -> None:
|
||||
"""세월교 관 몫 — 한 련 길이 × 련 수를 관매설 · 커플링 열에 더함(관 공과 같은 열 · 131).
|
||||
|
||||
`pipes` 에는 련마다 한 줄(좌측 패널 1본 길이로 커플링을 다시 셀 때 련 수만큼 셈).
|
||||
`pipes` 에는 련마다 한 줄(좌측 패널 1본 길이로 커플링을 다시 셀 때 련 수만큼 셈) ·
|
||||
조합 = 시설 본체 + 본체 잇기(`body` · 143-1).
|
||||
"""
|
||||
diameter = int(_num(base["options"].get("pipe_diameter_mm")) or 1000)
|
||||
count = int(_num(base["options"].get("pipe_count")) or 1)
|
||||
head = ["관 공", f"Φ{names.get(f'pipe.diameter={diameter}', diameter)}m/m"]
|
||||
combos = [_combo(base.get("summary_item"))]
|
||||
diameter = _count(base["options"], "pipe_diameter_mm")
|
||||
count = _count(base["options"], "pipe_count")
|
||||
head = _pipe_head(names, diameter)
|
||||
combos = [_combo(base.get("summary_item")), body]
|
||||
each = couplings(length, one_m)
|
||||
sheet.add(
|
||||
at,
|
||||
f"len|{diameter}",
|
||||
(2, diameter, 0),
|
||||
f"len|{_key(diameter)}",
|
||||
(2, diameter or math.inf, 0),
|
||||
[*head, "관매설"],
|
||||
"m",
|
||||
length * count if length else None,
|
||||
length * count if length and count else None,
|
||||
combos,
|
||||
)
|
||||
sheet.add(
|
||||
at,
|
||||
f"cp|{diameter}",
|
||||
(2, diameter, 1),
|
||||
f"cp|{_key(diameter)}",
|
||||
(2, diameter or math.inf, 1),
|
||||
[*head, "커플링"],
|
||||
"ea",
|
||||
None if each is None else each * count,
|
||||
None if each is None or count is None else each * count,
|
||||
combos,
|
||||
)
|
||||
pipes += [{"chainage_m": at, "column": f"pipe:cp|{diameter}", "length_m": length}] * count
|
||||
if any(combos):
|
||||
column = f"pipe:cp|{_key(diameter)}"
|
||||
pipes += [{"chainage_m": at, "column": column, "length_m": length}] * (count or 0)
|
||||
|
||||
|
||||
def _pavement(
|
||||
@@ -467,12 +493,15 @@ def pipe_summary(
|
||||
names: dict[str, str],
|
||||
one_m: float | None,
|
||||
kinds: dict[str, Any] | None = None,
|
||||
linked: dict[str, dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""횡단배수 한 곳마다 컨테이너 값 → 집계표 열 · 줄 · 계(저장 안 함).
|
||||
|
||||
`names` = 관 정의 이름(공용 흙막이 · 돌붙임 머리) · `kinds` = M02 정의(비면 읽음).
|
||||
`names` = 관 정의 이름(공용 흙막이 · 돌붙임 머리) · `kinds` = M02 정의(비면 읽음) ·
|
||||
`linked` = 연결된 사본(비면 읽음) — 칸마다 그 칸을 낸 컨테이너 사본이 있을 때만 셈(143-1).
|
||||
"""
|
||||
kinds = kinds or definition.read()
|
||||
linked = snapshots.linked_snapshots(project_root) if linked is None else linked
|
||||
items = fill.collect_items(
|
||||
project_root,
|
||||
fill.pipe_lengths_from_designs(designs),
|
||||
@@ -488,15 +517,19 @@ def pipe_summary(
|
||||
box_lengths = _station_values(designs, "box_length_m")
|
||||
ford_lengths = _station_values(designs, "ford_pipe_length_m")
|
||||
by_owner: dict[tuple[str, str], dict[str, Any]] = {}
|
||||
others: dict[str, bool] = {} # 집계표 밖 구조물 — 사본 하나라도 있나(조합 열은 화면이 그림)
|
||||
for item in items:
|
||||
if item["type_id"] not in DRAIN_TYPES:
|
||||
continue
|
||||
owner, _, link = item["복사본"].partition(GROUP_JOIN)
|
||||
if item["type_id"] not in DRAIN_TYPES:
|
||||
others[owner] = others.get(owner, False) or item["복사본"] in linked
|
||||
continue
|
||||
entry = by_owner.setdefault((item["type_id"], owner), {"links": {}})
|
||||
# 거름 한 곳 — 사본 없는 잇기는 조합 없음으로(그 칸만 안 셈 · 143-1)
|
||||
combo = _combo(item.get("summary_item")) if item["복사본"] in linked else None
|
||||
if link:
|
||||
entry["links"][link] = _combo(item.get("summary_item"))
|
||||
entry["links"][link] = combo
|
||||
else:
|
||||
entry["base"] = item
|
||||
entry["base"] = {**item, "summary_item": item.get("summary_item") if combo else None}
|
||||
|
||||
sheet = _Sheet()
|
||||
pipes: list[dict[str, Any]] = []
|
||||
@@ -506,28 +539,36 @@ def pipe_summary(
|
||||
if base is None:
|
||||
continue
|
||||
links = entry["links"]
|
||||
sheet.owner = owner
|
||||
point = raw.get((type_id, owner))
|
||||
options = dict(point.options or {}) if point else {}
|
||||
at = float(base["chainage_m"])
|
||||
if type_id != "pipe":
|
||||
box_length = fill._nearest(box_lengths, at) if type_id == "box_culvert" else None
|
||||
no_box += type_id == "box_culvert" and not box_length
|
||||
group = (point.group_items if point else None) or {}
|
||||
group = { # 세월교 날 잇기(저장된 잇기)도 사본 있는 것만
|
||||
gid: chosen
|
||||
for gid, chosen in ((point.group_items if point else None) or {}).items()
|
||||
if f"{owner}{GROUP_JOIN}{gid}" in linked
|
||||
}
|
||||
_drain(sheet, at, type_id, base, options, links, group, kinds, names, box_length)
|
||||
body_link = BOX_LINKS["body"] if type_id == "box_culvert" else FORD_LINKS["body"]
|
||||
body = links.get(body_link) or _combo(group.get(body_link))
|
||||
counted = bool(base["summary_item"] or body) # 본체 칸을 셈(사본 있음)
|
||||
no_box += type_id == "box_culvert" and counted and not box_length
|
||||
if type_id == "ford_bridge":
|
||||
ford_length = fill._nearest(ford_lengths, at)
|
||||
no_ford += not ford_length
|
||||
_ford_pipes(sheet, at, base, ford_length, one_m, names, pipes)
|
||||
no_ford += counted and not ford_length
|
||||
_ford_pipes(sheet, at, base, ford_length, one_m, names, pipes, body)
|
||||
continue
|
||||
diameter = int(_num(base["options"].get("pipe_diameter_mm")) or 1000)
|
||||
pipe_head = ["관 공", f"Φ{names.get(f'pipe.diameter={diameter}', diameter)}m/m"]
|
||||
diameter = _count(base["options"], "pipe_diameter_mm")
|
||||
pipe_head = _pipe_head(names, diameter)
|
||||
pipe_combos = [_combo(base.get("summary_item")), links.get(LINKS["pipe"])]
|
||||
length = base.get("관연장")
|
||||
no_length += not length
|
||||
no_length += any(pipe_combos) and not length
|
||||
sheet.add(
|
||||
at,
|
||||
f"len|{diameter}",
|
||||
(2, diameter, 0),
|
||||
f"len|{_key(diameter)}",
|
||||
(2, diameter or math.inf, 0),
|
||||
[*pipe_head, "관매설"],
|
||||
"m",
|
||||
length,
|
||||
@@ -535,21 +576,23 @@ def pipe_summary(
|
||||
)
|
||||
sheet.add(
|
||||
at,
|
||||
f"cp|{diameter}",
|
||||
(2, diameter, 1),
|
||||
f"cp|{_key(diameter)}",
|
||||
(2, diameter or math.inf, 1),
|
||||
[*pipe_head, "커플링"],
|
||||
"ea",
|
||||
couplings(length, one_m),
|
||||
pipe_combos,
|
||||
)
|
||||
pipes.append({"chainage_m": at, "column": f"pipe:cp|{diameter}", "length_m": length})
|
||||
if any(pipe_combos):
|
||||
column = f"pipe:cp|{_key(diameter)}"
|
||||
pipes.append({"chainage_m": at, "column": column, "length_m": length})
|
||||
shape = fill._nearest(basins, at)
|
||||
no_basin += shape is None
|
||||
no_basin += bool(links.get(LINKS["inlet"])) and shape is None
|
||||
if shape:
|
||||
sheet.add(
|
||||
at,
|
||||
f"basin|{diameter}|{shape}",
|
||||
(2, diameter, 2 + _order(SHAPES, shape), shape),
|
||||
f"basin|{_key(diameter)}|{shape}",
|
||||
(2, diameter or math.inf, 2 + _order(SHAPES, shape), shape),
|
||||
[*pipe_head, "관보호공"],
|
||||
names.get(f"inlet.form={shape}", shape),
|
||||
1.0,
|
||||
@@ -558,11 +601,18 @@ def pipe_summary(
|
||||
|
||||
sides = [side for side in ("inlet", "outlet") if options.get(f"{side}_type") == SOIL]
|
||||
sources = [(side, links.get(LINKS[side])) for side in sides]
|
||||
_revets(sheet, at, options, links, sources, pipe_wall_min_height_m(diameter / 1000), names)
|
||||
# 관경 빈칸 = 빈 높이도 「미입력」(관경 최소 높이를 모름)
|
||||
wall = pipe_wall_min_height_m(diameter / 1000) if diameter else None
|
||||
_revets(sheet, at, options, links, sources, wall, names)
|
||||
_chutes(sheet, at, options, links, names)
|
||||
|
||||
out = sheet.result()
|
||||
notes = []
|
||||
unlinked = len(sheet.unlinked) + sum(not found for found in others.values())
|
||||
if unlinked:
|
||||
notes.append(
|
||||
f"조합 사본 없는 시설 {unlinked}곳 — B05 · B06 좌측 패널에서 조합 연결 · [저장]"
|
||||
)
|
||||
if no_length:
|
||||
notes.append(f"관 연장 없음 {no_length}곳 — B06 횡단 [저장] 뒤 채워짐")
|
||||
if no_basin:
|
||||
@@ -579,6 +629,7 @@ def pipe_summary(
|
||||
"one_length_m": one_m,
|
||||
"types": list(DRAIN_TYPES),
|
||||
"pipes": pipes,
|
||||
"linked": linked,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
*
|
||||
* 줄 = 구조물 · 배관이 놓인 측점(같은 측점은 한 줄 · 오름차순)
|
||||
* 열 = 저장된 구조물 조합(본체 `summary_item` + 그룹 `group_items`) — 같은 층 · 주인 · 키는 한 열
|
||||
* 조합 = 프로젝트 안 사본이 있는 것만(`linkedOnly` · 서버 `linked_snapshots` · PLAN 143-1)
|
||||
*
|
||||
* 읽기만 — B05/B06 저장 자료(structures.json · pipe_points.json)를 고치지 않는다.
|
||||
* 런타임 import 는 `common_util_summary_item` 하나 — 시험이 두 파일을 이어 붙여 돌림.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
GROUP_JOIN,
|
||||
readGroupItems,
|
||||
readSummaryItem,
|
||||
type SummaryItem,
|
||||
@@ -25,6 +27,35 @@ export interface PlacedItem {
|
||||
group_items?: unknown;
|
||||
}
|
||||
|
||||
/** 연결된 사본 한 건(서버 `M02_Item_Snapshot.linked_snapshots` · 143-1) — `경로` = 가져온 때 M02 경로. */
|
||||
export interface LinkedSnapshot {
|
||||
항목: SummaryItem;
|
||||
폴더: string;
|
||||
/** 있는 부분(도면 · 면적 · 일위대가) */
|
||||
부분: string[];
|
||||
경로: string[];
|
||||
}
|
||||
|
||||
/** 사본 있는 조합만 남김 — 본체 = `{구조물 id}` · 그룹 = `{구조물 id}__{그룹}` 사본 · 하나도 없으면 뺌. */
|
||||
export function linkedOnly<T extends PlacedItem>(
|
||||
things: readonly T[],
|
||||
linked: Readonly<Record<string, LinkedSnapshot>>,
|
||||
): T[] {
|
||||
const out: T[] = [];
|
||||
for (const thing of things) {
|
||||
const id = thing.structure_id ?? "";
|
||||
const own = id in linked ? readSummaryItem(thing.summary_item) : null;
|
||||
const groups = Object.fromEntries(
|
||||
Object.entries(readGroupItems(thing.group_items)).filter(
|
||||
([gid]) => `${id}${GROUP_JOIN}${gid}` in linked,
|
||||
),
|
||||
);
|
||||
if (own || Object.keys(groups).length)
|
||||
out.push({ ...thing, summary_item: own, group_items: groups });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** M02 조합 정보에서 읽은 구분 · 이름 — 못 찾으면 null(저장된 이름으로 보임). */
|
||||
export interface ComboLabel {
|
||||
group: string;
|
||||
@@ -84,11 +115,12 @@ export function summaryColumns(
|
||||
);
|
||||
}
|
||||
|
||||
/** 한 측점에 놓인 조합 한 건 — 어느 구조물 것인지(복사본 찾기용)까지. */
|
||||
/** 한 측점에 놓인 조합 한 건 — 어느 구조물 것인지 · 사본 id(본체 = 구조물 id · 그룹 = `{id}__{그룹}`). */
|
||||
export interface StationCombo {
|
||||
id: string;
|
||||
item: SummaryItem;
|
||||
structureId: string | null;
|
||||
copyId: string | null;
|
||||
}
|
||||
|
||||
/** 측점(1cm 안 같은 측점)에 놓인 조합들 — 구조물마다 본체 → 그룹 차례 · 같은 구조물의 같은 조합은 한 건. */
|
||||
@@ -99,16 +131,17 @@ export function combosAt(things: readonly PlacedItem[], station: number): Statio
|
||||
const at = stationOf(thing);
|
||||
if (at === null || Math.round(at * 100) !== Math.round(station * 100)) continue;
|
||||
const own = readSummaryItem(thing.summary_item);
|
||||
for (const item of [
|
||||
...(own ? [own] : []),
|
||||
...Object.values(readGroupItems(thing.group_items)),
|
||||
const structureId = thing.structure_id ?? null;
|
||||
for (const [gid, item] of [
|
||||
...(own ? [["", own] as const] : []),
|
||||
...Object.entries(readGroupItems(thing.group_items)),
|
||||
]) {
|
||||
const structureId = thing.structure_id ?? null;
|
||||
const key = `${structureId}
|
||||
${comboId(item)}`;
|
||||
const copyId = structureId && (gid ? `${structureId}${GROUP_JOIN}${gid}` : structureId);
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
found.push({ id: comboId(item), item, structureId });
|
||||
found.push({ id: comboId(item), item, structureId, copyId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 조합 한 개 = M02 항목 카드(`M02_MasterTemplete_ItemCards`) 그대로 — 도면(도각 포함 · 보기 전용 CAD ·
|
||||
* 여러 장이면 장 탭) + 면적산출.
|
||||
*
|
||||
* 원천 = 그 구조물의 프로젝트 복사본(같은 항목일 때) · 없으면 M02 원본 층 — 읽기만
|
||||
* 원천 = 그 구조물의 프로젝트 사본만(연결된 사본 · PLAN 143-1) — 없으면 「사본 없음」 · 읽기만
|
||||
* 조합 목록 = 집계표와 같음(`B08_Quantity_Summary_Data` `combosAt`)
|
||||
* 카드 · CAD · 스프레드시트는 모달을 열 때만 `import()` · 닫을 때 카드를 모두 정리
|
||||
* ========================================================================== */
|
||||
@@ -12,19 +12,11 @@
|
||||
import "./B08_Quantity_UI_Drawing.css";
|
||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { layerOf, type PickerRow } from "@ui/ui_template_item_picker_data";
|
||||
import {
|
||||
PART_OF,
|
||||
projectItems,
|
||||
sameItem,
|
||||
structureFolder,
|
||||
type ProjectItemInfo,
|
||||
} from "@ui/ui_template_item_picker_project";
|
||||
import { readTemplate } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
|
||||
import { PART_OF, projectItems } from "@ui/ui_template_item_picker_project";
|
||||
import type { ItemCardsHandle } from "../M02_MasterTemplete/M02_MasterTemplete_ItemCards";
|
||||
import type { ItemSource } from "../M02_MasterTemplete/M02_MasterTemplete_ItemSource";
|
||||
import { summaryTooltip, type SummaryItem } from "../common_util/common_util_summary_item";
|
||||
import type { ComboLabel, StationCombo } from "./B08_Quantity_Summary_Data";
|
||||
import type { ComboLabel, LinkedSnapshot, StationCombo } from "./B08_Quantity_Summary_Data";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
@@ -42,48 +34,21 @@ export interface SheetsRequest {
|
||||
/** 모달 제목에 붙는 측점 글 */
|
||||
station: string;
|
||||
combos: StationCombo[];
|
||||
/** `comboId` → M02 줄 */
|
||||
rows: Map<string, PickerRow>;
|
||||
/** 연결된 사본 `{구조물 id: 사본}`(집계 응답 · 143-1) */
|
||||
linked: Record<string, LinkedSnapshot>;
|
||||
describe: (item: SummaryItem) => ComboLabel | null;
|
||||
}
|
||||
|
||||
/** 그 구조물의 복사본 목록 — 프로젝트 복사본을 못 읽으면 원본만(모달은 그대로 열림). */
|
||||
const loadCopies = (projectId: string): Promise<ProjectItemInfo[]> =>
|
||||
projectItems(projectId)
|
||||
.list()
|
||||
.catch(() => []);
|
||||
|
||||
function sourceOf(
|
||||
request: SheetsRequest,
|
||||
combo: StationCombo,
|
||||
row: PickerRow,
|
||||
copies: ProjectItemInfo[],
|
||||
): { source: ItemSource; copy: boolean } {
|
||||
const own = combo.structureId
|
||||
? copies.find((info) => info.폴더 === structureFolder(combo.structureId!))
|
||||
: undefined;
|
||||
if (own && sameItem(own, row)) {
|
||||
const items = projectItems(request.projectId);
|
||||
return {
|
||||
copy: true,
|
||||
source: {
|
||||
layer: "project",
|
||||
projectId: request.projectId,
|
||||
read: async (part) => {
|
||||
const doc = await items.read(own.폴더, PART_OF[part]);
|
||||
if (doc == null) throw new Error(L("B08_Quantity_Sheets_NoCopy"));
|
||||
return { 문서: doc };
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
const layer = layerOf(row);
|
||||
/** 사본 폴더 읽기 — 그 부분 파일이 없으면 던짐(카드가 「없음」). */
|
||||
function sourceOf(projectId: string, folder: string): ItemSource {
|
||||
const items = projectItems(projectId);
|
||||
return {
|
||||
copy: false,
|
||||
source: {
|
||||
layer,
|
||||
projectId: null,
|
||||
read: (part, column) => readTemplate(layer, part, column, null),
|
||||
layer: "project",
|
||||
projectId,
|
||||
read: async (part) => {
|
||||
const doc = await items.read(folder, PART_OF[part]);
|
||||
if (doc == null) throw new Error(L("B08_Quantity_Sheets_NoCopy"));
|
||||
return { 문서: doc };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -92,7 +57,6 @@ function sourceOf(
|
||||
function comboSection(
|
||||
request: SheetsRequest,
|
||||
combo: StationCombo,
|
||||
copies: ProjectItemInfo[],
|
||||
createItemCards: typeof import("../M02_MasterTemplete/M02_MasterTemplete_ItemCards").createItemCards,
|
||||
handles: ItemCardsHandle[],
|
||||
): HTMLElement {
|
||||
@@ -100,7 +64,7 @@ function comboSection(
|
||||
const name = label
|
||||
? [label.group, label.name].filter(Boolean).join(" ")
|
||||
: combo.item.name || combo.item.key;
|
||||
const row = request.rows.get(combo.id) ?? null;
|
||||
const copy = combo.copyId ? request.linked[combo.copyId] : undefined;
|
||||
const section = document.createElement("section");
|
||||
section.className = "b08-sheets__combo";
|
||||
section.dataset.b08Combo = combo.id;
|
||||
@@ -109,27 +73,25 @@ function comboSection(
|
||||
title.textContent = name;
|
||||
title.title = summaryTooltip(combo.item);
|
||||
section.append(title);
|
||||
if (!row?.열) {
|
||||
if (!copy) {
|
||||
section.append(note(L("B08_Quantity_Drawing_Missing")));
|
||||
return section;
|
||||
}
|
||||
const picked = sourceOf(request, combo, row, copies);
|
||||
const source = sourceOf(request.projectId, copy.폴더);
|
||||
const from = document.createElement("span");
|
||||
from.className = "b08-sheets__from";
|
||||
from.textContent = L(
|
||||
picked.copy ? "B08_Quantity_Sheets_FromCopy" : "B08_Quantity_Sheets_FromMaster",
|
||||
);
|
||||
from.textContent = L("B08_Quantity_Sheets_FromCopy");
|
||||
title.append(from);
|
||||
if (!row.도번) section.append(note(L("B08_Quantity_Drawing_NoDrawing")));
|
||||
if (!copy.부분.includes("도면")) section.append(note(L("B08_Quantity_Drawing_NoDrawing")));
|
||||
const cards = createItemCards({
|
||||
source: () => picked.source,
|
||||
source: () => source,
|
||||
editable: () => false,
|
||||
onEdit: () => undefined,
|
||||
warnOnFail: () => false, // 못 읽으면 카드에 「없음」
|
||||
});
|
||||
handles.push(cards);
|
||||
section.append(cards.root);
|
||||
void cards.load({ id: row.열, label: name });
|
||||
void cards.load({ id: copy.폴더, label: name });
|
||||
return section;
|
||||
}
|
||||
|
||||
@@ -151,14 +113,12 @@ export function openStructureSheets(request: SheetsRequest): void {
|
||||
body.append(note(L("B08_Quantity_Drawing_Loading")));
|
||||
void (async () => {
|
||||
try {
|
||||
const [{ createItemCards }, copies] = await Promise.all([
|
||||
import("../M02_MasterTemplete/M02_MasterTemplete_ItemCards"),
|
||||
loadCopies(request.projectId),
|
||||
]);
|
||||
const { createItemCards } =
|
||||
await import("../M02_MasterTemplete/M02_MasterTemplete_ItemCards");
|
||||
if (closed) return;
|
||||
body.replaceChildren(
|
||||
...request.combos.map((combo) =>
|
||||
comboSection(request, combo, copies, createItemCards, handles),
|
||||
comboSection(request, combo, createItemCards, handles),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
|
||||
@@ -28,7 +28,7 @@ const SUMMARY_COLUMNS: DataColumn[] = [
|
||||
export function renderEarthworkSummary(host: HTMLElement, table: EarthworkTable): void {
|
||||
const info = note(
|
||||
"토적표 합계 · 평균운반거리 · 종방향 무대운반거리를 실무 토공집계표 식대로 묶음 · " +
|
||||
"연암 · 경암 = 암 × 좌측 패널 비율 · 구조물 쪽 줄은 구조물 수량에서(- = 아직 값 없음)",
|
||||
"연암 · 경암 = 암 × 좌측 패널 비율 · 「비율 미정」 = 반영 비율 확인 뒤 채움(- = 아직 값 없음)",
|
||||
);
|
||||
const grid = createDataTable({
|
||||
columns: SUMMARY_COLUMNS,
|
||||
|
||||
@@ -430,7 +430,11 @@ export function buildHydroSheet(
|
||||
dims.append(
|
||||
rowF("내공 폭 B (m)", null, numberInput("box_width_m", "입력")),
|
||||
rowF("내공 높이 H (m)", null, numberInput("box_height_m", "입력")),
|
||||
rowF("수심비 h / H", hint(`기본 ${sheetDefaults.depth_ratio}`), numberInput("depth_ratio")),
|
||||
rowF(
|
||||
"수심비 h / H",
|
||||
hint("빈칸이면 계산 멈춤 — 값 넣기"),
|
||||
numberInput("depth_ratio", "입력"),
|
||||
),
|
||||
rowF("수심 h (m)", formula("h = 수심비 × H"), outCell("depth")),
|
||||
rowF("단면적", formula("A = B × h"), outCell("section_area")),
|
||||
rowF("윤변", formula("P = B + 2h"), outCell("perimeter")),
|
||||
|
||||
@@ -7,11 +7,13 @@
|
||||
* 고칠 칸 = 단위수량 · ㎥당 배합 · 할증 % — 조작 = 캐시(`b08-materials`) + 짝 계산(`Material_Calc.ts`)으로 바로
|
||||
* 총괄 모래 · 자갈 · 시멘트 할증 = 좌측 패널 값(PLAN 124 · 표에서 안 고침 · 패널 조작에 바로 다시 셈)
|
||||
* [저장] = PUT …/quantity/materials → 서버가 정본으로 다시 셈 · 캐시 비움 · 자동 저장 없음
|
||||
* 값 없는 줄은 숨김(화면만 · 셈 · 열 · 단위수량은 그대로) · 표는 공용 카드 안(PLAN 143-3)
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import {
|
||||
createButton,
|
||||
createCard,
|
||||
hideLoadingOverlay,
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
@@ -49,6 +51,12 @@ const HINTS: Record<MaterialSheetId, string> = {
|
||||
concrete: "㎥당 = 산림품셈 12-1-3 (B) 배합 · 건축 9-1-1 모르터 기본(무근은 빈칸) — 고칠 수 있음",
|
||||
quantity: "수량 = 구조물 수량 × 단위수량 · 단위수량은 빈칸(사용자 입력) · 관매설은 1",
|
||||
};
|
||||
const HIDDEN = "값 없는 줄은 숨김";
|
||||
|
||||
/** 보일 줄 차례(원래 줄 번호) — 값 없는 줄은 숨김(143-3) */
|
||||
const shown = <T>(rows: readonly T[], has: (row: T) => boolean): number[] =>
|
||||
rows.flatMap((row, i) => (has(row) ? [i] : []));
|
||||
const anyValue = (cells: Cells): boolean => Object.values(cells).some((v) => v != null);
|
||||
|
||||
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
@@ -99,7 +107,9 @@ interface Table {
|
||||
|
||||
function quantityTable(s: MaterialSheets, o: MaterialOverrides): Table {
|
||||
const { materials, rows, total } = s.quantity;
|
||||
const rowsOut: DataRow[] = rows.flatMap((row) => {
|
||||
const keep = shown(rows, (row) => row.qty != null || anyValue(row.units));
|
||||
const rowsOut: DataRow[] = keep.flatMap((at) => {
|
||||
const row = rows[at];
|
||||
const unit: DataRow = {
|
||||
name: row.name,
|
||||
spec: row.spec,
|
||||
@@ -137,7 +147,7 @@ function quantityTable(s: MaterialSheets, o: MaterialOverrides): Table {
|
||||
total: totalRow,
|
||||
frozen: 5,
|
||||
editable: (i, key) => i % 2 === 0 && key.startsWith("m:"),
|
||||
target: (i, key) => [o.units, rows[i / 2].id, key.slice(2)],
|
||||
target: (i, key) => [o.units, rows[keep[i / 2]].id, key.slice(2)],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -149,9 +159,11 @@ const MIX_LABEL: Record<(typeof MIX_FIELDS)[number], [string, string, string]> =
|
||||
|
||||
function concreteTable(s: MaterialSheets, o: MaterialOverrides): Table {
|
||||
const { rows, total } = s.concrete;
|
||||
const rowsOut: DataRow[] = rows.map((row, i) => {
|
||||
const keep = shown(rows, (row) => row.qty != null || anyValue(row.amounts));
|
||||
const rowsOut: DataRow[] = keep.map((at, i) => {
|
||||
const row = rows[at];
|
||||
const out: DataRow = {
|
||||
name: i > 0 && rows[i - 1].name === row.name ? "" : row.name,
|
||||
name: i > 0 && rows[keep[i - 1]].name === row.name ? "" : row.name,
|
||||
spec: row.spec,
|
||||
qty: row.qty,
|
||||
};
|
||||
@@ -187,12 +199,13 @@ function concreteTable(s: MaterialSheets, o: MaterialOverrides): Table {
|
||||
total: totalRow,
|
||||
frozen: 2,
|
||||
editable: (_i, key) => key.startsWith("per:"),
|
||||
target: (i, key) => [o.mix, rows[i].key, key.slice(4)],
|
||||
target: (i, key) => [o.mix, rows[keep[i]].key, key.slice(4)],
|
||||
};
|
||||
}
|
||||
|
||||
function totalTable(s: MaterialSheets, o: MaterialOverrides): Table {
|
||||
const { rows } = s.material;
|
||||
const keep = shown(rows, (row) => row.qty != null || row.amount != null);
|
||||
return {
|
||||
columns: [
|
||||
{ key: "group", label: "구분", width: 96 },
|
||||
@@ -203,19 +216,22 @@ function totalTable(s: MaterialSheets, o: MaterialOverrides): Table {
|
||||
numberColumn({ key: "amount", label: "합계", width: 96 }),
|
||||
{ key: "note", label: "비고", width: 96 },
|
||||
],
|
||||
rows: rows.map((row, i) => ({
|
||||
group: i > 0 && rows[i - 1].group === row.group ? "" : row.group,
|
||||
spec: row.spec,
|
||||
unit: row.unit,
|
||||
qty: row.qty,
|
||||
surcharge: row.surcharge,
|
||||
amount: row.amount,
|
||||
note: row.panel ? "할증 = 좌측 패널" : "",
|
||||
})),
|
||||
rows: keep.map((at, i) => {
|
||||
const row = rows[at];
|
||||
return {
|
||||
group: i > 0 && rows[keep[i - 1]].group === row.group ? "" : row.group,
|
||||
spec: row.spec,
|
||||
unit: row.unit,
|
||||
qty: row.qty,
|
||||
surcharge: row.surcharge,
|
||||
amount: row.amount,
|
||||
note: row.panel ? "할증 = 좌측 패널" : "",
|
||||
};
|
||||
}),
|
||||
total: null,
|
||||
frozen: 2,
|
||||
editable: (i, key) => key === "surcharge" && !rows[i].panel,
|
||||
target: (i) => [o.surcharge, rows[i].key, null],
|
||||
editable: (i, key) => key === "surcharge" && !rows[keep[i]].panel,
|
||||
target: (i) => [o.surcharge, rows[keep[i]].key, null],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -254,7 +270,7 @@ export async function renderMaterialSheet(
|
||||
const toolbar = document.createElement("div");
|
||||
toolbar.className = "b08-material__toolbar";
|
||||
const notes = document.createElement("div");
|
||||
notes.append(note(BASIS), note(HINTS[sheet]));
|
||||
notes.append(note(BASIS), note(`${HINTS[sheet]} · ${HIDDEN}`));
|
||||
if (sheet !== "quantity") {
|
||||
// 빈 까닭 한 줄(131) — 단위수량 빈 줄 · 서버 알림 · 빈 칸은 빈칸 그대로
|
||||
const blank = blankUnitRows(sheets);
|
||||
@@ -300,7 +316,7 @@ export async function renderMaterialSheet(
|
||||
built = BUILD[sheet](sheets, overrides);
|
||||
table.setRows(built.rows, built.total);
|
||||
};
|
||||
host.replaceChildren(toolbar, table.root);
|
||||
host.replaceChildren(createCard({ body: [toolbar, table.root] }));
|
||||
onOptionsChange(host, () => {
|
||||
sheets = calc();
|
||||
redraw();
|
||||
|
||||
@@ -142,8 +142,9 @@ export function createOptionsPanel(projectId: string | null, onSaved: () => void
|
||||
rockRow.className = "b08-options__row";
|
||||
rockRow.append(soft.root, hard.root);
|
||||
|
||||
// 1본 = 프로젝트 값만(읽기 전 빈칸 · 143-2)
|
||||
let saved: { one: number | null; soft: number; surcharge: Cells } = {
|
||||
one: 8,
|
||||
one: null,
|
||||
soft: 90,
|
||||
surcharge: PANEL_SURCHARGE,
|
||||
};
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* 표는 공용 많은 자료 표(`ui_template_data_table` · PLAN 70-2) — 머리 · 측점 열 고정.
|
||||
*
|
||||
* 구조물 = GET …/route/structures · 배관 = GET …/drainage/pipe-points (읽기만)
|
||||
* 머리 두 줄 = 조합 구분(M02 경로 첫 칸) · 조합 이름(나머지 경로) — `GET /api/m02/items?all=1`
|
||||
* 머리 두 줄 = 조합 구분(경로 첫 칸) · 조합 이름(나머지 경로) — 사본 manifest 경로(집계 응답 `linked`)
|
||||
* 조합 · 측점 = 프로젝트 안 사본이 있는 것만(`linkedOnly` · PLAN 143-1) — 없으면 그 시설은 없음
|
||||
* 횡단배수 열 · 칸 = GET …/quantity/summary(서버 단독 · PLAN 98 · 120) — 머리 구조물 · 형식 · 규격
|
||||
* 3단 + 단위 줄 · 「관 공」 맨 윗 머리 = 관에 이은 조합 경로(있으면) · 서버가 펼친 종류(`types`)는
|
||||
* 조합 열에서 뺌 · 다른 구조물 열은 조합 그대로
|
||||
@@ -15,9 +16,7 @@
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { loadRows as loadPickerRows } from "@ui/ui_template_item_picker";
|
||||
import { pathOf, rowId, type PickerRow } from "@ui/ui_template_item_picker_data";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { createCard, showToast } from "@ui/ui_template_elements";
|
||||
import { fetchStructures } from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { fetchLatestRoute, readRouteLatestCache } from "../B05_Profile/B05_Profile_Api_Fetch";
|
||||
import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
@@ -28,10 +27,12 @@ import { summaryTooltip, type SummaryItem } from "../common_util/common_util_sum
|
||||
import {
|
||||
combosAt,
|
||||
comboId,
|
||||
linkedOnly,
|
||||
summaryColumns,
|
||||
summaryStations,
|
||||
type ComboColumn,
|
||||
type ComboLabel,
|
||||
type LinkedSnapshot,
|
||||
type PlacedItem,
|
||||
type StationCombo,
|
||||
widthOf,
|
||||
@@ -72,6 +73,8 @@ interface PipeSummary {
|
||||
types: string[];
|
||||
one_length_m: number | null;
|
||||
pipes: PipeLength[];
|
||||
/** 연결된 사본 `{구조물 id: 사본}`(143-1) */
|
||||
linked: Record<string, LinkedSnapshot>;
|
||||
}
|
||||
|
||||
async function fetchPipeSummary(projectId: string): Promise<PipeSummary> {
|
||||
@@ -88,7 +91,7 @@ const stationKey = (station: number): number => Math.round(station * 100);
|
||||
const placesOf = (values: number[]): number =>
|
||||
Math.max(0, ...values.map((v) => (String(Math.round(v * 100) / 100).split(".")[1] ?? "").length));
|
||||
|
||||
/** 배관 열 → 표 열 · 「관 공」 맨 윗 머리 = 관에 이은 조합 경로(M02 에 있으면 · 조합 잇기 98). */
|
||||
/** 배관 열 → 표 열 · 「관 공」 맨 윗 머리 = 관에 이은 조합 경로(사본 경로 · 조합 잇기 98). */
|
||||
function pipeColumns(summary: PipeSummary, comboPath: (id: string) => string | null): DataColumn[] {
|
||||
const top =
|
||||
summary.columns
|
||||
@@ -168,12 +171,12 @@ async function openSheets(
|
||||
projectId: string,
|
||||
station: string,
|
||||
combos: StationCombo[],
|
||||
rows: Map<string, PickerRow>,
|
||||
linked: Record<string, LinkedSnapshot>,
|
||||
describe: (item: SummaryItem) => ComboLabel | null,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { openStructureSheets } = await import("./B08_Quantity_UI_Drawing");
|
||||
openStructureSheets({ projectId, station, combos, rows, describe });
|
||||
openStructureSheets({ projectId, station, combos, linked, describe });
|
||||
} catch (error) {
|
||||
showToast(`${L("B08_Quantity_Drawing_Failed")} — ${String(error)}`, "error");
|
||||
}
|
||||
@@ -183,9 +186,8 @@ async function openSheets(
|
||||
export async function renderStructureSummary(host: HTMLElement, projectId: string): Promise<void> {
|
||||
host.replaceChildren(note(L("B08_Quantity_Summary_Loading")));
|
||||
try {
|
||||
const [structures, pickerRows, pipes, interval, pipe] = await Promise.all([
|
||||
const [structures, pipes, interval, pipe] = await Promise.all([
|
||||
fetchStructures(projectId),
|
||||
loadPickerRows(),
|
||||
// 배수 분석 전이면 관이 없음 — 구조물만으로 그림
|
||||
fetchDetailPipePoints(projectId).then(
|
||||
(response) => response.pipe_points,
|
||||
@@ -195,28 +197,28 @@ export async function renderStructureSummary(host: HTMLElement, projectId: strin
|
||||
// 배관 집계 실패 = 까닭 한 줄 · 나머지 표는 그대로
|
||||
fetchPipeSummary(projectId).catch((error: unknown) => String(error)),
|
||||
]);
|
||||
const m02 = new Map(pickerRows.map((row) => [rowId(row), row]));
|
||||
const saved = typeof pipe === "string" ? null : pipe;
|
||||
// 조합 이름 = 사본 manifest 경로(층 목록을 안 읽음 · 143-1)
|
||||
const linked = saved?.linked ?? {};
|
||||
const paths = new Map(
|
||||
Object.values(linked).map((copy) => [comboId(copy.항목), copy.경로] as const),
|
||||
);
|
||||
const describe = (item: SummaryItem): ComboLabel | null => {
|
||||
const row = m02.get(comboId(item));
|
||||
if (!row) return null;
|
||||
const [group = "", ...rest] = pathOf(row);
|
||||
return { group, name: rest.join(" ") || group || row.key };
|
||||
const [group = "", ...rest] = paths.get(comboId(item)) ?? [];
|
||||
return group ? { group, name: rest.join(" ") || group } : null;
|
||||
};
|
||||
const comboPath = (id: string): string | null => {
|
||||
const row = m02.get(id);
|
||||
return row ? pathOf(row).join(" ") : null;
|
||||
};
|
||||
const placed = pipes.map((point) => ({
|
||||
...point,
|
||||
structure_id: pipeStructureId(point.chainage_m),
|
||||
}));
|
||||
const things: PlacedItem[] = [...structures.structures, ...placed];
|
||||
const comboPath = (id: string): string | null => paths.get(id)?.join(" ") || null;
|
||||
const placed = linkedOnly(
|
||||
pipes.map((point) => ({ ...point, structure_id: pipeStructureId(point.chainage_m) })),
|
||||
linked,
|
||||
);
|
||||
const mine = linkedOnly(structures.structures, linked);
|
||||
const things: PlacedItem[] = [...mine, ...placed];
|
||||
const stations = summaryStations(things);
|
||||
// 횡단배수는 서버 집계 열(98 · 120) — 조합 열은 다른 구조물 · 시설만(모달은 그대로 전부)
|
||||
const saved = typeof pipe === "string" ? null : pipe;
|
||||
const served = new Set(saved?.types ?? []);
|
||||
const others = [
|
||||
...structures.structures.filter((item) => !served.has(item.type_id)),
|
||||
...mine.filter((item) => !served.has(item.type_id)),
|
||||
...placed.filter((point) => !served.has(point.facility ?? "pipe")),
|
||||
];
|
||||
const columns = summaryColumns(others, describe);
|
||||
@@ -231,12 +233,12 @@ export async function renderStructureSummary(host: HTMLElement, projectId: strin
|
||||
const draw = (): HTMLElement =>
|
||||
stations.length
|
||||
? buildTable(stations, columns, live(), comboPath, interval, (station, label) =>
|
||||
openSheets(projectId, label, combosAt(things, station), m02, describe),
|
||||
openSheets(projectId, label, combosAt(things, station), linked, describe),
|
||||
)
|
||||
: note(L("B08_Quantity_Summary_NoStation"));
|
||||
let table = draw();
|
||||
parts.push(table);
|
||||
host.replaceChildren(...parts);
|
||||
host.replaceChildren(createCard({ body: parts })); // 공용 카드 안(토공 표와 같은 꼴 · 143-3)
|
||||
onOptionsChange(host, () => {
|
||||
const next = draw();
|
||||
table.replaceWith(next);
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
"""B09 원가 바탕 단가 셈(PLAN 144) — 화면 없음 · 저장 안 함 · 파일 · M01 안 읽음.
|
||||
|
||||
⚠ 짝 = `B09_Estimation_Calc_Base.ts`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 ·
|
||||
거울 시험 `resources/tester/test_144_b09_base_calc.py`.
|
||||
조작 중은 화면 짝이 바로 셈 · [저장] 때 서버가 이 파일로 정본을 다시 셈.
|
||||
|
||||
실무 엑셀(기번3 공통 xlsx) 탭 꼴 · 끝수 = 엑셀 자리 그대로:
|
||||
노무비목록표 · 경비목록표 = 굳힌 단가 그대로(사용자 고침 없음 · [단가 갱신] 때만 새로)
|
||||
환율및기초자료 = 환율 · 운전사 시간 노임 = ROUNDDOWN(일당 × 분자 ÷ 분모, 0)
|
||||
(분자 · 분모 = M01 CC000336 1/8 × 16/12 × 25/20 — 서버가 넣어 줌)
|
||||
자재단가대비표 = 출처 단가 중 최저(0 · 빈칸 뺌) 자동 · 사용자 적용 단가가 있으면 그 값
|
||||
· 관급 · 별도계상 = 사용자 표시 → 재료비목록표 단가 = 적용 단가
|
||||
중기사용료 = 손료(경비 · 기준가 천원 × 손료계수 ÷ 10,000) + 운전사(노무 · 인 × 시간 노임)
|
||||
+ 연료(재료 · ℓ/hr × 적용 단가) + 잡품(재료 · 연료 금액 × % ÷ 100)
|
||||
줄 = ROUNDDOWN(단가 × 수량, 1) · 비목 합 = ROUNDDOWN(Σ, 0) → 중기목록표
|
||||
빈칸 규칙 = 값을 낸 원천이 없으면 None(0 을 채우지 않음) · 그 칸을 쓰는 합도 None ·
|
||||
「연결 없음」 목록(`gaps`)에 탭 · 줄 키 · 까닭을 남김(목차 탭이 이동 링크로 씀).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
#: 떠도는 끝수(부동소수) — 이만큼은 자리 올림 · 버림에서 무시
|
||||
EPS = 1e-7
|
||||
|
||||
#: 자재단가대비표 출처 칸 차례 — 엑셀 조달 · 물가정보 · 물가자료(같은 값이면 앞 칸)
|
||||
# ↔ M01 재료_자재품목 값 열 · 유가 = 오피넷
|
||||
SOURCES = ("관급", "물가정보", "물가자료", "유통물가", "거래가격", "유가")
|
||||
|
||||
#: 자재 줄 사용자 표시
|
||||
SUPPLY = ("관급", "별도")
|
||||
|
||||
|
||||
def _scaled(value: float, places: int, fn) -> float:
|
||||
a = abs(value)
|
||||
if places >= 0:
|
||||
scale = 10**places
|
||||
out = fn(a * scale) / scale
|
||||
else:
|
||||
unit = 10 ** (-places)
|
||||
out = fn(a / unit) * unit
|
||||
return -out if value < 0 else out
|
||||
|
||||
|
||||
def rounddown(value: float, places: int = 0) -> float:
|
||||
"""엑셀 ROUNDDOWN — 0 쪽으로 버림."""
|
||||
return _scaled(value, places, lambda x: math.floor(x + EPS))
|
||||
|
||||
|
||||
def roundup(value: float, places: int = 0) -> float:
|
||||
"""엑셀 ROUNDUP — 0 에서 먼 쪽으로 올림."""
|
||||
return _scaled(value, places, lambda x: math.ceil(x - EPS))
|
||||
|
||||
|
||||
def round_half(value: float, places: int = 0) -> float:
|
||||
"""엑셀 ROUND — 반 올림(0 에서 먼 쪽)."""
|
||||
return _scaled(value, places, lambda x: math.floor(x + 0.5 + EPS))
|
||||
|
||||
|
||||
def num(value: Any) -> float | None:
|
||||
"""수 · 빈칸 — 수가 아니면 None."""
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return None
|
||||
return float(value) if math.isfinite(value) else None
|
||||
|
||||
|
||||
def total(values: list[float | None]) -> float | None:
|
||||
"""합 — 하나라도 빈칸이면 빈칸(연결 안 된 값은 비움)."""
|
||||
out = 0.0
|
||||
for v in values:
|
||||
if v is None:
|
||||
return None
|
||||
out += v
|
||||
return out
|
||||
|
||||
|
||||
def gap(tab: str, key: str, name: str, why: str) -> dict[str, str]:
|
||||
return {"tab": tab, "key": key, "name": name, "why": why}
|
||||
|
||||
|
||||
def driver_hour(day: float | None, factor: dict[str, Any] | None) -> float | None:
|
||||
"""운전사 시간 노임 = ROUNDDOWN(일당 × 분자 ÷ 분모, 0)."""
|
||||
if day is None or not factor or not factor.get("den"):
|
||||
return None
|
||||
return rounddown(day * factor["num"] / factor["den"], 0)
|
||||
|
||||
|
||||
def labor_sheet(labor: list[dict[str, Any]], gaps: list) -> list[dict[str, Any]]:
|
||||
rows = []
|
||||
for no, row in enumerate(labor, 1):
|
||||
price = num(row.get("price"))
|
||||
if price is None:
|
||||
gaps.append(gap("labor", row["key"], row["name"], "노임 단가 없음"))
|
||||
rows.append(
|
||||
{
|
||||
**_head(row, no),
|
||||
"code": row.get("code"),
|
||||
"unit": row.get("unit") or "인",
|
||||
"price": price,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _head(row: dict[str, Any], no: int) -> dict[str, Any]:
|
||||
return {
|
||||
"no": no,
|
||||
"key": row["key"],
|
||||
"name": row.get("name") or "",
|
||||
"spec": row.get("spec") or "",
|
||||
"source": row.get("source") or "",
|
||||
}
|
||||
|
||||
|
||||
def expense_sheet(machines: list[dict[str, Any]], gaps: list) -> list[dict[str, Any]]:
|
||||
"""경비목록표 — 기계 손료 기준가(천원)."""
|
||||
rows = []
|
||||
for no, m in enumerate(machines, 1):
|
||||
price = num(m.get("price"))
|
||||
if price is None:
|
||||
gaps.append(gap("expense", m["key"], m["name"], "기계 기준가 없음"))
|
||||
rows.append({**_head(m, no), "unit": "천원", "price": price})
|
||||
return rows
|
||||
|
||||
|
||||
def lowest(prices: dict[str, Any]) -> tuple[float | None, str | None]:
|
||||
"""출처 단가 중 최저 — 0 · 빈칸 뺌 · 같으면 앞 칸."""
|
||||
best: tuple[float | None, str | None] = (None, None)
|
||||
for source in SOURCES:
|
||||
v = num(prices.get(source))
|
||||
if v is not None and v > 0 and (best[0] is None or v < best[0]):
|
||||
best = (v, source)
|
||||
return best
|
||||
|
||||
|
||||
def compare_sheet(
|
||||
materials: list[dict[str, Any]], user: dict[str, Any], gaps: list
|
||||
) -> list[dict[str, Any]]:
|
||||
"""자재단가대비표 — 출처 · 최저 · 적용(사용자 값이 앞섬) · 관급 · 별도 표시."""
|
||||
rows = []
|
||||
for no, m in enumerate(materials, 1):
|
||||
mine = user.get(m["key"]) or {}
|
||||
prices = m.get("prices") or {}
|
||||
low, low_from = lowest(prices)
|
||||
if "applied" in mine:
|
||||
applied, applied_from = num(mine["applied"]), "사용자"
|
||||
else:
|
||||
applied, applied_from = low, low_from
|
||||
if applied is None:
|
||||
gaps.append(gap("compare", m["key"], m["name"], "적용 단가 없음(출처 없음)"))
|
||||
supply = mine.get("supply") if mine.get("supply") in SUPPLY else None
|
||||
rows.append(
|
||||
{
|
||||
**_head(m, no),
|
||||
"unit": m.get("unit") or "",
|
||||
"origin": m.get("origin") or "",
|
||||
"prices": {s: num(prices.get(s)) for s in SOURCES},
|
||||
"pages": {s: (m.get("pages") or {}).get(s) for s in SOURCES},
|
||||
"lowest": low,
|
||||
"lowest_from": low_from,
|
||||
"applied": applied,
|
||||
"applied_from": applied_from if applied is not None else None,
|
||||
"supply": supply,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def material_sheet(compare: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""재료비목록표 — 단가 = 대비표 적용 단가."""
|
||||
keep = ("no", "key", "name", "spec", "unit", "supply", "origin")
|
||||
return [{**{k: row[k] for k in keep}, "price": row["applied"]} for row in compare]
|
||||
|
||||
|
||||
def _line(name, spec, qty, unit, kind, price, ref, pct=False) -> dict[str, Any]:
|
||||
if price is None or qty is None:
|
||||
amount = None
|
||||
elif pct:
|
||||
amount = rounddown(price * qty / 100, 1)
|
||||
else:
|
||||
amount = rounddown(price * qty, 1)
|
||||
return {
|
||||
"name": name,
|
||||
"spec": spec,
|
||||
"qty": qty,
|
||||
"unit": unit,
|
||||
"kind": kind,
|
||||
"price": price,
|
||||
"amount": amount,
|
||||
"ref": ref,
|
||||
}
|
||||
|
||||
|
||||
def machine_block(
|
||||
m: dict[str, Any],
|
||||
drivers: dict[str, dict[str, Any]],
|
||||
fuels: dict[str, dict[str, Any]],
|
||||
gaps: list,
|
||||
) -> dict[str, Any]:
|
||||
"""중기사용료 한 블록 — 손료 · 운전사 · 연료 · 잡품 줄 + 비목 합."""
|
||||
lines = []
|
||||
loss = num(m.get("loss"))
|
||||
coef = loss / 10000 if loss is not None else None
|
||||
price = num(m.get("price"))
|
||||
lines.append(_line(m["name"], m.get("spec") or "", coef, "천원", "expense", price,
|
||||
{"tab": "expense", "key": m["key"]})) # fmt: skip
|
||||
crew = num(m.get("crew"))
|
||||
if crew:
|
||||
code = str(m.get("crew_code") or "")
|
||||
driver = drivers.get(code) or {}
|
||||
hour = driver.get("hour")
|
||||
if hour is None:
|
||||
gaps.append(gap("machine_blocks", m["key"], m["name"], "운전사 노임 없음"))
|
||||
lines.append(_line(driver.get("name") or "운전사", "", crew, "인", "labor", hour,
|
||||
{"tab": "basis", "key": code})) # fmt: skip
|
||||
lph = num(m.get("fuel_lph"))
|
||||
if lph:
|
||||
kind = str(m.get("fuel") or "")
|
||||
fuel = fuels.get(kind) or {}
|
||||
fprice = fuel.get("applied")
|
||||
if fprice is None:
|
||||
gaps.append(gap("machine_blocks", m["key"], m["name"], f"연료({kind}) 단가 없음"))
|
||||
fuel_line = _line(fuel.get("name") or kind, fuel.get("spec") or "", lph, "ℓ", "material",
|
||||
fprice, {"tab": "compare", "key": fuel.get("key") or ""}) # fmt: skip
|
||||
lines.append(fuel_line)
|
||||
misc = num(m.get("misc_pct"))
|
||||
if misc:
|
||||
lines.append(_line("잡품", "주연료의 %", misc, "%", "material", fuel_line["amount"],
|
||||
None, pct=True)) # fmt: skip
|
||||
if price is None or coef is None:
|
||||
gaps.append(gap("machine_blocks", m["key"], m["name"], "손료 기준가 · 계수 없음"))
|
||||
sums: dict[str, float | None] = {}
|
||||
for part in ("labor", "material", "expense"):
|
||||
s = total([ln["amount"] for ln in lines if ln["kind"] == part])
|
||||
sums[part] = None if s is None else rounddown(s, 0)
|
||||
return {
|
||||
"key": m["key"],
|
||||
"name": m["name"],
|
||||
"spec": m.get("spec") or "",
|
||||
"unit": "시간",
|
||||
"lines": lines,
|
||||
"labor": sums["labor"],
|
||||
"material": sums["material"],
|
||||
"expense": sums["expense"],
|
||||
"sum": total([sums["labor"], sums["material"], sums["expense"]]),
|
||||
}
|
||||
|
||||
|
||||
def basis_sheet(
|
||||
inputs: dict[str, Any], labor_rows: list[dict[str, Any]]
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
"""환율및기초자료 — 환율 · 운전사 시간 노임(기계가 부르는 직종만)."""
|
||||
factor = inputs.get("driver_factor")
|
||||
by_code = {str(row.get("code")): row for row in labor_rows if row.get("code") is not None}
|
||||
codes: list[str] = []
|
||||
for m in inputs.get("machines") or []:
|
||||
code = str(m.get("crew_code")) if m.get("crew_code") else None
|
||||
if code and num(m.get("crew")) and code not in codes:
|
||||
codes.append(code)
|
||||
drivers = []
|
||||
for code in codes:
|
||||
row = by_code.get(code) or {}
|
||||
day = row.get("price")
|
||||
drivers.append(
|
||||
{
|
||||
"key": code,
|
||||
"labor_key": row.get("key"),
|
||||
"name": row.get("name") or f"직종 {code}",
|
||||
"day": day,
|
||||
"factor": (factor or {}).get("text") or "",
|
||||
"hour": driver_hour(day, factor),
|
||||
}
|
||||
)
|
||||
fx = [
|
||||
{"key": f["key"], "name": f.get("name") or "", "unit": f.get("unit") or "",
|
||||
"value": num(f.get("value")), "date": f.get("date") or ""}
|
||||
for f in inputs.get("fx") or []
|
||||
] # fmt: skip
|
||||
return {"fx": fx, "drivers": drivers}
|
||||
|
||||
|
||||
def compute_base(inputs: dict[str, Any], user: dict[str, Any]) -> dict[str, Any]:
|
||||
"""144 탭 일곱 + 「연결 없음」 목록."""
|
||||
gaps: list[dict[str, str]] = []
|
||||
labor_rows = labor_sheet(inputs.get("labor") or [], gaps)
|
||||
expense = expense_sheet(inputs.get("machines") or [], gaps)
|
||||
compare = compare_sheet(inputs.get("materials") or [], user.get("materials") or {}, gaps)
|
||||
basis = basis_sheet(inputs, labor_rows)
|
||||
drivers = {d["key"]: d for d in basis["drivers"]}
|
||||
by_key = {row["key"]: row for row in compare}
|
||||
fuels = {
|
||||
kind: by_key[key] for kind, key in (inputs.get("fuel_keys") or {}).items() if key in by_key
|
||||
}
|
||||
blocks = [machine_block(m, drivers, fuels, gaps) for m in inputs.get("machines") or []]
|
||||
machine = [
|
||||
{
|
||||
"no": no,
|
||||
"key": b["key"],
|
||||
"name": b["name"],
|
||||
"spec": b["spec"],
|
||||
"unit": b["unit"],
|
||||
"sum": b["sum"],
|
||||
"labor": b["labor"],
|
||||
"material": b["material"],
|
||||
"expense": b["expense"],
|
||||
}
|
||||
for no, b in enumerate(blocks, 1)
|
||||
]
|
||||
return {
|
||||
"labor": labor_rows,
|
||||
"expense": expense,
|
||||
"basis": basis,
|
||||
"compare": compare,
|
||||
"material": material_sheet(compare),
|
||||
"machine_blocks": blocks,
|
||||
"machine": machine,
|
||||
"gaps": gaps,
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_Calc_Base.ts
|
||||
* B09 원가 바탕 단가 셈(PLAN 144) — 화면 없음 · 저장 안 함 · import 없음(시험이 node 로 돌림).
|
||||
*
|
||||
* ⚠ 짝 = `B09_Estimation_Calc_Base.py`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 ·
|
||||
* 거울 시험 `resources/tester/test_144_b09_base_calc.py`.
|
||||
* 조작 중은 이 파일이 바로 셈 · [저장] 때 서버가 파이썬 짝으로 정본을 다시 셈.
|
||||
*
|
||||
* 노무비 · 경비목록표 = 굳힌 단가 · 환율및기초자료 = 운전사 시간 노임 ROUNDDOWN(일당 × 분자 ÷ 분모, 0)
|
||||
* 자재단가대비표 = 출처 중 최저 자동 · 사용자 적용 단가 앞섬 · 관급 · 별도 표시 → 재료비목록표
|
||||
* 중기사용료 = 손료 + 운전사 + 연료 + 잡품 · 줄 ROUNDDOWN 1 · 비목 합 ROUNDDOWN 0 → 중기목록표
|
||||
* 빈칸 = 원천이 없으면 null · 그 칸을 쓰는 합도 null · 「연결 없음」 목록(gaps)에 남김
|
||||
* ========================================================================== */
|
||||
|
||||
export type Num = number | null;
|
||||
export type Row = Record<string, any>;
|
||||
|
||||
export interface Gap {
|
||||
tab: string;
|
||||
key: string;
|
||||
name: string;
|
||||
why: string;
|
||||
}
|
||||
|
||||
/** 떠도는 끝수(부동소수) — 이만큼은 자리 올림 · 버림에서 무시 */
|
||||
const EPS = 1e-7;
|
||||
|
||||
/** 자재단가대비표 출처 칸 차례 — 엑셀 조달 · 물가정보 · 물가자료(같은 값이면 앞 칸) */
|
||||
export const SOURCES = ["관급", "물가정보", "물가자료", "유통물가", "거래가격", "유가"] as const;
|
||||
|
||||
/** 자재 줄 사용자 표시 */
|
||||
export const SUPPLY = ["관급", "별도"] as const;
|
||||
|
||||
function scaled(value: number, places: number, fn: (x: number) => number): number {
|
||||
const a = Math.abs(value);
|
||||
let out: number;
|
||||
if (places >= 0) {
|
||||
const scale = 10 ** places;
|
||||
out = fn(a * scale) / scale;
|
||||
} else {
|
||||
const unit = 10 ** -places;
|
||||
out = fn(a / unit) * unit;
|
||||
}
|
||||
return value < 0 ? -out : out;
|
||||
}
|
||||
|
||||
/** 엑셀 ROUNDDOWN */
|
||||
export const rounddown = (value: number, places = 0): number =>
|
||||
scaled(value, places, (x) => Math.floor(x + EPS));
|
||||
|
||||
/** 엑셀 ROUNDUP */
|
||||
export const roundup = (value: number, places = 0): number =>
|
||||
scaled(value, places, (x) => Math.ceil(x - EPS));
|
||||
|
||||
/** 엑셀 ROUND(반 올림) */
|
||||
export const roundHalf = (value: number, places = 0): number =>
|
||||
scaled(value, places, (x) => Math.floor(x + 0.5 + EPS));
|
||||
|
||||
export function num(value: unknown): Num {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
/** 합 — 하나라도 빈칸이면 빈칸 */
|
||||
export function total(values: Num[]): Num {
|
||||
let out = 0;
|
||||
for (const v of values) {
|
||||
if (v === null) return null;
|
||||
out += v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const gap = (tab: string, key: string, name: string, why: string): Gap => ({
|
||||
tab,
|
||||
key,
|
||||
name,
|
||||
why,
|
||||
});
|
||||
|
||||
export function driverHour(day: Num, factor: Row | null | undefined): Num {
|
||||
if (day === null || !factor || !factor.den) return null;
|
||||
return rounddown((day * factor.num) / factor.den, 0);
|
||||
}
|
||||
|
||||
const head = (row: Row, no: number): Row => ({
|
||||
no,
|
||||
key: row.key,
|
||||
name: row.name || "",
|
||||
spec: row.spec || "",
|
||||
source: row.source || "",
|
||||
});
|
||||
|
||||
function laborSheet(labor: Row[], gaps: Gap[]): Row[] {
|
||||
return labor.map((row, i) => {
|
||||
const price = num(row.price);
|
||||
if (price === null) gaps.push(gap("labor", row.key, row.name, "노임 단가 없음"));
|
||||
return { ...head(row, i + 1), code: row.code ?? null, unit: row.unit || "인", price };
|
||||
});
|
||||
}
|
||||
|
||||
function expenseSheet(machines: Row[], gaps: Gap[]): Row[] {
|
||||
return machines.map((m, i) => {
|
||||
const price = num(m.price);
|
||||
if (price === null) gaps.push(gap("expense", m.key, m.name, "기계 기준가 없음"));
|
||||
return { ...head(m, i + 1), unit: "천원", price };
|
||||
});
|
||||
}
|
||||
|
||||
export function lowest(prices: Row): [Num, string | null] {
|
||||
let best: [Num, string | null] = [null, null];
|
||||
for (const source of SOURCES) {
|
||||
const v = num(prices[source]);
|
||||
if (v !== null && v > 0 && (best[0] === null || v < best[0])) best = [v, source];
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function compareSheet(materials: Row[], user: Row, gaps: Gap[]): Row[] {
|
||||
return materials.map((m, i) => {
|
||||
const mine: Row = user[m.key] || {};
|
||||
const prices: Row = m.prices || {};
|
||||
const [low, lowFrom] = lowest(prices);
|
||||
let applied: Num;
|
||||
let appliedFrom: string | null;
|
||||
if ("applied" in mine) [applied, appliedFrom] = [num(mine.applied), "사용자"];
|
||||
else [applied, appliedFrom] = [low, lowFrom];
|
||||
if (applied === null) gaps.push(gap("compare", m.key, m.name, "적용 단가 없음(출처 없음)"));
|
||||
const supply = (SUPPLY as readonly string[]).includes(mine.supply) ? mine.supply : null;
|
||||
const pages: Row = m.pages || {};
|
||||
return {
|
||||
...head(m, i + 1),
|
||||
unit: m.unit || "",
|
||||
origin: m.origin || "",
|
||||
prices: Object.fromEntries(SOURCES.map((s) => [s, num(prices[s])])),
|
||||
pages: Object.fromEntries(SOURCES.map((s) => [s, pages[s] ?? null])),
|
||||
lowest: low,
|
||||
lowest_from: lowFrom,
|
||||
applied,
|
||||
applied_from: applied !== null ? appliedFrom : null,
|
||||
supply,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function materialSheet(compare: Row[]): Row[] {
|
||||
const keep = ["no", "key", "name", "spec", "unit", "supply", "origin"];
|
||||
return compare.map((row) => ({
|
||||
...Object.fromEntries(keep.map((k) => [k, row[k]])),
|
||||
price: row.applied,
|
||||
}));
|
||||
}
|
||||
|
||||
function line(
|
||||
name: string,
|
||||
spec: string,
|
||||
qty: Num,
|
||||
unit: string,
|
||||
kind: string,
|
||||
price: Num,
|
||||
ref: Row | null,
|
||||
pct = false,
|
||||
): Row {
|
||||
let amount: Num;
|
||||
if (price === null || qty === null) amount = null;
|
||||
else if (pct) amount = rounddown((price * qty) / 100, 1);
|
||||
else amount = rounddown(price * qty, 1);
|
||||
return { name, spec, qty, unit, kind, price, amount, ref };
|
||||
}
|
||||
|
||||
function machineBlock(m: Row, drivers: Row, fuels: Row, gaps: Gap[]): Row {
|
||||
const lines: Row[] = [];
|
||||
const loss = num(m.loss);
|
||||
const coef = loss !== null ? loss / 10000 : null;
|
||||
const price = num(m.price);
|
||||
lines.push(
|
||||
line(m.name, m.spec || "", coef, "천원", "expense", price, { tab: "expense", key: m.key }),
|
||||
);
|
||||
const crew = num(m.crew);
|
||||
if (crew) {
|
||||
const code = String(m.crew_code || "");
|
||||
const driver: Row = drivers[code] || {};
|
||||
const hour: Num = driver.hour ?? null;
|
||||
if (hour === null) gaps.push(gap("machine_blocks", m.key, m.name, "운전사 노임 없음"));
|
||||
lines.push(
|
||||
line(driver.name || "운전사", "", crew, "인", "labor", hour, { tab: "basis", key: code }),
|
||||
);
|
||||
}
|
||||
const lph = num(m.fuel_lph);
|
||||
if (lph) {
|
||||
const kind = String(m.fuel || "");
|
||||
const fuel: Row = fuels[kind] || {};
|
||||
const fprice: Num = fuel.applied ?? null;
|
||||
if (fprice === null) gaps.push(gap("machine_blocks", m.key, m.name, `연료(${kind}) 단가 없음`));
|
||||
const fuelLine = line(fuel.name || kind, fuel.spec || "", lph, "ℓ", "material", fprice, {
|
||||
tab: "compare",
|
||||
key: fuel.key || "",
|
||||
});
|
||||
lines.push(fuelLine);
|
||||
const misc = num(m.misc_pct);
|
||||
if (misc)
|
||||
lines.push(line("잡품", "주연료의 %", misc, "%", "material", fuelLine.amount, null, true));
|
||||
}
|
||||
if (price === null || coef === null)
|
||||
gaps.push(gap("machine_blocks", m.key, m.name, "손료 기준가 · 계수 없음"));
|
||||
const sums: Record<string, Num> = {};
|
||||
for (const part of ["labor", "material", "expense"]) {
|
||||
const s = total(lines.filter((ln) => ln.kind === part).map((ln) => ln.amount));
|
||||
sums[part] = s === null ? null : rounddown(s, 0);
|
||||
}
|
||||
return {
|
||||
key: m.key,
|
||||
name: m.name,
|
||||
spec: m.spec || "",
|
||||
unit: "시간",
|
||||
lines,
|
||||
labor: sums.labor,
|
||||
material: sums.material,
|
||||
expense: sums.expense,
|
||||
sum: total([sums.labor, sums.material, sums.expense]),
|
||||
};
|
||||
}
|
||||
|
||||
function basisSheet(inputs: Row, laborRows: Row[]): Row {
|
||||
const factor = inputs.driver_factor;
|
||||
const byCode: Row = {};
|
||||
for (const row of laborRows)
|
||||
if (row.code !== null && row.code !== undefined) byCode[String(row.code)] = row;
|
||||
const codes: string[] = [];
|
||||
for (const m of inputs.machines || []) {
|
||||
const code = m.crew_code ? String(m.crew_code) : null;
|
||||
if (code && num(m.crew) && !codes.includes(code)) codes.push(code);
|
||||
}
|
||||
const drivers = codes.map((code) => {
|
||||
const row: Row = byCode[code] || {};
|
||||
const day: Num = row.price ?? null;
|
||||
return {
|
||||
key: code,
|
||||
labor_key: row.key ?? null,
|
||||
name: row.name || `직종 ${code}`,
|
||||
day,
|
||||
factor: (factor || {}).text || "",
|
||||
hour: driverHour(day, factor),
|
||||
};
|
||||
});
|
||||
const fx = (inputs.fx || []).map((f: Row) => ({
|
||||
key: f.key,
|
||||
name: f.name || "",
|
||||
unit: f.unit || "",
|
||||
value: num(f.value),
|
||||
date: f.date || "",
|
||||
}));
|
||||
return { fx, drivers };
|
||||
}
|
||||
|
||||
/** 144 탭 일곱 + 「연결 없음」 목록 */
|
||||
export function computeBase(inputs: Row, user: Row): Row {
|
||||
const gaps: Gap[] = [];
|
||||
const laborRows = laborSheet(inputs.labor || [], gaps);
|
||||
const expense = expenseSheet(inputs.machines || [], gaps);
|
||||
const compare = compareSheet(inputs.materials || [], user.materials || {}, gaps);
|
||||
const basis = basisSheet(inputs, laborRows);
|
||||
const drivers: Row = Object.fromEntries(basis.drivers.map((d: Row) => [d.key, d]));
|
||||
const byKey: Row = Object.fromEntries(compare.map((row) => [row.key, row]));
|
||||
const fuels: Row = {};
|
||||
for (const [kind, key] of Object.entries(inputs.fuel_keys || {}))
|
||||
if ((key as string) in byKey) fuels[kind] = byKey[key as string];
|
||||
const blocks = (inputs.machines || []).map((m: Row) => machineBlock(m, drivers, fuels, gaps));
|
||||
const machine = blocks.map((b: Row, i: number) => ({
|
||||
no: i + 1,
|
||||
key: b.key,
|
||||
name: b.name,
|
||||
spec: b.spec,
|
||||
unit: b.unit,
|
||||
sum: b.sum,
|
||||
labor: b.labor,
|
||||
material: b.material,
|
||||
expense: b.expense,
|
||||
}));
|
||||
return {
|
||||
labor: laborRows,
|
||||
expense,
|
||||
basis,
|
||||
compare,
|
||||
material: materialSheet(compare),
|
||||
machine_blocks: blocks,
|
||||
machine,
|
||||
gaps,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
"""B09 연결된 사본 → 원가 수요 자원 키(PLAN 144 · 143-1 원천) — 서버 단독 · 읽기만.
|
||||
|
||||
연결된 사본 = `M02_Item_Snapshot.linked_snapshots`(지정 ∩ 같은 항목 사본 · B08 · B09 공용).
|
||||
사본마다 `일위대가.json` 담은 로직을 그 사본 단가 묶음(`단가.json`) · 입력값 · 고른자재로 돌리며
|
||||
실제로 부른 자원 줄(인력 LB · 기계 EQ · 자재 MT · 유가 MO)만 모음 — 단가 묶음 통째
|
||||
(고르기 후보 전부)는 안 씀 · 값은 쓰지 않음(단가는 원가 한 벌 `price_book.json` 이 맡음).
|
||||
구조물 조합 사본 없으면 그 구조물은 없음 · 일위대가 없는 사본은 자원 없음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Item_Prices as item_prices
|
||||
from M02_MasterTemplete.M02_Item_Snapshot import (
|
||||
PICKS_FILE,
|
||||
VALUES_FILE,
|
||||
linked_snapshots,
|
||||
structures_dir,
|
||||
)
|
||||
from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, PRICE_FILE, read_json
|
||||
|
||||
#: 원가 단가 목록에 오르는 자원 표
|
||||
RESOURCE_TABLES = ("LB", "EQ", "MT", "MO")
|
||||
|
||||
mf = item_prices.mf
|
||||
|
||||
|
||||
def used_keys(folder: Path) -> list[str]:
|
||||
"""사본 하나가 담은 로직을 돌릴 때 부른 자원 키 — 부른 차례 · 겹침 없음."""
|
||||
labor = read_json(folder / LABOR_FILE)
|
||||
if not isinstance(labor, dict):
|
||||
return []
|
||||
snapshot = item_prices.load_prices(read_json(folder / PRICE_FILE) or {})
|
||||
values = read_json(folder / VALUES_FILE) or {}
|
||||
picks = read_json(folder / PICKS_FILE) or {}
|
||||
seen: list[str] = []
|
||||
base_get = snapshot.get
|
||||
|
||||
def get(ref: str) -> dict[str, Any]:
|
||||
row = base_get(ref)
|
||||
key = str(row.get("키") or "")
|
||||
if key[:2] in RESOURCE_TABLES and key not in seen:
|
||||
seen.append(key)
|
||||
return row
|
||||
|
||||
snapshot.get = get # 이 사본 묶음 한 벌에만 — 마스터 캐시는 안 건드림
|
||||
for key in item_prices.mcb.logic_keys(labor.get("줄") or {}):
|
||||
if not key:
|
||||
continue
|
||||
given = {name: (one or {}).get("값") for name, one in (values.get(key) or {}).items()}
|
||||
try:
|
||||
with mf.mp.choosing((picks or {}).get(key)):
|
||||
mf.run(snapshot, key, given)
|
||||
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError):
|
||||
continue # 못 돈 로직 — 부른 데까지만
|
||||
return seen
|
||||
|
||||
|
||||
def linked_resource_keys(project_root: str | Path) -> list[str]:
|
||||
"""연결된 사본 전부가 부른 자원 키 — 구조물 id 차례 · 겹침 없음."""
|
||||
base = structures_dir(project_root)
|
||||
out: list[str] = []
|
||||
for _owner, linked in sorted(linked_snapshots(project_root).items()):
|
||||
if "일위대가" not in linked.get("부분", []):
|
||||
continue
|
||||
for key in used_keys(base / linked["폴더"]):
|
||||
if key not in out:
|
||||
out.append(key)
|
||||
return out
|
||||
@@ -0,0 +1,255 @@
|
||||
"""B09 원가 단가 한 벌 · 수요 모음(PLAN 144) — 서버 단독(M01 을 읽는 곳은 여기 하나).
|
||||
|
||||
원가 단가 = 프로젝트 한 벌 — 원가산출에서 M01 단가를 한 번 복사해 굳힘(`price_book.json`) ·
|
||||
[단가 갱신] 때만 새로 · 대가 · 산근도 이 단가로 셈(한 직종 · 한 자재 = 한 단가).
|
||||
|
||||
줄 = 수요 따라(사용자 승인 ① · 10-9) — 엑셀 목록을 기본으로 깔지 않음:
|
||||
연결된 사본이 부른 자원 키(`B09_Estimation_Linked` — 143-1 「연결된 사본」 원천 · 없으면 빈 표)
|
||||
+ 기계가 부르는 운전사(조종원직종 → 인력 원문번호) · 연료(주연료종류 → 유가 「종류:전국평균」)
|
||||
+ B08 총괄자재수량집계표 자재(M01 짝 없음 → 출처 빈칸 · 적용 단가는 사용자 입력)
|
||||
운전사 시간 계수 = M01 CC000336(시간환산 · 상여계수 · 휴지계수 분자 · 분모 곱).
|
||||
연료 지역 = 전국평균(M01 기계경비 로직 지역 첫 값) — 다른 지역 값은 자재 적용 단가를 고쳐 씀.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
|
||||
#: 운전사 시간 노임 계수 표(건설품셈 공통 8-1-3 · 건협기술-2230)
|
||||
DRIVER_TABLE = "CC000336"
|
||||
DRIVER_PARTS = ("시간환산", "상여계수", "휴지계수")
|
||||
#: 연료 지역 — M01 기계경비 로직 지역 고르기 첫 값
|
||||
FUEL_REGION = "전국평균"
|
||||
#: 자재품목 값 열 → 대비표 출처 칸
|
||||
MT_SOURCES = ("관급", "물가자료", "유통물가", "물가정보", "거래가격")
|
||||
|
||||
|
||||
def _f(value: Any) -> float | None:
|
||||
if isinstance(value, Decimal):
|
||||
return float(value)
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return None
|
||||
return float(value)
|
||||
|
||||
|
||||
def _master():
|
||||
return m01.loaded()[1]
|
||||
|
||||
|
||||
def _row(master, key: str) -> dict[str, Any] | None:
|
||||
return master.index.get(key[:2], {}).get(key)
|
||||
|
||||
|
||||
def labor_input(row: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"key": row["키"],
|
||||
"code": str(row.get("원문번호") or ""),
|
||||
"name": row.get("이름") or "",
|
||||
"spec": row.get("규격") or "",
|
||||
"unit": "인",
|
||||
"price": _f(row.get("값")),
|
||||
"source": row.get("출처") or "",
|
||||
}
|
||||
|
||||
|
||||
def machine_input(row: dict[str, Any]) -> dict[str, Any]:
|
||||
v = row.get("값") or {}
|
||||
return {
|
||||
"key": row["키"],
|
||||
"code": str(row.get("원문번호") or ""),
|
||||
"name": row.get("이름") or "",
|
||||
"spec": row.get("규격") or "",
|
||||
"price": _f(v.get("가격")),
|
||||
"loss": _f(v.get("손료계수")),
|
||||
"fuel": v.get("주연료종류") or None,
|
||||
"fuel_lph": _f(v.get("주연료")),
|
||||
"misc_pct": _f(v.get("잡재료율")),
|
||||
"crew": _f(v.get("조종원")),
|
||||
"crew_code": str(v["조종원직종"]) if v.get("조종원직종") else None,
|
||||
"source": (row.get("출처") or {}).get("가격") or row.get("원문") or "",
|
||||
}
|
||||
|
||||
|
||||
def material_input(row: dict[str, Any]) -> dict[str, Any]:
|
||||
"""자재품목(MT) · 유가(MO) 줄 → 대비표 입력 줄."""
|
||||
if row["키"].startswith("MO"):
|
||||
prices = {"유가": _f(row.get("값"))}
|
||||
pages = {"유가": row.get("규격") or None}
|
||||
origin = "유가"
|
||||
spec = f"{row.get('상세구분') or ''} {row.get('규격') or ''}".strip()
|
||||
unit = (row.get("단위") or "").replace("원/", "")
|
||||
else:
|
||||
prices = {s: _f(row.get(s)) for s in MT_SOURCES}
|
||||
pages = {s: (row.get("면수") or {}).get(s) for s in MT_SOURCES if s != "관급"}
|
||||
origin = "M01"
|
||||
spec = row.get("규격") or ""
|
||||
unit = row.get("단위") or ""
|
||||
return {
|
||||
"key": row["키"],
|
||||
"name": row.get("이름") or "",
|
||||
"spec": spec,
|
||||
"unit": unit,
|
||||
"origin": origin,
|
||||
"prices": prices,
|
||||
"pages": pages,
|
||||
"source": row.get("출처") or "",
|
||||
}
|
||||
|
||||
|
||||
def driver_factor(master) -> dict[str, Any] | None:
|
||||
table = _row(master, DRIVER_TABLE)
|
||||
if not table:
|
||||
return None
|
||||
parts = {r.get("구분"): r for r in table.get("줄") or []}
|
||||
if any(p not in parts for p in DRIVER_PARTS):
|
||||
return None
|
||||
top = bottom = 1
|
||||
text = []
|
||||
for p in DRIVER_PARTS:
|
||||
top *= int(parts[p]["분자"])
|
||||
bottom *= int(parts[p]["분모"])
|
||||
text.append(f"{parts[p]['분자']}/{parts[p]['분모']}")
|
||||
return {"num": top, "den": bottom, "text": " × ".join(text)}
|
||||
|
||||
|
||||
def fx_inputs(master) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{"key": key, "name": row.get("이름") or "", "unit": row.get("단위") or "",
|
||||
"value": _f(row.get("값")), "date": row.get("규격") or ""}
|
||||
for key, row in sorted(master.index.get("FX", {}).items())
|
||||
] # fmt: skip
|
||||
|
||||
|
||||
def _fuel_row(master, kind: str) -> dict[str, Any] | None:
|
||||
return master.numbers.get("MO", {}).get(f"{kind}:{FUEL_REGION}")
|
||||
|
||||
|
||||
def demand(master, keys: list[str]) -> dict[str, Any]:
|
||||
"""수요 키 → 줄 차례(노무 · 기계 · 자재) + 연료 짝 · 찾지 못한 키."""
|
||||
labor: list[str] = []
|
||||
machines: list[str] = []
|
||||
materials: list[str] = []
|
||||
missing: list[str] = []
|
||||
fuel_keys: dict[str, str] = {}
|
||||
|
||||
def add(bucket: list[str], key: str) -> None:
|
||||
if key not in bucket:
|
||||
bucket.append(key)
|
||||
|
||||
for key in keys:
|
||||
if _row(master, key) is None:
|
||||
missing.append(key)
|
||||
elif key.startswith("LB"):
|
||||
add(labor, key)
|
||||
elif key.startswith("EQ"):
|
||||
add(machines, key)
|
||||
elif key.startswith(("MT", "MO")):
|
||||
add(materials, key)
|
||||
for key in machines:
|
||||
v = _row(master, key).get("값") or {}
|
||||
code = v.get("조종원직종")
|
||||
driver = master.numbers.get("LB", {}).get(str(code)) if code and v.get("조종원") else None
|
||||
if driver:
|
||||
add(labor, driver["키"])
|
||||
kind = v.get("주연료종류")
|
||||
fuel = _fuel_row(master, kind) if kind and v.get("주연료") else None
|
||||
if fuel:
|
||||
add(materials, fuel["키"])
|
||||
fuel_keys[kind] = fuel["키"]
|
||||
return {
|
||||
"labor": labor,
|
||||
"machines": machines,
|
||||
"materials": materials,
|
||||
"fuel_keys": fuel_keys,
|
||||
"missing": missing,
|
||||
}
|
||||
|
||||
|
||||
def _convert(master, key: str) -> dict[str, Any]:
|
||||
row = _row(master, key)
|
||||
if key.startswith("LB"):
|
||||
return labor_input(row)
|
||||
if key.startswith("EQ"):
|
||||
return machine_input(row)
|
||||
return material_input(row)
|
||||
|
||||
|
||||
def refresh_book(keys: list[str]) -> dict[str, Any]:
|
||||
"""[단가 갱신] — 수요 키 전부를 지금 M01 값으로 새로 굳힘."""
|
||||
master = _master()
|
||||
want = demand(master, keys)
|
||||
rows = {
|
||||
key: _convert(master, key)
|
||||
for key in [*want["labor"], *want["machines"], *want["materials"]]
|
||||
}
|
||||
return {
|
||||
"made": datetime.now().isoformat(timespec="seconds"),
|
||||
"rows": rows,
|
||||
"driver_factor": driver_factor(master),
|
||||
"fx": fx_inputs(master),
|
||||
}
|
||||
|
||||
|
||||
def extend_book(book: dict[str, Any] | None, keys: list[str]) -> dict[str, Any]:
|
||||
"""[저장] — 책에 없는 새 줄만 더함(있는 줄 · 계수 · 환율은 안 바꿈) · 책이 없으면 새로 굳힘."""
|
||||
if book is None:
|
||||
return refresh_book(keys)
|
||||
master = _master()
|
||||
want = demand(master, keys)
|
||||
rows = dict(book["rows"])
|
||||
for key in [*want["labor"], *want["machines"], *want["materials"]]:
|
||||
if key not in rows:
|
||||
rows[key] = _convert(master, key)
|
||||
return {**book, "rows": rows}
|
||||
|
||||
|
||||
def b08_material_rows(b08_sheets: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
"""B08 총괄자재수량집계표 줄(수량 있는 줄만) → 자재 입력 줄(M01 짝 없음 · 출처 빈칸)."""
|
||||
rows = ((b08_sheets or {}).get("material") or {}).get("rows") or []
|
||||
out = []
|
||||
for row in rows:
|
||||
if row.get("amount") is None:
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"key": f"b08:{row['key']}",
|
||||
"name": row.get("group") or "",
|
||||
"spec": row.get("spec") or "",
|
||||
"unit": row.get("unit") or "",
|
||||
"origin": "B08",
|
||||
"prices": {},
|
||||
"pages": {},
|
||||
"source": "B08 총괄자재수량집계표",
|
||||
"qty": row.get("amount"),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def build_inputs(
|
||||
book: dict[str, Any] | None, keys: list[str], b08_sheets: dict[str, Any] | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""표 셈 입력 — 줄 = 수요 · 값 = 굳힌 책(없는 줄은 지금 M01 값 · `fresh` 표시)."""
|
||||
master = _master()
|
||||
want = demand(master, keys)
|
||||
saved = (book or {}).get("rows") or {}
|
||||
|
||||
def pick(key: str) -> dict[str, Any]:
|
||||
if key in saved:
|
||||
return saved[key]
|
||||
return {**_convert(master, key), "fresh": True}
|
||||
|
||||
factor = (book or {}).get("driver_factor") if book else driver_factor(master)
|
||||
return {
|
||||
"labor": [pick(k) for k in want["labor"]],
|
||||
"machines": [pick(k) for k in want["machines"]],
|
||||
"materials": [pick(k) for k in want["materials"]] + b08_material_rows(b08_sheets),
|
||||
"fuel_keys": want["fuel_keys"],
|
||||
"driver_factor": factor,
|
||||
"fx": (book or {}).get("fx") if book else fx_inputs(master),
|
||||
"missing": want["missing"],
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
"""B09 원가계산 길(PLAN 144 · 145) — 결과는 저장하지 않고 GET 때 서버가 셈(B08 꼴).
|
||||
|
||||
GET /api/projects/{id}/estimation
|
||||
입력(굳힌 단가 · 수요 · B08 수량) + 사용자 값 → 표 전부
|
||||
PUT /api/projects/{id}/estimation {user}
|
||||
[저장] — 사용자 값 · 책에 없는 새 단가 줄만 굳힘 → 서버가 정본으로 다시 셈
|
||||
POST /api/projects/{id}/estimation/prices
|
||||
[단가 갱신] — 수요 단가 전부를 지금 M01 값으로 새로 굳힘
|
||||
|
||||
저장 자리 = 프로젝트 안 `B09_Estimation/v1/`(`B09_Estimation_Store`) ·
|
||||
단가 = `B09_Estimation_Prices` ·
|
||||
수요 = `B09_Estimation_Linked`(연결된 사본) · 셈 = `B09_Estimation_Calc_*`(화면 짝과 같은 식).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Body
|
||||
|
||||
from B08_Quantity.B08_Quantity_Router_Hydro import _project_root
|
||||
from B08_Quantity.B08_Quantity_Router_Material import material_sheets
|
||||
from B09_Estimation import B09_Estimation_Prices as prices
|
||||
from B09_Estimation import B09_Estimation_Store as store
|
||||
from B09_Estimation.B09_Estimation_Calc_Base import compute_base
|
||||
from B09_Estimation.B09_Estimation_Linked import linked_resource_keys
|
||||
|
||||
router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"])
|
||||
|
||||
|
||||
async def _b08(project_id: UUID, notes: list[str]) -> dict[str, Any]:
|
||||
"""B08 수량 원천 — 못 읽으면 빈칸 + 알림(B09 는 B08 를 고치지 않음)."""
|
||||
out: dict[str, Any] = {}
|
||||
try:
|
||||
out["materials"] = await material_sheets(project_id)
|
||||
except Exception as error: # noqa: BLE001 — 원천 하나가 막혀도 나머지 표는 그림
|
||||
notes.append(f"B08 자재 집계표 못 읽음 — {getattr(error, 'detail', error)}")
|
||||
out["materials"] = None
|
||||
return out
|
||||
|
||||
|
||||
async def payload(project_id: UUID, root: Path) -> dict[str, Any]:
|
||||
notes: list[str] = []
|
||||
b08 = await _b08(project_id, notes)
|
||||
keys = await asyncio.to_thread(linked_resource_keys, root)
|
||||
if not keys:
|
||||
notes.append(
|
||||
"연결된 구조물 조합 사본 없음 — 노무 · 경비 · 중기 단가 줄은 사본이 연결되면 생김"
|
||||
)
|
||||
book = await asyncio.to_thread(store.read_book, root)
|
||||
inputs = await asyncio.to_thread(prices.build_inputs, book, keys, b08["materials"])
|
||||
user = await asyncio.to_thread(store.read_user, root)
|
||||
return {
|
||||
"inputs": inputs,
|
||||
"user": user,
|
||||
"book": {"made": book.get("made")} if book else None,
|
||||
"sheets": compute_base(inputs, user),
|
||||
"notes": notes,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{project_id}/estimation")
|
||||
async def estimation(project_id: UUID) -> dict[str, Any]:
|
||||
root = await _project_root(project_id)
|
||||
return await payload(project_id, root)
|
||||
|
||||
|
||||
@router.put("/{project_id}/estimation")
|
||||
async def save_estimation(project_id: UUID, body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
root = await _project_root(project_id)
|
||||
await asyncio.to_thread(store.write_user, root, body.get("user"))
|
||||
keys = await asyncio.to_thread(linked_resource_keys, root)
|
||||
book = await asyncio.to_thread(store.read_book, root)
|
||||
book = await asyncio.to_thread(prices.extend_book, book, keys)
|
||||
await asyncio.to_thread(store.write_book, root, book)
|
||||
return await payload(project_id, root)
|
||||
|
||||
|
||||
@router.post("/{project_id}/estimation/prices")
|
||||
async def refresh_prices(project_id: UUID) -> dict[str, Any]:
|
||||
root = await _project_root(project_id)
|
||||
keys = await asyncio.to_thread(linked_resource_keys, root)
|
||||
book = await asyncio.to_thread(prices.refresh_book, keys)
|
||||
await asyncio.to_thread(store.write_book, root, book)
|
||||
return await payload(project_id, root)
|
||||
@@ -0,0 +1,111 @@
|
||||
"""B09 원가 저장 자리(PLAN 144-1) — 프로젝트 안 `B09_Estimation/v1/`.
|
||||
|
||||
price_book.json 굳힌 단가 한 벌 {"made": 시각, "rows": {키: 입력 줄},
|
||||
"driver_factor": …, "fx": […]}
|
||||
— [단가 갱신] 때만 M01 로 새로 ·
|
||||
[저장] 때 책에 없는 새 줄만 더함(있는 줄 안 바꿈)
|
||||
user_values.json 사용자 값 {"materials": {자재 키: {applied?, supply?}}, "estimate": {…}}
|
||||
— 고친 칸만(키가 있으면 그 값 · null = 빈칸 · 없으면 기본)
|
||||
|
||||
결과(표)는 저장하지 않음 — GET 때 서버가 셈(B08 꼴).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B09_Estimation.B09_Estimation_Calc_Base import SUPPLY
|
||||
|
||||
FOLDER = ("B09_Estimation", "v1")
|
||||
BOOK = "price_book.json"
|
||||
USER = "user_values.json"
|
||||
|
||||
#: 원가계산 사용자 입력 칸 — 요율 고르는 값 · 손입력(엑셀 총괄 · 원가계산서 · 내역 손입력 칸)
|
||||
ESTIMATE_FIELDS = (
|
||||
"estimated_price", # 추정가격(원) — 요율 구간 고름
|
||||
"duration_days", # 공사기간(일)
|
||||
"work_type", # 공종(글)
|
||||
"safety_base", # 산업안전보건관리비 기초액(원)
|
||||
"profit_adjust", # 이윤 보정(원 · 빼는 값)
|
||||
"vat_adjust", # 부가가치세 조정(원)
|
||||
"indirect_material", # 간접재료비(원)
|
||||
"scrap", # 작업설 부산물(원 · 빼는 값)
|
||||
"procurement_fee_pct", # 조달수수료율(%)
|
||||
)
|
||||
|
||||
|
||||
def folder(project_root: str | Path) -> Path:
|
||||
return Path(project_root).joinpath(*FOLDER)
|
||||
|
||||
|
||||
def _read(path: Path) -> dict[str, Any] | None:
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def _write(path: Path, data: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temp = path.with_suffix(".tmp")
|
||||
temp.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
os.replace(temp, path)
|
||||
|
||||
|
||||
def read_book(project_root: str | Path) -> dict[str, Any] | None:
|
||||
book = _read(folder(project_root) / BOOK)
|
||||
if book is None or not isinstance(book.get("rows"), dict):
|
||||
return None
|
||||
return book
|
||||
|
||||
|
||||
def write_book(project_root: str | Path, book: dict[str, Any]) -> None:
|
||||
_write(folder(project_root) / BOOK, book)
|
||||
|
||||
|
||||
def _num(value: Any) -> float | None:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return None
|
||||
return float(value) if math.isfinite(value) else None
|
||||
|
||||
|
||||
def clean_user(raw: Any) -> dict[str, Any]:
|
||||
"""사용자 값 다듬기 — 모르는 칸 버림 · 수 칸은 수 · null(빈칸)만."""
|
||||
raw = raw if isinstance(raw, dict) else {}
|
||||
materials: dict[str, dict[str, Any]] = {}
|
||||
for key, cell in (raw.get("materials") or {}).items():
|
||||
if not isinstance(cell, dict):
|
||||
continue
|
||||
out: dict[str, Any] = {}
|
||||
if "applied" in cell:
|
||||
out["applied"] = _num(cell["applied"])
|
||||
if cell.get("supply") in SUPPLY:
|
||||
out["supply"] = cell["supply"]
|
||||
if out:
|
||||
materials[str(key)] = out
|
||||
estimate: dict[str, Any] = {}
|
||||
given = raw.get("estimate") if isinstance(raw.get("estimate"), dict) else {}
|
||||
for field in ESTIMATE_FIELDS:
|
||||
if field not in given:
|
||||
continue
|
||||
value = given[field]
|
||||
estimate[field] = (str(value) if value else None) if field == "work_type" else _num(value)
|
||||
rates = given.get("rates")
|
||||
if isinstance(rates, dict):
|
||||
estimate["rates"] = {str(k): _num(v) for k, v in rates.items()}
|
||||
return {"materials": materials, "estimate": estimate}
|
||||
|
||||
|
||||
def read_user(project_root: str | Path) -> dict[str, Any]:
|
||||
return clean_user(_read(folder(project_root) / USER))
|
||||
|
||||
|
||||
def write_user(project_root: str | Path, user: dict[str, Any]) -> dict[str, Any]:
|
||||
clean = clean_user(user)
|
||||
_write(folder(project_root) / USER, clean)
|
||||
return clean
|
||||
@@ -0,0 +1,32 @@
|
||||
/* B09 원가계산 — 탭 줄 · 안내 글. 색은 테마 변수만(밝음 · 어두움 같이). */
|
||||
|
||||
.b09-estimation {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-16) var(--spacing-24); /* B08 본문과 같은 값 */
|
||||
}
|
||||
|
||||
.b09-estimation .ui-general-block__empty,
|
||||
.b09-estimation__hint {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.b09-estimation__hint {
|
||||
margin: 0 0 var(--spacing-8);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.b09-estimation__tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-4);
|
||||
padding-bottom: var(--spacing-8);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* 표 머리 — 좁은 열에서 말줄임 대신 줄바꿈 */
|
||||
[data-b09-table] thead th {
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@@ -1,39 +1,251 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_UI_Shell.ts
|
||||
* 로그인 후 09: 6차 워크플로우 (원가계산) — **빈 화면** (PLAN 7-3)
|
||||
* 로그인 후 09: 6차 워크플로우 (원가계산) — 탭 화면 (PLAN 144-1)
|
||||
*
|
||||
* - 옛 원가계산 코드는 통째로 `old_code/B09_Estimation/` 에 있음(지우지 않음).
|
||||
* - 다시 짤 때까지 메뉴와 주소만 남김 — 제목·단계 막대만 뜨고 탭·계산은 없음.
|
||||
* 원본 = 기번3 공통 원가산출 엑셀 · 탭 하나 = 화면 표 하나(`…_UI_Tabs` 차례) · 옛 원가 코드 안 봄
|
||||
* 자료 = GET …/estimation(입력 · 사용자 값 · 서버가 셈한 표) · 조작 = 캐시(`b09-estimation`) +
|
||||
* 짝 계산(`…_Calc_*.ts`)으로 바로 다시 셈 · [저장] = PUT → 서버 정본 · [단가 갱신] = POST …/prices
|
||||
* 호표 칸 누름 = 그 탭 그 줄로 이동 · 자동 저장 없음
|
||||
* ========================================================================== */
|
||||
|
||||
import "@ui/ui_template_general_layout.css";
|
||||
import "./B09_Estimation_UI_Page.css";
|
||||
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
|
||||
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
||||
import {
|
||||
createButton,
|
||||
hideLoadingOverlay,
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
||||
import { createDataTable } from "@ui/ui_template_data_table";
|
||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
|
||||
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
|
||||
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
||||
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav";
|
||||
import { computeBase, type Row } from "./B09_Estimation_Calc_Base";
|
||||
import {
|
||||
buildCheck,
|
||||
rowIndex,
|
||||
TABS,
|
||||
type Link,
|
||||
type TabBuild,
|
||||
type TabContext,
|
||||
type TabId,
|
||||
} from "./B09_Estimation_UI_Tabs";
|
||||
import {
|
||||
buildBasis,
|
||||
buildCompare,
|
||||
buildExpense,
|
||||
buildLabor,
|
||||
buildMachine,
|
||||
buildMachineBlocks,
|
||||
buildMaterial,
|
||||
} from "./B09_Estimation_UI_Tabs_Base";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
const LATER: Record<string, string> = {
|
||||
dg_list: "일위대가목록표 — 145-2(다른 창)가 들어오면 생김",
|
||||
dg: "일위대가표 — 145-2(다른 창)가 들어오면 생김",
|
||||
sg_list: "단가산출근거목록표 — 145-1(다른 창)가 들어오면 생김",
|
||||
sg: "단가산출근거 — 145-1(다른 창)가 들어오면 생김",
|
||||
};
|
||||
|
||||
const BUILDERS: Partial<Record<TabId, TabBuild>> = {
|
||||
check: buildCheck,
|
||||
basis: buildBasis,
|
||||
machine: buildMachine,
|
||||
machine_blocks: buildMachineBlocks,
|
||||
material: buildMaterial,
|
||||
labor: buildLabor,
|
||||
expense: buildExpense,
|
||||
compare: buildCompare,
|
||||
};
|
||||
|
||||
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...init,
|
||||
});
|
||||
const body = (await response.json().catch(() => null)) as { detail?: unknown } | null;
|
||||
if (!response.ok) {
|
||||
const detail = body?.detail;
|
||||
throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`);
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
|
||||
function note(textValue: string): HTMLElement {
|
||||
const p = document.createElement("p");
|
||||
p.className = "ui-general-block__empty";
|
||||
p.textContent = textValue;
|
||||
return p;
|
||||
}
|
||||
|
||||
/** 브라우저 셈 — 서버 짝과 같은 식(조작 중) */
|
||||
export function computeSheets(payload: Row, user: Row): Row {
|
||||
return computeBase(payload.inputs, user);
|
||||
}
|
||||
|
||||
export function renderB09Estimation(root: HTMLElement): void {
|
||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||||
let payload: Row | null = null;
|
||||
let user: Row = {};
|
||||
let sheets: Row = {};
|
||||
let current: TabId = "check";
|
||||
|
||||
const note = document.createElement("p");
|
||||
note.className = "b09-estimation__rebuilding";
|
||||
note.textContent = L("B09_Estimation_Rebuilding");
|
||||
const tabBar = document.createElement("div");
|
||||
tabBar.className = "b09-estimation__tabs";
|
||||
tabBar.setAttribute("role", "tablist");
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "b09-estimation__panel";
|
||||
panel.setAttribute("role", "tabpanel");
|
||||
const body = document.createElement("div");
|
||||
body.append(note);
|
||||
body.className = "b09-estimation";
|
||||
body.append(tabBar, panel);
|
||||
|
||||
const made = document.createElement("p");
|
||||
made.className = "b09-estimation__hint";
|
||||
|
||||
const recompute = (): void => {
|
||||
if (payload) sheets = computeSheets(payload, user);
|
||||
};
|
||||
|
||||
const setUser = (next: Row): void => {
|
||||
user = next;
|
||||
if (projectId) writeState("b09-estimation", user, projectId);
|
||||
recompute();
|
||||
show(current);
|
||||
};
|
||||
|
||||
/** 탭 하나 그림 · focus = 이동해 온 줄 키 */
|
||||
const show = (tab: TabId, focus?: string): void => {
|
||||
current = tab;
|
||||
tabBar.replaceChildren(
|
||||
...TABS.map(([id, label]) => {
|
||||
const button = createButton({
|
||||
label,
|
||||
variant: id === tab ? "filled" : "ghost",
|
||||
size: "sm",
|
||||
onClick: () => id !== current && show(id),
|
||||
});
|
||||
button.setAttribute("role", "tab");
|
||||
button.setAttribute("aria-selected", String(id === tab));
|
||||
button.dataset.b09Tab = id;
|
||||
return button;
|
||||
}),
|
||||
);
|
||||
if (!projectId) return void panel.replaceChildren(note("프로젝트를 먼저 고르세요."));
|
||||
if (!payload) return void panel.replaceChildren(note("원가 자료를 불러오는 중…"));
|
||||
const build = BUILDERS[tab];
|
||||
if (!build) return void panel.replaceChildren(note(LATER[tab] ?? "다음 단계에서 채움"));
|
||||
const ctx: TabContext = { sheets, payload, user, setUser };
|
||||
const spec = build(ctx);
|
||||
if (typeof spec === "string") return void panel.replaceChildren(note(spec));
|
||||
const goto = (link: Link | null): void => {
|
||||
if (link) show(link.tab, link.key);
|
||||
};
|
||||
const table = createDataTable({
|
||||
columns: spec.columns,
|
||||
rows: spec.rows,
|
||||
total: spec.total ?? null,
|
||||
frozen: spec.frozen ?? 0,
|
||||
empty: "",
|
||||
caption: TABS.find(([id]) => id === tab)?.[1],
|
||||
editable: spec.editable,
|
||||
onEdit: spec.onEdit,
|
||||
onCellClick: (i, key) => {
|
||||
if (spec.onCell) spec.onCell(i, key);
|
||||
if (spec.link && !spec.editable?.(i, key)) goto(spec.link(i, key));
|
||||
},
|
||||
});
|
||||
table.root.dataset.b09Table = tab;
|
||||
panel.replaceChildren(...(spec.hint ? [note(spec.hint)] : []), table.root);
|
||||
if (focus !== undefined) {
|
||||
const index = rowIndex(spec.rows, focus);
|
||||
if (index >= 0) requestAnimationFrame(() => table.focusRow(index));
|
||||
}
|
||||
};
|
||||
|
||||
const apply = (next: Row, fromServer: boolean): void => {
|
||||
payload = next;
|
||||
const draft = projectId && !fromServer ? readState<Row>("b09-estimation", projectId) : null;
|
||||
user = draft ?? next.user ?? {};
|
||||
recompute();
|
||||
made.textContent = next.book?.made
|
||||
? `단가 굳힘 ${String(next.book.made).replace("T", " ")} — [단가 갱신] 때만 새로`
|
||||
: "단가 아직 안 굳힘 — [저장] 이나 [단가 갱신] 때 M01 값을 굳힘";
|
||||
show(current);
|
||||
};
|
||||
|
||||
const run = async (path: string, method: string, done: string, init?: Row): Promise<void> => {
|
||||
if (!projectId) return;
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
const next = await request<Row>(`/projects/${projectId}${path}`, {
|
||||
method,
|
||||
...(init ? { body: JSON.stringify(init) } : {}),
|
||||
});
|
||||
clearState("b09-estimation", projectId);
|
||||
apply(next, true);
|
||||
showToast(done, "success");
|
||||
} catch (error) {
|
||||
showToast(`하지 못했습니다 — ${String(error)}`, "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
};
|
||||
|
||||
const save = createButton({
|
||||
label: "저장",
|
||||
variant: "filled",
|
||||
onClick: () => void run("/estimation", "PUT", "원가 사용자 값을 저장했습니다.", { user }),
|
||||
});
|
||||
save.dataset.b09Save = "";
|
||||
const refresh = createButton({
|
||||
label: "단가 갱신",
|
||||
variant: "ghost",
|
||||
onClick: () =>
|
||||
void run("/estimation/prices", "POST", "단가를 지금 M01 값으로 새로 굳혔습니다."),
|
||||
});
|
||||
refresh.dataset.b09Refresh = "";
|
||||
const section = document.createElement("section");
|
||||
section.className = "ui-collapsible ui-sidebar-section ui-sidebar-card";
|
||||
const heading = document.createElement("h3");
|
||||
heading.className = "ui-collapsible__title";
|
||||
heading.textContent = "원가 단가";
|
||||
section.append(heading, made, refresh);
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "ui-sidebar-actions";
|
||||
actions.append(save);
|
||||
const left = document.createElement("div");
|
||||
left.className = "ui-sidebar-panel b09-options";
|
||||
left.append(section, actions);
|
||||
attachCollapsible(left);
|
||||
|
||||
const layout = createWorkflowLayout({
|
||||
title: L("B09_Estimation_Title"),
|
||||
steps: workflowSteps(),
|
||||
activeStep: 6,
|
||||
mainContent: body,
|
||||
leftPanel: left,
|
||||
routes: WORKFLOW_STEP_ROUTES,
|
||||
onStepClick: (stepIndex: number) => {
|
||||
if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
|
||||
},
|
||||
});
|
||||
root.append(layout.root);
|
||||
show(current);
|
||||
|
||||
if (projectId)
|
||||
request<Row>(`/projects/${projectId}/estimation`).then(
|
||||
(next) => apply(next, false),
|
||||
(error: unknown) =>
|
||||
panel.replaceChildren(note(`원가 자료를 불러오지 못했습니다 — ${String(error)}`)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_UI_Tabs.ts
|
||||
* B09 탭 목록 · 표 짜기 공용(PLAN 144-1) — 탭 하나 = 화면 표 하나(실무 엑셀 탭 차례).
|
||||
*
|
||||
* 탭 = 연결 점검(목차 · 안내) · 원가 · 총괄 · 내역 · 대가 둘 · 산근 둘 · 기초 · 중기 둘 · 단가 넷 · 집계표 넷
|
||||
* 표 = 공용 `createDataTable` · 「연결 없음」 칸 = 빈칸 · 호표 칸 누름 = 그 탭 그 줄로 이동(`link`)
|
||||
* 탭별 표 = `…_UI_Tabs_Base`(144) · `…_UI_Tabs_Cost`(145) · 여기는 차례 · 꼴 · 연결 점검만
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DataColumn, DataRow } from "@ui/ui_template_data_table";
|
||||
import type { Row } from "./B09_Estimation_Calc_Base";
|
||||
|
||||
export type TabId =
|
||||
| "check"
|
||||
| "cost"
|
||||
| "total"
|
||||
| "bill"
|
||||
| "dg_list"
|
||||
| "dg"
|
||||
| "sg_list"
|
||||
| "sg"
|
||||
| "basis"
|
||||
| "machine"
|
||||
| "machine_blocks"
|
||||
| "material"
|
||||
| "labor"
|
||||
| "expense"
|
||||
| "compare"
|
||||
| "sum_material"
|
||||
| "sum_labor"
|
||||
| "sum_expense"
|
||||
| "sum_machine";
|
||||
|
||||
/** 엑셀 탭 차례 — [id, 탭 이름] */
|
||||
export const TABS: Array<[TabId, string]> = [
|
||||
["check", "연결 점검"],
|
||||
["cost", "공사원가계산서"],
|
||||
["total", "총괄설계내역서"],
|
||||
["bill", "설계내역서"],
|
||||
["dg_list", "일위대가목록표"],
|
||||
["dg", "일위대가표"],
|
||||
["sg_list", "단가산출근거목록표"],
|
||||
["sg", "단가산출근거"],
|
||||
["basis", "환율및기초자료"],
|
||||
["machine", "중기목록표"],
|
||||
["machine_blocks", "중기사용료"],
|
||||
["material", "재료비목록표"],
|
||||
["labor", "노무비목록표"],
|
||||
["expense", "경비목록표"],
|
||||
["compare", "자재단가대비표"],
|
||||
["sum_material", "재료비수량금액집계표"],
|
||||
["sum_labor", "노무비수량금액집계표"],
|
||||
["sum_expense", "경비수량금액집계표"],
|
||||
["sum_machine", "중기시간금액집계표"],
|
||||
];
|
||||
|
||||
export const tabName = (id: string): string => TABS.find(([t]) => t === id)?.[1] ?? id;
|
||||
|
||||
/** 다른 탭 줄 — 키가 같은 줄로 이동 */
|
||||
export interface Link {
|
||||
tab: TabId;
|
||||
key: string;
|
||||
}
|
||||
|
||||
/** 페이지가 탭 짜기에 주는 것 */
|
||||
export interface TabContext {
|
||||
sheets: Row;
|
||||
payload: Row;
|
||||
user: Row;
|
||||
/** 사용자 값 고침 → 캐시 · 바로 다시 셈 */
|
||||
setUser: (user: Row) => void;
|
||||
}
|
||||
|
||||
/** 탭 한 장 = 표 하나 */
|
||||
export interface TabTable {
|
||||
columns: DataColumn[];
|
||||
rows: DataRow[];
|
||||
total?: DataRow | null;
|
||||
frozen?: number;
|
||||
/** 표 위 안내 글(근거 · 끝수) */
|
||||
hint?: string;
|
||||
/** 칸 누름 → 갈 줄(호표 칸) */
|
||||
link?: (index: number, key: string) => Link | null;
|
||||
editable?: (index: number, key: string) => boolean;
|
||||
onEdit?: (index: number, key: string, text: string) => void;
|
||||
/** 칸 누름 → 그 자리 동작(예 관급 · 별도 표시 바꿈) */
|
||||
onCell?: (index: number, key: string) => void;
|
||||
}
|
||||
|
||||
export type TabBuild = (ctx: TabContext) => TabTable | string;
|
||||
|
||||
/* ── 열 꼴 ────────────────────────────────────────────────────────────── */
|
||||
|
||||
export const text = (key: string, label: string, width?: number, group?: string[]): DataColumn => ({
|
||||
key,
|
||||
label,
|
||||
width,
|
||||
group,
|
||||
});
|
||||
|
||||
export const money = (key: string, label: string, group?: string[], decimals = 0): DataColumn => ({
|
||||
key,
|
||||
label,
|
||||
group,
|
||||
kind: "number",
|
||||
decimals,
|
||||
width: 96,
|
||||
});
|
||||
|
||||
export const qty = (key: string, label: string, decimals = 4, group?: string[]): DataColumn => ({
|
||||
key,
|
||||
label,
|
||||
group,
|
||||
kind: "number",
|
||||
decimals,
|
||||
trim: true,
|
||||
width: 76,
|
||||
});
|
||||
|
||||
/** 글 → 값 · 빈칸 = null · 수가 아니면 undefined(무시) */
|
||||
export function parseNumber(value: string): number | null | undefined {
|
||||
const t = value.replace(/,/g, "").trim();
|
||||
if (!t) return null;
|
||||
const v = Number(t);
|
||||
return Number.isFinite(v) ? v : undefined;
|
||||
}
|
||||
|
||||
/** 줄 키로 줄 번호 찾기(이동) */
|
||||
export function rowIndex(rows: DataRow[], key: string): number {
|
||||
return rows.findIndex((row) => row.key === key);
|
||||
}
|
||||
|
||||
/* ── 연결 점검(목차 · 안내) ───────────────────────────────────────────── */
|
||||
|
||||
export const buildCheck: TabBuild = ({ sheets, payload }): TabTable => {
|
||||
const gaps: Row[] = sheets.gaps || [];
|
||||
const rows: DataRow[] = gaps.map((g, i) => ({
|
||||
no: i + 1,
|
||||
key: `${g.tab}|${g.key}|${i}`,
|
||||
tab: tabName(g.tab),
|
||||
name: g.name,
|
||||
why: g.why,
|
||||
}));
|
||||
const notes: string[] = [...(payload.notes || [])];
|
||||
const missing: string[] = payload.inputs?.missing || [];
|
||||
if (missing.length) notes.push(`M01 에 없는 키 ${missing.length} — ${missing.join(" · ")}`);
|
||||
return {
|
||||
columns: [
|
||||
qty("no", "번호", 0),
|
||||
text("tab", "탭", 150),
|
||||
text("name", "명칭", 260),
|
||||
text("why", "까닭", 320),
|
||||
],
|
||||
rows,
|
||||
frozen: 1,
|
||||
hint:
|
||||
`연결 없음 ${gaps.length}곳 — 줄을 누르면 그 탭 그 줄로 · 빈칸 = 값을 낸 원천이 없음(0 을 채우지 않음)` +
|
||||
(notes.length ? ` · ${notes.join(" · ")}` : ""),
|
||||
link: (i) => {
|
||||
const g = gaps[i];
|
||||
return g ? { tab: g.tab as TabId, key: g.key } : null;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,275 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_UI_Tabs_Base.ts
|
||||
* B09 바탕 단가 탭 일곱(PLAN 144-2~4) — 노무비 · 경비목록표 · 환율및기초자료 · 자재단가대비표 ·
|
||||
* 재료비목록표 · 중기사용료 · 중기목록표(실무 엑셀 꼴 · 값 = `B09_Estimation_Calc_Base`).
|
||||
*
|
||||
* 고칠 칸 = 자재단가대비표 적용 단가(빈칸으로 지우면 최저 자동) · 관급 / 별도 칸 누름 = 표시 바꿈
|
||||
* 이동 = 재료비목록표 줄 → 대비표 · 중기목록표 줄 → 중기사용료 블록 · 블록 줄 → 경비 · 기초 · 대비표
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DataRow } from "@ui/ui_template_data_table";
|
||||
import { SOURCES, type Row } from "./B09_Estimation_Calc_Base";
|
||||
import {
|
||||
money,
|
||||
parseNumber,
|
||||
qty,
|
||||
text,
|
||||
type TabBuild,
|
||||
type TabId,
|
||||
type TabTable,
|
||||
} from "./B09_Estimation_UI_Tabs";
|
||||
|
||||
const LINK_HINT = "줄을 누르면 단가를 낸 탭 · 줄로 이동";
|
||||
|
||||
/** 단가 줄 표(노무비 · 경비목록표) — 호표 · 명칭 · 규격 · 단위 · 단가 · 비고(출처) */
|
||||
function priceList(rows: Row[], unitPrice: string, hint: string): TabTable {
|
||||
return {
|
||||
columns: [
|
||||
qty("no", "호표", 0),
|
||||
text("name", "명칭", 200),
|
||||
text("spec", "규격", 140),
|
||||
text("unit", "단위", 56),
|
||||
money("price", unitPrice),
|
||||
text("source", "비고", 220),
|
||||
],
|
||||
rows: rows.map((r) => ({ ...r })),
|
||||
frozen: 2,
|
||||
hint,
|
||||
};
|
||||
}
|
||||
|
||||
export const buildLabor: TabBuild = ({ sheets, payload }) =>
|
||||
priceList(
|
||||
sheets.labor || [],
|
||||
"단가(원/인)",
|
||||
`노임 = 굳힌 단가(${madeText(payload)}) · M01 인력 · 바꾸려면 [단가 갱신]`,
|
||||
);
|
||||
|
||||
export const buildExpense: TabBuild = ({ sheets, payload }) =>
|
||||
priceList(
|
||||
sheets.expense || [],
|
||||
"기준가(천원)",
|
||||
`기계 손료 기준가 = 굳힌 단가(${madeText(payload)}) · M01 기계 가격 · 바꾸려면 [단가 갱신]`,
|
||||
);
|
||||
|
||||
function madeText(payload: Row): string {
|
||||
return payload.book?.made ? `${String(payload.book.made).slice(0, 10)} 굳힘` : "아직 안 굳힘";
|
||||
}
|
||||
|
||||
export const buildBasis: TabBuild = ({ sheets }): TabTable => {
|
||||
const basis: Row = sheets.basis || { fx: [], drivers: [] };
|
||||
const rows: DataRow[] = [
|
||||
...basis.fx.map((f: Row) => ({
|
||||
part: "1. 환율",
|
||||
key: f.key,
|
||||
name: f.name,
|
||||
spec: f.date,
|
||||
unit: f.unit,
|
||||
day: null,
|
||||
factor: "",
|
||||
value: f.value,
|
||||
})),
|
||||
...basis.drivers.map((d: Row) => ({
|
||||
part: "2. 인건비",
|
||||
key: d.key,
|
||||
name: d.name,
|
||||
spec: `직종 ${d.key}`,
|
||||
unit: "원/시간",
|
||||
day: d.day,
|
||||
factor: d.factor,
|
||||
value: d.hour,
|
||||
labor_key: d.labor_key,
|
||||
})),
|
||||
];
|
||||
return {
|
||||
columns: [
|
||||
text("part", "구분", 90),
|
||||
text("name", "명칭", 160),
|
||||
text("spec", "규격 · 기준일", 120),
|
||||
text("unit", "단위", 80),
|
||||
money("day", "일당(원)"),
|
||||
text("factor", "시간 환산", 160),
|
||||
money("value", "값", undefined, 2),
|
||||
],
|
||||
rows,
|
||||
frozen: 2,
|
||||
hint: "운전사 시간 노임 = ROUNDDOWN(일당 × 1/8 × 16/12 × 25/20, 0)(M01 CC000336 · 건협기술-2230) · 환율 = M01 한국은행 · 인건비 줄 누름 = 노무비목록표",
|
||||
link: (i) => {
|
||||
const row = rows[i];
|
||||
return row?.labor_key ? { tab: "labor", key: String(row.labor_key) } : null;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const SUPPLY_NEXT: Record<string, string | null> = { "": "관급", 관급: "별도", 별도: null };
|
||||
|
||||
export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => {
|
||||
const rows: Row[] = sheets.compare || [];
|
||||
const out: DataRow[] = rows.map((r) => {
|
||||
const row: DataRow = {
|
||||
no: r.no,
|
||||
key: r.key,
|
||||
name: r.name,
|
||||
spec: r.spec,
|
||||
unit: r.unit,
|
||||
lowest: r.lowest,
|
||||
applied: r.applied,
|
||||
applied_from: r.applied_from === "사용자" ? "사용자" : (r.applied_from ?? ""),
|
||||
supply: r.supply ?? "",
|
||||
};
|
||||
for (const s of SOURCES) {
|
||||
row[`p:${s}`] = r.prices[s];
|
||||
row[`g:${s}`] = r.pages[s] ?? "";
|
||||
}
|
||||
return row;
|
||||
});
|
||||
const mine = (key: string): Row => ({ ...((user.materials || {})[key] || {}) });
|
||||
const put = (key: string, cell: Row): void => {
|
||||
const materials = { ...(user.materials || {}) };
|
||||
if (Object.keys(cell).length) materials[key] = cell;
|
||||
else delete materials[key];
|
||||
setUser({ ...user, materials });
|
||||
};
|
||||
return {
|
||||
columns: [
|
||||
qty("no", "번호", 0),
|
||||
text("name", "품명", 180),
|
||||
text("spec", "규격", 140),
|
||||
text("unit", "단위", 56),
|
||||
...SOURCES.flatMap((s) => [money(`p:${s}`, "단가", [s]), text(`g:${s}`, "면", 64, [s])]),
|
||||
money("lowest", "최저"),
|
||||
money("applied", "적용 단가", ["적용"]),
|
||||
text("applied_from", "출처", 80, ["적용"]),
|
||||
text("supply", "관급 · 별도", 80),
|
||||
],
|
||||
rows: out,
|
||||
frozen: 2,
|
||||
hint: "적용 단가 = 출처 중 최저(0 · 빈칸 뺌) 자동 · 칸에 넣으면 사용자 값 · 지우면 다시 최저 · 「관급 · 별도」 칸 누름 = 없음 → 관급 → 별도",
|
||||
editable: (_i, key) => key === "applied",
|
||||
onEdit: (i, key, value) => {
|
||||
const row = out[i];
|
||||
if (!row || key !== "applied") return;
|
||||
const v = parseNumber(value);
|
||||
if (v === undefined) return;
|
||||
const cell = mine(String(row.key));
|
||||
if (v === null) delete cell.applied;
|
||||
else cell.applied = v;
|
||||
put(String(row.key), cell);
|
||||
},
|
||||
onCell: (i, key) => {
|
||||
const row = out[i];
|
||||
if (!row || key !== "supply") return;
|
||||
const cell = mine(String(row.key));
|
||||
const next = SUPPLY_NEXT[String(row.supply ?? "")] ?? null;
|
||||
if (next) cell.supply = next;
|
||||
else delete cell.supply;
|
||||
put(String(row.key), cell);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const buildMaterial: TabBuild = ({ sheets }): TabTable => {
|
||||
const rows: DataRow[] = (sheets.material || []).map((r: Row) => ({
|
||||
...r,
|
||||
supply: r.supply ?? "",
|
||||
}));
|
||||
return {
|
||||
columns: [
|
||||
qty("no", "호표", 0),
|
||||
text("name", "명칭", 200),
|
||||
text("spec", "규격", 160),
|
||||
text("unit", "단위", 56),
|
||||
money("price", "단가"),
|
||||
text("supply", "관급 · 별도", 80),
|
||||
text("origin", "비고", 90),
|
||||
],
|
||||
rows,
|
||||
frozen: 2,
|
||||
hint: `단가 = 자재단가대비표 적용 단가 · ${LINK_HINT}`,
|
||||
link: (i) => (rows[i] ? { tab: "compare", key: String(rows[i].key) } : null),
|
||||
};
|
||||
};
|
||||
|
||||
export const buildMachine: TabBuild = ({ sheets }): TabTable => {
|
||||
const rows: DataRow[] = (sheets.machine || []).map((r: Row) => ({ ...r }));
|
||||
return {
|
||||
columns: [
|
||||
qty("no", "호표", 0),
|
||||
text("name", "명칭", 200),
|
||||
text("spec", "규격", 120),
|
||||
text("unit", "단위", 56),
|
||||
money("sum", "합계"),
|
||||
money("labor", "노무비"),
|
||||
money("material", "재료비"),
|
||||
money("expense", "경비"),
|
||||
],
|
||||
rows,
|
||||
frozen: 2,
|
||||
hint: `시간당 = 중기사용료 블록 합계 · ${LINK_HINT}`,
|
||||
link: (i) => (rows[i] ? { tab: "machine_blocks", key: String(rows[i].key) } : null),
|
||||
};
|
||||
};
|
||||
|
||||
const KIND_TAB: Record<string, TabId> = {
|
||||
expense: "expense",
|
||||
labor: "basis",
|
||||
material: "compare",
|
||||
};
|
||||
|
||||
export const buildMachineBlocks: TabBuild = ({ sheets }): TabTable => {
|
||||
const rows: DataRow[] = [];
|
||||
const links: Array<{ tab: TabId; key: string } | null> = [];
|
||||
(sheets.machine_blocks || []).forEach((b: Row, n: number) => {
|
||||
rows.push({ key: b.key, name: `제 ${n + 1} 호표 ${b.name}`, spec: b.spec, unit: b.unit });
|
||||
links.push(null);
|
||||
for (const ln of b.lines) {
|
||||
const at = (part: string): number | null => (ln.kind === part ? ln.price : null);
|
||||
const amt = (part: string): number | null => (ln.kind === part ? ln.amount : null);
|
||||
rows.push({
|
||||
key: `${b.key}|${ln.name}`,
|
||||
name: ` ${ln.name}`,
|
||||
spec: ln.spec,
|
||||
qty: ln.qty,
|
||||
unit: ln.unit,
|
||||
sum_price: ln.price,
|
||||
sum: ln.amount,
|
||||
labor_price: at("labor"),
|
||||
labor: amt("labor"),
|
||||
material_price: at("material"),
|
||||
material: amt("material"),
|
||||
expense_price: at("expense"),
|
||||
expense: amt("expense"),
|
||||
});
|
||||
links.push(ln.ref?.key ? { tab: KIND_TAB[ln.kind] ?? "compare", key: ln.ref.key } : null);
|
||||
}
|
||||
rows.push({
|
||||
key: `${b.key}|합계`,
|
||||
name: " 합 계",
|
||||
sum: b.sum,
|
||||
labor: b.labor,
|
||||
material: b.material,
|
||||
expense: b.expense,
|
||||
});
|
||||
links.push(null);
|
||||
});
|
||||
const pair = (key: string, label: string) => [
|
||||
money(`${key}_price`, "단가", [label], 1),
|
||||
money(key, "금액", [label], 1),
|
||||
];
|
||||
return {
|
||||
columns: [
|
||||
text("name", "명칭", 220),
|
||||
text("spec", "규격", 120),
|
||||
qty("qty", "수량"),
|
||||
text("unit", "단위", 56),
|
||||
...pair("sum", "합계"),
|
||||
...pair("labor", "노무비"),
|
||||
...pair("material", "재료비"),
|
||||
...pair("expense", "경비"),
|
||||
],
|
||||
rows,
|
||||
frozen: 1,
|
||||
hint: "손료 = 기준가(천원) × 손료계수 ÷ 10,000 · 운전사 = 인 × 시간 노임 · 연료 = ℓ/hr × 적용 단가 · 잡품 = 연료 금액 × % · 줄 ROUNDDOWN 1 · 비목 합 ROUNDDOWN 0 · 줄 누름 = 단가를 낸 탭",
|
||||
link: (i) => links[i] ?? null,
|
||||
};
|
||||
};
|
||||
@@ -304,6 +304,34 @@ def complete_old(folder: Path) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
#: 「연결된 사본」 부분 이름 → 파일(있는 것만 `부분` 에 담음)
|
||||
LINKED_PARTS = (("도면", "도면.json"), ("면적", "면적.json"), ("일위대가", LABOR_FILE))
|
||||
|
||||
|
||||
def linked_snapshots(project_root: str | Path) -> dict[str, dict[str, Any]]:
|
||||
"""연결된 사본 — 지정 ∩ 같은 항목 사본 `{구조물 id: {항목, 폴더, 부분, 경로}}`(PLAN 143-1).
|
||||
|
||||
구조물 id = `assignments` 키(관 지점 `pipe@측점` · 옵션 그룹 `{id}__{그룹}`) ·
|
||||
미지정 · 사본 없음 · 다른 항목 사본은 안 담음(M02 `assign_items` 규칙) ·
|
||||
원본 층과 대조 안 함(빠른 읽기 · 쓰기 없음) ·
|
||||
`경로` = manifest 원본 경로(가져온 때 M02 구분 · 이름) · B08 · B09 공용.
|
||||
"""
|
||||
base = structures_dir(project_root)
|
||||
found: dict[str, dict[str, Any]] = {}
|
||||
for owner, item in assignments(project_root).items():
|
||||
folder = base / folder_name(owner)
|
||||
manifest = read_json(folder / MANIFEST_NAME)
|
||||
if not same_item(manifest, item):
|
||||
continue
|
||||
found[owner] = {
|
||||
"항목": item,
|
||||
"폴더": folder.name,
|
||||
"부분": [part for part, name in LINKED_PARTS if (folder / name).is_file()],
|
||||
"경로": list(manifest["원본"].get("경로") or []),
|
||||
}
|
||||
return found
|
||||
|
||||
|
||||
def read_snapshot(project_root: str | Path, owner_id: str) -> dict[str, Any] | None:
|
||||
"""그 구조물 복사본의 `집계.json`(`{줄, 열}`) — 없으면 None(옛 항목 폴더는 먼저 옮겨 받음)."""
|
||||
if items_dir(project_root).is_dir():
|
||||
|
||||
@@ -68,6 +68,7 @@ from B08_Quantity.B08_Quantity_Router_Hydro import router as b08_hydro_router
|
||||
from B08_Quantity.B08_Quantity_Router_Options import router as b08_options_router
|
||||
from B08_Quantity.B08_Quantity_Router_Summary import router as b08_summary_router
|
||||
from B08_Quantity.B08_Quantity_Router_Material import router as b08_material_router
|
||||
from B09_Estimation.B09_Estimation_Router import router as b09_estimation_router
|
||||
from M01_MasterData.M01_MasterData_Router import router as m01_master_data_router
|
||||
from M02_MasterTemplete.M02_Labor_Router import router as m02_labor_router
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router import router as m02_master_template_router
|
||||
@@ -653,6 +654,7 @@ app.include_router(b08_earthwork_router, dependencies=protected_with_company)
|
||||
app.include_router(b08_summary_router, dependencies=protected_with_company)
|
||||
app.include_router(b08_material_router, dependencies=protected_with_company)
|
||||
app.include_router(b08_options_router, dependencies=protected_with_company)
|
||||
app.include_router(b09_estimation_router, dependencies=protected_with_company)
|
||||
# B08 수량·B09 원가계산 라우터 13 개는 끊음 — 코드는 `old_code/` 에 있고 화면은 빈 틀만 뜬다(PLAN 7-3).
|
||||
# 마스터 데이터 — 회사·프로젝트가 아니라 시스템 관리자만 본다.
|
||||
system_admin_only = [Depends(verify_session), Depends(require_system_admin)]
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
400 세월교 Φ1000×2련 월류 폭 12 · 유입 날개벽 · 유출 흙막이 찰(높이 빈칸 → 미입력) 3 + 4
|
||||
500 물넘이 포장 월류 폭 8 · H=1.0
|
||||
600 개거 + 노출형 횡단수로 · 700 맹암거(그룹 조합)
|
||||
143-1 — 칸은 그 칸을 낸 조합 사본이 있을 때만 셈: 견본은 칸을 내는 잇기를 모두 두고 연결된 사본 =
|
||||
지정 전부 · 사본 없으면 없음은 test_143_1
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -22,6 +24,7 @@ import pytest
|
||||
|
||||
from B06_Section.B06_Section_Schema import CrossSectionPatch
|
||||
from B08_Quantity import B08_Quantity_Router_Summary as summary
|
||||
from M02_MasterTemplete import M02_Item_Snapshot as snapshots
|
||||
from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
@@ -36,7 +39,7 @@ def _item(key: str) -> dict:
|
||||
|
||||
|
||||
POINTS = [
|
||||
{"chainage_m": 300.0, "options": {"pipe_diameter_mm": 800}},
|
||||
{"chainage_m": 300.0, "options": {"pipe_diameter_mm": 800}, "summary_item": _item("SS000100")},
|
||||
{
|
||||
"chainage_m": 300.0,
|
||||
"facility": "box_culvert",
|
||||
@@ -50,7 +53,11 @@ POINTS = [
|
||||
"outlet_revet_after_m": 5,
|
||||
},
|
||||
"summary_item": _item("SS000101"),
|
||||
"group_items": {"gboxbody": _item("SS000102")},
|
||||
"group_items": {
|
||||
"gboxbody": _item("SS000102"),
|
||||
"gboxin": _item("SS000106"),
|
||||
"gboxout": _item("SS000107"),
|
||||
},
|
||||
},
|
||||
{
|
||||
"chainage_m": 340.0,
|
||||
@@ -61,6 +68,8 @@ POINTS = [
|
||||
"wing_in": "없음",
|
||||
"wing_out": "날개벽",
|
||||
},
|
||||
"summary_item": _item("SS000101"),
|
||||
"group_items": {"gboxout": _item("SS000107")},
|
||||
},
|
||||
{
|
||||
"chainage_m": 400.0,
|
||||
@@ -77,21 +86,33 @@ POINTS = [
|
||||
"outlet_revet_before_m": 3,
|
||||
"outlet_revet_after_m": 4,
|
||||
},
|
||||
"group_items": {"gford": _item("SS000103"), "gfordin": _item("SS000104")},
|
||||
"group_items": {
|
||||
"gford": _item("SS000103"),
|
||||
"gfordin": _item("SS000104"),
|
||||
"gfordout": _item("SS000108"),
|
||||
},
|
||||
},
|
||||
{
|
||||
"chainage_m": 500.0,
|
||||
"facility": "ford_pavement",
|
||||
"options": {"ford_width_m": 8, "ford_height_m": 1.0},
|
||||
"summary_item": _item("SS000109"),
|
||||
},
|
||||
]
|
||||
STRUCTURES = [
|
||||
{"structure_id": "s-ditch", "type_id": "open_ditch", "placement": "point", "chainage_m": 600},
|
||||
{
|
||||
"structure_id": "s-ditch",
|
||||
"type_id": "open_ditch",
|
||||
"placement": "point",
|
||||
"chainage_m": 600,
|
||||
"summary_item": _item("SS000110"),
|
||||
},
|
||||
{
|
||||
"structure_id": "s-exposed",
|
||||
"type_id": "cross_drain_exposed",
|
||||
"placement": "point",
|
||||
"chainage_m": 600,
|
||||
"summary_item": _item("SS000111"),
|
||||
},
|
||||
{
|
||||
"structure_id": "s-ud",
|
||||
@@ -123,7 +144,8 @@ def out(tmp_path: Path) -> dict:
|
||||
encoding="utf-8",
|
||||
)
|
||||
names = definition.read()["종류"]["pipe"]["이름"]
|
||||
return summary.pipe_summary(root, DESIGNS, names, 6.0)
|
||||
linked = {owner: {} for owner in snapshots.assignments(root)} # 지정 전부 사본 있음
|
||||
return summary.pipe_summary(root, DESIGNS, names, 6.0, linked=linked)
|
||||
|
||||
|
||||
def _cell(out: dict, at: float, cid: str):
|
||||
|
||||
@@ -40,9 +40,9 @@ def root(tmp_path, monkeypatch) -> Path:
|
||||
return project
|
||||
|
||||
|
||||
def test_기본값은_8_m_와_90_10_파일에_안_씀(root):
|
||||
def test_1본_프로젝트_값_없으면_빈칸_90_10_파일에_안_씀(root):
|
||||
assert options.read_options(root) == {
|
||||
"one_pipe_length_m": 8,
|
||||
"one_pipe_length_m": None, # 143-2 — 시스템 층 8 m 로 안 채움
|
||||
"rock_ratios_pct": {"연암": 90.0, "경암": 10.0},
|
||||
"material_surcharge_pct": {"sand": 12, "gravel": 5, "cement": 3}, # 124
|
||||
}
|
||||
@@ -51,7 +51,7 @@ def test_기본값은_8_m_와_90_10_파일에_안_씀(root):
|
||||
|
||||
|
||||
def test_1본_6_m_저장_뒤_커플링을_다시_셈(root):
|
||||
assert summary.couplings(22, summary.one_pipe_length(root)) == 2 # 올림(22/8) − 1
|
||||
assert summary.couplings(22, summary.one_pipe_length(root)) is None # 프로젝트 값 없음
|
||||
out = options.save_options(root, options.OptionsSave(one_pipe_length_m=6))
|
||||
assert out["one_pipe_length_m"] == 6 == summary.one_pipe_length(root)
|
||||
assert summary.couplings(22, 6) == 3 and summary.couplings(16, 6) == 2
|
||||
@@ -63,6 +63,7 @@ def test_1본_6_m_저장_뒤_커플링을_다시_셈(root):
|
||||
# 다시 저장 = 같은 사본 판 위에 씀
|
||||
options.save_options(root, options.OptionsSave(one_pipe_length_m=8))
|
||||
assert summary.one_pipe_length(root) == 8
|
||||
assert summary.couplings(22, summary.one_pipe_length(root)) == 2 # 올림(22/8) − 1
|
||||
|
||||
|
||||
def test_비율_70_30_저장_뒤_남고_다른_칸은_그대로(root):
|
||||
@@ -79,7 +80,7 @@ def test_비율_70_30_저장_뒤_남고_다른_칸은_그대로(root):
|
||||
assert stored["rock_methods"] == {"연암": "ripping"}
|
||||
assert options.read_options(root)["rock_ratios_pct"]["연암"] == 70.0
|
||||
# 1본 길이는 안 보냈으니 그대로
|
||||
assert options.read_options(root)["one_pipe_length_m"] == 8
|
||||
assert options.read_options(root)["one_pipe_length_m"] is None
|
||||
|
||||
|
||||
def test_비율_읽기_자름_경암만_있을_때():
|
||||
|
||||
@@ -20,6 +20,7 @@ from test_121_b08_material_sheets import SUMMARY, UNITS, _run_ts
|
||||
from B08_Quantity import B08_Quantity_Router_Material as router_mod
|
||||
from B08_Quantity import B08_Quantity_Router_Summary as summary_mod
|
||||
from B08_Quantity.B08_Quantity_Material_Calc import clean_overrides, compute, finish
|
||||
from M02_MasterTemplete import M02_Item_Snapshot as snapshots
|
||||
from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
@@ -107,7 +108,8 @@ def _ford_summary(tmp_path: Path, designs: list[dict]) -> dict:
|
||||
encoding="utf-8",
|
||||
)
|
||||
names = definition.read()["종류"]["pipe"]["이름"]
|
||||
return summary_mod.pipe_summary(root, designs, names, 6.0)
|
||||
linked = {owner: {} for owner in snapshots.assignments(root)} # 143-1 지정 전부 사본 있음
|
||||
return summary_mod.pipe_summary(root, designs, names, 6.0, linked=linked)
|
||||
|
||||
|
||||
def _cell(out: dict, at: float, cid: str):
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""B08 구조물 집계표 — 조합 사본 없으면 없음(PLAN 143-1).
|
||||
|
||||
견본(관 지점 넷 + 세월교 둘 + 개거 하나):
|
||||
100 관 Φ800 L16 · 사본 있음 · 유출 흙막이 잇기 사본 없음 → 관 칸만 · 유출 흙막이 칸 없음
|
||||
200 관 Φ800 L18 · 지정 있으나 사본 폴더가 다른 항목 → 없음
|
||||
300 관 Φ800 L20 · 지정 있으나 사본 폴더 없음 → 없음
|
||||
400 관 Φ800 L22 · 지정 없음 → 없음
|
||||
500 세월교 Φ1000×2련 · 본체 잇기(gford) 사본 있음 → 관 몫 · 월류 폭 셈
|
||||
600 세월교 · 잇기 없음 → 관 몫 · pipes 에도 없음
|
||||
700 개거 · 지정 없음 → 「사본 없는 시설」 에 셈
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from B08_Quantity import B08_Quantity_Router_Summary as summary
|
||||
from M02_MasterTemplete import M02_Item_Snapshot as snapshots
|
||||
from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _item(key: str) -> dict:
|
||||
return {"layer": "system", "owner": None, "key": key, "name": key}
|
||||
|
||||
|
||||
def _soil() -> dict:
|
||||
return {
|
||||
"outlet_type": "기슭막이",
|
||||
"outlet_revet_form": "돌쌓기(메)",
|
||||
"outlet_revet_height_m": 2,
|
||||
"outlet_revet_before_m": 5,
|
||||
"outlet_revet_after_m": 5,
|
||||
}
|
||||
|
||||
|
||||
POINTS = [
|
||||
{
|
||||
"chainage_m": 100.0,
|
||||
"options": {"pipe_diameter_mm": 800, **_soil()},
|
||||
"summary_item": _item("SS000001"),
|
||||
"group_items": {"g9lq9uk": _item("SS000002")},
|
||||
},
|
||||
{"chainage_m": 200.0, "options": {"pipe_diameter_mm": 800}, "summary_item": _item("SS000001")},
|
||||
{"chainage_m": 300.0, "options": {"pipe_diameter_mm": 800}, "summary_item": _item("SS000001")},
|
||||
{"chainage_m": 400.0, "options": {"pipe_diameter_mm": 800}},
|
||||
{
|
||||
"chainage_m": 500.0,
|
||||
"facility": "ford_bridge",
|
||||
"options": {"pipe_diameter_mm": 1000, "pipe_count": 2, "ford_width_m": 12},
|
||||
"group_items": {"gford": _item("SS000003")},
|
||||
},
|
||||
{
|
||||
"chainage_m": 600.0,
|
||||
"facility": "ford_bridge",
|
||||
"options": {"pipe_diameter_mm": 1000, "pipe_count": 1, "ford_width_m": 8},
|
||||
},
|
||||
]
|
||||
STRUCTURES = [
|
||||
{"structure_id": "s-ditch", "type_id": "open_ditch", "placement": "point", "chainage_m": 700},
|
||||
]
|
||||
DESIGNS = [
|
||||
{"chainage_m": 100.0, "design": {"pipe_length_m": 16}},
|
||||
{"chainage_m": 200.0, "design": {"pipe_length_m": 18}},
|
||||
{"chainage_m": 300.0, "design": {"pipe_length_m": 20}},
|
||||
{"chainage_m": 400.0, "design": {"pipe_length_m": 22}},
|
||||
{"chainage_m": 500.0, "design": {"ford_pipe_length_m": 9}},
|
||||
{"chainage_m": 600.0, "design": {"ford_pipe_length_m": 7}},
|
||||
]
|
||||
|
||||
|
||||
def _snapshot(root: Path, owner: str, item: dict, parts: tuple[str, ...] = ()) -> None:
|
||||
folder = snapshots.structures_dir(root) / snapshots.folder_name(owner)
|
||||
folder.mkdir(parents=True)
|
||||
origin = {**item, "경로": ["횡단배수", item["key"], "이름"]}
|
||||
(folder / snapshots.MANIFEST_NAME).write_text(
|
||||
json.dumps({"원본": origin}, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
for name in parts:
|
||||
(folder / name).write_text("{}", encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def root(tmp_path: Path) -> Path:
|
||||
found = tmp_path / "proj"
|
||||
edits = found / "B04_PreProcess/drainage/edits"
|
||||
edits.mkdir(parents=True)
|
||||
points = [{**p, "source": "user"} for p in POINTS]
|
||||
(edits / "pipe_points.json").write_text(
|
||||
json.dumps({"points": points}, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
route = found / "B05_Profile/route"
|
||||
route.mkdir(parents=True)
|
||||
(route / "structures.json").write_text(
|
||||
json.dumps({"revision": 1, "structures": STRUCTURES}, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
_snapshot(found, "pipe@100.0", _item("SS000001"), ("도면.json", "일위대가.json"))
|
||||
_snapshot(found, "pipe@200.0", _item("SS000009")) # 다른 항목 사본
|
||||
_snapshot(found, "pipe@500.0__gford", _item("SS000003"))
|
||||
return found
|
||||
|
||||
|
||||
def _run(root: Path) -> dict:
|
||||
names = definition.read()["종류"]["pipe"]["이름"]
|
||||
return summary.pipe_summary(root, DESIGNS, names, 6.0)
|
||||
|
||||
|
||||
def _cell(out: dict, at: float, cid: str):
|
||||
row = next((r for r in out["rows"] if abs(r["chainage_m"] - at) < 0.01), None)
|
||||
return None if row is None else row["values"].get(cid)
|
||||
|
||||
|
||||
def test_연결된_사본은_지정_교집합_같은_항목(root):
|
||||
linked = snapshots.linked_snapshots(root)
|
||||
assert set(linked) == {"pipe@100.0", "pipe@500.0__gford"}
|
||||
one = linked["pipe@100.0"]
|
||||
assert one["항목"]["key"] == "SS000001" and one["폴더"] == "pipe@100.0"
|
||||
assert one["부분"] == ["도면", "일위대가"] # 있는 파일만
|
||||
assert one["경로"] == ["횡단배수", "SS000001", "이름"]
|
||||
|
||||
|
||||
def test_사본_없는_칸은_안_셈(root):
|
||||
out = _run(root)
|
||||
assert _cell(out, 100, "pipe:len|800") == 16
|
||||
assert _cell(out, 100, "pipe:cp|800") == 2
|
||||
# 유출 흙막이 잇기는 사본 없음 → 그 칸만 없음(열도 안 섬)
|
||||
assert not any(c["id"].startswith("pipe:revet") for c in out["columns"])
|
||||
# 다른 항목 사본 · 사본 없음 · 지정 없음 → 줄 없음
|
||||
assert [round(r["chainage_m"]) for r in out["rows"]] == [100, 500]
|
||||
assert out["total"]["pipe:len|800"] == 16
|
||||
|
||||
|
||||
def test_세월교_관_몫은_본체_잇기_사본으로(root):
|
||||
out = _run(root)
|
||||
assert _cell(out, 500, "pipe:len|1000") == 18 # 9 × 2련
|
||||
assert _cell(out, 500, "pipe:ford|1000|2|width") == 12
|
||||
assert _cell(out, 600, "pipe:len|1000") is None
|
||||
assert [(round(p["chainage_m"]), p["column"]) for p in out["pipes"]] == [
|
||||
(100, "pipe:cp|800"),
|
||||
(500, "pipe:cp|1000"),
|
||||
(500, "pipe:cp|1000"),
|
||||
]
|
||||
width = next(c for c in out["columns"] if c["id"] == "pipe:ford|1000|2|width")
|
||||
assert width["combos"] == ["system||SS000003"]
|
||||
|
||||
|
||||
def test_사본_없는_시설_알림_응답에_사본(root):
|
||||
out = _run(root)
|
||||
# 100(유출 흙막이) · 200 · 300 · 400 · 500(기본 날개벽 잇기) · 600 · 개거 700 = 7곳
|
||||
assert not any("wing" in c["id"] for c in out["columns"])
|
||||
assert "조합 사본 없는 시설 7곳 — B05 · B06 좌측 패널에서 조합 연결 · [저장]" in out["notes"]
|
||||
assert not any("관 연장 없음" in n for n in out["notes"])
|
||||
assert set(out["linked"]) == {"pipe@100.0", "pipe@500.0__gford"}
|
||||
|
||||
|
||||
def test_화면_조합_이름_구조물도는_사본에서():
|
||||
ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8")
|
||||
assert "loadPickerRows" not in ui # 층 목록 GET 안 씀
|
||||
assert "linkedOnly(" in ui
|
||||
drawing = (ROOT / "B08_Quantity/B08_Quantity_UI_Drawing.ts").read_text(encoding="utf-8")
|
||||
assert "readTemplate" not in drawing # 층 폴백 없음
|
||||
assert "B08_Quantity_Sheets_NoCopy" in drawing
|
||||
|
||||
|
||||
def test_화면_사본_있는_조합만_사본_id():
|
||||
"""`linkedOnly` — 본체 · 그룹마다 사본 있는 것만 · 하나도 없으면 시설째 뺌 · `combosAt` 사본 id."""
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
imports = re.compile(r"^import[^;]*;\n", re.MULTILINE)
|
||||
sources = [
|
||||
ROOT / "common_util" / "common_util_summary_item.ts",
|
||||
ROOT / "B08_Quantity" / "B08_Quantity_Summary_Data.ts",
|
||||
]
|
||||
things = [
|
||||
{"structure_id": "s1", "chainage_m": 40, "summary_item": _item("SS1"),
|
||||
"group_items": {"g1": _item("SS3"), "g2": _item("SS4")}},
|
||||
{"structure_id": "s2", "chainage_m": 80, "summary_item": _item("SS2")},
|
||||
] # fmt: skip
|
||||
linked = {"s1__g1": {}, "s1": {}}
|
||||
body = (
|
||||
f"\nconst kept = linkedOnly({json.dumps(things)}, {json.dumps(linked)});"
|
||||
"\nconsole.log(JSON.stringify([kept.map((t) => [t.structure_id, Object.keys(t.group_items)]),"
|
||||
" combosAt(kept, 40).map((c) => c.copyId)]));"
|
||||
)
|
||||
script = "\n".join(imports.sub("", p.read_text(encoding="utf-8")) for p in sources) + body
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", script],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == [[["s1", ["g1"]]], ["s1", "s1__g1"]]
|
||||
@@ -0,0 +1,80 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""B08 구조물 집계표 — 관경 · 련 빈칸 = 「미입력」(임의 1000 · 1 로 안 채움 · PLAN 143-2).
|
||||
|
||||
견본: 100 관 관경 빈칸 L16 · 유출 흙막이 높이 빈칸 / 200 세월교 Φ1000 련 빈칸 · 관 길이 9.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from B08_Quantity import B08_Quantity_Router_Summary as summary
|
||||
from M02_MasterTemplete import M02_Item_Snapshot as snapshots
|
||||
from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition
|
||||
|
||||
ITEM = {"layer": "system", "owner": None, "key": "SS000001", "name": "견본"}
|
||||
POINTS = [
|
||||
{
|
||||
"chainage_m": 100.0,
|
||||
"options": {
|
||||
"pipe_diameter_mm": "",
|
||||
"outlet_type": "기슭막이",
|
||||
"outlet_revet_form": "돌쌓기(메)",
|
||||
"outlet_revet_before_m": 5,
|
||||
"outlet_revet_after_m": 5,
|
||||
},
|
||||
"summary_item": ITEM,
|
||||
"group_items": {"g9lq9uk": ITEM},
|
||||
},
|
||||
{
|
||||
"chainage_m": 200.0,
|
||||
"facility": "ford_bridge",
|
||||
"options": {"pipe_diameter_mm": 1000, "pipe_count": "", "ford_width_m": 12},
|
||||
"summary_item": ITEM,
|
||||
},
|
||||
]
|
||||
DESIGNS = [
|
||||
{"chainage_m": 100.0, "design": {"pipe_length_m": 16}},
|
||||
{"chainage_m": 200.0, "design": {"ford_pipe_length_m": 9}},
|
||||
]
|
||||
|
||||
|
||||
def _run(tmp_path: Path) -> dict:
|
||||
root = tmp_path / "proj"
|
||||
edits = root / "B04_PreProcess/drainage/edits"
|
||||
edits.mkdir(parents=True)
|
||||
points = [{**p, "source": "user"} for p in POINTS]
|
||||
(edits / "pipe_points.json").write_text(
|
||||
json.dumps({"points": points}, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
names = definition.read()["종류"]["pipe"]["이름"]
|
||||
linked = {owner: {} for owner in snapshots.assignments(root)}
|
||||
return summary.pipe_summary(root, DESIGNS, names, 6.0, linked=linked)
|
||||
|
||||
|
||||
def _cell(out: dict, at: float, cid: str):
|
||||
row = next(r for r in out["rows"] if abs(r["chainage_m"] - at) < 0.01)
|
||||
return row["values"].get(cid)
|
||||
|
||||
|
||||
def test_관경_빈칸은_미입력_열(tmp_path):
|
||||
out = _run(tmp_path)
|
||||
cols = {c["id"]: c for c in out["columns"]}
|
||||
assert cols["pipe:len|-"]["group"] == ["관 공", "미입력", "관매설"]
|
||||
assert _cell(out, 100, "pipe:len|-") == 16
|
||||
assert _cell(out, 100, "pipe:cp|-") == 2 # 1본 6 m — 관경과 무관
|
||||
assert not any(c.startswith("pipe:len|1000") and _cell(out, 100, c) for c in cols)
|
||||
# 흙막이 빈 높이 = 관경 최소 높이를 모름 → 「미입력」
|
||||
assert cols["pipe:revet|돌쌓기(메)|-"]["group"][2] == "미입력"
|
||||
assert _cell(out, 100, "pipe:revet|돌쌓기(메)|-") == 10
|
||||
|
||||
|
||||
def test_세월교_련_빈칸은_미입력_관_몫_빈칸(tmp_path):
|
||||
out = _run(tmp_path)
|
||||
cols = {c["id"]: c for c in out["columns"]}
|
||||
assert cols["pipe:ford|1000|-|width"]["group"][1] == "Φ1000×미입력련"
|
||||
assert _cell(out, 200, "pipe:ford|1000|-|width") == 12
|
||||
assert _cell(out, 200, "pipe:len|1000") is None # 련 수를 모르면 관 몫도 빈칸
|
||||
assert _cell(out, 200, "pipe:cp|1000") is None
|
||||
assert not [p for p in out["pipes"] if p["column"] == "pipe:cp|1000"]
|
||||
@@ -0,0 +1,30 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""B08 구조물 탭 — 자재 셋 값 없는 줄 숨김 · 표 넷 공용 카드 안(PLAN 143-3).
|
||||
|
||||
숨김은 화면만 — 셈 · 열 · 단위수량은 그대로 · 고친 칸은 원래 줄 번호(`keep`)로 저장 자리를 찾음.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
MATERIAL = (ROOT / "B08_Quantity/B08_Quantity_UI_Material.ts").read_text(encoding="utf-8")
|
||||
SUMMARY = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_자재_셋_값_없는_줄_숨김():
|
||||
assert MATERIAL.count("const keep = shown(rows, ") == 3 # 총괄 · 콘크리트 · 자재수량
|
||||
assert "row.qty != null || anyValue(row.units)" in MATERIAL # 손 단위수량 있으면 보임
|
||||
assert "row.qty != null || anyValue(row.amounts)" in MATERIAL
|
||||
assert "row.qty != null || row.amount != null" in MATERIAL
|
||||
# 고친 칸 → 원래 줄(숨긴 줄 건너뜀)
|
||||
for target in (
|
||||
"rows[keep[i / 2]].id",
|
||||
"rows[keep[i]].key, key.slice(4)",
|
||||
"rows[keep[i]].panel",
|
||||
):
|
||||
assert target in MATERIAL, target
|
||||
|
||||
|
||||
def test_표_넷_공용_카드_안():
|
||||
assert "createCard({ body: [toolbar, table.root] })" in MATERIAL
|
||||
assert "createCard({ body: parts })" in SUMMARY
|
||||
@@ -0,0 +1,170 @@
|
||||
"""144-2~4 B09 바탕 단가 셈 — 엑셀 검산 대조 · 거울(`B09_Estimation_Calc_Base.py` ↔ `.ts`).
|
||||
|
||||
견본 = 기번3 공통 엑셀(`2024년 간선임도사업(기번3).xlsx`) 같은 입력:
|
||||
노무비목록표 E10~E12 · 환율및기초자료 G6~G8 · 경비목록표 E4 · E5 · E7 · E13 · E16 · E21 ·
|
||||
자재단가대비표 5~12줄 · 중기사용료 1 · 2 · 4 · 11 · 14 · 16 호표(F11 126,000 …)
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B09_Estimation.B09_Estimation_Calc_Base import ( # noqa: E402
|
||||
compute_base,
|
||||
round_half,
|
||||
rounddown,
|
||||
roundup,
|
||||
)
|
||||
|
||||
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
|
||||
FACTOR = {"num": 400, "den": 1920, "text": "1/8 × 16/12 × 25/20"}
|
||||
|
||||
LABOR = [
|
||||
{"key": "LB000048", "code": "1048", "name": "건설기계운전사", "price": 267360},
|
||||
{"key": "LB000049", "code": "1049", "name": "화물차운전사", "price": 226709},
|
||||
{"key": "LB000050", "code": "1050", "name": "일반기계운전사", "price": 161142},
|
||||
{"key": "LB000002", "code": "1002", "name": "보통인부", "price": None},
|
||||
]
|
||||
|
||||
|
||||
def _m(key, name, price, loss, fuel, lph, misc, crew_code):
|
||||
return {"key": key, "name": name, "spec": "", "price": price, "loss": loss, "fuel": fuel,
|
||||
"fuel_lph": lph, "misc_pct": misc, "crew": 1 if crew_code else None,
|
||||
"crew_code": crew_code} # fmt: skip
|
||||
|
||||
|
||||
MACHINES = [
|
||||
_m("X00001", "불도우저19ton", 184499, 1811, "경유", 25, 16, "1048"),
|
||||
_m("X00003", "굴삭기(0.2m3)", 62550, 2085, "경유", 5, 21, "1048"),
|
||||
_m("X00005", "굴삭기(0.7m3)", 110926, 2085, "경유", 11.6, 22, "1048"),
|
||||
_m("X00032", "덤프자동덮개", 1546, 2684, None, None, None, None),
|
||||
_m("X00048", "콘크리트믹서0.45m3", 5409, 3039, "휘발유", 3.9, 2, "1050"),
|
||||
_m("X00074", "덤프트럭2.5ton(토사)", 20793, 2967, "경유", 2.9, None, "1049"),
|
||||
_m("X09999", "기준가 없는 기계", None, 2000, "중유", 3, 10, "1048"),
|
||||
]
|
||||
|
||||
|
||||
def _mt(key, name, **prices):
|
||||
return {"key": key, "name": name, "spec": "", "unit": "", "origin": "M01", "prices": prices}
|
||||
|
||||
|
||||
MATERIALS = [
|
||||
_mt("MO1", "휘발유", 관급=1369, 물가정보=0, 물가자료=0),
|
||||
_mt("MO2", "경유", 관급=1272),
|
||||
_mt("M3", "각재", 관급=500661, 물가정보=829341, 물가자료=748503),
|
||||
_mt("M4", "못", 관급=1503, 물가정보=1935, 물가자료=1665),
|
||||
_mt("M8", "화강석", 관급=0, 물가정보=288000, 물가자료=288000),
|
||||
_mt("M9", "사용자 값 줄", 물가자료=1000),
|
||||
_mt("b08:sand", "골재"),
|
||||
]
|
||||
|
||||
INPUTS = {
|
||||
"labor": LABOR,
|
||||
"machines": MACHINES,
|
||||
"materials": MATERIALS,
|
||||
"fuel_keys": {"경유": "MO2", "휘발유": "MO1"},
|
||||
"driver_factor": FACTOR,
|
||||
"fx": [
|
||||
{"key": "FX000001", "name": "USD", "unit": "원/USD", "value": 1414.9, "date": "2026-08-14"}
|
||||
],
|
||||
}
|
||||
USER = {"materials": {"M9": {"applied": 950, "supply": "관급"}, "M4": {"supply": "별도"},
|
||||
"b08:sand": {"applied": None}}} # fmt: skip
|
||||
|
||||
|
||||
def _run_ts(inputs, user):
|
||||
calc = ROOT / "B09_Estimation" / "B09_Estimation_Calc_Base.ts"
|
||||
body = (
|
||||
f"\nconst i = {json.dumps(inputs, ensure_ascii=False)};"
|
||||
f"\nconst u = {json.dumps(user, ensure_ascii=False)};"
|
||||
"\nconsole.log(JSON.stringify(computeBase(i, u)));"
|
||||
)
|
||||
script = IMPORTS.sub("", calc.read_text(encoding="utf-8")) + body
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", script],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_끝수_엑셀_자리():
|
||||
assert rounddown(33412.7689, 1) == 33412.7 and rounddown(126000.9) == 126000
|
||||
assert rounddown(-1.25, 1) == -1.2 # 0 쪽으로
|
||||
assert roundup(69849380, -3) == 69850000 and roundup(69850000, -3) == 69850000
|
||||
assert round_half(2.5) == 3 and round_half(0.125, 2) == 0.13 and round_half(-2.5) == -3
|
||||
assert rounddown(0.1 + 0.2, 1) == 0.3 # 떠도는 끝수는 버리지 않음
|
||||
|
||||
|
||||
def test_144_2_노무_경비_환율_기초자료():
|
||||
s = compute_base(INPUTS, {})
|
||||
assert [r["price"] for r in s["labor"]] == [267360, 226709, 161142, None]
|
||||
assert ("labor", "LB000002") in {(g["tab"], g["key"]) for g in s["gaps"]}
|
||||
hours = {d["key"]: d["hour"] for d in s["basis"]["drivers"]}
|
||||
assert hours == {"1048": 55700, "1050": 33571, "1049": 47231} # 기초자료 G6 · G8 · G7
|
||||
assert s["basis"]["drivers"][0]["factor"] == "1/8 × 16/12 × 25/20"
|
||||
assert [r["price"] for r in s["expense"]][:3] == [
|
||||
184499,
|
||||
62550,
|
||||
110926,
|
||||
] # 경비목록표 E4 · E5 · E7
|
||||
assert s["expense"][0]["unit"] == "천원"
|
||||
assert s["basis"]["fx"][0]["value"] == 1414.9
|
||||
|
||||
|
||||
def test_144_3_자재단가대비_최저_사용자_관급():
|
||||
s = compute_base(INPUTS, USER)
|
||||
rows = {r["key"]: r for r in s["compare"]}
|
||||
# 엑셀 O 열 = 최저 · P 열 출처(0 은 뺌 · 같은 값이면 앞 칸 — 화강석 「물정」)
|
||||
assert (rows["MO1"]["applied"], rows["MO1"]["applied_from"]) == (1369, "관급")
|
||||
assert (rows["M3"]["applied"], rows["M3"]["lowest_from"]) == (500661, "관급")
|
||||
assert rows["M4"]["applied"] == 1503 and rows["M4"]["supply"] == "별도"
|
||||
assert (rows["M8"]["applied"], rows["M8"]["applied_from"]) == (288000, "물가정보")
|
||||
assert (rows["M9"]["lowest"], rows["M9"]["applied"], rows["M9"]["applied_from"]) == (
|
||||
1000,
|
||||
950,
|
||||
"사용자",
|
||||
)
|
||||
assert rows["M9"]["supply"] == "관급"
|
||||
assert rows["b08:sand"]["applied"] is None # 출처 없음 · 사용자 빈칸 = 빈칸
|
||||
material = {r["key"]: r["price"] for r in s["material"]}
|
||||
assert material == {r["key"]: r["applied"] for r in s["compare"]} # 재료비목록표 = 적용 단가
|
||||
gaps = [(g["tab"], g["key"]) for g in s["gaps"] if g["tab"] == "compare"]
|
||||
assert gaps == [("compare", "b08:sand")]
|
||||
|
||||
|
||||
def test_144_4_중기사용료_엑셀_검산():
|
||||
s = compute_base(INPUTS, {})
|
||||
got = {r["key"]: (r["sum"], r["labor"], r["material"], r["expense"]) for r in s["machine"]}
|
||||
assert got["X00001"] == (126000, 55700, 36888, 33412) # 중기목록표 4줄
|
||||
assert got["X00003"] == (76436, 55700, 7695, 13041)
|
||||
assert got["X00005"] == (96829, 55700, 18001, 23128)
|
||||
assert got["X00032"] == (414, 0, 0, 414) # 덮개 — 운전사 · 연료 없음
|
||||
assert got["X00048"] == (40659, 33571, 5445, 1643) # 일반기계운전사 · 휘발유 · 잡유 2 %
|
||||
assert got["X00074"][1:3] == (47231, 3688) # 화물차운전사 · 잡품 없음
|
||||
assert got["X09999"] == (None, 55700, None, None) # 기준가 · 중유 단가 없음 = 그 비목 · 합 빈칸
|
||||
block = s["machine_blocks"][0]
|
||||
assert [ln["amount"] for ln in block["lines"]] == [
|
||||
33412.7,
|
||||
55700,
|
||||
31800,
|
||||
5088,
|
||||
] # 중기사용료 F7~F10
|
||||
assert block["lines"][0]["qty"] == 0.1811 and block["lines"][0]["ref"] == {
|
||||
"tab": "expense",
|
||||
"key": "X00001",
|
||||
}
|
||||
why = {g["why"] for g in s["gaps"] if g["key"] == "X09999"}
|
||||
assert why == {"연료(중유) 단가 없음", "기계 기준가 없음", "손료 기준가 · 계수 없음"}
|
||||
|
||||
|
||||
def test_거울_TS_가_같은_값():
|
||||
for user in ({}, USER):
|
||||
assert _run_ts(INPUTS, user) == compute_base(INPUTS, user)
|
||||
@@ -0,0 +1,134 @@
|
||||
"""144-1 B09 뼈대 — 탭 차례 · 저장 자리 · 길 · 빈칸 규칙 · 「연결 없음」 목록 · 단가 굳히기.
|
||||
|
||||
- 탭 = 기번3 공통 엑셀 보이는 탭 차례(목차 · 안내 = 「연결 점검」 한 탭 · INITIAL 안 옮김)
|
||||
- 저장 = 프로젝트 안 `B09_Estimation/v1/`(사용자 값 · 굳힌 단가) · 결과는 저장 안 함
|
||||
- 줄 = 수요 따라(사용자 승인 ①) — 연결된 사본 없으면 노무 · 경비 · 중기 빈 표
|
||||
· B08 자재만 줄 · 출처 빈칸
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B09_Estimation import B09_Estimation_Prices as prices # noqa: E402
|
||||
from B09_Estimation import B09_Estimation_Router as router_mod # noqa: E402
|
||||
from B09_Estimation import B09_Estimation_Store as store # noqa: E402
|
||||
|
||||
#: 엑셀 보이는 탭(목차 · 안내 빼고) — 조사 F 1절 차례
|
||||
EXCEL_TABS = [
|
||||
"공사원가계산서", "총괄설계내역서", "설계내역서", "일위대가목록표", "일위대가표",
|
||||
"단가산출근거목록표", "단가산출근거", "환율및기초자료", "중기목록표", "중기사용료",
|
||||
"재료비목록표", "노무비목록표", "경비목록표", "자재단가대비표", "재료비수량금액집계표",
|
||||
"노무비수량금액집계표", "경비수량금액집계표", "중기시간금액집계표",
|
||||
] # fmt: skip
|
||||
|
||||
B08_SHEETS = {
|
||||
"material": {
|
||||
"rows": [
|
||||
{"key": "sand", "group": "골재", "spec": "모래", "unit": "㎥", "amount": 16},
|
||||
{"key": "rubble", "group": "골재", "spec": "막자갈", "unit": "㎥", "amount": None},
|
||||
{"key": "cement", "group": "시멘트", "spec": "40kg/대", "unit": "대", "amount": 244},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_탭_차례가_엑셀과_같다():
|
||||
source = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs.ts").read_text(encoding="utf-8")
|
||||
block = source[
|
||||
source.index("export const TABS") : source.index("];", source.index("export const TABS"))
|
||||
]
|
||||
names = re.findall(r'\["\w+", "([^"]+)"\]', block)
|
||||
assert names[0] == "연결 점검"
|
||||
assert names[1:] == EXCEL_TABS
|
||||
|
||||
|
||||
def test_길_셋():
|
||||
routes = {(r.path, tuple(sorted(r.methods))) for r in router_mod.router.routes}
|
||||
assert ("/api/projects/{project_id}/estimation", ("GET",)) in routes
|
||||
assert ("/api/projects/{project_id}/estimation", ("PUT",)) in routes
|
||||
assert ("/api/projects/{project_id}/estimation/prices", ("POST",)) in routes
|
||||
main = (ROOT / "main.py").read_text(encoding="utf-8")
|
||||
assert "app.include_router(b09_estimation_router" in main
|
||||
|
||||
|
||||
def test_사용자_값_다듬기_저장_자리(tmp_path):
|
||||
raw = {
|
||||
"materials": {"MT1": {"applied": 1200, "supply": "관급", "x": 1}, "MT2": {"applied": None},
|
||||
"MT3": {"supply": "엉뚱"}},
|
||||
"estimate": {"estimated_price": "abc", "profit_adjust": 905980, "work_type": "토목",
|
||||
"rates": {"RT000001": 3.56, "RT000002": None}, "zzz": 1},
|
||||
"other": 1,
|
||||
} # fmt: skip
|
||||
clean = store.write_user(tmp_path, raw)
|
||||
assert clean == {
|
||||
"materials": {"MT1": {"applied": 1200.0, "supply": "관급"}, "MT2": {"applied": None}},
|
||||
"estimate": {"estimated_price": None, "profit_adjust": 905980.0, "work_type": "토목",
|
||||
"rates": {"RT000001": 3.56, "RT000002": None}},
|
||||
} # fmt: skip
|
||||
path = tmp_path / "B09_Estimation" / "v1" / "user_values.json"
|
||||
assert json.loads(path.read_text(encoding="utf-8")) == clean
|
||||
assert store.read_user(tmp_path) == clean
|
||||
assert store.read_book(tmp_path) is None
|
||||
|
||||
|
||||
def test_단가_굳히기_운전사_연료_따라옴():
|
||||
book = prices.refresh_book(["EQ000004"]) # 불도저(무한궤도) 19ton · 조종원 1048 · 경유
|
||||
rows = book["rows"]
|
||||
assert set(rows) == {"EQ000004", "LB000048", "MO000002"}
|
||||
assert rows["EQ000004"]["loss"] == 1811 and rows["EQ000004"]["crew_code"] == "1048"
|
||||
assert rows["LB000048"]["code"] == "1048" and rows["MO000002"]["prices"]["유가"] > 0
|
||||
assert book["driver_factor"] == {"num": 400, "den": 1920, "text": "1/8 × 16/12 × 25/20"}
|
||||
# [저장] = 책에 없는 새 줄만 더함 — 있는 줄 값은 안 바꿈
|
||||
stale = {**book, "rows": {**rows, "LB000048": {**rows["LB000048"], "price": 1}}}
|
||||
grown = prices.extend_book(stale, ["EQ000004", "LB000002"])
|
||||
assert grown["rows"]["LB000048"]["price"] == 1 and "LB000002" in grown["rows"]
|
||||
# 책 밖 줄 = 지금 M01 값 + fresh 표시
|
||||
inputs = prices.build_inputs(stale, ["EQ000004", "LB000002"])
|
||||
fresh = {row["key"]: row.get("fresh", False) for row in inputs["labor"]}
|
||||
assert fresh == {"LB000002": True, "LB000048": False}
|
||||
assert inputs["fuel_keys"] == {"경유": "MO000002"}
|
||||
|
||||
|
||||
def test_연결_없으면_빈_표와_연결_없음(tmp_path, monkeypatch):
|
||||
async def fake_materials(_project_id):
|
||||
return B08_SHEETS
|
||||
|
||||
monkeypatch.setattr(router_mod, "material_sheets", fake_materials)
|
||||
body = asyncio.run(router_mod.payload("p", tmp_path))
|
||||
sheets = body["sheets"]
|
||||
assert sheets["labor"] == [] and sheets["expense"] == [] and sheets["machine"] == []
|
||||
assert [row["key"] for row in sheets["compare"]] == ["b08:sand", "b08:cement"] # 수량 있는 줄만
|
||||
assert all(row["applied"] is None for row in sheets["compare"]) # 출처 없음 = 빈칸(0 아님)
|
||||
assert [(g["tab"], g["key"]) for g in sheets["gaps"]] == [
|
||||
("compare", "b08:sand"),
|
||||
("compare", "b08:cement"),
|
||||
]
|
||||
assert any("연결된 구조물 조합 사본 없음" in n for n in body["notes"])
|
||||
assert body["book"] is None # GET 은 아무것도 쓰지 않음
|
||||
assert not (tmp_path / "B09_Estimation").exists()
|
||||
|
||||
|
||||
def test_연결된_사본이_부른_자원만(tmp_path):
|
||||
"""사본 단가 묶음 통째(고르기 후보 기계 100여)가 아니라 로직이 실제로 부른 줄만."""
|
||||
from B09_Estimation.B09_Estimation_Linked import used_keys
|
||||
from M02_MasterTemplete import M02_Item_Prices as item_prices
|
||||
|
||||
bundle = item_prices.to_json(item_prices.collect(["GC000268"])) # 기계 시간당 기계경비
|
||||
files = {
|
||||
"단가.json": bundle,
|
||||
"일위대가.json": {"줄": {"종류": "일위대가", "담은로직": [{"로직": "GC000268"}]}},
|
||||
"입력값.json": {"GC000268": {"기계": {"값": "0101-0019"}, "지역": {"값": "전국평균"}}},
|
||||
}
|
||||
for name, data in files.items():
|
||||
(tmp_path / name).write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||
assert used_keys(tmp_path) == ["EQ000004", "MO000002", "LB000048"]
|
||||
(tmp_path / "입력값.json").write_text("{}", encoding="utf-8")
|
||||
assert used_keys(tmp_path) == [] # 입력 빈 로직은 못 돎 — 부른 줄 없음
|
||||
assert used_keys(tmp_path / "없음") == []
|
||||
@@ -32,10 +32,14 @@ def test_read_only():
|
||||
assert "editable: () => false" in DRAWING
|
||||
|
||||
|
||||
def test_copy_first_then_master():
|
||||
# 같은 항목의 프로젝트 복사본이 있으면 복사본 · 없으면 M02 원본 층
|
||||
assert "sameItem(own, row)" in DRAWING and "structureFolder(" in DRAWING
|
||||
assert "readTemplate(layer, part, column, null)" in DRAWING
|
||||
def test_copy_only():
|
||||
# 143-1 — 연결된 사본만(그룹은 `{id}__{그룹}` 사본) · 없으면 「사본 없음」 · M02 층 폴백 없음
|
||||
assert (
|
||||
"request.linked[combo.copyId]" in DRAWING
|
||||
and "sourceOf(request.projectId, copy.폴더)" in DRAWING
|
||||
)
|
||||
assert "readTemplate" not in DRAWING and "FromMaster" not in DRAWING
|
||||
assert "사본 없음" in LOCALE
|
||||
|
||||
|
||||
def test_locale_keys_exist():
|
||||
|
||||
@@ -195,12 +195,15 @@ def test_토공집계표_식() -> None:
|
||||
assert value[("토사운반", "", "19 TON B/D")] == 20
|
||||
assert value[("유용토 소계", "", "")] == pytest.approx(t["diverted_m3"] + 82)
|
||||
assert value[("잔토처리", "", "백호우")] == -98
|
||||
assert value[("성토면다짐", "", "")] == excel_round(101.7 * 0.8)
|
||||
assert value[("초류종자살포", "", "씨드스프레이")] == excel_round(101.7 * 0.5 + 15.3)
|
||||
tree = excel_round(133.2 * 0.5 + 82.8, 2)
|
||||
assert value[("지장목제거", "", "")] == excel_round(tree * 0.8)
|
||||
# 143-2 — 거창 계수(0.8 · 0.5) 자리는 빈칸 + 「비율 미정」 · 구조물 쪽 줄 8 뺌 · 나머지 7 빈칸
|
||||
notes = {r["group"]: r["note"] for r in rows}
|
||||
for group in ("성토면다짐", "초류종자살포", "지장목제거"):
|
||||
assert next(r["value"] for r in rows if r["group"] == group) is None
|
||||
assert notes[group] == "비율 미정"
|
||||
assert value[("표토제거", "", "")] is None
|
||||
assert len(rows) == 33 # 시트 C5:C37
|
||||
assert not {"구조물터파기", "토사다짐", "되메우기", "잔토"} & {r["group"] for r in rows}
|
||||
assert sum(r["value"] is None and r["note"] != "비율 미정" for r in rows) == 7
|
||||
assert len(rows) == 25 # 시트 C5:C37 33 − 구조물 쪽 8
|
||||
|
||||
|
||||
def test_토적표_머리_글자는_사용자_견본대로():
|
||||
|
||||
@@ -56,15 +56,27 @@ def test_장은_시설이_정함() -> None:
|
||||
|
||||
|
||||
def test_박스_울진_산출서_단면() -> None:
|
||||
r = compute(_inputs("box", box_width_m=3.0, box_height_m=1.5, slope_denominator=50))
|
||||
assert SHEET_DEFAULTS["box"]["n"] == 0.013 and SHEET_DEFAULTS["box"]["depth_ratio"] == 0.8
|
||||
# 143-2 — 수심비 처음 값 빈칸(울진 0.8 은 손으로 넣은 값으로만)
|
||||
r = compute(
|
||||
_inputs("box", box_width_m=3.0, box_height_m=1.5, slope_denominator=50, depth_ratio=0.8)
|
||||
)
|
||||
assert SHEET_DEFAULTS["box"]["n"] == 0.013 and SHEET_DEFAULTS["box"]["depth_ratio"] is None
|
||||
assert r["n"] == 0.013
|
||||
assert r["depth"] == pytest.approx(1.2)
|
||||
assert r["section_area"] == pytest.approx(3.6)
|
||||
assert r["perimeter"] == pytest.approx(5.4)
|
||||
assert r["radius"] == pytest.approx(3.6 / 5.4)
|
||||
# 실무 29.236 = 1/n 75 · R 0.67 반올림 — 같은 n 이면 R 반올림 차(0.3 %)만 남음
|
||||
r75 = compute(_inputs("box", box_width_m=3.0, box_height_m=1.5, slope_denominator=50, n=1 / 75))
|
||||
r75 = compute(
|
||||
_inputs(
|
||||
"box",
|
||||
box_width_m=3.0,
|
||||
box_height_m=1.5,
|
||||
slope_denominator=50,
|
||||
n=1 / 75,
|
||||
depth_ratio=0.8,
|
||||
)
|
||||
)
|
||||
assert r75["capacity"] == pytest.approx(29.236, rel=0.004)
|
||||
assert 75 * 0.67 ** (2 / 3) * math.sqrt(1 / 50) * 3.6 == pytest.approx(29.236, abs=0.001)
|
||||
assert r["capacity"] > r75["capacity"] # n 고치면 따라감
|
||||
@@ -79,6 +91,9 @@ def test_박스_내공_경사_빈칸이면_대시() -> None:
|
||||
r = compute(_inputs("box"))
|
||||
assert r["design_flow"] is not None
|
||||
assert r["depth"] is None and r["capacity"] is None and r["ok"] is None
|
||||
# 수심비 빈칸 = 내공 · 경사가 있어도 계산 멈춤(143-2)
|
||||
r = compute(_inputs("box", box_width_m=3.0, box_height_m=1.5, slope_denominator=50))
|
||||
assert r["depth"] is None and r["capacity"] is None and r["ok"] is None
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
78+7 Φ800 L18 · 집수정 ㄷ(U) · 유출 메 H2.0 10 m · 메붙임 20 ㎡ → 커플링 2
|
||||
84+0 Φ800 L16 · 집수정 ㄴ(L) · 유출 메 H2.0 → 커플링 올림(16/6)−1 = 2 · 1본 8 m 면 1
|
||||
89+10 Φ800 L(없음) · 유출 높이 빈칸 → 관경 최소 1.8 · 집수정 기록 없음 → 알림
|
||||
143-1 — 칸은 그 칸을 낸 조합 사본이 있을 때만 셈: 픽스처는 관 · 유입 · 유출 · 흙막이 1 · 돌붙임 1 을
|
||||
모두 잇고 연결된 사본 = 지정 전부(`_run`) · 사본 없으면 없음은 test_143_1
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -17,12 +19,15 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from B08_Quantity import B08_Quantity_Router_Summary as summary
|
||||
from M02_MasterTemplete import M02_Item_Snapshot as snapshots
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition
|
||||
|
||||
COMBO = {"layer": "system", "owner": None, "key": "SS000011", "name": "횡단배수 배관"}
|
||||
OUT = {"layer": "company", "owner": "3", "key": "SC3-000001", "name": "기슭막이 메쌓기"}
|
||||
WALL = {"side": "outlet", "key": "extra0"}
|
||||
#: 컨테이너 잇기 전부(143-1 — 잇지 않은 칸은 안 셈) · 흙막이 칸 조합 = OUT
|
||||
LINKED = {"gnbf9m9": COMBO, "g9lq9uk": OUT, "gsoiladd1": OUT, "gchuteadd1": COMBO}
|
||||
|
||||
|
||||
def _revet(side: str, form: str, height: float | None) -> dict:
|
||||
@@ -101,7 +106,15 @@ def root(tmp_path: Path) -> Path:
|
||||
found = tmp_path / "proj"
|
||||
edits = found / "B04_PreProcess/drainage/edits"
|
||||
edits.mkdir(parents=True)
|
||||
points = [{**p, "source": "user"} for p in POINTS]
|
||||
points = [
|
||||
{
|
||||
**p,
|
||||
"source": "user",
|
||||
"summary_item": COMBO,
|
||||
"group_items": {**LINKED, **(p.get("group_items") or {})},
|
||||
}
|
||||
for p in POINTS
|
||||
]
|
||||
(edits / "pipe_points.json").write_text(
|
||||
json.dumps({"points": points}, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
@@ -110,7 +123,8 @@ def root(tmp_path: Path) -> Path:
|
||||
|
||||
def _run(root: Path, one_m: float | None = 6.0) -> dict:
|
||||
names = definition.read()["종류"]["pipe"]["이름"]
|
||||
return summary.pipe_summary(root, DESIGNS, names, one_m)
|
||||
linked = {owner: {} for owner in snapshots.assignments(root)} # 지정 전부 사본 있음
|
||||
return summary.pipe_summary(root, DESIGNS, names, one_m, linked=linked)
|
||||
|
||||
|
||||
def _cell(out: dict, at: float, cid: str):
|
||||
@@ -201,7 +215,7 @@ def test_1본_길이는_프로젝트_층_변수(root, tmp_path, monkeypatch):
|
||||
json.dumps({"변수": {"파형강관_1본_m": 8}}), encoding="utf-8"
|
||||
)
|
||||
monkeypatch.setattr(layers, "SYSTEM_ROOT", system)
|
||||
assert summary.one_pipe_length(root) == 8
|
||||
assert summary.one_pipe_length(root) is None # 143-2 — 시스템 층으로 안 채움
|
||||
mine = layers.template_path(layers.project_dir(root), "table", "구조물집계표")
|
||||
mine.parent.mkdir(parents=True)
|
||||
mine.write_text(json.dumps({"변수": {"파형강관_1본_m": 6}}), encoding="utf-8")
|
||||
|
||||
@@ -138,6 +138,12 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 이동해 온 줄(PLAN 144 · focusRow) */
|
||||
.ui-data-table__table tbody tr.is-focus td {
|
||||
background: var(--dt-hover);
|
||||
box-shadow: inset 0 -2px 0 var(--color-accent);
|
||||
}
|
||||
|
||||
/* 고칠 칸(PLAN 121) — 칸 가득 글 상자 · 숫자 오른쪽 맞춤 */
|
||||
.ui-data-table__table td.is-edit {
|
||||
padding: 0;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* host.append(t.root); t.setRows(rows2, total2);
|
||||
* 열 = { key: "cut_soil_area", label: "단면적", group: ["절토", "토사"], kind: "number", decimals: 2 }
|
||||
* 고칠 칸(PLAN 121) = editable(줄, 열) 참인 칸만 글 상자 · 바꾸면 onEdit(줄, 열, 글) — 다시 그려도 커서 자리 지킴
|
||||
* 칸 누름(PLAN 144) = onCellClick(줄, 열) · 줄로 이동 = t.focusRow(줄)(스크롤 + 강조)
|
||||
* ========================================================================== */
|
||||
|
||||
import "./ui_template_data_table.css";
|
||||
@@ -48,6 +49,10 @@ export interface DataTableOptions {
|
||||
empty?: string;
|
||||
/** 줄 누름(본문 줄만 · 합계 제외) — 주면 줄에 `data-i`(줄 번호)·`is-clickable` 이 붙음 */
|
||||
onRowClick?: (index: number) => void;
|
||||
/** 칸 누름(본문 줄만) — 주면 줄에 `data-i`·`is-clickable` 이 붙음 · 줄 누름보다 먼저 부름 */
|
||||
onCellClick?: (index: number, key: string) => void;
|
||||
/** 강조할 줄(`focusRow` 가 정함) */
|
||||
focus?: number | null;
|
||||
/** 고칠 칸인지(본문 줄만 · 합계 제외) — 참이면 칸이 글 상자 */
|
||||
editable?: (index: number, key: string) => boolean;
|
||||
/** 고친 칸 글(바꾼 뒤 · 커서가 옮겨 간 다음에 부름) */
|
||||
@@ -57,6 +62,8 @@ export interface DataTableOptions {
|
||||
export interface DataTable {
|
||||
root: HTMLElement;
|
||||
setRows: (rows: DataRow[], total?: DataRow | null) => void;
|
||||
/** 그 줄로 스크롤하고 강조 */
|
||||
focusRow: (index: number) => void;
|
||||
}
|
||||
|
||||
const EMPTY = "-";
|
||||
@@ -124,6 +131,7 @@ function rowHtml(
|
||||
index: number | null = null,
|
||||
edit: number | null = null,
|
||||
editable?: (index: number, key: string) => boolean,
|
||||
focus = false,
|
||||
): string {
|
||||
let tr = "";
|
||||
for (let j = 0; j < cols.length; j++) {
|
||||
@@ -146,7 +154,9 @@ function rowHtml(
|
||||
(fmts[j] ? ' data-num=""' : "");
|
||||
tr += `<td${attrs}>${esc(txt)}</td>`;
|
||||
}
|
||||
const cls = [alt && "is-alt", index !== null && "is-clickable"].filter(Boolean).join(" ");
|
||||
const cls = [alt && "is-alt", index !== null && "is-clickable", focus && "is-focus"]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const data = index !== null ? ` data-i="${index}"` : "";
|
||||
return cls || data ? `<tr${cls ? ` class="${cls}"` : ""}${data}>${tr}</tr>` : `<tr>${tr}</tr>`;
|
||||
}
|
||||
@@ -185,7 +195,7 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin
|
||||
const to = win ? Math.max(from, Math.min(win.to, n)) : n;
|
||||
let body = win ? pad(from * win.rowH, cols.length) : "";
|
||||
const blank = o.empty ?? EMPTY;
|
||||
const pick = o.onRowClick !== undefined;
|
||||
const pick = o.onRowClick !== undefined || o.onCellClick !== undefined;
|
||||
for (let i = from; i < to; i++)
|
||||
body += rowHtml(
|
||||
cols,
|
||||
@@ -197,6 +207,7 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin
|
||||
pick ? i : null,
|
||||
i,
|
||||
o.editable,
|
||||
o.focus === i,
|
||||
);
|
||||
if (win) body += pad((n - to) * win.rowH, cols.length);
|
||||
const foot = o.total ? `<tfoot>${rowHtml(cols, fmts, left, o.total, false, blank)}</tfoot>` : "";
|
||||
@@ -250,7 +261,11 @@ export function createDataTable(opts: DataTableOptions): DataTable {
|
||||
});
|
||||
root.addEventListener("click", (event) => {
|
||||
const row = (event.target as Element).closest<HTMLElement>("tr[data-i]");
|
||||
if (row && root.contains(row)) state.onRowClick?.(Number(row.dataset.i));
|
||||
if (!row || !root.contains(row)) return;
|
||||
const cell = (event.target as Element).closest<HTMLTableCellElement>("td");
|
||||
const column = cell ? state.columns[cell.cellIndex] : undefined;
|
||||
if (column) state.onCellClick?.(Number(row.dataset.i), column.key);
|
||||
state.onRowClick?.(Number(row.dataset.i));
|
||||
});
|
||||
draw(true);
|
||||
return {
|
||||
@@ -260,5 +275,10 @@ export function createDataTable(opts: DataTableOptions): DataTable {
|
||||
if (total !== undefined) state.total = total;
|
||||
draw(true);
|
||||
},
|
||||
focusRow(index) {
|
||||
state.focus = index;
|
||||
root.scrollTop = Math.max(0, index * win.rowH - root.clientHeight / 3);
|
||||
draw(true);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ export const ui_locales_b4 = {
|
||||
B08_Quantity_Tab_MaterialQuantity: ["자재수량집계표", "Material Quantity Table"],
|
||||
B08_Quantity_Sheets_Title: ["구조물도", "Structure Sheets"],
|
||||
B08_Quantity_Sheets_FromCopy: ["프로젝트 복사본", "Project copy"],
|
||||
B08_Quantity_Sheets_FromMaster: ["M02 원본", "M02 original"],
|
||||
B08_Quantity_Sheets_NoCopy: ["복사본에 없음", "Not in the project copy"],
|
||||
B08_Quantity_Drawing_Loading: ["구조물도를 불러오는 중…", "Loading structure sheets…"],
|
||||
B08_Quantity_Drawing_Failed: [
|
||||
@@ -36,15 +35,15 @@ export const ui_locales_b4 = {
|
||||
"Failed to load structure sheets",
|
||||
],
|
||||
B08_Quantity_Drawing_Missing: [
|
||||
"M02 에 없는 조합입니다 — 도면 · 면적산출을 보일 수 없습니다.",
|
||||
"This combination is not in M02 — no drawing or area sheet to show.",
|
||||
"사본 없음 — B05 · B06 좌측 패널에서 조합 연결 · [저장] 뒤 보입니다.",
|
||||
"No project copy — link the combination in the B05 · B06 panel and save.",
|
||||
],
|
||||
B08_Quantity_Drawing_NoDrawing: [
|
||||
"이 조합은 도면이 없습니다 — M02 구조물 조합에서 그립니다.",
|
||||
"This combination has no drawing — draw it in M02 structure combinations.",
|
||||
"이 조합 사본은 도면이 없습니다 — M02 구조물 조합에서 그린 뒤 [재적용].",
|
||||
"This copy has no drawing — draw it in M02 structure combinations, then reapply.",
|
||||
],
|
||||
B08_Quantity_Summary_Station: ["측점", "Station"],
|
||||
B08_Quantity_Summary_MissingGroup: ["M02 에 없음", "Not in M02"],
|
||||
B08_Quantity_Summary_MissingGroup: ["경로 없음", "No path"],
|
||||
B08_Quantity_Summary_Loading: ["구조물 집계표를 불러오는 중…", "Loading the structure summary…"],
|
||||
B08_Quantity_Summary_Failed: [
|
||||
"구조물 집계표를 불러오지 못했습니다",
|
||||
|
||||
Reference in New Issue
Block a user