Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_6
# Conflicts: # B09_Estimation/B09_Estimation_Router.py # B09_Estimation/B09_Estimation_UI_Shell.ts
This commit is contained in:
@@ -10,7 +10,6 @@
|
||||
"""
|
||||
|
||||
import math
|
||||
from bisect import bisect_left
|
||||
from typing import Any
|
||||
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
|
||||
@@ -22,7 +21,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
|
||||
polyline_entity,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_IpTable import ip_table_entities
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import _balloon_entities
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import _balloon_entities, balloon_size
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import (
|
||||
_COMPASS_MARGIN,
|
||||
_COMPASS_SIZE,
|
||||
@@ -36,6 +35,20 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import (
|
||||
STREAM_COLOR,
|
||||
STRUCTURE_COLOR,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanLabels import (
|
||||
label_entities,
|
||||
place_labels,
|
||||
text_width,
|
||||
)
|
||||
|
||||
# 비탈 · 노선 따라가기 기하는 700줄을 넘어 떼어냄 — 호출부 import 경로는 그대로.
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanSlope import ( # noqa: F401
|
||||
TICK_STEP_M,
|
||||
route_frame,
|
||||
side_is_cut,
|
||||
slope_end,
|
||||
slope_pieces,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
|
||||
compass_entities,
|
||||
entities_bbox,
|
||||
@@ -130,7 +143,6 @@ _BOUNDARY_STYLE: dict[str, tuple[int, list[int] | None]] = {
|
||||
"sgg": (3, [14, 5, 3, 5]),
|
||||
}
|
||||
PARCEL_FONT = 3.6 # 필지 정보 글 크기(종이 mm) — 용지도 자리 잡기도 이 값
|
||||
TICK_STEP_M = 2.0 # 비탈 눈금 간격(실거리 m) — 긴 · 짧은 눈금이 번갈아 선다
|
||||
_MAJOR_EVERY = 5 # 측점 NO 큰 글자 · 동그라미 간격(측점 수)
|
||||
_LABEL_FONT = 2.5
|
||||
_MAJOR_FONT = 4.5
|
||||
@@ -142,178 +154,6 @@ def layer_id(piece: str) -> str:
|
||||
return f"b07-plan-{piece}"
|
||||
|
||||
|
||||
# ── 노선 따라가기 ─────────────────────────────────────
|
||||
|
||||
|
||||
def route_frame(
|
||||
route: list[tuple[float, float, float]], chainage: float
|
||||
) -> tuple[float, float, float, float]:
|
||||
"""노선 (누가거리, x, y) 위 그 누가거리 자리와 좌측 법선 (x, y, nx, ny)."""
|
||||
chainages = [item[0] for item in route]
|
||||
index = min(max(bisect_left(chainages, chainage), 1), len(route) - 1)
|
||||
(c0, x0, y0), (c1, x1, y1) = route[index - 1], route[index]
|
||||
dx, dy = x1 - x0, y1 - y0
|
||||
length = math.hypot(dx, dy) or 1.0
|
||||
ratio = (chainage - c0) / (c1 - c0) if c1 > c0 else 0.0
|
||||
return (x0 + dx * ratio, y0 + dy * ratio, -dy / length, dx / length)
|
||||
|
||||
|
||||
def _interp(records: list[dict[str, float]], chainage: float, key: str) -> float:
|
||||
chainages = [record["chainage_m"] for record in records]
|
||||
index = bisect_left(chainages, chainage)
|
||||
if index <= 0:
|
||||
return records[0][key]
|
||||
if index >= len(records):
|
||||
return records[-1][key]
|
||||
before, after = records[index - 1], records[index]
|
||||
span = after["chainage_m"] - before["chainage_m"]
|
||||
ratio = (chainage - before["chainage_m"]) / span if span > 0 else 0.0
|
||||
return before[key] + (after[key] - before[key]) * ratio
|
||||
|
||||
|
||||
def _ground(samples: list[dict[str, Any]]) -> list[tuple[float, float]]:
|
||||
return sorted(
|
||||
(float(item["offset_m"]), float(item["elevation_m"]))
|
||||
for item in samples
|
||||
if item.get("valid", True) and isinstance(item.get("elevation_m"), (int, float))
|
||||
)
|
||||
|
||||
|
||||
def _ground_at(ground: list[tuple[float, float]], offset: float) -> float:
|
||||
offsets = [x for x, _z in ground]
|
||||
index = min(max(bisect_left(offsets, offset), 1), len(ground) - 1)
|
||||
(x0, z0), (x1, z1) = ground[index - 1], ground[index]
|
||||
return z0 + (z1 - z0) * ((offset - x0) / (x1 - x0) if x1 > x0 else 0.0)
|
||||
|
||||
|
||||
def side_is_cut(samples: list[dict[str, Any]], edge: float, elevation: float) -> bool:
|
||||
"""노견 끝에서 지반이 노면보다 높으면 그 쪽 비탈은 절토다(낮으면 성토)."""
|
||||
ground = _ground(samples)
|
||||
return len(ground) >= 2 and _ground_at(ground, edge) > elevation
|
||||
|
||||
|
||||
def slope_end(
|
||||
design_line: list[dict[str, Any]], samples: list[dict[str, Any]], edge: float, side: str
|
||||
) -> float:
|
||||
"""노견 끝에서 바깥으로 — 설계선이 원지반에 닿는 오프셋(비탈 끝). 못 닿으면 설계선 끝.
|
||||
|
||||
설계선(B06 횡단 설계 결과)은 비탈이 지반과 만난 뒤로 지반을 따라간다 — 그 첫 자리가 끝이다.
|
||||
좌측은 오프셋이 커지는 쪽, 우측은 작아지는 쪽이 바깥이다.
|
||||
"""
|
||||
ground = _ground(samples)
|
||||
points = sorted((float(p["offset_m"]), float(p["elevation_m"])) for p in design_line)
|
||||
outward = (
|
||||
[p for p in points if p[0] > edge]
|
||||
if side == "left"
|
||||
else [p for p in points[::-1] if p[0] < edge]
|
||||
)
|
||||
if not ground:
|
||||
return outward[-1][0] if outward else edge
|
||||
end, differs = edge, False
|
||||
for offset, elevation in outward:
|
||||
if abs(elevation - _ground_at(ground, offset)) <= 1e-3:
|
||||
return offset if differs else end
|
||||
differs, end = True, offset
|
||||
return end
|
||||
|
||||
|
||||
def _split_runs(
|
||||
points: list[list[float]], flags: list[bool]
|
||||
) -> tuple[list[list[list[float]]], list[list[list[float]]]]:
|
||||
"""한 선을 참 · 거짓 구간으로 끊는다 — 바뀌는 자리 점은 앞뒤 구간이 나눠 가져 이어진다."""
|
||||
runs: dict[bool, list[list[list[float]]]] = {True: [], False: []}
|
||||
current: list[list[float]] = []
|
||||
previous: bool | None = None
|
||||
for point, flag in zip(points, flags):
|
||||
if flag != previous:
|
||||
if current:
|
||||
current.append(point)
|
||||
current = [point]
|
||||
runs[flag].append(current)
|
||||
previous = flag
|
||||
else:
|
||||
current.append(point)
|
||||
return (
|
||||
[run for run in runs[True] if len(run) >= 2],
|
||||
[run for run in runs[False] if len(run) >= 2],
|
||||
)
|
||||
|
||||
|
||||
def slope_pieces(
|
||||
route: list[tuple[float, float, float]],
|
||||
records: list[dict[str, Any]],
|
||||
start_m: float,
|
||||
end_m: float,
|
||||
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any]]:
|
||||
"""측점별 노견 끝 · 비탈 끝 오프셋에서 노견선 · 성토 외곽선 · 절토 외곽선 · 비탈 눈금을 만든다.
|
||||
|
||||
`records` = [{chainage_m, left_edge_m, left_end_m, right_edge_m, right_end_m,
|
||||
left_cut?, right_cut?, (x, y, nx, ny)?}] — 좌(+) · 우(-) 오프셋. 측점 자리는 횡단 파일의
|
||||
중심 · 좌축을 그대로 써서 측점에서 외곽선 폭이 횡단 설계 값과 같다. 측점 사이는 노선을
|
||||
따라 보간한다. 비탈 끝 선은 그 쪽이 절토인지 성토인지로 나눠 두 조각에 싣는다.
|
||||
"""
|
||||
records = sorted(
|
||||
(
|
||||
{
|
||||
**record,
|
||||
**{f"{s}_cut": float(bool(record.get(f"{s}_cut"))) for s in ("left", "right")},
|
||||
}
|
||||
for record in records
|
||||
),
|
||||
key=lambda record: record["chainage_m"],
|
||||
)
|
||||
if len(route) < 2 or not records:
|
||||
return {"lines": []}, {"lines": []}, {"lines": []}, {"ticks": []}
|
||||
keys = ("left_edge_m", "left_end_m", "right_edge_m", "right_end_m")
|
||||
by_chainage = {round(record["chainage_m"], 3): record for record in records}
|
||||
samples = {
|
||||
round(record["chainage_m"], 3)
|
||||
for record in records
|
||||
if start_m <= record["chainage_m"] <= end_m
|
||||
}
|
||||
step = start_m
|
||||
while step <= end_m:
|
||||
samples.add(round(step, 3))
|
||||
step += TICK_STEP_M
|
||||
lines: dict[str, list[list[float]]] = {key: [] for key in keys}
|
||||
cut: dict[str, list[bool]] = {"left": [], "right": []}
|
||||
ticks: list[list[list[float]]] = []
|
||||
for index, chainage in enumerate(sorted(samples)):
|
||||
record = by_chainage.get(chainage)
|
||||
if record and all(k in record for k in ("x", "y", "nx", "ny")):
|
||||
x, y, nx, ny = record["x"], record["y"], record["nx"], record["ny"]
|
||||
else:
|
||||
x, y, nx, ny = route_frame(route, chainage)
|
||||
offsets = {key: _interp(records, chainage, key) for key in keys}
|
||||
points = {
|
||||
key: [round(x + nx * value, 4), round(y + ny * value, 4)]
|
||||
for key, value in offsets.items()
|
||||
}
|
||||
for key in keys:
|
||||
lines[key].append(points[key])
|
||||
for side in cut:
|
||||
cut[side].append(_interp(records, chainage, f"{side}_cut") > 0.5)
|
||||
# 눈금은 노견 끝에서 비탈 끝으로 — 긴 것 · 짧은 것이 번갈아 선다(납품 도면 관례).
|
||||
for side in ("left", "right"):
|
||||
edge, end = offsets[f"{side}_edge_m"], offsets[f"{side}_end_m"]
|
||||
if abs(end - edge) < 0.05:
|
||||
continue
|
||||
reach = end if index % 2 == 0 else edge + (end - edge) / 2.0
|
||||
ticks.append([points[f"{side}_edge_m"], [x + nx * reach, y + ny * reach]])
|
||||
cut_lines: list[list[list[float]]] = []
|
||||
fill_lines: list[list[list[float]]] = []
|
||||
for side in cut:
|
||||
cut_runs, fill_runs = _split_runs(lines[f"{side}_end_m"], cut[side])
|
||||
cut_lines += cut_runs
|
||||
fill_lines += fill_runs
|
||||
return (
|
||||
{"lines": [lines["left_edge_m"], lines["right_edge_m"]]},
|
||||
{"lines": fill_lines},
|
||||
{"lines": cut_lines},
|
||||
{"ticks": ticks},
|
||||
)
|
||||
|
||||
|
||||
def curve_pieces(
|
||||
curves: list[dict[str, Any]],
|
||||
route: list[tuple[float, float, float]],
|
||||
@@ -338,7 +178,7 @@ def curve_pieces(
|
||||
"end": list(end[:2]),
|
||||
}
|
||||
)
|
||||
# R 라벨은 곡선 한가운데에서 교각점 반대쪽(곡선 안쪽)으로 조금 띄운다.
|
||||
# R 라벨 후보는 곡선 한가운데에서 교각점 반대쪽(곡선 안쪽) · 바깥으로(종이 mm · 141-1).
|
||||
middle = min(
|
||||
((x, y) for _c, x, y in route),
|
||||
key=lambda point: math.hypot(point[0] - apex[0], point[1] - apex[1]),
|
||||
@@ -349,7 +189,8 @@ def curve_pieces(
|
||||
radii.append(
|
||||
{
|
||||
"label": f"R={round(float(radius), 1):g}",
|
||||
"at": [middle[0] + dx / length * 4.0, middle[1] + dy / length * 4.0],
|
||||
"middle": list(middle),
|
||||
"dir": [dx / length, dy / length],
|
||||
}
|
||||
)
|
||||
return {"items": ips}, {"items": radii}
|
||||
@@ -371,11 +212,30 @@ def _reading(nx: float, ny: float) -> tuple[float, float]:
|
||||
return (tx, ty) if tx > 0 or (abs(tx) < 1e-9 and ty > 0) else (-tx, -ty)
|
||||
|
||||
|
||||
def _side_spots(cx: float, cy: float, size: float) -> list[dict[str, Any]]:
|
||||
"""기호 곁 글 후보 — 오른쪽(옛 자리) → 왼 · 위 · 아래 → 멀리 오른 · 왼(지시선)."""
|
||||
return [
|
||||
{"x": cx + size + 1.0, "y": cy, "align": "left"},
|
||||
{"x": cx - size - 1.0, "y": cy, "align": "right"},
|
||||
{"x": cx, "y": cy + size + 2.5},
|
||||
{"x": cx, "y": cy - size - 2.5},
|
||||
{"x": cx + size + 8.0, "y": cy + 4.0, "align": "left", "far": True},
|
||||
{"x": cx - size - 8.0, "y": cy - 4.0, "align": "right", "far": True},
|
||||
]
|
||||
|
||||
|
||||
def _piece_entities(
|
||||
drawing_id: str, piece: str, data: dict[str, Any], paper: Any, mm: float = MM
|
||||
drawing_id: str,
|
||||
piece: str,
|
||||
data: dict[str, Any],
|
||||
paper: Any,
|
||||
mm: float = MM,
|
||||
labels: list[dict[str, Any]] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""조각의 선 · 기호 엔티티. 글은 후보 자리째 `labels` 에 모음(자리는 `place_labels` · 141-1)."""
|
||||
layer, color = layer_id(piece), _COLORS.get(piece, TABLE_LABEL_COLOR)
|
||||
out: list[dict[str, Any]] = []
|
||||
labels = labels if labels is not None else []
|
||||
|
||||
def line(points: Any, suffix: str, width: int = 1, dash: list[int] | None = None) -> None:
|
||||
entity = polyline_entity(
|
||||
@@ -390,11 +250,24 @@ def _piece_entities(
|
||||
if entity:
|
||||
out.append(entity)
|
||||
|
||||
def text(seed: str, label: str, at: tuple[float, float], size: float, **kw: Any) -> None:
|
||||
out.append(
|
||||
_text_entity(
|
||||
f"{drawing_id}:{piece}:{seed}", label, at[0], at[1], layer, size, color, **kw
|
||||
)
|
||||
def text(
|
||||
seed: str,
|
||||
label: str,
|
||||
size: float,
|
||||
spots: list[dict[str, Any]],
|
||||
anchor: tuple[float, float] | None = None,
|
||||
) -> None:
|
||||
labels.append(
|
||||
{
|
||||
"piece": piece,
|
||||
"seed": f"{drawing_id}:{piece}:{seed}",
|
||||
"label": label,
|
||||
"size": size,
|
||||
"layer": layer,
|
||||
"color": color,
|
||||
"anchor": anchor,
|
||||
"candidates": spots,
|
||||
}
|
||||
)
|
||||
|
||||
if piece == "lidar":
|
||||
@@ -434,10 +307,39 @@ def _piece_entities(
|
||||
)
|
||||
if out_circle:
|
||||
out.append(out_circle)
|
||||
text(f"{index}", item["name"], (apex[0], apex[1] - _CIRCLE_MM - 3.0), _LABEL_FONT)
|
||||
ax, ay, reach = apex[0], apex[1], _CIRCLE_MM + 3.0
|
||||
text(
|
||||
f"{index}",
|
||||
item["name"],
|
||||
_LABEL_FONT,
|
||||
[
|
||||
{"x": ax, "y": ay - reach},
|
||||
{"x": ax, "y": ay + reach},
|
||||
{"x": ax - _CIRCLE_MM - 1.0, "y": ay, "align": "right"},
|
||||
{"x": ax + _CIRCLE_MM + 1.0, "y": ay, "align": "left"},
|
||||
{"x": ax, "y": ay - reach - 5.0, "far": True},
|
||||
{"x": ax, "y": ay + reach + 5.0, "far": True},
|
||||
],
|
||||
apex,
|
||||
)
|
||||
elif piece == "radius":
|
||||
for index, item in enumerate(data.get("items") or []):
|
||||
text(f"{index}", item["label"], paper(item["at"]), _LABEL_FONT)
|
||||
if "middle" not in item: # 옛 조각 파일 — 자리 하나
|
||||
at = paper(item["at"])
|
||||
text(f"{index}", item["label"], _LABEL_FONT, [{"x": at[0], "y": at[1]}])
|
||||
continue
|
||||
mx, my = paper(item["middle"])
|
||||
dx, dy = item["dir"]
|
||||
text(
|
||||
f"{index}",
|
||||
item["label"],
|
||||
_LABEL_FONT,
|
||||
[
|
||||
{"x": mx + dx * d, "y": my + dy * d, "far": abs(d) > 6.0}
|
||||
for d in (3.0, 6.0, -3.0, -6.0, 10.0, -10.0)
|
||||
],
|
||||
(mx, my),
|
||||
)
|
||||
elif piece == "ip_table":
|
||||
out.extend(ip_table_entities(drawing_id, data))
|
||||
elif piece == "station":
|
||||
@@ -457,13 +359,25 @@ def _piece_entities(
|
||||
)
|
||||
if circle:
|
||||
out.append(circle)
|
||||
gap = _MAJOR_FONT * 1.8
|
||||
gap, (tx, ty) = _MAJOR_FONT * 1.8, _reading(nx, ny)
|
||||
shift = text_width(item["label"], _MAJOR_FONT) / 2.0 + 2.0
|
||||
text(
|
||||
f"{index}",
|
||||
item["label"],
|
||||
(cx + nx * gap, cy + ny * gap),
|
||||
_MAJOR_FONT,
|
||||
direction=_reading(nx, ny),
|
||||
[
|
||||
{
|
||||
"x": cx + nx * g + tx * t,
|
||||
"y": cy + ny * g + ty * t,
|
||||
"direction": (tx, ty),
|
||||
}
|
||||
for g, t in ((gap, 0.0), (-gap, 0.0), (gap, shift), (gap, -shift))
|
||||
]
|
||||
+ [
|
||||
{"x": cx + nx * g, "y": cy + ny * g, "direction": (tx, ty), "far": True}
|
||||
for g in (gap * 1.8, -gap * 1.8)
|
||||
],
|
||||
(cx, cy),
|
||||
)
|
||||
elif piece == "endpoints":
|
||||
for index, item in enumerate(data.get("items") or []):
|
||||
@@ -478,7 +392,16 @@ def _piece_entities(
|
||||
width=2,
|
||||
)
|
||||
cx, cy = paper((item["x"], item["y"]))
|
||||
text(f"{index}", item["label"], (cx + nx * 9.0, cy + ny * 9.0), _LABEL_FONT + 0.5)
|
||||
text(
|
||||
f"{index}",
|
||||
item["label"],
|
||||
_LABEL_FONT + 0.5,
|
||||
[
|
||||
{"x": cx + nx * g, "y": cy + ny * g, "far": abs(g) > 9.0}
|
||||
for g in (9.0, -9.0, 15.0, -15.0)
|
||||
],
|
||||
(cx, cy),
|
||||
)
|
||||
elif piece == "refuge":
|
||||
size = 2.0 # 네모 기호 반크기(종이 mm)
|
||||
for index, item in enumerate(data.get("items") or []):
|
||||
@@ -486,7 +409,7 @@ def _piece_entities(
|
||||
corners = [(-1, -1), (1, -1), (1, 1), (-1, 1), (-1, -1)]
|
||||
line([(x + sx * half, y + sy * half) for sx, sy in corners], f":{index}")
|
||||
cx, cy = paper((x, y))
|
||||
text(f"{index}", item["label"], (cx + size + 1.0, cy), _LABEL_FONT, align="left")
|
||||
text(f"{index}", item["label"], _LABEL_FONT, _side_spots(cx, cy, size), (cx, cy))
|
||||
elif piece == "drainage":
|
||||
size = _STRUCTURE_SIZE_MM # 마름모 기호 반크기(종이 mm)
|
||||
for index, item in enumerate(data.get("items") or []):
|
||||
@@ -502,7 +425,7 @@ def _piece_entities(
|
||||
if diamond:
|
||||
out.append(diamond)
|
||||
text(
|
||||
f"{index}", item["label"], (cx + size + 1.0, cy), _STRUCTURE_FONT_SIZE, align="left"
|
||||
f"{index}", item["label"], _STRUCTURE_FONT_SIZE, _side_spots(cx, cy, size), (cx, cy)
|
||||
)
|
||||
elif piece in _BOUNDARY_STYLE:
|
||||
width, dash = _BOUNDARY_STYLE[piece]
|
||||
@@ -526,6 +449,12 @@ def _piece_entities(
|
||||
return out
|
||||
|
||||
|
||||
def _table_rect(entity: dict[str, Any]) -> tuple[float, float, float, float]:
|
||||
shape = entity["shapeData"]
|
||||
x, y = shape["origin"]["x"], shape["origin"]["y"]
|
||||
return (x, y - sum(shape["rowHeights"]), x + sum(shape["columnWidths"]), y)
|
||||
|
||||
|
||||
def build_plan_kind_drawing(
|
||||
drawing_id: str,
|
||||
label: str,
|
||||
@@ -541,9 +470,23 @@ def build_plan_kind_drawing(
|
||||
return ((point[0] - min_x) * mm, (point[1] - min_y) * mm)
|
||||
|
||||
entities: list[dict[str, Any]] = []
|
||||
labels: list[dict[str, Any]] = []
|
||||
obstacles: list[tuple[float, float, float, float]] = []
|
||||
for piece in PIECE_IDS:
|
||||
if piece in pieces:
|
||||
entities.extend(_piece_entities(drawing_id, piece, pieces[piece], paper, mm))
|
||||
made = _piece_entities(drawing_id, piece, pieces[piece], paper, mm, labels)
|
||||
entities.extend(made)
|
||||
# 글이 비켜 갈 자리 — IP 표 · 필지 상자(141-1). 제목은 내용 위에 따로 서서 안 겹침.
|
||||
if piece == "ip_table":
|
||||
obstacles += [_table_rect(e) for e in made if e.get("type") == "Table"]
|
||||
elif piece == "parcel_info":
|
||||
for item in pieces[piece].get("items") or []:
|
||||
width, height = balloon_size(item["lines"], PARCEL_FONT)
|
||||
cx, cy = paper(item["center"])
|
||||
obstacles.append(
|
||||
(cx - width / 2, cy - height / 2, cx + width / 2, cy + height / 2)
|
||||
)
|
||||
entities.extend(label_entities(drawing_id, place_labels(labels, obstacles)))
|
||||
# 도곽 · 방위표 자리는 조각과 무관하게 같은 자리 — 그 장 범위를 기준으로 잡는다.
|
||||
frame_box = (0.0, 0.0, (box[2] - min_x) * mm, (box[3] - min_y) * mm)
|
||||
entities.extend(
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""B07 평면도 글 자리 잡기(141-1) — 글끼리 · 장애물(IP 표 · 필지 상자)과 안 겹치게.
|
||||
|
||||
조각(`_piece_entities`)은 글을 바로 그리지 않고 후보 자리째 모은다. 여기서 우선순위 차례로
|
||||
후보 중 첫 빈 자리를 고르고(못 찾으면 첫 후보) 멀리 놓인 글엔 지시선을 단다.
|
||||
우선순위 = 시종점 > 측점 NO > IP > R > 배수 > 대피소. 서버 단독 계산(CLAUDE.md 5장 ③).
|
||||
|
||||
좌표 = 종이 mm. 글 사각 = 글 크기 × (한글 1.0 · 그 밖 0.6) 폭 · 글 크기 높이(세로 가운데 기준 —
|
||||
CAD 가 textBaseline middle 로 그림) · 방향이 있으면 돌린 사각의 바깥 상자.
|
||||
"""
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import _text_entity, polyline_entity
|
||||
|
||||
PRIORITY = ("endpoints", "station", "ip", "radius", "drainage", "refuge")
|
||||
_GAP_MM = 0.6 # 글 사이 틈(종이 mm)
|
||||
|
||||
Rect = tuple[float, float, float, float]
|
||||
|
||||
|
||||
def text_width(label: str, size: float) -> float:
|
||||
return sum(1.0 if ord(char) > 0x2E7F else 0.6 for char in label) * size
|
||||
|
||||
|
||||
def text_rect(
|
||||
label: str,
|
||||
x: float,
|
||||
y: float,
|
||||
size: float,
|
||||
align: str = "center",
|
||||
direction: tuple[float, float] = (1.0, 0.0),
|
||||
) -> Rect:
|
||||
width = text_width(label, size)
|
||||
left = {"left": 0.0, "right": -width}.get(align, -width / 2.0)
|
||||
dx, dy = direction
|
||||
length = math.hypot(dx, dy) or 1.0
|
||||
dx, dy = dx / length, dy / length
|
||||
corners = [
|
||||
(x + cx * dx - cy * dy, y + cx * dy + cy * dx)
|
||||
for cx in (left, left + width)
|
||||
for cy in (-size / 2.0, size / 2.0)
|
||||
]
|
||||
xs, ys = [p[0] for p in corners], [p[1] for p in corners]
|
||||
return min(xs), min(ys), max(xs), max(ys)
|
||||
|
||||
|
||||
def overlaps(a: Rect, b: Rect, gap: float = 0.0) -> bool:
|
||||
return a[0] < b[2] + gap and b[0] < a[2] + gap and a[1] < b[3] + gap and b[1] < a[3] + gap
|
||||
|
||||
|
||||
def _rect(item: dict[str, Any], spot: dict[str, Any]) -> Rect:
|
||||
return text_rect(
|
||||
item["label"],
|
||||
spot["x"],
|
||||
spot["y"],
|
||||
item["size"],
|
||||
spot.get("align", "center"),
|
||||
spot.get("direction", (1.0, 0.0)),
|
||||
)
|
||||
|
||||
|
||||
def place_labels(labels: list[dict[str, Any]], obstacles: list[Rect]) -> list[dict[str, Any]]:
|
||||
"""우선순위 차례로 후보 중 첫 빈 자리 — 글마다 고른 자리(`spot`) · 사각(`rect`)을 붙여 냄.
|
||||
|
||||
labels = [{piece, seed, label, size, layer, color, anchor?, candidates: [{x, y, align?,
|
||||
direction?, far?}]}] — 첫 후보 = 옛 자리.
|
||||
"""
|
||||
taken = list(obstacles)
|
||||
placed = []
|
||||
rank = {piece: index for index, piece in enumerate(PRIORITY)}
|
||||
for item in sorted(labels, key=lambda label: rank.get(label["piece"], len(PRIORITY))):
|
||||
rects = [_rect(item, spot) for spot in item["candidates"]]
|
||||
# ponytail: 후보가 모두 막히면 첫 후보 그대로(겹침 남음) — 더 필요하면 후보 고리를 늘림
|
||||
index = next(
|
||||
(
|
||||
i
|
||||
for i, rect in enumerate(rects)
|
||||
if not any(overlaps(rect, t, _GAP_MM) for t in taken)
|
||||
),
|
||||
0,
|
||||
)
|
||||
taken.append(rects[index])
|
||||
placed.append({**item, "spot": item["candidates"][index], "rect": rects[index]})
|
||||
return placed
|
||||
|
||||
|
||||
def _nearest_on_rect(point: tuple[float, float], rect: Rect) -> tuple[float, float]:
|
||||
return (min(max(point[0], rect[0]), rect[2]), min(max(point[1], rect[1]), rect[3]))
|
||||
|
||||
|
||||
def label_entities(drawing_id: str, placed: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""자리 잡은 글 + 멀리 놓인 글(후보 far)의 지시선."""
|
||||
out: list[dict[str, Any]] = []
|
||||
for item in placed:
|
||||
spot = item["spot"]
|
||||
out.append(
|
||||
_text_entity(
|
||||
item["seed"],
|
||||
item["label"],
|
||||
spot["x"],
|
||||
spot["y"],
|
||||
item["layer"],
|
||||
item["size"],
|
||||
item["color"],
|
||||
align=spot.get("align", "center"),
|
||||
direction=spot.get("direction", (1.0, 0.0)),
|
||||
)
|
||||
)
|
||||
anchor = item.get("anchor")
|
||||
if spot.get("far") and anchor:
|
||||
leader = polyline_entity(
|
||||
drawing_id,
|
||||
[anchor, _nearest_on_rect(anchor, item["rect"])],
|
||||
item["layer"],
|
||||
item["color"],
|
||||
suffix=f":leader:{item['seed']}",
|
||||
)
|
||||
if leader:
|
||||
out.append(leader)
|
||||
return out
|
||||
@@ -0,0 +1,182 @@
|
||||
"""B07 계획평면도 비탈 · 노선 따라가기 기하 — 노견선 · 성토 · 절토 외곽선 · 비탈 눈금(60 · 67장).
|
||||
|
||||
`_Engine_Cad_PlanKinds.py` 가 700줄을 넘어 떼어냄(141-1) — 파일 입출력 없음 · 좌표 = 사업지 m.
|
||||
"""
|
||||
|
||||
import math
|
||||
from bisect import bisect_left
|
||||
from typing import Any
|
||||
|
||||
TICK_STEP_M = 2.0 # 비탈 눈금 간격(실거리 m) — 긴 · 짧은 눈금이 번갈아 선다
|
||||
|
||||
|
||||
# ── 노선 따라가기 ─────────────────────────────────────
|
||||
|
||||
|
||||
def route_frame(
|
||||
route: list[tuple[float, float, float]], chainage: float
|
||||
) -> tuple[float, float, float, float]:
|
||||
"""노선 (누가거리, x, y) 위 그 누가거리 자리와 좌측 법선 (x, y, nx, ny)."""
|
||||
chainages = [item[0] for item in route]
|
||||
index = min(max(bisect_left(chainages, chainage), 1), len(route) - 1)
|
||||
(c0, x0, y0), (c1, x1, y1) = route[index - 1], route[index]
|
||||
dx, dy = x1 - x0, y1 - y0
|
||||
length = math.hypot(dx, dy) or 1.0
|
||||
ratio = (chainage - c0) / (c1 - c0) if c1 > c0 else 0.0
|
||||
return (x0 + dx * ratio, y0 + dy * ratio, -dy / length, dx / length)
|
||||
|
||||
|
||||
def _interp(records: list[dict[str, float]], chainage: float, key: str) -> float:
|
||||
chainages = [record["chainage_m"] for record in records]
|
||||
index = bisect_left(chainages, chainage)
|
||||
if index <= 0:
|
||||
return records[0][key]
|
||||
if index >= len(records):
|
||||
return records[-1][key]
|
||||
before, after = records[index - 1], records[index]
|
||||
span = after["chainage_m"] - before["chainage_m"]
|
||||
ratio = (chainage - before["chainage_m"]) / span if span > 0 else 0.0
|
||||
return before[key] + (after[key] - before[key]) * ratio
|
||||
|
||||
|
||||
def _ground(samples: list[dict[str, Any]]) -> list[tuple[float, float]]:
|
||||
return sorted(
|
||||
(float(item["offset_m"]), float(item["elevation_m"]))
|
||||
for item in samples
|
||||
if item.get("valid", True) and isinstance(item.get("elevation_m"), (int, float))
|
||||
)
|
||||
|
||||
|
||||
def _ground_at(ground: list[tuple[float, float]], offset: float) -> float:
|
||||
offsets = [x for x, _z in ground]
|
||||
index = min(max(bisect_left(offsets, offset), 1), len(ground) - 1)
|
||||
(x0, z0), (x1, z1) = ground[index - 1], ground[index]
|
||||
return z0 + (z1 - z0) * ((offset - x0) / (x1 - x0) if x1 > x0 else 0.0)
|
||||
|
||||
|
||||
def side_is_cut(samples: list[dict[str, Any]], edge: float, elevation: float) -> bool:
|
||||
"""노견 끝에서 지반이 노면보다 높으면 그 쪽 비탈은 절토다(낮으면 성토)."""
|
||||
ground = _ground(samples)
|
||||
return len(ground) >= 2 and _ground_at(ground, edge) > elevation
|
||||
|
||||
|
||||
def slope_end(
|
||||
design_line: list[dict[str, Any]], samples: list[dict[str, Any]], edge: float, side: str
|
||||
) -> float:
|
||||
"""노견 끝에서 바깥으로 — 설계선이 원지반에 닿는 오프셋(비탈 끝). 못 닿으면 설계선 끝.
|
||||
|
||||
설계선(B06 횡단 설계 결과)은 비탈이 지반과 만난 뒤로 지반을 따라간다 — 그 첫 자리가 끝이다.
|
||||
좌측은 오프셋이 커지는 쪽, 우측은 작아지는 쪽이 바깥이다.
|
||||
"""
|
||||
ground = _ground(samples)
|
||||
points = sorted((float(p["offset_m"]), float(p["elevation_m"])) for p in design_line)
|
||||
outward = (
|
||||
[p for p in points if p[0] > edge]
|
||||
if side == "left"
|
||||
else [p for p in points[::-1] if p[0] < edge]
|
||||
)
|
||||
if not ground:
|
||||
return outward[-1][0] if outward else edge
|
||||
end, differs = edge, False
|
||||
for offset, elevation in outward:
|
||||
if abs(elevation - _ground_at(ground, offset)) <= 1e-3:
|
||||
return offset if differs else end
|
||||
differs, end = True, offset
|
||||
return end
|
||||
|
||||
|
||||
def _split_runs(
|
||||
points: list[list[float]], flags: list[bool]
|
||||
) -> tuple[list[list[list[float]]], list[list[list[float]]]]:
|
||||
"""한 선을 참 · 거짓 구간으로 끊는다 — 바뀌는 자리 점은 앞뒤 구간이 나눠 가져 이어진다."""
|
||||
runs: dict[bool, list[list[list[float]]]] = {True: [], False: []}
|
||||
current: list[list[float]] = []
|
||||
previous: bool | None = None
|
||||
for point, flag in zip(points, flags):
|
||||
if flag != previous:
|
||||
if current:
|
||||
current.append(point)
|
||||
current = [point]
|
||||
runs[flag].append(current)
|
||||
previous = flag
|
||||
else:
|
||||
current.append(point)
|
||||
return (
|
||||
[run for run in runs[True] if len(run) >= 2],
|
||||
[run for run in runs[False] if len(run) >= 2],
|
||||
)
|
||||
|
||||
|
||||
def slope_pieces(
|
||||
route: list[tuple[float, float, float]],
|
||||
records: list[dict[str, Any]],
|
||||
start_m: float,
|
||||
end_m: float,
|
||||
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any]]:
|
||||
"""측점별 노견 끝 · 비탈 끝 오프셋에서 노견선 · 성토 외곽선 · 절토 외곽선 · 비탈 눈금을 만든다.
|
||||
|
||||
`records` = [{chainage_m, left_edge_m, left_end_m, right_edge_m, right_end_m,
|
||||
left_cut?, right_cut?, (x, y, nx, ny)?}] — 좌(+) · 우(-) 오프셋. 측점 자리는 횡단 파일의
|
||||
중심 · 좌축을 그대로 써서 측점에서 외곽선 폭이 횡단 설계 값과 같다. 측점 사이는 노선을
|
||||
따라 보간한다. 비탈 끝 선은 그 쪽이 절토인지 성토인지로 나눠 두 조각에 싣는다.
|
||||
"""
|
||||
records = sorted(
|
||||
(
|
||||
{
|
||||
**record,
|
||||
**{f"{s}_cut": float(bool(record.get(f"{s}_cut"))) for s in ("left", "right")},
|
||||
}
|
||||
for record in records
|
||||
),
|
||||
key=lambda record: record["chainage_m"],
|
||||
)
|
||||
if len(route) < 2 or not records:
|
||||
return {"lines": []}, {"lines": []}, {"lines": []}, {"ticks": []}
|
||||
keys = ("left_edge_m", "left_end_m", "right_edge_m", "right_end_m")
|
||||
by_chainage = {round(record["chainage_m"], 3): record for record in records}
|
||||
samples = {
|
||||
round(record["chainage_m"], 3)
|
||||
for record in records
|
||||
if start_m <= record["chainage_m"] <= end_m
|
||||
}
|
||||
step = start_m
|
||||
while step <= end_m:
|
||||
samples.add(round(step, 3))
|
||||
step += TICK_STEP_M
|
||||
lines: dict[str, list[list[float]]] = {key: [] for key in keys}
|
||||
cut: dict[str, list[bool]] = {"left": [], "right": []}
|
||||
ticks: list[list[list[float]]] = []
|
||||
for index, chainage in enumerate(sorted(samples)):
|
||||
record = by_chainage.get(chainage)
|
||||
if record and all(k in record for k in ("x", "y", "nx", "ny")):
|
||||
x, y, nx, ny = record["x"], record["y"], record["nx"], record["ny"]
|
||||
else:
|
||||
x, y, nx, ny = route_frame(route, chainage)
|
||||
offsets = {key: _interp(records, chainage, key) for key in keys}
|
||||
points = {
|
||||
key: [round(x + nx * value, 4), round(y + ny * value, 4)]
|
||||
for key, value in offsets.items()
|
||||
}
|
||||
for key in keys:
|
||||
lines[key].append(points[key])
|
||||
for side in cut:
|
||||
cut[side].append(_interp(records, chainage, f"{side}_cut") > 0.5)
|
||||
# 눈금은 노견 끝에서 비탈 끝으로 — 긴 것 · 짧은 것이 번갈아 선다(납품 도면 관례).
|
||||
for side in ("left", "right"):
|
||||
edge, end = offsets[f"{side}_edge_m"], offsets[f"{side}_end_m"]
|
||||
if abs(end - edge) < 0.05:
|
||||
continue
|
||||
reach = end if index % 2 == 0 else edge + (end - edge) / 2.0
|
||||
ticks.append([points[f"{side}_edge_m"], [x + nx * reach, y + ny * reach]])
|
||||
cut_lines: list[list[list[float]]] = []
|
||||
fill_lines: list[list[list[float]]] = []
|
||||
for side in cut:
|
||||
cut_runs, fill_runs = _split_runs(lines[f"{side}_end_m"], cut[side])
|
||||
cut_lines += cut_runs
|
||||
fill_lines += fill_runs
|
||||
return (
|
||||
{"lines": [lines["left_edge_m"], lines["right_edge_m"]]},
|
||||
{"lines": fill_lines},
|
||||
{"lines": cut_lines},
|
||||
{"ticks": ticks},
|
||||
)
|
||||
@@ -12,6 +12,7 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M02_MasterTemplete import M02_Item_Prices as item_prices
|
||||
from M02_MasterTemplete.M02_Item_Snapshot import (
|
||||
PICKS_FILE,
|
||||
@@ -50,7 +51,11 @@ def used_keys(folder: Path) -> list[str]:
|
||||
if not key:
|
||||
continue
|
||||
given = {name: (one or {}).get("값") for name, one in (values.get(key) or {}).items()}
|
||||
given = {k: v for k, v in given.items() if v not in (None, "")}
|
||||
try:
|
||||
# 빈 입력 = 자동값(142-1 · 145-1 산근 풀이와 같음) — 안 채우면 그 로직 자원을 못 모음
|
||||
auto = m01.mau.values(snapshot, snapshot.logic(key))
|
||||
given = {**{k: v for k, v in auto.items() if v is not None}, **m01._dec(given)}
|
||||
with mf.mp.choosing((picks or {}).get(key)):
|
||||
mf.run(snapshot, key, given)
|
||||
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError):
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
|
||||
저장 자리 = 프로젝트 안 `B09_Estimation/v1/`(`B09_Estimation_Store`) ·
|
||||
단가 = `B09_Estimation_Prices` ·
|
||||
수요 = `B09_Estimation_Linked`(연결된 사본) · 셈 = `B09_Estimation_Calc_*`(화면 짝과 같은 식).
|
||||
수요 = `B09_Estimation_Linked`(연결된 사본) · 셈 = `B09_Estimation_Calc_*`(화면 짝과 같은 식) ·
|
||||
산근 = `B09_Estimation_Sangeun`(서버 단독 · 145-1 · 화면은 받은 그대로).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -25,6 +26,7 @@ from B08_Quantity.B08_Quantity_Router_Earthwork import earthwork_table
|
||||
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_Sangeun as sangeun
|
||||
from B09_Estimation import B09_Estimation_Store as store
|
||||
from B09_Estimation.B09_Estimation_Bill_Items import bill_items, hopyo_prices
|
||||
from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation
|
||||
@@ -67,6 +69,8 @@ async def payload(project_id: UUID, root: Path) -> dict[str, Any]:
|
||||
inputs["hopyo"] = hopyo_prices(root)
|
||||
user = await asyncio.to_thread(store.read_user, root)
|
||||
sheets = compute_estimation(inputs, user)
|
||||
sheets.update(await asyncio.to_thread(sangeun.build, root, sheets)) # 145-1 — 굳힌 단가로 풀이
|
||||
sheets["gaps"] = [*sheets["gaps"], *sheets["sg_gaps"]]
|
||||
# 145-4 총괄 · 원가계산서 = 설계내역서 비목 합(145-3 `bill.sums`) + 사용자 요율 · 손입력
|
||||
sheets["total"] = compute_total((sheets.get("bill") or {}).get("sums"), user.get("estimate"))
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
"""B09 단가산출근거 · 단가산출근거목록표(PLAN 145-1) — 서버 단독(③ · 화면 짝 없음 · 저장 안 함).
|
||||
|
||||
산근 호표 = 연결된 사본(`linked_snapshots` · 143-1)의 일위대가 로직 줄 중
|
||||
`승격: true` 인 줄(134-1 · 입력값 = 사본 `입력값.json` · 빈 입력 = 자동값) +
|
||||
옛 단가승격 참조 줄(134-2 ③ 로 남은 SP · 입력값 = 굳힌 `참조사본`)
|
||||
같은 로직 + 같은 입력값(+ 켜쌓기율 · 고른 자재) = 산근 하나(`sangeun_key`) ·
|
||||
번호 = 첫 등장 차례 ·
|
||||
로직 줄이 부르는 다른 돈 로직(기계경비 밖) = 하위 산근(그 산근도 목록에 · 산근 → 산근).
|
||||
풀이 = 로직 호표 줄을 사본 단가 묶음(`단가.json`)으로 펼쳐 수량 · 자원 키만 얻고
|
||||
(M01 엔진 식 그대로) ·
|
||||
단가 = B09 프로젝트 한 벌 굳힌 단가(144 · `compute_base` 표) — 노무 = 노무비목록표 ·
|
||||
중기 = 중기목록표 노 · 재 · 경 · 재료 = 자재단가대비표 적용 단가 ·
|
||||
% 줄(덧줄) = 엔진 식에 B09 비목 합.
|
||||
끝수 = 엑셀 자리 — 수량 = 결과단위 1 단위(÷ 배) ·
|
||||
작업량 Q(중간 이름 Q · Q1 …) = ROUND(식, 2) 뒤 쓰임 ·
|
||||
줄 금액 = ROUNDDOWN(단가 × 수량, 1) 비목마다 · 총계 = 비목마다 ROUNDDOWN(Σ, 0) · 합계 = 셋 합.
|
||||
빈칸 규칙 = 단가 · 수량을 낸 원천이 없으면 그 칸 · 그 비목 총계 None(0 을 채우지 않음)
|
||||
+ 「연결 없음」.
|
||||
조합 밖 토공 · 운반 · 사토 산근 · 준비공 = 원천 없음(만들지 않음 · 목록 안내 한 줄).
|
||||
대가(145-2) · 내역서(145-3)가 읽는 길 = `sangeun_key` + `price_of`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B09_Estimation.B09_Estimation_Calc_Base import gap, num, round_half, rounddown, total
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
|
||||
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
|
||||
|
||||
mf = item_prices.mf
|
||||
PARTS = ("labor", "material", "expense")
|
||||
COST_PART = {"노무비": "labor", "재료비": "material", "경비": "expense"}
|
||||
DEPTH = 4 # 하위 산근 깊이 상한
|
||||
_Q = re.compile(r"Q\d*") # 작업량 중간값 이름(산림품셈 09장 토공 · 07장 집재 …)
|
||||
NO_SOURCE = "조합 밖 토공 · 운반 · 사토 산근 · 준비공 = 원천 없음(연결 없음 · 만들지 않음)"
|
||||
EMPTY = "연결된 구조물 조합 사본에 단가승격 줄 없음 — 연결 없음"
|
||||
|
||||
|
||||
# ── 산근 정체 ─────────────────────────────────────────────────────────
|
||||
def _clean_given(given: Any) -> dict[str, Any]:
|
||||
return {
|
||||
str(k): v for k, v in sorted((given or {}).items()) if v not in (None, "") and k != "줄"
|
||||
}
|
||||
|
||||
|
||||
def sangeun_key(code: str, given: Any = None, rate: Any = None, picks: Any = None) -> str:
|
||||
"""산근 이름표 — 같은 로직 + 같은 입력값(+ 켜쌓기율 · 고른 자재)이면 같은 글.
|
||||
145-2 대가 · 145-3 내역서가 승격 줄에서 이 글을 만들어 `price_of` 로 단가를 읽음."""
|
||||
body = {
|
||||
"입력값": _clean_given(given),
|
||||
"율": None if rate in (None, 100) else float(rate),
|
||||
"고름": {str(k): v for k, v in sorted((picks or {}).items())},
|
||||
}
|
||||
text = json.dumps(body, ensure_ascii=False, sort_keys=True, default=str)
|
||||
return f"SG:{code}:{hashlib.sha1(text.encode('utf-8')).hexdigest()[:10]}"
|
||||
|
||||
|
||||
def price_of(sheets: dict[str, Any], key: str) -> dict[str, Any] | None:
|
||||
"""산근 한 줄 단가 `{no, labor, material, expense, sum}`(1 단위 · 빈칸 = None) — 없으면 None."""
|
||||
for row in sheets.get("sg_list") or []:
|
||||
if row["key"] == key:
|
||||
return {k: row[k] for k in ("no", *PARTS, "sum")}
|
||||
return None
|
||||
|
||||
|
||||
def _values(values: dict[str, Any], code: str) -> dict[str, Any]:
|
||||
return {name: (one or {}).get("값") for name, one in (values.get(code) or {}).items()}
|
||||
|
||||
|
||||
def rows_of(folder: Path) -> list[dict[str, Any]]:
|
||||
"""사본 하나의 산근 원천 줄 — 승격 로직 줄 · 옛 SP 참조 줄(차례대로)."""
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
|
||||
labor = read_json(folder / LABOR_FILE)
|
||||
if not isinstance(labor, dict):
|
||||
return []
|
||||
values = read_json(folder / VALUES_FILE) or {}
|
||||
picks = read_json(folder / PICKS_FILE) or {}
|
||||
copies = labor.get("참조사본") or {}
|
||||
out = []
|
||||
for one in (labor.get("줄") or {}).get("담은로직") or []:
|
||||
if not isinstance(one, dict):
|
||||
continue
|
||||
ref = one.get("참조")
|
||||
if one.get("승격") is True and one.get("로직"):
|
||||
code = str(one["로직"])
|
||||
got = {
|
||||
"code": code,
|
||||
"given": _values(values, code),
|
||||
"rate": one.get("켜쌓기율"),
|
||||
"picks": picks.get(code) or one.get("고름"),
|
||||
}
|
||||
elif isinstance(ref, dict) and refs.kind_of(ref.get("키")) == "promote":
|
||||
line = ((copies.get(refs.ref_id(ref)) or {}).get("담은로직") or [None])[0]
|
||||
if not isinstance(line, dict) or not line.get("로직"):
|
||||
continue
|
||||
got = {
|
||||
"code": str(line["로직"]),
|
||||
"given": line.get("입력값") or {},
|
||||
"rate": line.get("켜쌓기율"),
|
||||
"picks": line.get("고름"),
|
||||
}
|
||||
else:
|
||||
continue
|
||||
out.append({**got, "given": _clean_given(got["given"])})
|
||||
return out
|
||||
|
||||
|
||||
# ── 풀이 ──────────────────────────────────────────────────────────────
|
||||
class _Prices:
|
||||
"""B09 표 단가 — 노무 · 중기(노 · 재 · 경) · 자재 적용 단가(키 → 값)."""
|
||||
|
||||
def __init__(self, sheets: dict[str, Any]) -> None:
|
||||
self.labor = {r["key"]: num(r.get("price")) for r in sheets.get("labor") or []}
|
||||
self.machine = {r["key"]: r for r in sheets.get("machine") or []}
|
||||
self.material = {r["key"]: num(r.get("applied")) for r in sheets.get("compare") or []}
|
||||
|
||||
|
||||
def _unit(code: str) -> tuple[str, Decimal]:
|
||||
"""결과단위 1 단위 — (단위, 배)(「원/10㎡」 = ㎡ · 10)."""
|
||||
pill = (combo_store.combo_pills({"담은로직": [{"로직": code}]})["물량"] or [{}])[0]
|
||||
return str(pill.get("단위") or ""), Decimal(str(pill.get("배") or 1))
|
||||
|
||||
|
||||
def _env(whole: Any, row: dict, given: dict) -> dict:
|
||||
"""입력(빈 입력 = 자동값) + 중간값 · 작업량 Q = ROUND(식, 2)(엑셀 자리)."""
|
||||
auto = {k: v for k, v in m01.mau.values(whole, row).items() if v is not None}
|
||||
env = mf._inputs(row, {**auto, **m01._dec(given or {})})
|
||||
for step in row.get("중간", []):
|
||||
try:
|
||||
value = mf.evaluate(mf.parse(step["식"]), env, whole, 0)
|
||||
except mf.EmptyInput as e:
|
||||
env[step["이름"]] = mf.Blank(str(e))
|
||||
continue
|
||||
if _Q.fullmatch(step["이름"]) and isinstance(value, (Decimal, int, float)):
|
||||
value = Decimal(str(round_half(float(value), 2)))
|
||||
env[step["이름"]] = value
|
||||
return env
|
||||
|
||||
|
||||
def _captured(whole: Any, node: Any, env: dict) -> list[str]:
|
||||
"""식을 돌리며 부른 기계 키(EQ) — 기계경비 로직 줄의 기계."""
|
||||
seen: list[str] = []
|
||||
base = whole.get
|
||||
|
||||
def get(ref: str) -> dict[str, Any]:
|
||||
row = base(ref)
|
||||
key = str(row.get("키") or "")
|
||||
if key.startswith("EQ") and key not in seen:
|
||||
seen.append(key)
|
||||
return row
|
||||
|
||||
whole.get = get
|
||||
try:
|
||||
mf.evaluate(node, env, whole, 1)
|
||||
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError):
|
||||
pass # 기계 단가를 못 셈 — 키만 있으면 됨(단가는 B09 중기목록표)
|
||||
finally:
|
||||
whole.get = base
|
||||
return seen
|
||||
|
||||
|
||||
def _money(price: float | None, qty: float | None) -> float | None:
|
||||
"""줄 금액 = ROUNDDOWN(단가 × 수량, 1)(엑셀 자리)."""
|
||||
return None if price is None or qty is None else rounddown(price * qty, 1)
|
||||
|
||||
|
||||
def block_totals(lines: list[dict[str, Any]]) -> dict[str, float | None]:
|
||||
"""총계 — 비목마다 ROUNDDOWN(Σ 줄 금액, 0) · 합계 = 셋 합 · 빈칸 하나면 그 비목 · 합계 빈칸."""
|
||||
out: dict[str, float | None] = {}
|
||||
for part in PARTS:
|
||||
s = total([ln[part] for ln in lines])
|
||||
out[part] = None if s is None else rounddown(s, 0)
|
||||
return {**out, "sum": total([out[p] for p in PARTS])}
|
||||
|
||||
|
||||
class Solver:
|
||||
"""한 번 셈 — 산근 블록을 첫 등장 차례로 모음(하위 산근 포함)."""
|
||||
|
||||
def __init__(self, sheets: dict[str, Any]) -> None:
|
||||
self.prices = _Prices(sheets)
|
||||
self.blocks: dict[str, dict[str, Any]] = {}
|
||||
|
||||
def add(
|
||||
self, whole: Any, src: dict[str, Any], owner: str | None, depth: int = 0
|
||||
) -> dict[str, Any]:
|
||||
key = sangeun_key(src["code"], src["given"], src.get("rate"), src.get("picks"))
|
||||
if key not in self.blocks:
|
||||
self.blocks[key] = {"key": key, "no": len(self.blocks) + 1, "uses": []} # 자리 먼저
|
||||
self.blocks[key].update(self._solve(whole, src, depth))
|
||||
if owner and owner not in self.blocks[key]["uses"]:
|
||||
self.blocks[key]["uses"].append(owner)
|
||||
return self.blocks[key]
|
||||
|
||||
def _solve(self, whole: Any, src: dict[str, Any], depth: int) -> dict[str, Any]:
|
||||
code = src["code"]
|
||||
try:
|
||||
row = whole.logic(code)
|
||||
except mf.FormulaError as e:
|
||||
return self._stop(code, code, f"로직 없음 — {e}")
|
||||
name = str(row.get("이름") or code)
|
||||
if isinstance(row.get("흐름"), dict) or "결과" in row:
|
||||
return self._stop(code, name, "돈 로직 아님(흐름 · 결과 로직) — 산근 풀이 꼴 없음")
|
||||
unit, times = _unit(code)
|
||||
rate = Decimal(str(src["rate"])) / 100 if src.get("rate") not in (None, 100) else None
|
||||
lines: list[dict[str, Any]] = []
|
||||
try:
|
||||
env = _env(whole, row, src["given"])
|
||||
with mf.mp.choosing(src.get("picks")):
|
||||
for n, item in enumerate(row.get("호표", [])):
|
||||
lines.append(self._line(whole, item, n, env, times, rate, depth))
|
||||
self._extras(whole, row, env, lines)
|
||||
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e:
|
||||
return self._stop(code, name, str(e) or type(e).__name__)
|
||||
sums = block_totals(lines)
|
||||
why = next((ln["why"] for ln in lines if ln.get("why")), None)
|
||||
return {
|
||||
"logic": code,
|
||||
"name": name,
|
||||
"spec": ", ".join(f"{k}={v}" for k, v in src["given"].items()),
|
||||
"unit": unit,
|
||||
"source": row.get("출처") or "",
|
||||
"lines": lines,
|
||||
**sums,
|
||||
"why": why,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _stop(code: str, name: str, why: str) -> dict[str, Any]:
|
||||
return {"logic": code, "name": name, "spec": "", "unit": "", "source": "", "lines": [],
|
||||
**dict.fromkeys(PARTS), "sum": None, "why": why} # fmt: skip
|
||||
|
||||
def _blank(self, item: dict, why: str) -> dict[str, Any]:
|
||||
part = COST_PART.get(item.get("비목"), "material")
|
||||
return {"kind": "blank", "name": item.get("이름", ""), "spec": "", "qty": None,
|
||||
"unit": item.get("단위", ""), "ref": None, "price": {},
|
||||
**{p: (None if p == part else 0.0) for p in PARTS}, "why": why} # fmt: skip
|
||||
|
||||
def _line(self, whole, item, n, env, times, rate, depth) -> dict[str, Any]:
|
||||
try:
|
||||
raw = mf.evaluate(mf.parse(item["수량"]), env, whole, 0)
|
||||
except mf.EmptyInput as e:
|
||||
return self._blank(item, str(e))
|
||||
qty = Decimal(str(raw)) / times * (rate or 1)
|
||||
q = float(qty)
|
||||
base = {"name": item.get("이름", ""), "spec": "", "qty": q, "unit": item.get("단위", "")}
|
||||
zero = dict.fromkeys(PARTS, 0.0)
|
||||
kind = item.get("종류")
|
||||
if kind == "로직":
|
||||
return self._logic_line(whole, item, env, q, base, zero, depth)
|
||||
if kind == "인력":
|
||||
key = mf.fill(item["요소"], env) if isinstance(item["요소"], str) else ""
|
||||
price = self.prices.labor.get(key)
|
||||
why = None if price is not None else f"노무비목록표에 없음 「{key}」"
|
||||
return {**base, **zero, "kind": "labor", "ref": {"tab": "labor", "key": key},
|
||||
"price": {"labor": price}, "labor": _money(price, q), "why": why} # fmt: skip
|
||||
ref = (
|
||||
{k: mf.fill(v, env) if isinstance(v, str) else v for k, v in item["요소"].items()}
|
||||
if isinstance(item["요소"], dict)
|
||||
else mf.fill(item["요소"], env)
|
||||
)
|
||||
_price, source, _why, extra = mf.mp.priced(whole, item, ref, env, n)
|
||||
goods = (extra or {}).get("품목") or {}
|
||||
key = str(
|
||||
goods.get("키") or (source or "").split(".")[0] or (ref if isinstance(ref, str) else "")
|
||||
)
|
||||
part = COST_PART.get(item.get("비목"), "material")
|
||||
price = self.prices.material.get(key)
|
||||
if q == 0:
|
||||
price = price if price is not None else 0.0 # 안 쓰는 재료 줄 — 금액 0
|
||||
why = None if price is not None else f"자재단가대비표에 없음 「{key}」"
|
||||
spec = goods.get("규격") or ""
|
||||
ref = {"tab": "compare", "key": key}
|
||||
return {**base, **zero, "spec": spec, "kind": "material", "ref": ref,
|
||||
"price": {part: price}, part: _money(price, q), "why": why} # fmt: skip
|
||||
|
||||
def _logic_line(self, whole, item, env, q, base, zero, depth) -> dict[str, Any]:
|
||||
node = mf.parse(item["요소"])
|
||||
called = node[1] if node and node[0] == "logic" else None
|
||||
try:
|
||||
target = whole.logic(called) if called else None
|
||||
except mf.FormulaError:
|
||||
target = None
|
||||
if target and any(s.get("이름") == "기계" for s in target.get("입력", [])):
|
||||
keys = _captured(whole, node, env)
|
||||
m = self.prices.machine.get(keys[0]) if keys else None
|
||||
price = {p: num((m or {}).get(p)) for p in PARTS}
|
||||
why = None if m else f"중기목록표에 없음 「{keys[0] if keys else item.get('이름')}」"
|
||||
amounts = {p: _money(price[p], q) if m else None for p in PARTS}
|
||||
ref = {"tab": "machine", "key": keys[0] if keys else ""}
|
||||
return {**base, "kind": "machine", "ref": ref,
|
||||
"price": price, **amounts, "why": why} # fmt: skip
|
||||
if not target or depth >= DEPTH:
|
||||
return {**self._blank(item, "하위 로직을 못 펼침"), **base}
|
||||
given = {name: mf.evaluate(expr, env, whole, 1) for name, expr in node[2]}
|
||||
sub = self.add(whole, {"code": called, "given": _clean_given(given)}, None, depth + 1)
|
||||
price = {p: sub.get(p) for p in PARTS}
|
||||
why = None if sub.get("sum") is not None else f"하위 산근 {sub['no']} 빈칸"
|
||||
return {**base, "kind": "sangeun", "ref": {"tab": "sg", "key": sub["key"]},
|
||||
"price": price, **{p: _money(price[p], q) for p in PARTS}, "why": why} # fmt: skip
|
||||
|
||||
def _extras(self, whole, row, env, lines) -> None:
|
||||
"""% 줄(덧줄) — 엔진 식 그대로 · 밑수 = 그때까지 B09 비목 합 · 줄 금액 = 이름별."""
|
||||
for extra in row.get("덧줄", []):
|
||||
sums = {name: total([ln[p] for ln in lines]) for name, p in COST_PART.items()}
|
||||
part = COST_PART.get(extra.get("비목"), "material")
|
||||
line = {"kind": "extra", "name": extra["이름"], "spec": extra["식"], "qty": 1.0,
|
||||
"unit": "식", "ref": None, **dict.fromkeys(PARTS, 0.0)} # fmt: skip
|
||||
if any(v is None for v in sums.values()):
|
||||
lines.append({**line, part: None, "price": {}, "why": "밑수 빈칸"})
|
||||
continue
|
||||
env.update({k: Decimal(str(v)) for k, v in sums.items()})
|
||||
env["줄"] = {
|
||||
ln["name"]: Decimal(str(sum(ln[p] or 0 for p in PARTS)))
|
||||
for ln in lines
|
||||
if ln["name"]
|
||||
}
|
||||
try:
|
||||
value = float(mf.evaluate(mf.parse(extra["식"]), env, whole, 0))
|
||||
except (mf.FormulaError, ArithmeticError, TypeError, ValueError) as e:
|
||||
lines.append({**line, part: None, "price": {}, "why": str(e)})
|
||||
continue
|
||||
lines.append({**line, "price": {part: value}, part: rounddown(value, 1), "why": None})
|
||||
|
||||
|
||||
# ── 모음 ──────────────────────────────────────────────────────────────
|
||||
def collect(entries: list[tuple[str, Path]], sheets: dict[str, Any]) -> dict[str, Any]:
|
||||
"""`entries` = [(구조물 id, 사본 폴더)] → 블록 · 목록 · 「연결 없음」."""
|
||||
solver = Solver(sheets)
|
||||
for owner, folder in entries:
|
||||
rows = rows_of(folder)
|
||||
if not rows:
|
||||
continue
|
||||
whole = item_prices.load_prices(read_json(folder / PRICE_FILE) or {})
|
||||
for src in rows:
|
||||
solver.add(whole, src, owner)
|
||||
blocks = sorted(solver.blocks.values(), key=lambda b: b["no"])
|
||||
listing = [
|
||||
{k: b[k] for k in ("no", "key", "logic", "name", "spec", "unit", "sum", *PARTS, "uses")}
|
||||
for b in blocks
|
||||
]
|
||||
gaps = [
|
||||
gap("sg", b["key"], f"산근 {b['no']} {b['name']}", b["why"] or "단가 빈칸")
|
||||
for b in blocks
|
||||
if b["sum"] is None
|
||||
]
|
||||
return {
|
||||
"sg": blocks,
|
||||
"sg_list": listing,
|
||||
"sg_gaps": gaps,
|
||||
"sg_notes": [NO_SOURCE] + ([] if blocks else [EMPTY]),
|
||||
}
|
||||
|
||||
|
||||
def build(project_root: str | Path, sheets: dict[str, Any]) -> dict[str, Any]:
|
||||
"""연결된 사본 전부(구조물 id 차례)의 산근."""
|
||||
base = structures_dir(project_root)
|
||||
entries = [
|
||||
(owner, base / linked["폴더"])
|
||||
for owner, linked in sorted(linked_snapshots(project_root).items())
|
||||
if "일위대가" in linked.get("부분", [])
|
||||
]
|
||||
return collect(entries, sheets)
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
buildSumMaterial,
|
||||
} from "./B09_Estimation_UI_Tabs_Summary";
|
||||
import { buildCost, buildTotal } from "./B09_Estimation_UI_Tabs_Total";
|
||||
import { buildSg, buildSgList } from "./B09_Estimation_UI_Tabs_Sangeun";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
@@ -61,8 +62,6 @@ function L(key: keyof typeof ui_locales): string {
|
||||
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>> = {
|
||||
@@ -81,6 +80,8 @@ const BUILDERS: Partial<Record<TabId, TabBuild>> = {
|
||||
sum_labor: buildSumLabor,
|
||||
sum_expense: buildSumExpense,
|
||||
sum_machine: buildSumMachine,
|
||||
sg_list: buildSgList,
|
||||
sg: buildSg,
|
||||
};
|
||||
|
||||
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
@@ -104,11 +105,21 @@ function note(textValue: string): HTMLElement {
|
||||
return p;
|
||||
}
|
||||
|
||||
/** 브라우저 셈 — 서버 짝과 같은 식(조작 중) */
|
||||
/** 산근(145-1) = 서버 단독 — 받은 그대로 두는 칸 */
|
||||
const SERVER_ONLY = ["sg", "sg_list", "sg_gaps", "sg_notes"] as const;
|
||||
|
||||
/** 브라우저 셈 — 서버 짝과 같은 식(조작 중) · 산근은 서버 답 그대로(+ 「연결 없음」) */
|
||||
export function computeSheets(payload: Row, user: Row): Row {
|
||||
const sheets = computeEstimation(payload.inputs, user);
|
||||
// 145-4 총괄 · 원가계산서 = 설계내역서 비목 합(145-3 `bill.sums`) + 사용자 요율 · 손입력
|
||||
return { ...sheets, total: computeTotal(sheets.bill?.sums, user.estimate) };
|
||||
const base = computeEstimation(payload.inputs, user);
|
||||
const server: Row = payload.sheets || {};
|
||||
const kept = Object.fromEntries(SERVER_ONLY.map((k) => [k, server[k] ?? []]));
|
||||
return {
|
||||
...base,
|
||||
...kept,
|
||||
gaps: [...base.gaps, ...(server.sg_gaps || [])],
|
||||
// 145-4 총괄 · 원가계산서 = 설계내역서 비목 합(145-3 `bill.sums`) + 사용자 요율 · 손입력
|
||||
total: computeTotal(base.bill?.sums, user.estimate),
|
||||
};
|
||||
}
|
||||
|
||||
export function renderB09Estimation(root: HTMLElement): void {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_UI_Tabs_Sangeun.ts
|
||||
* B09 단가산출근거 · 단가산출근거목록표 탭(PLAN 145-1) — 값 = 서버 `B09_Estimation_Sangeun`
|
||||
* (서버 단독 · 이 탭엔 조작 없음 · [저장] · [단가 갱신] 때 서버가 다시 셈).
|
||||
*
|
||||
* 목록표 = 산근 호표 한 줄(합계 · 노무비 · 재료비 · 경비 · 1 단위) · 줄 누름 = 그 블록
|
||||
* 풀이표 = 블록 머리(제 n 호표) · 줄(노무 · 중기 노 · 재 · 경 · 자재 · 하위 산근 · % 줄) · 총계
|
||||
* 줄 누름 = 단가를 낸 탭 · 줄(노무비목록표 · 중기목록표 · 자재단가대비표 · 하위 산근)
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DataRow } from "@ui/ui_template_data_table";
|
||||
import type { Row } from "./B09_Estimation_Calc_Base";
|
||||
import {
|
||||
money,
|
||||
qty,
|
||||
text,
|
||||
type Link,
|
||||
type TabBuild,
|
||||
type TabId,
|
||||
type TabTable,
|
||||
} from "./B09_Estimation_UI_Tabs";
|
||||
|
||||
const PART_NAME: Record<string, string> = {
|
||||
labor: "노무비",
|
||||
material: "재료비",
|
||||
expense: "경 비",
|
||||
};
|
||||
const PARTS = ["labor", "material", "expense"] as const;
|
||||
|
||||
const notes = (sheets: Row): string =>
|
||||
((sheets.sg_notes as string[]) || []).map((n) => ` · ${n}`).join("");
|
||||
|
||||
export const buildSgList: TabBuild = ({ sheets }): TabTable => {
|
||||
const rows: DataRow[] = ((sheets.sg_list as Row[]) || []).map((r) => ({
|
||||
...r,
|
||||
note: `쓰는 구조물 ${(r.uses || []).length}`,
|
||||
}));
|
||||
return {
|
||||
columns: [
|
||||
qty("no", "호표", 0),
|
||||
text("name", "명칭", 220),
|
||||
text("spec", "규격(입력값)", 200),
|
||||
text("unit", "단위", 56),
|
||||
money("sum", "합계"),
|
||||
money("labor", "노무비"),
|
||||
money("material", "재료비"),
|
||||
money("expense", "경비"),
|
||||
text("note", "비고", 110),
|
||||
],
|
||||
rows,
|
||||
frozen: 2,
|
||||
hint:
|
||||
"산근 = 연결된 구조물 조합 사본의 「단가승격」 로직 줄 · 같은 로직 + 같은 입력값 = 한 호표 · 합계 = 블록 총계 · 줄 누름 = 그 블록" +
|
||||
notes(sheets),
|
||||
link: (i) => (rows[i] ? { tab: "sg", key: String(rows[i].key) } : null),
|
||||
};
|
||||
};
|
||||
|
||||
const LINE_TAB: Record<string, TabId> = {
|
||||
labor: "labor",
|
||||
machine: "machine",
|
||||
compare: "compare",
|
||||
sg: "sg",
|
||||
};
|
||||
|
||||
export const buildSg: TabBuild = ({ sheets }): TabTable | string => {
|
||||
const rows: DataRow[] = [];
|
||||
const links: Array<Link | null> = [];
|
||||
const push = (row: DataRow, link: Link | null = null): void => {
|
||||
rows.push(row);
|
||||
links.push(link);
|
||||
};
|
||||
for (const b of (sheets.sg as Row[]) || []) {
|
||||
push({
|
||||
key: b.key,
|
||||
name: `제 ${b.no} 호표 ${b.name}`,
|
||||
spec: b.spec,
|
||||
unit: b.unit,
|
||||
sum: b.sum,
|
||||
labor: b.labor,
|
||||
material: b.material,
|
||||
expense: b.expense,
|
||||
});
|
||||
if (b.source) push({ key: `${b.key}|출처`, name: ` ${b.source}` });
|
||||
(b.lines as Row[]).forEach((ln, n) => {
|
||||
const link = ln.ref?.key ? { tab: LINE_TAB[ln.ref.tab] ?? "compare", key: ln.ref.key } : null;
|
||||
// 중기 = 노 · 재 · 경 세 줄(엑셀 꼴) · 그 밖 = 한 줄
|
||||
const parts = ln.kind === "machine" ? PARTS.filter((p) => ln.price?.[p] !== 0) : [null];
|
||||
for (const part of parts) {
|
||||
const at = (p: string): number | null => (part === null || part === p ? ln[p] : 0);
|
||||
const price = part ? ln.price?.[part] : (Object.values(ln.price || {})[0] ?? null);
|
||||
push(
|
||||
{
|
||||
key: `${b.key}|${n}${part ? `|${part}` : ""}`,
|
||||
name: ` ${ln.name}${part ? ` ${PART_NAME[part]}` : ""}`,
|
||||
spec: ln.why ? `${ln.spec ? `${ln.spec} · ` : ""}${ln.why}` : ln.spec,
|
||||
qty: ln.qty,
|
||||
unit: ln.unit,
|
||||
price: price as number | null,
|
||||
sum: PARTS.some((p) => at(p) === null)
|
||||
? null
|
||||
: PARTS.reduce((s, p) => s + (at(p) as number), 0),
|
||||
labor: at("labor"),
|
||||
material: at("material"),
|
||||
expense: at("expense"),
|
||||
},
|
||||
link,
|
||||
);
|
||||
}
|
||||
});
|
||||
push({
|
||||
key: `${b.key}|총계`,
|
||||
name: " 총 계",
|
||||
spec: b.why ?? "",
|
||||
sum: b.sum,
|
||||
labor: b.labor,
|
||||
material: b.material,
|
||||
expense: b.expense,
|
||||
});
|
||||
}
|
||||
if (!rows.length) return ((sheets.sg_notes as string[]) || []).join(" · ") || "산근 없음";
|
||||
return {
|
||||
columns: [
|
||||
text("name", "공종 · 산출내역", 260),
|
||||
text("spec", "규격 · 까닭", 180),
|
||||
qty("qty", "수량"),
|
||||
text("unit", "단위", 56),
|
||||
money("price", "단가", undefined, 1),
|
||||
money("sum", "합계", undefined, 1),
|
||||
money("labor", "노무비", undefined, 1),
|
||||
money("material", "재료비", undefined, 1),
|
||||
money("expense", "경비", undefined, 1),
|
||||
],
|
||||
rows,
|
||||
frozen: 1,
|
||||
hint:
|
||||
"수량 = 로직 품(1 단위 · 작업량 Q = ROUND 2) · 단가 = 굳힌 B09 단가 · 줄 ROUNDDOWN 1 · 총계 비목마다 ROUNDDOWN 0 · 자재 적용 단가를 고치면 [저장] 뒤 반영 · 줄 누름 = 단가를 낸 탭" +
|
||||
notes(sheets),
|
||||
link: (i: number) => links[i] ?? null,
|
||||
};
|
||||
};
|
||||
@@ -221,19 +221,19 @@ def check_saved(files: dict, changed: list[str], book: dict, narrowed=None) -> l
|
||||
|
||||
|
||||
def _labor_files() -> list[tuple[str, Path]]:
|
||||
"""모든 층(마스터 · 회사 · 개인)의 일위대가 `labor/<열>.json` · 단가승격 `promote/<키>.json`
|
||||
· 사전조합 `precombo/<키>.json`(51-11) — (어디, 파일)."""
|
||||
"""모든 층(마스터 · 회사 · 개인)의 일위대가 `labor/<열>.json` · 사전조합 `precombo/<키>.json`(51-11) —
|
||||
(어디, 파일)."""
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers # M02 가 M01 을 부름 — 늦게
|
||||
|
||||
return [
|
||||
(f"{what} {where}", path)
|
||||
for kind, what in (("labor", "일위대가"), ("promote", "단가승격"), ("precombo", "사전조합"))
|
||||
for kind, what in (("labor", "일위대가"), ("precombo", "사전조합"))
|
||||
for where, path in layers.layer_files(kind)
|
||||
]
|
||||
|
||||
|
||||
def labor_errors(whole) -> list[str]:
|
||||
"""일위대가 · 단가승격 · 사전조합 문서가 담은 로직이 마스터에 없음(39-1d · 50-3) —
|
||||
"""일위대가 · 사전조합 문서가 담은 로직 마스터에 없음(39-1d · 50-3) —
|
||||
로직 지우기를 막음 · 어디서 쓰는지 알림 · 참조 줄(로직 없음)은 건너뜀. 전부터 깨진 것은
|
||||
`save` 가 고치기 전 검사와 견주어 뺌."""
|
||||
out = []
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""M02 일위대가 문서 `labor/<열id>.json` — 정리 · 검사 · 계산 · 알약(PLAN 39-1a · 설계_정함 1장).
|
||||
|
||||
문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬,
|
||||
담은로직: [{로직, 별칭, 메모, 입력값, 수량, 켜쌓기율?, 고름?} | {참조: {층, 주인, 키}, 별칭, 메모, 수량}],
|
||||
담은로직: [{로직, 별칭, 메모, 입력값, 수량, 켜쌓기율?, 고름?, 승격?} | {참조: {층, 주인, 키}, 별칭, 메모, 수량}],
|
||||
자재: [{코드, 이름, 규격, 단위, 수량}], 계산값}` — 이름은 구조물 열 이름(따로 안 둠).
|
||||
수량 = `null`(면적산출 알약 칸 값 · 로직 알약 「이름|단위」 · 자재 알약 「자재|MT키」) | `{손: 수}`
|
||||
(손이 칸보다 앞섬) · 같은 로직 두 번 금지 · 입력값 = 그 로직 입력 이름만(빈 값 = 자동값).
|
||||
@@ -17,6 +17,7 @@
|
||||
로직 자재(103-1) = 담은 로직 맨 위 층 호표 재료 줄(`logic_mats` · 줄마다 `재료`) ·
|
||||
고름 = `{호표 차례: MT키}`(엔진 `choosing` 그대로) · 금액은 로직 재료비 안이라 합 · 알약에 안 더함(보기만) · 사용자 자재와
|
||||
같은 품목 두 번은 [저장] 때 400.
|
||||
승격(134-1) = 로직 줄 「단가승격」 표시(`true` 만 · 없음 = 단가산출) · 금액 무관 · B09 호표 몫.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -124,6 +125,7 @@ def clean(doc: Any) -> dict[str, Any]:
|
||||
"수량": _qty(one.get("수량"), where),
|
||||
**({"켜쌓기율": rate} if rate is not None else {}),
|
||||
**({"고름": picks} if picks else {}),
|
||||
**({"승격": True} if one.get("승격") is True else {}),
|
||||
}
|
||||
)
|
||||
mats, codes = [], set()
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""M02 일위대가 조합 서고 — 단가승격 · 사전조합 · 참조(PLAN 50-3 · 조사 갈래 A).
|
||||
"""M02 일위대가 사전조합 서고 · 참조(PLAN 50-3 · 조사 갈래 A · 134-2 단가승격 서고 지움).
|
||||
|
||||
자리 = 층 양식 길 새 종류 둘(`M02_Template_Layers.LIBRARY_KINDS`) — 파일 이름 = 키:
|
||||
promote `{층}/promote/SP-######.json` 단가승격
|
||||
`{종류:"단가승격", 키, 이름, 단위, 배, 본뜬, 담은로직:[{로직, 입력값}]}` —
|
||||
줄 하나 · 입력값은 승격 때 고정 · 값 = 로직 기준 ÷ 배(1 단위 값 · `배` 는 저장 때)
|
||||
자리 = 층 양식 길 종류(`M02_Template_Layers.LIBRARY_KINDS`) — 파일 이름 = 키:
|
||||
promote 옛 단가승격 `{층}/promote/SP-######.json`(134-2 · 새로 안 만듦 · 읽기 · 셈만) —
|
||||
`{종류:"단가승격", 키, 이름, 단위, 배, 담은로직:[{로직, 입력값}]}` · 값 = 로직 기준 ÷ 배 ·
|
||||
층 문서를 읽을 때 옛 참조 줄 = 로직 줄 + `승격`(`unpromote`) · 프로젝트 사본은 그대로 셈
|
||||
precombo `{층}/precombo/SC-######.json` 사전조합
|
||||
`{종류:"사전조합", 키, 이름, 단위, 본뜬, 담은로직:[{참조, 수량:{손}, 메모}
|
||||
| {로직, 입력값, 메모, 수량:{손}}]}` — 승격 · 사전조합 · 단가산출 로직(51-11) ·
|
||||
@@ -27,8 +27,8 @@ from typing import Any
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
|
||||
PREFIX = {"promote": "SP", "precombo": "SC"}
|
||||
TITLE = {"promote": "단가승격", "precombo": "사전조합"}
|
||||
PREFIX = {"precombo": "SC"}
|
||||
TITLE = {"precombo": "사전조합"}
|
||||
DEPTH = 8 # 참조 깊이 상한(저장 · 계산 둘 다)
|
||||
REF_HEAD = "참조|"
|
||||
_KEY = re.compile(r"^(SP|SC)-(\d{6})$")
|
||||
@@ -133,61 +133,67 @@ def check_key(kind: str, name: str) -> None:
|
||||
|
||||
def clean_doc(kind: str, doc: Any) -> dict[str, Any]:
|
||||
"""서고 문서를 서버가 받는 꼴로 — `doc["키"]` = 파일 이름(부르는 쪽이 넣음)."""
|
||||
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
|
||||
if not isinstance(doc, dict):
|
||||
raise _bad("서고 문서가 묶음 아님")
|
||||
key = str(doc.get("키") or "")
|
||||
check_key(kind, key)
|
||||
name, unit = str(doc.get("이름") or "").strip(), str(doc.get("단위") or "").strip()
|
||||
out: dict[str, Any] = {"종류": TITLE[kind], "키": key}
|
||||
if kind == "promote":
|
||||
lines = doc.get("담은로직") or []
|
||||
if len(lines) != 1 or not isinstance(lines[0], dict) or not lines[0].get("로직"):
|
||||
raise _bad("단가승격은 단가산출 로직 한 줄")
|
||||
line = labor.clean({"담은로직": lines})["담은로직"][0]
|
||||
pill = (combo_store.combo_pills({"담은로직": [line]})["물량"] or [{}])[0]
|
||||
out.update(
|
||||
이름=name or pill.get("이름") or line["로직"],
|
||||
단위=unit or pill.get("단위") or None,
|
||||
배=pill.get("배") or 1, # ponytail: 저장 때 적음 · 마스터 결과단위가 바뀌면 다시 저장
|
||||
담은로직=[{k: line[k] for k in ("로직", "입력값", "켜쌓기율") if k in line}],
|
||||
**origin(line["로직"]),
|
||||
)
|
||||
else:
|
||||
if not name:
|
||||
raise _bad("사전조합 이름이 빔")
|
||||
out.update(이름=name, 단위=unit or None, 담은로직=_combo_lines(doc, key))
|
||||
out.update({k: str(doc.get(k) or "").strip() for k in ("구분", "상세구분")})
|
||||
if not name:
|
||||
raise _bad("사전조합 이름이 빔")
|
||||
out.update(이름=name, 단위=unit or None, 담은로직=_combo_lines(doc, key))
|
||||
out.update({k: str(doc.get(k) or "").strip() for k in ("구분", "상세구분")})
|
||||
out["본뜬"] = doc["본뜬"] if isinstance(doc.get("본뜬"), dict) else None
|
||||
return out
|
||||
|
||||
|
||||
def origin(code: str) -> dict[str, Any]:
|
||||
"""단가승격 칸 — 원 로직의 구분 · 상세구분 + 연결 {로직 키, 출처 글}(51-12 · 없으면 빈 글)."""
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
|
||||
try:
|
||||
got = m01.loaded()[1].logic(code)
|
||||
except m01.cm.mf.FormulaError:
|
||||
got = {}
|
||||
return {
|
||||
"구분": got.get("구분") or "",
|
||||
"상세구분": got.get("상세구분") or "",
|
||||
"연결": {"로직": code, "출처": got.get("출처") or ""},
|
||||
}
|
||||
_OLD_CLASS = re.compile(r"품셈") # 134-5 — 옛 품셈 분류(건설품셈 공통 · 산림품셈 · 07장 …)
|
||||
|
||||
|
||||
def filled(kind: str, doc: Any) -> Any:
|
||||
"""옛 서고 문서(51-12 앞) 읽을 때 칸 채움 — 승격 = 원 로직 것 · 사전조합 = 빈 글(화면 몫)."""
|
||||
if not isinstance(doc, dict) or kind not in PREFIX or "구분" in doc:
|
||||
"""옛 서고 문서 읽을 때 칸 채움 — 칸 없음(51-12 앞) · 옛 품셈 분류(134-5) = 빈 글(미분류) ·
|
||||
파일은 안 고침([저장] 때 굳음) · 그 밖 지금 값 그대로(구조물정의가 바뀌어도 화면이 선택지로 둠)."""
|
||||
if not isinstance(doc, dict) or kind not in PREFIX:
|
||||
return doc
|
||||
if kind == "precombo":
|
||||
return {**doc, "구분": "", "상세구분": ""}
|
||||
line = (doc.get("담은로직") or [{}])[0]
|
||||
code = str(line.get("로직") or "") if isinstance(line, dict) else ""
|
||||
return {**doc, **origin(code)}
|
||||
if "구분" in doc and not _OLD_CLASS.search(str(doc.get("구분") or "")):
|
||||
return doc
|
||||
return {**doc, "구분": "", "상세구분": ""}
|
||||
|
||||
|
||||
def unpromote(kind: str, doc: Any) -> Any:
|
||||
"""층 문서를 읽을 때(134-2) — 옛 단가승격 참조 줄 → 로직 줄 `{로직, 입력값, 켜쌓기율, 수량}` +
|
||||
일위대가면 `승격: true`(사전조합 안 로직 줄엔 없음) · 값 같음(승격 값 = 기준 ÷ 배 = 로직 줄 반영) ·
|
||||
같은 로직 줄이 이미 있으면 그 줄은 옛 참조 그대로(읽기 · 셈 · 저장 됨) · 파일은 안 고침([저장] 때 굳음)."""
|
||||
lines = doc.get("담은로직") if isinstance(doc, dict) and kind in ("labor", "precombo") else None
|
||||
if not isinstance(lines, list):
|
||||
return doc
|
||||
have = {str(one.get("로직")) for one in lines if isinstance(one, dict) and one.get("로직")}
|
||||
out, changed = [], False
|
||||
for one in lines:
|
||||
ref = one.get("참조") if isinstance(one, dict) else None
|
||||
src = (
|
||||
read_ref(ref) if isinstance(ref, dict) and kind_of(ref.get("키")) == "promote" else None
|
||||
)
|
||||
line = ((src or {}).get("담은로직") or [None])[0]
|
||||
code = str(line.get("로직") or "") if isinstance(line, dict) else ""
|
||||
if not code or code in have:
|
||||
out.append(one)
|
||||
continue
|
||||
have.add(code)
|
||||
changed = True
|
||||
# ponytail: 수량 null(면적산출 칸 연결)은 알약 키가 로직 것으로 바뀌어 칸을 다시 이어야 함
|
||||
out.append(
|
||||
{
|
||||
"로직": code,
|
||||
"별칭": one.get("별칭"),
|
||||
"메모": one.get("메모"),
|
||||
"입력값": dict(line.get("입력값") or {}),
|
||||
"수량": one.get("수량"),
|
||||
**{k: line[k] for k in ("켜쌓기율", "고름") if line.get(k) is not None},
|
||||
**({"승격": True} if kind == "labor" else {}),
|
||||
}
|
||||
)
|
||||
return {**doc, "담은로직": out} if changed else doc
|
||||
|
||||
|
||||
def _combo_lines(doc: dict, key: str) -> list[dict[str, Any]]:
|
||||
@@ -314,7 +320,7 @@ def carry(doc: dict[str, Any], source: Here) -> dict[str, Any]:
|
||||
return ref
|
||||
if ref_id(ref) in done:
|
||||
return done[ref_id(ref)]
|
||||
if depth > DEPTH or not readable(source, ref):
|
||||
if depth > DEPTH or not readable(source, ref) or kind_of(ref.get("키")) == "promote":
|
||||
raise _bad(f"복사할 수 없는 참조 「{ref.get('키')}」")
|
||||
got = read_ref(ref)
|
||||
if got is None:
|
||||
|
||||
@@ -81,7 +81,7 @@ def _copy(kind: str, doc: dict[str, Any], source: refs.Here, ref: dict) -> dict[
|
||||
@router.post("/layers/{layer}/library/{kind}")
|
||||
def new_library(
|
||||
layer: Layer,
|
||||
kind: Literal["promote", "precombo"],
|
||||
kind: Literal["precombo"],
|
||||
body: LibraryBody,
|
||||
owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
|
||||
@@ -10,8 +10,8 @@ export type Layer = "system" | "company" | "personal" | "project";
|
||||
/** 서버 종류 — 화면의 「표 양식」 = table · 「도면 양식」 = drawing · 「구조물 도면」 = structure ·
|
||||
* 「면적산출」 = area(구조물집계표 열마다 한 통합문서) ·
|
||||
* 「일위대가」 = labor(열마다 문서 · 39장) ·
|
||||
* 「단가승격」 = promote · 「사전조합」 = precombo(일위대가 조합 서고 · 50-3 · 키가 이름) */
|
||||
export type Kind = "table" | "drawing" | "structure" | "area" | "labor" | "promote" | "precombo";
|
||||
* 「사전조합」 = precombo(일위대가 사전조합 서고 · 50-3 · 키가 이름 · 옛 단가승격 promote 는 134-2 에서 뺌) */
|
||||
export type Kind = "table" | "drawing" | "structure" | "area" | "labor" | "precombo";
|
||||
|
||||
export interface TemplateInfo {
|
||||
종류: Kind;
|
||||
@@ -70,26 +70,39 @@ export const getActiveLayer = (): Layer => activeLayer;
|
||||
export const getActiveOwner = (): string | null => activeOwner;
|
||||
|
||||
const enc = encodeURIComponent;
|
||||
/** 지금 층 길이면 `owner` 인자를 붙임 */
|
||||
const owned = (layer: Layer, path: string): string =>
|
||||
layer === activeLayer && activeOwner && layer !== "system" && layer !== "project"
|
||||
? `${path}${path.includes("?") ? "&" : "?"}owner=${enc(activeOwner)}`
|
||||
/** 지금 층 길이면 `owner` 인자를 붙임 · `owner` 를 넘기면(134-3 · 사전조합 층) 그 주인만(null = 자기 층) */
|
||||
const owned = (layer: Layer, path: string, owner?: string | null): string => {
|
||||
const who = owner === undefined ? (layer === activeLayer ? activeOwner : null) : owner;
|
||||
return who && layer !== "system" && layer !== "project"
|
||||
? `${path}${path.includes("?") ? "&" : "?"}owner=${enc(who)}`
|
||||
: path;
|
||||
};
|
||||
const scoped = <T>(path: string, init: RequestInit = {}): Promise<T> =>
|
||||
call<T>(owned(activeLayer, `/layers/${activeLayer}${path}`), init);
|
||||
|
||||
const q = (projectId: string | null): string => (projectId ? `?project_id=${enc(projectId)}` : "");
|
||||
const head = (layer: Layer): string => `/layers/${layer}/templates`;
|
||||
const item = (layer: Layer, kind: Kind, name: string, projectId: string | null): string =>
|
||||
owned(layer, `${head(layer)}/${kind}/${enc(name)}${layer === "system" ? "" : q(projectId)}`);
|
||||
const item = (
|
||||
layer: Layer,
|
||||
kind: Kind,
|
||||
name: string,
|
||||
projectId: string | null,
|
||||
owner?: string | null,
|
||||
): string =>
|
||||
owned(
|
||||
layer,
|
||||
`${head(layer)}/${kind}/${enc(name)}${layer === "system" ? "" : q(projectId)}`,
|
||||
owner,
|
||||
);
|
||||
|
||||
/** 층 길은 `{층, 양식: [...]}` · 옛 시스템 길은 배열 — 둘 다 목록으로 */
|
||||
export const listTemplates = async (
|
||||
layer: Layer,
|
||||
projectId: string | null,
|
||||
owner?: string | null,
|
||||
): Promise<TemplateInfo[]> => {
|
||||
const got = await call<TemplateInfo[] | { 양식: TemplateInfo[] }>(
|
||||
owned(layer, `${head(layer)}${layer === "system" ? "" : q(projectId)}`),
|
||||
owned(layer, `${head(layer)}${layer === "system" ? "" : q(projectId)}`, owner),
|
||||
);
|
||||
return Array.isArray(got) ? got : got.양식;
|
||||
};
|
||||
@@ -99,7 +112,8 @@ export const readTemplate = (
|
||||
kind: Kind,
|
||||
name: string,
|
||||
projectId: string | null,
|
||||
): Promise<TemplateDoc> => call(item(layer, kind, name, projectId));
|
||||
owner?: string | null,
|
||||
): Promise<TemplateDoc> => call(item(layer, kind, name, projectId, owner));
|
||||
|
||||
export const saveTemplate = (
|
||||
layer: Layer,
|
||||
@@ -108,8 +122,12 @@ export const saveTemplate = (
|
||||
projectId: string | null,
|
||||
판: string,
|
||||
문서: unknown,
|
||||
owner?: string | null,
|
||||
): Promise<TemplateInfo> =>
|
||||
call(item(layer, kind, name, projectId), { method: "PUT", body: JSON.stringify({ 판, 문서 }) });
|
||||
call(item(layer, kind, name, projectId, owner), {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ 판, 문서 }),
|
||||
});
|
||||
|
||||
/** 시스템 층만 지움 */
|
||||
export const deleteTemplate = (kind: Kind, name: string, 판: string): Promise<{ ok: boolean }> =>
|
||||
@@ -121,8 +139,9 @@ export const deleteLayerTemplate = (
|
||||
kind: Kind,
|
||||
name: string,
|
||||
판: string,
|
||||
owner?: string | null,
|
||||
): Promise<{ ok: boolean }> =>
|
||||
call(owned(layer, `/layers/${layer}/templates/${kind}/${enc(name)}?판=${enc(판)}`), {
|
||||
call(owned(layer, `/layers/${layer}/templates/${kind}/${enc(name)}?판=${enc(판)}`, owner), {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
@@ -131,10 +150,11 @@ export type LibraryBody =
|
||||
{ 문서: Record<string, unknown> } | { 본뜬: { 층: Layer; 주인: string | null; 키: string } };
|
||||
export const newLibrary = (
|
||||
layer: Layer,
|
||||
kind: "promote" | "precombo",
|
||||
kind: "precombo",
|
||||
body: LibraryBody,
|
||||
owner?: string | null,
|
||||
): Promise<TemplateInfo> =>
|
||||
call(owned(layer, `/layers/${layer}/library/${kind}`), {
|
||||
call(owned(layer, `/layers/${layer}/library/${kind}`, owner), {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
@@ -204,6 +204,16 @@
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 구분 드롭다운(134-1) — 칸 너비만 · 품셈 책 글은 아래 */
|
||||
.m02-labrow__kind .ui-field {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.m02-labrow__kind .ui-select {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 줄 접고 폄 표(51-2) · 고른 줄 하위 행 — 이름 들여쓰기 · 흐린 바탕 · 위 줄보다 촘촘(51-7) */
|
||||
.m02-labrow .m02-labrow__sub td {
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
|
||||
@@ -133,6 +133,8 @@ export interface LaborRow {
|
||||
켜쌓기율?: number | null;
|
||||
/** 고른 로직 자재(103-1) — {호표 차례: MT키} · 없으면 엔진 기본 */
|
||||
고름?: Record<string, string>;
|
||||
/** 단가승격(134-1 · 로직 줄 구분 드롭다운) — 없음 = 단가산출 · 금액 무관 · B09 호표 몫 */
|
||||
승격?: boolean;
|
||||
}
|
||||
export interface LaborMat {
|
||||
코드: string;
|
||||
@@ -170,6 +172,7 @@ export function normalizeLabor(doc: unknown, column: string): LaborDoc {
|
||||
...(r.참조 ? { 참조: r.참조 } : {}),
|
||||
...(r.켜쌓기율 != null ? { 켜쌓기율: r.켜쌓기율 } : {}),
|
||||
...(r.고름 && Object.keys(r.고름).length ? { 고름: { ...r.고름 } } : {}),
|
||||
...(r.승격 === true && !r.참조 ? { 승격: true } : {}),
|
||||
})),
|
||||
자재: (d.자재 ?? []).map((m) => ({
|
||||
코드: m.코드,
|
||||
@@ -215,9 +218,9 @@ export type {
|
||||
Money as LaborCost,
|
||||
} from "./M02_MasterTemplete_Labor_Calc";
|
||||
|
||||
/** 지금 층이면 `owner` 를 붙인 층 길(`/layers/{층}/labor/{열}…`) */
|
||||
const laborAt = (layer: Layer, column: string, tail: string): string => {
|
||||
const owner = layer === getActiveLayer() ? getActiveOwner() : null;
|
||||
/** 지금 층이면 `owner` 를 붙인 층 길(`/layers/{층}/labor/{열}…`) · `who` 를 넘기면 그 주인(134-3) */
|
||||
const laborAt = (layer: Layer, column: string, tail: string, who?: string | null): string => {
|
||||
const owner = who !== undefined ? who : layer === getActiveLayer() ? getActiveOwner() : null;
|
||||
const q = owner && layer !== "system" && layer !== "project" ? `?owner=${enc(owner)}` : "";
|
||||
return `/layers/${layer}/labor/${enc(column)}${tail}${q}`;
|
||||
};
|
||||
@@ -228,8 +231,9 @@ export const calcLabor = (
|
||||
column: string,
|
||||
doc: LaborDoc | null,
|
||||
수량?: Record<string, number>,
|
||||
owner?: string | null,
|
||||
): Promise<LaborCalc & { 알약: unknown[] }> =>
|
||||
request(laborAt(layer, column, "/calc"), "POST", {
|
||||
request(laborAt(layer, column, "/calc", owner), "POST", {
|
||||
...(doc ? { doc } : {}),
|
||||
...(수량 ? { 수량 } : {}),
|
||||
});
|
||||
@@ -239,14 +243,14 @@ export interface LibraryRow {
|
||||
이름: string;
|
||||
제목?: string | null;
|
||||
단위?: string | null;
|
||||
/** 단가승격 = 원 로직 것 · 사전조합 = 사용자가 고름(51-12) */
|
||||
/** 사전조합 = 사용자가 고름(51-12) */
|
||||
구분?: string | null;
|
||||
상세구분?: string | null;
|
||||
}
|
||||
/** 서고 목록 — 그 층(주인) 단가승격 `promote` · 사전조합 `precombo` 문서 */
|
||||
/** 서고 목록 — 그 층(주인) 사전조합 `precombo` 문서 */
|
||||
export const fetchLibrary = async (
|
||||
ref: Omit<LaborRef, "키">,
|
||||
kind: "promote" | "precombo",
|
||||
kind: "precombo",
|
||||
): Promise<LibraryRow[]> => {
|
||||
const q = ref.주인 && ref.층 !== "system" ? `?owner=${enc(ref.주인)}` : "";
|
||||
const got = await call<{ 양식: (LibraryRow & { 종류: string })[] }>(
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
* · 자재목록 = 로직 자재 줄(103-1 · `Labor_LogicMats` · [바꾸기] → `담은로직[].고름` · 입력이 바뀌면 걸린 고름 풂) +
|
||||
* 사용자 자재 줄([삭제]) · 로직 자재와 같은 품목은 사용자 자재로 못 담음
|
||||
* · 오른쪽 「원문」 접는 패널(47-4 · 상세만 · 공용 `ui_template_side_panels`) = 고른 줄 로직의 출처 원문
|
||||
* · 서고 모드(50-3 · `mode`) = 단가승격 · 사전조합 — 이름 칸 · 단가산출 로직 표 · 원문만(자재 · 면적 · 다이어그램 없음) ·
|
||||
* 정보 칸 구분 · 상세구분(51-12 · 승격 = 읽기만 + 연결 · 사전조합 = 고름) ·
|
||||
* 승격은 로직 한 줄(입력값 고정 · 더하기 · 순서 · 수량 없음) · 사전조합 = 일위대가 상세와 같은 [로직 추가] · 표 · 동작(51-11) · 수량 손 입력(기본 1)
|
||||
* · 서고 모드(50-3 · `mode`) = 사전조합 — 이름 칸 · 단가산출 로직 표 · 원문만(자재 · 면적 · 다이어그램 없음) ·
|
||||
* 정보 칸 구분 · 상세구분(51-12 · 고름) · 일위대가 상세와 같은 [로직 추가] · 표 · 동작(51-11) · 수량 손 입력(기본 1)
|
||||
* · 로직 줄 구분 드롭다운(134-1 · 단가산출 | 단가승격 · 서고 모드엔 없음)
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
@@ -27,7 +27,6 @@ import { ROUTES } from "@config/config_frontend";
|
||||
import { navigateTo } from "../A00_Common/router";
|
||||
import { setPendingLogicKey } from "../M01_MasterData/M01_MasterData_UI_Side";
|
||||
import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source";
|
||||
import type { SubBrief } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||||
import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet";
|
||||
type Recalc = (typeof import("../A00_Common/spreadsheet/spreadsheet_recalc"))["recalcWorkbook"];
|
||||
import {
|
||||
@@ -43,7 +42,6 @@ import { lineId, settle, type LaborRef } from "./M02_MasterTemplete_Labor_Calc";
|
||||
import {
|
||||
areaLinks,
|
||||
calcLabor,
|
||||
fetchLibLogicSubs,
|
||||
fetchLogicInputs,
|
||||
normalizeLabor,
|
||||
type LaborCalc,
|
||||
@@ -68,6 +66,8 @@ import { insideKeys, releaseTied } from "./M02_Labor_LogicMats_Model";
|
||||
import { keepKnown } from "./M02_Labor_Values_Model";
|
||||
import { pickMaterial } from "./M02_Material_Pick";
|
||||
import { createUnitPick, NO_UNIT } from "./DataFlow/M02_DF_UnitPick";
|
||||
import { fetchStructureDef } from "./StructOptions/M02_SO_Api";
|
||||
import { precomboClasses } from "./M02_MasterTemplete_UI_Side_Precombo";
|
||||
import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css";
|
||||
import "../M01_MasterData/M01_MasterData_UI_LogicLab_Style.css";
|
||||
import "@ui/ui_template_general_layout.css";
|
||||
@@ -84,8 +84,10 @@ export interface LaborDetailOptions {
|
||||
/** 구조물 조합 카드 갈래(43-4) — 단가산출 로직 표 + 자재목록만 · 위로 · 아래로 · 로직 추가 ·
|
||||
* 자재 추가 · 정보(단위 · 본뜬) · 다이어그램 없음 · 옵션 · 삭제 · 줄 · 수량 모달은 상세와 같음 */
|
||||
card?: boolean;
|
||||
/** 일위대가 조합 서고(50-3) — 단가승격 · 사전조합 문서(`column` = 키) */
|
||||
mode?: "promote" | "precombo";
|
||||
/** 일위대가 사전조합 서고(50-3) — 사전조합 문서(`column` = 키) */
|
||||
mode?: "precombo";
|
||||
/** 그 층 주인(134-3 · 사전조합 층 · null = 자기 층) — 없으면 페이지 층 것 */
|
||||
owner?: string | null;
|
||||
}
|
||||
|
||||
export interface LaborDetailHandle {
|
||||
@@ -105,7 +107,6 @@ export function mountLaborDetail(
|
||||
const saved = JSON.stringify(cur);
|
||||
const edit = !opt.readOnly;
|
||||
const lib = opt.mode ?? null;
|
||||
const promote = lib === "promote";
|
||||
const logics = new Map<string, ItemLogic>();
|
||||
let calc: LaborCalc | null = null;
|
||||
/** 마지막으로 받은 calc — 다시 세는 동안 로직 자재 줄을 그대로 둠(줄이 사라졌다 나타나지 않게) */
|
||||
@@ -195,7 +196,7 @@ export function mountLaborDetail(
|
||||
logicTable(
|
||||
{ rows: cur.담은로직, logics, calc, fail, cells: cells(), selected: picked },
|
||||
// 읽기만이면 순서 · 삭제 없음([옵션] = 보기 모달은 둠)
|
||||
edit && !promote
|
||||
edit
|
||||
? {
|
||||
open: pick,
|
||||
option: openRow,
|
||||
@@ -204,6 +205,8 @@ export function mountLaborDetail(
|
||||
move: reorder,
|
||||
remove: removeRow,
|
||||
alias: setAlias,
|
||||
// 134-1 — 구조물 일위대가 로직 줄만(사전조합 안 로직 줄엔 없음)
|
||||
...(lib ? {} : { promote: setPromote }),
|
||||
}
|
||||
: { open: pick, option: openRow, ref: openRef },
|
||||
),
|
||||
@@ -242,7 +245,13 @@ export function mountLaborDetail(
|
||||
draw();
|
||||
try {
|
||||
// 저장 안 한 칸 연결이 있으면 그 칸 값으로(없으면 서버가 저장된 면적산출을 읽음)
|
||||
const got = await calcLabor(opt.layer, opt.column, cur, areaDirty ? given() : undefined);
|
||||
const got = await calcLabor(
|
||||
opt.layer,
|
||||
opt.column,
|
||||
cur,
|
||||
areaDirty ? given() : undefined,
|
||||
opt.owner,
|
||||
);
|
||||
if (!alive || mine !== seq) return;
|
||||
calc = last = got;
|
||||
pills = new Map(
|
||||
@@ -361,6 +370,12 @@ export function mountLaborDetail(
|
||||
cur.담은로직[i].별칭 = value;
|
||||
opt.onChanged(dirty());
|
||||
}
|
||||
/** 구분 드롭다운(134-1) — 화면 캐시만 · 금액 무관이라 다시 셈 없음 */
|
||||
function setPromote(i: number, on: boolean): void {
|
||||
if (on) cur.담은로직[i].승격 = true;
|
||||
else delete cur.담은로직[i].승격;
|
||||
opt.onChanged(dirty());
|
||||
}
|
||||
function removeMat(i: number): void {
|
||||
cur.자재.splice(i, 1);
|
||||
changed();
|
||||
@@ -445,12 +460,17 @@ export function mountLaborDetail(
|
||||
/** 서고 탭(50-3 ④) — 참조 줄 더함(같은 참조 두 번 막음 · 위 층만은 서버도 막음) */
|
||||
const library = {
|
||||
layer: opt.layer,
|
||||
owner: opt.layer === getActiveLayer() ? getActiveOwner() : null,
|
||||
owner:
|
||||
opt.owner !== undefined
|
||||
? opt.owner
|
||||
: opt.layer === getActiveLayer()
|
||||
? getActiveOwner()
|
||||
: null,
|
||||
pick: (ref: LaborRef, name: string) => {
|
||||
const id = lineId({ 참조: ref });
|
||||
if (cur.담은로직.some((r) => lineId(r) === id))
|
||||
return showToast(tc("Labor_AlreadyRef").replace("{value}", `${ref.키} ${name}`), "error");
|
||||
const 수량 = lib === "precombo" ? { 손: 1 } : null; // 사전조합 줄 기본 1(51-6)
|
||||
const 수량 = lib ? { 손: 1 } : null; // 사전조합 줄 기본 1(51-6)
|
||||
cur.담은로직.push({ 로직: "", 참조: ref, 별칭: null, 메모: null, 입력값: {}, 수량 });
|
||||
changed();
|
||||
},
|
||||
@@ -464,7 +484,7 @@ export function mountLaborDetail(
|
||||
if (cur.담은로직.some((r) => r.로직 === x.키))
|
||||
return showToast(tc("Labor_Already").replace("{value}", `${x.키} ${x.이름}`), "error");
|
||||
// 사전조합 = 면적산출 없음 → 손 1(51-11)
|
||||
const 수량 = lib === "precombo" ? { 손: 1 } : null;
|
||||
const 수량 = lib ? { 손: 1 } : null;
|
||||
cur.담은로직.push({ 로직: x.키, 별칭: null, 메모: null, 입력값: {}, 수량 });
|
||||
void loadLogics().then(() => changed());
|
||||
},
|
||||
@@ -539,30 +559,15 @@ export function mountLaborDetail(
|
||||
opt.onChanged(dirty());
|
||||
});
|
||||
|
||||
/** 서고 구분 · 상세구분(51-12) — 승격 = 원 로직 것 읽기만 + 연결(원 로직 키 · 출처) ·
|
||||
* 사전조합 = 로직 구분 목록(logic-lib/subs)에서 고름(구분 바꾸면 상세구분 = 그 첫 줄) */
|
||||
const meta = cur as { 구분?: string | null; 상세구분?: string | null; 연결?: unknown };
|
||||
const readField = (label: string, field: string, value: string): HTMLElement => {
|
||||
const f = createInputField({ type: "text", label });
|
||||
f.root.dataset.laborField = field;
|
||||
f.input.value = value;
|
||||
f.input.readOnly = true;
|
||||
return f.root;
|
||||
};
|
||||
/** 서고 구분 · 상세구분(51-12 · 134-5) — 구조물군 › 종류 + 공통(`precomboClasses`)에서 고름 ·
|
||||
* 구분 바꾸면 상세구분 = 그 첫 줄(공통) · 빈 값 = 미분류 */
|
||||
const meta = cur as { 구분?: string | null; 상세구분?: string | null };
|
||||
const libMeta = (): HTMLElement[] => {
|
||||
if (promote) {
|
||||
const link = (meta.연결 ?? {}) as { 로직?: string; 출처?: string };
|
||||
return [
|
||||
readField(tc("Sub"), "구분", meta.구분 ?? ""),
|
||||
readField(tc("Detail"), "상세구분", meta.상세구분 ?? ""),
|
||||
readField(tc("Lib_Link"), "연결", [link.로직, link.출처].filter(Boolean).join(" · ")),
|
||||
];
|
||||
}
|
||||
const sub = createSelectField({ options: [], label: tc("Sub"), disabled: !edit });
|
||||
const detail = createSelectField({ options: [], label: tc("Detail"), disabled: !edit });
|
||||
sub.root.dataset.laborField = "구분";
|
||||
detail.root.dataset.laborField = "상세구분";
|
||||
let subs: SubBrief[] = [];
|
||||
let subs: { name: string; details: string[] }[] = [];
|
||||
// 목록에 없는 지금 값(빈 것 = 「—」 · 옛 문서)도 선택지로 둠
|
||||
const opts = (names: string[], now: string | null | undefined) =>
|
||||
[...new Set([now ?? "", ...names])]
|
||||
@@ -590,9 +595,9 @@ export function mountLaborDetail(
|
||||
opt.onChanged(dirty());
|
||||
});
|
||||
fill();
|
||||
void fetchLibLogicSubs().then(
|
||||
(got) => {
|
||||
subs = got;
|
||||
void fetchStructureDef().then(
|
||||
(def) => {
|
||||
subs = precomboClasses(def);
|
||||
fill();
|
||||
},
|
||||
() => undefined,
|
||||
@@ -625,8 +630,8 @@ export function mountLaborDetail(
|
||||
],
|
||||
}),
|
||||
]),
|
||||
// 승격 = 로직 한 줄(새로 만들 때 고름) · 사전조합 = 같은 [로직 추가](51-11)
|
||||
section(tc("Logics_Title"), opt.card || promote ? null : addLogic, logicHost),
|
||||
// 사전조합 = 같은 [로직 추가](51-11)
|
||||
section(tc("Logics_Title"), opt.card ? null : addLogic, logicHost),
|
||||
...(lib ? [] : [section(tc("Labor_Mats"), opt.card ? null : addMat, matHost)]),
|
||||
...(opt.card || lib ? [] : [dgBox]),
|
||||
],
|
||||
@@ -657,7 +662,7 @@ export function mountLaborDetail(
|
||||
// 서고 모드 = 문서 종류 그대로 · 열 · 자재 없음
|
||||
getDoc: () => {
|
||||
if (!lib) return cur;
|
||||
const out: Record<string, unknown> = { ...cur, 종류: promote ? "단가승격" : "사전조합" };
|
||||
const out: Record<string, unknown> = { ...cur, 종류: "사전조합" };
|
||||
delete out.열;
|
||||
delete out.자재;
|
||||
return out as unknown as LaborDoc;
|
||||
|
||||
@@ -60,11 +60,11 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra
|
||||
detail.select.disabled = true;
|
||||
const close = (): void => backdrop.remove();
|
||||
|
||||
/* 한 목록 — 구분(단가산출 · 단가승격 · 일위조합) 칸 · 층 단추는 서고 줄(승격 · 조합)의 층 */
|
||||
/* 한 목록 — 구분(단가산출 · 일위조합) 칸 · 층 단추는 서고 줄(사전조합)의 층 */
|
||||
const layerBar = el("div", { className: "ui-btn-group", attrs: { "data-pick-layers": "" } });
|
||||
let scopes: Scope[] = [];
|
||||
let scope: Scope | null = null;
|
||||
let libRows: { kind: "Kind_Promote" | "Kind_Combo"; row: LibraryRow }[] = [];
|
||||
let libRows: { kind: "Kind_Combo"; row: LibraryRow }[] = [];
|
||||
let loading = false;
|
||||
let seq = 0;
|
||||
/* 공용 찾기 목록 — 구분 · 상세구분 2열 · 이름 왼쪽 맞춤 · ↑ ↓ Enter */
|
||||
@@ -132,15 +132,9 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra
|
||||
loading = true;
|
||||
find();
|
||||
try {
|
||||
const [promote, combo] = await Promise.all([
|
||||
fetchLibrary(at, "promote"),
|
||||
fetchLibrary(at, "precombo"),
|
||||
]);
|
||||
const combo = await fetchLibrary(at, "precombo");
|
||||
if (mine !== seq) return;
|
||||
libRows = [
|
||||
...promote.map((row) => ({ kind: "Kind_Promote" as const, row })),
|
||||
...combo.map((row) => ({ kind: "Kind_Combo" as const, row })),
|
||||
];
|
||||
libRows = combo.map((row) => ({ kind: "Kind_Combo" as const, row }));
|
||||
} catch (error) {
|
||||
if (mine !== seq) return;
|
||||
libRows = [];
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
* M02_MasterTemplete_Labor_RefEdit.ts
|
||||
* 일위대가 참조 줄 모달(51-14) — 서고 원본(50-3)을 그 자리에서 고침 · [저장] = 서고 저장 길(`saveTemplate`) ·
|
||||
* 그 문서를 쓰는 모든 구조물에 따라감(머리 한 줄 알림) · 그 층을 못 고치면 읽기만.
|
||||
* · 단가승격 [옵션] = 승격 문서 로직 한 줄의 넣을 값 모달(`Labor_Input`)
|
||||
* · 일위조합 [수정] = 사전조합 상세(서고 모드 표 · 안 줄 [옵션] · 수량 · 위로 · 아래로 · 삭제 · 추가)
|
||||
* 닫으면(저장 · 취소 모두) `closed` — 부르는 쪽이 구조물 일위대가 표를 다시 셈.
|
||||
* ========================================================================== */
|
||||
@@ -10,15 +9,13 @@
|
||||
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { readTemplate, saveTemplate, type Layer } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { fetchLogicInputs, type LaborRow } from "./M02_MasterTemplete_Labor_Api";
|
||||
import type { LaborRow } from "./M02_MasterTemplete_Labor_Api";
|
||||
import type { LaborRef } from "./M02_MasterTemplete_Labor_Calc";
|
||||
import { openLaborInput } from "./M02_MasterTemplete_Labor_Input";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import { keepKnown } from "./M02_Labor_Values_Model";
|
||||
|
||||
export interface RefEditOptions {
|
||||
ref: LaborRef;
|
||||
/** 「SP-000001 메쌓기」 */
|
||||
/** 「SC-000001 돌붙임」 */
|
||||
title: string;
|
||||
readOnly: boolean;
|
||||
closed: () => void;
|
||||
@@ -28,50 +25,23 @@ const fail = (error: unknown, key: "Load_Failed" | "Lib_SaveFailed"): void =>
|
||||
showToast(error instanceof Error ? error.message : tc(key), "error");
|
||||
|
||||
export async function openRefEdit(opt: RefEditOptions): Promise<void> {
|
||||
const kind = opt.ref.키.startsWith("SP-") ? "promote" : "precombo";
|
||||
const kind = "precombo";
|
||||
const layer = opt.ref.층 as Layer;
|
||||
let doc: { 담은로직?: LaborRow[] };
|
||||
let 판: string;
|
||||
try {
|
||||
const got = await readTemplate(layer, kind, opt.ref.키, null);
|
||||
const got = await readTemplate(layer, kind, opt.ref.키, null, opt.ref.주인); // 134-3 — 참조 주인 그대로
|
||||
doc = got.문서 as typeof doc;
|
||||
판 = got.판 ?? "";
|
||||
} catch (error) {
|
||||
return fail(error, "Load_Failed");
|
||||
}
|
||||
const save = (body: unknown): Promise<unknown> =>
|
||||
saveTemplate(layer, kind, opt.ref.키, null, 판, body).then(() =>
|
||||
saveTemplate(layer, kind, opt.ref.키, null, 판, body, opt.ref.주인).then(() =>
|
||||
showToast(tc("Saved"), "success"),
|
||||
);
|
||||
const notice = opt.readOnly ? `${tc("Ref_Shared")} · ${tc("Ref_ReadOnly")}` : tc("Ref_Shared");
|
||||
|
||||
if (kind === "promote") {
|
||||
const row = doc.담은로직?.[0];
|
||||
if (!row) return fail(null, "Load_Failed");
|
||||
const logic = row.로직
|
||||
? ((await fetchLogicInputs([row.로직]).catch(() => null))?.로직[0] ?? null)
|
||||
: null;
|
||||
return openLaborInput({
|
||||
title: opt.title,
|
||||
logic,
|
||||
values: row.입력값,
|
||||
memo: row.메모,
|
||||
rate: row.켜쌓기율 ?? null,
|
||||
readOnly: opt.readOnly,
|
||||
notice,
|
||||
okLabel: tc("Save"),
|
||||
done: (next) => {
|
||||
row.입력값 = logic ? keepKnown(logic.입력, next.입력값).kept : next.입력값;
|
||||
row.메모 = next.메모;
|
||||
if (next.켜쌓기율 === null) delete row.켜쌓기율;
|
||||
else row.켜쌓기율 = next.켜쌓기율;
|
||||
void save(doc)
|
||||
.catch((error) => fail(error, "Lib_SaveFailed"))
|
||||
.finally(opt.closed);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 사전조합 — 상세와 같은 부품(서로 부르므로 늦게 받음)
|
||||
const { mountLaborDetail } = await import("./M02_MasterTemplete_Labor_Detail");
|
||||
let dirty = false;
|
||||
@@ -98,6 +68,7 @@ export async function openRefEdit(opt: RefEditOptions): Promise<void> {
|
||||
onChanged: (d) => (dirty = d && !opt.readOnly),
|
||||
goArea: null,
|
||||
mode: "precombo",
|
||||
owner: opt.ref.주인,
|
||||
});
|
||||
const cancel = createButton({
|
||||
label: tc(opt.readOnly ? "In_Close" : "In_Cancel"),
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
* 자재목록 = 코드 · 이름 · 규격 · 수량산출 · 단위 · 가격 다섯(기준 칸 강조) · 기준단가 / 반영 금액(43-3).
|
||||
* 금액은 calc 답(없으면 「…」).
|
||||
* 줄 누름 · 줄 단추는 `on` 이 있을 때만(보기 갈래 = 없음) — 순서 칸 2행(51-14 · 위 [위로] [아래로] / 아래 [옵션] 또는 [수정] · [삭제] ·
|
||||
* 묶음 `ui-btn-group` · 순서 없는 카드 갈래 = 아래 줄만) · 단가승격 줄 = [옵션] · 일위조합 줄 = [수정](`ref` · 서고 원본) ·
|
||||
* 묶음 `ui-btn-group` · 순서 없는 카드 갈래 = 아래 줄만) · 일위조합 줄 = [수정](`ref` · 서고 원본 · 옛 단가승격 참조 줄 = 단추 없음) ·
|
||||
* 상세는 줄 누름 = 고름(`selected` 줄 하이라이트 · 그 줄 바로 아래 하위 행 · 51-2 · 화살표 없음 51-7) · 넣을 값 모달은 [옵션](47-5).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { createButton, createSelectField, el } from "@ui/ui_template_elements";
|
||||
import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money";
|
||||
import type { ItemLogic } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import type {
|
||||
@@ -41,14 +41,36 @@ const HEAD: Record<(typeof COSTS)[number], Parameters<typeof tc>[0]> = {
|
||||
/** 옵션 값 줄이 걸치는 칸 수 — 구분 · 이름 · 별칭(113-1 · 나머지 칸은 위 줄에서 rowspan 2) */
|
||||
const SPAN = 3;
|
||||
|
||||
/** 구분(50-4 · 저장 안 함) — 줄 모양(참조 키 머리)에서 읽음 · SP- = 단가승격 · SC- = 일위조합(50-3) */
|
||||
export const kindOf = (row: Pick<LaborRow, "로직" | "참조">): string => {
|
||||
/** 구분(50-4) — 로직 줄 = `승격`(134-1 · 단가승격 · 없음 = 단가산출) · 참조 줄 = 키 머리(SC- = 일위조합 ·
|
||||
* 옛 SP- = 단가승격 글 고정 · 134-2) */
|
||||
export const kindOf = (row: Pick<LaborRow, "로직" | "참조" | "승격">): string => {
|
||||
const key = row.참조?.키 ?? row.로직;
|
||||
return tc(
|
||||
key.startsWith("SP-") ? "Kind_Promote" : key.startsWith("SC-") ? "Kind_Combo" : "Kind_Logic",
|
||||
row.승격 || key.startsWith("SP-")
|
||||
? "Kind_Promote"
|
||||
: key.startsWith("SC-")
|
||||
? "Kind_Combo"
|
||||
: "Kind_Logic",
|
||||
);
|
||||
};
|
||||
|
||||
/** 구분 드롭다운(134-1) — 로직 줄만 [단가산출 | 단가승격] · 줄 누름과 따로 */
|
||||
function kindPick(on: boolean, set: (on: boolean) => void): HTMLElement {
|
||||
const pick = createSelectField({
|
||||
options: [
|
||||
{ value: "", text: tc("Kind_Logic") },
|
||||
{ value: "1", text: tc("Kind_Promote") },
|
||||
],
|
||||
value: on ? "1" : "",
|
||||
compact: true,
|
||||
ariaLabel: tc("Sub"),
|
||||
onChange: (v) => set(v === "1"),
|
||||
});
|
||||
pick.select.dataset.laborKind = "";
|
||||
pick.root.addEventListener("click", (e) => e.stopPropagation()); // 줄 누름(고름)과 따로
|
||||
return pick.root;
|
||||
}
|
||||
|
||||
/** 품셈 책(102-3) — 출처 첫 낱말 */
|
||||
const BOOK = /^(건설|산림)품셈/;
|
||||
|
||||
@@ -180,7 +202,7 @@ export interface RowHandlers {
|
||||
open: (i: number) => void;
|
||||
/** [옵션] 단추(47-5 · 넣을 값 모달) — 없으면 단추 없음 */
|
||||
option?: (i: number) => void;
|
||||
/** 참조 줄 단추(51-14) — 단가승격 [옵션] · 일위조합 [수정] = 서고 원본 모달 · 없으면 단추 없음 */
|
||||
/** 참조 줄 단추(51-14) — 일위조합 [수정] = 서고 원본 모달 · 없으면 단추 없음 */
|
||||
ref?: (i: number) => void;
|
||||
/** 수량산출 칸 누름(손 입력 · 엑셀에서 고르기) — 없으면 줄 누름과 같음 */
|
||||
qty?: (i: number) => void;
|
||||
@@ -189,6 +211,8 @@ export interface RowHandlers {
|
||||
remove?: (i: number) => void;
|
||||
/** 별칭 칸 고침(44-6 · 글자마다 · 빈 글 = null) — 없으면 글만 보임 */
|
||||
alias?: (i: number, value: string | null) => void;
|
||||
/** 구분 드롭다운(134-1 · 로직 줄 단가산출 ↔ 단가승격) — 없으면 글만 */
|
||||
promote?: (i: number, on: boolean) => void;
|
||||
}
|
||||
|
||||
/** 별칭 칸(44-6) — 고칠 수 있으면 칸 안 글상자(누르면 바로 고침 · 줄 누름과 따로) · 아니면 글 · 비면 빈 칸 */
|
||||
@@ -361,7 +385,9 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
el("td", {
|
||||
className: "m02-labrow__kind",
|
||||
children: [
|
||||
kindOf(row),
|
||||
on?.promote && !row.참조
|
||||
? kindPick(!!row.승격, (v) => on.promote?.(i, v))
|
||||
: kindOf(row),
|
||||
// 102-3 — 단가산출 줄만 품셈 책(출처 첫 낱말)
|
||||
...(book ? [el("small", { className: "m02-labrow__book", text: book })] : []),
|
||||
],
|
||||
@@ -419,13 +445,8 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
: []),
|
||||
group([
|
||||
...(row.참조
|
||||
? ref
|
||||
? [
|
||||
small(
|
||||
tc(row.참조.키.startsWith("SP-") ? "Labor_Option" : "Labor_Edit"),
|
||||
() => ref(i),
|
||||
),
|
||||
]
|
||||
? ref && !row.참조.키.startsWith("SP-")
|
||||
? [small(tc("Labor_Edit"), () => ref(i))]
|
||||
: []
|
||||
: option
|
||||
? [small(tc("Labor_Option"), () => option(i))]
|
||||
|
||||
@@ -140,8 +140,9 @@ const TEXT = {
|
||||
In_Close: ["닫기", "Close"],
|
||||
Lib_Name: ["이름", "Name"],
|
||||
// 서고 목록표(51-4)
|
||||
Lib_Promote: ["단가산출 승격", "Promoted logic"],
|
||||
Lib_PrecomboAll: ["일위대가 사전조합", "Pre-combination"],
|
||||
Lib_None: ["사전조합 없음", "No pre-combination"],
|
||||
Lib_Unsorted: ["미분류", "Unsorted"],
|
||||
Lib_Create: ["생성", "Create"],
|
||||
Lib_Copy: ["복사", "Copy"],
|
||||
Lib_Delete: ["삭제", "Delete"],
|
||||
@@ -150,7 +151,6 @@ const TEXT = {
|
||||
Lib_Base: ["기준 금액", "Base amount"],
|
||||
Lib_Date: ["고친 날", "Modified"],
|
||||
// 서고 구분 · 연결(51-12)
|
||||
Lib_Link: ["연결", "Linked logic"],
|
||||
} as const satisfies Record<string, readonly [string, string]>;
|
||||
|
||||
export type ComboTextKey = keyof typeof TEXT;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Layer.ts
|
||||
* M02 한 화면의 뿌리 셋(마스터 · 회사 · 개인 · PLAN 27-1) — 진입 해시
|
||||
* `#/m02-master-template?open=구조물조합|옵션|도면&root=master|company|personal&owner=…&company=…`
|
||||
* (옛 `?layer=` 도 root 로 읽음) · 고칠 권한(`accessFor` — 서버 `M02_Layer_Access` 와 짝 · 27-1c) ·
|
||||
* `#/m02-master-template?open=구조물조합|옵션|도면&root=master|company|personal&owner=…&company=…&pre=…`
|
||||
* (옛 `?layer=` 도 root 로 읽음 · `pre` = 일위대가 사전조합 층 · 134-3) · 고칠 권한(`accessFor` — 서버 `M02_Layer_Access` 와 짝 · 27-1c) ·
|
||||
* 회사 없음 안내 · 회사용 읽기만일 때 [개인용으로 복사] 막대.
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -25,20 +25,30 @@ export interface Entry {
|
||||
open: OpenTarget;
|
||||
/** 시스템 관리자가 보는 회사(PLAN 29 · 없으면 sessionStorage `aislo.adminCompany` · 자기 회사) */
|
||||
company: string | null;
|
||||
/** 일위대가 사전조합 층(134-3 · 페이지 층과 따로) — 없으면 기본(`defaultRoot`) */
|
||||
pre: Root | null;
|
||||
}
|
||||
|
||||
const OPENS: OpenTarget[] = ["구조물조합", "옵션", "도면", "일위대가조합"];
|
||||
const ROOTS: Root[] = ["master", "company", "personal"];
|
||||
|
||||
export const layerOfRoot = (root: Root): ManageLayer => (root === "master" ? "system" : root);
|
||||
|
||||
/** 주소 인자에서 진입 — 모르는 값 · 없음 = 마스터 · 구조물 조합 */
|
||||
export function entryFromHash(): Entry {
|
||||
/** 주소 인자에서 진입 — 층 모르는 값 · 없음 = 기본 층(134-4 · 개인 · 회사 없으면 마스터 · 사용자를 안 주면
|
||||
* 마스터) · 열 곳 없음 = 구조물 조합 */
|
||||
export function entryFromHash(user?: SessionUser | null): Entry {
|
||||
const query = currentQuery();
|
||||
const raw = query.get("root") ?? query.get("layer");
|
||||
const root: Root = raw === "company" || raw === "personal" ? raw : "master";
|
||||
const root: Root =
|
||||
raw === "master" || raw === "company" || raw === "personal"
|
||||
? raw
|
||||
: user === undefined
|
||||
? "master"
|
||||
: defaultRoot(user);
|
||||
const open = OPENS.find((one) => one === query.get("open")) ?? "구조물조합";
|
||||
const owner = root === "master" ? null : query.get("owner") || null;
|
||||
return { root, owner, open, company: query.get("company") || null };
|
||||
const pre = ROOTS.find((one) => one === query.get("pre")) ?? null;
|
||||
return { root, owner, open, company: query.get("company") || null, pre };
|
||||
}
|
||||
|
||||
/** 진입 해시 인자 — `navigateTo(ROUTES.M02_MASTER_TEMPLATE, entryQuery(…))` */
|
||||
@@ -47,8 +57,13 @@ export const entryQuery = (entry: Entry): Record<string, string> => ({
|
||||
root: entry.root,
|
||||
...(entry.owner ? { owner: entry.owner } : {}),
|
||||
...(entry.company ? { company: entry.company } : {}),
|
||||
...(entry.pre ? { pre: entry.pre } : {}),
|
||||
});
|
||||
|
||||
/** 기본 층(134-4) — 내 개인 층이 있으면 개인 · 회사 없으면 마스터 */
|
||||
export const defaultRoot = (user: SessionUser | null): Root =>
|
||||
ownOwner("personal", user) ? "personal" : "master";
|
||||
|
||||
/** 볼 회사 — 시스템 관리자 = 해시 `company` · sessionStorage `aislo.adminCompany`(대시보드가 넣음) ·
|
||||
* 자기 회사 차례 · 그 밖 = 자기 회사 · 없으면 null */
|
||||
export function viewCompany(entry: Entry, user: SessionUser | null): string | null {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Library_List.ts
|
||||
* 일위대가 조합 서고 목록표(51-4) — 좌측 항목(단가산출 승격 · 일위대가 사전조합)을 누르면 상세 칸에 뜸.
|
||||
* 일위대가 사전조합 서고 목록표(51-4) — 좌측 사전조합 층 단추를 누르면 상세 칸에 뜸.
|
||||
* 표 = 고름 · 키 · 구분 · 상세구분(51-12) · 이름 · 단위 · 기준 금액(서버 1 단위 계) · 고친 날 · 줄 누름 = 그 문서 상세.
|
||||
* 머리 [생성] · [복사](고른 줄) · [삭제](고른 줄 · 쓰는 곳 있으면 서버 409 까닭 그대로).
|
||||
* 꼴 = M01 단가산출 로직 목록 — 찾기 줄(`buildHeadFilters`) · 쪽(`buildPager`) · 표(`m01-master__grid`) 를 M01 에서 import.
|
||||
@@ -21,7 +21,6 @@ import "../M01_MasterData/M01_MasterData_UI_Style.css";
|
||||
import {
|
||||
deleteLayerTemplate,
|
||||
deleteTemplate,
|
||||
getActiveOwner,
|
||||
listTemplates,
|
||||
newLibrary,
|
||||
readTemplate,
|
||||
@@ -30,8 +29,6 @@ import {
|
||||
type LibraryBody,
|
||||
type TemplateInfo,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { fetchLibLogicSubs } from "./M02_MasterTemplete_Labor_Api";
|
||||
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import type { PrecomboKind } from "./M02_MasterTemplete_UI_Side_Precombo";
|
||||
|
||||
@@ -46,10 +43,14 @@ type Row = TemplateInfo & {
|
||||
|
||||
export interface LibraryListOptions {
|
||||
layer: Layer;
|
||||
/** 그 층 주인(134-3 · 사전조합 층 · null = 자기 층) */
|
||||
owner: string | null;
|
||||
kind: PrecomboKind;
|
||||
canEdit: boolean;
|
||||
/** 줄 누름 · 생성 · 복사 뒤 — 그 문서 상세 */
|
||||
open: (key: string, label: string) => void;
|
||||
/** 생성 · 복사 · 삭제 뒤 — 좌측 나무를 다시 그림 */
|
||||
changed: () => void;
|
||||
}
|
||||
|
||||
const SIZE = 30;
|
||||
@@ -131,7 +132,9 @@ export function mountLibraryList(
|
||||
|
||||
const load = async (): Promise<void> => {
|
||||
try {
|
||||
rows = (await listTemplates(opt.layer, null)).filter((r) => r.종류 === opt.kind) as Row[];
|
||||
rows = (await listTemplates(opt.layer, null, opt.owner)).filter(
|
||||
(r) => r.종류 === opt.kind,
|
||||
) as Row[];
|
||||
} catch (error) {
|
||||
rows = [];
|
||||
showToast(L("M02_LoadFailed").replace("{value}", why(error)), "error");
|
||||
@@ -172,34 +175,16 @@ export function mountLibraryList(
|
||||
/** 새로 — 서버가 그 층 다음 키로 씀 → 그 문서 상세 */
|
||||
const made = (key: string, label: string): void => {
|
||||
showToast(L("M02_Created"), "success");
|
||||
opt.changed();
|
||||
opt.open(key, label);
|
||||
};
|
||||
const create = async (body: LibraryBody, label: string): Promise<void> =>
|
||||
made((await newLibrary(opt.layer, opt.kind, body)).이름, label);
|
||||
made((await newLibrary(opt.layer, opt.kind, body, opt.owner)).이름, label);
|
||||
|
||||
/** 단가승격 = 단가산출 로직 고르기 → 이름 · 단위 · 구분 · 연결은 로직 것(서버가 채움) ·
|
||||
* 사전조합 = 이름 + 구분 · 상세구분 처음 값 = 로직 구분 목록 첫 줄(51-12 · 상세에서 바꿈) · 빈 묶음 */
|
||||
/** 사전조합 = 이름 · 구분 · 상세구분 빈 값(미분류 · 134-5 · 상세에서 고름) · 빈 묶음 */
|
||||
btnNew.addEventListener("click", () => {
|
||||
if (opt.kind === "promote")
|
||||
return openLogicPicker((x) => {
|
||||
const doc = {
|
||||
이름: x.이름,
|
||||
단위: x.결과단위 || null,
|
||||
담은로직: [{ 로직: x.키, 입력값: {} }],
|
||||
};
|
||||
void create({ 문서: doc }, x.이름).catch((error) =>
|
||||
showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error"),
|
||||
);
|
||||
});
|
||||
nameDialog(L("M02_NewTitle"), "", async (name) => {
|
||||
const first = (await fetchLibLogicSubs())[0];
|
||||
const 문서 = {
|
||||
이름: name,
|
||||
구분: first?.name ?? null,
|
||||
상세구분: first?.details[0] ?? null,
|
||||
단위: null,
|
||||
담은로직: [],
|
||||
};
|
||||
const 문서 = { 이름: name, 구분: "", 상세구분: "", 단위: null, 담은로직: [] };
|
||||
await create({ 문서 }, name);
|
||||
});
|
||||
});
|
||||
@@ -209,14 +194,22 @@ export function mountLibraryList(
|
||||
const from = rows.find((r) => r.이름 === picked);
|
||||
if (!from) return;
|
||||
nameDialog(L("M02_CopyTitle"), `${from.제목 ?? from.이름} (2)`, async (name) => {
|
||||
const got = await newLibrary(opt.layer, opt.kind, {
|
||||
본뜬: { 층: opt.layer, 주인: getActiveOwner(), 키: from.이름 },
|
||||
});
|
||||
const doc = await readTemplate(opt.layer, opt.kind, got.이름, null);
|
||||
await saveTemplate(opt.layer, opt.kind, got.이름, null, got.판, {
|
||||
...(doc.문서 as object),
|
||||
이름: name,
|
||||
});
|
||||
const got = await newLibrary(
|
||||
opt.layer,
|
||||
opt.kind,
|
||||
{ 본뜬: { 층: opt.layer, 주인: opt.owner, 키: from.이름 } },
|
||||
opt.owner,
|
||||
);
|
||||
const doc = await readTemplate(opt.layer, opt.kind, got.이름, null, opt.owner);
|
||||
await saveTemplate(
|
||||
opt.layer,
|
||||
opt.kind,
|
||||
got.이름,
|
||||
null,
|
||||
got.판,
|
||||
{ ...(doc.문서 as object), 이름: name },
|
||||
opt.owner,
|
||||
);
|
||||
made(got.이름, name);
|
||||
});
|
||||
});
|
||||
@@ -229,8 +222,9 @@ export function mountLibraryList(
|
||||
if (!(await showConfirmDialog(L("M02_DeleteConfirm").replace("{value}", label)))) return;
|
||||
try {
|
||||
if (opt.layer === "system") await deleteTemplate(opt.kind, info.이름, info.판);
|
||||
else await deleteLayerTemplate(opt.layer, opt.kind, info.이름, info.판);
|
||||
else await deleteLayerTemplate(opt.layer, opt.kind, info.이름, info.판, opt.owner);
|
||||
showToast(L("M02_Deleted"), "success");
|
||||
opt.changed();
|
||||
picked = null;
|
||||
await load();
|
||||
} catch (error) {
|
||||
|
||||
@@ -311,6 +311,8 @@ async def read_layer_template(
|
||||
found["층"] = layer
|
||||
found["출처"] = layers.read_manifest(folder).get(f"{kind}/{name}")
|
||||
found["문서"] = refs.filled(kind, found["문서"]) # 옛 서고 문서 구분 · 상세구분(51-12)
|
||||
if layer != "project": # 옛 단가승격 참조 줄 = 새 꼴(134-2 · 프로젝트 사본은 그대로)
|
||||
found["문서"] = await asyncio.to_thread(refs.unpromote, kind, found["문서"])
|
||||
return found
|
||||
|
||||
|
||||
|
||||
@@ -570,7 +570,7 @@ def _server(kind: str, doc: Any, source: Path | None = None) -> Any:
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
|
||||
if source is not None and isinstance(doc, dict) and kind != "promote":
|
||||
if source is not None and isinstance(doc, dict):
|
||||
doc = refs.carry(doc, refs.where(source))
|
||||
if kind == "labor":
|
||||
return labor.server(doc)
|
||||
|
||||
@@ -46,6 +46,10 @@ export interface Selection {
|
||||
name: string;
|
||||
/** 화면 제목에 쓸 글 — 없으면 name 그대로(구조물 도면은 열 머리글) */
|
||||
label?: string;
|
||||
/** 사전조합 층(134-3 · 페이지 층과 따로) — 서버 길에 붙일 주인(null = 자기 층) · 고칠 권한 ·
|
||||
* 없으면 페이지 층 것 */
|
||||
owner?: string | null;
|
||||
canWrite?: boolean;
|
||||
}
|
||||
|
||||
interface EditorHandle {
|
||||
@@ -243,9 +247,11 @@ export function createMain(
|
||||
const empty = (text: string): void => {
|
||||
host.replaceChildren(el("p", { className: "m02-main__empty", text }));
|
||||
};
|
||||
/** 고를 것마다 고칠 권한 — 사전조합 층은 고름에 실림(134-3) · 그 밖 페이지 층 */
|
||||
const writable = (at: Selection): boolean => at.canWrite ?? canEdit(at.layer, isAdmin);
|
||||
const bar = (): void => {
|
||||
reload.hidden = !sel;
|
||||
save.hidden = !sel || !canEdit(sel.layer, isAdmin);
|
||||
save.hidden = !sel || !writable(sel);
|
||||
back.hidden = !sel || !(sel.kind in NUMBER_FIELD);
|
||||
nav.update(back.hidden ? null : sel); // 보임 조건 = 뒤로 단추와 같음
|
||||
const kind = sel && sel.kind in HELP_TITLE ? (sel.kind as keyof typeof HELP_TITLE) : null;
|
||||
@@ -345,7 +351,7 @@ export function createMain(
|
||||
}
|
||||
|
||||
async function mount(doc: unknown, at: Selection): Promise<void> {
|
||||
const readOnly = !canEdit(at.layer, isAdmin);
|
||||
const readOnly = !writable(at);
|
||||
host.replaceChildren();
|
||||
if (at.kind === "table") {
|
||||
const create = await findExport<(h: HTMLElement, d: unknown, o: object) => EditorHandle>(
|
||||
@@ -424,7 +430,7 @@ export function createMain(
|
||||
void open({ ...at, kind: "area" });
|
||||
return true;
|
||||
},
|
||||
...(mode ? { mode } : {}),
|
||||
...(mode ? { mode, owner: at.owner ?? null } : {}),
|
||||
});
|
||||
// [저장] = 엑셀에서 고른 칸 연결(면적 문서) 먼저 · 그다음 일위대가(서버가 새 칸 값으로 계산값)
|
||||
editor = {
|
||||
@@ -508,14 +514,16 @@ export function createMain(
|
||||
editor = mountLibraryList(host, slot, {
|
||||
layer: next.layer,
|
||||
kind: next.kind,
|
||||
canEdit: canEdit(next.layer, isAdmin),
|
||||
owner: next.owner ?? null,
|
||||
canEdit: writable(next),
|
||||
open: (name, label) => void open({ ...next, name, label }),
|
||||
changed: () => onSaved?.(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const stop = showLoading(host); // 0.3초 넘으면 상세 칸 안에 「불러오는 중」(PLAN 30-6)
|
||||
try {
|
||||
const got = await readTemplate(next.layer, next.kind, next.name, next.projectId);
|
||||
const got = await readTemplate(next.layer, next.kind, next.name, next.projectId, next.owner);
|
||||
if (mine !== seq) return;
|
||||
version = got.판 ?? "";
|
||||
loaded = got.문서;
|
||||
@@ -563,7 +571,15 @@ export function createMain(
|
||||
...(edited as Record<string, unknown>),
|
||||
}) // + 저장 전 [보내기 취소] 연결(112-2)
|
||||
: edited;
|
||||
const info = await saveTemplate(at.layer, at.kind, at.name, at.projectId, version, doc);
|
||||
const info = await saveTemplate(
|
||||
at.layer,
|
||||
at.kind,
|
||||
at.name,
|
||||
at.projectId,
|
||||
version,
|
||||
doc,
|
||||
at.owner,
|
||||
);
|
||||
version = info.판;
|
||||
loaded = doc;
|
||||
dirty = false;
|
||||
|
||||
@@ -19,6 +19,7 @@ import { fetchOwners, setActiveLayer } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import {
|
||||
accessFor,
|
||||
buildReadOnlyBar,
|
||||
defaultRoot,
|
||||
entryFromHash,
|
||||
entryQuery,
|
||||
layerOfRoot,
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
} from "./M02_MasterTemplete_TreePanel_Roots";
|
||||
import { canEdit, createMain, newItemButton, type Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
import { buildSide } from "./M02_MasterTemplete_UI_Side";
|
||||
import { precomboList, type PrecomboScope } from "./M02_MasterTemplete_UI_Side_Precombo";
|
||||
import { buildTwoWay, pickedName } from "./M02_MasterTemplete_ItemPick";
|
||||
import "./M02_MasterTemplete_UI_Style.css";
|
||||
|
||||
@@ -50,7 +52,7 @@ const NEW_ASK =
|
||||
|
||||
export async function renderM02MasterTemplate(root: HTMLElement): Promise<MountedPage> {
|
||||
const user = await fetchSessionUser().catch(() => null);
|
||||
const entry = entryFromHash();
|
||||
const entry = entryFromHash(user); // 134-4 — 해시에 층 없음 = 개인(회사 없으면 마스터)
|
||||
const company = viewCompany(entry, user); // 볼 회사(시스템 관리자는 고른 회사)
|
||||
let layer = layerOfRoot(entry.root);
|
||||
// 회사 층 주인 = 볼 회사 · 자기 층 주인을 적어도 자기 층 · 회사 없는 사람의 회사 · 개인 = 마스터(단추 자리에 안내)
|
||||
@@ -173,6 +175,35 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
|
||||
kind: "table",
|
||||
name: STRUCTURE_TABLE,
|
||||
};
|
||||
// 일위대가 사전조합 층(134-3) — 페이지 층(해시 root)과 따로 · 마스터 · 볼 회사 · 내 개인(사람 나무 없음) ·
|
||||
// 못 가는 층(회사 없음) = 마스터 · 고른 층 = 주소 `pre`(페이지를 다시 세우지 않음 · replaceState)
|
||||
const preScope = (want: Root): PrecomboScope => {
|
||||
const at =
|
||||
(want === "company" && !company) || (want === "personal" && !ownOwner("personal", user))
|
||||
? "master"
|
||||
: want;
|
||||
const to = layerOfRoot(at);
|
||||
const who = at === "company" ? companyOwner(company, user) : null;
|
||||
return { root: at, layer: to, owner: who, canWrite: accessFor(to, who, user).canWrite };
|
||||
};
|
||||
let pre = preScope(entry.pre ?? defaultRoot(user));
|
||||
const setPre = (want: Root): void => {
|
||||
pre = preScope(want);
|
||||
entry.pre = pre.root;
|
||||
const query = new URLSearchParams(entryQuery(entry));
|
||||
history.replaceState(null, "", `#/${ROUTES.M02_MASTER_TEMPLATE}?${query}`);
|
||||
void side.refreshPrecombo();
|
||||
};
|
||||
const preBar = (): HTMLElement =>
|
||||
createLayerBar({
|
||||
user,
|
||||
active: { root: pre.root, owner: pre.owner },
|
||||
company,
|
||||
go: (next) => setPre(next),
|
||||
// 켜진 단추 다시 누름 = 그 층 목록표(생성 · 복사 · 삭제)
|
||||
reselect: () =>
|
||||
void main.confirmLeave().then((ok) => ok && void main.open(precomboList(pre))),
|
||||
});
|
||||
const side = buildSide({
|
||||
layer,
|
||||
isAdmin,
|
||||
@@ -184,8 +215,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
|
||||
layerBar("구조물조합", tree.add, () => void main.openTable(layer)),
|
||||
peopleTree("구조물조합", tree.root),
|
||||
],
|
||||
// 일위대가 조합(50-3) — 같은 층 단추 · 누르면 그 층 화면에서 이 컨테이너를 펼침
|
||||
precomboBar: [layerBar("일위대가조합", null, () => undefined)],
|
||||
precombo: { scope: () => pre, bar: preBar },
|
||||
});
|
||||
|
||||
const layout = el("div", { className: "ui-workflow-layout m02-master" });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_UI_Side.ts
|
||||
* 마스터 템플릿 좌측 패널 — 「구조물 조합」(층 단추 · 나무) · 「일위대가 조합」(층 단추 · 단가승격 · 사전조합 ·
|
||||
* `UI_Side_Precombo` · 50-3) · 「도면 양식」(새로 · 본떠 · 지우기).
|
||||
* 마스터 템플릿 좌측 패널 — 「구조물 조합」(층 단추 · 나무) · 「일위대가 사전조합」(층 단추 따로 · 2단 폴더 나무 ·
|
||||
* `UI_Side_Precombo` · 50-3 · 134-3) · 「도면 양식」(새로 · 본떠 · 지우기).
|
||||
* 서버 길은 계약 `6_계약.md`.
|
||||
* 구조물 도면 · 일위대가 · 면적산출 상세는 좌측 줄이 아니라 항목 카드 [수정]으로(PLAN 45-2).
|
||||
* 화면 이름 「구조물 조합」(PLAN 26-7a) — 파일 이름 · 양식 키 · API 길은 「구조물집계표」 그대로.
|
||||
@@ -32,7 +32,11 @@ import { STRUCTURE_TABLE } from "./M02_MasterTemplete_Structure";
|
||||
import { buildOptionsSection } from "./StructOptions/M02_SO_Options";
|
||||
import { buildPracticeSection } from "./M02_MasterTemplete_Practice";
|
||||
import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
import { buildPrecomboSection, PRECOMBO_TITLE } from "./M02_MasterTemplete_UI_Side_Precombo";
|
||||
import {
|
||||
buildPrecomboSection,
|
||||
PRECOMBO_TITLE,
|
||||
type PrecomboOptions,
|
||||
} from "./M02_MasterTemplete_UI_Side_Precombo";
|
||||
|
||||
const KINDS: { kind: Kind; label: () => string }[] = [
|
||||
{ kind: "table", label: () => L("M02_KindTable") },
|
||||
@@ -60,8 +64,8 @@ export interface SideOptions {
|
||||
showView: (label: string, node: HTMLElement) => (dirty: boolean) => void;
|
||||
/** 「구조물 조합」 줄 아래 — 층 단추 · 고른 층의 구조물 라이브러리 나무(PLAN 29-2 · 페이지가 만듦) */
|
||||
tree?: HTMLElement[];
|
||||
/** 「일위대가 조합」 줄 아래 층 단추(50-3 · 페이지가 만듦) */
|
||||
precomboBar?: HTMLElement[];
|
||||
/** 「일위대가 사전조합」 층(134-3 · 페이지 층과 따로 · 층 단추는 페이지가 만듦) */
|
||||
precombo: Pick<PrecomboOptions, "scope" | "bar">;
|
||||
}
|
||||
|
||||
export interface SideHandle {
|
||||
@@ -71,6 +75,8 @@ export interface SideHandle {
|
||||
paint: () => void;
|
||||
/** 진입 해시 `open`(PLAN 27-1d) — 그 컨테이너를 펼침(옵션은 열기까지) · 옵션 · 도면은 시스템 층에만 */
|
||||
reveal: (target: "옵션" | "도면" | "일위대가조합") => void;
|
||||
/** 사전조합 층이 바뀜(134-3) — 그 컨테이너만 다시 그림 */
|
||||
refreshPrecombo: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** 서버 이름 규칙과 같게 — 화면에서 먼저 막음 */
|
||||
@@ -134,13 +140,12 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
openOnExpand(summary, async () => {
|
||||
if (!isPicked("table", STRUCTURE_TABLE) && (await opt.confirmLeave())) opt.onOpen(null);
|
||||
});
|
||||
// 일위대가 조합 서고(50-3) — 구조물 조합 바로 아래 · 층마다(마스터 · 회사 · 개인)
|
||||
// 일위대가 사전조합 서고(50-3 · 134-3) — 구조물 조합 바로 아래 · 층은 페이지 층과 따로
|
||||
const precombo = buildPrecomboSection({
|
||||
layer,
|
||||
bar: opt.precomboBar ?? [],
|
||||
...opt.precombo,
|
||||
confirmLeave: opt.confirmLeave,
|
||||
onOpen: opt.onOpen,
|
||||
openKind: () => (opt.getOpen()?.layer === layer ? (opt.getOpen()?.kind ?? null) : null),
|
||||
getOpen: opt.getOpen,
|
||||
});
|
||||
const precomboSec = section(PRECOMBO_TITLE, precombo.body);
|
||||
// 구조물 옵션 정의 · 도면 양식은 시스템 층(마스터 템플릿)에만 · 정의 고쳐 씀은 시스템 관리자만 · 다른 역할은 읽기만(PLAN 48-2)
|
||||
@@ -243,7 +248,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
if (mine !== seq) return;
|
||||
paintList();
|
||||
paintButtons();
|
||||
precombo.paint();
|
||||
await precombo.refresh();
|
||||
}
|
||||
|
||||
/* --- 시스템 층 새로 · 본떠 · 지우기(도면 양식만 — 구조물 도면은 집계표 열이 정함) --- */
|
||||
@@ -331,5 +336,5 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
};
|
||||
|
||||
void refresh();
|
||||
return { root, refresh, paint, reveal };
|
||||
return { root, refresh, paint, reveal, refreshPrecombo: precombo.refresh };
|
||||
}
|
||||
|
||||
@@ -1,68 +1,198 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_UI_Side_Precombo.ts
|
||||
* 좌측 「일위대가 조합」 컨테이너(50-3 ③ · 51-4) — 구조물 조합 아래 · 층 단추(페이지가 만듦) ·
|
||||
* 항목 둘 「단가산출 승격」(promote) · 「일위대가 사전조합」(precombo)만.
|
||||
* 항목 누름 = 상세 칸에 그 종류 목록표(`Library_List` · 생성 · 복사 · 삭제는 거기) ·
|
||||
* 표 줄 누름 = 편집 칸(UI_Main 종류 promote · precombo = 일위대가 상세 모드).
|
||||
* 좌측 「일위대가 사전조합」 컨테이너(50-3 ③ · 51-4 · 134-3) — 구조물 조합 아래 · 층 단추(페이지가 만듦 ·
|
||||
* 페이지 층과 따로 · 마스터 · 회사 · 내 개인) + 그 층 사전조합 나무(구분 › 상세구분 2단 폴더 · 빈 구분 = 미분류).
|
||||
* 문서 누름 = 편집 칸(UI_Main 종류 precombo = 일위대가 상세 모드) · 켜진 층 단추 다시 누름 = 목록표
|
||||
* (`Library_List` · 생성 · 복사 · 삭제는 거기) · 그 층 주인(`owner`) · 고칠 권한(`canWrite`)은 고름에 실어 보냄.
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import type { Kind, Layer } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { createTreeRow, renderTree, type TreeNode } from "@ui/ui_template_tree";
|
||||
import { listTemplates, type Kind, type Layer } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import type { Root } from "./M02_MasterTemplete_Layer";
|
||||
import type { StructDef } from "./StructOptions/M02_SO_Def_Model";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import type { Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
|
||||
export type PrecomboKind = Extract<Kind, "promote" | "precombo">;
|
||||
export type PrecomboKind = Extract<Kind, "precombo">;
|
||||
|
||||
const PARTS = [
|
||||
{ kind: "promote", label: "Lib_Promote" },
|
||||
{ kind: "precombo", label: "Lib_PrecomboAll" },
|
||||
] as const;
|
||||
export const PRECOMBO_TITLE = "일위대가 조합";
|
||||
export const isPrecombo = (kind: Kind): kind is PrecomboKind =>
|
||||
kind === "promote" || kind === "precombo";
|
||||
export const PRECOMBO_TITLE = "일위대가 사전조합";
|
||||
export const isPrecombo = (kind: Kind): kind is PrecomboKind => kind === "precombo";
|
||||
|
||||
/** 여러 구조물에 쓰는 묶음(콘크리트 · 거푸집 등) — 군마다 첫 상세구분(134-5) */
|
||||
export const COMMON = "공통";
|
||||
|
||||
/** 사전조합 구분 · 상세구분(134-5) — 구조물군 A~G › 그 군 구조물 종류 + 「공통」(원천 `구조물정의.json` ·
|
||||
* B05 구조물 배치와 같은 갈래 · 꺼진 종류도 넣음) */
|
||||
export const precomboClasses = (
|
||||
def: Pick<StructDef, "구조물군" | "types">,
|
||||
): { name: string; details: string[] }[] =>
|
||||
Object.entries(def.구조물군)
|
||||
.filter(([key]) => /^[A-G]$/.test(key))
|
||||
.map(([key, name]) => ({
|
||||
name,
|
||||
details: [COMMON, ...new Set(def.types.filter((t) => t.group === key).map((t) => t.name))],
|
||||
}));
|
||||
|
||||
/** 사전조합 층(134-3) — 페이지 층과 따로 · `owner` null = 자기 층 */
|
||||
export interface PrecomboScope {
|
||||
root: Root;
|
||||
layer: Layer;
|
||||
owner: string | null;
|
||||
canWrite: boolean;
|
||||
}
|
||||
|
||||
/** 그 층 목록표 고름(켜진 층 단추 다시 누름) */
|
||||
export const precomboList = (scope: PrecomboScope): Selection => ({
|
||||
layer: scope.layer,
|
||||
owner: scope.owner,
|
||||
canWrite: scope.canWrite,
|
||||
projectId: null,
|
||||
kind: "precombo",
|
||||
name: "",
|
||||
label: tc("Lib_PrecomboAll"),
|
||||
});
|
||||
|
||||
export interface PrecomboOptions {
|
||||
layer: Layer;
|
||||
/** 층 단추 줄(페이지가 만듦 · 누르면 그 층 화면 + 이 컨테이너 펼침) */
|
||||
bar: HTMLElement[];
|
||||
scope: () => PrecomboScope;
|
||||
/** 층 단추 줄(페이지가 만듦 · 그릴 때마다 새로 — 켜진 단추가 바뀜) */
|
||||
bar: () => HTMLElement;
|
||||
confirmLeave: () => Promise<boolean>;
|
||||
onOpen: (sel: Selection | null) => void;
|
||||
/** 이 층에서 지금 열린 종류(목록이든 문서든) — 항목 고름 표시 */
|
||||
openKind: () => Kind | null;
|
||||
getOpen: () => Selection | null;
|
||||
}
|
||||
|
||||
export interface PrecomboHandle {
|
||||
body: HTMLElement;
|
||||
/** 고름 표시만 */
|
||||
paint: () => void;
|
||||
/** 층 단추 · 나무를 그 층 목록으로 다시 그림 */
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface Row {
|
||||
이름: string;
|
||||
종류: string;
|
||||
제목?: string | null;
|
||||
구분?: string | null;
|
||||
상세구분?: string | null;
|
||||
}
|
||||
|
||||
/** 줄 → 구분 › 상세구분 2단 나무(빈 구분 = 미분류 · 맨 뒤 · 빈 상세구분 = 구분 바로 아래) */
|
||||
export function groupRows(
|
||||
rows: Row[],
|
||||
unsorted: string,
|
||||
): { 구분: string; 상세: Map<string, Row[]> }[] {
|
||||
const out = new Map<string, Map<string, Row[]>>();
|
||||
for (const row of rows) {
|
||||
const top = row.구분 || "";
|
||||
const sub = row.구분 ? row.상세구분 || "" : "";
|
||||
if (!out.has(top)) out.set(top, new Map());
|
||||
const subs = out.get(top)!;
|
||||
subs.set(sub, [...(subs.get(sub) ?? []), row]);
|
||||
}
|
||||
const order = (a: string, b: string): number =>
|
||||
!a ? 1 : !b ? -1 : a.localeCompare(b, "ko-KR", { numeric: true });
|
||||
return [...out.keys()].sort(order).map((top) => ({
|
||||
구분: top || unsorted,
|
||||
상세: new Map([...out.get(top)!].sort(([a], [b]) => order(a, b))),
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildPrecomboSection(opt: PrecomboOptions): PrecomboHandle {
|
||||
const items = PARTS.map((part) => {
|
||||
const b = el("button", {
|
||||
className: "m02-side__item ui-sidebar-row",
|
||||
text: tc(part.label),
|
||||
attrs: { type: "button", "data-precombo": part.kind },
|
||||
const barHost = el("div");
|
||||
const treeHost = el("div", { className: "ui-tree", attrs: { "data-precombo-tree": "" } });
|
||||
const leaves = new Map<string, HTMLElement>();
|
||||
const closed = new Set<string>(); // 접은 폴더 — 다시 그려도 그대로
|
||||
let rows: Row[] = [];
|
||||
let seq = 0;
|
||||
|
||||
const open = async (row: Row): Promise<void> => {
|
||||
const at = opt.scope();
|
||||
if (!(await opt.confirmLeave())) return;
|
||||
opt.onOpen({
|
||||
...precomboList(at),
|
||||
name: row.이름,
|
||||
label: row.제목 ? `${row.이름} ${row.제목}` : row.이름,
|
||||
});
|
||||
// 이름 "" = 목록표(UI_Main)
|
||||
b.addEventListener("click", async () => {
|
||||
if (!(await opt.confirmLeave())) return;
|
||||
opt.onOpen({
|
||||
layer: opt.layer,
|
||||
projectId: null,
|
||||
kind: part.kind,
|
||||
name: "",
|
||||
label: tc(part.label),
|
||||
});
|
||||
});
|
||||
return b;
|
||||
};
|
||||
const leaf = (row: Row): TreeNode => ({
|
||||
id: `doc:${row.이름}`,
|
||||
label: row.제목 || row.이름,
|
||||
tip: row.이름,
|
||||
count: null,
|
||||
run: () => void open(row),
|
||||
});
|
||||
const folder = (id: string, label: string, children: TreeNode[]): TreeNode => ({
|
||||
id,
|
||||
label,
|
||||
count: children.length,
|
||||
folder: true,
|
||||
open: !closed.has(id),
|
||||
children,
|
||||
});
|
||||
|
||||
const draw = (): void => {
|
||||
leaves.clear();
|
||||
if (!rows.length)
|
||||
return void treeHost.replaceChildren(
|
||||
el("p", { className: "m02-side__empty", text: tc("Lib_None") }),
|
||||
);
|
||||
const nodes = groupRows(rows, tc("Lib_Unsorted")).map((g) =>
|
||||
folder(
|
||||
`sub:${g.구분}`,
|
||||
g.구분,
|
||||
[...g.상세].flatMap(([sub, list]) =>
|
||||
sub ? [folder(`sub:${g.구분}/${sub}`, sub, list.map(leaf))] : list.map(leaf),
|
||||
),
|
||||
),
|
||||
);
|
||||
treeHost.replaceChildren(
|
||||
...renderTree(nodes, (node, caret, depth, onClick) => {
|
||||
const row = createTreeRow(node, caret, depth, () => {
|
||||
onClick();
|
||||
if (!node.children?.length) return;
|
||||
if (row.querySelector(".ui-tree__caret")?.textContent === "▸") closed.add(node.id);
|
||||
else closed.delete(node.id);
|
||||
});
|
||||
if (node.run) {
|
||||
row.dataset.precomboKey = node.id.slice(4);
|
||||
leaves.set(node.id.slice(4), row);
|
||||
}
|
||||
return row;
|
||||
}),
|
||||
);
|
||||
paint();
|
||||
};
|
||||
|
||||
const paint = (): void => {
|
||||
const cur = opt.getOpen();
|
||||
const at = opt.scope();
|
||||
const mine =
|
||||
cur && isPrecombo(cur.kind) && cur.layer === at.layer && (cur.owner ?? null) === at.owner;
|
||||
for (const [key, row] of leaves) row.classList.toggle("is-active", !!mine && cur.name === key);
|
||||
};
|
||||
|
||||
const refresh = async (): Promise<void> => {
|
||||
const mine = ++seq;
|
||||
const at = opt.scope();
|
||||
barHost.replaceChildren(opt.bar());
|
||||
treeHost.replaceChildren(el("p", { className: "m02-side__empty", text: tc("Loading") }));
|
||||
let got: Row[] = [];
|
||||
try {
|
||||
got = (await listTemplates(at.layer, null, at.owner)).filter((r) => r.종류 === "precombo");
|
||||
} catch {
|
||||
got = []; // 못 받으면 빈 나무(목록표에서 까닭)
|
||||
}
|
||||
if (mine !== seq) return;
|
||||
rows = got;
|
||||
draw();
|
||||
};
|
||||
|
||||
const body = el("div", {
|
||||
className: "m02-side__items ui-sidebar-list",
|
||||
children: [...opt.bar, ...items],
|
||||
attrs: { "data-precombo": "" },
|
||||
children: [barHost, treeHost],
|
||||
});
|
||||
const paint = (): void => {
|
||||
const on = opt.openKind();
|
||||
items.forEach((b, i) => b.classList.toggle("is-active", PARTS[i].kind === on));
|
||||
};
|
||||
return { body, paint };
|
||||
return { body, paint, refresh };
|
||||
}
|
||||
|
||||
@@ -28,8 +28,8 @@ from config import config_system
|
||||
LAYERS = ("system", "company", "personal", "project")
|
||||
# area = 면적산출 — 통합문서(구조물집계표 열마다 · `A00_Common/spreadsheet/`)
|
||||
# labor = 일위대가(구조물 열마다 · `M02_Labor_Doc`)
|
||||
# promote = 단가승격(SP-) · precombo = 사전조합(SC-) — 일위대가 조합 서고(`M02_Labor_Ref`)
|
||||
LIBRARY_KINDS = ("promote", "precombo")
|
||||
# precombo = 사전조합(SC-) — 일위대가 사전조합 서고(`M02_Labor_Ref` · 옛 단가승격 promote 는 134-2 에서 뺌)
|
||||
LIBRARY_KINDS = ("precombo",)
|
||||
KINDS = ("table", "drawing", "structure", "labor", "area", *LIBRARY_KINDS)
|
||||
TEMPLATES_DIRNAME = "templates"
|
||||
INITIAL_DIRNAME = "_initial"
|
||||
@@ -173,7 +173,6 @@ _SKELETON = {
|
||||
"structure": ("도면.entities",),
|
||||
"labor": ("담은로직",),
|
||||
"area": ("시트",),
|
||||
"promote": ("담은로직",),
|
||||
"precombo": ("담은로직",),
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/* M02 일위대가 문서 화면 정리(`M02_MasterTemplete_Labor_Api.ts` `normalizeLabor`) 헬퍼(134-1) —
|
||||
* 그 자리에서 트랜스파일해 Node 로 돌림 · 가져오는 길은 빈 대역 · stdin = {doc, column} · stdout = 정리한 문서 JSON. */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const file = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"M02_MasterTemplete",
|
||||
"M02_MasterTemplete_Labor_Api.ts",
|
||||
);
|
||||
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const stub = new Proxy({}, { get: () => function () {} });
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(box.exports, box, () => stub);
|
||||
|
||||
const input = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
process.stdout.write(JSON.stringify(box.exports.normalizeLabor(input.doc, input.column)));
|
||||
@@ -0,0 +1,54 @@
|
||||
/* M02 일위대가 사전조합 층(134-3) 헬퍼 — `M02_MasterTemplete_Api_Fetch.ts` 명시 owner 길 ·
|
||||
* `M02_MasterTemplete_UI_Side_Precombo.ts` `groupRows`(구분 › 상세구분 2단) 를 트랜스파일해 Node 로 돌림 ·
|
||||
* `precomboClasses`(134-5 · 구조물군 › 종류 + 공통) · fetch 대역이 부른 주소를 모음 ·
|
||||
* stdin = {rows, def?} · stdout = {urls, groups, classes} JSON. */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const STUBS = { "@config/config_frontend": { API_BASE_URL: "/api" } };
|
||||
const stub = new Proxy({}, { get: (_t, k) => (k === "currentLanguageIndex" ? () => 0 : () => {}) });
|
||||
const cache = new Map();
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) => {
|
||||
if (STUBS[request]) return STUBS[request];
|
||||
if (request.startsWith(".") && !request.endsWith(".css"))
|
||||
return loadTs(`${path.resolve(path.dirname(full), request)}.ts`);
|
||||
return stub;
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const urls = [];
|
||||
global.fetch = async (url) => {
|
||||
urls.push(url);
|
||||
return { status: 200, ok: true, json: async () => ({ 양식: [] }) };
|
||||
};
|
||||
|
||||
(async () => {
|
||||
const input = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
const api = loadTs(path.join(ROOT, "M02_MasterTemplete", "M02_MasterTemplete_Api_Fetch.ts"));
|
||||
const side = loadTs(
|
||||
path.join(ROOT, "M02_MasterTemplete", "M02_MasterTemplete_UI_Side_Precombo.ts"),
|
||||
);
|
||||
api.setActiveLayer("personal", "7/99"); // 페이지 = 남의 개인을 보는 중
|
||||
await api.readTemplate("personal", "labor", "x1", null); // 페이지 층 = 그 사람
|
||||
await api.readTemplate("personal", "precombo", "SC-000001", null, null); // 사전조합 = 내 개인
|
||||
await api.listTemplates("company", null, "8"); // 사전조합 = 볼 회사
|
||||
await api.saveTemplate("system", "precombo", "SC-000002", null, "v", {}, null);
|
||||
const groups = side.groupRows(input.rows, "미분류").map((g) => ({
|
||||
구분: g.구분,
|
||||
상세: [...g.상세].map(([k, v]) => [k, v.map((r) => r.이름)]),
|
||||
}));
|
||||
const classes = input.def ? side.precomboClasses(input.def) : null; // 134-5
|
||||
process.stdout.write(JSON.stringify({ urls, groups, classes }));
|
||||
})();
|
||||
@@ -0,0 +1,139 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""141-1 B07 평면도 글자 겹침 피하기.
|
||||
|
||||
못박는 것
|
||||
- 우선순위 = 시종점 > 측점 NO > IP > R > 배수 > 대피소 — 앞 글이 첫 후보(옛 자리)를 가짐.
|
||||
- 후보 중 첫 빈 자리 · 모두 막히면 첫 후보 그대로(글은 남음).
|
||||
- 장애물(IP 표 · 필지 상자) 비켜 감.
|
||||
- 가까운 IP · R · 배수 둘 · NO.0 + 시점 한 장 → 글 사각 겹침 0 · 글 모두 남음.
|
||||
- 멀리 놓인 글(후보 far)엔 지시선.
|
||||
"""
|
||||
|
||||
from itertools import combinations
|
||||
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import build_plan_kind_drawing
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanLabels import (
|
||||
overlaps,
|
||||
place_labels,
|
||||
text_rect,
|
||||
)
|
||||
|
||||
|
||||
def _label(piece: str, label: str, spots: list[dict], anchor=None) -> dict:
|
||||
return {
|
||||
"piece": piece,
|
||||
"seed": f"s:{piece}:{label}",
|
||||
"label": label,
|
||||
"size": 2.5,
|
||||
"layer": "l",
|
||||
"color": "#fff",
|
||||
"anchor": anchor,
|
||||
"candidates": spots,
|
||||
}
|
||||
|
||||
|
||||
def test_priority_keeps_first_spot_and_next_moves() -> None:
|
||||
spot = [{"x": 0.0, "y": 0.0}, {"x": 0.0, "y": 10.0}]
|
||||
placed = place_labels([_label("refuge", "대피소", spot), _label("endpoints", "시점", spot)], [])
|
||||
by = {item["piece"]: item["spot"] for item in placed}
|
||||
assert by["endpoints"] == spot[0] # 시종점이 먼저 — 옛 자리
|
||||
assert by["refuge"] == spot[1]
|
||||
|
||||
|
||||
def test_all_blocked_falls_back_to_first() -> None:
|
||||
spot = [{"x": 0.0, "y": 0.0}]
|
||||
placed = place_labels([_label("ip", "IP1", spot), _label("radius", "R=12", spot)], [])
|
||||
assert [item["spot"] for item in placed] == [spot[0], spot[0]] # 글은 남음
|
||||
|
||||
|
||||
def test_obstacle_is_avoided() -> None:
|
||||
spots = [{"x": 0.0, "y": 0.0}, {"x": 30.0, "y": 0.0}]
|
||||
placed = place_labels([_label("ip", "IP1", spots)], [(-5.0, -5.0, 5.0, 5.0)])
|
||||
assert placed[0]["spot"] == spots[1]
|
||||
|
||||
|
||||
def _texts(drawing: dict) -> list[dict]:
|
||||
return [
|
||||
e
|
||||
for e in drawing["entities"]
|
||||
if e["type"] == "Text"
|
||||
and e["layerId"].startswith("b07-plan-")
|
||||
and e["layerId"] != "b07-plan-title"
|
||||
]
|
||||
|
||||
|
||||
def _rect(entity: dict) -> tuple:
|
||||
shape = entity["shapeData"]
|
||||
opts = shape["options"]
|
||||
direction = (opts["textDirection"]["x"], opts["textDirection"]["y"])
|
||||
return text_rect(
|
||||
shape["label"],
|
||||
shape["basePoint"]["x"],
|
||||
shape["basePoint"]["y"],
|
||||
opts["fontSize"],
|
||||
opts["textAlign"],
|
||||
direction,
|
||||
)
|
||||
|
||||
|
||||
def test_crowded_sheet_has_no_overlapping_labels() -> None:
|
||||
box = (0.0, 0.0, 300.0, 200.0)
|
||||
pieces = {
|
||||
"ip": {
|
||||
"items": [
|
||||
{
|
||||
"name": "IP1",
|
||||
"apex": [100.0, 100.0],
|
||||
"start": [90.0, 100.0],
|
||||
"end": [100.0, 110.0],
|
||||
},
|
||||
{
|
||||
"name": "IP2",
|
||||
"apex": [103.0, 101.0],
|
||||
"start": [95.0, 101.0],
|
||||
"end": [103.0, 111.0],
|
||||
},
|
||||
]
|
||||
},
|
||||
"radius": {
|
||||
"items": [
|
||||
{"label": "R=12", "middle": [100.0, 98.0], "dir": [0.0, -1.0]},
|
||||
{"label": "R=15", "middle": [103.0, 99.0], "dir": [0.0, -1.0]},
|
||||
]
|
||||
},
|
||||
"drainage": {
|
||||
"items": [
|
||||
{"x": 101.0, "y": 96.0, "label": "D800 L=8.0"},
|
||||
{"x": 102.0, "y": 95.0, "label": "D600 L=6.0"},
|
||||
]
|
||||
},
|
||||
"station": {
|
||||
"items": [
|
||||
{"x": 50.0, "y": 50.0, "nx": 0.0, "ny": 1.0, "label": "NO.0", "major": True},
|
||||
]
|
||||
},
|
||||
"endpoints": {
|
||||
"items": [{"label": "시점 NO.0", "x": 50.0, "y": 50.0, "nx": 0.0, "ny": 1.0}]
|
||||
},
|
||||
}
|
||||
drawing = build_plan_kind_drawing("plan_x", "시험", box, pieces)
|
||||
texts = _texts(drawing)
|
||||
labels = sorted(e["shapeData"]["label"] for e in texts)
|
||||
assert labels == sorted(
|
||||
["IP1", "IP2", "R=12", "R=15", "D800 L=8.0", "D600 L=6.0", "NO.0", "시점 NO.0"]
|
||||
)
|
||||
pairs = [
|
||||
(a["shapeData"]["label"], b["shapeData"]["label"])
|
||||
for a, b in combinations(texts, 2)
|
||||
if overlaps(_rect(a), _rect(b))
|
||||
]
|
||||
assert pairs == []
|
||||
|
||||
|
||||
def test_far_spot_draws_leader() -> None:
|
||||
near = [{"x": 0.0, "y": 0.0}, {"x": 40.0, "y": 0.0, "far": True}]
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanLabels import label_entities
|
||||
|
||||
placed = place_labels([_label("ip", "IP1", near, (0.0, 0.0))], [(-3.0, -3.0, 3.0, 3.0)])
|
||||
entities = label_entities("plan_x", placed)
|
||||
assert [e["type"] for e in entities] == ["Text", "PolyLine"]
|
||||
@@ -130,5 +130,6 @@ def test_연결된_사본이_부른_자원만(tmp_path):
|
||||
(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) == [] # 입력 빈 로직은 못 돎 — 부른 줄 없음
|
||||
auto = used_keys(tmp_path) # 빈 입력 = 자동값(145-1 · 142-1) — 자동 기계 한 대 자원만
|
||||
assert auto and len(auto) <= 3 and auto[0].startswith("EQ")
|
||||
assert used_keys(tmp_path / "없음") == []
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
"""145-1 B09 단가산출근거 · 목록표 — 승격 줄 → 산근 블록 · 엑셀 검산 · 같은 산근 하나 · 빈 표.
|
||||
|
||||
- 산근 = 연결된 사본 일위대가 로직 줄 중 `승격: true` + 옛 SP 참조 줄(134-2 ③) · 승격 아닌 줄은 안 담음
|
||||
- 줄 금액 = ROUNDDOWN(B09 굳힌 단가 × 1 단위 수량, 1) · 총계 = 비목마다 ROUNDDOWN 0 — 엑셀
|
||||
단가산출근거 제 6호표(뒷채움) 줄 값 · 총계 8,328(6,878 · 538 · 912)과 같음
|
||||
- 같은 로직 + 같은 입력값 = 산근 하나(쓰는 구조물이 늘어남) · 입력이 다르면 둘 · 번호 = 첫 등장
|
||||
- 사본 없으면 빈 표 + 「연결 없음」 · 조합 밖 토공 · 운반 산근 안내 한 줄 · 작업량 Q = ROUND 2
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
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_Sangeun as sg # noqa: E402
|
||||
from B09_Estimation.B09_Estimation_Calc_Base import compute_base # noqa: E402
|
||||
from B09_Estimation.B09_Estimation_Linked import used_keys # noqa: E402
|
||||
from M02_MasterTemplete import M02_Item_Prices as item_prices # noqa: E402
|
||||
|
||||
STONE = "GF000219" # 돌쌓기 메쌓기(인력) — 석공 0.195 · 보통인부 0.156 / ㎡
|
||||
NET = "GC000337" # 낙석방지망 — 노무 · 크레인(중기) · 자재 셋
|
||||
NET_IN = {"구분": "기계식", "방지망": "포켓식", "지역": "전국평균", "크레인": "2104-0010"}
|
||||
SP_REF = {"층": "system", "주인": None, "키": "SP-000001"}
|
||||
|
||||
|
||||
def _snapshot(folder: Path, rows, values=None, copies=None) -> Path:
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
labor = {
|
||||
"줄": {"종류": "일위대가", "담은로직": rows},
|
||||
**({"참조사본": copies} if copies else {}),
|
||||
}
|
||||
files = {
|
||||
"단가.json": item_prices.to_json(item_prices.collect([STONE, NET])),
|
||||
"일위대가.json": labor,
|
||||
"입력값.json": {k: {n: {"값": v} for n, v in d.items()} for k, d in (values or {}).items()},
|
||||
}
|
||||
for name, data in files.items():
|
||||
(folder / name).write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||
return folder
|
||||
|
||||
|
||||
def _sheets(*folders: Path) -> dict:
|
||||
keys = [k for f in folders for k in used_keys(f)]
|
||||
return compute_base(prices.build_inputs(None, list(dict.fromkeys(keys))), {})
|
||||
|
||||
|
||||
def test_승격_줄만_산근_블록_단가는_B09_표(tmp_path):
|
||||
a = _snapshot(tmp_path / "a", [{"로직": STONE, "승격": True}, {"로직": NET}], {NET: NET_IN})
|
||||
sheets = _sheets(a)
|
||||
got = sg.collect([("s1", a)], sheets)
|
||||
(block,) = got["sg"]
|
||||
assert (block["no"], block["logic"], block["unit"]) == (1, STONE, "㎡")
|
||||
labor = {r["key"]: r["price"] for r in sheets["labor"]}
|
||||
lines = block["lines"]
|
||||
assert [(ln["kind"], ln["ref"]["key"], ln["qty"]) for ln in lines] == [
|
||||
("labor", "LB000033", 0.195),
|
||||
("labor", "LB000002", 0.156),
|
||||
]
|
||||
want = [sg.rounddown(labor[ln["ref"]["key"]] * ln["qty"], 1) for ln in lines]
|
||||
assert [ln["labor"] for ln in lines] == want
|
||||
assert block["labor"] == sg.rounddown(sum(want), 0) and block["sum"] == block["labor"]
|
||||
assert block["material"] == 0 and block["expense"] == 0 and block["why"] is None
|
||||
assert got["sg_list"][0]["key"] == block["key"] and got["sg_gaps"] == []
|
||||
assert sg.price_of(got, block["key"]) == {
|
||||
"no": 1, "labor": block["labor"], "material": 0.0, "expense": 0.0, "sum": block["sum"],
|
||||
} # fmt: skip
|
||||
|
||||
|
||||
def test_중기는_중기목록표_노_재_경_자재는_적용_단가(tmp_path):
|
||||
a = _snapshot(tmp_path / "a", [{"로직": NET, "승격": True}], {NET: NET_IN})
|
||||
sheets = _sheets(a)
|
||||
(block,) = sg.collect([("s1", a)], sheets)["sg"]
|
||||
kinds = [ln["kind"] for ln in block["lines"]]
|
||||
assert kinds == ["labor", "labor", "machine", "material", "material", "material"]
|
||||
crane = block["lines"][2]
|
||||
row = next(r for r in sheets["machine"] if r["key"] == crane["ref"]["key"])
|
||||
for part in sg.PARTS:
|
||||
assert crane[part] == sg.rounddown(row[part] * crane["qty"], 1)
|
||||
net = block["lines"][3]
|
||||
applied = next(r["applied"] for r in sheets["compare"] if r["key"] == net["ref"]["key"])
|
||||
assert net["material"] == sg.rounddown(applied * net["qty"], 1)
|
||||
assert block["sum"] == block["labor"] + block["material"] + block["expense"]
|
||||
|
||||
|
||||
def test_엑셀_6호표_뒷채움_검산():
|
||||
"""단가산출근거 C285 = 8,328(6,878 · 538 · 912) — 보통인부 0.018 × 165,545 · 굴삭기(0.2m3) 0.07 ×
|
||||
중기목록표 55,700 · 7,695 · 13,041(줄 2,979.8 · 3,899.0 · 538.6 · 912.8 · 계 8,330.2 → 비목마다 버림)."""
|
||||
zero = dict.fromkeys(sg.PARTS, 0.0)
|
||||
labor = {**zero, "labor": sg._money(165545, 0.018)}
|
||||
machine = {p: sg._money(v, 0.07) for p, v in zip(sg.PARTS, (55700, 7695, 13041))}
|
||||
assert labor["labor"] == 2979.8
|
||||
assert (machine["labor"], machine["material"], machine["expense"]) == (3899.0, 538.6, 912.8)
|
||||
assert sg.block_totals([labor, machine]) == {
|
||||
"labor": 6878, "material": 538, "expense": 912, "sum": 8328,
|
||||
} # fmt: skip
|
||||
assert sg.block_totals([labor, {**machine, "material": None}])["sum"] is None # 빈칸 = 빈칸
|
||||
|
||||
|
||||
def test_같은_로직_같은_입력은_하나_다르면_둘_옛_SP_도_같은_산근(tmp_path):
|
||||
a = _snapshot(tmp_path / "a", [{"로직": NET, "승격": True}], {NET: NET_IN})
|
||||
b = _snapshot(tmp_path / "b", [{"로직": NET, "승격": True}], {NET: {**NET_IN, "": ""}})
|
||||
other = {**NET_IN, "방지망": "비포켓식"}
|
||||
c = _snapshot(tmp_path / "c", [{"로직": NET, "승격": True}], {NET: other})
|
||||
sp = {"담은로직": [{"로직": NET, "입력값": NET_IN}], "배": 1}
|
||||
d = _snapshot(
|
||||
tmp_path / "d",
|
||||
[{"로직": "", "참조": SP_REF}, {"로직": STONE, "승격": True}],
|
||||
copies={"참조|system||SP-000001": sp},
|
||||
)
|
||||
got = sg.collect([("s1", a), ("s2", b), ("s3", c), ("s4", d)], _sheets(a, c, d))
|
||||
blocks = got["sg"]
|
||||
assert [(x["no"], x["logic"], x["uses"]) for x in blocks] == [
|
||||
(1, NET, ["s1", "s2", "s4"]), # 빈 입력 칸은 무시 · 옛 SP(같은 로직 · 입력) = 같은 산근
|
||||
(2, NET, ["s3"]),
|
||||
(3, STONE, ["s4"]),
|
||||
]
|
||||
assert blocks[0]["key"] == sg.sangeun_key(NET, {**NET_IN, "x": None}, 100, {})
|
||||
assert blocks[0]["key"] != sg.sangeun_key(NET, NET_IN, 90) # 켜쌓기율이 다르면 다른 산근
|
||||
|
||||
|
||||
def test_사본_없으면_빈_표_연결_없음(tmp_path, monkeypatch):
|
||||
got = sg.collect([("s1", tmp_path / "없음")], {})
|
||||
assert got["sg"] == [] and got["sg_list"] == [] and got["sg_gaps"] == []
|
||||
assert got["sg_notes"] == [sg.NO_SOURCE, sg.EMPTY]
|
||||
plain = _snapshot(tmp_path / "p", [{"로직": STONE}]) # 승격 없는 사본도 빈 표
|
||||
assert sg.collect([("s1", plain)], {})["sg"] == []
|
||||
|
||||
async def fake_materials(_project_id):
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr(router_mod, "material_sheets", fake_materials)
|
||||
sheets = asyncio.run(router_mod.payload("p", tmp_path))["sheets"]
|
||||
assert sheets["sg"] == [] and sheets["sg_list"] == []
|
||||
assert sg.EMPTY in sheets["sg_notes"] and sg.NO_SOURCE in sheets["sg_notes"]
|
||||
|
||||
|
||||
def test_단가_없으면_빈칸과_연결_없음(tmp_path):
|
||||
a = _snapshot(tmp_path / "a", [{"로직": STONE, "승격": True}])
|
||||
got = sg.collect([("s1", a)], {"labor": [], "machine": [], "compare": []})
|
||||
(block,) = got["sg"]
|
||||
assert block["labor"] is None and block["sum"] is None
|
||||
assert "노무비목록표에 없음" in block["why"]
|
||||
assert [(g["tab"], g["key"]) for g in got["sg_gaps"]] == [("sg", block["key"])]
|
||||
|
||||
|
||||
def test_작업량_Q는_소수_둘():
|
||||
import M01_MasterData.M01_MasterData_Store as m01
|
||||
|
||||
whole = m01.loaded()[1]
|
||||
for code, row in sorted((whole.index.get("GF") or {}).items()):
|
||||
if not any(sg._Q.fullmatch(s["이름"]) for s in row.get("중간", [])):
|
||||
continue
|
||||
try:
|
||||
env = sg._env(whole, row, {})
|
||||
except Exception: # noqa: BLE001 — 입력이 필요한 로직은 건너뜀
|
||||
continue
|
||||
q = next(env[s["이름"]] for s in row["중간"] if sg._Q.fullmatch(s["이름"]))
|
||||
if hasattr(q, "as_tuple"):
|
||||
assert q == round(q, 2), code
|
||||
return
|
||||
pytest.skip("자동값으로 도는 작업량 Q 로직 없음")
|
||||
|
||||
|
||||
def test_화면_탭_둘_서버_산근_그대로():
|
||||
shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
|
||||
assert "sg_list: buildSgList," in shell and "sg: buildSg," in shell
|
||||
assert 'const SERVER_ONLY = ["sg", "sg_list", "sg_gaps", "sg_notes"] as const;' in shell
|
||||
assert "gaps: [...base.gaps, ...(server.sg_gaps || [])]" in shell
|
||||
tabs = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Sangeun.ts").read_text("utf-8")
|
||||
assert 'link: (i) => (rows[i] ? { tab: "sg", key: String(rows[i].key) } : null),' in tabs
|
||||
assert "PARTS.filter((p) => ln.price?.[p] !== 0)" in tabs # 중기 = 노 · 재 · 경 세 줄
|
||||
@@ -217,7 +217,8 @@ def test_회사_찾기_거르기():
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
def test_구조물_조합_기본_층_개인():
|
||||
"""123-1 — 회사 있는 사람은 역할 무관 구조물 조합 = 개인 · 회사 없으면 지금 규칙 · 다른 곳 그대로."""
|
||||
"""123-1 — 회사 있는 사람은 역할 무관 구조물 조합 = 개인 · 회사 없으면 지금 규칙 · 다른 곳 그대로 ·
|
||||
134-4 — 층 없는 주소도 개인(회사 없으면 마스터) · 사전조합 기본 층도 같은 규칙."""
|
||||
m = "B01_Dashboard_PermMasterWrite"
|
||||
combo = f"{M02}?open=구조물조합&root=personal"
|
||||
assert _cards({"role": "SYSTEM_ADMIN", "is_master": False, "company_id": 1}, 3) == [
|
||||
@@ -238,7 +239,12 @@ def test_구조물_조합_기본_층_개인():
|
||||
assert _cards({"role": "ADMIN", "is_master": False, "company_id": None})[0][2] == (
|
||||
f"{M02}?open=구조물조합&root=company"
|
||||
)
|
||||
# 다른 곳 기본 그대로 — 고르기 탭(일위대가 · 복사 생성 · 복사해 가져오기) · 층 없는 주소
|
||||
# 다른 곳 기본 그대로 — 고르기 탭(일위대가 · 복사 생성 · 복사해 가져오기)
|
||||
layer = _text(ROOT / "M02_MasterTemplete" / "M02_MasterTemplete_Layer.ts")
|
||||
assert 'return { tabs, first: isSystem || !own ? "master" : "personal" };' in layer
|
||||
assert 'raw === "company" || raw === "personal" ? raw : "master";' in layer
|
||||
# 134-4 — 층 없는 주소 = 기본 층(개인 · 회사 없으면 마스터) · 사전조합 층도 같은 기본
|
||||
assert 'ownOwner("personal", user) ? "personal" : "master";' in layer
|
||||
assert ': user === undefined\n ? "master"\n : defaultRoot(user);' in layer
|
||||
page = _text(ROOT / "M02_MasterTemplete" / "M02_MasterTemplete_UI_Page.ts")
|
||||
assert "const entry = entryFromHash(user);" in page
|
||||
assert "let pre = preScope(entry.pre ?? defaultRoot(user));" in page
|
||||
|
||||
@@ -81,7 +81,7 @@ def test_일위대가_카드는_상세_카드_갈래():
|
||||
assert "move: reorder," in detail # 85-3 — 카드도 순서(▲▼ · 끌기) · 빼기는 그대로
|
||||
# 서고 모드(50-3 · lib)도 더하기 · 다이어그램 없음
|
||||
# 51-11 사전조합 = 일위대가 상세와 같은 [로직 추가] · 같은 목록(걸러냄 없음)
|
||||
assert "opt.card || promote ? null : addLogic" in detail
|
||||
assert "opt.card ? null : addLogic" in detail
|
||||
assert "only" not in _text("M02_MasterTemplete_Labor_Pick.ts")
|
||||
assert "...(opt.card || lib ? [] : [dgBox])," in detail
|
||||
assert "opt.card ? null : addMat" in detail # 49-6 — 카드엔 [자재 추가] 없음 · 상세는 그대로
|
||||
@@ -151,16 +151,17 @@ def test_일위대가_줄_고름과_옵션_단추():
|
||||
|
||||
|
||||
def test_순서칸_2행_참조줄_옵션_수정():
|
||||
"""51-14 — 순서 칸 2행(위 [위로] [아래로] / 아래 [옵션]·[수정] [삭제]) · 단가승격 [옵션] · 일위조합 [수정] =
|
||||
서고 원본 모달(저장 = 서고 저장 길 · 자기 층만 고침) · 닫으면 다시 셈."""
|
||||
"""51-14 — 순서 칸 2행(위 [위로] [아래로] / 아래 [옵션]·[수정] [삭제]) · 일위조합 [수정] =
|
||||
서고 원본 모달(저장 = 서고 저장 길 · 자기 층만 고침) · 닫으면 다시 셈 · 옛 단가승격 참조 줄 = 단추 없음(134-2)."""
|
||||
rows = _text("M02_MasterTemplete_Labor_Rows.ts")
|
||||
assert 'className: "m02-labrow__tools"' in rows
|
||||
assert '"SP-") ? "Labor_Option" : "Labor_Edit"' in rows
|
||||
assert 'ref && !row.참조.키.startsWith("SP-")' in rows
|
||||
assert '[small(tc("Labor_Edit"), () => ref(i))]' in rows
|
||||
detail = _text("M02_MasterTemplete_Labor_Detail.ts")
|
||||
assert "readOnly: !edit || ref.층 !== opt.layer," in detail
|
||||
assert "closed: () => alive && recalc()," in detail
|
||||
ref = _text("M02_MasterTemplete_Labor_RefEdit.ts")
|
||||
assert "saveTemplate(layer, kind, opt.ref.키, null, 판, body)" in ref
|
||||
assert "saveTemplate(layer, kind, opt.ref.키, null, 판, body, opt.ref.주인)" in ref
|
||||
assert 'mode: "precombo",' in ref and "showConfirmDialog" in ref
|
||||
assert 'Labor_Edit: ["수정"' in _text("M02_MasterTemplete_Labor_Text.ts")
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
- GC000260 90% = 100% × 0.9(비목 · 계 · 줄 수량 · 금액) · 단가는 그대로.
|
||||
- clean = 0 초과 200 이하 · 소수 한 자리 · 100 · 대상 밖은 안 둠.
|
||||
- 프로젝트 사본 계산(`M02_Item_Prices.calc`)도 같은 율 · 같은 값.
|
||||
- 서고(단가승격)도 율을 담고 같은 값.
|
||||
- 옛 단가승격 서고 문서도 율을 따라 같은 값(134-2 · 프로젝트 사본 셈).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -83,11 +83,8 @@ def test_문서_계산과_프로젝트_사본_같음() -> None:
|
||||
assert _d(line["계"]) == pytest.approx(_d(full["줄"][0]["기준"]["계"]) * Decimal("0.9"))
|
||||
|
||||
|
||||
def test_서고_단가승격도_율() -> None:
|
||||
doc = refs.clean_doc(
|
||||
"promote", {"키": "SP-900001", "담은로직": [{"로직": CODE, "켜쌓기율": 90}]}
|
||||
)
|
||||
assert doc["담은로직"][0]["켜쌓기율"] == 90
|
||||
def test_옛_단가승격도_율() -> None:
|
||||
doc = {"키": "SP-900001", "배": 1, "담은로직": [{"로직": CODE, "켜쌓기율": 90}]}
|
||||
whole = m01.loaded()[1]
|
||||
expand = refs.Expand(whole, lambda ref: doc)
|
||||
got = expand({"층": "system", "주인": None, "키": "SP-900001"})
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""일위대가 로직 줄 단가승격 표시(PLAN 134-1) — 줄에 `승격: true` · 금액 무관.
|
||||
|
||||
- 서버 `clean` = `true` 만 남김(거짓 · 글 · 참조 줄은 버림) · 저장 왕복에 남음 · 금액 그대로
|
||||
- 사전조합 안 로직 줄엔 승격 없음(정리 때 버림)
|
||||
- 화면 `normalizeLabor` 도 `true` 만 통과(Node 헬퍼)
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from resources.tester.test_m02_structure import write_sample_table
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
NET = "GC000337"
|
||||
BARE = "GC000994"
|
||||
|
||||
|
||||
def _doc(lines):
|
||||
return {"열": "rv", "담은로직": lines, "자재": []}
|
||||
|
||||
|
||||
def test_clean_은_참만_남김():
|
||||
got = labor.clean(_doc([{"로직": NET, "승격": True}, {"로직": BARE, "승격": "yes"}]))[
|
||||
"담은로직"
|
||||
]
|
||||
assert got[0]["승격"] is True and "승격" not in got[1]
|
||||
|
||||
|
||||
def test_승격은_금액을_안_바꿈():
|
||||
plain = labor.calc(labor.clean(_doc([{"로직": NET, "수량": {"손": 2}}])), {})
|
||||
flag = labor.calc(labor.clean(_doc([{"로직": NET, "수량": {"손": 2}, "승격": True}])), {})
|
||||
assert flag["반영합"] == plain["반영합"] and flag["기준합"] == plain["기준합"]
|
||||
|
||||
|
||||
def test_사전조합_안_로직_줄엔_승격_없음(monkeypatch):
|
||||
monkeypatch.setattr(refs, "here", lambda: ("system", None))
|
||||
lines = refs._combo_lines({"담은로직": [{"로직": NET, "승격": True}]}, "SC-000009")
|
||||
assert "승격" not in lines[0]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def system(tmp_path, monkeypatch):
|
||||
write_sample_table(tmp_path)
|
||||
monkeypatch.setattr(store, "FOLDER", tmp_path)
|
||||
store.create_structure("rv")
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_저장_왕복에_승격_남음(system):
|
||||
have = store.read("labor", "rv")
|
||||
sent = {
|
||||
**have["문서"],
|
||||
"담은로직": [
|
||||
{"로직": NET, "수량": None, "승격": True},
|
||||
{"로직": BARE, "수량": None},
|
||||
],
|
||||
}
|
||||
store.write("labor", "rv", have["판"], sent)
|
||||
rows = store.read("labor", "rv")["문서"]["담은로직"]
|
||||
assert rows[0]["승격"] is True and "승격" not in rows[1]
|
||||
# 내리면(드롭다운 단가산출) 칸째 사라짐
|
||||
have = store.read("labor", "rv")
|
||||
store.write("labor", "rv", have["판"], {**have["문서"], "담은로직": [{"로직": NET}]})
|
||||
assert "승격" not in store.read("labor", "rv")["문서"]["담은로직"][0]
|
||||
|
||||
|
||||
def test_화면_정리도_참만_통과():
|
||||
doc = {
|
||||
"담은로직": [
|
||||
{"로직": NET, "승격": True},
|
||||
{"로직": BARE, "승격": False},
|
||||
{"로직": "", "참조": {"층": "system", "주인": None, "키": "SC-000001"}, "승격": True},
|
||||
]
|
||||
}
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_m02_labor_normalize.cjs")],
|
||||
input=json.dumps({"doc": doc, "column": "rv"}, ensure_ascii=False),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
check=True,
|
||||
)
|
||||
rows = json.loads(proc.stdout)["담은로직"]
|
||||
assert [r.get("승격") for r in rows] == [True, None, None]
|
||||
@@ -1,7 +1,9 @@
|
||||
"""일위대가 조합 서고(PLAN 50-3 · `M02_Labor_Ref`) — 단가승격 · 사전조합 · 참조 줄.
|
||||
"""일위대가 사전조합 서고(PLAN 50-3 · `M02_Labor_Ref`) — 사전조합 · 참조 줄 · 옛 단가승격(134-2).
|
||||
|
||||
펼침(재귀 · 순환 · 깊이) · 층 규칙(위 층만) · 쓰는 곳 지우기 409 · 층 사이 따라 복사 ·
|
||||
프로젝트 굳힘 · 로직 지우기 검사 · TS 짝 settle 거울. 층은 임시 폴더(마스터 · storage).
|
||||
옛 단가승격(SP) = 새로 못 만듦 · 남은 파일은 읽기 · 셈 · 굳힘만(`_promote` = 옛 파일을 바로 씀) ·
|
||||
층 문서를 읽을 때 옛 참조 줄 = 로직 줄 + 승격(`unpromote` · 같은 로직이 있으면 그대로).
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -44,9 +46,26 @@ def world(tmp_path, monkeypatch):
|
||||
}
|
||||
|
||||
|
||||
def _promote(folder, code=TEN, **extra):
|
||||
with store.at(folder):
|
||||
return refs.create("promote", {"담은로직": [{"로직": code, "입력값": {}}], **extra})["이름"]
|
||||
def _promote(folder, code=TEN, values=None, **extra):
|
||||
"""옛 단가승격 파일(134-2 앞 꼴) — 층마다 다음 번호 · 배 = 로직 물량 알약 배."""
|
||||
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
|
||||
|
||||
line = {"로직": code, "입력값": values or {}, **extra}
|
||||
pill = (combo_store.combo_pills({"담은로직": [line]})["물량"] or [{}])[0]
|
||||
folder = folder / "promote"
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
key = f"SP-{len(list(folder.glob('SP-*.json'))) + 1:06d}"
|
||||
doc = {
|
||||
"종류": "단가승격",
|
||||
"키": key,
|
||||
"이름": pill.get("이름") or code,
|
||||
"단위": pill.get("단위"),
|
||||
"배": pill.get("배") or 1,
|
||||
"담은로직": [line],
|
||||
"본뜬": None,
|
||||
}
|
||||
(folder / f"{key}.json").write_text(json.dumps(doc, ensure_ascii=False), encoding="utf-8")
|
||||
return key
|
||||
|
||||
|
||||
def _combo(folder, lines, name="조합"):
|
||||
@@ -65,30 +84,78 @@ def _base(code):
|
||||
|
||||
|
||||
# ── 자료 틀 ────────────────────────────────────────────────────────────
|
||||
def test_승격은_로직_한_줄_단위_배_이름은_로직에서_키는_층마다_다음_번호(world):
|
||||
assert _promote(world["system"]) == "SP-000001"
|
||||
assert _promote(world["system"], STONE, 이름="돌") == "SP-000002"
|
||||
assert _promote(world["personal"]) == "SP-000001" # 층마다 셈
|
||||
doc = _read(world["system"], "promote", "SP-000001")
|
||||
assert (doc["종류"], doc["단위"], doc["배"]) == ("단가승격", "㎡", 10)
|
||||
assert doc["이름"] and doc["담은로직"] == [{"로직": TEN, "입력값": {}}]
|
||||
assert _read(world["system"], "promote", "SP-000002")["이름"] == "돌"
|
||||
def test_단가승격_서고는_없음_층_종류에서_뺌(world):
|
||||
assert "promote" not in layers.KINDS and layers.LIBRARY_KINDS == ("precombo",)
|
||||
with store.at(world["system"]), pytest.raises(KeyError):
|
||||
refs.create("promote", {"담은로직": [{"로직": TEN}]})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
{"담은로직": []},
|
||||
{"담은로직": [{"로직": TEN}, {"로직": STONE}]},
|
||||
{"담은로직": [{"로직": "GF999999"}]},
|
||||
{"담은로직": [{"참조": {**SYS, "키": "SP-000001"}}]},
|
||||
],
|
||||
)
|
||||
def test_틀린_승격은_400(world, body):
|
||||
_promote(world["system"])
|
||||
with store.at(world["system"]), pytest.raises(store.StoreError) as e:
|
||||
refs.create("promote", body)
|
||||
assert e.value.status == 400
|
||||
# ── 옛 단가승격 참조 줄 = 읽을 때 새 꼴(134-2) ───────────────────────────
|
||||
def test_옛_참조_줄은_로직_줄_승격으로_보이고_값_같음(world):
|
||||
sp = _promote(world["system"], TEN)
|
||||
old = {
|
||||
"열": "rv",
|
||||
"담은로직": [
|
||||
{"참조": {**SYS, "키": sp}, "별칭": "승", "메모": "m", "수량": {"손": 4}},
|
||||
{"로직": STONE, "수량": {"손": 1}},
|
||||
],
|
||||
"자재": [],
|
||||
}
|
||||
got = refs.unpromote("labor", old)
|
||||
assert got["담은로직"][0] == {
|
||||
"로직": TEN,
|
||||
"별칭": "승",
|
||||
"메모": "m",
|
||||
"입력값": {},
|
||||
"수량": {"손": 4},
|
||||
"승격": True,
|
||||
}
|
||||
assert old["담은로직"][0]["참조"]["키"] == sp # 받은 문서는 그대로
|
||||
held = _promote(world["system"], PLAIN, {"규격": "NX"}, 켜쌓기율=90) # 입력값 · 율도 옮김
|
||||
one = refs.unpromote("labor", {"담은로직": [{"참조": {**SYS, "키": held}}]})["담은로직"][0]
|
||||
assert (one["입력값"], one["켜쌓기율"]) == ({"규격": "NX"}, 90)
|
||||
with store.at(world["system"]):
|
||||
before = labor.calc(labor.clean(old), {})["반영합"]["계"]
|
||||
after = labor.clean(got)
|
||||
assert after["담은로직"][0]["승격"] is True # [저장] 때 새 꼴로 굳음
|
||||
assert labor.calc(after, {})["반영합"]["계"] == pytest.approx(before, rel=1e-9)
|
||||
|
||||
|
||||
def test_옛_참조_줄은_같은_로직이_있으면_그대로_사전조합은_승격_없이(world):
|
||||
sp = _promote(world["system"], STONE)
|
||||
lines = [{"로직": STONE, "수량": None}, {"참조": {**SYS, "키": sp}, "수량": None}]
|
||||
doc = {"열": "rv", "담은로직": lines, "자재": []}
|
||||
assert refs.unpromote("labor", doc) is doc # 겹침 = 옛 참조 줄 그대로(저장 됨)
|
||||
with store.at(world["system"]):
|
||||
assert labor.clean(doc)["담은로직"][1]["참조"]["키"] == sp
|
||||
combo = {"담은로직": [{"참조": {**SYS, "키": sp}, "수량": {"손": 2}, "메모": None}]}
|
||||
one = refs.unpromote("precombo", combo)["담은로직"][0]
|
||||
assert one["로직"] == STONE and one["수량"] == {"손": 2} and "승격" not in one
|
||||
assert refs.unpromote("area", combo) is combo # 다른 종류는 안 봄
|
||||
|
||||
|
||||
def test_층_길_읽기는_옛_참조_줄을_새_꼴로_파일은_그대로(world):
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from common_util.common_util_auth import verify_session
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layer_router
|
||||
|
||||
sp = _promote(world["system"])
|
||||
with store.at(world["personal"]):
|
||||
line = {"참조": {**SYS, "키": sp}, "수량": {"손": 2}}
|
||||
store.write("labor", "x1", "", {"열": "x1", "담은로직": [line], "자재": []})
|
||||
app = FastAPI()
|
||||
app.include_router(layer_router.router)
|
||||
app.dependency_overrides[verify_session] = lambda: {
|
||||
"user_id": 42,
|
||||
"company_id": 7,
|
||||
"role": "USER",
|
||||
"is_master": False,
|
||||
}
|
||||
got = TestClient(app).get("/api/m02/layers/personal/templates/labor/x1").json()
|
||||
assert got["문서"]["담은로직"][0]["로직"] == TEN and got["문서"]["담은로직"][0]["승격"] is True
|
||||
assert _read(world["personal"], "labor", "x1")["담은로직"][0]["참조"]["키"] == sp
|
||||
|
||||
|
||||
def test_사전조합_틀린_줄_400_순환_422(world):
|
||||
@@ -219,25 +286,25 @@ def test_참조_줄_settle_파이썬과_TS_가_같은_답(world):
|
||||
|
||||
# ── 지우기 · 복사 · 굳힘 · 로직 지우기 ─────────────────────────────────
|
||||
def test_쓰는_곳이_있으면_지우기_409_없으면_지움(world):
|
||||
sp = _promote(world["system"])
|
||||
sc = _combo(world["system"], [{"로직": TEN}])
|
||||
with store.at(world["personal"]):
|
||||
line = {"참조": {**SYS, "키": sp}}
|
||||
line = {"참조": {**SYS, "키": sc}}
|
||||
store.write("labor", "x1", "", {"열": "x1", "담은로직": [line], "자재": []})
|
||||
with store.at(world["system"]), pytest.raises(store.StoreError) as e:
|
||||
store.delete("promote", sp)
|
||||
store.delete("precombo", sc)
|
||||
assert e.value.status == 409 and "개인 7/42" in e.value.detail
|
||||
(world["personal"] / "labor" / "x1.json").unlink()
|
||||
with store.at(world["system"]):
|
||||
store.delete("promote", sp)
|
||||
assert not (world["system"] / "promote" / f"{sp}.json").exists()
|
||||
store.delete("precombo", sc)
|
||||
assert not (world["system"] / "precombo" / f"{sc}.json").exists()
|
||||
|
||||
|
||||
def test_복사는_못_읽는_참조를_같이_복사_본뜬(world):
|
||||
sp = _promote(world["personal"])
|
||||
inner = _combo(world["personal"], [{"로직": TEN}], "안")
|
||||
sys_sp = _promote(world["system"])
|
||||
sc = _combo(
|
||||
world["personal"],
|
||||
[{"참조": {**MINE, "키": sp}}, {"참조": {**SYS, "키": sys_sp}, "수량": {"손": 2}}],
|
||||
[{"참조": {**MINE, "키": inner}}, {"참조": {**SYS, "키": sys_sp}, "수량": {"손": 2}}],
|
||||
)
|
||||
with store.at(world["personal"]):
|
||||
doc = store.read("precombo", sc)["문서"]
|
||||
@@ -248,10 +315,15 @@ def test_복사는_못_읽는_참조를_같이_복사_본뜬(world):
|
||||
got = _read(world["company"], "precombo", made["이름"])
|
||||
assert got["본뜬"] == {**MINE, "키": sc}
|
||||
assert [one["참조"] for one in got["담은로직"]] == [
|
||||
{**COMPANY, "키": "SP-000001"}, # 회사가 못 읽는 개인 승격 → 회사 층 새 키
|
||||
{**SYS, "키": sys_sp}, # 마스터는 그대로
|
||||
{**COMPANY, "키": "SC-000001"}, # 회사가 못 읽는 개인 사전조합 → 회사 층 새 키
|
||||
{**SYS, "키": sys_sp}, # 마스터는 그대로(옛 승격도)
|
||||
]
|
||||
assert _read(world["company"], "promote", "SP-000001")["본뜬"] == {**MINE, "키": sp}
|
||||
assert _read(world["company"], "precombo", "SC-000001")["본뜬"] == {**MINE, "키": inner}
|
||||
# 못 읽는 옛 개인 승격은 따라 복사 안 함(134-2 · 승격 서고 없음)
|
||||
mine_sp = _promote(world["personal"])
|
||||
with store.at(world["company"]), pytest.raises(store.StoreError) as e:
|
||||
refs.carry({"담은로직": [{"참조": {**MINE, "키": mine_sp}}]}, ("personal", "7/42"))
|
||||
assert e.value.status == 400
|
||||
|
||||
|
||||
def test_길_새로_복사_권한(world):
|
||||
@@ -271,14 +343,15 @@ def test_길_새로_복사_권한(world):
|
||||
}
|
||||
client = TestClient(app)
|
||||
url = "/api/m02/layers/{}/library/{}"
|
||||
body = {"문서": {"담은로직": [{"로직": TEN}]}}
|
||||
got = client.post(url.format("personal", "promote"), json=body).json()
|
||||
assert got["이름"] == "SP-000001"
|
||||
assert client.post(url.format("system", "promote"), json=body).status_code == 403
|
||||
copy = {"본뜬": {**MINE, "키": "SP-000001"}}
|
||||
assert client.post(url.format("personal", "promote"), json=copy).json()["이름"] == "SP-000002"
|
||||
other = {"본뜬": {"층": "personal", "주인": "7/99", "키": "SP-000001"}}
|
||||
assert client.post(url.format("personal", "promote"), json=other).status_code == 403
|
||||
body = {"문서": {"이름": "가", "담은로직": [{"로직": TEN}]}}
|
||||
got = client.post(url.format("personal", "precombo"), json=body).json()
|
||||
assert got["이름"] == "SC-000001"
|
||||
assert client.post(url.format("system", "precombo"), json=body).status_code == 403
|
||||
copy = {"본뜬": {**MINE, "키": "SC-000001"}}
|
||||
assert client.post(url.format("personal", "precombo"), json=copy).json()["이름"] == "SC-000002"
|
||||
other = {"본뜬": {"층": "personal", "주인": "7/99", "키": "SC-000001"}}
|
||||
assert client.post(url.format("personal", "precombo"), json=other).status_code == 403
|
||||
assert client.post(url.format("personal", "promote"), json=body).status_code == 422 # 134-2
|
||||
|
||||
|
||||
def test_프로젝트는_참조사본으로_굳힘_원본을_지워도_같은_값(world):
|
||||
@@ -305,7 +378,7 @@ def test_프로젝트는_참조사본으로_굳힘_원본을_지워도_같은_
|
||||
assert line["수량"] == 1 and line["반영"]["계"] == pytest.approx(live, rel=1e-9)
|
||||
|
||||
|
||||
def test_로직_지우기_검사는_단가승격도_보고_참조_줄은_건너뜀(world):
|
||||
def test_로직_지우기_검사는_승격_줄도_보고_참조_줄은_건너뜀(world):
|
||||
from M01_MasterData import M01_MasterData_Store_Cache as cache
|
||||
|
||||
sp = _promote(world["system"], STONE)
|
||||
@@ -319,8 +392,14 @@ def test_로직_지우기_검사는_단가승격도_보고_참조_줄은_건너
|
||||
if code == STONE:
|
||||
raise cache.cm.mf.FormulaError("없음")
|
||||
|
||||
assert cache.labor_errors(Gone()) == [] # 옛 승격 서고는 안 봄(134-2) · 참조 줄 건너뜀
|
||||
with store.at(world["system"]):
|
||||
have = store.read("labor", "rv")
|
||||
line = {"로직": STONE, "승격": True}
|
||||
store.write("labor", "rv", have["판"], {**have["문서"], "담은로직": [line]})
|
||||
number = store.read("labor", "rv")["문서"]["일위대가번"]
|
||||
got = cache.labor_errors(Gone())
|
||||
assert got == [f"단가승격 마스터 「{sp}」 이 로직 「{STONE}」 을 씀 — 거기서 먼저 뺌"]
|
||||
assert got == [f"일위대가 마스터 「{number}」 이 로직 「{STONE}」 을 씀 — 거기서 먼저 뺌"]
|
||||
|
||||
|
||||
def test_새_프로젝트_씨앗은_서고를_안_가져감(world, tmp_path):
|
||||
@@ -332,16 +411,11 @@ def test_새_프로젝트_씨앗은_서고를_안_가져감(world, tmp_path):
|
||||
def test_층_목록_서고_줄은_이름_단위_기준금액을_실음(world):
|
||||
sp = _promote(world["system"])
|
||||
sc = _combo(world["system"], [{"참조": {**SYS, "키": sp}, "수량": {"손": 3}}], "셋")
|
||||
rows = [
|
||||
{"종류": "promote", "이름": sp},
|
||||
{"종류": "precombo", "이름": sc},
|
||||
{"종류": "labor", "이름": "rv"},
|
||||
]
|
||||
rows = [{"종류": "precombo", "이름": sc}, {"종류": "labor", "이름": "rv"}]
|
||||
refs.summaries(world["system"], rows)
|
||||
one = float(Decimal(str(_base(TEN)["계"])) / 10)
|
||||
assert rows[0]["기준금액"] == pytest.approx(one)
|
||||
assert (rows[1]["제목"], rows[1]["기준금액"]) == ("셋", pytest.approx(one * 3))
|
||||
assert "기준금액" not in rows[2]
|
||||
assert (rows[0]["제목"], rows[0]["기준금액"]) == ("셋", pytest.approx(one * 3))
|
||||
assert "기준금액" not in rows[1]
|
||||
|
||||
|
||||
def test_사전조합_로직_줄_섞임_합은_손셈_지우기_409_굳힘(world):
|
||||
@@ -370,9 +444,6 @@ def test_사전조합_로직_줄_섞임_합은_손셈_지우기_409_굳힘(world
|
||||
assert one["기준"]["계"] == pytest.approx(float(want))
|
||||
assert one["반영"]["계"] == pytest.approx(float(want * 5))
|
||||
assert [x.get("로직") or x["참조"]["키"] for x in one["안"]] == [sp, STONE, TEN]
|
||||
with store.at(world["system"]), pytest.raises(store.StoreError) as e:
|
||||
store.delete("promote", sp)
|
||||
assert e.value.status == 409 and "사전조합 마스터 「섞음」" in e.value.detail
|
||||
|
||||
class Gone: # STONE 을 지운 마스터
|
||||
def logic(self, code):
|
||||
@@ -409,15 +480,8 @@ def test_사전조합이_자기_자신을_담으면_422_수량_기본_1(world):
|
||||
assert e.value.status == 422 and "자기 자신" in e.value.detail
|
||||
|
||||
|
||||
def test_서고_구분_상세구분_연결_승격은_원_로직_사전조합은_고른_값_옛_문서는_읽을_때(world):
|
||||
import M01_MasterData.M01_MasterData_Store as m01
|
||||
|
||||
row = m01.loaded()[1].logic(TEN)
|
||||
sp = _promote(world["system"], 구분="틀림", 상세구분="틀림", 연결={"로직": "x"})
|
||||
doc = _read(world["system"], "promote", sp)
|
||||
assert row["구분"] and row["상세구분"]
|
||||
assert (doc["구분"], doc["상세구분"]) == (row["구분"], row["상세구분"]) # 화면 값 안 받음
|
||||
assert doc["연결"] == {"로직": TEN, "출처": row.get("출처") or ""}
|
||||
def test_서고_구분_상세구분_사전조합은_고른_값_옛_문서는_읽을_때(world):
|
||||
sp = _promote(world["system"])
|
||||
sc = _combo(world["system"], [{"참조": {**SYS, "키": sp}}])
|
||||
assert (_read(world["system"], "precombo", sc)["구분"], "") == ("", "")
|
||||
with store.at(world["system"]):
|
||||
@@ -425,12 +489,10 @@ def test_서고_구분_상세구분_연결_승격은_원_로직_사전조합은_
|
||||
store.write("precombo", sc, have["판"], {**have["문서"], "구분": " 가 ", "상세구분": "나"})
|
||||
got = _read(world["system"], "precombo", sc)
|
||||
assert (got["구분"], got["상세구분"]) == ("가", "나")
|
||||
# 옛 문서(칸 없음) — 승격은 원 로직 것 · 사전조합은 빈 글
|
||||
old = {k: v for k, v in doc.items() if k not in ("구분", "상세구분", "연결")}
|
||||
(world["system"] / "promote" / f"{sp}.json").write_text(json.dumps(old), encoding="utf-8")
|
||||
assert refs.filled("promote", old)["연결"] == doc["연결"]
|
||||
# 옛 문서(칸 없음) — 사전조합은 빈 글 · 옛 승격은 손대지 않음
|
||||
old = _read(world["system"], "promote", sp)
|
||||
assert refs.filled("promote", old) is old
|
||||
assert refs.filled("precombo", {"담은로직": []})["구분"] == ""
|
||||
rows = [{"종류": "promote", "이름": sp}, {"종류": "precombo", "이름": sc}]
|
||||
rows = [{"종류": "precombo", "이름": sc}]
|
||||
refs.summaries(world["system"], rows)
|
||||
assert (rows[0]["구분"], rows[0]["상세구분"]) == (row["구분"], row["상세구분"])
|
||||
assert (rows[1]["구분"], rows[1]["상세구분"], rows[1]["연결"]) == ("가", "나", None)
|
||||
assert (rows[0]["구분"], rows[0]["상세구분"], rows[0]["연결"]) == ("가", "나", None)
|
||||
|
||||
@@ -57,9 +57,9 @@ def test_좌측_하위_컨테이너와_전체_항목_길_없음():
|
||||
|
||||
def test_층_단추는_구조물_조합_한_벌():
|
||||
page = _text("M02_MasterTemplete_UI_Page.ts")
|
||||
# 구조물 조합 줄 아래 하나 + 일위대가 조합 서고(50-3) 하나
|
||||
assert page.count('layerBar("') == 2
|
||||
assert 'layerBar("일위대가조합", null, () => undefined)' in page
|
||||
# 구조물 조합 줄 아래 하나 · 일위대가 사전조합은 층 따로(134-3 · `preBar`)
|
||||
assert page.count('layerBar("') == 1
|
||||
assert "const preBar = (): HTMLElement =>" in page
|
||||
assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page
|
||||
|
||||
|
||||
|
||||
@@ -86,7 +86,9 @@ def test_주소_인자를_라우트에서_뗌():
|
||||
layer = _text(LAYER)
|
||||
# 진입 해시 `?open=&root=&owner=`(PLAN 27-1d) · 옛 `?layer=` 도 root 로
|
||||
assert 'query.get("root") ?? query.get("layer")' in layer
|
||||
assert 'raw === "company" || raw === "personal" ? raw : "master"' in layer
|
||||
assert (
|
||||
'raw === "master" || raw === "company" || raw === "personal"' in layer
|
||||
) # 없음 = 기본(134-4)
|
||||
assert '["구조물조합", "옵션", "도면", "일위대가조합"]' in layer # 일위대가 조합 서고 = 50-3
|
||||
picker = _text(ROOT / "ui_template" / "ui_template_item_picker.ts")
|
||||
assert 'open: "구조물조합", root: "personal"' in picker and "layer=personal" not in picker
|
||||
@@ -201,7 +203,7 @@ def test_층_단추_셋_남의_회사_사람_나무():
|
||||
assert "...(opt.canEdit || canCopy ? { menu: () => menuFor(target, id) } : {})" in panel
|
||||
assert "const got = opt.canEdit && api.paste ? clipOf(clipKind) : null;" in panel
|
||||
api = _text(M02 / "M02_MasterTemplete_Api_Fetch.ts")
|
||||
assert "owner=${enc(activeOwner)}" in api and "/owners" in api
|
||||
assert "owner=${enc(who)}" in api and "/owners" in api # 134-3 — 명시 owner 도 같은 자리
|
||||
|
||||
|
||||
def test_층_길_접두어와_고정_풀림():
|
||||
@@ -215,7 +217,10 @@ def test_층_길_접두어와_고정_풀림():
|
||||
assert '"system"' not in _text(M02 / "M02_MasterTemplete_Structure.ts")
|
||||
# 열 조합 읽기(옛 Combo_Api · 39-4 D3 지움) 자리 = 열마다 일위대가 문서 — 층을 받아 부름
|
||||
labor = _text(M02 / "M02_MasterTemplete_Labor_Api.ts")
|
||||
assert "const laborAt = (layer: Layer, column: string, tail: string): string =>" in labor
|
||||
assert (
|
||||
"const laborAt = (layer: Layer, column: string, tail: string, who?: string | null): string =>"
|
||||
in labor
|
||||
)
|
||||
side = _text(M02 / "M02_MasterTemplete_UI_Side.ts")
|
||||
assert "const layer = opt.layer;" in side
|
||||
assert 'layer === "system"' in side # 도면 양식은 시스템 층에만
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""일위대가 사전조합 구분 · 상세구분 새 분류(PLAN 134-5) — 구조물군 A~G › 그 군 종류 + 「공통」.
|
||||
|
||||
- 원천 = 마스터 `구조물정의.json`(구조물군 · types) — 군마다 첫 상세구분 「공통」 · 꺼진 종류도 · 「호환(기타)」 뺌
|
||||
- 옛 품셈 분류(건설품셈 공통 · 산림품셈) = 읽을 때 빈 값(미분류) · 파일 안 고침 · 새 분류 값 · 지금 값은 그대로
|
||||
- 새 문서 = 빈 값(미분류) · 상세 드롭다운 원천 = 구조물 정의(로직 구분 목록 아님)
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = Path(HERE).parents[1]
|
||||
M02 = ROOT / "M02_MasterTemplete"
|
||||
|
||||
|
||||
def test_분류는_구조물군_종류_공통():
|
||||
defn = json.loads((ROOT / "resources/master_template/구조물정의.json").read_text("utf-8"))
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_m02_precombo_scope.cjs")],
|
||||
input=json.dumps({"rows": [], "def": defn}, ensure_ascii=False),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
check=True,
|
||||
)
|
||||
classes = json.loads(proc.stdout)["classes"]
|
||||
groups = {k: v for k, v in defn["구조물군"].items() if k in "ABCDEFG"}
|
||||
assert [c["name"] for c in classes] == list(
|
||||
groups.values()
|
||||
) # A 횡단배수 … G 노면공 · 기타 없음
|
||||
c = next(c for c in classes if c["name"] == groups["C"])
|
||||
assert c["details"] == [
|
||||
"공통",
|
||||
"옹벽",
|
||||
"돌쌓기(찰)",
|
||||
"돌쌓기(메)",
|
||||
"흙막이",
|
||||
"큰돌쌓기",
|
||||
"소단",
|
||||
]
|
||||
b = next(c for c in classes if c["name"] == groups["B"])
|
||||
assert "측구(옆도랑)" in b["details"] # 꺼진 종류도 넣음
|
||||
assert all(
|
||||
c["details"][0] == "공통" and len(set(c["details"])) == len(c["details"]) for c in classes
|
||||
)
|
||||
|
||||
|
||||
def test_옛_품셈_분류는_미분류_새_값은_그대로():
|
||||
old = {"이름": "가", "구분": "건설품셈 공통", "상세구분": "07장 돌공사", "담은로직": []}
|
||||
got = refs.filled("precombo", old)
|
||||
assert (got["구분"], got["상세구분"]) == ("", "") and old["구분"] == "건설품셈 공통"
|
||||
assert refs.filled("precombo", {**old, "구분": "산림품셈", "상세구분": "03장"})["구분"] == ""
|
||||
now = {**old, "구분": "C 사면안정", "상세구분": "옹벽"}
|
||||
assert refs.filled("precombo", now) is now
|
||||
gone = {**old, "구분": "Z 없어진 군", "상세구분": "x"} # 지금 값은 선택지로(화면 몫)
|
||||
assert refs.filled("precombo", gone) is gone
|
||||
assert refs.filled("precombo", {"담은로직": []})["구분"] == "" # 칸 없는 옛 문서
|
||||
|
||||
|
||||
def test_화면_원천은_구조물_정의_새_문서는_미분류():
|
||||
detail = (M02 / "M02_MasterTemplete_Labor_Detail.ts").read_text("utf-8")
|
||||
assert "subs = precomboClasses(def);" in detail and "fetchLibLogicSubs" not in detail
|
||||
assert '[...new Set([now ?? "", ...names])]' in detail # 지금 값도 선택지 그대로
|
||||
lib = (M02 / "M02_MasterTemplete_Library_List.ts").read_text("utf-8")
|
||||
assert '구분: "", 상세구분: ""' in lib and "fetchLibLogicSubs" not in lib
|
||||
@@ -0,0 +1,88 @@
|
||||
"""일위대가 사전조합 층 따로(PLAN 134-3) — 페이지 층과 따로 · 명시 owner · 2단 폴더 나무.
|
||||
|
||||
- 서버 길: 명시 owner 를 넘기면 그 주인만(null = 자기 층) — 남의 개인을 보는 중에도 사전조합 길에 안 섞임 ·
|
||||
안 넘기면 지금처럼 페이지 층 주인(Node · fetch 대역)
|
||||
- 나무: 구분 › 상세구분 2단 · 빈 구분 = 미분류 맨 뒤 · 빈 상세구분 = 구분 바로 아래
|
||||
- 사전조합 층 단추 = 해시를 바꿔 페이지를 다시 세우지 않음(replaceState · 그 컨테이너만 다시 그림) ·
|
||||
구조물 조합 층 단추는 그대로 해시 이동 · 주소 `pre` 에 남음 · 고름에 owner · canWrite
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
M02 = Path(HERE).parents[1] / "M02_MasterTemplete"
|
||||
|
||||
|
||||
def _text(name: str) -> str:
|
||||
return (M02 / name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _run(rows):
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_m02_precombo_scope.cjs")],
|
||||
input=json.dumps({"rows": rows}, ensure_ascii=False),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
check=True,
|
||||
)
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_명시_owner_길은_페이지_층_주인과_안_섞임():
|
||||
urls = _run([])["urls"]
|
||||
assert urls[0] == "/api/m02/layers/personal/templates/labor/x1?owner=7%2F99"
|
||||
assert urls[1] == "/api/m02/layers/personal/templates/precombo/SC-000001"
|
||||
assert urls[2] == "/api/m02/layers/company/templates?owner=8"
|
||||
assert urls[3] == "/api/m02/layers/system/templates/precombo/SC-000002"
|
||||
|
||||
|
||||
def test_나무는_구분_상세구분_2단_미분류는_맨_뒤():
|
||||
rows = [
|
||||
{"이름": "SC-000003", "종류": "precombo", "구분": "C 사면안정", "상세구분": "옹벽"},
|
||||
{"이름": "SC-000001", "종류": "precombo", "구분": "", "상세구분": "07장"},
|
||||
{"이름": "SC-000002", "종류": "precombo", "구분": "A 횡단배수", "상세구분": ""},
|
||||
{"이름": "SC-000004", "종류": "precombo", "구분": "C 사면안정", "상세구분": "돌쌓기"},
|
||||
{"이름": "SC-000005", "종류": "precombo", "구분": "A 횡단배수", "상세구분": "공통"},
|
||||
]
|
||||
groups = _run(rows)["groups"]
|
||||
assert [g["구분"] for g in groups] == ["A 횡단배수", "C 사면안정", "미분류"]
|
||||
assert groups[0]["상세"] == [["공통", ["SC-000005"]], ["", ["SC-000002"]]]
|
||||
assert groups[1]["상세"] == [["돌쌓기", ["SC-000004"]], ["옹벽", ["SC-000003"]]]
|
||||
assert groups[2]["상세"] == [["", ["SC-000001"]]] # 구분 없으면 상세구분도 안 봄
|
||||
|
||||
|
||||
def test_사전조합_층_단추는_페이지를_다시_안_세움():
|
||||
page = _text("M02_MasterTemplete_UI_Page.ts")
|
||||
pre = page.split("const setPre = (want: Root): void => {")[1].split("};")[0]
|
||||
assert "history.replaceState(" in pre and "side.refreshPrecombo()" in pre
|
||||
assert "navigateTo" not in pre and "setActiveLayer" not in pre # 페이지 층 · 해시 이동 없음
|
||||
assert "entry.pre = pre.root;" in pre # 구조물 조합 층 이동(go) 때도 주소에 남음
|
||||
bar = page.split("const preBar = (): HTMLElement =>")[1].split("const side = buildSide(")[0]
|
||||
assert "go: (next) => setPre(next)," in bar and "main.open(precomboList(pre))" in bar
|
||||
# 구조물 조합 층 단추는 그대로 해시 이동 한 벌
|
||||
assert page.count('layerBar("') == 1
|
||||
assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page
|
||||
assert "precombo: { scope: () => pre, bar: preBar }," in page
|
||||
|
||||
|
||||
def test_고름에_owner_권한_서고_길에_명시_owner():
|
||||
side = _text("M02_MasterTemplete_UI_Side_Precombo.ts")
|
||||
assert 'export const PRECOMBO_TITLE = "일위대가 사전조합";' in side
|
||||
assert "owner: scope.owner," in side and "canWrite: scope.canWrite," in side
|
||||
assert "listTemplates(at.layer, null, at.owner)" in side
|
||||
main = _text("M02_MasterTemplete_UI_Main.ts")
|
||||
assert (
|
||||
"const writable = (at: Selection): boolean => at.canWrite ?? canEdit(at.layer, isAdmin);"
|
||||
in main
|
||||
)
|
||||
assert "readTemplate(next.layer, next.kind, next.name, next.projectId, next.owner)" in main
|
||||
assert "at.owner,\n );" in main # saveTemplate
|
||||
assert "owner: next.owner ?? null," in main and "mode, owner: at.owner ?? null" in main
|
||||
lib = _text("M02_MasterTemplete_Library_List.ts")
|
||||
assert "getActiveOwner" not in lib and lib.count("opt.owner") >= 6
|
||||
detail = _text("M02_MasterTemplete_Labor_Detail.ts")
|
||||
assert "opt.owner !== undefined" in detail
|
||||
Reference in New Issue
Block a user