merge: origin/dev 받음 — 도면 칸 CAD 💾 · 접기와 sub1 저장 안 한 고침 알림 통로를 다 살림
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PxvYb5ufV1kWdBvZbpDfu6
This commit is contained in:
@@ -35,7 +35,8 @@ async function call<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
...init,
|
||||
});
|
||||
const body = (await response.json().catch(() => ({}))) as { detail?: unknown } & T;
|
||||
if (response.status === 409) throw new StaleError(String(body.detail ?? "409"));
|
||||
if (response.status === 409)
|
||||
throw new StaleError(typeof body.detail === "string" ? body.detail : "409");
|
||||
if (!response.ok) {
|
||||
const detail = body.detail;
|
||||
throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`);
|
||||
@@ -82,6 +83,14 @@ export const saveTemplate = (
|
||||
export const deleteTemplate = (kind: Kind, name: string, 판: string): Promise<{ ok: boolean }> =>
|
||||
call(`/templates/${kind}/${enc(name)}?판=${enc(판)}`, { method: "DELETE" });
|
||||
|
||||
/** 개인·회사 층에서 지움 — 서버 길은 sub4 (없으면 404) */
|
||||
export const deleteLayerTemplate = (
|
||||
layer: Layer,
|
||||
kind: Kind,
|
||||
name: string,
|
||||
): Promise<{ ok: boolean }> =>
|
||||
call(`/layers/${layer}/templates/${kind}/${enc(name)}`, { method: "DELETE" });
|
||||
|
||||
/* --- 프로젝트 층 단추 다섯 --- */
|
||||
const project = (id: string, tail: string): string => `/projects/${enc(id)}/templates/${tail}`;
|
||||
const post = (path: string, body: object = {}): Promise<unknown> =>
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface DrawingTemplateOptions {
|
||||
layer?: string;
|
||||
/** 프로젝트 층이면 프로젝트 id — 칸이 `m02:project:프로젝트id:이름` 이 된다. */
|
||||
projectId?: string | null;
|
||||
/** 저장 안 한 고침이 생기면 true — CAD 편집 · 작도 영역 값 바꿈. */
|
||||
onChanged?: (dirty: boolean) => void;
|
||||
}
|
||||
|
||||
const RECOVERY_PREFIX = "OPEN_WEB_CAD__RECOVERY__";
|
||||
@@ -128,6 +130,7 @@ export function mountDrawingTemplate(
|
||||
const areaInputs = AREA_LABELS.map((label, index) => {
|
||||
const field = createInputField({ label, type: "number", value: String(start[index]) });
|
||||
field.input.disabled = readOnly;
|
||||
field.input.addEventListener("input", () => options.onChanged?.(true));
|
||||
return field;
|
||||
});
|
||||
area.append(areaTitle, ...areaInputs.map((field) => field.root));
|
||||
@@ -167,21 +170,6 @@ export function mountDrawingTemplate(
|
||||
showToast(error instanceof Error ? error.message : "자리표 목록을 받지 못했습니다.", "error"),
|
||||
);
|
||||
|
||||
// CAD 안 💾 · Ctrl+S — 양식 파일은 페이지 [저장] 만 쓴다. 저장 중 또 눌러도 한 번만 부른다.
|
||||
let saving = false;
|
||||
const hostSave = (): void => {
|
||||
if (readOnly) return void showToast("이 양식은 볼 수만 있습니다.", "info");
|
||||
if (saving || !options.onSave) return;
|
||||
saving = true;
|
||||
getDoc()
|
||||
.then((next) => options.onSave?.(next))
|
||||
.catch((error) =>
|
||||
showToast(error instanceof Error ? error.message : "양식을 저장하지 못했습니다.", "error"),
|
||||
)
|
||||
.finally(() => (saving = false));
|
||||
};
|
||||
|
||||
const cad = createCadHost<DrawingTemplateDoc>({ title: "도면 양식", onHostSave: hostSave });
|
||||
const load = (drawing: DrawingTemplateDoc): void => {
|
||||
cad.beginLoading();
|
||||
cad.load(
|
||||
|
||||
@@ -279,6 +279,39 @@ async def save_layer_template(
|
||||
return {"종류": kind, "이름": name, "판": version, "층": layer}
|
||||
|
||||
|
||||
@router.delete("/layers/{layer}/templates/{kind}/{name}")
|
||||
async def delete_layer_template(
|
||||
layer: Layer,
|
||||
kind: str,
|
||||
name: str,
|
||||
판: str | None = Query(None),
|
||||
project_id: str | None = Query(None),
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
) -> dict[str, Any]:
|
||||
"""개인 = 본인만 · 회사 = 회사 관리자만 · 시스템 · 프로젝트 층은 거절 · 판이 다르면 409."""
|
||||
if layer in ("system", "project"):
|
||||
raise HTTPException(status_code=403, detail="개인 · 회사 층 양식만 지울 수 있습니다.")
|
||||
folder = await _layer_dir(session, layer, project_id=project_id, write=True)
|
||||
try:
|
||||
layers.check_kind(kind)
|
||||
layers.check_name(name)
|
||||
except ValueError as error:
|
||||
raise _bad(error) from error
|
||||
if not 판:
|
||||
raise HTTPException(status_code=400, detail="지울 양식의 판이 필요합니다.")
|
||||
try:
|
||||
found = await asyncio.to_thread(
|
||||
layers.delete_template, folder, kind, name, version=판, check_version=True
|
||||
)
|
||||
except layers.StaleTemplate as error:
|
||||
raise HTTPException(
|
||||
status_code=409, detail={"message": str(error), "판": error.current}
|
||||
) from error
|
||||
if not found:
|
||||
raise HTTPException(status_code=404, detail="양식을 찾을 수 없습니다.")
|
||||
return {"종류": kind, "이름": name, "층": layer, "지움": True}
|
||||
|
||||
|
||||
# ── 프로젝트 길 ───────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ from common_util.common_util_json import atomic_write_json
|
||||
|
||||
FOLDER: Path = Path(__file__).resolve().parent.parent / "resources" / "master_template"
|
||||
KINDS = ("table", "drawing") # 시험은 FOLDER 를 사본으로 바꿈
|
||||
_BAD_NAME = re.compile(r'[\/:*?"<>|\x00-\x1f]')
|
||||
_BAD_NAME = re.compile(r'[\\/:*?"<>|\x00-\x1f]|\.\.')
|
||||
# ponytail: 저장은 한 번에 하나(프로세스 안 잠금) · 서버를 여럿 띄우면 파일 잠금으로
|
||||
_LOCK = threading.Lock()
|
||||
|
||||
@@ -37,7 +37,7 @@ def _path(kind: str, name: str) -> Path:
|
||||
if kind not in KINDS:
|
||||
raise StoreError(404, f"없는 종류 「{kind}」")
|
||||
if not name or name != name.strip() or name.startswith(".") or _BAD_NAME.search(name):
|
||||
raise StoreError(422, f"쓸 수 없는 이름 「{name}」")
|
||||
raise StoreError(400, f"쓸 수 없는 글자가 있는 이름 「{name}」")
|
||||
return FOLDER / kind / f"{name}.json"
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 부품 파일이 없거나 못 읽어도 페이지는 뜸 — 빈 자리 글.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import {
|
||||
readFilled,
|
||||
@@ -48,6 +48,8 @@ export interface MainHandle {
|
||||
root: HTMLElement;
|
||||
open: (sel: Selection | null) => Promise<void>;
|
||||
current: () => Selection | null;
|
||||
/** 저장 안 한 고침이 있으면 버릴지 물음 — 취소면 false */
|
||||
confirmLeave: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
/** 이 층에서 고칠 수 있나 — 시스템은 관리자만 · 회사는 여기서 못 고침(공식으로 저장으로만) */
|
||||
@@ -75,6 +77,21 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle {
|
||||
let loaded: unknown = null;
|
||||
let editor: EditorHandle | null = null;
|
||||
let seq = 0;
|
||||
let dirty = false;
|
||||
|
||||
const guard = (event: BeforeUnloadEvent): void => {
|
||||
if (!root.isConnected) return window.removeEventListener("beforeunload", guard);
|
||||
if (!dirty) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
};
|
||||
window.addEventListener("beforeunload", guard);
|
||||
const confirmLeave = async (): Promise<boolean> => {
|
||||
if (!dirty) return true;
|
||||
if (!(await showConfirmDialog(L("M02_DiscardConfirm")))) return false;
|
||||
dirty = false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const showNotice = (nodes: (HTMLElement | string)[]): void => {
|
||||
notice.replaceChildren(...nodes);
|
||||
@@ -102,7 +119,10 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle {
|
||||
"createSheet",
|
||||
);
|
||||
if (create) {
|
||||
editor = create(host, doc, { mode: at.layer === "project" ? "project" : "master" });
|
||||
editor = create(host, doc, {
|
||||
mode: at.layer === "project" ? "project" : "master",
|
||||
onChange: () => (dirty = !readOnly),
|
||||
});
|
||||
return;
|
||||
}
|
||||
return empty(L("M02_NoSheet"));
|
||||
@@ -118,6 +138,7 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle {
|
||||
name: at.name,
|
||||
layer: at.layer,
|
||||
projectId: at.projectId,
|
||||
onChanged: (d: boolean) => (dirty = d && !readOnly),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -126,6 +147,7 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle {
|
||||
|
||||
async function open(next: Selection | null): Promise<void> {
|
||||
const mine = ++seq;
|
||||
dirty = false;
|
||||
drop();
|
||||
showNotice([]);
|
||||
sel = next;
|
||||
@@ -166,6 +188,7 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle {
|
||||
const info = await saveTemplate(at.layer, at.kind, at.name, at.projectId, version, doc);
|
||||
version = info.판;
|
||||
loaded = doc;
|
||||
dirty = false;
|
||||
showNotice([]);
|
||||
showToast(L("M02_Saved"), "success");
|
||||
onSaved?.(); // 좌측 목록의 판을 새 판으로
|
||||
@@ -182,8 +205,10 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle {
|
||||
}
|
||||
|
||||
save.addEventListener("click", () => void doSave());
|
||||
reload.addEventListener("click", () => sel && void open(sel));
|
||||
reload.addEventListener("click", async () => {
|
||||
if (sel && (await confirmLeave())) void open(sel);
|
||||
});
|
||||
bar();
|
||||
empty("");
|
||||
return { root, open, current: () => sel };
|
||||
return { root, open, current: () => sel, confirmLeave };
|
||||
}
|
||||
|
||||
@@ -20,9 +20,11 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<void>
|
||||
const main = createMain(isAdmin, () => void side.refresh());
|
||||
const side = buildSide({
|
||||
isAdmin,
|
||||
isCompanyAdmin: isAdmin || user?.role === "ADMIN" || !!user?.is_master,
|
||||
userName: user?.name,
|
||||
onOpen: (sel) => void main.open(sel),
|
||||
getOpen: main.current,
|
||||
confirmLeave: main.confirmLeave,
|
||||
});
|
||||
|
||||
const layout = el("div", { className: "ui-workflow-layout m02-master" });
|
||||
|
||||
@@ -17,6 +17,7 @@ import { openModal } from "@ui/ui_template_modal";
|
||||
import { fetchUserProjects, type ProjectItem } from "../B01_Dashboard/B01_Dashboard_Api_Fetch";
|
||||
import {
|
||||
applyToProject,
|
||||
deleteLayerTemplate,
|
||||
deleteTemplate,
|
||||
fetchSources,
|
||||
listTemplates,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
resetProject,
|
||||
saveProjectAs,
|
||||
saveTemplate,
|
||||
StaleError,
|
||||
type Kind,
|
||||
type Layer,
|
||||
type TemplateInfo,
|
||||
@@ -78,10 +80,14 @@ const store = {
|
||||
|
||||
export interface SideOptions {
|
||||
isAdmin: boolean;
|
||||
/** 회사 관리자(회사 층 지우기 · 회사 공식으로 저장) */
|
||||
isCompanyAdmin?: boolean;
|
||||
/** 로그인한 사용자 이름 — 프로젝트 기본값(내가 만든 최근 것)에 씀 */
|
||||
userName?: string;
|
||||
onOpen: (sel: Selection | null) => void;
|
||||
getOpen: () => Selection | null;
|
||||
/** 저장 안 한 고침을 버릴지 확인 — 취소면 false */
|
||||
confirmLeave: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface SideHandle {
|
||||
@@ -89,6 +95,10 @@ export interface SideHandle {
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** 서버 이름 규칙과 같게 — 화면에서 먼저 막음 */
|
||||
const badName = (name: string): boolean =>
|
||||
/[\\/:*?"<>|\x00-\x1f]|\.\./.test(name) || name.startsWith(".");
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
|
||||
const failed = (error: unknown): void =>
|
||||
showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error");
|
||||
@@ -144,7 +154,8 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
const layerButtons = LAYERS.map(({ layer: id, label }) => {
|
||||
const button = createButton({ label: label(), variant: "ghost" });
|
||||
button.disabled = id === "system" && !opt.isAdmin;
|
||||
button.addEventListener("click", () => {
|
||||
button.addEventListener("click", async () => {
|
||||
if (id !== layer && !(await opt.confirmLeave())) return;
|
||||
layer = id;
|
||||
store.set(LAYER_KEY, id);
|
||||
opt.onOpen(null);
|
||||
@@ -173,7 +184,11 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
};
|
||||
|
||||
const projectField = createSelectField({ label: L("M02_Project"), options: [] });
|
||||
projectField.select.addEventListener("change", () => {
|
||||
projectField.select.addEventListener("change", async () => {
|
||||
if (!(await opt.confirmLeave())) {
|
||||
projectField.select.value = projectId;
|
||||
return;
|
||||
}
|
||||
projectId = projectField.select.value;
|
||||
remember(projectId);
|
||||
opt.onOpen(null);
|
||||
@@ -221,7 +236,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
text: info.이름,
|
||||
attrs: { type: "button", title: info.수정일 },
|
||||
});
|
||||
b.addEventListener("click", () => select(info));
|
||||
b.addEventListener("click", () => void select(info));
|
||||
return b;
|
||||
})
|
||||
: [el("p", { className: "m02-side__empty", text: L("M02_NoTemplates") })]),
|
||||
@@ -235,14 +250,19 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
layerButtons.forEach(({ id, button }) => button.classList.toggle("is-active", id === layer));
|
||||
projectField.root.hidden = !isProject();
|
||||
const editable = layer === "system" && canEdit(layer, opt.isAdmin);
|
||||
editRow.hidden = layer !== "system";
|
||||
const canDelete =
|
||||
editable || layer === "personal" || (layer === "company" && !!opt.isCompanyAdmin);
|
||||
editRow.hidden = isProject();
|
||||
btnNew.hidden = btnCopy.hidden = layer !== "system";
|
||||
btnNew.disabled = !editable;
|
||||
btnCopy.disabled = btnDel.disabled = !editable || !currentInfo();
|
||||
btnCopy.disabled = !editable || !currentInfo();
|
||||
btnDel.disabled = !canDelete || !currentInfo();
|
||||
projectRow.hidden = !isProject();
|
||||
projectRow.querySelectorAll("button").forEach((b) => (b.disabled = !projectId));
|
||||
}
|
||||
|
||||
function select(info: TemplateInfo): void {
|
||||
async function select(info: TemplateInfo): Promise<void> {
|
||||
if (!isOpen(info) && !(await opt.confirmLeave())) return;
|
||||
opt.onOpen({ layer, projectId: pid(), kind: info.종류, name: info.이름 });
|
||||
paintList();
|
||||
paintButtons();
|
||||
@@ -298,11 +318,16 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
ok.addEventListener("click", async () => {
|
||||
const text = name.input.value.trim();
|
||||
if (!text) return showToast(L("M02_NameNeeded"), "error");
|
||||
if (badName(text)) return showToast(L("M02_BadName"), "error");
|
||||
ok.disabled = true; // 연달아 눌러도 한 번만
|
||||
try {
|
||||
await onOk(kind.select.value as Kind, text);
|
||||
close();
|
||||
} catch (error) {
|
||||
failed(error);
|
||||
if (error instanceof StaleError) showToast(L("M02_NameExists"), "error");
|
||||
else failed(error);
|
||||
} finally {
|
||||
ok.disabled = false;
|
||||
}
|
||||
});
|
||||
body.append(...(withKind ? [kind.root] : []), name.root, ok);
|
||||
@@ -315,7 +340,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
showToast(L("M02_Created"), "success");
|
||||
await refresh();
|
||||
const info = items.find((i) => i.종류 === kind && i.이름 === name);
|
||||
if (info) select(info);
|
||||
if (info) await select(info);
|
||||
};
|
||||
|
||||
btnNew.addEventListener("click", () =>
|
||||
@@ -339,7 +364,8 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
if (!info) return;
|
||||
if (!(await showConfirmDialog(L("M02_DeleteConfirm").replace("{value}", info.이름)))) return;
|
||||
try {
|
||||
await deleteTemplate(info.종류, info.이름, info.판);
|
||||
if (layer === "system") await deleteTemplate(info.종류, info.이름, info.판);
|
||||
else await deleteLayerTemplate(layer, info.종류, info.이름);
|
||||
showToast(L("M02_Deleted"), "success");
|
||||
opt.onOpen(null);
|
||||
await refresh();
|
||||
@@ -350,6 +376,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
|
||||
/* --- 프로젝트 층 단추 다섯 — 길은 계약 · 아직 없으면 404 를 토스트로 --- */
|
||||
const act = async (run: () => Promise<unknown>): Promise<void> => {
|
||||
if (!(await opt.confirmLeave())) return;
|
||||
try {
|
||||
await run();
|
||||
showToast(L("M02_Done"), "success");
|
||||
@@ -364,9 +391,21 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
b.addEventListener("click", onClick);
|
||||
projectRow.append(b);
|
||||
};
|
||||
const saveOpenAs = (to: "personal" | "company"): void => {
|
||||
const saveOpenAs = async (to: "personal" | "company"): Promise<void> => {
|
||||
const cur = opt.getOpen();
|
||||
if (!cur) return void showToast(L("M02_PickTemplate"), "error");
|
||||
try {
|
||||
const rows = await listTemplates(to, null);
|
||||
const same = rows.some((r) => r.종류 === cur.kind && r.이름 === cur.name);
|
||||
if (
|
||||
same &&
|
||||
!(await showConfirmDialog(L("M02_OverwriteConfirm").replace("{value}", cur.name)))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
return failed(error);
|
||||
}
|
||||
void act(() => saveProjectAs(projectId, to, cur.kind, cur.name));
|
||||
};
|
||||
projectButton(L("M02_ProjectReset"), async () => {
|
||||
@@ -407,8 +446,8 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
},
|
||||
});
|
||||
});
|
||||
projectButton(L("M02_ProjectSavePersonal"), () => saveOpenAs("personal"));
|
||||
projectButton(L("M02_ProjectSaveCompany"), () => saveOpenAs("company"));
|
||||
projectButton(L("M02_ProjectSavePersonal"), () => void saveOpenAs("personal"));
|
||||
projectButton(L("M02_ProjectSaveCompany"), () => void saveOpenAs("company"));
|
||||
|
||||
void refresh();
|
||||
return { root, refresh };
|
||||
|
||||
@@ -100,7 +100,10 @@ def check_name(name: str) -> str:
|
||||
text = str(name or "").strip()
|
||||
bad = not text or text != name or text.startswith((".", "_")) or len(text) > 80
|
||||
if bad or any(ch in text for ch in '/\\:*?"<>|') or ".." in text:
|
||||
raise ValueError("양식 이름이 올바르지 않습니다.")
|
||||
raise ValueError(
|
||||
"양식 이름이 올바르지 않습니다 — 빈 이름 · 앞뒤 빈칸 · 80자 초과 · 앞머리 . _ · "
|
||||
'`/ \ .. : * ? " < > |` 는 못 씁니다.'
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
@@ -189,10 +192,20 @@ def write_template(
|
||||
return version_of(path) or ""
|
||||
|
||||
|
||||
def delete_template(layer_dir: str | Path, kind: str, name: str) -> bool:
|
||||
def delete_template(
|
||||
layer_dir: str | Path,
|
||||
kind: str,
|
||||
name: str,
|
||||
*,
|
||||
version: str | None = None,
|
||||
check_version: bool = False,
|
||||
) -> bool:
|
||||
"""양식 지움 · manifest 줄도 뺌. `check_version` 이면 지금 판과 달라 `StaleTemplate`."""
|
||||
path = template_path(layer_dir, kind, name)
|
||||
if not path.is_file():
|
||||
return False
|
||||
if check_version and version_of(path) != version:
|
||||
raise StaleTemplate(version_of(path))
|
||||
path.unlink()
|
||||
manifest = read_manifest(layer_dir)
|
||||
if manifest.pop(f"{kind}/{name}", None) is not None:
|
||||
|
||||
@@ -47,13 +47,9 @@ def test_new_save_reopen_delete_and_stale(client):
|
||||
|
||||
|
||||
def test_bad_names_and_kinds(client):
|
||||
assert (
|
||||
client.put("/api/m02/templates/table/a..b", json={"판": "", "문서": {"열": []}}).status_code
|
||||
== 200
|
||||
)
|
||||
assert (
|
||||
client.put("/api/m02/templates/table/.숨김", json={"판": "", "문서": {}}).status_code == 422
|
||||
)
|
||||
for bad in ("a..b", ".숨김", "a%5Cb", "a%3Ab"):
|
||||
got = client.put(f"/api/m02/templates/table/{bad}", json={"판": "", "문서": {"열": []}})
|
||||
assert got.status_code == 400 and "쓸 수 없는" in got.json()["detail"]
|
||||
assert client.put("/api/m02/templates/other/x", json={"판": "", "문서": {}}).status_code == 404
|
||||
|
||||
|
||||
|
||||
@@ -354,3 +354,51 @@ def test_뼈대_없는_문서는_400으로_거절하고_파일을_안_씀(world:
|
||||
table = _url()
|
||||
assert client.put(table, json={"판": None, "문서": {}}).status_code == 400
|
||||
assert client.put(url, json={"판": version, "문서": {"entities": []}}).status_code == 200
|
||||
|
||||
|
||||
def _mine(name: str = "내양식", layer: str = "personal") -> str:
|
||||
return f"/api/m02/layers/{layer}/templates/table/{name}?project_id={P1}"
|
||||
|
||||
|
||||
def test_개인_회사_양식_지우기_권한_판_이름_예외(world: dict[str, Any]) -> None:
|
||||
client, session = world["client"], world["session"]
|
||||
doc = {"열": []}
|
||||
for layer in ("personal", "company"):
|
||||
client.put(_mine(layer=layer), json={"판": None, "문서": doc})
|
||||
personal = layers.personal_dir(7, 42) / "table/내양식.json"
|
||||
company = layers.company_dir(7) / "table/내양식.json"
|
||||
assert personal.is_file() and not company.is_file() # 일반 사용자 회사 저장은 403
|
||||
version = layers.version_of(personal)
|
||||
# 시스템 · 프로젝트 층은 거절
|
||||
for layer in ("system", "project"):
|
||||
assert client.delete(_mine("구조물집계표", layer) + f"&판={version}").status_code == 403
|
||||
assert (_root(world, P1) / "templates/table/구조물집계표.json").is_file()
|
||||
# 이름 예외 · 판 없음 · 판 다름 · 없는 양식
|
||||
for bad in ("%2E%2E", "a%2E%2Eb", "a%2Fb", "a%5Cb", "_initial", ".x", "a*b"):
|
||||
got = client.delete(_mine(bad) + f"&판={version}")
|
||||
assert got.status_code in (400, 404) and personal.is_file(), bad
|
||||
assert client.delete(_mine("a%2Fb") + f"&판={version}").status_code in (400, 404)
|
||||
assert client.delete(_mine()).status_code == 400
|
||||
stale = client.delete(_mine() + "&판=0000000000000000")
|
||||
assert stale.status_code == 409 and stale.json()["detail"]["판"] == version
|
||||
assert client.delete(_mine("없는것") + f"&판={version}").status_code == 404
|
||||
# 다른 회사 사람은 못 지움 · 회사 층은 관리자만
|
||||
assert client.delete(_mine("내양식", "company") + f"&판={version}").status_code == 403
|
||||
# 지움 · 연달아 누르면 둘째는 404 · manifest 줄도 없어짐
|
||||
assert client.delete(_mine() + f"&판={version}").status_code == 200
|
||||
assert not personal.exists()
|
||||
assert client.delete(_mine() + f"&판={version}").status_code == 404
|
||||
session["role"] = "ADMIN"
|
||||
client.put(_mine(layer="company"), json={"판": None, "문서": doc})
|
||||
assert company.is_file()
|
||||
session.update(role="USER")
|
||||
assert (
|
||||
client.delete(_mine(layer="company") + f"&판={layers.version_of(company)}").status_code
|
||||
== 403
|
||||
)
|
||||
session.update(role="ADMIN")
|
||||
assert (
|
||||
client.delete(_mine(layer="company") + f"&판={layers.version_of(company)}").status_code
|
||||
== 200
|
||||
)
|
||||
assert "table/내양식" not in layers.read_manifest(layers.company_dir(7))
|
||||
|
||||
@@ -193,3 +193,23 @@ def test_mirror_random_docs_match_decimal():
|
||||
assert out["계산"] == {r: {n: _plain(v) for n, v in w.items()} for r, w in want.items()}
|
||||
for name, total in sums.items():
|
||||
assert out["합계"]["s"][name] == _plain(total)
|
||||
|
||||
|
||||
def test_text_in_number_column_is_flagged_and_skipped():
|
||||
doc = _doc(
|
||||
[{"id": "a"}, {"id": "n", "꼴": "글"}, {"id": "f", "식": "[a]*2"}],
|
||||
[
|
||||
{"id": "r1", "값": {"a": 5, "n": "abc"}},
|
||||
{"id": "r2", "값": {"a": "abc", "n": "x"}},
|
||||
{"id": "r3", "값": {"a": "1,000"}},
|
||||
{"id": "r4", "값": {"a": ""}},
|
||||
],
|
||||
합계줄=[{"id": "t", "이름": "합계", "식": "SUM"}],
|
||||
)
|
||||
out = _run(doc)
|
||||
assert out["합계"]["t"]["a"] == "1005" # abc 는 빼고 · 1,000 은 수 · 빈 칸은 0
|
||||
errors = {(e["줄"], e["열"]): e["까닭"] for e in out["오류"]}
|
||||
assert errors[("r2", "a")] == "수가 아님"
|
||||
assert errors[("t", "a")].startswith("수가 아닌 칸 1 개")
|
||||
# 글 칸 · 정상 수 칸은 표시 없음(r2 의 식 칸은 글을 곱해 식 오류 — 따로)
|
||||
assert not any(k[1] == "n" or k[0] in ("r1", "r3", "r4") for k in errors)
|
||||
|
||||
@@ -34,6 +34,13 @@ import type {
|
||||
|
||||
const ROUND_MODE = { 반올림: "round", 올림: "away", 버림: "trunc" } as const;
|
||||
|
||||
/** 수 칸에 수가 아닌 글 — 계산은 그 칸을 빼고 하되 알림(엑셀처럼 무시 · 오류 목록에는 이 까닭으로). */
|
||||
export const NOT_A_NUMBER = "수가 아님";
|
||||
export const SKIPPED_PREFIX = "수가 아닌 칸";
|
||||
/** 값은 그대로 두고 알리기만 하는 까닭인가 — 화면이 글을 「#오류」 로 바꾸지 않게. */
|
||||
export const isNotice = (why: string): boolean =>
|
||||
why === NOT_A_NUMBER || why.startsWith(SKIPPED_PREFIX);
|
||||
|
||||
export const isNumberColumn = (col: SheetColumn): boolean => col.꼴 !== "글";
|
||||
|
||||
/** 합계 줄 한 칸의 식 — 없으면 null(빈 칸). `"SUM"` 은 그 열의 열 합. */
|
||||
@@ -167,6 +174,26 @@ export function recalcSheet(doc: SheetDoc): SheetResult {
|
||||
};
|
||||
for (const id of rows.keys()) solve(id, result.계산);
|
||||
for (const id of totals.keys()) solve(id, result.합계);
|
||||
|
||||
// 수 칸에 든 수 아닌 글 — 그 칸 + 그 열을 더하는 합계 칸에 알림
|
||||
const badCount = new Map<string, number>();
|
||||
for (const row of doc.줄) {
|
||||
for (const col of doc.열) {
|
||||
if (!isNumberColumn(col) || formulaOf(row.id, col)) continue;
|
||||
if (typeof inputValue(row.값[col.id]) !== "string") continue;
|
||||
result.오류.push({ 줄: row.id, 열: col.id, 까닭: NOT_A_NUMBER });
|
||||
badCount.set(col.id, (badCount.get(col.id) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
for (const total of totals.values()) {
|
||||
for (const col of doc.열) {
|
||||
const n = badCount.get(col.id);
|
||||
const formula = n && totalFormula(total, col);
|
||||
if (!formula || !formula.includes(`[${col.id}`)) continue;
|
||||
const 까닭 = `${SKIPPED_PREFIX} ${n} 개는 빼고 더함`;
|
||||
result.오류.push({ 줄: total.id, 열: col.id, 까닭 });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
pagePlan,
|
||||
type SheetMode,
|
||||
} from "./ui_template_sheet_ops";
|
||||
import { groupDigits, totalFormula } from "./ui_template_sheet_recalc";
|
||||
import { groupDigits, isNotice, totalFormula } from "./ui_template_sheet_recalc";
|
||||
import { st } from "./ui_template_sheet_text";
|
||||
import type { SheetColumn, SheetDoc, SheetResult, SheetRow } from "./ui_template_sheet_types";
|
||||
|
||||
@@ -130,7 +130,7 @@ export function renderSheet(state: RenderState): HTMLElement {
|
||||
const why = errors.get(`${key}|${col.id}`);
|
||||
if (why) {
|
||||
td.classList.add("is-error");
|
||||
td.textContent = st("Error");
|
||||
if (!isNotice(why)) td.textContent = st("Error");
|
||||
td.title = why;
|
||||
} else if (text) td.title = text;
|
||||
if (state.sel?.r === key && state.sel.c === col.id) td.classList.add("is-selected");
|
||||
@@ -240,7 +240,11 @@ export function renderSheet(state: RenderState): HTMLElement {
|
||||
cols.forEach((col, j) => {
|
||||
const td = bodyCell(`t:${total.id}`, col, j, shown(state.result.합계[total.id]?.[col.id]));
|
||||
const formula = totalFormula(total, col);
|
||||
if (formula) td.title = formulaLabel(formula, names);
|
||||
if (formula)
|
||||
td.title = td.classList.contains("is-error")
|
||||
? `${td.title}
|
||||
${formulaLabel(formula, names)}`
|
||||
: formulaLabel(formula, names);
|
||||
tr.append(td);
|
||||
});
|
||||
return tr;
|
||||
|
||||
@@ -59,6 +59,16 @@ export const ui_locales_m2 = {
|
||||
],
|
||||
M02_ImportTitle: ["양식 가져오기", "Import template"],
|
||||
M02_ImportNone: ["가져올 양식 없음", "Nothing to import"],
|
||||
M02_NameExists: ["이미 있는 이름", "That name already exists"],
|
||||
M02_BadName: [
|
||||
'쓸 수 없는 글자 — / \\ : * ? " < > | 와 .. · 앞의 점은 안 됨',
|
||||
'Invalid name — no / \\ : * ? " < > | or .. or leading dot',
|
||||
],
|
||||
M02_DiscardConfirm: [
|
||||
"저장 안 한 고침이 있음 — 버리고 넘어갈까?",
|
||||
"Unsaved changes — discard and continue?",
|
||||
],
|
||||
M02_OverwriteConfirm: ["「{value}」 이(가) 이미 있음 — 덮을까?", "“{value}” exists — overwrite?"],
|
||||
M02_Done: ["했음", "Done"],
|
||||
M02_ActionFailed: ["안 됨 — {value}", "Failed — {value}"],
|
||||
} as const;
|
||||
|
||||
Reference in New Issue
Block a user