Merge remote-tracking branch 'origin/dev' into main_laptop_1
This commit is contained in:
@@ -6,15 +6,18 @@
|
|||||||
② [넣기] 같은 파일 + 고른 호표 차례 + 우리 구조물 종류 → 서버가 **파일을 다시 읽어** 개인 단에 씀.
|
② [넣기] 같은 파일 + 고른 호표 차례 + 우리 구조물 종류 → 서버가 **파일을 다시 읽어** 개인 단에 씀.
|
||||||
⚠ 브라우저가 보낸 줄을 받아 적지 않음(CLAUDE.md 5장) · 개인 단만(판정 Ⓗ) · 종류당 하나라 같은 종류 내 것은
|
⚠ 브라우저가 보낸 줄을 받아 적지 않음(CLAUDE.md 5장) · 개인 단만(판정 Ⓗ) · 종류당 하나라 같은 종류 내 것은
|
||||||
덮어씀(판정 Ⓐ) · 종류는 사용자가 고름 — 이름으로 자동으로 안 붙임(판정 Ⓒ).
|
덮어씀(판정 Ⓐ) · 종류는 사용자가 고름 — 이름으로 자동으로 안 붙임(판정 Ⓒ).
|
||||||
|
③ [복제해서 내 것으로](PLAN 4장) — 기본·회사 단 항목을 개인 단에 베낌. 프로젝트 작업본은 안 바꿈.
|
||||||
|
(구조물도 라우터가 700줄에 닿아 개인 단으로 넣는 창구를 이 파일에 모음.)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import io
|
import io
|
||||||
from typing import Any
|
from typing import Any, Literal
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, Form, UploadFile
|
from fastapi import APIRouter, Depends, File, Form, UploadFile
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
from common_util.common_util_auth import verify_session
|
from common_util.common_util_auth import verify_session
|
||||||
|
|
||||||
@@ -110,3 +113,39 @@ async def save_stmate_recipe(
|
|||||||
"note": item["note"],
|
"note": item["note"],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LibraryCloneRequest(BaseModel):
|
||||||
|
"""베낄 항목 — 단과 코드(이름은 겹칠 수 있음). 개인 단 것은 이미 내 것이라 안 받음."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
type_id: str = Field(min_length=1, max_length=100)
|
||||||
|
tier: Literal["company", "program"]
|
||||||
|
code: str = Field(pattern=r"^AX-ST-[0-9a-f]{8}$")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{project_id}/quantity/structure-sheets/library/clone")
|
||||||
|
async def clone_library_item(
|
||||||
|
project_id: UUID,
|
||||||
|
payload: LibraryCloneRequest,
|
||||||
|
session: dict[str, Any] = Depends(verify_session),
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""③ 기본·회사 항목을 **로그인한 사람 개인 단**에 베낌 — 가져와 고친 뒤 [내 라이브러리에 저장]할 길을 한 번에."""
|
||||||
|
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import (
|
||||||
|
find_item,
|
||||||
|
save_item_personal,
|
||||||
|
tier_dirs,
|
||||||
|
)
|
||||||
|
|
||||||
|
dirs = tier_dirs(session.get("company_id"), session.get("user_id"))
|
||||||
|
folder = dirs.get("personal")
|
||||||
|
if folder is None:
|
||||||
|
return _error(403, "개인 라이브러리는 회사에 속한 사용자만 씁니다.")
|
||||||
|
item = await asyncio.to_thread(find_item, dirs, payload.tier, payload.code)
|
||||||
|
if item is None or item.get("type_id") != payload.type_id:
|
||||||
|
return _error(404, "베낄 항목을 찾지 못했습니다.")
|
||||||
|
body = {k: v for k, v in item.items() if k not in ("code", "imported_from", "library_tier")}
|
||||||
|
body["cloned_from"] = {"tier": payload.tier, "code": payload.code, "name": item.get("name")}
|
||||||
|
code = await asyncio.to_thread(save_item_personal, folder, body)
|
||||||
|
return JSONResponse(content={"status": "success", "code": code, "name": item.get("name")})
|
||||||
|
|||||||
@@ -80,6 +80,49 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement {
|
|||||||
take.className = "b08-spec__save";
|
take.className = "b08-spec__save";
|
||||||
take.textContent = "가져오기";
|
take.textContent = "가져오기";
|
||||||
take.hidden = true;
|
take.hidden = true;
|
||||||
|
// 복제 — 기본·회사 항목을 개인 단에 베낌(PLAN 4장). 개인 단 것은 이미 내 것이라 안 눌림.
|
||||||
|
const clone = document.createElement("button");
|
||||||
|
clone.type = "button";
|
||||||
|
clone.className = "b08-quantity__tab";
|
||||||
|
clone.textContent = "복제해서 내 것으로";
|
||||||
|
clone.hidden = true;
|
||||||
|
/** 목록을 다시 받은 뒤 보일 한 줄 — 받는 동안 상태 줄이 지워져 복제 결과가 사라지지 않게. */
|
||||||
|
let afterLoad = "";
|
||||||
|
const syncClone = (): void => {
|
||||||
|
const [tier] = list.value.split("|");
|
||||||
|
clone.disabled = tier === "personal";
|
||||||
|
};
|
||||||
|
list.addEventListener("change", syncClone);
|
||||||
|
clone.addEventListener("click", () => {
|
||||||
|
const [tier, code] = list.value.split("|");
|
||||||
|
const label = list.selectedOptions[0]?.textContent ?? "";
|
||||||
|
if (!tier || !code || tier === "personal") return;
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
`「${label}」을 내 라이브러리로 베낌 — 같은 종류 내 것이 있으면 덮어씀 · 프로젝트 값은 안 바뀜`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void (async () => {
|
||||||
|
clone.disabled = true;
|
||||||
|
try {
|
||||||
|
await readJson(
|
||||||
|
await fetch(libraryUrl(projectId, "/clone"), {
|
||||||
|
method: "PUT",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ type_id: typeId, tier, code }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
afterLoad = "내 라이브러리에 베낌 — 가져와 고친 뒤 [내 라이브러리에 저장]";
|
||||||
|
load.click();
|
||||||
|
} catch (error) {
|
||||||
|
status.textContent = error instanceof Error ? error.message : "복제 못함";
|
||||||
|
syncClone();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
load.addEventListener("click", () => {
|
load.addEventListener("click", () => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@@ -101,8 +144,10 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement {
|
|||||||
return option;
|
return option;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
list.hidden = take.hidden = items.length === 0;
|
list.hidden = take.hidden = clone.hidden = items.length === 0;
|
||||||
status.textContent = items.length ? "" : "가져올 항목이 없음";
|
syncClone();
|
||||||
|
status.textContent = items.length ? afterLoad : "가져올 항목이 없음";
|
||||||
|
afterLoad = "";
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
status.textContent = error instanceof Error ? error.message : "목록을 받지 못함";
|
status.textContent = error instanceof Error ? error.message : "목록을 받지 못함";
|
||||||
} finally {
|
} finally {
|
||||||
@@ -191,7 +236,7 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement {
|
|||||||
mine.className = "b08-sheet__actions";
|
mine.className = "b08-sheet__actions";
|
||||||
mine.append(save, remove);
|
mine.append(save, remove);
|
||||||
|
|
||||||
panel.append(title, scope, load, list, take, mine, status);
|
panel.append(title, scope, load, list, take, clone, mine, status);
|
||||||
// 고정형 항목을 만드는 둘째 길 — STmate 출력 엑셀에서 호표 하나를 뽑아 개인 단에(PLAN 4장).
|
// 고정형 항목을 만드는 둘째 길 — STmate 출력 엑셀에서 호표 하나를 뽑아 개인 단에(PLAN 4장).
|
||||||
panel.append(
|
panel.append(
|
||||||
buildStmatePanel({
|
buildStmatePanel({
|
||||||
|
|||||||
@@ -233,7 +233,16 @@ export function unitPriceEditor(options: EditorOptions): HTMLElement {
|
|||||||
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
|
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
|
||||||
const items = payload.items ?? [];
|
const items = payload.items ?? [];
|
||||||
results.replaceChildren(
|
results.replaceChildren(
|
||||||
...(items.length ? items.map(choice) : [el("span", "b08-grid__caption", "없음")]),
|
...(items.length
|
||||||
|
? items.map(choice)
|
||||||
|
: [
|
||||||
|
// 후보 0 — 조용히 막히지 않게 다음 길을 적음(갈래 바꿔 찾기 · 수동 단가는 미확정으로 셈).
|
||||||
|
el(
|
||||||
|
"span",
|
||||||
|
"b08-grid__caption",
|
||||||
|
"단가표에 후보 없음 — 낱말·갈래(품셈/자원)를 바꿔 찾거나, 이 줄에 수동 단가를 넣을 것(미확정으로 셈)",
|
||||||
|
),
|
||||||
|
]),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
results.replaceChildren(
|
results.replaceChildren(
|
||||||
|
|||||||
@@ -105,6 +105,8 @@ def test_찾기는_코드_없는_줄_이름으로_후보만_띄운다() -> None:
|
|||||||
encoding="utf-8"
|
encoding="utf-8"
|
||||||
)
|
)
|
||||||
assert "query.value = row.name" in ui and "!row.ref_code && !row.work_item_code" in ui
|
assert "query.value = row.name" in ui and "!row.ref_code && !row.work_item_code" in ui
|
||||||
|
# 후보 0 이면 다음 길(갈래 바꾸기 · 수동 단가)을 적음 — 조용히 「없음」으로 막히지 않게(2026-09-14 브레인)
|
||||||
|
assert "수동 단가를 넣을 것" in ui
|
||||||
|
|
||||||
|
|
||||||
def test_사용자가_코드를_고르면_금액이_선다() -> None:
|
def test_사용자가_코드를_고르면_금액이_선다() -> None:
|
||||||
|
|||||||
@@ -216,6 +216,41 @@ def test_확정_때_기본_양식을_박는다(project: Path) -> None:
|
|||||||
assert library_module.pin_program_templates(project) == 0 # 이미 박힌 것은 그대로
|
assert library_module.pin_program_templates(project) == 0 # 이미 박힌 것은 그대로
|
||||||
|
|
||||||
|
|
||||||
|
def test_복제는_고른_항목을_개인_단에_베끼고_프로젝트는_안_바꾼다(
|
||||||
|
client: TestClient, project: Path, storage: Path
|
||||||
|
) -> None:
|
||||||
|
"""PLAN 4장 「복제해서 내 것 만들기」 — 기본(또는 회사) 항목을 개인 단으로 한 번에 베낌."""
|
||||||
|
import B08_Quantity.B08_Quantity_Router_StmateLibrary as extra_router
|
||||||
|
|
||||||
|
client.app.include_router(extra_router.router)
|
||||||
|
base = load_template("masonry_wet")
|
||||||
|
folder = storage / "7" / "42" / "library"
|
||||||
|
for path in folder.glob("*.json"):
|
||||||
|
path.unlink() # 개인 단을 비워 새로 생기는지 봄
|
||||||
|
response = client.put(
|
||||||
|
f"{SHEETS}/library/clone",
|
||||||
|
json={"type_id": "masonry_wet", "tier": "program", "code": base["code"]},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
code = response.json()["code"]
|
||||||
|
saved = json.loads((folder / f"{code}.json").read_text(encoding="utf-8"))
|
||||||
|
assert saved["library_tier"] == "personal" and saved["code"] != base["code"]
|
||||||
|
assert saved["cloned_from"] == {"tier": "program", "code": base["code"], "name": base["name"]}
|
||||||
|
assert saved["rows"] == base["rows"]
|
||||||
|
assert not (project / "B08_Quantity" / "library").exists() # 프로젝트 작업본은 그대로
|
||||||
|
missing = client.put(
|
||||||
|
f"{SHEETS}/library/clone",
|
||||||
|
json={"type_id": "masonry_wet", "tier": "program", "code": "AX-ST-00000000"},
|
||||||
|
)
|
||||||
|
assert missing.status_code == 404
|
||||||
|
client.app.dependency_overrides[verify_session] = lambda: {"company_id": None, "user_id": 1}
|
||||||
|
denied = client.put(
|
||||||
|
f"{SHEETS}/library/clone",
|
||||||
|
json={"type_id": "masonry_wet", "tier": "program", "code": base["code"]},
|
||||||
|
)
|
||||||
|
assert denied.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
def test_항목마다_양식형_고정형_종류가_붙는다(tmp_path: Path) -> None:
|
def test_항목마다_양식형_고정형_종류가_붙는다(tmp_path: Path) -> None:
|
||||||
"""PLAN 4장 「항목마다 종류 배지」 — 명세 13장: 칸은 같고 `formula` 유무로만 갈림."""
|
"""PLAN 4장 「항목마다 종류 배지」 — 명세 13장: 칸은 같고 `formula` 유무로만 갈림."""
|
||||||
folder = tmp_path / "personal"
|
folder = tmp_path / "personal"
|
||||||
@@ -236,6 +271,14 @@ def test_항목마다_양식형_고정형_종류가_붙는다(tmp_path: Path) ->
|
|||||||
assert "양식형" in ui and "고정형" in ui and "item.kind" in ui
|
assert "양식형" in ui and "고정형" in ui and "item.kind" in ui
|
||||||
|
|
||||||
|
|
||||||
|
def test_복제_단추는_개인_단_항목에선_안_눌린다() -> None:
|
||||||
|
ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet_Library.ts").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
assert "/clone" in ui and "복제해서 내 것으로" in ui
|
||||||
|
assert 'clone.disabled = tier === "personal"' in ui
|
||||||
|
|
||||||
|
|
||||||
def test_코드_모양이_아니면_박지_않는다(project: Path) -> None:
|
def test_코드_모양이_아니면_박지_않는다(project: Path) -> None:
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
library_module.import_item(project, {"type_id": "masonry_wet", "code": "../x"}, "personal")
|
library_module.import_item(project, {"type_id": "masonry_wet", "code": "../x"}, "personal")
|
||||||
|
|||||||
Reference in New Issue
Block a user