Merge remote-tracking branch 'origin/dev' into sub_laptop_3
This commit is contained in:
@@ -27,6 +27,7 @@ import type { RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History";
|
||||
import {
|
||||
choiceLabel,
|
||||
DEFAULT_WEIGHTS,
|
||||
NO_WEIGHT_LOCKS,
|
||||
initialRouteSnapshot,
|
||||
normalizeRouteInitial,
|
||||
readInitialChoice,
|
||||
@@ -85,6 +86,11 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
|
||||
let opening: RouteEditSnapshot | null = null;
|
||||
let edited = false;
|
||||
let busy = false;
|
||||
/** 비중 · 고정 — 모달을 닫았다 열어도 이 편집 창이 사는 동안 남는다. */
|
||||
const weightMemory = {
|
||||
weights: null as RouteInitialWeights | null,
|
||||
locks: { ...NO_WEIGHT_LOCKS },
|
||||
};
|
||||
/** `/route/plan` 한 번 읽은 것 — 예상노선(지도 바탕) · 초기값 노선([초기화]). 창이 사는 동안 안 바뀐다. */
|
||||
let planRead: Promise<RoutePlanResponse> | null = null;
|
||||
|
||||
@@ -130,6 +136,7 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
|
||||
criteria: choice?.criteriaOverrides ?? base.criteria,
|
||||
defaults,
|
||||
weights: { ...(choice?.weights ?? base.weights ?? DEFAULT_WEIGHTS) },
|
||||
weightMemory,
|
||||
planMinRadiusM: params.planMinRadiusM(),
|
||||
expected: (plan?.expected ?? []).map(([x, y]) => [x, y]),
|
||||
current: opening!.planned.map(([x, y]) => [x, y]),
|
||||
|
||||
@@ -51,6 +51,68 @@ export function rebalanceWeights(
|
||||
return { expected: out.expected, earthwork: out.earthwork, grade: out.grade };
|
||||
}
|
||||
|
||||
/** 비중마다 고정 표시 — 고정한 값은 다른 비중을 움직여도 안 바뀐다. */
|
||||
export type WeightLocks = Record<WeightKey, boolean>;
|
||||
|
||||
export const NO_WEIGHT_LOCKS: WeightLocks = { expected: false, earthwork: false, grade: false };
|
||||
|
||||
const lockedKeys = (locks: WeightLocks): WeightKey[] => WEIGHT_KEYS.filter((name) => locks[name]);
|
||||
|
||||
/** 나머지 둘이 고정돼 저절로 정해지는 비중 — 없으면 null. 이 비중은 잠김으로 보인다. */
|
||||
export function derivedWeightKey(locks: WeightLocks): WeightKey | null {
|
||||
const free = WEIGHT_KEYS.filter((name) => !locks[name]);
|
||||
return free.length === 1 ? free[0] : null;
|
||||
}
|
||||
|
||||
/** 이 비중의 고정 단추를 새로 누를 수 있나 — 셋 다 고정은 막는다(마지막 하나는 저절로 잠김).
|
||||
* 이미 고정한 것을 푸는 것은 늘 된다. */
|
||||
export function canLockWeight(locks: WeightLocks, key: WeightKey): boolean {
|
||||
return locks[key] || lockedKeys(locks).length < 2;
|
||||
}
|
||||
|
||||
/** 슬라이더로 한 비중을 옮긴다 — 고정한 것은 그대로, 안 고정한 것끼리 합 100 을 맞춘다.
|
||||
* 고정하지 않은 것이 셋이면 나머지 둘이 지금 비율대로(`rebalanceWeights`), 둘이면 다른 하나가 남는 몫을
|
||||
* 받고, 이 비중 혼자면(나머지 둘 고정) 값이 정해져 있어 안 움직인다. 값은 남는 몫 안으로 잘린다. */
|
||||
export function moveWeight(
|
||||
weights: RouteInitialWeights,
|
||||
locks: WeightLocks,
|
||||
key: WeightKey,
|
||||
value: number,
|
||||
): RouteInitialWeights {
|
||||
if (locks[key]) return { ...weights };
|
||||
const fixedSum = lockedKeys(locks).reduce((sum, name) => sum + weights[name], 0);
|
||||
const room = 100 - fixedSum;
|
||||
const free = WEIGHT_KEYS.filter((name) => !locks[name]);
|
||||
if (free.length === WEIGHT_KEYS.length) return rebalanceWeights(weights, key, value);
|
||||
const out = { ...weights };
|
||||
if (free.length === 1) {
|
||||
out[key] = room;
|
||||
return out;
|
||||
}
|
||||
const other = free.find((name) => name !== key)!;
|
||||
const next = Math.min(room, Math.max(0, Math.round(Number.isFinite(value) ? value : 0)));
|
||||
out[key] = next;
|
||||
out[other] = room - next;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 값 칸에 직접 넣은 숫자 — 0~100 정수이고 고정 규칙 아래 합 100 을 맞출 수 있을 때만 받는다.
|
||||
* 못 맞추면 `ok: false`(칸에 빨간 테두리 · 서버에 안 보냄). 고정한 비중은 고칠 수 없다. */
|
||||
export function inputWeight(
|
||||
weights: RouteInitialWeights,
|
||||
locks: WeightLocks,
|
||||
key: WeightKey,
|
||||
value: number,
|
||||
): { ok: boolean; weights: RouteInitialWeights } {
|
||||
const same = { ok: false, weights: { ...weights } };
|
||||
if (locks[key] || !Number.isInteger(value) || value < 0 || value > 100) return same;
|
||||
const room = 100 - lockedKeys(locks).reduce((sum, name) => sum + weights[name], 0);
|
||||
const free = WEIGHT_KEYS.filter((name) => !locks[name]);
|
||||
if (free.length === 1) return value === room ? { ok: true, weights: { ...weights } } : same;
|
||||
if (value > room) return same;
|
||||
return { ok: true, weights: moveWeight(weights, locks, key, value) };
|
||||
}
|
||||
|
||||
/** 결과 비교값 — 서버가 셈해 준 것을 보이기만 한다. 없는 칸은 null. */
|
||||
export interface InitialMetrics {
|
||||
lengthM: number | null;
|
||||
|
||||
@@ -24,7 +24,10 @@ import {
|
||||
CRITERIA_KEYS,
|
||||
LOGIC_LABEL,
|
||||
metricsLine,
|
||||
rebalanceWeights,
|
||||
canLockWeight,
|
||||
derivedWeightKey,
|
||||
inputWeight,
|
||||
moveWeight,
|
||||
relaxedLine,
|
||||
violationsLine,
|
||||
violationText,
|
||||
@@ -33,6 +36,7 @@ import {
|
||||
type InitialChoice,
|
||||
type InitialCriteria,
|
||||
type MapLayer,
|
||||
type WeightLocks,
|
||||
type WeightKey,
|
||||
} from "./B05_Profile_UI_RouteEdit_Initial_Model";
|
||||
import { createRecommendMap, RECOMMEND_MAP_HTML } from "./B05_Profile_UI_RouteEdit_Recommend_Map";
|
||||
@@ -55,6 +59,11 @@ const WEIGHT_NAME: Record<WeightKey, string> = {
|
||||
grade: "기울기",
|
||||
};
|
||||
|
||||
/** 고정 단추 그림 — 자물쇠(글자 색을 따라 밝음 · 어두움에서 보인다). */
|
||||
const LOCK_ICON = `<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" fill="none"
|
||||
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3.5" y="7" width="9" height="6.5" rx="1.2" /><path d="M5.5 7V5a2.5 2.5 0 0 1 5 0v2" /></svg>`;
|
||||
|
||||
/** 공통 제약 칸 — 표시 이름 · 단위 · 서버가 준 값(`InitialCriteria`)에서 그 칸을 꺼내는 법. */
|
||||
const CRITERIA_FIELD: Record<
|
||||
CriteriaKey,
|
||||
@@ -92,6 +101,8 @@ export interface RecommendParams {
|
||||
/** 칸을 비운 채 처음 물었을 때 서버가 답한 값 — 빈 칸 placeholder. 여기서 채워 간다. */
|
||||
defaults: Partial<Record<CriteriaKey, number>>;
|
||||
weights: RouteInitialWeights;
|
||||
/** 비중 · 고정 상태 — 편집 창이 사는 동안 모달을 닫았다 열어도 남는다. 여기서 고쳐 간다. */
|
||||
weightMemory: { weights: RouteInitialWeights | null; locks: WeightLocks };
|
||||
planMinRadiusM: number;
|
||||
/** 지도 바탕 — 예상노선(원본 점 묶음) · 창을 연 노선(노드). */
|
||||
expected: Vertex[];
|
||||
@@ -108,7 +119,10 @@ export interface RecommendParams {
|
||||
|
||||
export function openRecommendModal(params: RecommendParams): void {
|
||||
let logic: RouteInitialLogic = "follow";
|
||||
let weights: RouteInitialWeights = { ...params.weights };
|
||||
const { locks } = params.weightMemory;
|
||||
let weights: RouteInitialWeights = { ...(params.weightMemory.weights ?? params.weights) };
|
||||
/** 값 칸에 넣은 숫자가 고정 규칙 아래 합 100 을 못 맞추는 비중 — 빨간 테두리 · 서버에 안 보냄. */
|
||||
const invalid = new Set<WeightKey>();
|
||||
let preview: InitialChoice | null = null;
|
||||
let busy = false;
|
||||
/** 마지막 미리 보기 뒤 슬라이더 · 제약 칸을 만졌다 — 미리 본 노선이 칸 값과 다르다. */
|
||||
@@ -156,12 +170,14 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
<div class="b05-routerec__weights" hidden>
|
||||
<div class="b05-routerec__bars">
|
||||
${WEIGHT_KEYS.map(
|
||||
(
|
||||
key,
|
||||
) => `<label class="b05-routerec__bar"><output data-weight-out="${key}"></output>
|
||||
(key) => `<div class="b05-routerec__bar">
|
||||
<input type="number" class="b05-routerec__num" min="0" max="100" step="1"
|
||||
inputmode="numeric" data-weight-num="${key}" aria-label="${WEIGHT_NAME[key]} 비중 값" />
|
||||
<input type="range" min="0" max="100" step="1" data-weight="${key}"
|
||||
aria-label="${WEIGHT_NAME[key]} 비중" />
|
||||
<span>${WEIGHT_NAME[key]}</span></label>`,
|
||||
<button type="button" class="b05-routerec__lock" data-weight-lock="${key}"
|
||||
aria-pressed="false" aria-label="${WEIGHT_NAME[key]} 비중 고정">${LOCK_ICON}</button>
|
||||
<span>${WEIGHT_NAME[key]}</span></div>`,
|
||||
).join("")}
|
||||
</div>
|
||||
<span class="b05-routerec__sum" data-weight-sum></span>
|
||||
@@ -232,9 +248,31 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
pill.disabled = busy;
|
||||
});
|
||||
weightsBox.hidden = logic !== "weighted";
|
||||
const derived = derivedWeightKey(locks);
|
||||
for (const key of WEIGHT_KEYS) {
|
||||
q<HTMLInputElement>(`[data-weight="${key}"]`).value = String(weights[key]);
|
||||
q<HTMLOutputElement>(`[data-weight-out="${key}"]`).value = `${weights[key]}`;
|
||||
const slider = q<HTMLInputElement>(`[data-weight="${key}"]`);
|
||||
const num = q<HTMLInputElement>(`[data-weight-num="${key}"]`);
|
||||
const lock = q<HTMLButtonElement>(`[data-weight-lock="${key}"]`);
|
||||
// 고정했거나 나머지 둘이 고정돼 저절로 정해진 비중은 잠김 — 움직이지 않는다.
|
||||
const shut = locks[key] || derived === key;
|
||||
slider.value = String(weights[key]);
|
||||
slider.disabled = busy || shut;
|
||||
// 넣다 만 틀린 글은 지우지 않는다 — 빨간 테두리로 남겨 고치게 한다.
|
||||
if (!invalid.has(key)) num.value = String(weights[key]);
|
||||
num.disabled = busy || shut;
|
||||
num.classList.toggle("is-invalid", invalid.has(key));
|
||||
num.setAttribute("aria-invalid", String(invalid.has(key)));
|
||||
lock.setAttribute("aria-pressed", String(shut));
|
||||
lock.classList.toggle("is-on", shut);
|
||||
lock.classList.toggle("is-auto", derived === key);
|
||||
// 셋 다 고정은 막는다 — 마지막 하나는 고정 단추가 꺼지고 잠김으로만 보인다.
|
||||
lock.disabled = busy || !canLockWeight(locks, key) || derived === key;
|
||||
lock.title =
|
||||
derived === key
|
||||
? "나머지 둘이 고정돼 저절로 정해집니다"
|
||||
: locks[key]
|
||||
? "고정 풀기"
|
||||
: "이 값 고정 — 다른 비중을 움직여도 안 바뀝니다";
|
||||
}
|
||||
q<HTMLElement>("[data-weight-sum]").textContent =
|
||||
`합 ${weights.expected + weights.earthwork + weights.grade}`;
|
||||
@@ -245,8 +283,8 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
input.disabled = busy;
|
||||
});
|
||||
rerun.hidden = !dirty;
|
||||
rerun.disabled = busy;
|
||||
applyButton.disabled = busy || dirty || !preview;
|
||||
rerun.disabled = busy || invalid.size > 0;
|
||||
applyButton.disabled = busy || dirty || !preview || invalid.size > 0;
|
||||
line("metrics").textContent = preview
|
||||
? preview.metrics
|
||||
? metricsLine(preview.metrics)
|
||||
@@ -269,6 +307,10 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
}
|
||||
|
||||
async function run(): Promise<void> {
|
||||
if (logic === "weighted" && invalid.size > 0) {
|
||||
showToast("비중 값이 합 100 을 못 맞춥니다 — 빨간 칸을 고쳐 주세요.", "error");
|
||||
return;
|
||||
}
|
||||
const mine = ++seq;
|
||||
const sentWeights = logic === "weighted" ? { ...weights } : null;
|
||||
const criteria = currentCriteria();
|
||||
@@ -314,17 +356,59 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
void run();
|
||||
});
|
||||
});
|
||||
const remember = (): void => {
|
||||
params.weightMemory.weights = { ...weights };
|
||||
};
|
||||
body.querySelectorAll<HTMLInputElement>("[data-weight]").forEach((input) => {
|
||||
input.addEventListener("input", () => {
|
||||
weights = rebalanceWeights(
|
||||
weights = moveWeight(
|
||||
weights,
|
||||
locks,
|
||||
input.dataset.weight as WeightKey,
|
||||
Number(input.value),
|
||||
);
|
||||
invalid.clear();
|
||||
remember();
|
||||
dirty = preview !== null;
|
||||
render();
|
||||
});
|
||||
});
|
||||
// 값 칸 — 0~100 정수 · 고정 규칙 아래 합 100 을 못 맞추면 받지 않고 빨간 테두리.
|
||||
body.querySelectorAll<HTMLInputElement>("[data-weight-num]").forEach((input) => {
|
||||
input.addEventListener("input", () => {
|
||||
const key = input.dataset.weightNum as WeightKey;
|
||||
const raw = input.value.trim();
|
||||
const result = inputWeight(weights, locks, key, raw === "" ? Number.NaN : Number(raw));
|
||||
if (result.ok) {
|
||||
weights = result.weights;
|
||||
invalid.clear();
|
||||
remember();
|
||||
dirty = preview !== null;
|
||||
// 다른 칸의 글도 새 값으로 — 이 칸은 넣은 그대로 둔다.
|
||||
body.querySelectorAll<HTMLInputElement>("[data-weight-num]").forEach((other) => {
|
||||
if (other !== input)
|
||||
other.value = String(weights[other.dataset.weightNum as WeightKey]);
|
||||
});
|
||||
} else {
|
||||
invalid.add(key);
|
||||
}
|
||||
render();
|
||||
});
|
||||
// 칸을 떠날 때 맞는 값이면 글을 값으로 다듬는다(예: 070 → 70).
|
||||
input.addEventListener("blur", () => {
|
||||
if (!invalid.has(input.dataset.weightNum as WeightKey)) render();
|
||||
});
|
||||
});
|
||||
// 고정 단추 — 지금 값을 붙든다. 셋 다 고정은 막혀 있다(단추가 꺼짐).
|
||||
body.querySelectorAll<HTMLButtonElement>("[data-weight-lock]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const key = button.dataset.weightLock as WeightKey;
|
||||
if (!canLockWeight(locks, key)) return;
|
||||
locks[key] = !locks[key];
|
||||
invalid.clear();
|
||||
render();
|
||||
});
|
||||
});
|
||||
criteriaInputs.forEach((input) => {
|
||||
input.addEventListener("input", () => {
|
||||
dirty = preview !== null;
|
||||
|
||||
@@ -163,13 +163,73 @@
|
||||
accent-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.b05-routerec__bar output {
|
||||
/* 값 칸 — 슬라이더 옆에서 숫자를 직접 넣는다. 합 100 을 못 맞추면 빨간 테두리. */
|
||||
.b05-routerec__num {
|
||||
width: 100%;
|
||||
min-width: 3.2em;
|
||||
padding: 2px 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-4, 4px);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-body);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
appearance: textfield;
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
.b05-routerec__bar span {
|
||||
.b05-routerec__num::-webkit-outer-spin-button,
|
||||
.b05-routerec__num::-webkit-inner-spin-button {
|
||||
margin: 0;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.b05-routerec__num:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.b05-routerec__num.is-invalid {
|
||||
border-color: var(--color-danger, #dc2626);
|
||||
box-shadow: 0 0 0 1px var(--color-danger, #dc2626);
|
||||
}
|
||||
|
||||
/* 고정 단추 — 자물쇠. 눌린 것 = 고정 · 저절로 정해진 비중은 눌린 채 꺼져(잠김 표시). */
|
||||
.b05-routerec__lock {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-4, 4px);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b05-routerec__lock:hover:not(:disabled) {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.b05-routerec__lock.is-on {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-primary);
|
||||
color: var(--color-on-primary, #fff);
|
||||
}
|
||||
|
||||
.b05-routerec__lock:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.b05-routerec__lock.is-auto {
|
||||
opacity: 0.8;
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.b05-routerec__bar > span {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"""프로젝트 집계 항목 복사본 읽기 · 쓰기 · 새로 가져오기 · 미리 가져오기 · 계산 (PLAN 25-2b).
|
||||
"""구조물 집계 항목 복사본 읽기 · 쓰기 · 재적용 · 미리 가져오기 · 개인에 저장 · 새 항목 · 계산
|
||||
(PLAN 25-2b · 26-2b).
|
||||
|
||||
복사본 = `{프로젝트}/templates/items/{폴더}/` (`M02_Item_Snapshot`). 파일마다 판 = 내용 해시 —
|
||||
쓸 때 판이 다르면 `Stale`(409). 고치면 manifest 에 `고침` 을 남겨 구조물에 안 쓰여도 안 지움.
|
||||
옛 복사본(새 파일 없음)은 읽을 때 채움(`complete_old`). 계산은 복사본만 씀(마스터 현재값 안 읽음).
|
||||
복사본 = `{프로젝트}/templates/structures/{구조물 id}/`
|
||||
(`M02_Item_Snapshot`) · 길의 폴더 = 구조물 id.
|
||||
파일마다 판 = 내용 해시 — 쓸 때 판이 다르면 `Stale`(409). 고치면 manifest 에 `고침` 을 남겨 지울 때
|
||||
`_지난/` 에 옮겨 둠. 옛 복사본(새 파일 없음) · 옛 항목 폴더는 읽을 때 채우고 옮겨 받음.
|
||||
계산은 복사본만 씀(마스터 현재값 안 읽음) — 원본 항목이 바뀌거나 지워져도 그대로.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -13,17 +16,25 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from common_util.common_util_summary_item import snapshot_folder_name
|
||||
from M02_MasterTemplete import M02_Item_Personal as personal
|
||||
from M02_MasterTemplete import M02_Item_Prices as prices
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
from M02_MasterTemplete.M02_Item_Snapshot import (
|
||||
FOLDER_PATTERN,
|
||||
LOCAL_FILES,
|
||||
MANIFEST_NAME,
|
||||
PICKS_FILE,
|
||||
VALUES_FILE,
|
||||
build_snapshot,
|
||||
complete_old,
|
||||
folder_name,
|
||||
items_dir,
|
||||
migrate_items,
|
||||
now_text,
|
||||
retire,
|
||||
same_item,
|
||||
source_files,
|
||||
structures_dir,
|
||||
write_snapshot,
|
||||
)
|
||||
from M02_MasterTemplete.M02_Item_Source import (
|
||||
@@ -48,7 +59,6 @@ KINDS = {
|
||||
#: 뼈대 검사에 쓰는 층 양식 종류(도면 · 면적산출 · 재료산출)
|
||||
SKELETON = {"도면": "structure", "면적": "area", "재료": "material"}
|
||||
SEATS = ("면적", "재료", "선택", None)
|
||||
_FOLDER = re.compile(r"^(system|company|personal)_[0-9A-Za-z-]+_SS\d{6}$")
|
||||
_MT = re.compile(r"^MT\d{6}$")
|
||||
|
||||
|
||||
@@ -58,11 +68,18 @@ class Stale(ValueError):
|
||||
self.current = current
|
||||
|
||||
|
||||
def _migrated(project_root: str | Path) -> Path:
|
||||
"""구조물 복사본 뿌리 — 옛 항목 폴더가 남았으면 먼저 옮겨 받음."""
|
||||
if items_dir(project_root).is_dir():
|
||||
migrate_items(project_root)
|
||||
return structures_dir(project_root)
|
||||
|
||||
|
||||
def folder_of(project_root: str | Path, folder: str) -> Path:
|
||||
"""복사본 폴더 — 이름 모양이 틀리거나 없으면 LookupError(404)."""
|
||||
if not _FOLDER.match(folder):
|
||||
"""구조물 복사본 폴더 — 이름 모양이 틀리거나 없으면 LookupError(404)."""
|
||||
if not FOLDER_PATTERN.match(folder):
|
||||
raise LookupError(f"복사본 폴더 이름 모양이 아님 「{folder}」")
|
||||
target = items_dir(project_root) / folder
|
||||
target = _migrated(project_root) / folder
|
||||
if not (target / MANIFEST_NAME).is_file():
|
||||
raise LookupError(f"복사본이 없음 「{folder}」")
|
||||
complete_old(target)
|
||||
@@ -79,10 +96,10 @@ def source_changed(manifest: dict[str, Any]) -> bool | None:
|
||||
|
||||
|
||||
def list_items(project_root: str | Path) -> list[dict[str, Any]]:
|
||||
base = items_dir(project_root)
|
||||
base = _migrated(project_root)
|
||||
out = []
|
||||
for child in sorted(base.iterdir()) if base.is_dir() else []:
|
||||
if not child.is_dir() or not _FOLDER.match(child.name):
|
||||
if not child.is_dir() or not FOLDER_PATTERN.match(child.name):
|
||||
continue
|
||||
if not (child / MANIFEST_NAME).is_file():
|
||||
continue
|
||||
@@ -215,12 +232,15 @@ def _kept_picks(picks: Any, labor: dict[str, Any]) -> dict[str, Any]:
|
||||
return out
|
||||
|
||||
|
||||
def refresh(project_root: str | Path, folder: str) -> dict[str, Any]:
|
||||
"""층 · 마스터에서 다시 가져옴 — 입력값 · 고른자재는 같은 키면 살림."""
|
||||
def reapply(project_root: str | Path, folder: str) -> dict[str, Any]:
|
||||
"""재적용 — 원본(층 · 마스터)을 다시 가져옴 · 입력값 · 고른자재는 같은 키면 살림.
|
||||
|
||||
원본과 달라 덮인 파일(도면 · 면적 · 재료 · 자재조합 · 입력연결)은 `덮임` 에 알림."""
|
||||
target = folder_of(project_root, folder)
|
||||
manifest = read_json(target / MANIFEST_NAME) or {}
|
||||
values = read_json(target / VALUES_FILE)
|
||||
picks = read_json(target / PICKS_FILE)
|
||||
before = {name: read_json(target / file) for name, file in KINDS.items()}
|
||||
files = build_snapshot(manifest["원본"])
|
||||
labor = files.get(LABOR_FILE) or {}
|
||||
files[VALUES_FILE] = _kept_values(values, labor)
|
||||
@@ -230,24 +250,74 @@ def refresh(project_root: str | Path, folder: str) -> dict[str, Any]:
|
||||
old.unlink()
|
||||
for name, content in files.items():
|
||||
atomic_write_json(target / name, content)
|
||||
source = {k: v for k, v in files.items() if k not in (VALUES_FILE, PICKS_FILE)}
|
||||
covered = [
|
||||
name
|
||||
for name, file in KINDS.items()
|
||||
if file not in LOCAL_FILES and before[name] != files.get(file)
|
||||
]
|
||||
source = version_of(source_files(files))
|
||||
stamp = now_text()
|
||||
fresh = {**manifest, "판": version_of(source), "원본판": version_of(source), "가져온날": stamp}
|
||||
fresh = {**manifest, "판": source, "원본판": source, "가져온날": stamp}
|
||||
atomic_write_json(target / MANIFEST_NAME, fresh)
|
||||
return {"폴더": folder, "가져온날": stamp, "원본판": fresh["원본판"]}
|
||||
return {"폴더": folder, "가져온날": stamp, "원본판": source, "덮임": covered}
|
||||
|
||||
|
||||
def prefetch(project_root: str | Path, item: dict[str, Any]) -> dict[str, Any]:
|
||||
"""구조물에 아직 안 이은 항목을 미리 가져옴 — 있으면 그대로 · 없으면 새로(쓰는 구조물 없음)."""
|
||||
folder = snapshot_folder_name(item)
|
||||
target = items_dir(project_root) / folder
|
||||
refresh = reapply # 25-1b 옛 이름(새로 가져오기)
|
||||
|
||||
|
||||
def prefetch(project_root: str | Path, owner_id: str, item: dict[str, Any]) -> dict[str, Any]:
|
||||
"""구조물 복사본을 미리 가져옴(아직 [저장] 전) — 같은 항목이면 그대로 · 다르면 새로.
|
||||
|
||||
[저장] 뒤에도 그 구조물이 이 항목을 안 쓰면 맞추기가 지움(고친 흔적이 있으면 `_지난/`)."""
|
||||
folder = folder_name(owner_id)
|
||||
if not FOLDER_PATTERN.match(folder):
|
||||
raise ValueError(f"구조물 id 모양이 아님 「{owner_id}」")
|
||||
target = _migrated(project_root) / folder
|
||||
manifest = read_json(target / MANIFEST_NAME)
|
||||
made = not isinstance(manifest, dict)
|
||||
made = not (isinstance(manifest, dict) and same_item(manifest, item))
|
||||
if made:
|
||||
manifest = write_snapshot(target, item, [], {"미리가져옴": now_text()})
|
||||
if target.is_dir():
|
||||
retire(target)
|
||||
manifest = write_snapshot(target, item, owner_id, {"미리가져옴": now_text()})
|
||||
return {"폴더": folder, "만듦": made, "가져온날": manifest.get("가져온날")}
|
||||
|
||||
|
||||
def save_as(
|
||||
project_root: str | Path, folder: str, company: int, user: int, path: list[str], name: str
|
||||
) -> dict[str, Any]:
|
||||
"""[개인에 저장] — 복사본 내용으로 개인 층에 새 항목(단가 · 입력값 · 고른자재는 안 가져감)."""
|
||||
target = folder_of(project_root, folder)
|
||||
manifest = read_json(target / MANIFEST_NAME) or {}
|
||||
made = personal.create_item(
|
||||
personal.personal_folder(company, user),
|
||||
path,
|
||||
name,
|
||||
None,
|
||||
copy=target,
|
||||
origin=manifest.get("원본"),
|
||||
)
|
||||
return {**made, "summary_item": personal.personal_item(company, user, made)}
|
||||
|
||||
|
||||
def new_item(
|
||||
project_root: str | Path,
|
||||
owner_id: str,
|
||||
company: int,
|
||||
user: int,
|
||||
path: list[str],
|
||||
name: str,
|
||||
unit: str | None,
|
||||
) -> dict[str, Any]:
|
||||
"""프로젝트에서 새 항목 — 개인 층에 빈 항목을 만들고 그 구조물 복사본으로.
|
||||
|
||||
돌려준 `summary_item` 을 화면이 그 구조물에 지정해 [저장] 해야 복사본이 남음(안 하면 맞추기가
|
||||
다른 항목 · 지정 없음으로 보고 지움)."""
|
||||
made = personal.create_item(personal.personal_folder(company, user), path, name, unit)
|
||||
item = personal.personal_item(company, user, made)
|
||||
got = prefetch(project_root, owner_id, item)
|
||||
return {**made, "폴더": got["폴더"], "summary_item": item}
|
||||
|
||||
|
||||
def calc(project_root: str | Path, folder: str) -> dict[str, Any]:
|
||||
"""복사본(입력값 · 고른자재 · 단가 · 일위대가)만으로 계산 — 마스터 현재값을 안 읽음."""
|
||||
target = folder_of(project_root, folder)
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""프로젝트에서 개인 층으로 — [개인에 저장](save-as) · 프로젝트에서 새 항목(new) (PLAN 26-2b).
|
||||
|
||||
개인 층 새 항목 = 구조물집계표 새 열 하나 + 빈 도면 · 면적산출 · 재료산출(번호 · SS 키는 저장소가
|
||||
줌). [개인에 저장]은 구조물 복사본의 열 정의 · 도면 · 면적 · 재료 · 자재조합 · 입력연결을 담음 —
|
||||
단가 묶음 · 입력값 · 고른자재는 프로젝트 몫이라 안 가져감. 쓰기는 지금 층 저장소 함수(`store.at` ·
|
||||
`store.write` · `matset.create` · 항목 연결 `write_file`) — 나무 고침(`M02_MasterTemplete_Tree`)과
|
||||
같은 집계표 저장 한 줄기(경로 · 머리 줄 수 맞춤 · 번호 · SS 장부)를 탐(PLAN 26-2a).
|
||||
권한(본인 개인 층 · 회사 없음 409)은 부르는 쪽 몫.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Inputs as inputs_store
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Items as items_store
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Matset as matset
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Tree as tree
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
from M02_MasterTemplete.M02_Item_Source import LINK_FILE, UM_FILE, read_json
|
||||
|
||||
#: 복사본 파일 → 층 양식 종류
|
||||
PART_KINDS = (("도면.json", "structure"), ("면적.json", "area"), ("재료.json", "material"))
|
||||
|
||||
|
||||
def heads_of(path: list[str], name: str | None) -> list[str]:
|
||||
"""경로 + 이름 → 항목 경로(깊이 자유 · 빈 칸은 뺌) — 머리 줄 수는 집계표 저장이 맞춤."""
|
||||
full = [str(p).strip() for p in [*path, name or ""] if str(p or "").strip()]
|
||||
if not full:
|
||||
raise ValueError("새 항목의 경로 · 이름이 비어 있습니다.")
|
||||
return full
|
||||
|
||||
|
||||
def _free_path(doc: dict[str, Any], full: list[str]) -> list[str]:
|
||||
"""같은 경로 항목이 이미 있으면 마지막 이름을 「이름 (2)」 로
|
||||
(나무는 같은 경로 두 항목을 안 둠)."""
|
||||
taken = [tree.path_of(c) for c in doc.get("열", []) if tree.is_item(c)]
|
||||
out, n = list(full), 2
|
||||
while out in taken:
|
||||
out, n = [*full[:-1], f"{full[-1]} ({n})"], n + 1
|
||||
return out
|
||||
|
||||
|
||||
def _new_matset(row: dict[str, Any], fallback: str) -> str:
|
||||
"""자재 조합 줄을 이 층에 새 키로 — 이름이 겹치면 「이름 (2)」."""
|
||||
taken = {str(r.get("이름") or "") for r in matset.listing()["줄"]}
|
||||
name = base = str(row.get("이름") or fallback)
|
||||
n = 2
|
||||
while name in taken:
|
||||
name, n = f"{base} ({n})", n + 1
|
||||
got = {k: row.get(k) for k in ("담은자재", "소유", "비고")}
|
||||
return matset.create({**got, "이름": name, "본뜬키": None})["키"]
|
||||
|
||||
|
||||
def create_item(
|
||||
folder: Path,
|
||||
path: list[str],
|
||||
name: str | None,
|
||||
unit: str | None,
|
||||
copy: Path | None = None,
|
||||
origin: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""개인 층 `folder` 에 새 항목 — `copy` = 구조물 복사본 폴더(있으면 그 내용을 담음).
|
||||
|
||||
돌려줌 `{key, 열, 이름}`."""
|
||||
heads = heads_of(path, name)
|
||||
total = read_json(copy / "집계.json") if copy else None
|
||||
base_col = dict((total or {}).get("열") or {})
|
||||
base_row = (total or {}).get("줄") or {}
|
||||
items_store.ensure(folder)
|
||||
with store.at(folder):
|
||||
table = store.read("table", store.STRUCTURE_TABLE)
|
||||
doc = table["문서"]
|
||||
used = {c.get("id") for c in doc.get("열", []) if isinstance(c, dict)}
|
||||
new_id = items_store._fresh_id(str(base_col.get("id") or "item").split("@")[0], used)
|
||||
um_row = read_json(copy / UM_FILE) if copy else None
|
||||
um_key = _new_matset(um_row, heads[0]) if isinstance(um_row, dict) else None
|
||||
heads = _free_path(doc, heads)
|
||||
column = {
|
||||
**json.loads(json.dumps(base_col)),
|
||||
"id": new_id,
|
||||
"머리": heads,
|
||||
"경로": heads,
|
||||
"꼴": "수",
|
||||
"단위": unit or base_col.get("단위") or base_row.get("단위"),
|
||||
"일위대가": base_col.get("일위대가") or base_row.get("일위대가"),
|
||||
"자재조합": um_key,
|
||||
}
|
||||
column.pop("본뜬열", None)
|
||||
if origin:
|
||||
column["본뜬"] = origin
|
||||
tree._place(doc, column) # 같은 앞마디 형제 곁(제목행 병합이 이어지게)
|
||||
store.write("table", store.STRUCTURE_TABLE, table["판"], doc) # 빈 세 파일 · 번호 · SS
|
||||
for file, kind in PART_KINDS if copy else ():
|
||||
got = read_json(copy / file)
|
||||
if not isinstance(got, dict):
|
||||
continue
|
||||
number = store._KEY[kind]
|
||||
have = store.read(kind, new_id)["문서"]
|
||||
body = {k: v for k, v in got.items() if k != "계산값"}
|
||||
body = {**body, "열": new_id, number: have.get(number)}
|
||||
if kind in store.SHEETS:
|
||||
body = store._server_values(body)
|
||||
with store._LOCK:
|
||||
atomic_write_json(store._path(kind, new_id), body)
|
||||
links = read_json(copy / LINK_FILE) if copy else None
|
||||
if isinstance(links, dict) and links.get("줄"):
|
||||
inputs_store.write_file(new_id, {"판": 1, "줄": links["줄"]})
|
||||
ss = next(r for r in store.read_combos()["줄"] if r.get("열") == new_id)
|
||||
return {"key": ss["키"], "열": new_id, "이름": tree.name_of(heads)}
|
||||
|
||||
|
||||
def personal_item(company: int, user: int, made: dict[str, Any]) -> dict[str, Any]:
|
||||
"""새로 만든 개인 층 항목의 `summary_item`."""
|
||||
return {
|
||||
"layer": "personal",
|
||||
"owner": f"{company}/{user}",
|
||||
"key": made["key"],
|
||||
"name": made["이름"],
|
||||
}
|
||||
|
||||
|
||||
def personal_folder(company: int, user: int) -> Path:
|
||||
return layers.personal_dir(company, user)
|
||||
@@ -1,20 +1,24 @@
|
||||
"""구조물 집계표 항목 스냅샷 — 프로젝트 `templates/items/{폴더}/` (PLAN 21-1 · 21-3 · 25-2b).
|
||||
"""구조물 집계 항목 복사본 — 프로젝트 `templates/structures/{구조물 id}/` (PLAN 25-2b · 26-2b).
|
||||
|
||||
복사본은 **구조물마다 하나** — 같은 항목을 고른 구조물 둘도 각자 도면 · 면적 · 재료 · 자재조합 ·
|
||||
일위대가 · 입력연결 · 입력값 · 고른자재 · 단가를 가짐(길이 · 소재 · 높이 · 옵션이 구조물마다 다름).
|
||||
폴더 이름 = 구조물 id(`structure_id`) · 관 지점은 `pipe@측점`.
|
||||
|
||||
[저장](`PUT /route/structures` · `PUT /drainage/pipe-points`) 뒤 서버가 맞춤:
|
||||
- 구조물 · 관 지점이 가리키는 항목(`summary_item`)마다 원본 층에서 내용을 복사
|
||||
(`M02_Item_Source.build_source` — 집계 · 도면 · 면적 · 재료 · 자재조합 · 일위대가 · 입력연결 ·
|
||||
단가) + 프로젝트 값 `입력값.json`(처음엔 빈 값 · 자동값은 안 적음) · `고른자재.json` ·
|
||||
`manifest.json`
|
||||
- 이미 있는 폴더는 내용을 다시 쓰지 않음 — 원본(마스터 · 회사 · 개인)이 바뀌어도 프로젝트
|
||||
값은 지정한 때 그대로 · `manifest` 의 쓰는 구조물 목록만 새로([새로 가져오기]는 사용자가)
|
||||
- 아무 구조물도 안 쓰는 폴더는 지움(지정 바뀜 · 해제 · 구조물 삭제) — 단 프로젝트에서 고친
|
||||
흔적(`manifest.고침`)이 있으면 남김
|
||||
원본 층 자리 = `M02_Template_Layers` 경로(시스템 층은 지금 파일) · 일위대가 · 단가는 M01 시스템 것.
|
||||
- 항목을 지정한 구조물마다 원본 층에서 내용을 복사(`M02_Item_Source.build_source`) + 프로젝트 값
|
||||
`입력값.json`(처음엔 빈 값 · 자동값은 안 적음) · `고른자재.json` · `manifest.json`
|
||||
- 이미 있는 복사본은 다시 쓰지 않음 — 원본(마스터 · 회사 · 개인)이 바뀌거나 지워져도 그대로
|
||||
([재적용]은 사용자가)
|
||||
- 지정을 다른 항목으로 바꾸면 새로 가져옴 · 구조물을 지우거나 지정을 풀면 복사본을 지움 —
|
||||
프로젝트에서 고친 흔적(`manifest.고침`)이 있으면 지우지 않고 `_지난/` 에 옮겨 둠
|
||||
- 옛 항목 폴더(`templates/items/{층}_{주인}_{키}/` · 25-2b)는 그 항목을 쓰는 구조물마다 복사해
|
||||
옮겨 받음(고친 값 유지)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -29,118 +33,197 @@ from M02_MasterTemplete.M02_Item_Source import (
|
||||
LINK_FILE,
|
||||
PRICE_FILE,
|
||||
build_source,
|
||||
item_path,
|
||||
read_json,
|
||||
version_of,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ITEMS_DIRNAME = "items"
|
||||
STRUCTURES_DIRNAME = "structures"
|
||||
OLD_ITEMS_DIRNAME = "items" # 25-2b 항목 폴더 — 옮겨 받기만
|
||||
PAST_DIRNAME = "_지난" # 고친 흔적이 있는 복사본을 지우지 않고 옮겨 두는 자리
|
||||
MANIFEST_NAME = "manifest.json"
|
||||
VALUES_FILE = "입력값.json" # {로직키: {입력이름: {값, 확인}}}
|
||||
PICKS_FILE = "고른자재.json" # {로직키: {호표 차례: MT키}}
|
||||
LOCAL_FILES = (VALUES_FILE, PICKS_FILE)
|
||||
#: 복사본 폴더 이름 — 구조물 id(영문 · 숫자 · `_` `-`) 또는 `pipe@측점`
|
||||
FOLDER_PATTERN = re.compile(r"^(?:pipe@\d+(?:\.\d+)?|[0-9A-Za-z][0-9A-Za-z_-]*)$")
|
||||
|
||||
|
||||
def structures_dir(project_root: str | Path) -> Path:
|
||||
return layers.project_dir(project_root) / STRUCTURES_DIRNAME
|
||||
|
||||
|
||||
def items_dir(project_root: str | Path) -> Path:
|
||||
return layers.project_dir(project_root) / ITEMS_DIRNAME
|
||||
"""옛 항목 폴더 뿌리(25-2b) — 옮겨 받기에만 씀."""
|
||||
return layers.project_dir(project_root) / OLD_ITEMS_DIRNAME
|
||||
|
||||
|
||||
def folder_name(owner_id: str) -> str:
|
||||
"""구조물 id → 복사본 폴더 이름 — id 없는 옛 구조물(`#차례`)은 `n차례`."""
|
||||
return owner_id.replace("#", "n")
|
||||
|
||||
|
||||
def now_text() -> str:
|
||||
return datetime.now().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def same_item(manifest: Any, item: dict[str, Any]) -> bool:
|
||||
origin = manifest.get("원본") if isinstance(manifest, dict) else None
|
||||
if not isinstance(origin, dict):
|
||||
return False
|
||||
return all(origin.get(k) == item.get(k) for k in ("layer", "owner", "key"))
|
||||
|
||||
|
||||
def origin_of(item: dict[str, Any]) -> dict[str, Any]:
|
||||
"""manifest `원본` — 층 · 주인 · 키 · 이름 + 경로(26-1 · 가져온 때 층 SS 줄)."""
|
||||
try:
|
||||
path = item_path(item)
|
||||
except LookupError:
|
||||
path = []
|
||||
return {**item, "경로": path}
|
||||
|
||||
|
||||
def build_snapshot(item: dict[str, Any]) -> dict[str, Any]:
|
||||
"""원본 내용 + 빈 프로젝트 값 — `{파일 이름: 내용}` · SS 줄이 없으면 LookupError."""
|
||||
return {**build_source(item), VALUES_FILE: {}, PICKS_FILE: {}}
|
||||
|
||||
|
||||
def write_snapshot(target: Path, item: dict[str, Any], users: list[str], extra: dict) -> dict:
|
||||
def source_files(files: dict[str, Any]) -> dict[str, Any]:
|
||||
return {k: v for k, v in files.items() if k not in LOCAL_FILES}
|
||||
|
||||
|
||||
def write_snapshot(target: Path, item: dict[str, Any], owner_id: str, extra: dict) -> dict:
|
||||
"""폴더에 새로 씀 — manifest 는 마지막(있으면 다 쓴 폴더 · 끊기면 다음 저장이 다시 씀)."""
|
||||
files = build_snapshot(item)
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
for name, content in files.items():
|
||||
atomic_write_json(target / name, content)
|
||||
source = {k: v for k, v in files.items() if k not in (VALUES_FILE, PICKS_FILE)}
|
||||
stamp = now_text()
|
||||
source = version_of(source_files(files))
|
||||
manifest = {
|
||||
"원본": item,
|
||||
"판": version_of(source),
|
||||
"원본판": version_of(source),
|
||||
"원본": origin_of(item),
|
||||
"판": source,
|
||||
"원본판": source,
|
||||
"적용일": stamp,
|
||||
"가져온날": stamp,
|
||||
"구조물": users,
|
||||
"구조물": [owner_id],
|
||||
**extra,
|
||||
}
|
||||
atomic_write_json(target / MANIFEST_NAME, manifest)
|
||||
return manifest
|
||||
|
||||
|
||||
def assignments(project_root: str | Path) -> dict[str, tuple[dict[str, Any], list[str]]]:
|
||||
"""`{폴더 이름: (항목, 쓰는 구조물 id)}` — 구조물 정본 + 관 지점 정본(id = `pipe@측점`)."""
|
||||
found: dict[str, tuple[dict[str, Any], list[str]]] = {}
|
||||
|
||||
def add(item: dict[str, Any] | None, owner_id: str) -> None:
|
||||
if item:
|
||||
found.setdefault(snapshot_folder_name(item), (item, []))[1].append(owner_id)
|
||||
|
||||
def assignments(project_root: str | Path) -> dict[str, dict[str, Any]]:
|
||||
"""`{구조물 id: 항목}` — 구조물 정본 + 관 지점 정본(id = `pipe@측점`) · 지정한 것만."""
|
||||
found: dict[str, dict[str, Any]] = {}
|
||||
_, structures = load_structures(str(project_root))
|
||||
for index, structure in enumerate(structures):
|
||||
add(structure.summary_item, structure.structure_id or f"#{index}")
|
||||
if structure.summary_item:
|
||||
found[structure.structure_id or f"#{index}"] = structure.summary_item
|
||||
for point in read_pipe_points_file(pipe_points_path_in(Path(project_root))):
|
||||
add(point.summary_item, f"pipe@{round(float(point.chainage_m), 3)}")
|
||||
if point.summary_item:
|
||||
found[f"pipe@{round(float(point.chainage_m), 3)}"] = point.summary_item
|
||||
return found
|
||||
|
||||
|
||||
def sync_snapshots(project_root: str | Path) -> dict[str, list[str]]:
|
||||
"""지정 항목 스냅샷을 맞춤 — `{만듦, 지움, 못만듦}` 폴더 이름."""
|
||||
base = items_dir(project_root)
|
||||
def retire(folder: Path) -> str | None:
|
||||
"""지울 복사본 — 고친 흔적이 있으면 `_지난/{이름}_{때}` 로 옮기고 그 이름 · 아니면 지움."""
|
||||
manifest = read_json(folder / MANIFEST_NAME)
|
||||
if isinstance(manifest, dict) and manifest.get("고침"):
|
||||
past = folder.parent / PAST_DIRNAME
|
||||
past.mkdir(parents=True, exist_ok=True)
|
||||
target = past / f"{folder.name}_{datetime.now():%Y%m%d%H%M%S}"
|
||||
shutil.move(str(folder), str(target))
|
||||
return target.name
|
||||
shutil.rmtree(folder)
|
||||
return None
|
||||
|
||||
|
||||
def migrate_items(project_root: str | Path) -> list[str]:
|
||||
"""옛 항목 폴더 → 그 항목을 쓰는 구조물마다 복사(고친 값 유지) — 옮겨 받은 구조물 폴더 이름.
|
||||
|
||||
다 옮긴 옛 폴더는 지움 · 쓰는 구조물이 없고 고친 흔적이 있으면 `structures/_지난/` 에 둠."""
|
||||
old = items_dir(project_root)
|
||||
if not old.is_dir():
|
||||
return []
|
||||
base = structures_dir(project_root)
|
||||
wanted = assignments(project_root)
|
||||
moved: list[str] = []
|
||||
for child in sorted(p for p in old.iterdir() if p.is_dir()):
|
||||
manifest = read_json(child / MANIFEST_NAME)
|
||||
if not isinstance(manifest, dict):
|
||||
continue
|
||||
users = [o for o, item in wanted.items() if snapshot_folder_name(item) == child.name]
|
||||
for owner in users:
|
||||
target = base / folder_name(owner)
|
||||
if (target / MANIFEST_NAME).is_file():
|
||||
continue # 이미 새 복사본이 있음
|
||||
shutil.copytree(child, target, dirs_exist_ok=True)
|
||||
origin = manifest.get("원본") or {}
|
||||
atomic_write_json(
|
||||
target / MANIFEST_NAME,
|
||||
{**manifest, "원본": origin_of(origin), "구조물": [owner], "옮겨받음": now_text()},
|
||||
)
|
||||
moved.append(target.name)
|
||||
if users or not manifest.get("고침"):
|
||||
shutil.rmtree(child)
|
||||
else:
|
||||
(base / PAST_DIRNAME).mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(child), str(base / PAST_DIRNAME / f"items_{child.name}"))
|
||||
if not any(old.iterdir()):
|
||||
old.rmdir()
|
||||
return moved
|
||||
|
||||
|
||||
def sync_snapshots(project_root: str | Path) -> dict[str, list[str]]:
|
||||
"""구조물 복사본을 지정에 맞춤 — `{만듦, 지움, 못만듦, 옮겨받음}` 폴더 이름."""
|
||||
moved = migrate_items(project_root)
|
||||
base = structures_dir(project_root)
|
||||
wanted = {
|
||||
folder_name(owner): (owner, item) for owner, item in assignments(project_root).items()
|
||||
}
|
||||
made: list[str] = []
|
||||
failed: list[str] = []
|
||||
for folder, (item, users) in sorted(wanted.items()):
|
||||
removed: list[str] = []
|
||||
for folder, (owner, item) in sorted(wanted.items()):
|
||||
target = base / folder
|
||||
manifest = read_json(target / MANIFEST_NAME)
|
||||
if isinstance(manifest, dict):
|
||||
if manifest.get("구조물") != users:
|
||||
atomic_write_json(target / MANIFEST_NAME, {**manifest, "구조물": users})
|
||||
if isinstance(manifest, dict) and same_item(manifest, item):
|
||||
continue
|
||||
if target.is_dir(): # 다른 항목으로 바뀜 — 옛 것은 지우거나(고침이면) `_지난` 에
|
||||
retire(target)
|
||||
removed.append(folder)
|
||||
try:
|
||||
write_snapshot(target, item, users, {})
|
||||
write_snapshot(target, item, owner, {})
|
||||
except (LookupError, ValueError) as error:
|
||||
logger.warning("집계 항목 스냅샷을 못 만듦 (%s): %s", folder, error)
|
||||
logger.warning("집계 항목 복사본을 못 만듦 (%s): %s", folder, error)
|
||||
failed.append(folder)
|
||||
continue
|
||||
made.append(folder)
|
||||
removed: list[str] = []
|
||||
if base.is_dir():
|
||||
for child in sorted(base.iterdir()):
|
||||
if not child.is_dir() or child.name in wanted:
|
||||
continue
|
||||
manifest = read_json(child / MANIFEST_NAME)
|
||||
if isinstance(manifest, dict) and manifest.get("고침"):
|
||||
if manifest.get("구조물"): # 고친 복사본은 남기되 쓰는 구조물은 비움
|
||||
atomic_write_json(child / MANIFEST_NAME, {**manifest, "구조물": []})
|
||||
continue
|
||||
shutil.rmtree(child)
|
||||
removed.append(child.name)
|
||||
return {"만듦": made, "지움": removed, "못만듦": failed}
|
||||
if child.is_dir() and not child.name.startswith("_") and child.name not in wanted:
|
||||
retire(child)
|
||||
removed.append(child.name)
|
||||
return {"만듦": made, "지움": removed, "못만듦": failed, "옮겨받음": moved}
|
||||
|
||||
|
||||
def safe_sync(project_root: str | Path) -> None:
|
||||
"""저장 뒤 부름 — 실패해도 저장은 그대로(스냅샷은 다음 저장이 다시 맞춤)."""
|
||||
"""저장 뒤 부름 — 실패해도 저장은 그대로(복사본은 다음 저장이 다시 맞춤)."""
|
||||
try:
|
||||
result = sync_snapshots(project_root)
|
||||
if any(result.values()):
|
||||
logger.info("집계 항목 스냅샷: %s", result)
|
||||
logger.info("집계 항목 복사본: %s", result)
|
||||
except Exception:
|
||||
logger.exception("집계 항목 스냅샷 맞춤 실패 (저장은 유지): %s", project_root)
|
||||
logger.exception("집계 항목 복사본 맞춤 실패 (저장은 유지): %s", project_root)
|
||||
|
||||
|
||||
def complete_old(folder: Path) -> bool:
|
||||
"""옛 스냅샷(21-3 · 새 파일 없음)을 채움 — 입력연결 · 단가는 지금 원본에서 · 값은 빈 것.
|
||||
"""옛 복사본(21-3 · 새 파일 없음)을 채움 — 입력연결 · 단가는 지금 원본에서 · 값은 빈 것.
|
||||
|
||||
옛 내용 파일(집계 · 도면 …)은 그대로 둠. 채웠으면 True(manifest 에 `옮겨받음` 날).
|
||||
"""
|
||||
옛 내용 파일(집계 · 도면 …)은 그대로 둠. 채웠으면 True(manifest 에 `옮겨받음` 날)."""
|
||||
manifest = read_json(folder / MANIFEST_NAME)
|
||||
if not isinstance(manifest, dict):
|
||||
return False
|
||||
@@ -151,15 +234,19 @@ def complete_old(folder: Path) -> bool:
|
||||
]
|
||||
if not lacking and "원본판" in manifest:
|
||||
return False
|
||||
source = (
|
||||
build_source(manifest["원본"]) if (LINK_FILE in lacking or PRICE_FILE in lacking) else {}
|
||||
)
|
||||
need_source = LINK_FILE in lacking or PRICE_FILE in lacking
|
||||
try:
|
||||
source = build_source(manifest["원본"]) if need_source else {}
|
||||
except LookupError:
|
||||
source = {} # 원본이 지워짐 — 빈 연결 · 빈 단가로(계산은 멈춤으로 알림)
|
||||
for name in lacking:
|
||||
atomic_write_json(folder / name, source.get(name, {}))
|
||||
atomic_write_json(
|
||||
folder / name, source.get(name, {"판": None, "줄": []} if name == LINK_FILE else {})
|
||||
)
|
||||
kept = {
|
||||
name: read_json(folder / name)
|
||||
for name in sorted(p.name for p in folder.glob("*.json"))
|
||||
if name not in (MANIFEST_NAME, VALUES_FILE, PICKS_FILE)
|
||||
if name not in (MANIFEST_NAME, *LOCAL_FILES)
|
||||
}
|
||||
atomic_write_json(
|
||||
folder / MANIFEST_NAME,
|
||||
@@ -167,13 +254,15 @@ def complete_old(folder: Path) -> bool:
|
||||
**manifest,
|
||||
"가져온날": manifest.get("가져온날") or manifest.get("적용일"),
|
||||
"원본판": manifest.get("원본판") or version_of(kept),
|
||||
"옮겨받음": now_text(),
|
||||
"옮겨받음": manifest.get("옮겨받음") or now_text(),
|
||||
},
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def read_snapshot(project_root: str | Path, item: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""프로젝트 스냅샷의 `집계.json`(`{줄, 열}`) — 없으면 None."""
|
||||
data = read_json(items_dir(project_root) / snapshot_folder_name(item) / "집계.json")
|
||||
def read_snapshot(project_root: str | Path, owner_id: str) -> dict[str, Any] | None:
|
||||
"""그 구조물 복사본의 `집계.json`(`{줄, 열}`) — 없으면 None(옛 항목 폴더는 먼저 옮겨 받음)."""
|
||||
if items_dir(project_root).is_dir():
|
||||
migrate_items(project_root)
|
||||
data = read_json(structures_dir(project_root) / folder_name(owner_id) / "집계.json")
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
@@ -54,13 +54,24 @@ def row_of(document: Any, key: str) -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
|
||||
def item_name(item: dict[str, Any]) -> str:
|
||||
"""층 SS 줄의 보이는 이름(종류 · 분류1~3) — 없으면 LookupError."""
|
||||
def row_path(row: dict[str, Any]) -> list[str]:
|
||||
"""SS 줄의 경로(26-1) — `경로` 칸이 있으면 그것 · 옛 줄은 종류 · 분류1~3(빈 분류는 건너뜀)."""
|
||||
if isinstance(row.get("경로"), list):
|
||||
return [str(p) for p in row["경로"] if str(p or "").strip()]
|
||||
return [str(row[k]) for k in ("종류", "분류1", "분류2", "분류3") if row.get(k)]
|
||||
|
||||
|
||||
def item_path(item: dict[str, Any]) -> list[str]:
|
||||
"""층 SS 줄의 경로 — 없으면 LookupError."""
|
||||
row = row_of(read_json(layer_root(item) / layers.COMBO_FILE), str(item.get("key")))
|
||||
if row is None:
|
||||
raise LookupError(f"집계 항목 {item.get('key')} 이 {item.get('layer')} 층에 없음")
|
||||
parts = (row.get(k) for k in ("종류", "분류1", "분류2", "분류3"))
|
||||
return " ".join(str(p) for p in parts if p) or str(row["키"])
|
||||
return row_path(row) or [str(row["키"])]
|
||||
|
||||
|
||||
def item_name(item: dict[str, Any]) -> str:
|
||||
"""층 SS 줄의 보이는 이름(경로를 이은 글) — 없으면 LookupError."""
|
||||
return " ".join(item_path(item))
|
||||
|
||||
|
||||
def version_of(content: Any) -> str:
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""M02 구조물집계표조합(SS 장부) — 구조물 열마다 줄 하나 · 경로 옮겨 받기(PLAN 26-2a).
|
||||
|
||||
제어용 장부 · 관리자는 집계표 화면(또는 나무)으로 고침 · 서버가 집계표 [저장] 때 맞춤 ·
|
||||
키 = SS + 6자리 · 한 번 준 키는 그 열에 남음 · 지운 열의 키는 다시 안 씀(`다음` 이 기억).
|
||||
줄 = `{키, 열, 경로, 이름, 종류, 분류1~3(경로 앞 넷 — 옛 코드가 읽음), 단위, 일위대가, 자재조합,
|
||||
도번, 면적번, 재료번, 바인딩, 펼침}`. `M02_MasterTemplete_Store.py` 가 커서 떼어 냄 — 저장소는
|
||||
`store.sync_combos` · `store.read_combos` 이름으로 이 파일을 부름.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Tree as tree
|
||||
|
||||
HEAD = "SS"
|
||||
NOTE = (
|
||||
"구조물집계표 구조물 열(꼴=수)마다 한 줄 · 서버가 집계표 [저장] 때 맞춤(손으로 고치지 않음) · "
|
||||
"키 SS 는 한 번 주면 안 바뀌고 지운 열의 키는 다시 안 씀 · 경로 = 폴더 이름 배열"
|
||||
)
|
||||
|
||||
|
||||
def _row(key: str, col: dict[str, Any], numbers: dict[str, str]) -> dict[str, Any]:
|
||||
path = tree.path_of(col)
|
||||
return {
|
||||
"키": key,
|
||||
"열": col["id"],
|
||||
"경로": path,
|
||||
"이름": tree.name_of(path),
|
||||
**tree.old_fields(path),
|
||||
"단위": col.get("단위"),
|
||||
"일위대가": col.get("일위대가"),
|
||||
"자재조합": col.get("자재조합"),
|
||||
**{field: numbers.get(field, "") for _, field, _, _ in store.PARTS},
|
||||
"바인딩": col.get("바인딩"),
|
||||
"펼침": col.get("펼침"),
|
||||
}
|
||||
|
||||
|
||||
def sync() -> dict[str, Any]:
|
||||
"""장부를 집계표에 맞춤 — 새 열 = 새 SS 키 · 지운 열 = 줄 지움 · 고친 열 = 칸 갱신 ·
|
||||
줄 차례 = 집계표 열 차례. 바뀐 것이 없으면 파일을 안 씀."""
|
||||
path = store.root() / store.COMBO_FILE
|
||||
with store._LOCK:
|
||||
old = json.loads(path.read_bytes()) if path.is_file() else {}
|
||||
keys = {row["열"]: row["키"] for row in old.get("줄", [])}
|
||||
taken = [int(k[len(HEAD) :]) for k in keys.values()]
|
||||
after = max(old.get("다음", 1), max(taken, default=0) + 1)
|
||||
numbers = store.structure_numbers()
|
||||
rows = []
|
||||
for col in store._structure_columns():
|
||||
if col["id"] not in keys:
|
||||
keys[col["id"]] = f"{HEAD}{after:06d}"
|
||||
after += 1
|
||||
rows.append(_row(keys[col["id"]], col, numbers.get(col["id"], {})))
|
||||
doc = {"그룹": "구조물집계표조합", "판": 1, "비고": NOTE, "다음": after}
|
||||
if old.get("지운번호"):
|
||||
doc["지운번호"] = old["지운번호"] # 지운 열의 도번 8자리 — 다시 안 줌
|
||||
doc["줄"] = rows
|
||||
if doc != old:
|
||||
atomic_write_json(path, doc)
|
||||
return doc
|
||||
|
||||
|
||||
def read() -> dict[str, Any]:
|
||||
"""장부 — 파일이 아직 없으면 지금 열 전부로 처음 한 번 만듦 · `경로` 가 없던 옛 줄은 읽을 때
|
||||
종류 · 분류1~3 에서 채움(빈 분류 건너뜀 · 파일은 다음 [저장] 때 새 모양)."""
|
||||
path = store.root() / store.COMBO_FILE
|
||||
doc = json.loads(path.read_bytes()) if path.is_file() else sync()
|
||||
for row in doc.get("줄") or []:
|
||||
if "경로" not in row:
|
||||
row["경로"] = tree.row_path(row)
|
||||
row["이름"] = tree.name_of(row["경로"])
|
||||
return doc
|
||||
|
||||
|
||||
def migrate_paths() -> bool:
|
||||
"""옛 집계표 · 장부 → 경로 모양(열 `경로` · 머리 줄 수 · 장부 `경로` · `이름`) — 한 번 · 두 번
|
||||
돌려도 같음. 바뀐 것이 없으면 파일을 안 씀 · 바꿨으면 참. 지금 층(`store.at`) 기준."""
|
||||
got = store.read("table", store.STRUCTURE_TABLE)
|
||||
if tree.normalize_table(got["문서"]) != got["문서"]:
|
||||
store.write("table", store.STRUCTURE_TABLE, got["판"], got["문서"])
|
||||
return True
|
||||
path = store.root() / store.COMBO_FILE
|
||||
before = path.read_bytes() if path.is_file() else b""
|
||||
sync()
|
||||
return (path.read_bytes() if path.is_file() else b"") != before
|
||||
@@ -15,6 +15,7 @@ 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_MasterTemplete_Matset as matset
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Tree as tree
|
||||
|
||||
_OWNER = "현장"
|
||||
|
||||
@@ -150,7 +151,7 @@ def branch_material_combo(
|
||||
return matset.create(row)["키"]
|
||||
|
||||
heads = next((c for c in store._structure_columns() if c["id"] == column), {})
|
||||
default = " ".join(h for h in store._heads(heads) if h) if heads else column
|
||||
default = tree.name_of(tree.path_of(heads)) if heads else column # 경로 이음(26-1)
|
||||
name = name.strip() or str(was.get("이름") or default or column)
|
||||
rows = matset.listing()["줄"]
|
||||
return _branch(
|
||||
|
||||
@@ -22,6 +22,8 @@ from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
# 21-1 목록 칸 — SS 줄에서 그대로(키는 `key`)
|
||||
ITEM_FIELDS = (
|
||||
"열",
|
||||
"경로",
|
||||
"이름",
|
||||
"종류",
|
||||
"분류1",
|
||||
"분류2",
|
||||
@@ -74,8 +76,9 @@ def _combo_names() -> dict[str, str]:
|
||||
|
||||
|
||||
def items(folder: Path) -> list[dict[str, Any]]:
|
||||
"""그 층 항목 — SS 줄마다 `{key, 열, 종류, 분류1~3, 단위, 일위대가, 자재조합,
|
||||
도번, 면적번, 재료번, names}` · `names` = 화면 이름(키 대신 · 없으면 null) —
|
||||
"""그 층 항목 — SS 줄마다 `{key, 열, 경로, 이름, 종류, 분류1~3, 단위, 일위대가, 자재조합,
|
||||
도번, 면적번, 재료번, names}` · 경로 = 폴더 이름 배열 · 이름 = 경로 이음(26-1 · 종류 ·
|
||||
분류1~3 은 경로 앞 넷) · `names` = 화면 이름(키 대신 · 없으면 null) —
|
||||
일위대가 = 조합 이름 · 자재조합 = 그 층 자재 조합 이름 ·
|
||||
도번 · 면적번 · 재료번 = 항목(열) 이름."""
|
||||
ua = _combo_names()
|
||||
@@ -84,7 +87,7 @@ def items(folder: Path) -> list[dict[str, Any]]:
|
||||
um = {str(r.get("키")): str(r.get("이름") or "") for r in matset.listing()["줄"]}
|
||||
out = []
|
||||
for r in rows:
|
||||
head = " ".join(str(r[k]) for k in ("종류", "분류1", "분류2", "분류3") if r.get(k))
|
||||
head = r.get("이름")
|
||||
names = {
|
||||
"일위대가": ua.get(str(r.get("일위대가"))) if r.get("일위대가") else None,
|
||||
"자재조합": um.get(str(r.get("자재조합"))) if r.get("자재조합") else None,
|
||||
@@ -155,4 +158,4 @@ def copy_item(source: Path, target: Path, key: str, origin: dict[str, Any]) -> d
|
||||
atomic_write_json(store._path(kind, new_id), body)
|
||||
ss = next(r for r in store.read_combos()["줄"] if r.get("열") == new_id)
|
||||
inputs_store.copy_between(source, target, col_id, new_id) # 항목 연결도 같이(판 1)
|
||||
return {"key": ss["키"], "열": new_id, "이름": " ".join(h for h in store._heads(made) if h)}
|
||||
return {"key": ss["키"], "열": new_id, "이름": ss["이름"]}
|
||||
|
||||
@@ -13,6 +13,7 @@ from typing import Any
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Tree as tree
|
||||
|
||||
mmc = m01.cm.mmc # 자재 조합 틀(`resources/master_data/scripts/master_material_combo.py`)
|
||||
FILE = mmc.FILE
|
||||
@@ -47,7 +48,7 @@ def listing() -> dict[str, Any]:
|
||||
|
||||
|
||||
def _name(col: dict) -> str:
|
||||
return " ".join(h for h in store._heads(col) if h) or col["id"]
|
||||
return tree.name_of(tree.path_of(col)) or col["id"] # 경로 이음(26-1)
|
||||
|
||||
|
||||
def one(key: str) -> dict[str, Any]:
|
||||
|
||||
@@ -24,6 +24,7 @@ from M02_MasterTemplete import M02_MasterTemplete_Inputs as inputs_store
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Items as items_store
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Matset as matset
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Tree as tree
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router import (
|
||||
BranchBody,
|
||||
@@ -44,6 +45,18 @@ class InputLinksBody(BaseModel):
|
||||
줄: list[Any] = []
|
||||
|
||||
|
||||
class TreeBody(BaseModel):
|
||||
"""26-1 나무 고침 — `{op, 경로, 이름?, 새경로?, 키?, 판}` (+ 항목 더하기 때 `단위?`)."""
|
||||
|
||||
op: str
|
||||
경로: list[str] | None = None
|
||||
이름: str | None = None
|
||||
새경로: list[str] | None = None
|
||||
키: str | None = None
|
||||
단위: str | None = None
|
||||
판: str
|
||||
|
||||
|
||||
class CopyItemBody(BaseModel):
|
||||
from_layer: Literal["system", "company"]
|
||||
from_owner: str | None = None
|
||||
@@ -195,6 +208,50 @@ def get_item_pills(
|
||||
return _call(_root(session, layer, owner), inputs_store.pills, key, place)
|
||||
|
||||
|
||||
# ── 폴더 구조(PLAN 26-1 · 26-2a) — 읽기 = 층 읽기 권한 · 고침 = 층 쓰기 권한 ──
|
||||
|
||||
|
||||
def _tree_view(folder: Path) -> dict[str, Any]:
|
||||
"""나무 — `{판(집계표), 폴더: [경로 …], items: [항목 …]}` · 폴더 = 빈 폴더 + 항목 앞마디."""
|
||||
with store.at(folder):
|
||||
got = store.read("table", store.STRUCTURE_TABLE)
|
||||
doc = tree.normalize_table(got["문서"]) # 옛 파일도 경로로 읽음
|
||||
return {"판": got["판"], "폴더": tree.folders_of(doc), "items": items_store.items(folder)}
|
||||
|
||||
|
||||
@router.get("/layers/{layer}/tree")
|
||||
def get_tree(layer: Layer, owner: str | None = None, session: Session = Depends(verify_session)):
|
||||
folder = _root(session, layer, owner)
|
||||
return _call(folder, _tree_view, folder)
|
||||
|
||||
|
||||
def _edit_tree(folder: Path, body: dict[str, Any]) -> dict[str, Any]:
|
||||
"""집계표를 고쳐 집계표 저장 한 줄기로 — 번호 · SS 장부 · 지운 항목의 세 파일 · 입력 연결."""
|
||||
got = store.read("table", store.STRUCTURE_TABLE)
|
||||
if got["판"] != body.get("판"):
|
||||
raise store.StoreError(409, {"stale": [store.STRUCTURE_TABLE], "판": got["판"]})
|
||||
ids = {str(r.get("키")): str(r.get("열")) for r in store.read_combos().get("줄", [])}
|
||||
try:
|
||||
doc, note = tree.apply(got["문서"], body, ids)
|
||||
except tree.TreeError as e:
|
||||
raise store.StoreError(e.status, e.detail) from e
|
||||
store.write("table", store.STRUCTURE_TABLE, got["판"], doc)
|
||||
return {**_tree_view(folder), "알림": note}
|
||||
|
||||
|
||||
@router.post("/layers/{layer}/tree")
|
||||
def post_tree(
|
||||
layer: Layer,
|
||||
body: TreeBody,
|
||||
owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
):
|
||||
"""폴더 더하기 · 항목 더하기 · 이름 바꾸기 · 옮기기 · 지우기 → 새 나무 · 판 다르면 409.
|
||||
쓰이는 항목을 지워도 프로젝트엔 영향 없음(프로젝트는 복사본을 씀 · 알림의 `지운항목`)."""
|
||||
folder = _root(session, layer, owner, write=True)
|
||||
return _call(folder, _edit_tree, folder, body.model_dump())
|
||||
|
||||
|
||||
# ── 층 길 — 옛 시스템 길과 같은 모양 ─────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ from typing import Any
|
||||
|
||||
from common_util import common_util_spreadsheet as spreadsheet
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Tree as tree
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
|
||||
FOLDER: Path = Path(__file__).resolve().parent.parent / "resources" / "master_template"
|
||||
@@ -46,9 +47,9 @@ def at(folder: str | Path) -> Iterator[Path]:
|
||||
# 구조물집계표 구조물 열(꼴=수) 마다 세 파일(파일 이름 = 열 id) —
|
||||
# 구조물 도면 `structure/` (도각 없는 전용 화면) · 면적산출 `area/` · 재료산출 `material/`
|
||||
# (둘 다 스프레드시트 통합문서)
|
||||
# 번호 = 도번 SD · 면적번 SA · 재료번 SM + 8자리 [종류][분류1][분류2][분류3]
|
||||
# (제목행 = 열 `머리` 네 층 · 없는 층 00) — 한 열의 세 번호는 같은 8자리 ·
|
||||
# 한 번 준 번호는 파일에 남아 안 바뀜(PLAN 13장)
|
||||
# 번호 = 도번 SD · 면적번 SA · 재료번 SM + 8자리 — 한 열의 세 번호는 같은 8자리 · 한 번 준 번호는
|
||||
# 파일에 남아 안 바뀜(PLAN 13장) · 새 항목은 자리 뜻 없는 일련번호(PLAN 26-1 — 있던 번호는 그대로)
|
||||
# 항목 경로(폴더 이름 배열) = 열 `머리` 의 빈 칸 건너뛴 이름(`M02_MasterTemplete_Tree`)
|
||||
STRUCTURE_TABLE = "구조물집계표"
|
||||
# 프로그램이 이름으로 부르는 양식 — 지우면 그 기능이 죽음(B07 이름은 `B07_DesignDetail_Engine_*`)
|
||||
IN_USE = {
|
||||
@@ -65,7 +66,6 @@ PARTS = (
|
||||
)
|
||||
_KEY = {kind: key for kind, key, _, _ in PARTS}
|
||||
SHEETS = ("area", "material") # 통합문서 종류 — [저장] 때 서버가 계산값을 새로 적음
|
||||
_LEVELS = 4
|
||||
_CODE = re.compile(r"^S[DAM](\d{8})$")
|
||||
_OLD_CODE = re.compile(r"^S[DCA](\d{6})$") # PLAN 12장 6자리 — 끝에 00 을 붙여 읽음
|
||||
_BAD_NAME = re.compile(r'[\\/:*?"<>|\x00-\x1f]|\.\.')
|
||||
@@ -158,6 +158,8 @@ def write(kind: str, name: str, version: str, doc: Any) -> dict[str, Any]:
|
||||
path = _path(kind, name)
|
||||
_check_skeleton(kind, doc)
|
||||
origins = _take_origins(doc) if kind == "table" else {}
|
||||
if kind == "table" and name == STRUCTURE_TABLE:
|
||||
doc = tree.normalize_table(doc) # 경로 · 머리 줄 수 · 빈 폴더(표 저장 · 나무 고침 한 줄기)
|
||||
if kind in SHEETS:
|
||||
doc = _server_values(doc)
|
||||
with _LOCK:
|
||||
@@ -228,9 +230,8 @@ def structure_numbers() -> dict[str, dict[str, str]]:
|
||||
|
||||
|
||||
def _heads(col: dict[str, Any]) -> tuple[str | None, ...]:
|
||||
"""제목행 네 층(종류 · 분류1 · 분류2 · 분류3) — 빈 층은 None."""
|
||||
parts = [*(col.get("머리") or []), *[None] * _LEVELS][:_LEVELS]
|
||||
return tuple((str(p).strip() if p is not None else "") or None for p in parts)
|
||||
"""옛 네 칸(종류 · 분류1 · 분류2 · 분류3) = 경로 앞 넷 — 없는 칸은 None(다른 코드가 읽음)."""
|
||||
return tuple(tree.old_fields(tree.path_of(col)).values())
|
||||
|
||||
|
||||
def _structure_columns() -> list[dict[str, Any]]:
|
||||
@@ -247,49 +248,16 @@ def _column_ids() -> list[str]:
|
||||
return [col["id"] for col in _structure_columns()]
|
||||
|
||||
|
||||
def _pick_code(
|
||||
col: dict[str, Any],
|
||||
taken: dict[str, str],
|
||||
heads: dict[str, tuple],
|
||||
retired: list[str] | None = None,
|
||||
) -> str:
|
||||
"""새 열 번호 — 층마다 같은 무리(그 층까지 제목행이 같은 열)가 이미 번호를 가졌으면 그 두 자리를
|
||||
이어 받고 · 없으면 그 층 가장 큰 번호 + 1 · 빈 층(종류 밖)은 00 · 맨 아래 층(분류3)은 늘 다음
|
||||
번호. 같은 8자리가 이미 있으면(같은 제목행 · 빈 분류3 겹침) 맨 아래 층을 그 무리 다음 번호로.
|
||||
`taken` = 이미 번호 있는 열 `{열 id: 8자리}` · `heads` = 열 id → 제목행 ·
|
||||
`retired` = 지운 열의 번호(다시 안 줌 — 「가장 큰 번호」 · 겹침 셈에만 듦)."""
|
||||
parts = _heads(col)
|
||||
codes = [*taken.values(), *(retired or [])]
|
||||
mates = [(heads[cid], code) for cid, code in taken.items() if cid in heads]
|
||||
|
||||
def next_of(prefix: str) -> str:
|
||||
at = slice(len(prefix), len(prefix) + 2)
|
||||
found = [int(c[at]) for c in codes if c.startswith(prefix)]
|
||||
return f"{max(found, default=0) + 1:02d}"
|
||||
|
||||
code = ""
|
||||
for depth, head in enumerate(parts):
|
||||
if depth and head is None:
|
||||
code += "00"
|
||||
continue
|
||||
at = slice(2 * depth, 2 * depth + 2)
|
||||
group = parts[: depth + 1]
|
||||
same = (c[at] for h, c in mates if h[: depth + 1] == group and c.startswith(code))
|
||||
leaf = depth == _LEVELS - 1
|
||||
code += (None if leaf else next(same, None)) or next_of(code)
|
||||
if code in codes:
|
||||
code = code[:-2] + next_of(code[:-2])
|
||||
return code
|
||||
def _serial(taken: dict[str, str], retired: list[str] | None = None) -> str:
|
||||
"""새 항목 번호 — 자리 뜻 없는 일련번호(있던 번호 · 지운 번호 가운데 가장 큰 수 + 1 · 8자리)."""
|
||||
codes = [int(c) for c in [*taken.values(), *(retired or [])] if str(c).isdigit()]
|
||||
return f"{max(codes, default=0) + 1:08d}"
|
||||
|
||||
|
||||
def _new_code(column: str) -> str:
|
||||
"""이 열이 쓸 8자리 — 짝 파일에 이미 있으면 그것 · 없으면 새로(잠금 안에서 부름)."""
|
||||
taken = _codes()
|
||||
if column in taken:
|
||||
return taken[column]
|
||||
cols = _structure_columns()
|
||||
col = next((c for c in cols if c["id"] == column), {"id": column})
|
||||
return _pick_code(col, taken, {c["id"]: _heads(c) for c in cols}, _retired())
|
||||
return taken[column] if column in taken else _serial(taken, _retired())
|
||||
|
||||
|
||||
def _retired() -> list[str]:
|
||||
@@ -447,7 +415,7 @@ def _move_basis() -> list[str]:
|
||||
|
||||
def renumber() -> dict[str, list[str]]:
|
||||
"""한 번 — 옛 산출근거를 면적산출로 옮김 · 옛 번호(6자리 · 구-NN · 표-NN · 없음)를 8자리로
|
||||
(6자리는 끝에 00 · 없는 열 차례 = 종류 · 분류1 · 분류2 · 분류3 차례) ·
|
||||
(6자리는 끝에 00 · 번호 없는 열은 열 차례로 일련번호) ·
|
||||
구조물 아닌 열 파일 지움 · 빠진 파일(재료산출 등) 만듦. 내용은 그대로 · 두 번 돌려도 같음."""
|
||||
moved = _move_basis()
|
||||
gone = prune_removed_structures()
|
||||
@@ -455,12 +423,11 @@ def renumber() -> dict[str, list[str]]:
|
||||
made: list[str] = []
|
||||
with _LOCK:
|
||||
cols = _structure_columns()
|
||||
heads = {c["id"]: _heads(c) for c in cols}
|
||||
taken = _codes()
|
||||
retired = _retired()
|
||||
for col in cols:
|
||||
if col["id"] not in taken:
|
||||
taken[col["id"]] = _pick_code(col, taken, heads, retired)
|
||||
taken[col["id"]] = _serial(taken, retired)
|
||||
docs = {kind: dict(_docs(kind)) for kind in _KEY}
|
||||
for col in cols:
|
||||
cid = col["id"]
|
||||
@@ -485,7 +452,7 @@ def _server_values(doc: Any) -> dict[str, Any]:
|
||||
|
||||
|
||||
def backfill_missing_structures() -> list[str]:
|
||||
"""구조물 열마다 세 파일이 있게 — 없는 것만 열 차례로 새로 만듦(그 무리 다음 번호).
|
||||
"""구조물 열마다 세 파일이 있게 — 없는 것만 열 차례로 새로 만듦(다음 일련번호).
|
||||
그 사이 남이 만들었으면 그대로 둠."""
|
||||
made: list[str] = []
|
||||
for cid in _column_ids():
|
||||
@@ -493,61 +460,19 @@ def backfill_missing_structures() -> list[str]:
|
||||
return made
|
||||
|
||||
|
||||
# ── 구조물집계표조합 ─────────────────────
|
||||
# 구조물 열마다 줄 하나(제어용 장부 · 관리자는 집계표 화면으로 고침 · 서버가 집계표로 맞춤) ·
|
||||
# 키 = SS + 6자리 · 한 번 준 키는 그 열에 남음 · 지운 열의 키는 다시 안 씀(`다음` 이 기억)
|
||||
# ── 구조물집계표조합(SS 장부) — `M02_MasterTemplete_Book` 몫 ─────────────────────
|
||||
COMBO_FILE = layers.COMBO_FILE
|
||||
_COMBO_HEAD = "SS"
|
||||
_COMBO_NOTE = (
|
||||
"구조물집계표 구조물 열(꼴=수)마다 한 줄 · 서버가 집계표 [저장] 때 맞춤(손으로 고치지 않음) · "
|
||||
"키 SS 는 한 번 주면 안 바뀌고 지운 열의 키는 다시 안 씀"
|
||||
)
|
||||
|
||||
|
||||
def _combo_row(key: str, col: dict[str, Any], numbers: dict[str, str]) -> dict[str, Any]:
|
||||
kind, level1, level2, level3 = _heads(col)
|
||||
return {
|
||||
"키": key,
|
||||
"열": col["id"],
|
||||
"종류": kind,
|
||||
"분류1": level1,
|
||||
"분류2": level2,
|
||||
"분류3": level3,
|
||||
"단위": col.get("단위"),
|
||||
"일위대가": col.get("일위대가"),
|
||||
"자재조합": col.get("자재조합"),
|
||||
**{field: numbers.get(field, "") for _, field, _, _ in PARTS},
|
||||
"바인딩": col.get("바인딩"),
|
||||
"펼침": col.get("펼침"),
|
||||
}
|
||||
|
||||
|
||||
def sync_combos() -> dict[str, Any]:
|
||||
"""조합 표를 집계표에 맞춤 — 새 열 = 새 SS 키 · 지운 열 = 줄 지움 · 고친 열 = 칸 갱신 ·
|
||||
줄 차례 = 집계표 열 차례. 바뀐 것이 없으면 파일을 안 씀."""
|
||||
path = root() / COMBO_FILE
|
||||
with _LOCK:
|
||||
old = json.loads(path.read_bytes()) if path.is_file() else {}
|
||||
keys = {row["열"]: row["키"] for row in old.get("줄", [])}
|
||||
taken = [int(k[len(_COMBO_HEAD) :]) for k in keys.values()]
|
||||
after = max(old.get("다음", 1), max(taken, default=0) + 1)
|
||||
numbers = structure_numbers()
|
||||
rows = []
|
||||
for col in _structure_columns():
|
||||
if col["id"] not in keys:
|
||||
keys[col["id"]] = f"{_COMBO_HEAD}{after:06d}"
|
||||
after += 1
|
||||
rows.append(_combo_row(keys[col["id"]], col, numbers.get(col["id"], {})))
|
||||
doc = {"그룹": "구조물집계표조합", "판": 1, "비고": _COMBO_NOTE, "다음": after}
|
||||
if old.get("지운번호"):
|
||||
doc["지운번호"] = old["지운번호"] # 지운 열의 도번 8자리 — 다시 안 줌
|
||||
doc["줄"] = rows
|
||||
if doc != old:
|
||||
atomic_write_json(path, doc)
|
||||
return doc
|
||||
"""조합 장부를 집계표에 맞춤(`Book.sync`) — 부를 때 읽음(장부 파일이 이 파일을 부름)."""
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Book as book
|
||||
|
||||
return book.sync()
|
||||
|
||||
|
||||
def read_combos() -> dict[str, Any]:
|
||||
"""조합 표 — 파일이 아직 없으면 지금 열 전부로 처음 한 번 만듦."""
|
||||
path = root() / COMBO_FILE
|
||||
return json.loads(path.read_bytes()) if path.is_file() else sync_combos()
|
||||
"""조합 장부 — 파일이 아직 없으면 지금 열 전부로 처음 한 번 만듦(`Book.read`)."""
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Book as book
|
||||
|
||||
return book.read()
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
"""M02 집계 항목 **폴더 구조**(PLAN 26-1 · 26-2a) — 항목 경로 · 집계표 맞춤 · 나무 고침.
|
||||
|
||||
항목 = 구조물집계표의 구조물 열(꼴=수) 하나 · 경로 = 폴더 이름 배열(깊이 자유 · 마지막이 항목 이름)
|
||||
· 이름 = 경로를 이은 글. 자료의 자리는 집계표 한 파일 — 열 `머리`(제목행)가 곧 경로(빈 칸은 건너뜀)
|
||||
이고, 열에 `경로` 를 같이 적어 둠. 머리 줄 수(`층`)는 가장 깊은 경로에 맞춤(넷보다 적게는 안 줄임 —
|
||||
옛 코드가 종류 · 분류1~3 넷을 읽음). 빈 폴더는 집계표 `폴더`(경로 목록)에 적음.
|
||||
|
||||
나무 고침(폴더 더하기 · 항목 더하기 · 이름 바꾸기 · 옮기기 · 지우기)은 집계표 문서를 고쳐
|
||||
돌려줄 뿐 — 저장은 부르는 쪽이 집계표 저장(`store.write`)으로 해서 표 저장과 한 줄기
|
||||
(번호 · SS 장부 · 세 파일 · 입력 연결 파일 지우기)를 탐. 이 파일은 저장소를 부르지 않음(순수 함수).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
MIN_LEVELS = 4 # 옛 약속 — 종류 · 분류1 · 분류2 · 분류3
|
||||
_OLD_NAMES = ("종류", "분류1", "분류2", "분류3")
|
||||
OPS = ("add-folder", "add-item", "rename", "move", "delete")
|
||||
MAX_NAME = 60
|
||||
_BAD = re.compile(r"[\x00-\x1f]")
|
||||
|
||||
|
||||
class TreeError(Exception):
|
||||
def __init__(self, status: int, detail: Any):
|
||||
super().__init__(detail)
|
||||
self.status, self.detail = status, detail
|
||||
|
||||
|
||||
# ── 경로 읽기 ─────────────────────────────────────────
|
||||
|
||||
|
||||
def _clean(parts: Any) -> list[str]:
|
||||
if not isinstance(parts, list):
|
||||
return []
|
||||
return [str(p).strip() for p in parts if p is not None and str(p).strip()]
|
||||
|
||||
|
||||
def path_of(col: dict[str, Any]) -> list[str]:
|
||||
"""열의 경로 — 머리(제목행)의 빈 칸을 건너뛴 이름들 · 머리가 없으면 `경로`."""
|
||||
heads = col.get("머리")
|
||||
return _clean(heads) if isinstance(heads, list) else _clean(col.get("경로"))
|
||||
|
||||
|
||||
def row_path(row: dict[str, Any]) -> list[str]:
|
||||
"""SS 장부 줄의 경로 — `경로` 가 없던 옛 줄은 종류 · 분류1~3 에서(빈 분류 건너뜀)."""
|
||||
got = _clean(row.get("경로"))
|
||||
return got or _clean([row.get(k) for k in _OLD_NAMES])
|
||||
|
||||
|
||||
def name_of(path: list[str]) -> str:
|
||||
return " ".join(path)
|
||||
|
||||
|
||||
def old_fields(path: list[str]) -> dict[str, str | None]:
|
||||
"""옛 칸(종류 · 분류1~3) — 경로 앞 넷(다른 코드가 읽음)."""
|
||||
padded = [*path, *[None] * MIN_LEVELS][:MIN_LEVELS]
|
||||
return dict(zip(_OLD_NAMES, padded))
|
||||
|
||||
|
||||
def is_item(col: Any) -> bool:
|
||||
return isinstance(col, dict) and bool(col.get("id")) and col.get("꼴") == "수"
|
||||
|
||||
|
||||
def _items(doc: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
return [c for c in doc.get("열") or [] if is_item(c)]
|
||||
|
||||
|
||||
# ── 집계표 맞춤 ───────────────────────────────────────
|
||||
|
||||
|
||||
def _trimmed(heads: Any) -> list[Any]:
|
||||
out = list(heads) if isinstance(heads, list) else []
|
||||
while out and (out[-1] is None or not str(out[-1]).strip()):
|
||||
out.pop()
|
||||
return out
|
||||
|
||||
|
||||
def normalize_table(doc: dict[str, Any]) -> dict[str, Any]:
|
||||
"""저장 앞 맞춤 — 항목 열: `경로` = 머리의 빈 칸 건너뛴 이름 · 머리 = 경로를 머리 줄 수만큼
|
||||
채움 · 머리 줄 수(`층`) = 가장 깊은 경로(넷보다 적게는 안 줄임) ·
|
||||
`폴더` = 경로 목록(겹침 · 빈 것 뺌).
|
||||
글 열(NO · 측점 · 비고)은 머리 줄 수만 맞춤."""
|
||||
out = copy.deepcopy(doc)
|
||||
cols = [c for c in out.get("열") or [] if isinstance(c, dict)]
|
||||
depth = max(
|
||||
[MIN_LEVELS, *(len(path_of(c)) for c in cols if is_item(c))]
|
||||
+ [len(_trimmed(c.get("머리"))) for c in cols if not is_item(c)]
|
||||
)
|
||||
names = [str(n) for n in (out.get("층") or [])][:depth]
|
||||
names += [_OLD_NAMES[i] if i < MIN_LEVELS else f"분류{i}" for i in range(len(names), depth)]
|
||||
out["층"] = names
|
||||
for col in cols:
|
||||
if is_item(col):
|
||||
path = path_of(col)
|
||||
col["경로"] = path
|
||||
col["머리"] = [*path, *[None] * depth][:depth]
|
||||
else:
|
||||
col["머리"] = [*_trimmed(col.get("머리")), *[None] * depth][:depth]
|
||||
folders: list[list[str]] = []
|
||||
for raw in out.get("폴더") or []:
|
||||
path = _clean(raw)
|
||||
if path and path not in folders:
|
||||
folders.append(path)
|
||||
out["폴더"] = folders
|
||||
return out
|
||||
|
||||
|
||||
def folders_of(doc: dict[str, Any]) -> list[list[str]]:
|
||||
"""나무의 폴더 전부 — 적어 둔 빈 폴더 + 항목 경로의 앞마디(차례 = 표 차례)."""
|
||||
out: list[list[str]] = []
|
||||
for col in _items(doc):
|
||||
path = path_of(col)
|
||||
for n in range(1, len(path)):
|
||||
if path[:n] not in out:
|
||||
out.append(path[:n])
|
||||
for path in doc.get("폴더") or []:
|
||||
path = _clean(path)
|
||||
if path and path not in out:
|
||||
out.append(path)
|
||||
return out
|
||||
|
||||
|
||||
# ── 나무 고침 ─────────────────────────────────────────
|
||||
|
||||
|
||||
def _name(value: Any, what: str = "이름") -> str:
|
||||
text = str(value).strip() if isinstance(value, str) else ""
|
||||
if not text or len(text) > MAX_NAME or _BAD.search(text):
|
||||
raise TreeError(422, f"「{what}」 — 빈 글 · {MAX_NAME}자 넘음 · 조절 문자는 못 씀")
|
||||
return text
|
||||
|
||||
|
||||
def _path(value: Any, what: str = "경로") -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, list):
|
||||
raise TreeError(422, f"「{what}」 이 폴더 이름 목록이 아님")
|
||||
return [_name(v, what) for v in value]
|
||||
|
||||
|
||||
def _find(doc: dict[str, Any], key: str | None, ids: dict[str, str]) -> dict[str, Any]:
|
||||
"""SS 키(또는 열 id)로 항목 열 — 없으면 404."""
|
||||
cid = ids.get(str(key), str(key)) if key else None
|
||||
col = next((c for c in _items(doc) if c["id"] == cid), None)
|
||||
if col is None:
|
||||
raise TreeError(404, f"없는 항목 「{key}」")
|
||||
return col
|
||||
|
||||
|
||||
def _item_paths(doc: dict[str, Any], skip: list[dict[str, Any]] | None = None) -> list[list[str]]:
|
||||
return [path_of(c) for c in _items(doc) if not skip or all(c is not s for s in skip)]
|
||||
|
||||
|
||||
def _taken_item(doc: dict[str, Any], path: list[str], skip=None) -> None:
|
||||
if path in _item_paths(doc, skip):
|
||||
raise TreeError(409, f"이미 있는 항목 「{name_of(path)}」")
|
||||
|
||||
|
||||
def _set_path(col: dict[str, Any], path: list[str]) -> None:
|
||||
col["경로"] = path
|
||||
col["머리"] = list(path) # 머리 줄 수는 저장 맞춤(`normalize_table`)이 채움
|
||||
|
||||
|
||||
def _common(a: list[str], b: list[str]) -> int:
|
||||
n = 0
|
||||
while n < min(len(a), len(b)) and a[n] == b[n]:
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
def _place(doc: dict[str, Any], col: dict[str, Any]) -> None:
|
||||
"""열을 가장 가까운 형제 뒤에 끼움(같은 앞마디가 긴 항목 중 마지막) — 제목행 병합이 이어지게.
|
||||
항목이 없으면 글 열 가운데 마지막 앞(비고 앞) · 그것도 없으면 끝."""
|
||||
cols = doc.setdefault("열", [])
|
||||
path = path_of(col)
|
||||
best, at = -1, None
|
||||
for i, other in enumerate(cols):
|
||||
if is_item(other):
|
||||
share = _common(path, path_of(other))
|
||||
if share >= best:
|
||||
best, at = share, i
|
||||
if at is None:
|
||||
at = len(cols) - 2 if len(cols) >= 2 else len(cols) - 1
|
||||
cols.insert(at + 1, col)
|
||||
|
||||
|
||||
def _fresh_id(doc: dict[str, Any]) -> str:
|
||||
used = {c.get("id") for c in doc.get("열") or [] if isinstance(c, dict)}
|
||||
n = 1
|
||||
while f"item{n}" in used:
|
||||
n += 1
|
||||
return f"item{n}"
|
||||
|
||||
|
||||
def _under(path: list[str], folder: list[str]) -> bool:
|
||||
return path[: len(folder)] == folder
|
||||
|
||||
|
||||
def _folder(doc: dict[str, Any], where: list[str], what: str) -> None:
|
||||
"""고칠 폴더가 있는지 — 경로가 비었으면 422 · 없으면 404."""
|
||||
if not where:
|
||||
raise TreeError(422, f"{what} 폴더 「경로」 가 없음")
|
||||
if where not in folders_of(doc) and not any(_under(p, where) for p in _item_paths(doc)):
|
||||
raise TreeError(404, f"없는 폴더 「{name_of(where)}」")
|
||||
|
||||
|
||||
def apply(
|
||||
doc: dict[str, Any], body: dict[str, Any], ids: dict[str, str] | None = None
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""나무 고침 하나 → (새 집계표 문서, 알림). `ids` = SS 키 → 열 id(항목을 키로 부를 때).
|
||||
본문 `{op, 경로, 이름?, 새경로?, 키?, 단위?}` — 26-1 약속 모양 · 판은 부르는 쪽이 봄."""
|
||||
out = copy.deepcopy(doc)
|
||||
ids = ids or {}
|
||||
op = body.get("op")
|
||||
if op not in OPS:
|
||||
raise TreeError(422, f"모르는 고침 「{op}」 — {' · '.join(OPS)}")
|
||||
where = _path(body.get("경로"))
|
||||
key = body.get("키")
|
||||
note: dict[str, Any] = {"op": op}
|
||||
folders = out.setdefault("폴더", [])
|
||||
|
||||
if op == "add-folder":
|
||||
made = [*where, _name(body.get("이름"))]
|
||||
if made in folders_of(out) or made in _item_paths(out):
|
||||
raise TreeError(409, f"이미 있는 폴더 · 항목 「{name_of(made)}」")
|
||||
folders.append(made)
|
||||
note["경로"] = made
|
||||
elif op == "add-item":
|
||||
made = [*where, _name(body.get("이름"))]
|
||||
_taken_item(out, made)
|
||||
col: dict[str, Any] = {"id": _fresh_id(out), "단위": body.get("단위"), "꼴": "수"}
|
||||
col.update({"일위대가": None, "자재조합": None})
|
||||
_set_path(col, made)
|
||||
_place(out, col)
|
||||
note.update({"경로": made, "열": col["id"]})
|
||||
elif op == "rename":
|
||||
new = _name(body.get("이름"))
|
||||
if key:
|
||||
col = _find(out, key, ids)
|
||||
path = path_of(col)
|
||||
moved = [*path[:-1], new]
|
||||
_taken_item(out, moved, [col])
|
||||
_set_path(col, moved)
|
||||
note["경로"] = moved
|
||||
else:
|
||||
_folder(out, where, "바꿀")
|
||||
moved = [*where[:-1], new]
|
||||
if moved != where and (
|
||||
moved in folders_of(out) or any(_under(p, moved) for p in _item_paths(out))
|
||||
):
|
||||
raise TreeError(409, f"이미 있는 폴더 「{name_of(moved)}」")
|
||||
_move_folder(out, where, moved)
|
||||
note["경로"] = moved
|
||||
elif op == "move":
|
||||
to = _path(body.get("새경로"), "새경로")
|
||||
if key:
|
||||
col = _find(out, key, ids)
|
||||
moved = [*to, path_of(col)[-1]]
|
||||
_taken_item(out, moved, [col])
|
||||
out["열"] = [c for c in out["열"] if c is not col]
|
||||
_set_path(col, moved)
|
||||
_place(out, col)
|
||||
note["경로"] = moved
|
||||
else:
|
||||
_folder(out, where, "옮길")
|
||||
if _under(to, where):
|
||||
raise TreeError(422, "폴더를 제 안으로 옮길 수 없음")
|
||||
moved = [*to, where[-1]]
|
||||
if moved in folders_of(out) or any(_under(p, moved) for p in _item_paths(out)):
|
||||
raise TreeError(409, f"이미 있는 폴더 「{name_of(moved)}」")
|
||||
_move_folder(out, where, moved, replace=True)
|
||||
note["경로"] = moved
|
||||
else: # delete
|
||||
if key:
|
||||
gone = [_find(out, key, ids)]
|
||||
else:
|
||||
_folder(out, where, "지울")
|
||||
gone = [c for c in _items(out) if _under(path_of(c), where)]
|
||||
out["폴더"] = [f for f in folders if not _under(_clean(f), where)]
|
||||
out["열"] = [c for c in out["열"] if all(c is not g for g in gone)]
|
||||
note["지운항목"] = [g["id"] for g in gone]
|
||||
return out, note
|
||||
|
||||
|
||||
def _move_folder(
|
||||
doc: dict[str, Any], old: list[str], new: list[str], replace: bool = False
|
||||
) -> None:
|
||||
"""폴더 `old` 아래 항목 · 빈 폴더의 앞마디를 `new` 로 — 이름 바꾸기는 제자리 ·
|
||||
옮기기(`replace`)는 새 형제 곁으로 열 차례도 옮김."""
|
||||
moving = [c for c in _items(doc) if _under(path_of(c), old)]
|
||||
if replace:
|
||||
doc["열"] = [c for c in doc["열"] if all(c is not m for m in moving)]
|
||||
for col in moving:
|
||||
_set_path(col, [*new, *path_of(col)[len(old) :]])
|
||||
if replace:
|
||||
_place(doc, col)
|
||||
doc["폴더"] = [
|
||||
[*new, *_clean(f)[len(old) :]] if _under(_clean(f), old) else f
|
||||
for f in doc.get("폴더") or []
|
||||
]
|
||||
@@ -1,12 +1,15 @@
|
||||
"""프로젝트 집계 항목 복사본 길 — `/api/projects/{id}/items…` (PLAN 25-1b · 25-2b · 25-2e).
|
||||
"""프로젝트 구조물 집계 항목 복사본 길 — `/api/projects/{id}/items…` (PLAN 25-1b · 26-1 · 26-2b).
|
||||
|
||||
GET …/items 목록(이름 · 원본 · 가져온날 · 마스터 바뀜)
|
||||
POST …/items {layer, owner, key} 미리 가져오기(구조물에 안 이은 항목) → {폴더, 만듦, 가져온날}
|
||||
GET …/items/{폴더}/{종류} {종류, 판, 내용}
|
||||
PUT …/items/{폴더}/{종류} {판, 내용} — 판이 다르면 409 · 면적 · 재료는 서버 계산값
|
||||
POST …/items/{폴더}/refresh 새로 가져오기(입력값 · 고른자재는 같은 키면 살림)
|
||||
POST …/items/{폴더}/calc 복사본만으로 계산(세 비목 · 계 · 빠진 입력)
|
||||
종류 = 도면 · 면적 · 재료 · 자재조합 · 입력연결 · 입력값 · 고른자재.
|
||||
폴더 = 구조물 id(`structure_id` · 관 지점은 `pipe@측점`) — 같은 항목을 고른 구조물도 복사본은 따로.
|
||||
GET …/items 목록(원본 · 가져온날 · 마스터바뀜 · 원본없음)
|
||||
POST …/items {structure, layer, owner, key} 미리 가져오기 → {폴더, 만듦, 가져온날}
|
||||
POST …/items/new {structure, 경로, 이름, 단위} 새 항목(개인 층에도) · 회사 없음 409
|
||||
GET …/items/{폴더}/{종류} {종류, 판, 내용}
|
||||
PUT …/items/{폴더}/{종류} {판, 내용} — 판이 다르면 409 · 면적 · 재료는 서버 계산값
|
||||
POST …/items/{폴더}/reapply 재적용(입력값 · 고른자재는 같은 키면 살림 · `덮임` 알림)
|
||||
POST …/items/{폴더}/save-as {to, 경로, 이름} 개인 층에 새 항목으로(단가 묶음은 안 가져감)
|
||||
POST …/items/{폴더}/calc 복사본만으로 계산(세 비목 · 계 · 빠진 입력)
|
||||
종류 = 도면 · 면적 · 재료 · 자재조합 · 입력연결 · 입력값 · 고른자재 · `refresh` = 옛 이름(재적용).
|
||||
⚠ 등록은 `main.py`(로그인만) — 프로젝트 권한(같은 회사)은 여기서 `_project` 로 봄(403 · 404).
|
||||
"""
|
||||
|
||||
@@ -23,6 +26,7 @@ from common_util.common_util_auth import verify_session
|
||||
from common_util.common_util_summary_item import parse_summary_item
|
||||
from M02_MasterTemplete import M02_Item_Copy as copies
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layer_routes
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete.M02_Item_Source import item_name
|
||||
|
||||
router = APIRouter(prefix="/api/projects", tags=["M02 Project Items"])
|
||||
@@ -36,8 +40,9 @@ class WriteBody(BaseModel):
|
||||
|
||||
|
||||
class PrefetchBody(BaseModel):
|
||||
"""`{layer, owner, key}`(이름은 층 SS 줄에서) — 옛 모양 `{summary_item}` 도 받음."""
|
||||
"""`{structure, layer, owner, key}`(이름은 층 SS 줄에서) — 옛 모양 `{summary_item}` 도 받음."""
|
||||
|
||||
structure: str | None = None
|
||||
layer: str | None = None
|
||||
owner: str | None = None
|
||||
key: str | None = None
|
||||
@@ -45,6 +50,28 @@ class PrefetchBody(BaseModel):
|
||||
summary_item: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class SaveAsBody(BaseModel):
|
||||
to: Literal["personal"] = "personal"
|
||||
경로: list[str] = []
|
||||
이름: str | None = None
|
||||
|
||||
|
||||
class NewBody(BaseModel):
|
||||
structure: str
|
||||
경로: list[str] = []
|
||||
이름: str | None = None
|
||||
단위: str | None = None
|
||||
|
||||
|
||||
def _me(session: dict[str, Any]) -> tuple[int, int]:
|
||||
"""개인 층 주인(회사 · 사용자) — 회사 없는 사용자는 409."""
|
||||
if session.get("company_id") is None:
|
||||
raise HTTPException(
|
||||
status_code=409, detail="회사부터 등록 — 개인 층은 회사 연결이 필요합니다."
|
||||
)
|
||||
return int(session["company_id"]), int(session["user_id"])
|
||||
|
||||
|
||||
async def _root(session: dict[str, Any], project_id: str):
|
||||
return (await layer_routes._project(session, project_id))["root"]
|
||||
|
||||
@@ -52,6 +79,8 @@ async def _root(session: dict[str, Any], project_id: str):
|
||||
async def _run(fn, *args):
|
||||
try:
|
||||
return await asyncio.to_thread(fn, *args)
|
||||
except store.StoreError as error:
|
||||
raise HTTPException(status_code=error.status, detail=error.detail) from error
|
||||
except copies.Stale as error:
|
||||
raise HTTPException(
|
||||
status_code=409, detail={"message": str(error), "판": error.current}
|
||||
@@ -73,6 +102,8 @@ async def list_items(
|
||||
async def prefetch_item(
|
||||
project_id: str, body: PrefetchBody, session: dict[str, Any] = Depends(verify_session)
|
||||
) -> dict[str, Any]:
|
||||
if not body.structure:
|
||||
raise HTTPException(status_code=400, detail="미리 가져올 구조물(structure)이 필요합니다.")
|
||||
raw = body.summary_item or {"layer": body.layer, "owner": body.owner, "key": body.key}
|
||||
try: # 모양 먼저(이름은 자리만) — 그다음 층 SS 줄에서 이름
|
||||
item = parse_summary_item({**raw, "name": raw.get("name") or body.name or "-"})
|
||||
@@ -83,7 +114,19 @@ async def prefetch_item(
|
||||
item["name"] = item_name(item)
|
||||
except LookupError as error:
|
||||
raise HTTPException(status_code=404, detail=str(error)) from error
|
||||
return await _run(copies.prefetch, await _root(session, project_id), item)
|
||||
root = await _root(session, project_id)
|
||||
return await _run(copies.prefetch, root, body.structure, item)
|
||||
|
||||
|
||||
@router.post("/{project_id}/items/new")
|
||||
async def new_item(
|
||||
project_id: str, body: NewBody, session: dict[str, Any] = Depends(verify_session)
|
||||
) -> dict[str, Any]:
|
||||
root = await _root(session, project_id)
|
||||
company, user = _me(session)
|
||||
return await _run(
|
||||
copies.new_item, root, body.structure, company, user, body.경로, body.이름, body.단위
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/items/{folder}/{kind}")
|
||||
@@ -112,11 +155,24 @@ async def write_item_file(
|
||||
return await _run(copies.write_file, root, folder, kind, body.판, body.내용, _recalc)
|
||||
|
||||
|
||||
@router.post("/{project_id}/items/{folder}/reapply")
|
||||
@router.post("/{project_id}/items/{folder}/refresh")
|
||||
async def refresh_item(
|
||||
async def reapply_item(
|
||||
project_id: str, folder: str, session: dict[str, Any] = Depends(verify_session)
|
||||
) -> dict[str, Any]:
|
||||
return await _run(copies.refresh, await _root(session, project_id), folder)
|
||||
return await _run(copies.reapply, await _root(session, project_id), folder)
|
||||
|
||||
|
||||
@router.post("/{project_id}/items/{folder}/save-as")
|
||||
async def save_item_as(
|
||||
project_id: str,
|
||||
folder: str,
|
||||
body: SaveAsBody,
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
) -> dict[str, Any]:
|
||||
root = await _root(session, project_id)
|
||||
company, user = _me(session)
|
||||
return await _run(copies.save_as, root, folder, company, user, body.경로, body.이름)
|
||||
|
||||
|
||||
@router.post("/{project_id}/items/{folder}/calc")
|
||||
|
||||
@@ -88,13 +88,15 @@ def collect_items(
|
||||
) -> list[dict[str, Any]]:
|
||||
"""설계 정본 → 구조물 목록.
|
||||
|
||||
한 건 = `{type_id, placement, chainage_m, start_m, end_m, options, 관연장}`.
|
||||
한 건 = `{type_id, placement, chainage_m, start_m, end_m, options, 관연장, summary_item,
|
||||
복사본}` ·
|
||||
`복사본` = 그 구조물 집계 항목 복사본 폴더의 구조물 id(관 지점은 `pipe@측점` · 26-2b).
|
||||
"""
|
||||
types = types or structure_type_map()
|
||||
lengths = pipe_lengths or {}
|
||||
items: list[dict[str, Any]] = []
|
||||
_, structures = load_structures(str(project_root))
|
||||
for item in structures:
|
||||
for index, item in enumerate(structures):
|
||||
definition = types.get(item.type_id)
|
||||
if definition is not None and definition.managed_by:
|
||||
continue # 관 지점 정본이 주인 — 옛 저장분이 남아 있어도 두 번 안 셈
|
||||
@@ -107,6 +109,7 @@ def collect_items(
|
||||
"end_m": item.end_m,
|
||||
"options": _with_defaults(definition, dict(item.options or {})),
|
||||
"summary_item": item.summary_item,
|
||||
"복사본": item.structure_id or f"#{index}",
|
||||
}
|
||||
)
|
||||
for point in read_pipe_points_file(pipe_points_path_in(Path(project_root))):
|
||||
@@ -122,6 +125,7 @@ def collect_items(
|
||||
"options": _with_defaults(types.get(type_id), dict(point.options or {})),
|
||||
"관연장": _nearest(lengths, chainage) if type_id == "pipe" else None,
|
||||
"summary_item": point.summary_item,
|
||||
"복사본": f"pipe@{round(chainage, 3)}",
|
||||
}
|
||||
)
|
||||
return items
|
||||
@@ -593,7 +597,8 @@ def assign_items(
|
||||
"""지정 항목 모드 준비 — 구조물마다 스냅샷 열 정의로 고른 열을 정함(PLAN 21-0 · 21-3).
|
||||
|
||||
- 미지정 · 스냅샷 없음 = 표에 안 넣음(자동 맞춤 없음) · 알림 한 줄씩
|
||||
- 시스템 층 항목 = 같은 id 열 자리에 스냅샷 열 정의(원본이 바뀌어도 지정한 때 값)
|
||||
- 열 정의 = 그 구조물 복사본의 것(26-2b · 원본이 바뀌거나 지워져도 지정한 때 값)
|
||||
- 시스템 층 항목 = 같은 id 열 자리
|
||||
- 회사 · 개인 층 항목 = `열id@스냅샷폴더` 새 열(시스템 열과 id · 묶음이 안 겹치게)
|
||||
돌려주는 것 = (열을 맞춘 문서, `열` · `열묶음` 을 단 구조물, 알림).
|
||||
"""
|
||||
@@ -606,7 +611,8 @@ def assign_items(
|
||||
unassigned = missing = 0
|
||||
for item in items:
|
||||
chosen = item.get("summary_item")
|
||||
column = (read_snapshot(project_root, chosen) or {}).get("열") if chosen else None
|
||||
owner = item.get("복사본")
|
||||
column = (read_snapshot(project_root, owner) or {}).get("열") if chosen and owner else None
|
||||
if not chosen or not isinstance(column, dict) or not column.get("id"):
|
||||
unassigned += not chosen
|
||||
missing += bool(chosen)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,19 @@
|
||||
{
|
||||
"그룹": "구조물집계표조합",
|
||||
"판": 1,
|
||||
"비고": "구조물집계표 구조물 열(꼴=수)마다 한 줄 · 서버가 집계표 [저장] 때 맞춤(손으로 고치지 않음) · 키 SS 는 한 번 주면 안 바뀌고 지운 열의 키는 다시 안 씀",
|
||||
"비고": "구조물집계표 구조물 열(꼴=수)마다 한 줄 · 서버가 집계표 [저장] 때 맞춤(손으로 고치지 않음) · 키 SS 는 한 번 주면 안 바뀌고 지운 열의 키는 다시 안 씀 · 경로 = 폴더 이름 배열",
|
||||
"다음": 52,
|
||||
"줄": [
|
||||
{
|
||||
"키": "SS000001",
|
||||
"열": "rv",
|
||||
"경로": [
|
||||
"돌기슭막이",
|
||||
"메쌓기",
|
||||
"1.2m",
|
||||
"기초유"
|
||||
],
|
||||
"이름": "돌기슭막이 메쌓기 1.2m 기초유",
|
||||
"종류": "돌기슭막이",
|
||||
"분류1": "메쌓기",
|
||||
"분류2": "1.2m",
|
||||
@@ -72,6 +79,13 @@
|
||||
{
|
||||
"키": "SS000002",
|
||||
"열": "c4",
|
||||
"경로": [
|
||||
"돌기슭막이",
|
||||
"메쌓기",
|
||||
"1.2m",
|
||||
"기초무, 소단"
|
||||
],
|
||||
"이름": "돌기슭막이 메쌓기 1.2m 기초무, 소단",
|
||||
"종류": "돌기슭막이",
|
||||
"분류1": "메쌓기",
|
||||
"분류2": "1.2m",
|
||||
@@ -88,6 +102,13 @@
|
||||
{
|
||||
"키": "SS000003",
|
||||
"열": "c1",
|
||||
"경로": [
|
||||
"돌기슭막이",
|
||||
"메쌓기",
|
||||
"1.5m",
|
||||
"기초유"
|
||||
],
|
||||
"이름": "돌기슭막이 메쌓기 1.5m 기초유",
|
||||
"종류": "돌기슭막이",
|
||||
"분류1": "메쌓기",
|
||||
"분류2": "1.5m",
|
||||
@@ -104,6 +125,11 @@
|
||||
{
|
||||
"키": "SS000004",
|
||||
"열": "c5",
|
||||
"경로": [
|
||||
"돌기슭막이",
|
||||
"찰쌓기"
|
||||
],
|
||||
"이름": "돌기슭막이 찰쌓기",
|
||||
"종류": "돌기슭막이",
|
||||
"분류1": "찰쌓기",
|
||||
"분류2": null,
|
||||
@@ -120,6 +146,12 @@
|
||||
{
|
||||
"키": "SS000005",
|
||||
"열": "pv_b",
|
||||
"경로": [
|
||||
"콘크리트포장",
|
||||
"T=(두께마다)",
|
||||
"B"
|
||||
],
|
||||
"이름": "콘크리트포장 T=(두께마다) B",
|
||||
"종류": "콘크리트포장",
|
||||
"분류1": "T=(두께마다)",
|
||||
"분류2": "B",
|
||||
@@ -154,6 +186,12 @@
|
||||
{
|
||||
"키": "SS000006",
|
||||
"열": "pv_l",
|
||||
"경로": [
|
||||
"콘크리트포장",
|
||||
"T=(두께마다)",
|
||||
"L"
|
||||
],
|
||||
"이름": "콘크리트포장 T=(두께마다) L",
|
||||
"종류": "콘크리트포장",
|
||||
"분류1": "T=(두께마다)",
|
||||
"분류2": "L",
|
||||
@@ -188,6 +226,12 @@
|
||||
{
|
||||
"키": "SS000007",
|
||||
"열": "pv_w",
|
||||
"경로": [
|
||||
"콘크리트포장",
|
||||
"T=(두께마다)",
|
||||
"확폭"
|
||||
],
|
||||
"이름": "콘크리트포장 T=(두께마다) 확폭",
|
||||
"종류": "콘크리트포장",
|
||||
"분류1": "T=(두께마다)",
|
||||
"분류2": "확폭",
|
||||
@@ -222,6 +266,12 @@
|
||||
{
|
||||
"키": "SS000008",
|
||||
"열": "pv_a",
|
||||
"경로": [
|
||||
"콘크리트포장",
|
||||
"T=(두께마다)",
|
||||
"A"
|
||||
],
|
||||
"이름": "콘크리트포장 T=(두께마다) A",
|
||||
"종류": "콘크리트포장",
|
||||
"분류1": "T=(두께마다)",
|
||||
"분류2": "A",
|
||||
@@ -238,6 +288,12 @@
|
||||
{
|
||||
"키": "SS000009",
|
||||
"열": "pv_jt",
|
||||
"경로": [
|
||||
"콘크리트포장",
|
||||
"T=(두께마다)",
|
||||
"수축줄눈"
|
||||
],
|
||||
"이름": "콘크리트포장 T=(두께마다) 수축줄눈",
|
||||
"종류": "콘크리트포장",
|
||||
"분류1": "T=(두께마다)",
|
||||
"분류2": "수축줄눈",
|
||||
@@ -254,6 +310,12 @@
|
||||
{
|
||||
"키": "SS000010",
|
||||
"열": "c3",
|
||||
"경로": [
|
||||
"콘크리트포장",
|
||||
"T=(두께마다)",
|
||||
"새 분류2"
|
||||
],
|
||||
"이름": "콘크리트포장 T=(두께마다) 새 분류2",
|
||||
"종류": "콘크리트포장",
|
||||
"분류1": "T=(두께마다)",
|
||||
"분류2": "새 분류2",
|
||||
@@ -270,6 +332,12 @@
|
||||
{
|
||||
"키": "SS000011",
|
||||
"열": "pp_len",
|
||||
"경로": [
|
||||
"관공",
|
||||
"(관종·관경마다)",
|
||||
"관매설"
|
||||
],
|
||||
"이름": "관공 (관종·관경마다) 관매설",
|
||||
"종류": "관공",
|
||||
"분류1": "(관종·관경마다)",
|
||||
"분류2": "관매설",
|
||||
@@ -307,6 +375,12 @@
|
||||
{
|
||||
"키": "SS000012",
|
||||
"열": "pp_cp",
|
||||
"경로": [
|
||||
"관공",
|
||||
"(관종·관경마다)",
|
||||
"커플링밴드"
|
||||
],
|
||||
"이름": "관공 (관종·관경마다) 커플링밴드",
|
||||
"종류": "관공",
|
||||
"분류1": "(관종·관경마다)",
|
||||
"분류2": "커플링밴드",
|
||||
@@ -323,6 +397,12 @@
|
||||
{
|
||||
"키": "SS000013",
|
||||
"열": "pg_in",
|
||||
"경로": [
|
||||
"관보호공",
|
||||
"(형식마다)",
|
||||
"유입"
|
||||
],
|
||||
"이름": "관보호공 (형식마다) 유입",
|
||||
"종류": "관보호공",
|
||||
"분류1": "(형식마다)",
|
||||
"분류2": "유입",
|
||||
@@ -371,6 +451,12 @@
|
||||
{
|
||||
"키": "SS000014",
|
||||
"열": "bx_len",
|
||||
"경로": [
|
||||
"BOX암거",
|
||||
"(폭×높이마다)",
|
||||
"연장"
|
||||
],
|
||||
"이름": "BOX암거 (폭×높이마다) 연장",
|
||||
"종류": "BOX암거",
|
||||
"분류1": "(폭×높이마다)",
|
||||
"분류2": "연장",
|
||||
@@ -404,6 +490,12 @@
|
||||
{
|
||||
"키": "SS000015",
|
||||
"열": "bx_wing",
|
||||
"경로": [
|
||||
"BOX암거",
|
||||
"(폭×높이마다)",
|
||||
"날개벽"
|
||||
],
|
||||
"이름": "BOX암거 (폭×높이마다) 날개벽",
|
||||
"종류": "BOX암거",
|
||||
"분류1": "(폭×높이마다)",
|
||||
"분류2": "날개벽",
|
||||
@@ -453,6 +545,11 @@
|
||||
{
|
||||
"키": "SS000016",
|
||||
"열": "fb",
|
||||
"경로": [
|
||||
"세월교",
|
||||
"(관경 × 련수마다)"
|
||||
],
|
||||
"이름": "세월교 (관경 × 련수마다)",
|
||||
"종류": "세월교",
|
||||
"분류1": "(관경 × 련수마다)",
|
||||
"분류2": null,
|
||||
@@ -490,6 +587,12 @@
|
||||
{
|
||||
"키": "SS000017",
|
||||
"열": "fp_w",
|
||||
"경로": [
|
||||
"물넘이포장",
|
||||
"T=(두께마다)",
|
||||
"폭"
|
||||
],
|
||||
"이름": "물넘이포장 T=(두께마다) 폭",
|
||||
"종류": "물넘이포장",
|
||||
"분류1": "T=(두께마다)",
|
||||
"분류2": "폭",
|
||||
@@ -524,6 +627,12 @@
|
||||
{
|
||||
"키": "SS000018",
|
||||
"열": "fp_l",
|
||||
"경로": [
|
||||
"물넘이포장",
|
||||
"T=(두께마다)",
|
||||
"길이"
|
||||
],
|
||||
"이름": "물넘이포장 T=(두께마다) 길이",
|
||||
"종류": "물넘이포장",
|
||||
"분류1": "T=(두께마다)",
|
||||
"분류2": "길이",
|
||||
@@ -558,6 +667,12 @@
|
||||
{
|
||||
"키": "SS000019",
|
||||
"열": "od_len",
|
||||
"경로": [
|
||||
"횡단개거",
|
||||
"(규격마다)",
|
||||
"길이"
|
||||
],
|
||||
"이름": "횡단개거 (규격마다) 길이",
|
||||
"종류": "횡단개거",
|
||||
"분류1": "(규격마다)",
|
||||
"분류2": "길이",
|
||||
@@ -589,6 +704,12 @@
|
||||
{
|
||||
"키": "SS000020",
|
||||
"열": "od_cnt",
|
||||
"경로": [
|
||||
"횡단개거",
|
||||
"(규격마다)",
|
||||
"개소"
|
||||
],
|
||||
"이름": "횡단개거 (규격마다) 개소",
|
||||
"종류": "횡단개거",
|
||||
"분류1": "(규격마다)",
|
||||
"분류2": "개소",
|
||||
@@ -629,6 +750,12 @@
|
||||
{
|
||||
"키": "SS000021",
|
||||
"열": "rw",
|
||||
"경로": [
|
||||
"옹벽",
|
||||
"(형식마다)",
|
||||
"H=(높이마다)"
|
||||
],
|
||||
"이름": "옹벽 (형식마다) H=(높이마다)",
|
||||
"종류": "옹벽",
|
||||
"분류1": "(형식마다)",
|
||||
"분류2": "H=(높이마다)",
|
||||
@@ -666,6 +793,12 @@
|
||||
{
|
||||
"키": "SS000022",
|
||||
"열": "ms",
|
||||
"경로": [
|
||||
"돌쌓기",
|
||||
"(찰·메마다)",
|
||||
"H=(높이마다)"
|
||||
],
|
||||
"이름": "돌쌓기 (찰·메마다) H=(높이마다)",
|
||||
"종류": "돌쌓기",
|
||||
"분류1": "(찰·메마다)",
|
||||
"분류2": "H=(높이마다)",
|
||||
@@ -713,6 +846,12 @@
|
||||
{
|
||||
"키": "SS000023",
|
||||
"열": "sg",
|
||||
"경로": [
|
||||
"흙막이",
|
||||
"(형태마다)",
|
||||
"H=(높이마다)"
|
||||
],
|
||||
"이름": "흙막이 (형태마다) H=(높이마다)",
|
||||
"종류": "흙막이",
|
||||
"분류1": "(형태마다)",
|
||||
"분류2": "H=(높이마다)",
|
||||
@@ -750,6 +889,12 @@
|
||||
{
|
||||
"키": "SS000024",
|
||||
"열": "bm_len",
|
||||
"경로": [
|
||||
"큰돌쌓기",
|
||||
"(찰·메 · 돌 크기 · 높이마다)",
|
||||
"길이"
|
||||
],
|
||||
"이름": "큰돌쌓기 (찰·메 · 돌 크기 · 높이마다) 길이",
|
||||
"종류": "큰돌쌓기",
|
||||
"분류1": "(찰·메 · 돌 크기 · 높이마다)",
|
||||
"분류2": "길이",
|
||||
@@ -790,6 +935,12 @@
|
||||
{
|
||||
"키": "SS000025",
|
||||
"열": "bm_area",
|
||||
"경로": [
|
||||
"큰돌쌓기",
|
||||
"(찰·메 · 돌 크기 · 높이마다)",
|
||||
"면적"
|
||||
],
|
||||
"이름": "큰돌쌓기 (찰·메 · 돌 크기 · 높이마다) 면적",
|
||||
"종류": "큰돌쌓기",
|
||||
"분류1": "(찰·메 · 돌 크기 · 높이마다)",
|
||||
"분류2": "면적",
|
||||
@@ -806,6 +957,11 @@
|
||||
{
|
||||
"키": "SS000026",
|
||||
"열": "ec",
|
||||
"경로": [
|
||||
"골막이",
|
||||
"(형태마다)"
|
||||
],
|
||||
"이름": "골막이 (형태마다)",
|
||||
"종류": "골막이",
|
||||
"분류1": "(형태마다)",
|
||||
"분류2": null,
|
||||
@@ -837,6 +993,10 @@
|
||||
{
|
||||
"키": "SS000027",
|
||||
"열": "be",
|
||||
"경로": [
|
||||
"소단"
|
||||
],
|
||||
"이름": "소단",
|
||||
"종류": "소단",
|
||||
"분류1": null,
|
||||
"분류2": null,
|
||||
@@ -856,6 +1016,10 @@
|
||||
{
|
||||
"키": "SS000028",
|
||||
"열": "rf",
|
||||
"경로": [
|
||||
"대피소"
|
||||
],
|
||||
"이름": "대피소",
|
||||
"종류": "대피소",
|
||||
"분류1": null,
|
||||
"분류2": null,
|
||||
@@ -875,6 +1039,11 @@
|
||||
{
|
||||
"키": "SS000029",
|
||||
"열": "c2",
|
||||
"경로": [
|
||||
"대피소",
|
||||
"새 분류1"
|
||||
],
|
||||
"이름": "대피소 새 분류1",
|
||||
"종류": "대피소",
|
||||
"분류1": "새 분류1",
|
||||
"분류2": null,
|
||||
@@ -891,6 +1060,10 @@
|
||||
{
|
||||
"키": "SS000030",
|
||||
"열": "wy",
|
||||
"경로": [
|
||||
"정차·작업장"
|
||||
],
|
||||
"이름": "정차·작업장",
|
||||
"종류": "정차·작업장",
|
||||
"분류1": null,
|
||||
"분류2": null,
|
||||
@@ -910,6 +1083,10 @@
|
||||
{
|
||||
"키": "SS000031",
|
||||
"열": "ta",
|
||||
"경로": [
|
||||
"차돌림곳"
|
||||
],
|
||||
"이름": "차돌림곳",
|
||||
"종류": "차돌림곳",
|
||||
"분류1": null,
|
||||
"분류2": null,
|
||||
@@ -929,6 +1106,10 @@
|
||||
{
|
||||
"키": "SS000032",
|
||||
"열": "gr_rail",
|
||||
"경로": [
|
||||
"가드레일"
|
||||
],
|
||||
"이름": "가드레일",
|
||||
"종류": "가드레일",
|
||||
"분류1": null,
|
||||
"분류2": null,
|
||||
@@ -951,6 +1132,10 @@
|
||||
{
|
||||
"키": "SS000033",
|
||||
"열": "gr_curb",
|
||||
"경로": [
|
||||
"경계석"
|
||||
],
|
||||
"이름": "경계석",
|
||||
"종류": "경계석",
|
||||
"분류1": null,
|
||||
"분류2": null,
|
||||
@@ -973,6 +1158,10 @@
|
||||
{
|
||||
"키": "SS000034",
|
||||
"열": "gr_sign",
|
||||
"경로": [
|
||||
"위험표지"
|
||||
],
|
||||
"이름": "위험표지",
|
||||
"종류": "위험표지",
|
||||
"분류1": null,
|
||||
"분류2": null,
|
||||
@@ -995,6 +1184,10 @@
|
||||
{
|
||||
"키": "SS000035",
|
||||
"열": "mr",
|
||||
"경로": [
|
||||
"반사경"
|
||||
],
|
||||
"이름": "반사경",
|
||||
"종류": "반사경",
|
||||
"분류1": null,
|
||||
"분류2": null,
|
||||
@@ -1014,6 +1207,10 @@
|
||||
{
|
||||
"키": "SS000036",
|
||||
"열": "bg",
|
||||
"경로": [
|
||||
"차단기"
|
||||
],
|
||||
"이름": "차단기",
|
||||
"종류": "차단기",
|
||||
"분류1": null,
|
||||
"분류2": null,
|
||||
@@ -1033,6 +1230,10 @@
|
||||
{
|
||||
"키": "SS000037",
|
||||
"열": "ps",
|
||||
"경로": [
|
||||
"국가지점번호판"
|
||||
],
|
||||
"이름": "국가지점번호판",
|
||||
"종류": "국가지점번호판",
|
||||
"분류1": null,
|
||||
"분류2": null,
|
||||
@@ -1052,6 +1253,10 @@
|
||||
{
|
||||
"키": "SS000038",
|
||||
"열": "cs",
|
||||
"경로": [
|
||||
"준공표지석"
|
||||
],
|
||||
"이름": "준공표지석",
|
||||
"종류": "준공표지석",
|
||||
"분류1": null,
|
||||
"분류2": null,
|
||||
@@ -1071,6 +1276,11 @@
|
||||
{
|
||||
"키": "SS000039",
|
||||
"열": "etc",
|
||||
"경로": [
|
||||
"기타",
|
||||
"(이름마다)"
|
||||
],
|
||||
"이름": "기타 (이름마다)",
|
||||
"종류": "기타",
|
||||
"분류1": "(이름마다)",
|
||||
"분류2": null,
|
||||
@@ -1102,6 +1312,10 @@
|
||||
{
|
||||
"키": "SS000040",
|
||||
"열": "h_intake",
|
||||
"경로": [
|
||||
"취수정"
|
||||
],
|
||||
"이름": "취수정",
|
||||
"종류": "취수정",
|
||||
"분류1": null,
|
||||
"분류2": null,
|
||||
@@ -1118,6 +1332,11 @@
|
||||
{
|
||||
"키": "SS000041",
|
||||
"열": "h_rip_c",
|
||||
"경로": [
|
||||
"돌붙임",
|
||||
"찰붙임"
|
||||
],
|
||||
"이름": "돌붙임 찰붙임",
|
||||
"종류": "돌붙임",
|
||||
"분류1": "찰붙임",
|
||||
"분류2": null,
|
||||
@@ -1134,6 +1353,11 @@
|
||||
{
|
||||
"키": "SS000042",
|
||||
"열": "h_rip_m",
|
||||
"경로": [
|
||||
"돌붙임",
|
||||
"메붙임"
|
||||
],
|
||||
"이름": "돌붙임 메붙임",
|
||||
"종류": "돌붙임",
|
||||
"분류1": "메붙임",
|
||||
"분류2": null,
|
||||
@@ -1150,6 +1374,10 @@
|
||||
{
|
||||
"키": "SS000043",
|
||||
"열": "h_stone_ch",
|
||||
"경로": [
|
||||
"돌수로"
|
||||
],
|
||||
"이름": "돌수로",
|
||||
"종류": "돌수로",
|
||||
"분류1": null,
|
||||
"분류2": null,
|
||||
@@ -1166,6 +1394,10 @@
|
||||
{
|
||||
"키": "SS000044",
|
||||
"열": "h_stone_bed",
|
||||
"경로": [
|
||||
"돌조공"
|
||||
],
|
||||
"이름": "돌조공",
|
||||
"종류": "돌조공",
|
||||
"분류1": null,
|
||||
"분류2": null,
|
||||
@@ -1182,6 +1414,10 @@
|
||||
{
|
||||
"키": "SS000045",
|
||||
"열": "h_pv_rail",
|
||||
"경로": [
|
||||
"포장 난간"
|
||||
],
|
||||
"이름": "포장 난간",
|
||||
"종류": "포장 난간",
|
||||
"분류1": null,
|
||||
"분류2": null,
|
||||
@@ -1198,6 +1434,10 @@
|
||||
{
|
||||
"키": "SS000046",
|
||||
"열": "h_pg_rail",
|
||||
"경로": [
|
||||
"관보호공 안전난간"
|
||||
],
|
||||
"이름": "관보호공 안전난간",
|
||||
"종류": "관보호공 안전난간",
|
||||
"분류1": null,
|
||||
"분류2": null,
|
||||
@@ -1214,6 +1454,10 @@
|
||||
{
|
||||
"키": "SS000047",
|
||||
"열": "h_soil_ditch",
|
||||
"경로": [
|
||||
"수로형 토사개거"
|
||||
],
|
||||
"이름": "수로형 토사개거",
|
||||
"종류": "수로형 토사개거",
|
||||
"분류1": null,
|
||||
"분류2": null,
|
||||
@@ -1230,6 +1474,10 @@
|
||||
{
|
||||
"키": "SS000048",
|
||||
"열": "h_ford_basin",
|
||||
"경로": [
|
||||
"물넘이 집수부"
|
||||
],
|
||||
"이름": "물넘이 집수부",
|
||||
"종류": "물넘이 집수부",
|
||||
"분류1": null,
|
||||
"분류2": null,
|
||||
@@ -1246,6 +1494,10 @@
|
||||
{
|
||||
"키": "SS000049",
|
||||
"열": "h_rockfall",
|
||||
"경로": [
|
||||
"낙석방지책"
|
||||
],
|
||||
"이름": "낙석방지책",
|
||||
"종류": "낙석방지책",
|
||||
"분류1": null,
|
||||
"분류2": null,
|
||||
@@ -1262,6 +1514,10 @@
|
||||
{
|
||||
"키": "SS000050",
|
||||
"열": "h_break",
|
||||
"경로": [
|
||||
"콘크리트·포장 깨기"
|
||||
],
|
||||
"이름": "콘크리트·포장 깨기",
|
||||
"종류": "콘크리트·포장 깨기",
|
||||
"분류1": null,
|
||||
"분류2": null,
|
||||
@@ -1278,6 +1534,10 @@
|
||||
{
|
||||
"키": "SS000051",
|
||||
"열": "h_waste",
|
||||
"경로": [
|
||||
"폐기물처리"
|
||||
],
|
||||
"이름": "폐기물처리",
|
||||
"종류": "폐기물처리",
|
||||
"분류1": null,
|
||||
"분류2": null,
|
||||
|
||||
@@ -255,6 +255,49 @@ trail.commit(step(1, { logic: "earthwork" }));
|
||||
trail.commit(step(2, null));
|
||||
const historyRecs = [trail.undo(), trail.undo(), trail.redo(), trail.redo()].map((one) => one.rec);
|
||||
|
||||
// ⑧ 비중 고정(계획서 26-2c) — 고정한 값은 안 움직이고 나머지가 합 100 을 맞춘다.
|
||||
const W = { expected: 70, earthwork: 15, grade: 15 };
|
||||
const L = (...keys) => ({ expected: false, earthwork: false, grade: false, ...Object.fromEntries(keys.map((k) => [k, true])) });
|
||||
const sweep = [];
|
||||
for (const locks of [L(), L("expected"), L("earthwork"), L("grade"), L("expected", "grade")]) {
|
||||
for (const key of ["expected", "earthwork", "grade"]) {
|
||||
for (let v = -5; v <= 105; v += 5) {
|
||||
const out = model.moveWeight(W, locks, key, v);
|
||||
sweep.push({
|
||||
sum: out.expected + out.earthwork + out.grade,
|
||||
lockedSame: Object.keys(locks).every((k) => !locks[k] || out[k] === W[k]),
|
||||
range: [out.expected, out.earthwork, out.grade].every((n) => Number.isInteger(n) && n >= 0 && n <= 100),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const weightLocks = {
|
||||
freeThree: model.moveWeight(W, L(), "expected", 40),
|
||||
lockExpected: model.moveWeight(W, L("expected"), "earthwork", 20),
|
||||
lockExpectedClamp: model.moveWeight(W, L("expected"), "earthwork", 90),
|
||||
twoLocked: model.moveWeight({ expected: 70, earthwork: 20, grade: 10 }, L("expected", "earthwork"), "grade", 50),
|
||||
movingLocked: model.moveWeight(W, L("expected"), "expected", 10),
|
||||
derived: [model.derivedWeightKey(L()), model.derivedWeightKey(L("expected")), model.derivedWeightKey(L("expected", "earthwork"))],
|
||||
canLock: {
|
||||
none: model.canLockWeight(L(), "grade"),
|
||||
one: model.canLockWeight(L("expected"), "grade"),
|
||||
two: model.canLockWeight(L("expected", "earthwork"), "grade"),
|
||||
unlockOfTwo: model.canLockWeight(L("expected", "earthwork"), "expected"),
|
||||
},
|
||||
input: {
|
||||
free: model.inputWeight(W, L(), "earthwork", 55),
|
||||
lockedOk: model.inputWeight(W, L("expected"), "earthwork", 20),
|
||||
lockedTooBig: model.inputWeight(W, L("expected"), "earthwork", 40),
|
||||
fraction: model.inputWeight(W, L(), "earthwork", 1.5),
|
||||
negative: model.inputWeight(W, L(), "earthwork", -1),
|
||||
over: model.inputWeight(W, L(), "earthwork", 101),
|
||||
nan: model.inputWeight(W, L(), "earthwork", Number.NaN),
|
||||
onLocked: model.inputWeight(W, L("earthwork"), "earthwork", 15),
|
||||
derivedSame: model.inputWeight({ expected: 70, earthwork: 20, grade: 10 }, L("expected", "earthwork"), "grade", 10),
|
||||
derivedOther: model.inputWeight({ expected: 70, earthwork: 20, grade: 10 }, L("expected", "earthwork"), "grade", 30),
|
||||
},
|
||||
};
|
||||
|
||||
// ④ 캐시 왕복 — 결과 통 프로젝트 칸. 꼴이 틀린 값은 안 읽는다.
|
||||
model.writeInitialChoice("p-1", plain);
|
||||
const readBack = model.readInitialChoice("p-1");
|
||||
@@ -296,5 +339,7 @@ process.stdout.write(
|
||||
initialRoute,
|
||||
mapLines,
|
||||
historyRecs,
|
||||
sweep,
|
||||
weightLocks,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -67,13 +67,14 @@ def test_옛_산출근거를_면적산출로_옮기고_번호_8자리(client, tm
|
||||
def test_새_구조물_도면은_면적_재료_진짜_빈_시트_같은_8자리(client):
|
||||
made1 = client.post("/api/m02/structures/rv").json()["문서"]
|
||||
made2 = client.post("/api/m02/structures/pv_b").json()["문서"]
|
||||
assert made1["도번"] == "SD01010100" and made2["도번"] == "SD02010100"
|
||||
# 새 항목 번호 = 자리 뜻 없는 일련번호(PLAN 26-1)
|
||||
assert made1["도번"] == "SD00000001" and made2["도번"] == "SD00000002"
|
||||
for kind, key, head in (("area", "면적번", "SA"), ("material", "재료번", "SM")):
|
||||
doc = client.get(f"/api/m02/templates/{kind}/pv_b").json()["문서"]
|
||||
assert doc[key] == f"{head}02010100"
|
||||
assert doc[key] == f"{head}00000002"
|
||||
assert doc["시트"][0]["칸"] == {} and doc["서식"] == [{}] # 머리 줄 없음
|
||||
numbers = client.get("/api/m02/structures/numbers").json()
|
||||
assert numbers["pv_b"] == {"도번": "SD02010100", "면적번": "SA02010100", "재료번": "SM02010100"}
|
||||
assert numbers["pv_b"] == {"도번": "SD00000002", "면적번": "SA00000002", "재료번": "SM00000002"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -89,7 +90,7 @@ def test_다른_열_내용을_복사해_가져옴_연결도_같이(client, kind,
|
||||
assert r.status_code == 200
|
||||
ms = client.get(f"/api/m02/templates/{kind}/ms").json()["문서"]
|
||||
assert (ms["시트"][0]["칸"], ms["연결"]) == ({"A1": {"식": "1+1"}}, LINKS)
|
||||
assert (ms[key], ms["열"]) == (f"{head}05010100", "ms") # 번호 · 열은 받는 쪽 그대로
|
||||
assert (ms[key], ms["열"]) == (f"{head}00000010", "ms") # 번호 · 열은 받는 쪽 그대로
|
||||
assert ms["계산값"] == SERVER # 서버가 다시 풂
|
||||
assert client.post(f"/api/m02/{kind}/ms/copy", params={"from": "없는열"}).status_code == 404
|
||||
|
||||
|
||||
@@ -213,3 +213,36 @@ def test_노선_추천_다듬기_23_3():
|
||||
assert "calc(var(--z-modal, 1000) + 2)" in css # 라벨 패널(+1) 위
|
||||
assert "writing-mode: vertical-lr" in css
|
||||
assert 'backdropClass: "b05-routerec-backdrop"' in read("B05_Profile_UI_RouteEdit_Recommend.ts")
|
||||
|
||||
|
||||
def test_비중_고정_합_100_규칙(results):
|
||||
"""고정한 값은 안 움직이고 나머지가 합 100 — 어떤 고정 · 어떤 값으로 옮겨도 정수 0~100 · 합 100."""
|
||||
assert all(row["sum"] == 100 and row["lockedSame"] and row["range"] for row in results["sweep"])
|
||||
w = results["weightLocks"]
|
||||
# 안 고정: 지금 비율대로 나눔(기존 규칙)
|
||||
assert w["freeThree"] == {"expected": 40, "earthwork": 30, "grade": 30}
|
||||
# 예상 70 고정 → 토공을 끌면 기울기만 따라 바뀜
|
||||
assert w["lockExpected"] == {"expected": 70, "earthwork": 20, "grade": 10}
|
||||
# 남는 몫(30)을 넘게 끌면 잘림
|
||||
assert w["lockExpectedClamp"] == {"expected": 70, "earthwork": 30, "grade": 0}
|
||||
# 둘 고정 → 셋째는 저절로 정해져 안 움직임
|
||||
assert w["twoLocked"] == {"expected": 70, "earthwork": 20, "grade": 10}
|
||||
assert w["movingLocked"] == {"expected": 70, "earthwork": 15, "grade": 15}
|
||||
assert w["derived"] == [None, None, "grade"]
|
||||
# 셋 다 고정은 막음 · 푸는 것은 늘 됨
|
||||
assert w["canLock"] == {"none": True, "one": True, "two": False, "unlockOfTwo": True}
|
||||
|
||||
|
||||
def test_비중_값_칸_직접_입력(results):
|
||||
"""0~100 정수 · 고정 규칙 아래 합 100 을 못 맞추면 받지 않음(빨간 테두리 · 안 보냄)."""
|
||||
i = results["weightLocks"]["input"]
|
||||
assert i["free"] == {"ok": True, "weights": {"expected": 37, "earthwork": 55, "grade": 8}}
|
||||
assert i["lockedOk"] == {"ok": True, "weights": {"expected": 70, "earthwork": 20, "grade": 10}}
|
||||
for name in ("lockedTooBig", "fraction", "negative", "over", "nan", "onLocked"):
|
||||
assert i[name]["ok"] is False, name
|
||||
assert i[name]["weights"] == {"expected": 70, "earthwork": 15, "grade": 15}, name
|
||||
assert i["derivedSame"]["ok"] is True
|
||||
assert i["derivedOther"] == {
|
||||
"ok": False,
|
||||
"weights": {"expected": 70, "earthwork": 20, "grade": 10},
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"""프로젝트 집계 항목 복사본 완전체 (PLAN 25-2b · 25-2e · 임시 저장소 · 임시 마스터 사본).
|
||||
"""프로젝트 집계 항목 복사본 완전체 (PLAN 25-2b · 25-2e · 26-2b · 임시 저장소 · 임시 마스터 사본).
|
||||
|
||||
복사본은 구조물마다(`templates/structures/{구조물 id}/` · 관은 `pipe@측점`).
|
||||
|
||||
- 지정 → [저장] → 폴더에 새 파일(입력연결 · 입력값 · 고른자재 · 단가) · manifest 가져온날 · 원본판
|
||||
- 단가 묶음이 닫혀 있음 — 조합 37개 · 로직 170개를 마스터와 복사본으로 돌려 같은 값
|
||||
@@ -32,7 +34,8 @@ from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
REAL = config_system.PROJECT_ROOT / "resources/master_template"
|
||||
MASTER = config_system.PROJECT_ROOT / "resources/master_data"
|
||||
PIPE = {"layer": "system", "owner": None, "key": "SS000011", "name": "관공 관매설"}
|
||||
FOLDER = "system_none_SS000011"
|
||||
FOLDER = "pipe@40.0" # 관 지점 복사본 폴더 = 구조물 id(26-2b)
|
||||
PIPE_PATH = ["관공", "(관종·관경마다)", "관매설"]
|
||||
LABOR_PRICE = "LB000002" # 보통인부 — 되메우기 · 파형강관 부설 호표가 씀
|
||||
|
||||
|
||||
@@ -82,7 +85,7 @@ def _read(path: Path) -> Any:
|
||||
|
||||
def _auto_values(root: Path) -> dict[str, Any]:
|
||||
"""담은 로직마다 자동값을 확인한 값으로 — 사용자가 다 채운 것과 같게."""
|
||||
labor = _read(root / "templates/items" / FOLDER / "일위대가.json")
|
||||
labor = _read(root / "templates/structures" / FOLDER / "일위대가.json")
|
||||
_, whole = m01.loaded()
|
||||
out: dict[str, Any] = {}
|
||||
for row in labor["로직"]:
|
||||
@@ -104,7 +107,7 @@ def _fill_values(root: Path) -> dict[str, Any]:
|
||||
def test_지정하면_새_파일까지_복사(world):
|
||||
root = world["root"]
|
||||
assert _save(root, [_pipe()])["만듦"] == [FOLDER]
|
||||
folder = root / "templates/items" / FOLDER
|
||||
folder = root / "templates/structures" / FOLDER
|
||||
assert _read(folder / "입력연결.json") == {"판": None, "줄": []} # 층 연결이 아직 없음
|
||||
assert _read(folder / "입력값.json") == {} and _read(folder / "고른자재.json") == {}
|
||||
price = _read(folder / "단가.json")
|
||||
@@ -118,7 +121,7 @@ def test_지정하면_새_파일까지_복사(world):
|
||||
{
|
||||
"폴더": FOLDER,
|
||||
"이름": "관공 관매설",
|
||||
"원본": PIPE,
|
||||
"원본": {**PIPE, "경로": PIPE_PATH},
|
||||
"원본판": manifest["원본판"],
|
||||
"가져온날": manifest["가져온날"],
|
||||
"마스터바뀜": False,
|
||||
@@ -221,7 +224,7 @@ def test_마스터_단가를_바꿔도_계산_그대로_새로_가져오면_바
|
||||
def test_새로_가져오면_없는_키는_버림(world):
|
||||
root = world["root"]
|
||||
_save(root, [_pipe()])
|
||||
folder = root / "templates/items" / FOLDER
|
||||
folder = root / "templates/structures" / FOLDER
|
||||
(folder / "입력값.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
@@ -267,31 +270,37 @@ def test_쓰기_뒤_다시_읽기_같음_판_다르면_409(world):
|
||||
copies.read_file(root, "../../etc", "입력값")
|
||||
|
||||
|
||||
def test_고친_복사본은_구조물에서_빠져도_안_지움(world):
|
||||
def test_고친_복사본은_구조물에서_빠지면_지난에_남김(world):
|
||||
root = world["root"]
|
||||
_save(root, [_pipe()])
|
||||
assert _save(root, [_pipe(None)])["지움"] == [FOLDER] # 안 고쳤으면 지움(지금 규칙)
|
||||
assert _save(root, [_pipe(None)])["지움"] == [FOLDER] # 안 고쳤으면 지움
|
||||
assert not (root / "templates/structures/_지난").exists()
|
||||
_save(root, [_pipe()])
|
||||
current = copies.read_file(root, FOLDER, "입력값")
|
||||
copies.write_file(root, FOLDER, "입력값", current["판"], {"GF000176": {"관경": {"값": 600}}})
|
||||
assert _save(root, [_pipe(None)])["지움"] == []
|
||||
assert copies.list_items(root)[0]["구조물"] == [] and copies.list_items(root)[0]["고침"]
|
||||
assert _save(root, [_pipe(None)])["지움"] == [FOLDER]
|
||||
assert copies.list_items(root) == []
|
||||
past = list((root / "templates/structures/_지난").iterdir())
|
||||
assert len(past) == 1 and past[0].name.startswith("pipe@40.0_")
|
||||
assert _read(past[0] / "입력값.json") == {"GF000176": {"관경": {"값": 600}}}
|
||||
|
||||
|
||||
def test_미리_가져오기(world):
|
||||
root = world["root"]
|
||||
_save(root, [])
|
||||
made = copies.prefetch(root, PIPE)
|
||||
made = copies.prefetch(root, FOLDER, PIPE)
|
||||
assert made["폴더"] == FOLDER and made["만듦"] is True
|
||||
assert copies.prefetch(root, PIPE)["만듦"] is False # 있으면 그대로
|
||||
assert copies.list_items(root)[0]["구조물"] == []
|
||||
assert _save(root, [])["지움"] == [FOLDER] # 안 고쳤으면 [저장] 때 지움
|
||||
assert copies.prefetch(root, FOLDER, PIPE)["만듦"] is False # 같은 항목이면 그대로
|
||||
assert copies.list_items(root)[0]["구조물"] == [FOLDER]
|
||||
assert _save(root, [])["지움"] == [FOLDER] # [저장] 뒤에도 안 쓰면 지움
|
||||
with pytest.raises(ValueError):
|
||||
copies.prefetch(root, "../x", PIPE)
|
||||
|
||||
|
||||
def test_옛_복사본은_읽을_때_채움(world):
|
||||
root = world["root"]
|
||||
_save(root, [_pipe()])
|
||||
folder = root / "templates/items" / FOLDER
|
||||
folder = root / "templates/structures" / FOLDER
|
||||
for name in ("입력연결.json", "입력값.json", "고른자재.json", "단가.json"):
|
||||
(folder / name).unlink()
|
||||
manifest = _read(folder / "manifest.json")
|
||||
@@ -331,12 +340,185 @@ def test_길_권한_남의_프로젝트는_403(world, monkeypatch):
|
||||
assert stale.status_code == 409 and stale.json()["detail"]["판"] == put.json()["판"]
|
||||
assert client.get(f"{base}/{FOLDER}/모름").status_code == 422
|
||||
assert client.post(f"{base}/{FOLDER}/calc").json()["멈춤"]
|
||||
assert client.post(base, json={"summary_item": {"layer": "x"}}).status_code == 400
|
||||
made = client.post(base, json={"layer": "system", "owner": None, "key": "SS000022"}).json()
|
||||
assert made["폴더"] == "system_none_SS000022" and made["만듦"] is True
|
||||
wall = {"structure": "st9", "layer": "system", "owner": None}
|
||||
assert client.post(base, json={**wall, "summary_item": {"layer": "x"}}).status_code == 400
|
||||
assert client.post(base, json={"layer": "system", "key": "SS000022"}).status_code == 400
|
||||
made = client.post(base, json={**wall, "key": "SS000022"}).json()
|
||||
assert made["폴더"] == "st9" and made["만듦"] is True
|
||||
names = {i["폴더"]: i["이름"] for i in client.get(base).json()["항목"]}
|
||||
assert names["system_none_SS000022"] == "돌쌓기 (찰·메마다) H=(높이마다)" # 층 SS 줄 이름
|
||||
assert client.post(base, json={"layer": "system", "key": "SS999999"}).status_code == 404
|
||||
assert names["st9"] == "돌쌓기 (찰·메마다) H=(높이마다)" # 층 SS 줄 이름(경로 이음)
|
||||
assert client.post(base, json={**wall, "key": "SS999999"}).status_code == 404
|
||||
assert client.post(f"{base}/{FOLDER}/reapply").json()["덮임"] == []
|
||||
session["company_id"] = 4
|
||||
assert client.get(base).status_code == 403
|
||||
assert client.post(f"{base}/{FOLDER}/refresh").status_code == 403
|
||||
|
||||
|
||||
# ── 구조물마다 복사본 (26-2b) ─────────────────────────
|
||||
|
||||
|
||||
MS = {"layer": "system", "owner": None, "key": "SS000022", "name": "돌쌓기"}
|
||||
|
||||
|
||||
def _wall(sid: str, summary: dict | None = MS) -> dict[str, Any]:
|
||||
return {
|
||||
"structure_id": sid,
|
||||
"type_id": "masonry_dry",
|
||||
"placement": "interval",
|
||||
"chainage_m": 85,
|
||||
"start_m": 80,
|
||||
"end_m": 90,
|
||||
"options": {"height_m": 2.0, "length_m": 10},
|
||||
"summary_item": summary,
|
||||
}
|
||||
|
||||
|
||||
def _save_all(root: Path, walls: list[dict], pipes: list[dict]) -> dict[str, list[str]]:
|
||||
(root / "B05_Profile/route/structures.json").write_text(
|
||||
json.dumps({"revision": 1, "structures": walls}, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
(root / "B04_PreProcess/drainage/edits/pipe_points.json").write_text(
|
||||
json.dumps({"points": pipes}, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
return snap.sync_snapshots(root)
|
||||
|
||||
|
||||
def _memo(root: Path, folder: str, text: str) -> None:
|
||||
"""면적 시트에 표식 한 칸 — 한쪽만 고쳤는지 보려는 것(계산값 풀이는 건너뜀)."""
|
||||
area = copies.read_file(root, folder, "면적")
|
||||
body = {**area["내용"], "메모": text}
|
||||
copies.write_file(root, folder, "면적", area["판"], body, recalc=lambda doc: doc)
|
||||
|
||||
|
||||
def test_같은_항목_구조물_둘은_따로(world):
|
||||
root = world["root"]
|
||||
second = {**_pipe(), "chainage_m": 60.0}
|
||||
made = _save_all(root, [_wall("a1"), _wall("a2")], [_pipe(), second])["만듦"]
|
||||
assert made == ["a1", "a2", FOLDER, "pipe@60.0"]
|
||||
_memo(root, "a1", "a1 만 고침")
|
||||
assert copies.read_file(root, "a1", "면적")["내용"]["메모"] == "a1 만 고침"
|
||||
assert "메모" not in copies.read_file(root, "a2", "면적")["내용"] # 다른 쪽 그대로
|
||||
_fill_values(root) # pipe@40.0 에만 값
|
||||
assert copies.calc(root, FOLDER)["멈춤"] == [] and copies.calc(root, FOLDER)["계"] > 0
|
||||
assert len(copies.calc(root, "pipe@60.0")["멈춤"]) == 3 # 계산도 각자
|
||||
flags = {i["폴더"]: i["고침"] for i in copies.list_items(root)}
|
||||
assert flags == {"a1": True, "a2": False, FOLDER: True, "pipe@60.0": False}
|
||||
|
||||
|
||||
def test_원본_항목을_지워도_복사본_계산_그대로(world):
|
||||
root, system = world["root"], world["system"]
|
||||
_save(root, [_pipe()])
|
||||
_fill_values(root)
|
||||
before = copies.calc(root, FOLDER)
|
||||
combo = _read(system / layers.COMBO_FILE)
|
||||
combo["줄"] = [r for r in combo["줄"] if r["키"] != PIPE["key"]]
|
||||
(system / layers.COMBO_FILE).write_text(json.dumps(combo, ensure_ascii=False), "utf-8")
|
||||
assert copies.calc(root, FOLDER) == before
|
||||
row = copies.list_items(root)[0]
|
||||
assert row["원본없음"] is True and row["마스터바뀜"] is False
|
||||
assert _save(root, [_pipe()])["만듦"] == [] # 다시 저장해도 복사본 그대로
|
||||
with pytest.raises(LookupError):
|
||||
copies.reapply(root, FOLDER) # 원본이 없으면 재적용 못 함(404)
|
||||
|
||||
|
||||
def test_재적용은_시트_고침을_덮고_알림_입력값은_살림(world):
|
||||
root = world["root"]
|
||||
_save(root, [_pipe()])
|
||||
values = _fill_values(root)
|
||||
_memo(root, FOLDER, "x")
|
||||
assert copies.reapply(root, FOLDER)["덮임"] == ["면적"]
|
||||
assert "메모" not in copies.read_file(root, FOLDER, "면적")["내용"]
|
||||
assert copies.read_file(root, FOLDER, "입력값")["내용"] == values
|
||||
assert copies.reapply(root, FOLDER)["덮임"] == []
|
||||
|
||||
|
||||
def test_개인에_저장하면_개인_층에_항목(world):
|
||||
root = world["root"]
|
||||
_save(root, [_pipe()])
|
||||
rows = [{"로직": "GF000176", "입력": "관경", "자리": "선택"}]
|
||||
current = copies.read_file(root, FOLDER, "입력연결")
|
||||
copies.write_file(root, FOLDER, "입력연결", current["판"], {"판": 3, "줄": rows})
|
||||
made = copies.save_as(root, FOLDER, 3, 12, ["관공", "우리 현장"], "파형강관 매설")
|
||||
assert made["summary_item"] == {
|
||||
"layer": "personal",
|
||||
"owner": "3/12",
|
||||
"key": made["key"],
|
||||
"name": "관공 우리 현장 파형강관 매설",
|
||||
}
|
||||
folder = layers.personal_dir(3, 12)
|
||||
row = next(r for r in _read(folder / layers.COMBO_FILE)["줄"] if r["키"] == made["key"])
|
||||
assert [row["종류"], row["분류1"], row["분류2"]] == ["관공", "우리 현장", "파형강관 매설"]
|
||||
assert row["일위대가"] == "UA000036" and row["단위"] == "m"
|
||||
copy = root / "templates/structures" / FOLDER
|
||||
drawing = _read(folder / "structure" / f"{made['열']}.json")
|
||||
assert drawing["도면"] == _read(copy / "도면.json")["도면"]
|
||||
assert _read(folder / "inputs" / f"{made['열']}.json") == {"판": 1, "줄": rows}
|
||||
assert not list(folder.rglob("단가.json")) # 단가 묶음은 안 가져감
|
||||
# 개인 층 항목을 다른 구조물에 지정하면 그 내용으로 복사본이 섬
|
||||
assert _save_all(root, [_wall("b1", made["summary_item"])], [])["만듦"] == ["b1"]
|
||||
|
||||
|
||||
def test_프로젝트에서_새_항목(world):
|
||||
root = world["root"]
|
||||
_save_all(root, [_wall("c1")], [])
|
||||
made = copies.new_item(root, "c1", 3, 12, ["신규 공사"], "돌붙임", "㎡")
|
||||
assert made["폴더"] == "c1" and made["summary_item"]["layer"] == "personal"
|
||||
origin = _read(root / "templates/structures/c1/manifest.json")["원본"]
|
||||
assert origin["key"] == made["key"] and origin["경로"] == ["신규 공사", "돌붙임"]
|
||||
assert copies.list_items(root)[0]["이름"] == "신규 공사 돌붙임"
|
||||
# 화면이 새 항목을 지정해 [저장] 하면 그대로 남음
|
||||
assert _save_all(root, [_wall("c1", made["summary_item"])], [])["만듦"] == []
|
||||
with pytest.raises(ValueError):
|
||||
copies.new_item(root, "c2", 3, 12, [], "", None) # 경로 · 이름 빈 값
|
||||
|
||||
|
||||
def test_옛_항목_폴더는_쓰는_구조물마다_옮겨_받음(world):
|
||||
root = world["root"]
|
||||
_save_all(root, [_wall("d1"), _wall("d2")], [])
|
||||
base = root / "templates/structures"
|
||||
old = root / "templates/items/system_none_SS000022"
|
||||
old.parent.mkdir(parents=True)
|
||||
shutil.move(str(base / "d1"), str(old))
|
||||
shutil.rmtree(base / "d2")
|
||||
manifest = _read(old / "manifest.json")
|
||||
manifest.update({"구조물": ["d1", "d2"], "고침": True})
|
||||
(old / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False), "utf-8")
|
||||
(old / "입력값.json").write_text(json.dumps({"GF": {"a": {"값": 1}}}), "utf-8")
|
||||
assert [i["폴더"] for i in copies.list_items(root)] == ["d1", "d2"]
|
||||
assert not (root / "templates/items").exists()
|
||||
for sid in ("d1", "d2"):
|
||||
assert _read(base / sid / "입력값.json") == {"GF": {"a": {"값": 1}}} # 고친 값 유지
|
||||
assert _read(base / sid / "manifest.json")["구조물"] == [sid]
|
||||
assert _save_all(root, [_wall("d1"), _wall("d2")], [])["만듦"] == []
|
||||
|
||||
|
||||
def test_지정을_바꾸면_고친_옛_복사본은_지난에(world):
|
||||
root = world["root"]
|
||||
_save_all(root, [_wall("e1")], [])
|
||||
_memo(root, "e1", "y")
|
||||
rv = {"layer": "system", "owner": None, "key": "SS000001", "name": "돌기슭막이"}
|
||||
result = _save_all(root, [_wall("e1", rv)], [])
|
||||
assert result["만듦"] == ["e1"] and result["지움"] == ["e1"]
|
||||
assert _read(root / "templates/structures/e1/manifest.json")["원본"]["key"] == "SS000001"
|
||||
past = list((root / "templates/structures/_지난").iterdir())
|
||||
assert len(past) == 1 and _read(past[0] / "면적.json")["메모"] == "y"
|
||||
|
||||
|
||||
def test_새_항목_길_회사_없으면_409(world, monkeypatch):
|
||||
root = world["root"]
|
||||
_save_all(root, [_wall("f1")], [])
|
||||
|
||||
async def fake_row(project_id: str) -> dict[str, Any]:
|
||||
return {"company_id": None, "storage_path": str(root)}
|
||||
|
||||
monkeypatch.setattr(layer_routes, "_project_row", fake_row)
|
||||
monkeypatch.setattr(layer_routes, "resolve_stored_project_path", lambda path: path)
|
||||
app = FastAPI()
|
||||
app.include_router(items_router.router)
|
||||
session = {"role": "SYSTEM_ADMIN", "company_id": None, "user_id": 12}
|
||||
app.dependency_overrides[verify_session] = lambda: session
|
||||
client = TestClient(app)
|
||||
body = {"structure": "f1", "경로": ["새"], "이름": "것"}
|
||||
assert client.post("/api/projects/p1/items/new", json=body).status_code == 409
|
||||
save_as = client.post("/api/projects/p1/items/f1/save-as", json={"경로": ["a"], "이름": "b"})
|
||||
assert save_as.status_code == 409
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""M02 구조물 도면(structure) — 새로 · 도번 SD+8자리 · 409 · 저장 · 지운 뒤 번호 ·
|
||||
제목행 번호 매김(분류3 까지) · 집계표 저장 때 만들기/지우기 · 복사 가져오기 · 프로젝트 복사.
|
||||
번호 매김(새 항목 = 자리 뜻 없는 일련번호 · PLAN 26-1) · 집계표 저장 때 만들기/지우기 ·
|
||||
복사 가져오기 · 프로젝트 복사.
|
||||
면적산출(area) · 재료산출(material)은 따로 파일 —
|
||||
`resources/tester/spreadsheet/test_spreadsheet_store.py`."""
|
||||
|
||||
@@ -75,7 +76,7 @@ def test_새로_도번_자동_409_없는_열(client):
|
||||
"구조물도면",
|
||||
"structure",
|
||||
"pp_len",
|
||||
"SD01010100",
|
||||
"SD00000001",
|
||||
)
|
||||
# 도각 없는 전용 화면 — 그린 만큼이 설계 영역 · 기본 도면층 하나(없으면 CAD 가 못 엶)
|
||||
assert doc["도면"]["format"] == 6
|
||||
@@ -86,13 +87,13 @@ def test_새로_도번_자동_409_없는_열(client):
|
||||
for kind in ("area", "material"):
|
||||
assert client.get(f"/api/m02/templates/{kind}/pp_len").json()["문서"]["종류"] == "통합문서"
|
||||
|
||||
assert client.post("/api/m02/structures/rv").json()["문서"]["도번"] == "SD02010100"
|
||||
assert client.post("/api/m02/structures/rv").json()["문서"]["도번"] == "SD00000002"
|
||||
assert client.post("/api/m02/structures/pp_len").status_code == 409
|
||||
assert client.post("/api/m02/structures/없는열").status_code == 404
|
||||
assert client.post("/api/m02/structures/no").status_code == 404 # 글 열은 구조물 아님
|
||||
assert client.get("/api/m02/structures/numbers").json() == {
|
||||
"pp_len": _num("01010100"),
|
||||
"rv": _num("02010100"),
|
||||
"pp_len": _num("00000001"),
|
||||
"rv": _num("00000002"),
|
||||
}
|
||||
rows = client.get("/api/m02/templates").json()
|
||||
assert {(r["종류"], r["이름"]) for r in rows if r["종류"] == "structure"} == {
|
||||
@@ -118,7 +119,7 @@ def test_저장_뼈대_거름(client):
|
||||
"/api/m02/templates/structure/pp_len", json={"판": now, "문서": {**doc, "도번": "SD9"}}
|
||||
)
|
||||
got = client.get("/api/m02/templates/structure/pp_len").json()["문서"]
|
||||
assert got["도번"] == "SD01010100" # 번호는 서버 것 — 화면 값을 안 받음
|
||||
assert got["도번"] == "SD00000001" # 번호는 서버 것 — 화면 값을 안 받음
|
||||
|
||||
|
||||
def test_지워도_번호를_당기지_않음_짝은_같은_번호(client):
|
||||
@@ -126,15 +127,15 @@ def test_지워도_번호를_당기지_않음_짝은_같은_번호(client):
|
||||
client.post(f"/api/m02/structures/{column}")
|
||||
assert client.delete("/api/m02/templates/structure/rv").status_code == 200
|
||||
numbers = client.get("/api/m02/structures/numbers").json()
|
||||
assert numbers["rv"] == {**_num("02010100"), "도번": ""} # 면적 · 재료는 남아 번호를 쥠
|
||||
assert numbers["ms"] == _num("03010100") # 다른 열 번호는 그대로
|
||||
# 같은 종류(관공) 새 열 = 그 무리 다음 번호 · 다시 만든 도면은 짝(면적산출) 번호를 이어 받음
|
||||
assert client.post("/api/m02/structures/pp_cp").json()["문서"]["도번"] == "SD01010200"
|
||||
assert client.post("/api/m02/structures/rv").json()["문서"]["도번"] == "SD02010100"
|
||||
assert numbers["rv"] == {**_num("00000002"), "도번": ""} # 면적 · 재료는 남아 번호를 쥠
|
||||
assert numbers["ms"] == _num("00000003") # 다른 열 번호는 그대로
|
||||
# 새 열 = 다음 일련번호(자리 뜻 없음) · 다시 만든 도면은 짝(면적산출) 번호를 이어 받음
|
||||
assert client.post("/api/m02/structures/pp_cp").json()["문서"]["도번"] == "SD00000004"
|
||||
assert client.post("/api/m02/structures/rv").json()["문서"]["도번"] == "SD00000002"
|
||||
|
||||
|
||||
def test_제목행으로_번호_매김_한_번_옮김(client, tmp_path):
|
||||
"""옛 구-NN 파일 · 글 열 파일 → 새 번호(열 차례 = 종류 · 분류1 · 분류2 · 분류3 차례) ·
|
||||
def test_번호_없는_열은_열_차례로_일련번호_한_번_옮김(client, tmp_path):
|
||||
"""옛 구-NN 파일 · 글 열 파일 → 새 번호(번호 없는 열 = 열 차례 일련번호 · 자리 뜻 없음) ·
|
||||
내용 그대로."""
|
||||
for column in ("no", "rv", "pv_l"):
|
||||
doc = {"양식": "구조물도면", "종류": "structure", "판": 1, "열": column, "도번": "구-07"}
|
||||
@@ -146,16 +147,16 @@ def test_제목행으로_번호_매김_한_번_옮김(client, tmp_path):
|
||||
assert done["지움"] == ["structure/no"] # 글 열(NO · 측점 · 비고)은 구조물 아님
|
||||
numbers = client.get("/api/m02/structures/numbers").json()
|
||||
assert set(numbers) == set(store._column_ids()) and "sta" not in numbers
|
||||
expect = {
|
||||
"rv": "01010100", # 돌기슭막이 · (형태마다) · H=(높이마다) · 분류3 없음 = 00
|
||||
"c1": "01010200", # 같은 종류 · 분류1 · 다음 분류2
|
||||
"pv_b": "02010100", # 콘크리트포장 · T=(두께마다) · B
|
||||
"pv_l": "02010200",
|
||||
"pp_len": "03010100",
|
||||
"fb": "04010000", # 세월교 · (관경 × 련수마다) · 분류2 없음 = 00
|
||||
"be": "06000000", # 소단 — 분류 모두 없음
|
||||
"h_rip_c": "07010000", # 돌붙임 · 찰붙임
|
||||
"h_rip_m": "07020000", # 돌붙임 · 메붙임 — 분류1 다음 번호
|
||||
expect = { # 구조물 열 차례 그대로 1 부터(제목행 자리 뜻 없음)
|
||||
"rv": "00000001",
|
||||
"c1": "00000002",
|
||||
"pv_b": "00000003",
|
||||
"pv_l": "00000004",
|
||||
"pp_len": "00000007",
|
||||
"fb": "00000009",
|
||||
"be": "00000011",
|
||||
"h_rip_c": "00000012",
|
||||
"h_rip_m": "00000013",
|
||||
}
|
||||
for column, code in expect.items():
|
||||
assert numbers[column] == _num(code), column
|
||||
@@ -182,24 +183,26 @@ def test_집계표_저장_때_새_열은_만들고_지운_열은_같이_지움(c
|
||||
assert "pv_a" not in numbers and "note2" not in numbers
|
||||
for kind_ in ("structure", "area", "material"):
|
||||
assert client.get(f"/api/m02/templates/{kind_}/pv_a").status_code == 404
|
||||
assert numbers["pv_x"] == _num("02010500") # 지운 pv_a(03)는 비워 둠 · 무리 다음
|
||||
assert numbers["zz"] == _num("08000000") # 새 종류 = 종류 다음 번호
|
||||
assert numbers["pv_y"] == _num("02010101") # pv_b 무리 안 분류3 다음 번호
|
||||
# 새 열 = 열 차례로 다음 일련번호 · 지운 pv_a(5) 는 비워 둠(다른 번호를 안 당김)
|
||||
assert numbers["pv_x"] == _num("00000014")
|
||||
assert numbers["zz"] == _num("00000015")
|
||||
assert numbers["pv_y"] == _num("00000016")
|
||||
assert numbers["pv_b"] == _num("00000003") # 있던 번호는 그대로
|
||||
|
||||
|
||||
def test_지운_열_번호는_다음_저장에도_다시_안_줌(client):
|
||||
"""무리 맨 끝 열(pv_jt 02010400)을 지워 저장한 뒤 새 열을 더해도 04 를 다시 안 받음."""
|
||||
"""가장 큰 번호 열(h_rip_m 13)을 지워 저장한 뒤 새 열을 더해도 13 을 다시 안 받음."""
|
||||
store.renumber()
|
||||
url = "/api/m02/templates/table/구조물집계표"
|
||||
got = client.get(url).json()
|
||||
got["문서"]["열"] = [c for c in got["문서"]["열"] if c["id"] != "pv_jt"]
|
||||
got["문서"]["열"] = [c for c in got["문서"]["열"] if c["id"] != "h_rip_m"]
|
||||
assert client.put(url, json={"판": got["판"], "문서": got["문서"]}).status_code == 200
|
||||
assert client.get("/api/m02/structure-combos").json()["지운번호"] == ["02010400"]
|
||||
assert client.get("/api/m02/structure-combos").json()["지운번호"] == ["00000013"]
|
||||
got = client.get(url).json()
|
||||
new = {"id": "pv_z", "머리": ["콘크리트포장", "T=(두께마다)", "Z", None], "꼴": "수"}
|
||||
got["문서"]["열"].append(new)
|
||||
assert client.put(url, json={"판": got["판"], "문서": got["문서"]}).status_code == 200
|
||||
assert client.get("/api/m02/structures/numbers").json()["pv_z"] == _num("02010500")
|
||||
assert client.get("/api/m02/structures/numbers").json()["pv_z"] == _num("00000014")
|
||||
|
||||
|
||||
def test_다른_열_도면을_복사해_가져옴(client):
|
||||
@@ -211,8 +214,8 @@ def test_다른_열_도면을_복사해_가져옴(client):
|
||||
assert r.status_code == 200
|
||||
ms = client.get("/api/m02/templates/structure/ms").json()["문서"]
|
||||
assert ms["도면"]["entities"] == [{"type": "circle"}]
|
||||
assert (ms["도번"], ms["열"]) == ("SD05010100", "ms") # 번호 · 열은 받는 쪽 그대로
|
||||
assert client.get("/api/m02/templates/structure/rv").json()["문서"]["도번"] == "SD01010100"
|
||||
assert (ms["도번"], ms["열"]) == ("SD00000010", "ms") # 번호 · 열은 받는 쪽 그대로
|
||||
assert client.get("/api/m02/templates/structure/rv").json()["문서"]["도번"] == "SD00000001"
|
||||
bad = client.post("/api/m02/structures/ms/copy", params={"from": "없는열"})
|
||||
assert bad.status_code == 404
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Book as book
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router import router
|
||||
@@ -97,7 +98,7 @@ def test_집계표_저장_때_새_열_지운_열_고친_열을_맞춤_키_유지
|
||||
def test_SS_머리글은_키대장과_안_겹침():
|
||||
ledger = json.loads((ROOT / "resources/master_data/_키대장.json").read_text("utf-8"))
|
||||
assert "SS" not in ledger["다음"]
|
||||
assert store._COMBO_HEAD == "SS"
|
||||
assert book.HEAD == "SS" # 장부는 `M02_MasterTemplete_Book`(26-2a 나눔)
|
||||
|
||||
|
||||
def test_새_프로젝트_복사에_실림(client, tmp_path, monkeypatch):
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
"""M02 집계 항목 폴더 구조(PLAN 26-1 · 26-2a) — 경로 · 옛 자료 읽기 · 일련번호 · 나무 고침 · 권한.
|
||||
|
||||
① 옛 마스터(실제 `resources/master_template` · 읽기만) 51 항목이 경로로 읽힘 — 개수 · 이름(옛 종류 ·
|
||||
분류1~3 이음과 같음) · 키 차례.
|
||||
② 나무 고침은 임시 storage(회사 · 개인 층)와 견본 시스템 집계표로만 — 빈 층 첫 폴더 · 항목 · 깊이
|
||||
1 · 2 · 5 · 이름 바꾸기 · 옮기기 · 지우기(세 파일 · 입력 연결 같이) · 마지막 항목 · 판 409 ·
|
||||
422 · 404 · 권한 403 · 표 저장과 한 줄기(머리를 고치면 경로가 따라옴) · 번호 일련 · 안 되돌림.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from common_util.common_util_auth import verify_session
|
||||
from config import config_system
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Book as book
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layers_router
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Tree as tree
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
from resources.tester.test_m02_structure import write_sample_table
|
||||
|
||||
TABLE = "table/구조물집계표"
|
||||
MASTER = Path(__file__).resolve().parents[2] / "resources" / "master_template"
|
||||
|
||||
|
||||
def _app(session: dict[str, Any]) -> TestClient:
|
||||
app = FastAPI()
|
||||
app.include_router(layers_router.router)
|
||||
app.include_router(items_router.router)
|
||||
app.dependency_overrides[verify_session] = lambda: session
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
# ── ① 옛 마스터 — 읽기만 ─────────────────────────────
|
||||
|
||||
|
||||
def test_옛_마스터_51_항목이_경로로_읽힘():
|
||||
raw = json.loads((MASTER / store.COMBO_FILE).read_bytes())["줄"]
|
||||
old = [
|
||||
" ".join(str(r[k]) for k in ("종류", "분류1", "분류2", "분류3") if r.get(k)) for r in raw
|
||||
]
|
||||
client = _app({"user_id": 1, "company_id": 1, "role": "USER", "is_master": False})
|
||||
rows = client.get("/api/m02/items").json()
|
||||
assert len(rows) == len(raw) == 51
|
||||
assert [r["key"] for r in rows] == [r["키"] for r in raw]
|
||||
assert [r["이름"] for r in rows] == old
|
||||
assert all(r["경로"] and r["이름"] == " ".join(r["경로"]) for r in rows)
|
||||
got = client.get("/api/m02/layers/system/tree").json()
|
||||
assert len(got["items"]) == 51 and got["판"]
|
||||
assert ["돌기슭막이"] in got["폴더"] and ["돌기슭막이", "메쌓기"] in got["폴더"]
|
||||
# 옛 줄(경로 없음)도 종류 · 분류1~3 에서 경로로(빈 분류 건너뜀)
|
||||
assert tree.row_path({"종류": "대피소", "분류1": None, "분류2": "", "분류3": "x"}) == [
|
||||
"대피소",
|
||||
"x",
|
||||
]
|
||||
|
||||
|
||||
def test_옮기기_함수는_두_번_돌려도_같음(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
for kind in store.KINDS:
|
||||
(tmp_path / kind).mkdir()
|
||||
for name in ("table/구조물집계표.json", store.COMBO_FILE):
|
||||
(tmp_path / name).write_bytes((MASTER / name).read_bytes())
|
||||
monkeypatch.setattr(store, "FOLDER", tmp_path)
|
||||
before = json.loads((tmp_path / store.COMBO_FILE).read_bytes())["줄"]
|
||||
store.backfill_missing_structures() # 사본에 세 파일(번호) — 옮기기가 번호를 안 바꾸는지 봄
|
||||
assert book.migrate_paths() is True
|
||||
assert book.migrate_paths() is False
|
||||
after = json.loads((tmp_path / store.COMBO_FILE).read_bytes())["줄"]
|
||||
assert [r["키"] for r in after] == [r["키"] for r in before]
|
||||
assert all(r["경로"] == tree.row_path({**r, "경로": None}) for r in after)
|
||||
table = json.loads((tmp_path / "table/구조물집계표.json").read_bytes())
|
||||
assert table["층"] == ["종류", "분류1", "분류2", "분류3"] and table["폴더"] == []
|
||||
assert all(c["경로"] == tree.path_of(c) for c in table["열"] if tree.is_item(c))
|
||||
|
||||
|
||||
# ── ② 나무 고침 — 임시 storage ──────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
|
||||
system = tmp_path / "system"
|
||||
system.mkdir()
|
||||
write_sample_table(system)
|
||||
monkeypatch.setattr(store, "FOLDER", system)
|
||||
monkeypatch.setattr(layers, "SYSTEM_ROOT", system)
|
||||
store.renumber()
|
||||
storage = tmp_path / "storage"
|
||||
storage.mkdir()
|
||||
monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage))
|
||||
session: dict[str, Any] = {"user_id": 42, "company_id": 7, "role": "USER", "is_master": False}
|
||||
return {"client": _app(session), "session": session, "storage": storage, "system": system}
|
||||
|
||||
|
||||
def _as(world: dict, role: str = "USER", user: int = 42) -> TestClient:
|
||||
world["session"].update(role=role, user_id=user)
|
||||
return world["client"]
|
||||
|
||||
|
||||
class Tree:
|
||||
"""한 층 나무를 판을 따라가며 고침."""
|
||||
|
||||
def __init__(self, client: TestClient, layer: str):
|
||||
self.client, self.layer = client, layer
|
||||
self.url = f"/api/m02/layers/{layer}/tree"
|
||||
self.now = client.get(self.url).json()
|
||||
|
||||
def do(self, want: int = 200, **body: Any) -> dict[str, Any]:
|
||||
res = self.client.post(self.url, json={"판": self.now["판"], **body})
|
||||
assert res.status_code == want, res.text
|
||||
if want == 200:
|
||||
self.now = res.json()
|
||||
assert self.now == {**self.client.get(self.url).json(), "알림": self.now["알림"]}
|
||||
return res.json()
|
||||
|
||||
def item(self, path: list[str]) -> dict[str, Any]:
|
||||
return next(r for r in self.now["items"] if r["경로"] == path)
|
||||
|
||||
def paths(self) -> list[list[str]]:
|
||||
return [r["경로"] for r in self.now["items"]]
|
||||
|
||||
|
||||
def _numbers(root: Path, cid: str) -> set[str]:
|
||||
out = set()
|
||||
for kind, key, _, _ in store.PARTS:
|
||||
doc = json.loads((root / kind / f"{cid}.json").read_bytes())
|
||||
out.add(doc[key][2:])
|
||||
return out
|
||||
|
||||
|
||||
def test_빈_층에서_폴더_항목_깊이_1_2_5_이름_옮기기_지우기(world):
|
||||
t = Tree(_as(world), "personal")
|
||||
root = world["storage"] / "7" / "42" / "templates"
|
||||
assert (t.now["폴더"], t.now["items"]) == ([], []) # 빈 층
|
||||
t.do(op="add-folder", 경로=[], 이름="돌쌓기")
|
||||
assert t.now["폴더"] == [["돌쌓기"]] and t.now["items"] == []
|
||||
t.do(op="add-item", 경로=[], 이름="소단", 단위="m") # 깊이 1
|
||||
t.do(op="add-item", 경로=["돌쌓기"], 이름="찰쌓기") # 깊이 2
|
||||
t.do(op="add-item", 경로=["a", "b", "c", "d"], 이름="e") # 깊이 5(폴더는 저절로)
|
||||
assert t.paths() == [["소단"], ["돌쌓기", "찰쌓기"], ["a", "b", "c", "d", "e"]]
|
||||
deep = t.item(["a", "b", "c", "d", "e"])
|
||||
assert deep["이름"] == "a b c d e"
|
||||
assert (deep["종류"], deep["분류1"], deep["분류2"], deep["분류3"]) == ("a", "b", "c", "d")
|
||||
assert t.item(["소단"])["단위"] == "m"
|
||||
table = json.loads((root / "table/구조물집계표.json").read_bytes())
|
||||
assert table["층"] == ["종류", "분류1", "분류2", "분류3", "분류4"] # 가장 깊은 경로에 맞춤
|
||||
assert all(len(c["머리"]) == 5 for c in table["열"])
|
||||
# 번호 = 자리 뜻 없는 일련번호 · 세 파일 같은 번호
|
||||
codes = [_numbers(root, t.item(p)["열"]) for p in t.paths()]
|
||||
assert codes == [{"00000001"}, {"00000002"}, {"00000003"}]
|
||||
assert ["a", "b", "c", "d"] in t.now["폴더"] and ["돌쌓기"] in t.now["폴더"]
|
||||
# 겹침 409 · 모양 422 · 없는 것 404
|
||||
t.do(409, op="add-item", 경로=["돌쌓기"], 이름="찰쌓기")
|
||||
t.do(409, op="add-folder", 경로=[], 이름="a")
|
||||
for bad in (
|
||||
{"op": "grow"},
|
||||
{"op": "add-item", "이름": " "},
|
||||
{"op": "add-item", "이름": "x" * 61},
|
||||
):
|
||||
t.do(422, **bad)
|
||||
t.do(404, op="rename", 키="SS999999", 이름="x")
|
||||
t.do(404, op="delete", 경로=["없는폴더"])
|
||||
# 이름 바꾸기 — 항목(키 · 열 · 번호 그대로) · 폴더(아래 항목 모두)
|
||||
key = t.item(["돌쌓기", "찰쌓기"])["key"]
|
||||
t.do(op="rename", 키=key, 이름="메쌓기")
|
||||
moved = t.item(["돌쌓기", "메쌓기"])
|
||||
assert moved["key"] == key and _numbers(root, moved["열"]) == {"00000002"}
|
||||
t.do(op="rename", 경로=["a"], 이름="A")
|
||||
assert ["A", "b", "c", "d", "e"] in t.paths() and ["a"] not in t.now["폴더"]
|
||||
t.do(409, op="rename", 경로=["A"], 이름="돌쌓기") # 있는 폴더로 합치기 막음
|
||||
# 옮기기 — 항목 · 폴더 · 제 안으로는 422
|
||||
t.do(op="move", 키=t.item(["소단"])["key"], 새경로=["돌쌓기"])
|
||||
t.do(op="move", 경로=["A", "b"], 새경로=["돌쌓기"])
|
||||
assert t.paths() == [["돌쌓기", "메쌓기"], ["돌쌓기", "소단"], ["돌쌓기", "b", "c", "d", "e"]]
|
||||
t.do(422, op="move", 경로=["돌쌓기"], 새경로=["돌쌓기", "b"])
|
||||
# 지우기 — 항목의 세 파일 · 입력 연결 파일도 같이 · 마지막 항목까지
|
||||
gone = t.item(["돌쌓기", "소단"])
|
||||
(root / "inputs").mkdir(exist_ok=True)
|
||||
(root / "inputs" / f"{gone['열']}.json").write_text('{"판": 1, "줄": []}', encoding="utf-8")
|
||||
note = t.do(op="delete", 키=gone["key"])["알림"]
|
||||
assert note["지운항목"] == [gone["열"]]
|
||||
for part in ("structure", "area", "material", "inputs"):
|
||||
assert not (root / part / f"{gone['열']}.json").is_file()
|
||||
t.do(op="add-item", 경로=["돌쌓기"], 이름="새것") # 지운 번호는 다시 안 줌
|
||||
assert _numbers(root, t.item(["돌쌓기", "새것"])["열"]) == {"00000004"}
|
||||
t.do(op="delete", 경로=["돌쌓기", "b"]) # 깊은 항목이 빠지면 머리 줄 넷으로
|
||||
table = json.loads((root / "table/구조물집계표.json").read_bytes())
|
||||
assert table["층"] == ["종류", "분류1", "분류2", "분류3"]
|
||||
t.do(op="delete", 경로=["돌쌓기"]) # 마지막 항목들까지
|
||||
assert t.now["items"] == [] and t.now["폴더"] == []
|
||||
assert json.loads((root / "table/구조물집계표.json").read_bytes())["열"] == []
|
||||
|
||||
|
||||
def test_판_409_권한_403(world):
|
||||
admin = Tree(_as(world, "ADMIN"), "company")
|
||||
stale = admin.now["판"]
|
||||
admin.do(op="add-folder", 경로=[], 이름="회사폴더")
|
||||
res = admin.client.post(admin.url, json={"판": stale, "op": "add-folder", "이름": "x"})
|
||||
assert res.status_code == 409 and res.json()["detail"]["판"] == admin.now["판"]
|
||||
user = _as(world) # 같은 회사 일반 사용자 — 읽기 200 · 고침 403
|
||||
assert user.get("/api/m02/layers/company/tree").json()["폴더"] == [["회사폴더"]]
|
||||
body = {"판": admin.now["판"], "op": "add-folder", "이름": "y"}
|
||||
assert user.post("/api/m02/layers/company/tree", json=body).status_code == 403
|
||||
assert user.get("/api/m02/layers/personal/tree?owner=7/9").status_code == 403 # 남의 개인
|
||||
system = user.get("/api/m02/layers/system/tree").json()
|
||||
body = {"판": system["판"], "op": "add-folder", "이름": "y"}
|
||||
assert user.post("/api/m02/layers/system/tree", json=body).status_code == 403
|
||||
sysadmin = Tree(_as(world, "SYSTEM_ADMIN"), "system")
|
||||
sysadmin.do(op="add-item", 경로=["돌붙임"], 이름="줄붙임")
|
||||
assert ["돌붙임", "줄붙임"] in sysadmin.paths()
|
||||
|
||||
|
||||
def test_표_저장과_나무가_한_줄기(world):
|
||||
"""집계표 표에서 머리를 고치면 경로가 따라오고, 나무 고침 뒤 표에도 같은 자료가 보임."""
|
||||
admin = _as(world, "SYSTEM_ADMIN")
|
||||
t = Tree(admin, "system")
|
||||
rv = next(r for r in t.now["items"] if r["열"] == "rv")
|
||||
url = f"/api/m02/layers/system/templates/{TABLE}"
|
||||
got = admin.get(url).json()
|
||||
col = next(c for c in got["문서"]["열"] if c["id"] == "rv")
|
||||
col["머리"] = ["돌기슭막이", None, "찰쌓기", None] # 빈 칸은 건너뜀
|
||||
assert admin.put(url, json=got).status_code == 200
|
||||
t = Tree(admin, "system")
|
||||
assert t.item(["돌기슭막이", "찰쌓기"])["key"] == rv["key"]
|
||||
t.do(op="rename", 키=rv["key"], 이름="메쌓기")
|
||||
table = admin.get(url).json()["문서"]
|
||||
col = next(c for c in table["열"] if c["id"] == "rv")
|
||||
assert col["경로"] == ["돌기슭막이", "메쌓기"] and col["머리"] == [
|
||||
"돌기슭막이",
|
||||
"메쌓기",
|
||||
None,
|
||||
None,
|
||||
]
|
||||
# 표에서 열을 지우면 나무에서도 빠지고 파일도 같이
|
||||
table["열"] = [c for c in table["열"] if c["id"] != "rv"]
|
||||
assert (
|
||||
admin.put(url, json={"판": admin.get(url).json()["판"], "문서": table}).status_code == 200
|
||||
)
|
||||
assert "rv" not in [r["열"] for r in Tree(admin, "system").now["items"]]
|
||||
assert not (world["system"] / "structure" / "rv.json").is_file()
|
||||
|
||||
|
||||
def test_프로젝트에서_새_항목도_경로_그대로(world):
|
||||
"""개인에 저장 · 프로젝트에서 새 항목(`M02_Item_Personal`)도 경로를 넷에 접지 않고 그대로 ·
|
||||
같은 경로면 「이름 (2)」."""
|
||||
from M02_MasterTemplete import M02_Item_Personal as personal
|
||||
|
||||
folder = layers.personal_dir(7, 42)
|
||||
made = personal.create_item(folder, ["가", "나", "다", "라"], "마", "m")
|
||||
again = personal.create_item(folder, ["가", "나", "다", "라"], "마", "m")
|
||||
t = Tree(_as(world), "personal")
|
||||
assert t.item(["가", "나", "다", "라", "마"])["key"] == made["key"]
|
||||
assert t.item(["가", "나", "다", "라", "마 (2)"])["key"] == again["key"]
|
||||
assert made["이름"] == "가 나 다 라 마"
|
||||
@@ -82,7 +82,8 @@ def _pipe(summary: dict | None) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _items(root: Path) -> Path:
|
||||
return root / "templates" / "items"
|
||||
"""구조물 복사본 뿌리(26-2b) — 구조물마다 폴더(`structure_id` · 관은 `pipe@측점`)."""
|
||||
return root / "templates" / "structures"
|
||||
|
||||
|
||||
# ── 칸 모양 ──────────────────────────────────────────
|
||||
@@ -126,12 +127,8 @@ def test_옛_파일은_칸_없음_그대로_읽고_씀():
|
||||
def test_지정하면_폴더가_생기고_내용이_원본과_같음(world):
|
||||
root = world["root"]
|
||||
result = _save(root, [_dry(MS), _wet()], [_pipe(PIPE)])
|
||||
assert result == {
|
||||
"만듦": ["system_none_SS000011", "system_none_SS000022"],
|
||||
"지움": [],
|
||||
"못만듦": [],
|
||||
}
|
||||
folder = _items(root) / "system_none_SS000022"
|
||||
assert result == {"만듦": ["pipe@40.0", "st0"], "지움": [], "못만듦": [], "옮겨받음": []}
|
||||
folder = _items(root) / "st0"
|
||||
got = {path.name for path in folder.iterdir()}
|
||||
assert {"집계.json", "도면.json", "면적.json", "재료.json", "manifest.json"} <= got
|
||||
total = json.loads((folder / "집계.json").read_text(encoding="utf-8"))
|
||||
@@ -139,15 +136,12 @@ def test_지정하면_폴더가_생기고_내용이_원본과_같음(world):
|
||||
drawing = json.loads((world["system"] / "structure/ms.json").read_text(encoding="utf-8"))
|
||||
assert json.loads((folder / "도면.json").read_text(encoding="utf-8")) == drawing
|
||||
manifest = json.loads((folder / "manifest.json").read_text(encoding="utf-8"))
|
||||
assert manifest["원본"] == MS and manifest["구조물"] == ["st0"] and len(manifest["판"]) == 16
|
||||
pipe = json.loads(
|
||||
(_items(root) / "system_none_SS000011/manifest.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert manifest["원본"] == {**MS, "경로": ["돌쌓기", "(찰·메마다)", "H=(높이마다)"]}
|
||||
assert manifest["구조물"] == ["st0"] and len(manifest["판"]) == 16
|
||||
pipe = json.loads((_items(root) / "pipe@40.0/manifest.json").read_text(encoding="utf-8"))
|
||||
assert pipe["구조물"] == ["pipe@40.0"]
|
||||
# 일위대가 UA 줄 + 담은 로직 줄(M01 시스템)
|
||||
labor = json.loads(
|
||||
(_items(root) / "system_none_SS000011/일위대가.json").read_text(encoding="utf-8")
|
||||
)
|
||||
labor = json.loads((_items(root) / "pipe@40.0/일위대가.json").read_text(encoding="utf-8"))
|
||||
assert labor["줄"]["키"] == "UA000036"
|
||||
assert [row["키"] for row in labor["로직"]] + labor["빠진로직"] == [
|
||||
entry["로직"] for entry in labor["줄"]["담은로직"]
|
||||
@@ -157,7 +151,7 @@ def test_지정하면_폴더가_생기고_내용이_원본과_같음(world):
|
||||
def test_원본을_바꿔도_프로젝트_값은_그대로(world):
|
||||
root, system = world["root"], world["system"]
|
||||
_save(root, [_dry(MS)], [])
|
||||
before = (_items(root) / "system_none_SS000022/집계.json").read_text(encoding="utf-8")
|
||||
before = (_items(root) / "st0/집계.json").read_text(encoding="utf-8")
|
||||
table = json.loads((system / "table/구조물집계표.json").read_text(encoding="utf-8"))
|
||||
for column in table["열"]:
|
||||
if column["id"] == "ms":
|
||||
@@ -166,7 +160,7 @@ def test_원본을_바꿔도_프로젝트_값은_그대로(world):
|
||||
json.dumps(table, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
assert _save(root, [_dry(MS)], [])["만듦"] == [] # 다시 저장해도 안 다시 씀
|
||||
after = (_items(root) / "system_none_SS000022/집계.json").read_text(encoding="utf-8")
|
||||
after = (_items(root) / "st0/집계.json").read_text(encoding="utf-8")
|
||||
assert after == before
|
||||
doc = fill.fill_table(root, table)
|
||||
heads = [c["머리"][0] for c in doc["열"] if c["id"].startswith("ms|")]
|
||||
@@ -174,16 +168,20 @@ def test_원본을_바꿔도_프로젝트_값은_그대로(world):
|
||||
|
||||
|
||||
def test_지정_바꿈_해제_삭제면_옛_폴더를_지움(world):
|
||||
"""같은 항목을 고른 구조물 둘 = 폴더 둘 · 한쪽만 바꾸면 그쪽만 새로 · 해제 · 삭제면 지움."""
|
||||
root = world["root"]
|
||||
_save(root, [_dry(MS), _dry(MS)], [])
|
||||
manifest = _items(root) / "system_none_SS000022/manifest.json"
|
||||
assert json.loads(manifest.read_text(encoding="utf-8"))["구조물"] == ["st0", "st1"]
|
||||
# 한 구조물만 바꿈 — 같은 항목을 쓰는 구조물이 남으면 폴더 유지 · 목록만 줄어듦
|
||||
assert sorted(p.name for p in _items(root).iterdir()) == ["st0", "st1"]
|
||||
result = _save(root, [_dry(RV), _dry(MS)], [])
|
||||
assert result["만듦"] == ["system_none_SS000001"] and result["지움"] == []
|
||||
assert json.loads(manifest.read_text(encoding="utf-8"))["구조물"] == ["st1"]
|
||||
assert result["만듦"] == ["st0"] and result["지움"] == ["st0"] # 안 고쳤으니 옛 것은 지움
|
||||
assert json.loads((_items(root) / "st0/manifest.json").read_text("utf-8"))["원본"]["key"] == (
|
||||
"SS000001"
|
||||
)
|
||||
assert json.loads((_items(root) / "st1/manifest.json").read_text("utf-8"))["원본"]["key"] == (
|
||||
"SS000022"
|
||||
)
|
||||
# 해제 · 삭제
|
||||
assert _save(root, [_dry(None)], [])["지움"] == ["system_none_SS000001", "system_none_SS000022"]
|
||||
assert _save(root, [_dry(None)], [])["지움"] == ["st0", "st1"]
|
||||
assert list(_items(root).iterdir()) == []
|
||||
|
||||
|
||||
@@ -197,8 +195,8 @@ def test_회사_층_항목은_회사_폴더에서_복사(world):
|
||||
row["분류3"] = "회사 것"
|
||||
(company / layers.COMBO_FILE).write_text(json.dumps(combo, ensure_ascii=False), "utf-8")
|
||||
owned = {**MS, "layer": "company", "owner": "3"}
|
||||
assert _save(root, [_dry(owned)], [])["만듦"] == ["company_3_SS000022"]
|
||||
total = json.loads((_items(root) / "company_3_SS000022/집계.json").read_text("utf-8"))
|
||||
assert _save(root, [_dry(owned)], [])["만듦"] == ["st0"]
|
||||
total = json.loads((_items(root) / "st0/집계.json").read_text("utf-8"))
|
||||
assert total["줄"]["분류3"] == "회사 것"
|
||||
# 채움 — 회사 항목은 시스템 열과 안 겹치는 새 열
|
||||
doc = fill.fill_table(
|
||||
@@ -210,7 +208,7 @@ def test_회사_층_항목은_회사_폴더에서_복사(world):
|
||||
|
||||
def test_원본에_없는_항목은_못만듦으로_남김(world):
|
||||
gone = {**MS, "key": "SS999999"}
|
||||
assert _save(world["root"], [_dry(gone)], [])["못만듦"] == ["system_none_SS999999"]
|
||||
assert _save(world["root"], [_dry(gone)], [])["못만듦"] == ["st0"]
|
||||
|
||||
|
||||
# ── 채움 ─────────────────────────────────────────────
|
||||
@@ -245,4 +243,4 @@ def test_스냅샷이_없으면_알림(world):
|
||||
def test_초기화는_templates_를_안_건드리고_복제는_통째로():
|
||||
assert not any(tree.startswith("templates") for tree in initial._FILE_TREES)
|
||||
tool = (config_system.PROJECT_ROOT / "db_management/tools_clone_project.py").read_text("utf-8")
|
||||
assert '"robocopy",' in tool and '"/E",' in tool # 프로젝트 폴더 전체(templates/items 포함)
|
||||
assert '"robocopy",' in tool and '"/E",' in tool # 프로젝트 폴더 전체(templates 포함)
|
||||
|
||||
Reference in New Issue
Block a user